feat: discover independent ONVIF lens sources and separate recorder archive links
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
## v0.1.0 功能与边界
|
## v0.1.0 功能与边界
|
||||||
|
|
||||||
源码包括空间 / 摄像头 / 录像机 / 镜头档案,局域网 RTSP 探测、同源 HLS 播放、本机可选持续录像、日期查询、录像时间轴、定位回放和片段下载、存储保护、管理员初始化与登录。
|
源码包括空间 / 摄像头 / 录像机 / 镜头档案,局域网 RTSP 探测、ONVIF 设备信息与独立镜头媒体配置读取、同源 HLS 播放、本机可选持续录像、日期查询、录像时间轴、定位回放和片段下载、存储保护、管理员初始化与登录。
|
||||||
|
|
||||||
**录像机原有历史录像回放尚未实现。** 需要取得具体型号及合法设备账号,接入其录像检索和回放接口;实时 RTSP 地址不能代替录像检索接口。驱动选项目前仅表示路径模板与接入类型,不能视为某个品牌全系列已经兼容。实际通道号需要由设备确认,不能根据三台双镜头直接猜成通道 1–6。
|
**录像机原有历史录像回放尚未实现。** 需要取得具体型号及合法设备账号,接入其录像检索和回放接口;实时 RTSP 地址不能代替录像检索接口。驱动选项目前仅表示路径模板与接入类型,不能视为某个品牌全系列已经兼容。实际通道号需要由设备确认,不能根据三台双镜头直接猜成通道 1–6。
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import urllib.error
|
|||||||
import urllib.parse as url
|
import urllib.parse as url
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
import onvif
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent
|
ROOT = Path(__file__).resolve().parent
|
||||||
os.umask(0o077)
|
os.umask(0o077)
|
||||||
@@ -163,8 +164,10 @@ def save_camera(body):
|
|||||||
archiveSource=body.get('archiveSource', 'recorder' if recorder else 'local'))
|
archiveSource=body.get('archiveSource', 'recorder' if recorder else 'local'))
|
||||||
if item['archiveSource'] not in ('local', 'recorder'):
|
if item['archiveSource'] not in ('local', 'recorder'):
|
||||||
raise Problem('录像存储来源不正确')
|
raise Problem('录像存储来源不正确')
|
||||||
if item['archiveSource'] == 'recorder' and not recorder:
|
archive_recorder = body.get('archiveRecorderId') or (recorder['id'] if recorder else '')
|
||||||
raise Problem('录像保存在录像机时,请先选择对应录像机')
|
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', '')),
|
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))
|
port=integer(recorder['port'] if recorder else body.get('port', 554), 1, 65535))
|
||||||
for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]:
|
for k, default in [('mainPath', '/stream1'), ('subPath', '/stream2')]:
|
||||||
@@ -513,6 +516,19 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.answer(save_asset(data))
|
self.answer(save_asset(data))
|
||||||
elif path == '/api/recorders':
|
elif path == '/api/recorders':
|
||||||
self.answer(save_recorder(data))
|
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':
|
elif path == '/api/storage':
|
||||||
policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365),
|
policy = {'retentionDays': integer(data.get('retentionDays'), 1, 365),
|
||||||
'maxGB': integer(data.get('maxGB'), 5, 100000),
|
'maxGB': integer(data.get('maxGB'), 5, 100000),
|
||||||
|
|||||||
@@ -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
|
||||||
+3
-1
@@ -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 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);}}
|
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 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=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).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=>`<option value="${a.id}">${esc(a.name)}</option>`).join('');f.elements.recorderId.innerHTML='<option value="">请选择录像机</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');f.elements.archiveRecorderId.innerHTML='<option value="">尚未绑定</option>'+state.recorders.map(r=>`<option value="${r.id}">${esc(r.name)}</option>`).join('');$('#onvif-result').innerHTML='';f.elements.siteId.innerHTML=state.sites.map(s=>`<option value="${s.id}">${esc(kind(s.kind)+' · '+s.name)}</option>`).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 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;}});}
|
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');
|
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;}
|
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;
|
$('#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)}));
|
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;}};
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@
|
|||||||
</main>
|
</main>
|
||||||
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">输入管理密码,进入视觉工作空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>管理员密码<input name="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
<dialog id="auth-dialog"><form id="auth-form"><span class="eyebrow">WELCOME HOME</span><h2 id="auth-title">欢迎回来</h2><p id="auth-note">输入管理密码,进入视觉工作空间。</p><label id="setup-code-label" hidden>初始化码<input name="code" autocomplete="off"></label><label>管理员密码<input name="password" type="password" minlength="10" maxlength="128" required autocomplete="current-password"></label><p class="form-error"></p><button type="submit" class="wide">进入系统</button></form></dialog>
|
||||||
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:莱阳老家"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
|
<dialog id="site-dialog"><form id="site-form"><div class="dialog-heading"><h2>空间档案</h2><button type="button" class="quiet" data-close="site-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>空间名称 *<input name="name" required maxlength="100" placeholder="例如:莱阳老家"></label><label>用途<select name="kind"><option value="home">家庭</option><option value="company">公司</option><option value="other">其他</option></select></label><label>所属家庭 / 单位<input name="owner" maxlength="100"></label><label>省 / 直辖市<input name="province" maxlength="100"></label><label>城市<input name="city" maxlength="100"></label><label>区 / 县<input name="district" maxlength="100"></label><label>街道 / 乡镇<input name="street" maxlength="100"></label><label>小区 / 园区 / 村<input name="community" maxlength="100"></label><label>楼栋<input name="building" maxlength="100" placeholder="例如:3 号楼"></label><label>单元<input name="unit" maxlength="100"></label><label>楼层<input name="floor" maxlength="100"></label><label>门牌号<input name="door" maxlength="100" placeholder="例如:502"></label><label class="full">详细地址<input name="address" maxlength="240"></label><label class="full">备注<input name="note" maxlength="240"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存空间</button></div></form></dialog>
|
||||||
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
|
<dialog id="camera-dialog"><form id="camera-form"><div class="dialog-heading"><h2>镜头通道配置</h2><button type="button" class="quiet" data-close="camera-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label class="full">所属摄像头对象 *<select name="assetId" required></select></label><label>接入方式<select name="sourceKind"><option value="recorder">通过录像机</option><option value="direct">直接连接摄像头</option></select></label><label>关联录像机<select name="recorderId"><option value="">请选择</option></select></label><label>录像机通道编号<input name="channelNumber" maxlength="30" placeholder="按录像机实际通道填写"></label><label>录像存储设备<select name="archiveRecorderId"><option value="">尚未绑定</option></select></label><label>历史录像主要保存于<select name="archiveSource"><option value="recorder">录像机</option><option value="local">本机</option></select></label><label>镜头 / 通道名称 *<input name="name" required maxlength="120" placeholder="例如:大门口"></label><label>所属空间 *<select name="siteId" required></select></label><label>安装位置<input name="point" maxlength="120" placeholder="例如:院门外侧"></label><label>品牌 / 型号<input name="model" maxlength="120"></label><label>设备 IP *<input name="host" required placeholder="局域网 IPv4 地址"></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>设备用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" autocomplete="new-password" placeholder="编辑时留空,保留原密码"></label><div class="full"><button id="onvif-read" type="button" class="secondary">读取 ONVIF 镜头配置</button><p class="description">直接接入摄像头时,可读取设备实际提供的媒体配置。默认 ONVIF HTTP 端口为 80。</p><div id="onvif-result"></div></div><label class="full">常见码流路径<select id="path-preset"><option value="custom">手动填写</option><option value="tplink">TP-Link 单镜头</option><option value="hikvision">海康威视 / 兼容路径 · 通道 1</option><option value="dahua">大华 / 兼容路径 · 通道 1</option></select></label><label class="full">主码流路径 *<input name="mainPath" required maxlength="500" value="/stream1"></label><label class="full">子码流路径 *<input name="subPath" required maxlength="500" value="/stream2"></label><label class="check"><input name="enabled" type="checkbox" checked>启用连接</label><label class="check"><input name="record" type="checkbox">在小电脑额外保存录像(可选)</label></div><p class="description">账号和密码只保存在本机受限数据目录。RTSP 服务响应代表设备可达,实际码流接通后才显示在线。路径预设需要与设备型号、通道一致。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存设备</button></div></form></dialog>
|
||||||
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
|
<dialog id="asset-dialog"><form id="asset-form"><div class="dialog-heading"><h2>摄像头对象</h2><button type="button" class="quiet" data-close="asset-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>对象名称 *<input name="name" required maxlength="120" placeholder="例如:院门双镜头摄像头"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>序列号<input name="serial" maxlength="120"></label><label>物理镜头数<input name="lensCount" type="number" value="2" min="1" max="16" required></label><label>安装位置<input name="point" maxlength="120"></label><label>备注<input name="note" maxlength="120"></label></div><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存对象</button></div></form></dialog>
|
||||||
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
|
<dialog id="recorder-dialog"><form id="recorder-form"><div class="dialog-heading"><h2>录像机配置</h2><button type="button" class="quiet" data-close="recorder-dialog">关闭</button></div><input name="id" type="hidden"><div class="form-grid"><label>录像机名称 *<input name="name" required maxlength="120"></label><label>所属空间 *<select name="siteId" required></select></label><label>品牌<input name="brand" maxlength="120"></label><label>型号<input name="model" maxlength="120"></label><label>录像机 IP *<input name="host" required></label><label>RTSP 端口<input name="port" type="number" value="554" min="1" max="65535" required></label><label>登录用户名<input name="username" maxlength="120" autocomplete="off"></label><label>设备密码<input name="password" type="password" maxlength="256" placeholder="留空保留原密码" autocomplete="new-password"></label><label class="full">接入驱动<select name="driver"><option value="generic-rtsp">通用 RTSP / 手动通道路径</option><option value="hikvision-rtsp">海康 / 兼容型号 RTSP 路径</option><option value="tplink-rtsp">TP-Link RTSP 路径</option><option value="dahua-rtsp">大华 / 兼容型号 RTSP 路径</option></select></label></div><p class="description">驱动在当前版本提供路径模板。录像机历史录像检索、回放及通道自动导入,需要型号确认后接入,当前尚未实现。</p><p class="form-error"></p><div class="dialog-footer"><button type="submit">保存录像机</button></div></form></dialog>
|
||||||
<div id="toast" role="status" hidden></div>
|
<div id="toast" role="status" hidden></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user