Files
ucvl-home-vision/onvif.py
T

84 lines
5.1 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.error
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 = ('<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({}), 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 + '"'})
try:
with opener.open(req, timeout=5) as response:
root = ET.fromstring(response.read(1024 * 1024))
except urllib.error.HTTPError as exc:
exc.close()
raise
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