Add verified camera settings, family version recovery and account self service
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""Capability-driven, per-lens ONVIF settings. Never accepts an endpoint from a client."""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
import urllib.parse as url
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
import onvif
|
||||
|
||||
IMAGING = 'http://www.onvif.org/ver20/imaging/wsdl'
|
||||
T = '{' + onvif.SCHEMA + '}'
|
||||
M = '{' + onvif.MEDIA + '}'
|
||||
I = '{' + IMAGING + '}'
|
||||
IMAGE_FIELDS = {
|
||||
'brightness': ('Brightness', 'Brightness', '亮度'),
|
||||
'contrast': ('Contrast', 'Contrast', '对比度'),
|
||||
'saturation': ('ColorSaturation', 'ColorSaturation', '饱和度'),
|
||||
'sharpness': ('Sharpness', 'Sharpness', '锐度'),
|
||||
'dayNight': ('IrCutFilter', 'IrCutFilterModes', '日夜模式'),
|
||||
'backlight': ('BacklightCompensation/Mode', 'BacklightCompensation/Mode', '背光补偿'),
|
||||
'wdr': ('WideDynamicRange/Mode', 'WideDynamicRange/Mode', '宽动态'),
|
||||
'wdrLevel': ('WideDynamicRange/Level', 'WideDynamicRange/Level', '宽动态强度'),
|
||||
'whiteBalance': ('WhiteBalance/Mode', 'WhiteBalance/Mode', '白平衡'),
|
||||
}
|
||||
ENUMS = {'dayNight': ('ON', 'OFF', 'AUTO'), 'backlight': ('ON', 'OFF'),
|
||||
'wdr': ('ON', 'OFF'), 'whiteBalance': ('AUTO', 'MANUAL')}
|
||||
VIDEO_FIELDS = {'fps': 'RateControl/FrameRateLimit', 'bitrate': 'RateControl/BitrateLimit',
|
||||
'quality': 'Quality', 'gop': 'H264/GovLength'}
|
||||
|
||||
|
||||
def path(value):
|
||||
return '/'.join(T + part for part in value.split('/'))
|
||||
|
||||
|
||||
def number(value):
|
||||
try:
|
||||
result = float(value)
|
||||
return result if math.isfinite(result) else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def limits(root, name):
|
||||
if root is None:
|
||||
return None
|
||||
minimum, maximum = number(root.findtext(path(name + '/Min'))), number(root.findtext(path(name + '/Max')))
|
||||
return dict(min=minimum, max=maximum) if minimum is not None and maximum is not None and minimum <= maximum else None
|
||||
|
||||
|
||||
def uri_path(value):
|
||||
parsed = url.urlsplit(value)
|
||||
return parsed.path, sorted(url.parse_qsl(parsed.query, keep_blank_values=True))
|
||||
|
||||
|
||||
class Settings:
|
||||
def __init__(self):
|
||||
self.guard = threading.Lock()
|
||||
self.locks = {}
|
||||
|
||||
def call(self, camera, endpoint, namespace, operation, body=''):
|
||||
return onvif.request(camera['host'], endpoint[0], endpoint[1], namespace, operation,
|
||||
body, camera['username'], camera['password'])
|
||||
|
||||
@staticmethod
|
||||
def endpoint(camera, address):
|
||||
parsed = url.urlsplit(address or '')
|
||||
if parsed.scheme != 'http' or parsed.hostname != camera['host'] or parsed.username or parsed.password:
|
||||
raise ValueError('设备返回了不匹配的服务地址')
|
||||
return parsed.port or 80, parsed.path + ('?' + parsed.query if parsed.query else '')
|
||||
|
||||
def discover(self, camera, quality):
|
||||
if camera.get('sourceKind', 'direct') != 'direct':
|
||||
raise ValueError('摄像机参数需要直连摄像头;录像机通道请在录像机中配置')
|
||||
if not camera.get('enabled'):
|
||||
raise ValueError('请先启用这个镜头通道')
|
||||
if quality not in ('main', 'sub'):
|
||||
raise ValueError('请选择主码流或子码流')
|
||||
device = (camera.get('onvifPort', 80), '/onvif/device_service')
|
||||
caps = self.call(camera, device, onvif.DEVICE, 'GetCapabilities', '<m:Category>All</m:Category>')
|
||||
services = {}
|
||||
for name in ('Media', 'Imaging', 'PTZ', 'Events', 'Recording', 'Replay'):
|
||||
address = caps.findtext('.//' + T + name + '/' + T + 'XAddr')
|
||||
if address:
|
||||
services[name] = self.endpoint(camera, address)
|
||||
if 'Media' not in services:
|
||||
raise ValueError('设备没有提供媒体配置服务')
|
||||
profiles = self.call(camera, services['Media'], onvif.MEDIA, 'GetProfiles')
|
||||
wanted = camera['mainPath' if quality == 'main' else 'subPath']
|
||||
selected = None
|
||||
# Exact path AND query matching prevents selecting the other lens on shared paths.
|
||||
for profile in profiles.findall('.//' + M + 'Profiles')[:16]:
|
||||
token = profile.get('token', '')
|
||||
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 = self.call(camera, services['Media'], onvif.MEDIA, 'GetStreamUri', content)
|
||||
uri = response.findtext('.//' + T + 'Uri')
|
||||
if uri and url.urlsplit(uri).hostname == camera['host'] and uri_path(uri) == uri_path(wanted):
|
||||
selected = profile
|
||||
break
|
||||
if selected is None:
|
||||
raise ValueError('未找到与当前镜头及码流路径精确匹配的配置,请先核对通道')
|
||||
source = selected.findtext(path('VideoSourceConfiguration/SourceToken'))
|
||||
encoder = selected.find(T + 'VideoEncoderConfiguration')
|
||||
if not source or encoder is None:
|
||||
raise ValueError('设备没有返回镜头或编码器配置')
|
||||
return dict(device=device, services=services, source=source, profile=selected.get('token'), encoder=encoder)
|
||||
|
||||
def read(self, camera, quality='main'):
|
||||
info = self.discover(camera, quality)
|
||||
return self.details(camera, info), info
|
||||
|
||||
def details(self, camera, info):
|
||||
result = dict(profile=info['profile'], source=info['source'], device={}, imaging={}, video={}, notes=[],
|
||||
advertisedServices=list(info['services']))
|
||||
try:
|
||||
device = self.call(camera, info['device'], onvif.DEVICE, 'GetDeviceInformation')
|
||||
result['device'] = {key: device.findtext('.//{' + onvif.DEVICE + '}' + key, '')
|
||||
for key in ('Manufacturer', 'Model', 'FirmwareVersion', 'HardwareId')}
|
||||
except Exception:
|
||||
result['notes'].append('设备型号读取失败,可重试')
|
||||
encoder = info['encoder']
|
||||
values = {key: number(encoder.findtext(path(field))) for key, field in VIDEO_FIELDS.items()}
|
||||
values['resolution'] = encoder.findtext(path('Resolution/Width'), '') + 'x' + encoder.findtext(path('Resolution/Height'), '')
|
||||
codec = encoder.get('encoding') or encoder.findtext(T + 'Encoding', '')
|
||||
result['video'] = dict(values=values, options={}, codec=codec)
|
||||
try:
|
||||
body = '<m:ConfigurationToken>' + escape(encoder.get('token', '')) + '</m:ConfigurationToken>'
|
||||
opts = self.call(camera, info['services']['Media'], onvif.MEDIA, 'GetVideoEncoderConfigurationOptions', body).find('.//' + M + 'Options')
|
||||
# Some EZVIZ devices expose H265 through the H264 compatibility schema.
|
||||
# Keep the entire original XML (including vendor encoding attribute) when writing.
|
||||
schema_codec = encoder.findtext(T + 'Encoding', '')
|
||||
if schema_codec not in ('H264', 'MPEG4', 'JPEG'):
|
||||
raise ValueError('unsupported encoding schema')
|
||||
codec_opts = opts.find(T + schema_codec)
|
||||
for key, field in [('fps', 'FrameRateRange'), ('gop', 'GovLengthRange')]:
|
||||
limit = limits(codec_opts, field)
|
||||
if limit and values[key] is not None:result['video']['options'][key] = limit
|
||||
bitrate = limits(opts, 'Extension/' + schema_codec + '/BitrateRange')
|
||||
quality = limits(opts, 'QualityRange')
|
||||
if bitrate and values['bitrate'] is not None:result['video']['options']['bitrate'] = bitrate
|
||||
if quality and values['quality'] is not None:result['video']['options']['quality'] = quality
|
||||
resolutions = [r.findtext(T + 'Width', '') + 'x' + r.findtext(T + 'Height', '')
|
||||
for r in codec_opts.findall(T + 'ResolutionsAvailable')]
|
||||
if resolutions:result['video']['options']['resolution'] = {'choices': list(dict.fromkeys(resolutions))}
|
||||
except Exception:
|
||||
result['notes'].append('码流可调范围读取失败,暂不可修改码流参数')
|
||||
result['video']['options'] = {}
|
||||
if 'Imaging' in info['services']:
|
||||
try:
|
||||
body = '<m:VideoSourceToken>' + escape(info['source']) + '</m:VideoSourceToken>'
|
||||
settings = self.call(camera, info['services']['Imaging'], IMAGING, 'GetImagingSettings', body).find('.//' + I + 'ImagingSettings')
|
||||
options = self.call(camera, info['services']['Imaging'], IMAGING, 'GetOptions', body).find('.//' + I + 'ImagingOptions')
|
||||
if settings is None or options is None:raise ValueError('missing imaging response')
|
||||
info['imagingXML'] = settings
|
||||
for key, (field, option, label) in IMAGE_FIELDS.items():
|
||||
value = settings.findtext(path(field))
|
||||
if value is None:continue
|
||||
if key in ENUMS:
|
||||
choices = [v.text for v in options.findall(path(option)) if v.text in ENUMS[key]]
|
||||
rule = {'choices': list(dict.fromkeys(choices))} if choices else None
|
||||
else:
|
||||
value, rule = number(value), limits(options, option)
|
||||
if rule and value is not None:result['imaging'][key] = dict(value=value, label=label, **rule)
|
||||
except Exception:
|
||||
result['notes'].append('图像参数读取失败或设备未开放,暂不可修改图像参数')
|
||||
else:
|
||||
result['notes'].append('设备未开放图像调节服务')
|
||||
fingerprint = dict(source=info['source'], profile=info['profile'], encoder=encoder.get('token'),
|
||||
imaging=result['imaging'], video=result['video'])
|
||||
result['revision'] = hashlib.sha256(json.dumps(fingerprint, sort_keys=True).encode()).hexdigest()
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def validate(values, rules, integer_fields=()):
|
||||
if not isinstance(values, dict) or not values or any(k not in rules for k in values):
|
||||
raise ValueError('提交的参数未由设备开放,或没有需要保存的参数')
|
||||
for key, value in values.items():
|
||||
rule = rules[key]
|
||||
if 'choices' in rule:
|
||||
if not isinstance(value, str) or value not in rule['choices']:raise ValueError('参数选项不受设备支持')
|
||||
elif (type(value) not in (int, float) or not math.isfinite(value) or not rule['min'] <= value <= rule['max']
|
||||
or (key in integer_fields and int(value) != value)):
|
||||
raise ValueError('参数超出设备允许范围')
|
||||
|
||||
def save(self, camera, data):
|
||||
key = (camera['host'], camera.get('onvifPort', 80))
|
||||
with self.guard:lock = self.locks.setdefault(key, threading.Lock())
|
||||
if not lock.acquire(blocking=False):raise ValueError('这台摄像头正在保存参数,请稍后重试')
|
||||
try:
|
||||
current, info = self.read(camera, data.get('quality', 'main'))
|
||||
if data.get('revision') != current['revision']:
|
||||
raise ValueError('设备参数已变化,请重新读取后再修改')
|
||||
section, values = data.get('section'), data.get('values')
|
||||
if section == 'imaging':
|
||||
self.validate(values, current['imaging'])
|
||||
settings = copy.deepcopy(info['imagingXML'])
|
||||
for key, value in values.items():settings.find(path(IMAGE_FIELDS[key][0])).text = str(value)
|
||||
body = '<m:VideoSourceToken>' + escape(info['source']) + '</m:VideoSourceToken>' + ET.tostring(settings, encoding='unicode') + '<m:ForcePersistence>true</m:ForcePersistence>'
|
||||
endpoint, namespace, operation = info['services']['Imaging'], IMAGING, 'SetImagingSettings'
|
||||
elif section == 'video':
|
||||
self.validate(values, current['video']['options'], ('fps', 'bitrate', 'gop'))
|
||||
settings = copy.deepcopy(info['encoder']);settings.tag = M + 'Configuration'
|
||||
for key, value in values.items():
|
||||
if key == 'resolution':
|
||||
for field, size in zip(('Width', 'Height'), value.split('x')):settings.find(path('Resolution/' + field)).text = size
|
||||
else:settings.find(path(VIDEO_FIELDS[key])).text = str(int(value) if key in ('fps', 'bitrate', 'gop') else value)
|
||||
body = ET.tostring(settings, encoding='unicode') + '<m:ForcePersistence>true</m:ForcePersistence>'
|
||||
endpoint, namespace, operation = info['services']['Media'], onvif.MEDIA, 'SetVideoEncoderConfiguration'
|
||||
else:raise ValueError('请选择图像或码流设置')
|
||||
self.call(camera, endpoint, namespace, operation, body)
|
||||
try:
|
||||
fresh, _ = self.read(camera, data.get('quality', 'main'))
|
||||
actual = {k: v['value'] for k, v in fresh['imaging'].items()} if section == 'imaging' else fresh['video']['values']
|
||||
confirmed = all(actual.get(k) == v for k, v in values.items())
|
||||
return dict(ok=True, verified=confirmed, settings=fresh,
|
||||
message='设备已保存并回读确认' if confirmed else '设备已响应,但回读值与提交值不同,请核对设备实际参数')
|
||||
except Exception:
|
||||
return dict(ok=True, verified=False, settings=None, message='设备已响应,暂未读回参数;请重新读取确认,不要反复提交')
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
|
||||
CONTROLLER = Settings()
|
||||
Reference in New Issue
Block a user