feat: discover independent ONVIF lens sources and separate recorder archive links
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user