"""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 = ('' '' + escape(username) + '' '' + digest + '' '' + base64.b64encode(nonce).decode() + '' '' + created + '') envelope = ('' '' + header + '' + content + '') 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', 'Media', 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 = 'RTP-UnicastRTSP' + escape(token) + '' 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