"""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'
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
# Camera services must stay on the validated device endpoint.
return None
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({}), NoRedirect(), 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