From 528007ea43353f0cd7ff8974e952658d2eaf62e5 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 3 Oct 2026 16:16:23 +0800 Subject: [PATCH] feat: discover independent ONVIF lens sources and separate recorder archive links --- README.md | 2 +- app.py | 20 ++++++++++++-- onvif.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++ web/app.js | 4 ++- web/index.html | 2 +- 5 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 onvif.py diff --git a/README.md b/README.md index 90f28ee..0203380 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ ## v0.1.0 功能与边界 -源码包括空间 / 摄像头 / 录像机 / 镜头档案,局域网 RTSP 探测、同源 HLS 播放、本机可选持续录像、日期查询、录像时间轴、定位回放和片段下载、存储保护、管理员初始化与登录。 +源码包括空间 / 摄像头 / 录像机 / 镜头档案,局域网 RTSP 探测、ONVIF 设备信息与独立镜头媒体配置读取、同源 HLS 播放、本机可选持续录像、日期查询、录像时间轴、定位回放和片段下载、存储保护、管理员初始化与登录。 **录像机原有历史录像回放尚未实现。** 需要取得具体型号及合法设备账号,接入其录像检索和回放接口;实时 RTSP 地址不能代替录像检索接口。驱动选项目前仅表示路径模板与接入类型,不能视为某个品牌全系列已经兼容。实际通道号需要由设备确认,不能根据三台双镜头直接猜成通道 1–6。 diff --git a/app.py b/app.py index 6cd5912..bac57f6 100644 --- a/app.py +++ b/app.py @@ -23,6 +23,7 @@ import urllib.error import urllib.parse as url import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import onvif ROOT = Path(__file__).resolve().parent os.umask(0o077) @@ -163,8 +164,10 @@ def save_camera(body): archiveSource=body.get('archiveSource', 'recorder' if recorder else 'local')) if item['archiveSource'] not in ('local', 'recorder'): raise Problem('录像存储来源不正确') - if item['archiveSource'] == 'recorder' and not recorder: - raise Problem('录像保存在录像机时,请先选择对应录像机') + archive_recorder = body.get('archiveRecorderId') or (recorder['id'] if recorder else '') + if archive_recorder and not get_object('recorders', archive_recorder): + raise Problem('关联的录像存储设备不存在') + item['archiveRecorderId'] = archive_recorder item.update(id=key, siteId=site, host=host_address(recorder['host'] if recorder else body.get('host', '')), port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535)) for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]: @@ -513,6 +516,19 @@ class Handler(BaseHTTPRequestHandler): self.answer(save_asset(data)) elif path == '/api/recorders': self.answer(save_recorder(data)) + elif path == '/api/onvif': + old = get_object('cameras', data.get('id')) or {} + host = host_address(data.get('host', '')) + port = integer(data.get('port', 80), 1, 65535) + username = clean_text(data.get('username', ''), 120, required=True) + password = data.get('password') or old.get('password', '') + if not isinstance(password, str) or len(password) > 256: + raise Problem('设备密码格式不正确') + try: + result = onvif.inspect(host, port, username, password) + except Exception: + raise Problem('ONVIF 读取未成功,请检查账号、设备时间、服务端口及 ONVIF 是否启用', 502) + self.answer(result) elif path == '/api/storage': policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365), 'maxGB': integer(data.get('maxGB'), 5, 100000), diff --git a/onvif.py b/onvif.py new file mode 100644 index 0000000..4a61a68 --- /dev/null +++ b/onvif.py @@ -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 = ('' + '' + 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 diff --git a/web/app.js b/web/app.js index f21cc1e..81f10e9 100644 --- a/web/app.js +++ b/web/app.js @@ -90,7 +90,7 @@ async function loadAudit() { try {const rows=await api('/api/audit');$('#audit-l async function refresh() {if(!loggedIn)return;try{state=await api('/api/state');render();}catch(e){$('#banner').textContent=e.message;$('#banner').hidden=false;}} async function auth() {try{const s=await api('/api/session');loggedIn=s.authenticated;setup=s.setupRequired;$('#setup-code-label').hidden=!setup;$('#auth-title').textContent=setup?'建立你的视觉空间':'欢迎回来';$('#auth-note').textContent=setup?'使用部署时生成的初始化码,设置你自己的管理密码。':'输入管理密码,进入视觉工作空间。';if(!loggedIn&&!$('#auth-dialog').open)$('#auth-dialog').showModal();if(loggedIn){$('#auth-dialog').close();await refresh();}}catch(e){toast('无法连接管理服务:'+e.message);}} function openSite(id) {const f=$('#site-form');f.reset();f.querySelector('.form-error').textContent='';const item=state.sites.find(s=>s.id===id);if(item)for(const [k,v]of Object.entries(item))if(f.elements[k])f.elements[k].value=v;$('#site-dialog').showModal();} -function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>``).join('');f.elements.recorderId.innerHTML=''+state.recorders.map(r=>``).join('');f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();} +function openCamera(id, host='') {if(!state.assets.length){toast('请先建立摄像头对象,再配置镜头通道');openAsset();return;}if(!state.sites.length){toast('请先建立空间档案,再添加摄像头');openSite();return;}const f=$('#camera-form');f.reset();f.querySelector('.form-error').textContent='';f.elements.assetId.innerHTML=state.assets.map(a=>``).join('');f.elements.recorderId.innerHTML=''+state.recorders.map(r=>``).join('');f.elements.archiveRecorderId.innerHTML=''+state.recorders.map(r=>``).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>``).join('');const c=state.cameras.find(c=>c.id===id);if(c){for(const [k,v]of Object.entries(c)){const el=f.elements[k];if(!el)continue;if(el.type==='checkbox')el.checked=v;else el.value=v;}}else{f.elements.host.value=host;if($('#site-filter').value)f.elements.siteId.value=$('#site-filter').value;}$('#path-preset').value='custom';if(!c){if(state.recorders.length){f.elements.recorderId.value=state.recorders[0].id;}else{f.elements.sourceKind.value='direct';f.elements.archiveSource.value='local';}}syncEndpoint();$('#camera-dialog').showModal();} function formValues(f) {return Object.fromEntries(new FormData(f).entries());} function handleForm(id, endpoint, transform=x=>x, after=()=>{}) {$(`#${id}`).addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;f.querySelector('.form-error').textContent='';try{await api(endpoint,transform(formValues(f),f));f.closest('dialog')?.close();toast('已保存');await refresh();after();}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}});} handleForm('site-form','/api/sites'); @@ -133,3 +133,5 @@ function openRecorder(id){if(!state.sites.length){toast('请先建立空间档 function syncEndpoint(){const f=$('#camera-form'),isRecorder=f.elements.sourceKind.value==='recorder',r=state.recorders.find(r=>r.id===f.elements.recorderId.value);f.elements.recorderId.disabled=!isRecorder;for(const k of ['host','port','username','password'])f.elements[k].readOnly=isRecorder;if(isRecorder&&r){f.elements.host.value=r.host;f.elements.port.value=r.port;f.elements.username.value=r.username;f.elements.password.value='';}const a=state.assets.find(a=>a.id===f.elements.assetId.value);if(a){f.elements.siteId.value=a.siteId;f.elements.point.value=f.elements.point.value||a.point;}f.elements.siteId.disabled=true;} $('#camera-form').elements.sourceKind.onchange=syncEndpoint;$('#camera-form').elements.recorderId.onchange=syncEndpoint;$('#camera-form').elements.assetId.onchange=syncEndpoint; handleForm('asset-form','/api/assets',d=>({...d,lensCount:Number(d.lensCount)}));handleForm('recorder-form','/api/recorders',d=>({...d,port:Number(d.port)})); + +$('#onvif-read').onclick=async()=>{const f=$('#camera-form'),b=$('#onvif-read');if(f.elements.sourceKind.value!=='direct'){toast('读取镜头配置时请使用直接连接摄像头');return;}b.disabled=true;$('#onvif-result').textContent='正在读取…';try{const d=await api('/api/onvif',{id:f.elements.id.value,host:f.elements.host.value,port:80,username:f.elements.username.value,password:f.elements.password.value});const groups=Map.groupBy?Map.groupBy(d.profiles,p=>p.sourceToken):d.profiles.reduce((m,p)=>{const k=p.sourceToken;m.set(k,[...(m.get(k)||[]),p]);return m;},new Map());$('#onvif-result').innerHTML='';const note=document.createElement('p');note.className='description';note.textContent=[d.device.Manufacturer,d.device.Model].join(' · ')+' / '+groups.size+' 个视频源';$('#onvif-result').append(note);for(const [source,profiles]of groups){profiles.sort((a,b)=>Number(b.width)*Number(b.height)-Number(a.width)*Number(a.height));const btn=document.createElement('button');btn.type='button';btn.className='quiet';btn.textContent=source+' · '+profiles[0].width+' × '+profiles[0].height;btn.onclick=()=>{f.elements.mainPath.value=profiles[0].path;f.elements.subPath.value=(profiles[1]||profiles[0]).path;f.elements.port.value=profiles[0].port;f.elements.model.value=d.device.Model;toast('已填入该视频源的主、子码流路径,请保存');};$('#onvif-result').append(btn);}}catch(e){$('#onvif-result').textContent=e.message;}finally{b.disabled=false;}}; diff --git a/web/index.html b/web/index.html index 6b213a6..1752afa 100644 --- a/web/index.html +++ b/web/index.html @@ -41,7 +41,7 @@
WELCOME HOME

欢迎回来

输入管理密码,进入视觉工作空间。

空间档案

-

镜头通道配置

账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。

+

镜头通道配置

直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。

账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。

摄像头对象

录像机配置

驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。