diff --git a/ICON.md b/ICON.md new file mode 100644 index 0000000..f6bc2e1 --- /dev/null +++ b/ICON.md @@ -0,0 +1,9 @@ +# 标识 + +资产:`web/zhao-icon.png`。使用内置 imagegen 生成,作为网页品牌和 favicon;无需外部图片服务。 + +“赵”字的甲骨文风格设计。香港中文大学[汉语多功能字库的「趙」条目](https://humanum.arts.cuhk.edu.hk/Lexis/lexi-mf/search.php?word=%E8%B6%99)收录金文、小篆等字例,其甲骨栏目未列出字例。这里的标识是现代风格化创作,不宣称为已考证甲骨文字形。 + +生成提示词: + +Use case: logo-brand. Create one polished app icon for 赵府智家, a private Chinese family home monitoring and genealogy application. Square app icon, dark deep teal #10191d rounded-square ground with generous mint #74d5bb single-color linework. The only subject is a single ancient-Chinese-style character emblem for the surname 趙 (modern simplified 赵), inspired by oracle bone carved strokes and the ancient components 走 and 肖. It is a contemporary oracle-bone-style design, NOT a claimed archaeological facsimile. Keep the ancient walking-person/foot component on the left and 肖-derived component on the right recognizable as one cohesive character. Thick clean slightly tapering carved strokes, balanced quiet confident composition, very legible as a small favicon, no photographic texture, no gradients, no glow, no shadows, no extra symbols, no English or captions, no surrounding words, no multiple options or mockup. Character occupies 72 percent of square, centered. Flat professional icon asset ready for website navigation and browser tab. diff --git a/README.md b/README.md index 786b380..9213816 100644 --- a/README.md +++ b/README.md @@ -49,9 +49,9 @@ python3 app.py mediamtx ./data/mediamtx.yml ``` -回归测试:`python3 -m unittest -v test_app` 和 `node --test test_live_player.js test_kinship.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 +回归测试:`python3 -m unittest -v test_app test_ptz` 和 `node --test test_live_player.js test_kinship.js test_calendar.js`。测试使用临时数据库和模拟播放器,不连接真实设备。 -访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 是单管理员模型。 +访问 `http://127.0.0.1:8790/`。后端配置变量:`VISION_BIND`、`VISION_PORT`、`VISION_DATA`、`VISION_RECORDINGS`;高清转码可用 `VISION_VAAPI_DEVICE` 指定渲染设备(默认 `/dev/dri/renderD128`)。HTTPS 反向代理场景可设 `VISION_SECURE_COOKIE=1`。当前管理 API 使用管理员与个人账号分权。 ## 数据与恢复 @@ -76,7 +76,7 @@ mediamtx ./data/mediamtx.yml - 每个账号具有独立用户名和密码,密码使用 scrypt 散列存储;更新密码、角色、空间范围、家谱权限或停用状态会撤销该账号会话。亲属关系不自动授予权限。 - 管理员在“账号与关系”中设置显示名称、相对于哪个账号的称呼,以及关联的家谱人物。支持常用称呼与自定义称呼。默认新个人账号没有空间或家谱权限。 - 家谱是本部署内的一份家庭档案,具有独立的不可访问、查看、编辑权限。一个编辑账号可录入任意数量的祖先、亲属与后辈,无需为每位人物开户。未实现跨家庭租户隔离。 -- 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。公历日期不确定时留空,将农历或口述信息写入备注。 +- 人物记录姓名/称呼、别名、性别、生卒日期、在世情况、籍贯、生平和信息来源。日期可按公历或农历录入,并自动换算;不确定日期可留空,在备注中保留原记载。 - 亲子关系按“父母一方 → 子女”逐条连接,支持亲生、收养、继亲和未注明;配偶关系为双向。禁止自我关系、重复关系、祖先循环、直系祖先与配偶冲突。关系可以停用后恢复,人物可归档后恢复。 - 支持关系参照人物、搜索、祖先树、后裔树、配偶列表和 JSON 导出。孙辈、曾孙辈、玄孙辈等直系辈分由路径推算;旁系复杂称谓显示明确路径,缺少出生日期时不猜测兄弟姐妹的长幼。称谓用于辅助阅读,以录入关系为准。 - 并发编辑使用家谱修订号,旧表单提交会被拒绝,避免覆盖他人的更新。导出包含人物和关系,不含密码和设备凭据。当前导出用于保存副本,尚未提供 JSON 导入界面。 @@ -84,3 +84,12 @@ mediamtx ./data/mediamtx.yml ### 从 v0.1.10 升级 部署前用 SQLite backup API 保存 `/var/lib/zhaovision/vision.db`。首次启动 v0.1.11 会将原有单密码账户迁移为 `admin`,保留原密码散列,并把既有会话关联到管理员;没有固定出厂密码。新增 `users`、`people`、`family_links` 表和 `sessions.user_id` 列。以后可以直接升级;若回退到 v0.1.10,必须同时恢复升级前数据库副本,因为旧版本使用旧账户模型。人物和实际账号数据仅在受限数据目录中,不包含在公开源码内。 + +## 监控墙、云台与日期(v0.1.13) + +- 监控墙支持自适应(每页最多六路)、1/2/4/6/9/16 分屏、翻页、拖动和前移/后移排列。布局和顺序按账号保存在当前浏览器;不跨设备同步。排列和分屏只改 CSS 与可见性,不重建播放器。全屏使用浏览器 Fullscreen API,Esc 退出。属于网页全屏模式,尚非可离线运行的安装包。 +- 当前监控页的通道保持连接,翻页隐藏的通道也继续播放,以便切回时不重复连接;大规模通道应评估转码和网络开销。浏览器处于前台并保持系统唤醒时可持续监看。 +- 云台读取 ONVIF PTZ 能力,仅对有匹配媒体配置、标准连续方向速度空间和一秒超时支持的直连摄像头提供四方向短距离微调。每次移动明确设置 PT1S,随后发 Stop;同一物理节点串行控制,独立停止按钮不排队。设备未声明的变焦、回到原点等功能不显示。协议参考:[ONVIF PTZ](https://www.onvif.org/ver20/ptz/wsdl/)。ONVIF 服务端口默认为 80。 +- 管理员具有云台权限;个人账号需同时具备空间访问和独立的云台控制权限。新账号默认不允许转动。两个镜头如果指向同一 PTZ 节点,控制的是同一物理云台,不能据镜头数量推断独立电机。 +- 人物默认在世;只在“不在世”时显示去世日期,旧人物的“待确认”状态保持原样。出生/去世各使用一个日期输入,选择历法,农历可勾选闰月;数据库统一保存公历日期,避免两份日期不一致。使用浏览器 ICU Chinese calendar,不联网。公历转农历范围 1901–2100,农历录入范围 1901–2099。更早祖先生卒日期可填公历并保留原始记载;不对古代历法作精确推断。 +- 站点与标签页图标为“赵”字的甲骨文风格原创设计,并非考古字形摹本。参考字库及生成说明见 [ICON.md](ICON.md)。 diff --git a/VERSION b/VERSION index 0e24a92..7ac4e5e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.12 +0.1.13 diff --git a/app.py b/app.py index fb5f90b..1512dc2 100644 --- a/app.py +++ b/app.py @@ -29,6 +29,7 @@ import onvif import playback import identity import genealogy +import ptz ROOT = Path(__file__).resolve().parent os.umask(0o077) @@ -559,13 +560,23 @@ class Handler(BaseHTTPRequestHandler): self.login(data, path == '/api/setup') return self.require_auth() - if path not in ('/api/logout', '/api/family/person', '/api/family/link'): + if path not in ('/api/logout', '/api/family/person', '/api/family/link', '/api/ptz'): self.require_admin() if path == '/api/logout': with LOCK: DB.execute('DELETE FROM sessions WHERE hash=?', (hashlib.sha256(self.token().encode()).hexdigest(),)) DB.commit() self.answer({'ok': True}, cookie='vision=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict') + elif path == '/api/ptz': + c = ACCOUNTS.control(self.user, get_object('cameras', data.get('camera'))) + try: + result = ptz.CONTROLLER.command(c, data.get('action')) + except ValueError as e: + raise Problem(str(e), 400) + except Exception: + raise Problem('云台通信未成功,请检查设备连接;设备端的一秒超时保护会停止本次转动', 502) + audit('云台 ' + data['action'], c['name']) + self.answer(result) elif path == '/api/users': self.answer(ACCOUNTS.save(data, self.user)) elif path in ('/api/family/person', '/api/family/link'): @@ -715,6 +726,15 @@ class Handler(BaseHTTPRequestHandler): elif path == '/api/users': self.require_admin() self.answer({'users': [ACCOUNTS.public(u) for u in objects('users')]}) + elif path == '/api/ptz': + c = ACCOUNTS.control(self.user, get_object('cameras', query.get('camera'))) + try: + result = ptz.CONTROLLER.capabilities(c) + except ValueError as e: + raise Problem(str(e), 400) + except Exception: + raise Problem('未能读取云台能力,请确认摄像头的 ONVIF 服务和连接', 502) + self.answer(result) elif path == '/api/family': self.answer(FAMILY.snapshot(self.user)) elif path == '/api/audit': @@ -764,6 +784,8 @@ class Handler(BaseHTTPRequestHandler): else: files = {'/': 'index.html', '/app.js': 'app.js', '/live-player.js': 'live-player.js', '/style.css': 'style.css', '/family.js': 'family.js', '/kinship.js': 'kinship.js', + '/monitor.js': 'monitor.js', '/calendar.js': 'calendar.js', '/date-fields.js': 'date-fields.js', + '/zhao-icon.png': 'zhao-icon.png', '/vendor/hls.min.js': 'vendor/hls.min.js'} if path not in files: raise Problem('页面不存在', 404) diff --git a/genealogy.py b/genealogy.py index 9ff38ef..45753a5 100644 --- a/genealogy.py +++ b/genealogy.py @@ -70,7 +70,7 @@ class Family: if item['birthDate'] and item['deathDate'] and item['birthDate'] > item['deathDate']: raise a.Problem('逝世日期不能早于出生日期') item['gender'] = data.get('gender', 'unknown') - item['lifeStatus'] = data.get('lifeStatus', 'unknown') + item['lifeStatus'] = data.get('lifeStatus', 'alive') if item['gender'] not in ('male', 'female', 'unknown') or item['lifeStatus'] not in ('alive', 'deceased', 'unknown'): raise a.Problem('人物属性不正确') if item['deathDate'] and item['lifeStatus'] != 'deceased': diff --git a/identity.py b/identity.py index 24a04bc..e7a7039 100644 --- a/identity.py +++ b/identity.py @@ -64,6 +64,9 @@ class Accounts: sites = data.get('siteIds', []) if not isinstance(sites, list) or any(not isinstance(s, str) or not a.get_object('sites', s) for s in sites): raise a.Problem('请选择有效空间') + ptz_control = data.get('ptzControl', old.get('ptzControl', False) if old else False) + if not isinstance(ptz_control, bool): + raise a.Problem('云台权限格式不正确') disabled = data.get('disabled', False) if not isinstance(disabled, bool): raise a.Problem('账号状态不正确') @@ -86,11 +89,11 @@ class Accounts: raise a.Problem('密码格式不正确') credentials = self.password(password) if password or not old else {k: old[k] for k in ('salt', 'hash')} user = dict(id=uid, username=username, name=a.clean_text(data.get('name', username), 80, True), - role=role, disabled=disabled, siteIds=sorted(set(sites)), familyAccess=access, + role=role, disabled=disabled, siteIds=sorted(set(sites)), familyAccess=access, ptzControl=ptz_control, personId=person, relatedToId=related, relationship=a.clean_text(data.get('relationship', ''), 40), **credentials) # Revoke sessions when credentials or authorization change, not for label edits. - if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'siteIds', 'familyAccess', 'disabled', 'username')): + if old and any(old.get(k) != user.get(k) for k in ('hash', 'role', 'siteIds', 'familyAccess', 'disabled', 'username', 'ptzControl')): a.DB.execute('DELETE FROM sessions WHERE user_id=?', (uid,)) a.save_object('users', user) a.audit('更新账号' if old else '建立账号', username) @@ -103,6 +106,14 @@ class Accounts: raise self.a.Problem('没有这个空间的访问权限', 403) return camera + def control(self, user, camera): + self.camera(user, camera) + if user['role'] != 'admin' and not user.get('ptzControl', False): + raise self.a.Problem('没有云台控制权限,请联系管理员授权', 403) + if not camera.get('enabled'): + raise self.a.Problem('摄像头已停用', 409) + return camera + def state(self, user, state): state['user'] = self.public(user) if user['role'] == 'admin': diff --git a/ptz.py b/ptz.py new file mode 100644 index 0000000..732df75 --- /dev/null +++ b/ptz.py @@ -0,0 +1,121 @@ +"""Capability-driven ONVIF pan/tilt: bounded nudges, never latched movement.""" +import hashlib +import threading +import time +import urllib.parse as url +from xml.sax.saxutils import escape +import onvif + +PTZ = 'http://www.onvif.org/ver20/ptz/wsdl' +SPACE = 'http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace' +T = '{' + onvif.SCHEMA + '}' +DIRECTIONS = {'left': (-0.25, 0), 'right': (0.25, 0), 'up': (0, 0.25), 'down': (0, -0.25)} + + +class Controller: + def __init__(self): + self.guard = threading.RLock() + self.cache = {} + self.locks = {} + + def call(self, c, endpoint, namespace, operation, body=''): + port, path = endpoint + return onvif.request(c['host'], port, path, namespace, operation, body, c['username'], c['password']) + + @staticmethod + def endpoint(c, text): + parsed = url.urlsplit(text) + if parsed.scheme != 'http' or parsed.hostname != c['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, c): + if c.get('sourceKind', 'direct') != 'direct': + raise ValueError('请使用摄像头直连通道控制云台') + key = (c['host'], c.get('onvifPort', 80), c['username'], hashlib.sha256(c['password'].encode()).hexdigest(), c['mainPath']) + with self.guard: + old = self.cache.get(key) + if old and old[0] > time.monotonic(): + return old[1] + caps = self.call(c, (c.get('onvifPort', 80), '/onvif/device_service'), onvif.DEVICE, 'GetCapabilities', 'All') + def service(name): + value = caps.find('.//' + T + name + '/' + T + 'XAddr') + if value is None or not value.text: + raise ValueError('设备没有提供云台服务') + return self.endpoint(c, value.text) + endpoint, media = service('PTZ'), service('Media') + profiles = self.call(c, media, onvif.MEDIA, 'GetProfiles') + available = [p for p in profiles.findall('.//{' + onvif.MEDIA + '}Profiles') if p.find(T + 'PTZConfiguration') is not None] + wanted = url.parse_qs(url.urlsplit(c['mainPath']).query).get('profile', [''])[0] + selected = next((p for p in available if p.get('token') == wanted), None) + if selected is None: + for p in available[:32]: + body = 'RTP-UnicastRTSP' + escape(p.get('token', '')) + '' + root = self.call(c, media, onvif.MEDIA, 'GetStreamUri', body) + uri = root.find('.//' + T + 'Uri') + if uri is not None and url.urlsplit(uri.text).path == url.urlsplit(c['mainPath']).path: + selected = p + break + if selected is None: + raise ValueError('这个镜头没有匹配的云台配置') + conf = selected.find(T + 'PTZConfiguration') + options = self.call(c, endpoint, PTZ, 'GetConfigurationOptions', '' + escape(conf.get('token')) + '') + spaces = options.findall('.//' + T + 'ContinuousPanTiltVelocitySpace') + space = next((s for s in spaces if s.findtext(T + 'URI') == SPACE), None) + if space is None: + raise ValueError('设备不支持通用方向微调') + for axis in ('XRange', 'YRange'): + if float(space.findtext(T + axis + '/' + T + 'Min')) > -0.25 or float(space.findtext(T + axis + '/' + T + 'Max')) < 0.25: + raise ValueError('设备云台速度范围不兼容') + # Do not issue movement unless the device advertises a one-second watchdog. + minimum = options.findtext('.//' + T + 'PTZTimeout/' + T + 'Min') + maximum = options.findtext('.//' + T + 'PTZTimeout/' + T + 'Max') + def seconds(value): + import re + m = re.fullmatch(r'PT(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?', value or '') + return sum(float(v or 0) * scale for v, scale in zip(m.groups(), (3600, 60, 1))) if m else -1 + if not 0 <= seconds(minimum) <= 1 <= seconds(maximum): + raise ValueError('设备不支持一秒自动停止保护') + result = {'endpoint': endpoint, 'profile': selected.get('token'), 'node': conf.findtext(T + 'NodeToken'), 'supported': True} + with self.guard: + if len(self.cache) > 128: + self.cache.clear() + self.cache[key] = (time.monotonic() + 300, result) + return result + + def capabilities(self, c): + self.discover(c) + return {'supported': True, 'directions': list(DIRECTIONS), 'mode': 'nudge', + 'note': '点击一次短距离转动并自动停止。同一摄像头的镜头可能共用云台。'} + + def command(self, c, action): + if action not in (*DIRECTIONS, 'stop'): + raise ValueError('请选择上、下、左、右或停止') + info = self.discover(c) + body = '' + escape(info['profile']) + '' + def stop(): + self.call(c, info['endpoint'], PTZ, 'Stop', body + 'true') + if action == 'stop': + stop() + return {'ok': True, 'stopped': True} + # Lock by physical host/node, including commands sent through another lens/account. + key = (c['host'], info['endpoint'][0], info['node']) + with self.guard: + lock = self.locks.setdefault(key, threading.Lock()) + if not lock.acquire(blocking=False): + raise ValueError('云台正在执行操作,请稍后再点') + try: + x, y = DIRECTIONS[action] + try: + self.call(c, info['endpoint'], PTZ, 'ContinuousMove', body + + f'PT1S') + time.sleep(0.3) + finally: + # Also stop after an ambiguous network failure. Device timeout is a second guard. + stop() + return {'ok': True, 'stopped': True} + finally: + lock.release() + + +CONTROLLER = Controller() diff --git a/test_app.py b/test_app.py index d6022e8..850b7d3 100644 --- a/test_app.py +++ b/test_app.py @@ -216,6 +216,22 @@ class WebTests(unittest.TestCase): self.assertIn('h264_vaapi', command) self.assertIn('pipe:1', command) + def test_ptz_requires_both_space_and_control_permission(self): + camera = dict(id='e'*16, name='Test', siteId='permitted', enabled=True) + viewer = dict(role='member', siteIds=['permitted'], ptzControl=False) + with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera) + viewer['ptzControl'] = True + self.assertEqual(app.ACCOUNTS.control(viewer, camera), camera) + viewer['siteIds'] = [] + with self.assertRaises(app.Problem):app.ACCOUNTS.control(viewer, camera) + self.assertEqual(self.request('/api/ptz?camera='+'e'*16,False)[0],401) + + def test_family_defaults_alive_and_rejects_death_for_alive(self): + p=app.FAMILY.person({'name':'Example'},None) + self.assertEqual(p['lifeStatus'],'alive') + with self.assertRaises(app.Problem):app.FAMILY.person({'name':'Example','deathDate':'2020-01-01'},None) + self.assertEqual(app.FAMILY.person({'name':'Example','lifeStatus':'deceased','deathDate':'2020-01-01'},None)['deathDate'],'2020-01-01') + def test_empty_recording_day_is_not_service_failure(self): error = app.urllib.error.HTTPError('http://localhost/list', 404, 'Not Found', {}, io.BytesIO(b'{"error":"no recording segments found"}')) diff --git a/test_calendar.js b/test_calendar.js new file mode 100644 index 0000000..6775fb3 --- /dev/null +++ b/test_calendar.js @@ -0,0 +1,17 @@ +'use strict'; +const test=require('node:test'),assert=require('node:assert/strict'),cal=require('./web/calendar.js'); +test('Spring Festival and leap month are bidirectional',()=>{ + assert.deepEqual(cal.lunar('2024-02-10'),{year:2024,month:1,day:1,leap:false}); + assert.equal(cal.solar('2024-01-01'),'2024-02-10'); + assert.deepEqual(cal.lunar('2023-03-22'),{year:2023,month:2,day:1,leap:true}); + assert.equal(cal.solar('2023-02-01',true),'2023-03-22'); + assert.equal(cal.solar('2023-02-01',false),'2023-02-20'); +}); +test('all days across a leap year round trip',()=>{ + for(let n=0;n<366;n++){const s=new Date(Date.UTC(2024,0,1+n,12)).toISOString().slice(0,10),l=cal.lunar(s);assert.equal(cal.solar(cal.iso(l),l.leap),s);} +}); +test('invalid dates, nonexistent leap months and range are explicit',()=>{ + for(const s of ['2026-02-30','2026-13-01','bad'])assert.throws(()=>cal.solarDate(s)); + assert.throws(()=>cal.solar('2024-02-01',true));assert.throws(()=>cal.solar('2024-12-31'));assert.throws(()=>cal.lunar('1800-01-01')); + assert.equal(cal.describe('1800-01-01'),'1800-01-01(公历)'); +}); diff --git a/test_ptz.py b/test_ptz.py new file mode 100644 index 0000000..c5bf87b --- /dev/null +++ b/test_ptz.py @@ -0,0 +1,55 @@ +import threading +import unittest +from unittest.mock import patch +from xml.etree.ElementTree import fromstring +import ptz + + +class PtzTests(unittest.TestCase): + def setUp(self): + self.controller = ptz.Controller() + self.camera = dict(host='192.168.0.2', username='admin', password='example', mainPath='/stream1') + self.info = dict(endpoint=(80, '/onvif/PTZ'), profile='profile1', node='shared') + + def test_nudge_has_device_deadline_and_explicit_stop(self): + with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call') as call, patch('ptz.time.sleep'): + self.assertTrue(self.controller.command(self.camera, 'left')['stopped']) + self.assertEqual([c.args[3] for c in call.call_args_list], ['ContinuousMove', 'Stop']) + self.assertIn('PT1S', call.call_args_list[0].args[4]) + self.assertIn('x="-0.25"', call.call_args_list[0].args[4]) + + def test_failed_move_still_stops(self): + with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call', side_effect=[OSError(), None]) as call: + with self.assertRaises(OSError): + self.controller.command(self.camera, 'up') + self.assertEqual(call.call_args_list[-1].args[3], 'Stop') + self.assertFalse(self.controller.locks[('192.168.0.2', 80, 'shared')].locked()) + + def test_shared_node_rejects_overlapping_moves_but_allows_stop(self): + lock=threading.Lock();lock.acquire();self.controller.locks[('192.168.0.2',80,'shared')]=lock + with patch.object(self.controller, 'discover', return_value=self.info), patch.object(self.controller, 'call') as call: + with self.assertRaises(ValueError):self.controller.command(self.camera,'right') + self.controller.command(self.camera,'stop') + self.assertEqual(call.call_args.args[3],'Stop') + lock.release() + + def test_arbitrary_commands_and_foreign_services_rejected(self): + for action in ['zoom','home','SetPreset',None]: + with self.assertRaises(ValueError):self.controller.command(self.camera,action) + for endpoint in ['http://example.org/onvif/PTZ','http://admin:secret@192.168.0.2/onvif/PTZ','https://192.168.0.2/onvif/PTZ']: + with self.assertRaises(ValueError):self.controller.endpoint(self.camera,endpoint) + + def test_discovery_and_short_timeout_requirement(self): + def xml(text):return fromstring(''+text+'') + caps=xml('http://192.168.0.2/onvif/PTZhttp://192.168.0.2/onvif/Media') + profiles=xml('shared') + template=''+ptz.SPACE+'-11-11{minimum}PT600S' + self.camera['mainPath']='/stream1?profile=profile1' + with patch.object(self.controller,'call',side_effect=[caps,profiles,xml(template.format(minimum='PT1S'))]): + self.assertTrue(self.controller.capabilities(self.camera)['supported']) + self.controller.cache.clear() + with patch.object(self.controller,'call',side_effect=[caps,profiles,xml(template.format(minimum='PT5S'))]): + with self.assertRaises(ValueError):self.controller.discover(self.camera) + + +if __name__=='__main__':unittest.main() diff --git a/web/app.js b/web/app.js index 6b81ad4..e946265 100644 --- a/web/app.js +++ b/web/app.js @@ -14,6 +14,7 @@ function applyPermissions(){ $('#current-user').textContent=currentUser ? currentUser.name+' · '+(admin?'管理员':'个人账号') : ''; } function clearPrivateView(){ + if(typeof closePtz==='function')closePtz();if(document.fullscreenElement)document.exitFullscreen().catch(()=>{}); currentUser=null;stopPlayers();resetPlayback(); state={cameras:[],assets:[],recorders:[],sites:[],storage:{},scan:{}}; for(const id of ['live-grid','asset-list','camera-list','recorder-list','site-list','audit-list','family-people','family-detail','family-tree','user-list'])$('#'+id).replaceChildren(); @@ -37,7 +38,7 @@ function setOptions(select, html) { const old = select.value; select.innerHTML = function stopPlayers() { players.forEach(p => p.destroy()); players = []; $$('#live-grid video').forEach(v => {v.pause();v.removeAttribute('src');v.load();}); liveKey = ''; } function showPage(page) { if(!isAdmin()&&!['live','playback','family'].includes(page))return; - if (activePage !== page) stopPlayers(); + if (activePage !== page) {stopPlayers();if(typeof closePtz==='function')closePtz();} activePage = page; $('.metrics').hidden=['family','users'].includes(page); $('.filter-row').hidden=['family','users'].includes(page); @@ -72,6 +73,7 @@ function render() { } if (activePage === 'storage') renderStorage(); applyPermissions(); + if(activePage==='live'&&typeof wallSync==='function')wallSync(); } function empty(title, description, button) { return `

${esc(title)}

${esc(description)}

${button || ''}
`; } function renderLive() { @@ -79,7 +81,7 @@ function renderLive() { const key = ZhaoLivePlayer.key(cameras, quality); if (key === liveKey) { updateLiveStatus(cameras); return; } stopPlayers(); liveKey = key; - $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); + $('#live-grid').innerHTML = cameras.length ? cameras.map(c=>`
⠿ 拖动
◎${c.enabled ? '正在等待画面' : '设备连接已停用'}
${esc(c.name)}

${esc(siteName(c.siteId)+' · '+(c.point || '安装位置待填写'))}

${c.recordingActive ? '● 正在录像' : c.ready ? '码流已接通' : c.enabled ? '等待接通' : '已停用'}
`).join('') : (isAdmin()?empty('先接入一台摄像头','建立空间档案,再把设备添加到对应位置。',''):empty('当前范围没有可查看的摄像头','请选择已授权空间,或联系管理员配置观看权限。')); for (const c of cameras.filter(c=>c.enabled)) { const video = $(`video[data-camera="${c.id}"]`); players.push(new ZhaoLivePlayer(video, video.parentElement, `/media/live/cam_${c.id}_${quality}/index.m3u8`)); diff --git a/web/calendar.js b/web/calendar.js new file mode 100644 index 0000000..df93272 --- /dev/null +++ b/web/calendar.js @@ -0,0 +1,38 @@ +/* Chinese calendar conversion supplied by the browser's ICU calendar, offline. */ +(function(root){ + 'use strict'; + const fmt=new Intl.DateTimeFormat('en-u-ca-chinese',{year:'numeric',month:'numeric',day:'numeric',timeZone:'Asia/Shanghai'}); + const cache=new Map(); + function solarDate(value){ + if(!/^\d{4}-\d{2}-\d{2}$/.test(value))throw Error('日期请填写 YYYY-MM-DD'); + const d=new Date(value+'T12:00:00Z'); + if(!Number.isFinite(+d)||d.toISOString().slice(0,10)!==value)throw Error('日期不存在'); + return d; + } + function lunar(value){ + const d=solarDate(value),year=d.getUTCFullYear(); + if(year<1901||year>2100)throw Error('自动换算支持 1901—2100 年;更早日期可填写公历并在备注保留原记载'); + if(fmt.resolvedOptions().calendar!=='chinese')throw Error('当前浏览器不支持农历换算'); + const parts=Object.fromEntries(fmt.formatToParts(d).map(p=>[p.type,p.value])); + return {year:Number(parts.relatedYear),month:parseInt(parts.month,10),day:Number(parts.day),leap:parts.month.includes('bis')}; + } + function solar(value,leap=false){ + if(!/^\d{4}-\d{2}-\d{2}$/.test(value))throw Error('农历请填写 YYYY-MM-DD,例如 2023-02-01'); + const [year,month,day]=value.split('-').map(Number); + if(year<1901||year>2099)throw Error('农历输入支持 1901—2099 年'); + if(month<1||month>12||day<1||day>30)throw Error('农历日期不存在'); + if(!cache.has(year)){ + const days=new Map(),start=Date.UTC(year,0,1,12),end=Date.UTC(year+1,2,1,12); + for(let t=start;t8)cache.clear();cache.set(year,days); + } + const result=cache.get(year).get(`${month}-${day}-${!!leap}`); + if(!result)throw Error('该农历日期或闰月不存在'); + return result; + } + const pad=n=>String(n).padStart(2,'0'); + function iso(l){return `${l.year}-${pad(l.month)}-${pad(l.day)}`;} + function describe(value){if(!value)return '';try{const l=lunar(value);return `${value} · 农历 ${l.year}年${l.leap?'闰':''}${l.month}月${l.day}日`;}catch{return value+'(公历)';}} + root.ZhaoCalendar={solarDate,lunar,solar,iso,describe}; + if(typeof module!=='undefined')module.exports=root.ZhaoCalendar; +})(typeof window==='undefined'?globalThis:window); diff --git a/web/date-fields.js b/web/date-fields.js new file mode 100644 index 0000000..6d0a682 --- /dev/null +++ b/web/date-fields.js @@ -0,0 +1,38 @@ +'use strict'; +function dateHint(field,strict=false){ + const input=field.querySelector('[data-date-value]'),hint=field.querySelector('[data-date-hint]'),hidden=field.querySelector('input[type=hidden]'); + const mode=field.querySelector('[data-calendar]').value,value=input.value.trim(),leap=field.querySelector('[data-date-leap]').checked; + field.querySelector('[data-leap-label]').hidden=mode!=='lunar';input.setCustomValidity(''); + if(!value){hidden.value='';hint.textContent='日期不详可留空';return;} + try{ + const solar=mode==='solar'?(ZhaoCalendar.solarDate(value),value):ZhaoCalendar.solar(value,leap); + hidden.value=solar; + if(mode==='lunar')hint.textContent='公历 '+solar; + else{try{const l=ZhaoCalendar.lunar(solar);hint.textContent=`农历 ${l.year}年${l.leap?'闰':''}${l.month}月${l.day}日`;}catch(e){hint.textContent=e.message;}} + }catch(e){hidden.value='';input.setCustomValidity(e.message);hint.textContent=e.message;if(strict)throw e;} +} +function syncLifeStatus(){ + const deceased=$('#person-form').elements.lifeStatus.value==='deceased',field=$('[data-date-field="deathDate"]');field.hidden=!deceased; + field.querySelectorAll('input,select').forEach(e=>e.disabled=!deceased); + if(!deceased)field.querySelector('[data-date-value]').setCustomValidity(''); +} +function initDateFields(){ + for(const field of $$('[data-date-field]')){ + field.querySelector('[data-calendar]').value='solar';field.querySelector('[data-date-leap]').checked=false; + field.querySelector('[data-date-value]').value=field.querySelector('input[type=hidden]').value;dateHint(field); + }syncLifeStatus(); +} +function syncDateFields(strict=false){ + syncLifeStatus();for(const field of $$('[data-date-field]'))if(!field.hidden)dateHint(field,strict); + if($('[data-date-field="deathDate"]').hidden)$('#person-form').elements.deathDate.value=''; +} +for(const field of $$('[data-date-field]')){ + field.querySelector('[data-date-value]').addEventListener('input',()=>dateHint(field)); + field.querySelector('[data-date-leap]').addEventListener('change',()=>dateHint(field)); + field.querySelector('[data-calendar]').addEventListener('change',()=>{ + const hidden=field.querySelector('input[type=hidden]'),input=field.querySelector('[data-date-value]'),mode=field.querySelector('[data-calendar]'); + if(hidden.value){try{if(mode.value==='solar')input.value=hidden.value;else{const l=ZhaoCalendar.lunar(hidden.value);input.value=ZhaoCalendar.iso(l);field.querySelector('[data-date-leap]').checked=l.leap;}}catch(e){mode.value='solar';toast(e.message);}} + dateHint(field); + }); +} +$('#person-form').elements.lifeStatus.addEventListener('change',syncLifeStatus); diff --git a/web/family.js b/web/family.js index 220eb3d..6f54323 100644 --- a/web/family.js +++ b/web/family.js @@ -27,7 +27,7 @@ function renderFamily(){ const p=family.people.find(p=>p.id===focusPerson); if(!p){$('#family-detail').innerHTML='

人物档案

选择或录入一位人物,查看关系与家谱。

';$('#family-tree').replaceChildren();return;} const edges=family.links.filter(e=>e.fromId===p.id||e.toId===p.id); - $('#family-detail').innerHTML=`
人物档案

${esc(p.name)}

${family.canEdit?``:''}

相对于 ${esc(personName(reference))}:${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}

${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',p.birthDate],['逝世日期',p.deathDate],['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`
${k}
${esc(v||'待补充')}
`).join('')}

生平与记载

${esc(p.biography||'尚未记录生平')}

${esc(p.note||'')}

亲属关系

${family.canEdit?'':''}
${edges.map(e=>``).join('')||'

尚未连接亲属。

'}`; + $('#family-detail').innerHTML=`
人物档案

${esc(p.name)}

${family.canEdit?``:''}

相对于 ${esc(personName(reference))}:${esc(ZhaoKinship.describe(family.people,family.links,reference,p.id))}

${[['别名',p.alias],['性别',{male:'男',female:'女',unknown:'待确认'}[p.gender]],['在世情况',{alive:'在世',deceased:'已故',unknown:'待确认'}[p.lifeStatus]],['出生日期',ZhaoCalendar.describe(p.birthDate)],...(p.lifeStatus==='deceased'?[['去世日期',ZhaoCalendar.describe(p.deathDate)]]:[]),['籍贯 / 出生地',p.birthplace]].map(([k,v])=>`
${k}
${esc(v||'待补充')}
`).join('')}

生平与记载

${esc(p.biography||'尚未记录生平')}

${esc(p.note||'')}

亲属关系

${family.canEdit?'':''}
${edges.map(e=>``).join('')||'

尚未连接亲属。

'}`; renderFamilyTree(); } function renderFamilyTree(){ @@ -45,12 +45,12 @@ function renderFamilyTree(){ const spouseIds=edges.filter(e=>e.kind==='spouse'&&(e.fromId===root.id||e.toId===root.id)).map(e=>e.fromId===root.id?e.toId:e.fromId); $('#family-tree').innerHTML=`

上溯祖先

下续后裔

配偶

${spouseIds.map(id=>``).join(' ')||'

尚未记录

'}
`; } -function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});$('#person-dialog').showModal();} +function openPerson(id){if(!family.canEdit)return;const p=family.people.find(p=>p.id===id)||{};fillForm($('#person-form'),{...p,revision:family.revision});initDateFields();$('#person-dialog').showModal();} function openRelation(id){if(!family.canEdit)return;if(family.people.length<2){toast('请先录入另一位亲属,再连接关系');return;}const f=$('#relation-form');f.elements.fromId.innerHTML=peopleOptions(false);f.elements.toId.innerHTML=peopleOptions(false);const edge=family.links.find(e=>e.id===id);fillForm(f,{...(edge||{fromId:focusPerson,active:true}),revision:family.revision});if(!edge)f.elements.toId.value=family.people.find(p=>p.id!==focusPerson)?.id||'';$('#relation-dialog').showModal();} for(const [name,path]of [['person','person'],['relation','link']]){ $('#'+name+'-form').addEventListener('submit',async e=>{ e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true; - try{const d=formValues(f);d.revision=Number(d.revision);if(name==='person')d.archived=f.elements.archived.checked;else d.active=f.elements.active.checked; + try{const d=formValues(f);d.revision=Number(d.revision);if(name==='person'){syncDateFields(true);Object.assign(d,formValues(f));d.revision=Number(d.revision);d.archived=f.elements.archived.checked;}else d.active=f.elements.active.checked; const result=await api('/api/family/'+path,d);if(name==='person')focusPerson=result.item.id;f.closest('dialog').close();await loadFamily();toast('家谱已保存'); }catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;} }); @@ -67,7 +67,7 @@ function openUser(id){ fillForm(f,u);f.elements.password.required=!u.id; $('#user-sites').innerHTML=state.sites.map(s=>``).join('');$('#user-dialog').showModal(); } -$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}}); +$('#user-form').addEventListener('submit',async e=>{e.preventDefault();const f=e.currentTarget,b=f.querySelector('[type=submit]');b.disabled=true;try{const d=formValues(f);d.siteIds=[...f.querySelectorAll('[name=siteIds]:checked')].map(el=>el.value);d.disabled=f.elements.disabled.checked;d.ptzControl=f.elements.ptzControl.checked;await api('/api/users',d);f.closest('dialog').close();await auth();if(loggedIn)await loadUsers();toast('账号已保存');}catch(err){f.querySelector('.form-error').textContent=err.message;}finally{b.disabled=false;}}); $('#new-person').onclick=()=>openPerson();$('#new-user').onclick=()=>openUser();$('#family-reload').onclick=loadFamily; $('#family-reference').onchange=renderFamily;$('#family-search').oninput=renderFamily;$('#family-archived').onchange=renderFamily;$('#family-depth').onchange=renderFamilyTree; $('#family-export').onclick=async()=>{try{const data=await api('/api/family');const blob=new Blob([JSON.stringify({format:'zhaofu-family',version:1,exportedAt:new Date().toISOString(),people:data.people,links:data.links},null,2)],{type:'application/json'});const link=document.createElement('a'),href=URL.createObjectURL(blob);link.href=href;link.download='zhaofu-family-'+new Date().toISOString().slice(0,10)+'.json';link.click();setTimeout(()=>URL.revokeObjectURL(href),60000);}catch(e){toast(e.message);}}; diff --git a/web/index.html b/web/index.html index 68e0570..48aea93 100644 --- a/web/index.html +++ b/web/index.html @@ -2,12 +2,12 @@ - 赵府智家 · 家庭空间 - + 赵府智家 · 家庭空间 +