Files
ucvl-home-vision/onvif.py
T

73 lines
4.7 KiB
Python

"""Read-only ONVIF Device/Media discovery. Never changes camera configuration."""
import base64
import datetime as dt
import hashlib
import secrets
import urllib.request
import urllib.parse
import xml.etree.ElementTree as ET
from xml.sax.saxutils import escape
DEVICE = 'http://www.onvif.org/ver10/device/wsdl'
MEDIA = 'http://www.onvif.org/ver10/media/wsdl'
SCHEMA = 'http://www.onvif.org/ver10/schema'
def request(host, port, endpoint, namespace, operation, content, username, password):
created = dt.datetime.now(dt.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
nonce = secrets.token_bytes(16)
digest = base64.b64encode(hashlib.sha1(nonce + created.encode() + password.encode()).digest()).decode()
header = ('<wsse:Security s:mustUnderstand="1"><wsse:UsernameToken>'
'<wsse:Username>' + escape(username) + '</wsse:Username>'
'<wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">' + digest + '</wsse:Password>'
'<wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">' + base64.b64encode(nonce).decode() + '</wsse:Nonce>'
'<wsu:Created>' + created + '</wsu:Created></wsse:UsernameToken></wsse:Security>')
envelope = ('<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" '
'xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" '
'xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">'
'<s:Header>' + header + '</s:Header><s:Body><m:' + operation + ' xmlns:m="' + namespace + '" xmlns:tt="' + SCHEMA + '">'
+ content + '</m:' + operation + '></s:Body></s:Envelope>')
target = 'http://' + host + ':' + str(port) + endpoint
passwords = urllib.request.HTTPPasswordMgrWithDefaultRealm()
passwords.add_password(None, target, username, password)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPDigestAuthHandler(passwords))
req = urllib.request.Request(target, data=envelope.encode(), headers={
'Content-Type': 'application/soap+xml; charset=utf-8; action="' + namespace + '/' + operation + '"'})
with opener.open(req, timeout=5) as response:
root = ET.fromstring(response.read(1024 * 1024))
if root.find('.//{http://www.w3.org/2003/05/soap-envelope}Fault') is not None:
raise ValueError('ONVIF returned a fault; check device credentials and permissions')
return root
def inspect(host, port, username, password):
result = {'device': {}, 'profiles': []}
info = request(host, port, '/onvif/device_service', DEVICE, 'GetDeviceInformation', '', username, password)
for k in ('Manufacturer', 'Model', 'FirmwareVersion', 'SerialNumber', 'HardwareId'):
value = info.find('.//{' + DEVICE + '}' + k)
result['device'][k] = value.text if value is not None else ''
caps = request(host, port, '/onvif/device_service', DEVICE, 'GetCapabilities', '<m:Category>Media</m:Category>', username, password)
addr = caps.find('.//{' + SCHEMA + '}Media/{' + SCHEMA + '}XAddr')
media_endpoint = '/onvif/Media'
media_port = port
if addr is not None and addr.text:
parsed = urllib.parse.urlsplit(addr.text)
if parsed.hostname == host and parsed.scheme == 'http':
media_endpoint, media_port = parsed.path, parsed.port or 80
profiles = request(host, media_port, media_endpoint, MEDIA, 'GetProfiles', '', username, password)
for profile in profiles.findall('.//{' + MEDIA + '}Profiles')[:32]:
token = profile.attrib['token']
def val(name):
el = profile.find('.//{' + SCHEMA + '}' + name)
return el.text if el is not None else ''
content = '<m:StreamSetup><tt:Stream>RTP-Unicast</tt:Stream><tt:Transport><tt:Protocol>RTSP</tt:Protocol></tt:Transport></m:StreamSetup><m:ProfileToken>' + escape(token) + '</m:ProfileToken>'
response = request(host, media_port, media_endpoint, MEDIA, 'GetStreamUri', content, username, password)
uri = response.find('.//{' + SCHEMA + '}Uri')
parsed = urllib.parse.urlsplit(uri.text if uri is not None else '')
# Only return connection metadata. Never return embedded URL credentials.
result['profiles'].append({'token': token, 'name': val('Name'), 'sourceToken': val('SourceToken'),
'codec': val('Encoding'), 'width': val('Width'), 'height': val('Height'),
'host': parsed.hostname or host, 'port': parsed.port or 554,
'path': parsed.path + ('?' + parsed.query if parsed.query else '')})
return result