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 @@
-
+