Author SHA1 Message Date
QiuSW bebc23cb6a docs: map T-019 to issue 67
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 15:04:04 +08:00
ila 7b95c7d155 Merge T-019 task definition 2026-08-11 15:03:35 +08:00
QiuSW 5a82035cc3 docs: define T-019 event ingress
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 15:03:20 +08:00
ila 607860b79a Merge pull request #65: T-018 Sense NVR management console
Product acceptance confirmed on 2026-08-11. Closes #63.
2026-08-11 14:42:44 +08:00
QiuSW 7c202b7ac8 docs(T-018): record product acceptance 2026-08-11 14:42:25 +08:00
QiuSW 7db707e596 feat(sense): add loopback NVR management console
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:38:22 +08:00
ila a4427a6d2b Merge T-018 issue mapping
Maps T-018 to Gitea issue 63.
2026-08-11 14:06:26 +08:00
QiuSW e3b166c5af docs(tasks): map T-018 to issue 63
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:06:12 +08:00
ila e46f175551 Merge T-018 Sense console task definition
Defines the Sense-first NVR management vertical slice and defers Brain ingress.
2026-08-11 14:05:25 +08:00
QiuSW 2ca51aa2bc docs(tasks): define T-018 Sense console vertical slice
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 14:05:11 +08:00
ila 19a3233f11 Merge T-017 Brain single-stream visual prototype
Validated engineering-only Python/OpenCV prototype; closes #59.
2026-08-11 11:03:44 +08:00
QiuSW a2790c1f5e feat(brain): add single-stream visual prototype (T-017)
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 11:02:37 +08:00
ila 2e04737922 Merge T-017 issue mapping
Maps T-017 to Gitea issue 59.
2026-08-11 10:30:30 +08:00
QiuSW 2768cfeacb docs(tasks): map T-017 to issue 59
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled
2026-08-11 10:18:40 +08:00
ila 5c9318ceef Merge T-017 task definition
Defines the scoped Brain single-stream visual prototype task.
2026-08-11 10:17:33 +08:00
40 changed files with 3227 additions and 26 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.py[cod]
.venv/
artifacts/
*.local.json
*.url
-1
View File
@@ -1 +0,0 @@
+53
View File
@@ -0,0 +1,53 @@
# YoVision Brain 单路工程原型
T-017 提供一条可见、可重复的工程链路:单路 frame source → 匿名人员检测/fixture → track → 归一化多边形区域 → `zone_entry` 候选事实。它不是生产模型、不是 NVR,也没有 Brain→Bell transport。
## 环境
- Python `3.10.11`
- NumPy `1.26.4`
- OpenCV `4.9.0.80`
本任务复用本机已验证版本;新环境显式安装:
```powershell
python -m pip install -r Brain/requirements-demo.txt
```
本机冻结的是 `opencv-python`;同一环境只能安装一种提供 `cv2` 命名空间的 OpenCV wheel。不要同时安装标准、headless 和 contrib 变体。后续生产容器若改用 headless,必须在独立任务核对 wheel、许可证与完整回归,不能在本版本号下静默替换包名。
## 启动
无需摄像头的确定性合成回放:
```powershell
python -m Brain.yovision_brain --source synthetic
```
打开 `http://127.0.0.1:8090/brain-demo`。合成人员框由 fixture 提供,页面和事件均显式标识,不得拿它证明模型效果。
使用真实流时,把完整 RTSP URL 写入仓库外绝对路径文件。推荐指向 Sense 管理的 MediaMTX path,而不是绕过 Sense 固化摄像头地址:
```powershell
python -m Brain.yovision_brain --source stream --stream-url-file D:\private\brain-stream.url
```
URL 文件只允许一行、最多 4096 字节,服务不会在状态、页面和错误中返回其内容。演示服务只接受 `127.0.0.1`、`localhost` 或 `::1`;没有暴露到局域网的开关。
真实流使用 OpenCV 内置 HOG/SVM 人员检测和轻量 centroid tracker,只验证可替换端口与区域链路。它不是 M3 生产模型,不能据此承诺召回率、误报率、GPU 容量或 16/128 路能力。
## 验证
```powershell
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
```
单文件 UI 原型位于 `docs/design/brain/index.html`,可直接打开;此时使用明确标识的离线原型数据。由本地服务打开时,同一文件消费回环 API,并用临时页面 token 保护区域写入。
## 边界
- `source_event_id` 由 Brain 产生,平台 `evt_` ULID 由 Bell 产生。
- 事件只保留在最多 100 项的内存环中;重启即丢失是刻意边界。
- T-018 冻结并实现身份映射、候选契约、持久 Outbox、HMAC、幂等和 Bell ingress。
- `D:\OPC\silver_pose` 保持独立,不是本模块的源码目录、运行依赖或模型来源路径。
+1
View File
@@ -0,0 +1 @@
"""YoVision Brain package root."""
+3
View File
@@ -0,0 +1,3 @@
# T-017 engineering prototype only. Do not infer the production Brain stack.
numpy==1.26.4
opencv-python==4.9.0.80
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from Brain.yovision_brain.domain import (
Box,
Detection,
Point,
Zone,
ZoneEntryEvaluator,
point_in_polygon,
zone_from_payload,
)
class GeometryTests(unittest.TestCase):
def setUp(self) -> None:
self.zone = Zone(
"zone-1",
"危险区域",
1,
(Point(0.4, 0.2), Point(0.8, 0.2), Point(0.8, 0.8), Point(0.4, 0.8)),
)
def test_point_in_polygon_includes_boundary(self) -> None:
self.assertTrue(point_in_polygon(Point(0.6, 0.5), self.zone.points))
self.assertTrue(point_in_polygon(Point(0.4, 0.5), self.zone.points))
self.assertFalse(point_in_polygon(Point(0.2, 0.5), self.zone.points))
def test_zone_requires_normalized_three_to_thirty_two_points(self) -> None:
with self.assertRaisesRegex(ValueError, "3 to 32"):
Zone("zone-1", "bad", 1, (Point(0, 0), Point(1, 1)))
with self.assertRaisesRegex(ValueError, "within"):
Point(1.1, 0.5)
with self.assertRaisesRegex(ValueError, "within"):
Point(float("nan"), 0.5)
with self.assertRaisesRegex(ValueError, "within"):
Box(0.1, 0.1, float("inf"), 0.9)
def test_zone_payload_rejects_unknown_fields_and_increments_version(self) -> None:
updated = zone_from_payload(
{"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]},
self.zone,
)
self.assertEqual(updated.version, 2)
self.assertEqual(updated.name, "新区域")
with self.assertRaisesRegex(ValueError, "unknown"):
zone_from_payload({"points": [], "tenant_id": "must-not-be-here"}, self.zone)
class ZoneEntryTests(unittest.TestCase):
def setUp(self) -> None:
self.zone = Zone(
"zone-1",
"危险区域",
1,
(Point(0.5, 0.2), Point(0.9, 0.2), Point(0.9, 0.9), Point(0.5, 0.9)),
)
ids = iter(("BRN-0001", "BRN-0002", "BRN-0003"))
self.evaluator = ZoneEntryEvaluator("device-ref", True, track_ttl_frames=2, event_id_factory=lambda: next(ids))
self.now = datetime(2026, 8, 11, 1, 2, 3, tzinfo=timezone.utc)
@staticmethod
def detection(track: str, center_x: float) -> Detection:
return Detection(track, "person", Box(center_x - 0.05, 0.3, center_x + 0.05, 0.8))
def test_first_seen_inside_does_not_fake_an_entry(self) -> None:
events, states = self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.7)])
self.assertEqual(events, [])
self.assertTrue(states["P-1"])
def test_entry_fires_once_until_track_exits_and_reenters(self) -> None:
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
events, _ = self.evaluator.evaluate(2, self.now, self.zone, [self.detection("P-1", 0.6)])
repeated, _ = self.evaluator.evaluate(3, self.now, self.zone, [self.detection("P-1", 0.7)])
self.evaluator.evaluate(4, self.now, self.zone, [self.detection("P-1", 0.3)])
reentered, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.6)])
self.assertEqual([item.source_event_id for item in events], ["BRN-0001"])
self.assertEqual(repeated, [])
self.assertEqual([item.source_event_id for item in reentered], ["BRN-0002"])
payload = events[0].as_dict()
self.assertNotIn("id", payload)
self.assertEqual(payload["confidence"], None)
self.assertTrue(payload["fixture"])
def test_expired_track_reappearing_inside_is_not_an_entry(self) -> None:
self.evaluator.evaluate(1, self.now, self.zone, [self.detection("P-1", 0.3)])
self.evaluator.evaluate(4, self.now, self.zone, [])
events, _ = self.evaluator.evaluate(5, self.now, self.zone, [self.detection("P-1", 0.7)])
self.assertEqual(events, [])
if __name__ == "__main__":
unittest.main()
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import unittest
from Brain.yovision_brain.runtime import DemoEngine
from Brain.yovision_brain.source import SyntheticSource, cv2
@unittest.skipIf(cv2 is None, "pinned OpenCV package is not installed")
class RuntimeTests(unittest.TestCase):
def test_rejects_non_finite_fps(self) -> None:
with self.assertRaisesRegex(ValueError, "fps"):
DemoEngine(SyntheticSource(width=320, height=180), fps=float("nan"))
def test_synthetic_step_produces_safe_state_and_jpeg(self) -> None:
engine = DemoEngine(SyntheticSource(width=320, height=180), fps=2.0)
engine.step()
state = engine.state()
self.assertTrue(state["source"]["fixture"])
self.assertEqual(state["detector"]["name"], "scripted_fixture")
self.assertEqual(state["frame"]["width"], 320)
self.assertGreater(len(engine.frame_jpeg() or b""), 100)
self.assertNotIn("url", state["source"])
def test_zone_update_increments_version(self) -> None:
engine = DemoEngine(SyntheticSource(width=320, height=180))
updated = engine.update_zone({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
self.assertEqual(updated.version, 2)
self.assertEqual(engine.state()["zone"]["name"], "新区域")
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
import http.client
import json
import threading
import unittest
from Brain.yovision_brain.domain import Point, Zone, zone_from_payload
from Brain.yovision_brain.server import DemoHTTPServer, DemoHandler, index_path, parse_bind
class FakeEngine:
def __init__(self) -> None:
self.zone = Zone("zone-demo-01", "测试区域", 1, (Point(0.1, 0.1), Point(0.9, 0.1), Point(0.5, 0.9)))
def state(self) -> dict[str, object]:
return {"source": {"label": "safe", "connected": True}, "events": [], "zone": {"version": self.zone.version}}
def frame_jpeg(self) -> bytes:
return b"\xff\xd8safe-jpeg\xff\xd9"
def update_zone(self, payload: object) -> Zone:
self.zone = zone_from_payload(payload, self.zone)
return self.zone
class BindTests(unittest.TestCase):
def test_only_explicit_loopback_is_allowed(self) -> None:
self.assertEqual(parse_bind("127.0.0.1:8090"), ("127.0.0.1", 8090))
self.assertEqual(parse_bind("localhost:8090"), ("localhost", 8090))
with self.assertRaisesRegex(ValueError, "loopback"):
parse_bind("0.0.0.0:8090")
with self.assertRaisesRegex(ValueError, "loopback"):
parse_bind("192.168.1.10:8090")
class HTTPTests(unittest.TestCase):
def setUp(self) -> None:
html = index_path().read_text(encoding="utf-8")
self.server = DemoHTTPServer(("127.0.0.1", 0), FakeEngine(), "test-token", html)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.connection = http.client.HTTPConnection("127.0.0.1", self.server.server_port, timeout=2)
def tearDown(self) -> None:
self.connection.close()
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
def test_index_substitutes_token_and_sets_security_headers(self) -> None:
self.connection.request("GET", "/brain-demo")
response = self.connection.getresponse()
body = response.read().decode("utf-8")
self.assertEqual(response.status, 200)
self.assertIn('content="test-token"', body)
self.assertNotIn("__BRAIN_DEMO_TOKEN__", body)
self.assertEqual(response.getheader("X-Frame-Options"), "DENY")
self.assertIn("default-src 'self'", response.getheader("Content-Security-Policy"))
def test_zone_write_requires_token_and_rejects_unknown_fields(self) -> None:
body = json.dumps({"name": "新区域", "points": [{"x": 0.1, "y": 0.1}, {"x": 0.9, "y": 0.1}, {"x": 0.5, "y": 0.9}]})
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json"})
forbidden = self.connection.getresponse()
forbidden.read()
self.assertEqual(forbidden.status, 403)
self.connection.request("PUT", "/api/v1/zones/active", body=body, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
accepted = self.connection.getresponse()
payload = json.loads(accepted.read())
self.assertEqual(accepted.status, 200)
self.assertEqual(payload["version"], 2)
invalid = json.dumps({"name": "bad", "points": [], "tenant_id": "leak"})
self.connection.request("PUT", "/api/v1/zones/active", body=invalid, headers={"Content-Type": "application/json", "X-Brain-Demo-Token": "test-token"})
rejected = self.connection.getresponse()
self.assertEqual(rejected.status, 400)
self.assertEqual(json.loads(rejected.read())["code"], "invalid_zone")
if __name__ == "__main__":
unittest.main()
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from Brain.yovision_brain.domain import Box
from Brain.yovision_brain.source import CentroidTracker, read_stream_url
class StreamURLTests(unittest.TestCase):
def test_requires_absolute_single_rtsp_url_file(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "stream.url"
path.write_text("rtsp://user:secret@127.0.0.1:8554/camera\n", encoding="utf-8")
self.assertEqual(read_stream_url(str(path)), "rtsp://user:secret@127.0.0.1:8554/camera")
path.write_text("rtsp://127.0.0.1/a\nrtsp://127.0.0.1/b\n", encoding="utf-8")
with self.assertRaisesRegex(ValueError, "exactly one") as caught:
read_stream_url(str(path))
self.assertNotIn("127.0.0.1", str(caught.exception))
def test_rejects_relative_path_without_echoing_input(self) -> None:
with self.assertRaisesRegex(ValueError, "absolute"):
read_stream_url("camera-secret.url")
def test_rejects_url_files_inside_repository(self) -> None:
repository_file = Path(__file__).resolve()
with self.assertRaisesRegex(ValueError, "outside the repository"):
read_stream_url(str(repository_file))
class TrackerTests(unittest.TestCase):
def test_nearby_boxes_retain_track_and_distant_box_gets_new_track(self) -> None:
tracker = CentroidTracker(max_distance=0.2)
first = tracker.update([(Box(0.1, 0.1, 0.2, 0.4), 0.8)], 1)
nearby = tracker.update([(Box(0.12, 0.1, 0.22, 0.4), 0.7)], 2)
distant = tracker.update([(Box(0.7, 0.1, 0.8, 0.4), 0.9)], 3)
self.assertEqual(first[0].track_id, nearby[0].track_id)
self.assertNotEqual(nearby[0].track_id, distant[0].track_id)
if __name__ == "__main__":
unittest.main()
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
HTML_PATH = Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
class UIContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.html = HTML_PATH.read_text(encoding="utf-8")
def test_prototype_is_self_contained_and_labels_fixture_truthfully(self) -> None:
self.assertIn("合成回放不等于模型效果", self.html)
self.assertIn("离线 HTML 原型数据", self.html)
self.assertNotRegex(self.html, r'(?:src|href)=["\']https?://')
self.assertNotIn("@import url", self.html)
def test_accessibility_and_responsive_guards_are_present(self) -> None:
required = (
'name="viewport"',
'class="skip-link"',
'aria-live="polite"',
':focus-visible',
'prefers-reduced-motion',
'min-height: 44px',
'@media (max-width: 420px)',
'.toolbar { display: grid; grid-template-columns: 1fr; }',
'键盘用户可使用右侧坐标表单',
)
for marker in required:
with self.subTest(marker=marker):
self.assertIn(marker, self.html)
def test_no_structural_emoji_or_unescaped_secret_placeholder_in_text(self) -> None:
visible_without_script = re.sub(r"<script[\s\S]*?</script>", "", self.html)
self.assertNotRegex(visible_without_script, r"[\U0001F300-\U0001FAFF]")
self.assertEqual(self.html.count("__BRAIN_DEMO_TOKEN__"), 1)
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -0,0 +1,7 @@
"""Single-stream Brain engineering prototype.
This package intentionally exposes no Brain-to-Bell transport. T-018 owns
that contract and its delivery semantics.
"""
__version__ = "0.1.0"
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
import argparse
import sys
from .runtime import DemoEngine
from .server import parse_bind, serve
from .source import StreamSource, SyntheticSource, read_stream_url, require_opencv
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="YoVision Brain single-stream engineering prototype")
parser.add_argument("--source", choices=("synthetic", "stream"), default="synthetic")
parser.add_argument("--stream-url-file", help="absolute external file containing one RTSP URL")
parser.add_argument("--bind", default="127.0.0.1:8090", help="loopback bind address")
parser.add_argument("--fps", type=float, default=2.0, help="prototype processing FPS (0, 30]")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
try:
require_opencv()
parse_bind(args.bind)
if args.source == "stream":
if not args.stream_url_file:
raise ValueError("--stream-url-file is required for stream mode")
source = StreamSource(read_stream_url(args.stream_url_file))
else:
if args.stream_url_file:
raise ValueError("--stream-url-file is only valid for stream mode")
source = SyntheticSource()
engine = DemoEngine(source, fps=args.fps)
except (RuntimeError, ValueError) as exc:
print(f"Brain demo configuration error: {exc}", file=sys.stderr)
return 2
print(f"Brain demo listening on http://{args.bind}/brain-demo")
print("Engineering prototype only; synthetic replay and HOG output are not production model evidence.")
try:
serve(engine, args.bind)
except KeyboardInterrupt:
return 0
return 0
if __name__ == "__main__":
raise SystemExit(main())
+217
View File
@@ -0,0 +1,217 @@
from __future__ import annotations
import math
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable, Iterable, Sequence
from uuid import uuid4
@dataclass(frozen=True)
class Point:
x: float
y: float
def __post_init__(self) -> None:
if not all(math.isfinite(value) for value in (self.x, self.y)) or not (
0.0 <= self.x <= 1.0 and 0.0 <= self.y <= 1.0
):
raise ValueError("point coordinates must be within [0, 1]")
@dataclass(frozen=True)
class Box:
x1: float
y1: float
x2: float
y2: float
def __post_init__(self) -> None:
values = (self.x1, self.y1, self.x2, self.y2)
if not all(math.isfinite(value) for value in values) or any(value < 0.0 or value > 1.0 for value in values):
raise ValueError("box coordinates must be within [0, 1]")
if self.x1 >= self.x2 or self.y1 >= self.y2:
raise ValueError("box must have positive area")
@property
def center(self) -> Point:
return Point((self.x1 + self.x2) / 2.0, (self.y1 + self.y2) / 2.0)
@dataclass(frozen=True)
class Detection:
track_id: str
class_name: str
box: Box
detector_score: float | None = None
def __post_init__(self) -> None:
if not self.track_id or len(self.track_id) > 128:
raise ValueError("track_id must contain 1 to 128 characters")
if self.class_name != "person":
raise ValueError("T-017 only supports anonymous person detections")
if self.detector_score is not None and not math.isfinite(self.detector_score):
raise ValueError("detector_score must be finite")
@dataclass(frozen=True)
class Zone:
zone_id: str
name: str
version: int
points: tuple[Point, ...]
def __post_init__(self) -> None:
if not self.zone_id or len(self.zone_id) > 128:
raise ValueError("zone_id must contain 1 to 128 characters")
if not self.name.strip() or len(self.name) > 80:
raise ValueError("zone name must contain 1 to 80 characters")
if self.version < 1:
raise ValueError("zone version must be positive")
if not 3 <= len(self.points) <= 32:
raise ValueError("zone must contain 3 to 32 points")
@dataclass(frozen=True)
class EventCandidate:
source_event_id: str
kind: str
source_ref: str
track_id: str
zone_id: str
zone_version: int
occurred_at: str
confidence: None
fixture: bool
def as_dict(self) -> dict[str, object]:
# Bell owns the platform `id`; it is deliberately absent here.
return {
"candidate_version": "brain-demo-v1",
"source_event_id": self.source_event_id,
"kind": self.kind,
"source_ref": self.source_ref,
"track_id": self.track_id,
"zone_id": self.zone_id,
"zone_version": self.zone_version,
"occurred_at": self.occurred_at,
"confidence": self.confidence,
"fixture": self.fixture,
}
def _on_segment(point: Point, start: Point, end: Point, epsilon: float = 1e-9) -> bool:
cross = (point.y - start.y) * (end.x - start.x) - (point.x - start.x) * (end.y - start.y)
if abs(cross) > epsilon:
return False
return (
min(start.x, end.x) - epsilon <= point.x <= max(start.x, end.x) + epsilon
and min(start.y, end.y) - epsilon <= point.y <= max(start.y, end.y) + epsilon
)
def point_in_polygon(point: Point, polygon: Sequence[Point]) -> bool:
if len(polygon) < 3:
return False
inside = False
previous = polygon[-1]
for current in polygon:
if _on_segment(point, previous, current):
return True
crosses = (current.y > point.y) != (previous.y > point.y)
if crosses:
boundary_x = (previous.x - current.x) * (point.y - current.y) / (previous.y - current.y) + current.x
if point.x < boundary_x:
inside = not inside
previous = current
return inside
class ZoneEntryEvaluator:
def __init__(
self,
source_ref: str,
fixture: bool,
track_ttl_frames: int = 8,
event_id_factory: Callable[[], str] | None = None,
) -> None:
if track_ttl_frames < 1:
raise ValueError("track_ttl_frames must be positive")
self._source_ref = source_ref
self._fixture = fixture
self._track_ttl_frames = track_ttl_frames
self._event_id_factory = event_id_factory or (lambda: f"BRN-{uuid4().hex}")
self._inside: dict[str, bool] = {}
self._last_seen: dict[str, int] = {}
def reset(self) -> None:
self._inside.clear()
self._last_seen.clear()
def evaluate(
self,
sequence: int,
occurred_at: datetime,
zone: Zone,
detections: Iterable[Detection],
) -> tuple[list[EventCandidate], dict[str, bool]]:
if sequence < 0:
raise ValueError("sequence cannot be negative")
if occurred_at.tzinfo is None:
raise ValueError("occurred_at must be timezone-aware")
expired = [
track_id
for track_id, last_seen in self._last_seen.items()
if sequence - last_seen > self._track_ttl_frames
]
for track_id in expired:
self._inside.pop(track_id, None)
self._last_seen.pop(track_id, None)
events: list[EventCandidate] = []
states: dict[str, bool] = {}
for detection in detections:
inside = point_in_polygon(detection.box.center, zone.points)
previous = self._inside.get(detection.track_id)
if previous is False and inside:
events.append(
EventCandidate(
source_event_id=self._event_id_factory(),
kind="zone_entry",
source_ref=self._source_ref,
track_id=detection.track_id,
zone_id=zone.zone_id,
zone_version=zone.version,
occurred_at=occurred_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"),
confidence=None,
fixture=self._fixture,
)
)
self._inside[detection.track_id] = inside
self._last_seen[detection.track_id] = sequence
states[detection.track_id] = inside
return events, states
def zone_from_payload(payload: object, current: Zone) -> Zone:
if not isinstance(payload, dict):
raise ValueError("request body must be an object")
if set(payload) - {"name", "points"}:
raise ValueError("unknown zone fields are not allowed")
name = payload.get("name", current.name)
raw_points = payload.get("points")
if not isinstance(name, str):
raise ValueError("zone name must be a string")
if not isinstance(raw_points, list):
raise ValueError("zone points must be an array")
points: list[Point] = []
for raw_point in raw_points:
if not isinstance(raw_point, dict) or set(raw_point) != {"x", "y"}:
raise ValueError("each point must contain only x and y")
x = raw_point["x"]
y = raw_point["y"]
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
raise ValueError("point coordinates must be numbers")
points.append(Point(float(x), float(y)))
return Zone(current.zone_id, name.strip(), current.version + 1, tuple(points))
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
import math
import threading
import time
from collections import deque
from datetime import datetime, timezone
from typing import Any
from .domain import Detection, Point, Zone, ZoneEntryEvaluator, zone_from_payload
from .source import CentroidTracker, HOGPersonDetector, require_opencv
try:
import cv2 # type: ignore
except ImportError: # pragma: no cover
cv2 = None
DEFAULT_ZONE = Zone(
"zone-demo-01",
"楼梯口危险区",
1,
(Point(0.55, 0.30), Point(0.90, 0.30), Point(0.90, 0.88), Point(0.55, 0.88)),
)
class DemoEngine:
def __init__(self, source: Any, fps: float = 2.0, event_limit: int = 100) -> None:
if not math.isfinite(fps) or fps <= 0.0 or fps > 30.0:
raise ValueError("fps must be within (0, 30]")
if not 1 <= event_limit <= 100:
raise ValueError("event_limit must be within [1, 100]")
require_opencv()
self._source = source
self._fps = fps
self._detector = None if source.fixture else HOGPersonDetector()
self._tracker = CentroidTracker()
self._evaluator = ZoneEntryEvaluator(source.source_ref, source.fixture)
self._zone = DEFAULT_ZONE
self._events: deque[dict[str, object]] = deque(maxlen=event_limit)
self._lock = threading.RLock()
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._sequence = 0
self._frame_jpeg: bytes | None = None
self._frame_width = 0
self._frame_height = 0
self._captured_at: str | None = None
self._detections: list[dict[str, object]] = []
self._latency_ms: float | None = None
self._connected = False
self._last_error_code: str | None = None
def start(self) -> None:
if self._thread is not None:
return
self._thread = threading.Thread(target=self._run, name="brain-demo", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=3.0)
self._thread = None
self._source.close()
def _run(self) -> None:
interval = 1.0 / self._fps
while not self._stop.is_set():
started = time.perf_counter()
try:
self.step()
except RuntimeError:
with self._lock:
self._connected = False
self._last_error_code = "source_unavailable"
elapsed = time.perf_counter() - started
self._stop.wait(max(0.0, interval - elapsed))
def step(self) -> None:
started = time.perf_counter()
packet = self._source.read()
self._sequence += 1
if packet.scripted_detections is not None:
detections = list(packet.scripted_detections)
else:
raw_boxes = self._detector.detect(packet.frame) if self._detector is not None else []
detections = self._tracker.update(raw_boxes, self._sequence)
with self._lock:
zone = self._zone
new_events, inside_by_track = self._evaluator.evaluate(self._sequence, packet.captured_at, zone, detections)
ok, encoded = cv2.imencode(".jpg", packet.frame, [int(cv2.IMWRITE_JPEG_QUALITY), 82])
if not ok:
raise RuntimeError("frame encoding failed")
height, width = packet.frame.shape[:2]
serialized_detections = [self._serialize_detection(item, inside_by_track.get(item.track_id, False)) for item in detections]
with self._lock:
for event in new_events:
self._events.appendleft(event.as_dict())
self._frame_jpeg = encoded.tobytes()
self._frame_width = int(width)
self._frame_height = int(height)
self._captured_at = packet.captured_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
self._detections = serialized_detections
self._latency_ms = round((time.perf_counter() - started) * 1000.0, 1)
self._connected = True
self._last_error_code = None
@staticmethod
def _serialize_detection(detection: Detection, inside: bool) -> dict[str, object]:
return {
"track_id": detection.track_id,
"class": detection.class_name,
"bbox": [detection.box.x1, detection.box.y1, detection.box.x2, detection.box.y2],
"detector_score": detection.detector_score,
"inside_zone": inside,
}
def update_zone(self, payload: object) -> Zone:
with self._lock:
updated = zone_from_payload(payload, self._zone)
self._zone = updated
self._evaluator.reset()
return updated
def frame_jpeg(self) -> bytes | None:
with self._lock:
return self._frame_jpeg
def state(self) -> dict[str, object]:
with self._lock:
zone = self._zone
return {
"prototype": True,
"notice": "工程原型;合成回放不是模型输出,HOG 适配器不是生产检测模型。",
"source": {
"mode": self._source.mode,
"label": self._source.label,
"fixture": self._source.fixture,
"connected": self._connected,
"ref": self._source.source_ref,
},
"detector": {
"name": "scripted_fixture" if self._source.fixture else self._detector.name,
"production_ready": False,
},
"frame": {
"sequence": self._sequence,
"width": self._frame_width,
"height": self._frame_height,
"captured_at": self._captured_at,
},
"inference": {"target_fps": self._fps, "latency_ms": self._latency_ms},
"zone": {
"id": zone.zone_id,
"name": zone.name,
"version": zone.version,
"points": [{"x": point.x, "y": point.y} for point in zone.points],
},
"detections": list(self._detections),
"events": list(self._events),
"last_error_code": self._last_error_code,
"generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
}
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import hmac
import json
import secrets
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from .domain import Zone
MAX_BODY_BYTES = 64 * 1024
def parse_bind(value: str) -> tuple[str, int]:
parsed = urlsplit(f"//{value}")
host = parsed.hostname
try:
port = parsed.port
except ValueError as exc:
raise ValueError("invalid demo bind address") from exc
if host not in {"127.0.0.1", "localhost", "::1"} or port is None or not 1 <= port <= 65535:
raise ValueError("Brain demo must bind to an explicit loopback address and valid port")
return host, port
def index_path() -> Path:
return Path(__file__).resolve().parents[2] / "docs" / "design" / "brain" / "index.html"
class DemoHTTPServer(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, address: tuple[str, int], engine: Any, token: str, html: str) -> None:
self.engine = engine
self.demo_token = token
self.html = html
super().__init__(address, DemoHandler)
class DemoHandler(BaseHTTPRequestHandler):
server: DemoHTTPServer
def log_message(self, _format: str, *_args: object) -> None:
# Do not echo request paths or headers; URL credentials never reach HTTP paths.
return
def _security_headers(self) -> None:
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Cache-Control", "no-store")
self.send_header(
"Content-Security-Policy",
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; "
"img-src 'self' data: blob:; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'",
)
def _send_bytes(self, status: int, content_type: str, body: bytes) -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self._security_headers()
self.end_headers()
self.wfile.write(body)
def _send_json(self, status: int, payload: object) -> None:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
self._send_bytes(status, "application/json; charset=utf-8", body)
def do_GET(self) -> None: # noqa: N802
path = urlsplit(self.path).path
if path in {"/", "/brain-demo"}:
body = self.server.html.replace("__BRAIN_DEMO_TOKEN__", self.server.demo_token).encode("utf-8")
self._send_bytes(HTTPStatus.OK, "text/html; charset=utf-8", body)
return
if path == "/healthz":
self._send_json(HTTPStatus.OK, {"status": "ok"})
return
if path == "/api/v1/state":
self._send_json(HTTPStatus.OK, self.server.engine.state())
return
if path == "/api/v1/frame.jpg":
frame = self.server.engine.frame_jpeg()
if frame is None:
self._send_json(HTTPStatus.SERVICE_UNAVAILABLE, {"code": "frame_not_ready"})
return
self._send_bytes(HTTPStatus.OK, "image/jpeg", frame)
return
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
def do_PUT(self) -> None: # noqa: N802
if urlsplit(self.path).path != "/api/v1/zones/active":
self._send_json(HTTPStatus.NOT_FOUND, {"code": "not_found"})
return
raw_length = self.headers.get("Content-Length")
try:
length = int(raw_length or "-1")
except ValueError:
length = -1
if length < 0 or length > MAX_BODY_BYTES:
self._send_json(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, {"code": "body_too_large"})
return
body = self.rfile.read(length)
supplied = self.headers.get("X-Brain-Demo-Token", "")
if not hmac.compare_digest(supplied, self.server.demo_token):
self._send_json(HTTPStatus.FORBIDDEN, {"code": "forbidden"})
return
try:
payload = json.loads(body.decode("utf-8"))
zone: Zone = self.server.engine.update_zone(payload)
except (UnicodeError, json.JSONDecodeError, ValueError):
self._send_json(HTTPStatus.BAD_REQUEST, {"code": "invalid_zone"})
return
self._send_json(
HTTPStatus.OK,
{
"id": zone.zone_id,
"name": zone.name,
"version": zone.version,
"points": [{"x": point.x, "y": point.y} for point in zone.points],
},
)
def build_server(engine: Any, bind: str = "127.0.0.1:8090", token: str | None = None) -> DemoHTTPServer:
html = index_path().read_text(encoding="utf-8")
return DemoHTTPServer(parse_bind(bind), engine, token or secrets.token_urlsafe(32), html)
def serve(engine: Any, bind: str) -> None:
server = build_server(engine, bind)
engine.start()
try:
server.serve_forever(poll_interval=0.25)
finally:
server.server_close()
engine.stop()
+201
View File
@@ -0,0 +1,201 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from urllib.parse import urlsplit
from .domain import Box, Detection
try:
import cv2 # type: ignore
import numpy as np # type: ignore
except ImportError: # pragma: no cover - exercised by the startup failure path
cv2 = None
np = None
@dataclass(frozen=True)
class FramePacket:
frame: Any
captured_at: datetime
scripted_detections: tuple[Detection, ...] | None
def require_opencv() -> None:
if cv2 is None or np is None:
raise RuntimeError("Brain demo requires the pinned NumPy and OpenCV packages")
def read_stream_url(path_value: str) -> str:
path = Path(path_value)
if not path.is_absolute():
raise ValueError("stream URL file must be an absolute external path")
try:
resolved = path.resolve(strict=True)
except OSError as exc:
raise ValueError("stream URL file does not exist") from exc
if not resolved.is_file():
raise ValueError("stream URL file does not exist")
repository_root = Path(__file__).resolve().parents[2]
try:
resolved.relative_to(repository_root)
except ValueError:
pass
else:
raise ValueError("stream URL file must be outside the repository")
try:
with resolved.open("rb") as stream:
raw = stream.read(4097)
except OSError as exc:
raise ValueError("stream URL file cannot be read") from exc
if len(raw) > 4096:
raise ValueError("stream URL file exceeds 4096 bytes")
try:
lines = raw.decode("utf-8").splitlines()
except UnicodeError as exc:
raise ValueError("stream URL file must be UTF-8") from exc
values = [line.strip() for line in lines if line.strip()]
if len(values) != 1:
raise ValueError("stream URL file must contain exactly one non-empty line")
parsed = urlsplit(values[0])
if parsed.scheme not in {"rtsp", "rtsps"} or not parsed.hostname:
raise ValueError("stream URL must be an RTSP URL")
return values[0]
class SyntheticSource:
mode = "synthetic"
label = "合成回放"
fixture = True
source_ref = "demo-camera-01"
def __init__(self, width: int = 960, height: int = 540) -> None:
require_opencv()
self.width = width
self.height = height
self._sequence = 0
def read(self) -> FramePacket:
self._sequence += 1
frame = np.zeros((self.height, self.width, 3), dtype=np.uint8)
frame[:] = (20, 28, 42)
cv2.rectangle(frame, (0, int(self.height * 0.72)), (self.width, self.height), (31, 42, 58), -1)
for x in range(0, self.width, 80):
cv2.line(frame, (x, int(self.height * 0.72)), (x + 80, self.height), (43, 57, 75), 1)
cv2.putText(frame, "SYNTHETIC FIXTURE - NOT MODEL OUTPUT", (24, 38), cv2.FONT_HERSHEY_SIMPLEX, 0.72, (82, 190, 245), 2)
phase = ((self._sequence - 1) % 160) / 159.0
center_x = -0.04 + phase * 1.08
x1 = max(0.0, center_x - 0.04)
x2 = min(1.0, center_x + 0.04)
detections: tuple[Detection, ...] = ()
if x2 - x1 > 0.01:
box = Box(x1, 0.34, x2, 0.82)
detections = (Detection("P-DEMO-001", "person", box, None),)
px = int(center_x * self.width)
head_y = int(self.height * 0.40)
cv2.circle(frame, (px, head_y), 17, (195, 210, 225), -1)
cv2.line(frame, (px, head_y + 18), (px, int(self.height * 0.65)), (195, 210, 225), 12)
cv2.line(frame, (px, int(self.height * 0.52)), (px - 30, int(self.height * 0.60)), (195, 210, 225), 8)
cv2.line(frame, (px, int(self.height * 0.52)), (px + 30, int(self.height * 0.60)), (195, 210, 225), 8)
cv2.line(frame, (px, int(self.height * 0.65)), (px - 24, int(self.height * 0.79)), (195, 210, 225), 9)
cv2.line(frame, (px, int(self.height * 0.65)), (px + 24, int(self.height * 0.79)), (195, 210, 225), 9)
return FramePacket(frame, datetime.now(timezone.utc), detections)
def close(self) -> None:
return
class StreamSource:
mode = "stream"
label = "外部 MediaMTX / RTSP"
fixture = False
source_ref = "configured-video-source"
def __init__(self, stream_url: str) -> None:
require_opencv()
self._stream_url = stream_url
self._capture: Any = None
def _open(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = cv2.VideoCapture(self._stream_url)
self._capture.set(cv2.CAP_PROP_BUFFERSIZE, 1)
def read(self) -> FramePacket:
if self._capture is None or not self._capture.isOpened():
self._open()
ok, frame = self._capture.read()
if not ok or frame is None:
self._open()
raise RuntimeError("stream frame unavailable")
return FramePacket(frame, datetime.now(timezone.utc), None)
def close(self) -> None:
if self._capture is not None:
self._capture.release()
self._capture = None
class HOGPersonDetector:
name = "opencv_hog_person_demo"
production_ready = False
def __init__(self) -> None:
require_opencv()
self._hog = cv2.HOGDescriptor()
self._hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
def detect(self, frame: Any) -> list[tuple[Box, float]]:
height, width = frame.shape[:2]
scale = min(1.0, 960.0 / max(width, 1))
working = frame if scale == 1.0 else cv2.resize(frame, (int(width * scale), int(height * scale)))
boxes, weights = self._hog.detectMultiScale(working, winStride=(8, 8), padding=(8, 8), scale=1.05)
result: list[tuple[Box, float]] = []
work_height, work_width = working.shape[:2]
for raw_box, weight in zip(boxes, weights):
x, y, box_width, box_height = (int(value) for value in raw_box)
x1 = max(0.0, min(1.0, x / work_width))
y1 = max(0.0, min(1.0, y / work_height))
x2 = max(0.0, min(1.0, (x + box_width) / work_width))
y2 = max(0.0, min(1.0, (y + box_height) / work_height))
if x2 > x1 and y2 > y1:
result.append((Box(x1, y1, x2, y2), float(weight)))
return result
class CentroidTracker:
def __init__(self, max_distance: float = 0.18, ttl_frames: int = 8) -> None:
self._max_distance = max_distance
self._ttl_frames = ttl_frames
self._next_id = 1
self._tracks: dict[str, tuple[Box, int]] = {}
def update(self, boxes: Iterable[tuple[Box, float]], sequence: int) -> list[Detection]:
incoming = list(boxes)
available = set(self._tracks)
detections: list[Detection] = []
for box, score in incoming:
center = box.center
selected: str | None = None
selected_distance = self._max_distance
for track_id in available:
old_center = self._tracks[track_id][0].center
distance = ((center.x - old_center.x) ** 2 + (center.y - old_center.y) ** 2) ** 0.5
if distance < selected_distance:
selected = track_id
selected_distance = distance
if selected is None:
selected = f"P-{self._next_id:04d}"
self._next_id += 1
else:
available.remove(selected)
self._tracks[selected] = (box, sequence)
detections.append(Detection(selected, "person", box, score))
for track_id, (_, last_seen) in list(self._tracks.items()):
if sequence - last_seen > self._ttl_frames:
del self._tracks[track_id]
return detections
+18 -2
View File
@@ -1,6 +1,6 @@
# Sense M1/M2 接入骨架
本目录是 YoVision Sense 的 M1/M2 接入骨架。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。默认关闭真实 ONVIF、公共业务路由和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
本目录是 YoVision Sense 的接入与 NVR 管理面。数据库保存期望态,ONVIF 和 MediaMTX 通过端口隔离;M1 默认使用 SQLite,T-009~T-016 增加 PostgreSQL 双 schema、Area 准入、本地审计 Outbox、Control API v1、多实例调和 fencing、孤儿受控处置和到 Bell 的审计 relay。T-018 增加默认关闭的回环工程控制台,用于展示设备、配额、收敛状态和最多 4 路按需 MediaMTX WebRTC 预览;它不包含录像/回放,也不是生产公网入口。默认关闭真实 ONVIF、公共业务路由、控制台和 relay;T-006 的真实样机结论仅覆盖已批准的精确海康基线,不能据此宣称多品牌兼容。
## 常用命令
@@ -14,7 +14,7 @@ go build -o bin/sense-api.exe ./cmd/sense-api
go run ./cmd/sense-api
```
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由。
Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`,SQLite 默认写入 `Sense/data/sense.db`,MediaMTX 控制 API 默认是 `http://127.0.0.1:9997`。默认运行暴露 `/healthz`、`/readyz` 和不含租户/设备标签的 `/metrics`;只有显式选择 PostgreSQL 并完成安全配置后才注册 7 个 `/api/v1` Control API 路由,`/sense-console/` 还需独立显式开启。
常用环境变量:
@@ -42,6 +42,8 @@ Unix 将构建产物改为 `bin/sense-api`。服务默认监听 `127.0.0.1:8080`
| `SENSE_CONTROL_AUTH_FILE` | 空 | 仓库外绝对路径;version 1 JSON 只保存 token SHA-256、主体、tenant、Site scope 和权限 |
| `SENSE_CONTROL_CURSOR_KEY_FILE` | 空 | 仓库外绝对路径;内容为至少 32 字节随机值的无填充 base64url |
| `SENSE_CONTROL_ALLOW_INSECURE_HTTP` | `false` | Control API 非回环明文监听的独立风险接受;正常部署应保持回环并在受控代理终止 TLS |
| `SENSE_CONSOLE_ENABLED` | `false` | 显式开启 `/sense-console/`;T-018 只允许与回环 Control API 一起使用 |
| `SENSE_CONSOLE_WEBRTC_BASE_URL` | `http://127.0.0.1:8889` | MediaMTX WebRTC 浏览器入口基地址;必须为无 userinfo/path/query/fragment 的显式回环 HTTP(S) URL |
| `SENSE_AUDIT_RELAY_ENABLED` | `false` | 显式开启 PostgreSQL Outbox → Bell relay;SQLite 不支持 |
| `SENSE_AUDIT_RELAY_URL` | 空 | 精确指向 Bell `/internal/v1/audit-events:batch`;非回环必须 HTTPS |
| `SENSE_AUDIT_RELAY_KEY_FILE` | 空 | 仓库外绝对路径 version 1 JSON key 文件,secret 至少 32 字节 |
@@ -122,6 +124,20 @@ go run ./cmd/sense-api
业务响应使用 `Cache-Control: no-store`;ETag 是写并发令牌,cursor 与认证 tenant/Site/筛选绑定。静态摘要文件只是首版私有部署适配器;公网/TLS、Bell 会话、JWT/OIDC 与热加载需后续任务,不能靠设置 `SENSE_CONTROL_ALLOW_INSECURE_HTTP=true` 冒充完成。
### 开启 T-018 回环控制台
先按上一节完成 PostgreSQL、Control API、仓库外 auth/cursor 文件和 MediaMTX 启动,再增加:
```powershell
$env:SENSE_CONSOLE_ENABLED = 'true'
$env:SENSE_CONSOLE_WEBRTC_BASE_URL = 'http://127.0.0.1:8889'
go run ./cmd/sense-api
```
浏览器打开 `http://127.0.0.1:8080/sense-console/`,输入已授权的 Site ID 和原始 Bearer token。token 只保留在当前页面 JavaScript 内存,输入框随即清空,刷新页面后必须重新输入;不得把 token 放进 URL、截图、命令历史或文档。设备列表默认每页 16 项,只有 `video_capture + enabled + online + converged` 的设备可选择,最多同时启动 4 路预览。
播放使用 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,Sense 不代理媒体字节,也不复制播放器源码。浏览器是否能解码取决于摄像头编码;首选已验收的低码率 H.264 子码流,H.265 或带 B-frame 的 H.264 不能因“Path 在线”就宣称浏览器可播放。本版 Sense 和播放端都必须显式回环;非回环 HTTPS、正式会话认证和 MediaMTX 外部鉴权需另立任务。控制台不会实现或暗示常态录像、录像计划和回放。
Windows 本地准备 MediaMTX(从仓库根目录执行):
```powershell
+13
View File
@@ -15,6 +15,7 @@ import (
"yovision/sense/internal/auditrelay"
"yovision/sense/internal/auth"
"yovision/sense/internal/config"
"yovision/sense/internal/console"
"yovision/sense/internal/controlapi"
"yovision/sense/internal/metrics"
"yovision/sense/internal/mtx"
@@ -160,6 +161,17 @@ func run(logger *slog.Logger) error {
if cfg.ControlAPIEnabled {
mux.Handle("/api/v1/", controlHandler)
}
if cfg.ConsoleEnabled {
consoleHandler, err := console.NewHandler(cfg.ConsoleWebRTCBaseURL)
if err != nil {
return err
}
mux.Handle("/sense-console/", consoleHandler)
mux.HandleFunc("GET /sense-console", func(writer http.ResponseWriter, request *http.Request) {
writer.Header().Set("Cache-Control", "no-store")
http.Redirect(writer, request, "/sense-console/", http.StatusTemporaryRedirect)
})
}
server := &http.Server{
Addr: cfg.HTTPAddress, Handler: mux,
@@ -173,6 +185,7 @@ func run(logger *slog.Logger) error {
logger.Info("Sense listening", "address", cfg.HTTPAddress, "version", version,
"instance_id", instanceID,
"control_api_enabled", cfg.ControlAPIEnabled,
"console_enabled", cfg.ConsoleEnabled,
"audit_relay_enabled", cfg.AuditRelayEnabled)
serverErrors <- server.ListenAndServe()
}()
+29
View File
@@ -27,6 +27,7 @@ const (
defaultONVIFMode = "disabled"
defaultControlAuthMode = "static-sha256"
defaultAuditRelayPeriod = time.Second
defaultConsoleWebRTCURL = "http://127.0.0.1:8889"
)
var instanceIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
@@ -54,6 +55,8 @@ type Config struct {
ControlAuthFile string
ControlCursorKeyFile string
ControlAllowInsecureHTTP bool
ConsoleEnabled bool
ConsoleWebRTCBaseURL string
AuditRelayEnabled bool
AuditRelayURL string
AuditRelayKeyFile string
@@ -98,6 +101,10 @@ func Load() (Config, error) {
if err != nil {
return Config{}, err
}
consoleEnabled, err := boolEnv("SENSE_CONSOLE_ENABLED", false)
if err != nil {
return Config{}, err
}
auditRelayEnabled, err := boolEnv("SENSE_AUDIT_RELAY_ENABLED", false)
if err != nil {
return Config{}, err
@@ -144,6 +151,8 @@ func Load() (Config, error) {
ControlAuthFile: stringEnv("SENSE_CONTROL_AUTH_FILE", ""),
ControlCursorKeyFile: stringEnv("SENSE_CONTROL_CURSOR_KEY_FILE", ""),
ControlAllowInsecureHTTP: controlAllowInsecure,
ConsoleEnabled: consoleEnabled,
ConsoleWebRTCBaseURL: stringEnv("SENSE_CONSOLE_WEBRTC_BASE_URL", defaultConsoleWebRTCURL),
AuditRelayEnabled: auditRelayEnabled,
AuditRelayURL: stringEnv("SENSE_AUDIT_RELAY_URL", ""),
AuditRelayKeyFile: stringEnv("SENSE_AUDIT_RELAY_KEY_FILE", ""),
@@ -247,6 +256,26 @@ func (c Config) Validate() error {
return fmt.Errorf("non-loopback Control API requires SENSE_CONTROL_ALLOW_INSECURE_HTTP=true")
}
}
if c.ConsoleEnabled {
if !c.ControlAPIEnabled {
return fmt.Errorf("Sense console requires SENSE_CONTROL_API_ENABLED=true")
}
if !isLoopback {
return fmt.Errorf("Sense console requires an explicit loopback SENSE_HTTP_ADDR")
}
previewURL, err := url.Parse(c.ConsoleWebRTCBaseURL)
if err != nil || previewURL.Host == "" ||
(previewURL.Scheme != "http" && previewURL.Scheme != "https") ||
previewURL.User != nil || previewURL.RawQuery != "" || previewURL.Fragment != "" ||
(previewURL.Path != "" && previewURL.Path != "/") {
return fmt.Errorf("invalid SENSE_CONSOLE_WEBRTC_BASE_URL")
}
previewHost := previewURL.Hostname()
previewIP := net.ParseIP(previewHost)
if previewHost != "localhost" && (previewIP == nil || !previewIP.IsLoopback()) {
return fmt.Errorf("SENSE_CONSOLE_WEBRTC_BASE_URL must use an explicit loopback host")
}
}
if c.AuditRelayEnabled {
if databaseDriver != postgresDatabaseDriver {
return fmt.Errorf("Sense audit relay requires SENSE_DB_DRIVER=postgres")
+36
View File
@@ -167,6 +167,42 @@ func TestValidateControlAPINonLoopbackNeedsSeparateRiskAcceptance(t *testing.T)
}
}
func TestValidateSenseConsoleSecurityBoundary(t *testing.T) {
base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
DatabaseDSN: "postgres://sense-runtime@127.0.0.1/yovision?sslmode=disable",
MediaMTXURL: "http://127.0.0.1:9997", ReconcileInterval: time.Second, ProbeInterval: time.Second,
ControlAPIEnabled: true, ControlAuthMode: "static-sha256",
ControlAuthFile: filepath.Join(t.TempDir(), "sense-auth.json"),
ControlCursorKeyFile: filepath.Join(t.TempDir(), "sense-cursor.key"),
ConsoleEnabled: true, ConsoleWebRTCBaseURL: "http://127.0.0.1:8889",
}
if err := base.Validate(); err != nil {
t.Fatalf("valid loopback console rejected: %v", err)
}
withoutControl := base
withoutControl.ControlAPIEnabled = false
if err := withoutControl.Validate(); err == nil {
t.Fatal("console without Control API was accepted")
}
remoteBind := base
remoteBind.HTTPAddress, remoteBind.AllowNonLoopback = "0.0.0.0:8080", true
remoteBind.ControlAllowInsecureHTTP = true
if err := remoteBind.Validate(); err == nil {
t.Fatal("console on non-loopback Sense listener was accepted")
}
for _, invalidURL := range []string{
"http://media.example:8889", "ftp://127.0.0.1:8889", "http://127.0.0.1:8889/path",
"http://127.0.0.1:8889?token=hidden", "http://user@127.0.0.1:8889",
} {
candidate := base
candidate.ConsoleWebRTCBaseURL = invalidURL
if err := candidate.Validate(); err == nil {
t.Fatalf("invalid console WebRTC URL was accepted: %s", invalidURL)
}
}
}
func TestValidateAuditRelaySecurityBoundary(t *testing.T) {
base := Config{
HTTPAddress: "127.0.0.1:8080", DatabaseDriver: "postgres",
+227
View File
@@ -0,0 +1,227 @@
:root {
color-scheme: dark;
--bg: #06111f;
--sidebar: #071522;
--surface: #0d2033;
--surface-2: #112a40;
--surface-3: #16344d;
--border: #294762;
--text: #f5f9fc;
--muted: #a8bbcb;
--accent: #55dcc7;
--accent-strong: #2bbca9;
--accent-ink: #03231f;
--info: #6fc6ff;
--success: #71e19c;
--warning: #ffd16a;
--danger: #ff8a95;
--focus: #8bdcff;
--shadow: 0 18px 48px rgba(0, 0, 0, .26);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif;
line-height: 1.5;
}
* { box-sizing: border-box; }
html { background: var(--bg); }
body { margin: 0; min-width: 320px; min-height: 100dvh; background: radial-gradient(circle at 75% -10%, #103150 0, transparent 32rem), var(--bg); color: var(--text); }
button, input, select { font: inherit; }
button, a, input, select { touch-action: manipulation; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: var(--info); }
[hidden] { display: none !important; }
:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.skip-link { position: fixed; left: 12px; top: -80px; z-index: 1000; padding: 10px 14px; border-radius: 8px; background: var(--text); color: var(--bg); font-weight: 700; }
.skip-link:focus { top: 8px; }
.engineering-banner { min-height: 30px; display: grid; place-items: center; padding: 4px 16px; background: #f5c95b; color: #1e1705; font-size: 12px; font-weight: 800; letter-spacing: .04em; text-align: center; }
.shell { display: grid; grid-template-columns: 240px minmax(0, 1fr); min-height: calc(100dvh - 30px); }
.sidebar { position: sticky; top: 0; height: calc(100dvh - 30px); display: flex; flex-direction: column; padding: 28px 14px 18px; border-right: 1px solid var(--border); background: color-mix(in srgb, var(--sidebar) 95%, transparent); }
.brand { display: flex; align-items: center; gap: 12px; padding: 8px 8px 28px; }
.brand strong, .brand small { display: block; }
.brand strong { font-size: 17px; }
.brand small { color: var(--muted); font-size: 12px; }
.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 12px; background: linear-gradient(135deg, var(--accent), #58a9ff); color: #06222a; font-weight: 900; box-shadow: 0 9px 24px rgba(85, 220, 199, .2); }
.primary-nav { display: grid; gap: 6px; }
.primary-nav button, .mobile-nav button { border: 0; cursor: pointer; }
.primary-nav button { min-height: 48px; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 8px; padding: 0 12px; border-radius: 12px; background: transparent; color: var(--muted); text-align: left; transition: background-color .18s ease, color .18s ease; }
.primary-nav button:hover { background: var(--surface); color: var(--text); }
.primary-nav button[aria-current="page"] { background: #123d3a; color: var(--text); box-shadow: inset 3px 0 var(--accent); }
.nav-count { min-width: 24px; padding: 2px 7px; border-radius: 999px; background: var(--surface-3); color: #d6e5ef; font-size: 11px; text-align: center; font-variant-numeric: tabular-nums; }
.sidebar-foot { margin-top: auto; display: flex; align-items: center; gap: 9px; padding: 13px; border: 1px solid var(--border); border-radius: 12px; color: var(--muted); font-size: 12px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #73879a; box-shadow: 0 0 0 4px rgba(115, 135, 154, .13); }
.sidebar-foot.connected .status-dot { background: var(--success); box-shadow: 0 0 0 4px rgba(113, 225, 156, .13); }
.workspace { min-width: 0; }
.topbar { min-height: 84px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px 28px; border-bottom: 1px solid var(--border); background: rgba(8, 24, 39, .78); backdrop-filter: blur(18px); }
.topbar strong, .topbar .eyebrow { display: block; }
.topbar strong { margin-top: 2px; }
.topbar-actions, .heading-actions { display: flex; align-items: center; gap: 10px; }
.eyebrow { color: var(--info); font-size: 11px; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }
.connection-pill { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 13px; }
.connection-pill > .connection-dot { width: 7px; height: 7px; border-radius: 50%; background: #718497; }
.connection-pill.connected { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); }
.connection-pill.connected > .connection-dot { background: var(--success); }
.degraded-banner { display: flex; align-items: center; gap: 12px; padding: 12px 28px; border-bottom: 1px solid rgba(255, 138, 149, .38); background: rgba(96, 26, 39, .82); color: #ffe7ea; }
.degraded-banner span { flex: 1; color: #ffc4ca; }
main { padding: 32px clamp(18px, 3vw, 42px) 80px; }
.view { max-width: 1440px; margin: 0 auto; }
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 22px; margin-bottom: 24px; }
h1, h2, p { margin-top: 0; }
h1 { margin-bottom: 4px; font-size: clamp(26px, 3vw, 36px); line-height: 1.2; letter-spacing: -.025em; }
h2 { margin-bottom: 4px; font-size: 17px; }
.page-heading p, .panel-heading p { margin-bottom: 0; color: var(--muted); }
.button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 14px; border: 1px solid transparent; border-radius: 10px; cursor: pointer; text-decoration: none; font-weight: 700; font-size: 13px; transition: background-color .18s ease, border-color .18s ease, opacity .18s ease; }
.button.primary { background: var(--accent); color: var(--accent-ink); }
.button.primary:hover { background: #7ae7d5; }
.button.secondary { border-color: var(--border); background: var(--surface); color: var(--text); }
.button.secondary:hover, .button.ghost:hover { border-color: #48708f; background: var(--surface-2); }
.button.ghost { border-color: transparent; background: transparent; color: var(--info); }
.button:disabled { cursor: not-allowed; opacity: .42; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 18px; }
.metric-card, .panel { border: 1px solid var(--border); border-radius: 15px; background: linear-gradient(145deg, rgba(17, 42, 64, .82), rgba(10, 29, 47, .9)); box-shadow: var(--shadow); }
.metric-card { min-height: 140px; display: flex; flex-direction: column; justify-content: center; padding: 20px; }
.metric-card > span { color: var(--muted); font-size: 13px; }
.metric-card strong { margin: 7px 0 2px; font-size: 30px; font-variant-numeric: tabular-nums; }
.metric-card small { color: var(--muted); }
.metric-card.warning strong { color: var(--warning); }
.content-grid, .operations-grid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(300px, .8fr); gap: 18px; }
.panel { padding: 18px; }
.panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.health-list { display: grid; gap: 8px; }
.health-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 12px 13px; border: 1px solid rgba(74, 111, 140, .56); border-radius: 11px; background: rgba(4, 17, 29, .35); }
.health-item strong, .health-item small { display: block; }
.health-item small { margin-top: 2px; color: var(--muted); }
.health-item .value { color: var(--warning); font-weight: 800; font-variant-numeric: tabular-nums; }
.empty-compact { min-height: 100px; display: grid; place-items: center; padding: 18px; border: 1px dashed var(--border); border-radius: 11px; color: var(--muted); text-align: center; }
.boundary-card { position: relative; overflow: hidden; }
.boundary-card::after { content: ""; position: absolute; right: -45px; top: -45px; width: 130px; height: 130px; border-radius: 50%; background: rgba(85, 220, 199, .09); }
.boundary-label, .feature-state { display: inline-flex; padding: 4px 8px; border-radius: 999px; background: rgba(85, 220, 199, .12); color: var(--accent); font-size: 11px; font-weight: 800; letter-spacing: .05em; }
.boundary-card h2 { margin-top: 14px; }
.boundary-card p, .boundary-card li { color: var(--muted); }
.boundary-card ul { padding-left: 18px; margin-bottom: 0; }
.monitor-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.monitor-toolbar strong, .monitor-toolbar span { display: block; }
.monitor-toolbar span { color: var(--muted); font-size: 13px; }
.preview-grid { min-height: 500px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.preview-empty { grid-column: 1 / -1; min-height: 500px; display: grid; align-content: center; justify-items: center; padding: 30px; border: 1px dashed var(--border); border-radius: 15px; background: rgba(7, 20, 33, .55); color: var(--muted); text-align: center; }
.preview-empty > span { width: 58px; height: 58px; display: grid; place-items: center; margin-bottom: 14px; border: 1px solid var(--border); border-radius: 50%; color: var(--accent); font-size: 24px; }
.preview-empty strong { color: var(--text); font-size: 18px; }
.preview-empty p { margin: 4px 0 0; }
.preview-card { overflow: hidden; border: 1px solid var(--border); border-radius: 15px; background: #02070b; box-shadow: var(--shadow); }
.preview-frame { position: relative; aspect-ratio: 16 / 9; background: #02070b; }
.preview-frame iframe { width: 100%; height: 100%; display: block; border: 0; }
.preview-meta { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; background: var(--surface); }
.preview-meta strong, .preview-meta small { display: block; }
.preview-meta small { color: var(--muted); }
.filter-panel { display: grid; grid-template-columns: minmax(220px, 1.4fr) repeat(3, minmax(140px, .7fr)) auto; align-items: end; gap: 12px; margin-bottom: 14px; }
label > span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 12px; font-weight: 700; }
input, select { width: 100%; min-height: 44px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: #081927; color: var(--text); }
input::placeholder { color: #72899c; }
select { cursor: pointer; }
.table-panel { padding: 0; overflow: hidden; }
.table-status { padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 13px; }
.device-table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 900px; }
th, td { padding: 13px 14px; border-bottom: 1px solid rgba(41, 71, 98, .75); text-align: left; vertical-align: middle; }
th { color: var(--muted); font-size: 11px; letter-spacing: .04em; }
td { font-size: 13px; }
td strong, td small { display: block; }
td small { margin-top: 2px; color: var(--muted); }
tbody tr:hover { background: rgba(34, 66, 92, .34); }
.select-preview { width: 44px; height: 44px; display: grid; place-items: center; border: 0; }
.select-preview input { width: 18px; min-height: auto; height: 18px; accent-color: var(--accent); }
.status-badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid var(--border); border-radius: 999px; color: var(--muted); font-size: 11px; font-weight: 800; white-space: nowrap; }
.status-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-badge.success { color: var(--success); border-color: rgba(113, 225, 156, .35); background: rgba(113, 225, 156, .08); }
.status-badge.warning { color: var(--warning); border-color: rgba(255, 209, 106, .35); background: rgba(255, 209, 106, .08); }
.status-badge.danger { color: var(--danger); border-color: rgba(255, 138, 149, .35); background: rgba(255, 138, 149, .08); }
.device-cards { display: none; }
.pagination { min-height: 66px; display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 10px 14px; }
.pagination > span { color: var(--muted); font-size: 13px; }
.pagination > div { display: flex; gap: 8px; }
.honest-empty { max-width: 820px; min-height: 340px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; padding: clamp(28px, 5vw, 60px); }
.honest-empty h2 { margin: 18px 0 8px; font-size: 24px; }
.honest-empty > p { max-width: 70ch; color: var(--muted); }
.next-list { width: 100%; display: grid; gap: 8px; margin-top: 14px; }
.next-list span { padding: 12px; border: 1px solid var(--border); border-radius: 10px; color: var(--muted); }
.next-list strong { margin-right: 10px; color: var(--text); }
dialog { width: min(520px, calc(100% - 28px)); padding: 0; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--text); box-shadow: 0 30px 90px rgba(0, 0, 0, .6); }
dialog::backdrop { background: rgba(0, 7, 13, .72); backdrop-filter: blur(4px); }
dialog form { padding: 22px; }
.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.dialog-heading h2 { margin-top: 5px; font-size: 22px; }
.icon-button { min-width: 44px; min-height: 44px; border: 1px solid var(--border); border-radius: 10px; background: transparent; cursor: pointer; font-size: 20px; }
dialog p { color: var(--muted); }
dialog label { display: block; margin-top: 14px; }
.form-error { margin-top: 12px; padding: 10px 12px; border: 1px solid rgba(255, 138, 149, .5); border-radius: 9px; background: rgba(120, 28, 43, .28); color: #ffdce0; }
.dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.toast { position: fixed; right: 20px; bottom: 24px; z-index: 200; max-width: min(420px, calc(100% - 40px)); padding: 13px 16px; border: 1px solid var(--border); border-radius: 11px; background: #173148; color: var(--text); box-shadow: var(--shadow); }
.mobile-nav { display: none; }
@media (max-width: 1080px) {
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.content-grid, .operations-grid { grid-template-columns: 1fr; }
.filter-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 760px) {
.engineering-banner { min-height: 38px; }
.shell { display: block; min-height: calc(100dvh - 38px); }
.sidebar { display: none; }
.topbar { min-height: 72px; padding: 12px 16px; }
.connection-pill { display: none; }
.topbar-actions .button { min-height: 44px; }
.degraded-banner { align-items: flex-start; flex-wrap: wrap; padding: 12px 16px; }
.degraded-banner span { flex-basis: calc(100% - 10px); }
main { padding: 24px 14px 92px; }
.page-heading { align-items: stretch; flex-direction: column; }
.page-heading .button, .heading-actions { width: 100%; }
.heading-actions .button { flex: 1; }
.metric-grid { grid-template-columns: 1fr 1fr; gap: 9px; }
.metric-card { min-height: 126px; padding: 15px; }
.metric-card strong { font-size: 25px; }
.panel { padding: 15px; }
.panel-heading, .monitor-toolbar { align-items: stretch; flex-direction: column; }
.preview-grid { grid-template-columns: 1fr; min-height: 420px; }
.preview-empty { min-height: 420px; }
.filter-panel { grid-template-columns: 1fr; }
input, select { min-height: 46px; font-size: 16px; }
.device-table-wrap { display: none; }
.device-cards { display: grid; gap: 10px; padding: 12px; }
.device-card { padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: rgba(5, 18, 30, .45); }
.device-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.device-card strong, .device-card small { display: block; }
.device-card small { color: var(--muted); }
.device-card dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0 0; }
.device-card dt { color: var(--muted); font-size: 11px; }
.device-card dd { margin: 2px 0 0; font-size: 13px; }
.pagination { align-items: stretch; flex-direction: column; }
.pagination > div { display: grid; grid-template-columns: 1fr 1fr; }
.pagination .button { min-height: 44px; }
.mobile-nav { position: fixed; left: 8px; right: 8px; bottom: 8px; z-index: 100; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); padding: 6px; border: 1px solid var(--border); border-radius: 15px; background: rgba(8, 24, 39, .96); box-shadow: 0 15px 40px rgba(0, 0, 0, .5); backdrop-filter: blur(18px); }
.mobile-nav button { min-height: 48px; border-radius: 10px; background: transparent; color: var(--muted); font-size: 12px; }
.mobile-nav button[aria-current="page"] { background: #153c3a; color: var(--accent); font-weight: 800; }
dialog form { padding: 18px; }
.dialog-actions { display: grid; grid-template-columns: 1fr 1fr; }
.dialog-actions .button { min-height: 46px; }
.toast { left: 14px; right: 14px; bottom: 78px; max-width: none; }
}
@media (max-width: 420px) {
.metric-grid { grid-template-columns: 1fr; }
.topbar { gap: 8px; }
.topbar strong { max-width: 170px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
+594
View File
@@ -0,0 +1,594 @@
"use strict";
const state = {
token: "",
siteId: "",
config: { page_size: 16, max_active_previews: 4, webrtc_base_url: "" },
items: [],
quota: null,
cursors: [null],
pageIndex: 0,
nextCursor: null,
selected: new Map(),
previewing: new Set(),
loading: false,
};
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const labels = {
modality: { video: "视频", radar: "雷达", contact: "门磁", button: "按钮", wearable: "可穿戴", other: "其他" },
desired: { enabled: "启用", disabled: "停用" },
actual: { online: "在线", offline: "离线", failed: "失败", pending: "收敛中" },
adapter: { ready: "适配器就绪", pending: "适配器处理中", adapter_not_ready: "适配器未交付", authentication_failed: "认证失败", unavailable: "适配器不可用" },
capability: { video_capture: "成像", audio_capture: "音频", spatial_rule: "空间配置", telemetry: "遥测" },
};
function showToast(message) {
const toast = $("#toast");
toast.textContent = message;
toast.hidden = false;
window.clearTimeout(showToast.timer);
showToast.timer = window.setTimeout(() => { toast.hidden = true; }, 4200);
}
function setText(selector, value) {
const target = $(selector);
if (target) target.textContent = String(value);
}
function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit",
hour12: false,
}).format(date);
}
function isPreviewEligible(device) {
return Array.isArray(device.capabilities) && device.capabilities.includes("video_capture") &&
device.desired_state === "enabled" && device.actual_state === "online" && device.converged === true;
}
function attentionNeeded(device) {
return device.converged !== true || device.actual_state === "offline" || device.actual_state === "failed" ||
device.adapter_status === "authentication_failed" || device.adapter_status === "unavailable";
}
function statusFor(device) {
if (device.actual_state === "online" && device.converged) return { text: "在线 · 已收敛", tone: "success" };
if (device.actual_state === "failed" || device.adapter_status === "authentication_failed") return { text: labels.adapter[device.adapter_status] || "失败", tone: "danger" };
if (device.actual_state === "offline") return { text: "离线", tone: "danger" };
return { text: device.converged ? (labels.actual[device.actual_state] || "未知") : "等待收敛", tone: "warning" };
}
function createStatusBadge(device) {
const value = statusFor(device);
const badge = document.createElement("span");
badge.className = `status-badge ${value.tone}`;
badge.textContent = value.text;
return badge;
}
function setConnected(connected) {
const pill = $("#connectionPill");
const foot = $(".sidebar-foot");
pill.classList.toggle("connected", connected);
foot.classList.toggle("connected", connected);
setText("#connectionText", connected ? "已连接" : "未连接");
setText("#sessionState", connected ? "会话仅驻留当前页面" : "尚未建立会话");
}
function setLoading(loading) {
state.loading = loading;
$("#refreshDevices").disabled = loading || !state.token;
$("#refreshOperations").disabled = loading || !state.token;
$("#retryLoad").disabled = loading || !state.token;
$("#prevPage").disabled = loading || state.pageIndex === 0;
$("#nextPage").disabled = loading || !state.nextCursor;
setText("#deviceTableStatus", loading ? "正在读取最新状态…" : state.token ? "已读取当前页真实设备状态。" : "请先建立会话。");
}
function showDegraded(title, message) {
setText("#degradedTitle", title);
setText("#degradedMessage", message);
$("#degradedBanner").hidden = false;
}
function clearDegraded() {
$("#degradedBanner").hidden = true;
}
async function apiFetch(path) {
const response = await fetch(path, {
headers: { Authorization: `Bearer ${state.token}`, Accept: "application/json" },
cache: "no-store",
});
let payload = null;
try { payload = await response.json(); } catch (_) { payload = null; }
if (!response.ok) {
const error = new Error(payload && payload.code ? payload.code : `http_${response.status}`);
error.status = response.status;
error.payload = payload;
throw error;
}
return payload;
}
function buildDevicePath(cursor) {
const params = new URLSearchParams();
params.set("limit", String(state.config.page_size));
const modality = $("#modalityFilter").value;
const desired = $("#desiredFilter").value;
const actual = $("#actualFilter").value;
if (modality) params.set("modality", modality);
if (desired) params.set("desired_state", desired);
if (actual) params.set("actual_state", actual);
if (cursor) params.set("cursor", cursor);
return `/api/v1/sites/${encodeURIComponent(state.siteId)}/devices?${params.toString()}`;
}
async function loadDevices(options = {}) {
if (!state.token || state.loading) return;
const cursor = state.cursors[state.pageIndex] || null;
setLoading(true);
clearDegraded();
try {
const payload = await apiFetch(buildDevicePath(cursor));
state.items = Array.isArray(payload.items) ? payload.items : [];
state.quota = payload.quota || null;
state.nextCursor = payload.page && payload.page.has_more ? payload.page.next_cursor : null;
for (const device of state.items) {
if (state.selected.has(device.id)) {
if (isPreviewEligible(device)) state.selected.set(device.id, device);
else state.selected.delete(device.id);
}
}
setConnected(true);
renderAll();
if (options.announce !== false) showToast(`已刷新第 ${state.pageIndex + 1} 页,共 ${state.items.length} 台设备`);
return true;
} catch (error) {
handleLoadError(error);
throw error;
} finally {
setLoading(false);
}
}
function handleLoadError(error) {
let title = "无法读取最新状态";
let message = "未知不会显示为在线。请检查 Sense 进程、网络和依赖后重试。";
if (error.status === 401) {
state.token = "";
setConnected(false);
title = "会话无效或已过期";
message = "Bearer token 已从页面内存清除,请重新建立会话。";
} else if (error.status === 403) {
title = "当前角色没有设备读取权限";
message = "页面不会尝试绕过权限;请使用具备 devices:read 的会话。";
} else if (error.status === 404) {
title = "无法访问该站点";
message = "目标不存在或当前会话无权访问,页面不会区分这两种情况。";
} else if (error.payload && ["quota_projection_unavailable", "area_policy_unavailable"].includes(error.payload.code)) {
title = "策略投影暂时不可用";
message = "已有链路不会因此被静默停用;相关新写入应保持禁用。";
}
showDegraded(title, message);
state.items = [];
state.quota = null;
renderAll();
}
function visibleItems() {
const query = $("#deviceSearch").value.trim().toLocaleLowerCase("zh-CN");
if (!query) return state.items;
return state.items.filter((device) => `${device.name} ${device.serial_number}`.toLocaleLowerCase("zh-CN").includes(query));
}
function renderAll() {
renderDevices();
renderMetrics();
renderHealth();
renderOperations();
renderSelection();
}
function renderMetrics() {
const used = state.quota && Number.isInteger(state.quota.used_video_channels) ? state.quota.used_video_channels : null;
const max = state.quota && Number.isInteger(state.quota.max_video_channels) ? state.quota.max_video_channels : null;
setText("#quotaMetric", used === null || max === null ? "—" : `${used} / ${max}`);
setText("#quotaHint", state.quota ? `投影状态:${state.quota.status || "未知"}` : "连接后读取 Bell 投影");
setText("#pageMetric", state.items.length);
setText("#convergedMetric", state.items.filter((device) => device.converged === true).length);
const attention = state.items.filter(attentionNeeded).length;
setText("#attentionMetric", attention);
setText("#deviceCount", state.items.length);
setText("#issueCount", attention);
}
function addTextCell(row, primary, secondary) {
const cell = document.createElement("td");
const strong = document.createElement("strong");
strong.textContent = primary || "—";
cell.appendChild(strong);
if (secondary) {
const small = document.createElement("small");
small.textContent = secondary;
cell.appendChild(small);
}
row.appendChild(cell);
}
function createPreviewCheckbox(device) {
const label = document.createElement("label");
label.className = "select-preview";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = state.selected.has(device.id);
checkbox.disabled = !isPreviewEligible(device);
checkbox.setAttribute("aria-label", checkbox.disabled ? `${device.name} 当前不可预览` : `选择 ${device.name} 进行预览`);
checkbox.addEventListener("change", () => toggleSelection(device, checkbox));
label.appendChild(checkbox);
return label;
}
function renderDevices() {
const rows = $("#deviceRows");
const cards = $("#deviceCards");
rows.replaceChildren();
cards.replaceChildren();
const items = visibleItems();
for (const device of items) {
const row = document.createElement("tr");
const selectCell = document.createElement("td");
selectCell.appendChild(createPreviewCheckbox(device));
row.appendChild(selectCell);
addTextCell(row, device.name, device.serial_number);
addTextCell(row, labels.modality[device.modality] || device.modality, (device.capabilities || []).map((value) => labels.capability[value] || value).join(" / "));
const statusCell = document.createElement("td");
statusCell.appendChild(createStatusBadge(device));
row.appendChild(statusCell);
addTextCell(row, labels.desired[device.desired_state] || device.desired_state, labels.actual[device.actual_state] || device.actual_state);
addTextCell(row, device.area_id || "—", device.projection_versions && device.projection_versions.area_policy_source_version ? `策略 v${device.projection_versions.area_policy_source_version}` : "策略版本未知");
addTextCell(row, formatTime(device.updated_at), device.next_attempt_at ? `重试 ${formatTime(device.next_attempt_at)}` : "");
rows.appendChild(row);
const card = document.createElement("article");
card.className = "device-card";
const cardHead = document.createElement("div");
cardHead.className = "device-card-head";
const identity = document.createElement("div");
const name = document.createElement("strong");
const serial = document.createElement("small");
name.textContent = device.name;
serial.textContent = device.serial_number;
identity.append(name, serial);
cardHead.append(identity, createPreviewCheckbox(device));
const status = createStatusBadge(device);
const details = document.createElement("dl");
const pairs = [
["状态", "", status],
["模态", labels.modality[device.modality] || device.modality],
["期望 / 实际", `${labels.desired[device.desired_state] || device.desired_state} / ${labels.actual[device.actual_state] || device.actual_state}`],
["Area", device.area_id || "—"],
];
for (const [term, value, node] of pairs) {
const wrapper = document.createElement("div");
const dt = document.createElement("dt");
const dd = document.createElement("dd");
dt.textContent = term;
if (node) dd.appendChild(node); else dd.textContent = value;
wrapper.append(dt, dd);
details.appendChild(wrapper);
}
card.append(cardHead, details);
cards.appendChild(card);
}
if (items.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有符合条件的设备。" : "请先建立会话。";
cards.appendChild(empty.cloneNode(true));
const cell = document.createElement("td");
cell.colSpan = 7;
cell.appendChild(empty);
const row = document.createElement("tr");
row.appendChild(cell);
rows.appendChild(row);
}
setText("#pageLabel", `第 ${state.pageIndex + 1} 页 · 每页 ${state.config.page_size} · 当前显示 ${items.length} 项`);
$("#prevPage").disabled = state.loading || state.pageIndex === 0;
$("#nextPage").disabled = state.loading || !state.nextCursor;
}
function toggleSelection(device, checkbox) {
if (checkbox.checked) {
if (!isPreviewEligible(device)) {
checkbox.checked = false;
showToast("只有在线、已启用且已收敛的视频设备可以预览");
return;
}
if (!state.selected.has(device.id) && state.selected.size >= state.config.max_active_previews) {
checkbox.checked = false;
showToast(`最多同时选择 ${state.config.max_active_previews} 路预览`);
return;
}
state.selected.set(device.id, device);
} else {
state.selected.delete(device.id);
if (state.previewing.has(device.id)) stopPreview(device.id);
}
renderSelection();
renderDevices();
}
function renderSelection() {
setText("#selectionCount", state.selected.size);
setText("#previewCount", `${state.previewing.size}/${state.config.max_active_previews}`);
$("#startPreview").disabled = state.selected.size === 0;
$("#stopAll").disabled = state.previewing.size === 0;
}
function previewURL(deviceID) {
const url = new URL(`${state.config.webrtc_base_url}/devices/${encodeURIComponent(deviceID)}`);
url.searchParams.set("controls", "true");
url.searchParams.set("muted", "true");
url.searchParams.set("autoplay", "true");
url.searchParams.set("playsInline", "true");
return url.toString();
}
function startSelectedPreviews() {
if (state.selected.size === 0) return;
state.previewing = new Set(state.selected.keys());
const grid = $("#previewGrid");
grid.replaceChildren();
for (const device of state.selected.values()) {
const card = document.createElement("article");
card.className = "preview-card";
card.dataset.deviceId = device.id;
const frame = document.createElement("div");
frame.className = "preview-frame";
const iframe = document.createElement("iframe");
iframe.title = `${device.name} 实时预览`;
iframe.loading = "eager";
iframe.allow = "autoplay; fullscreen";
iframe.sandbox = "allow-scripts allow-same-origin";
iframe.referrerPolicy = "no-referrer";
iframe.src = previewURL(device.id);
frame.appendChild(iframe);
const meta = document.createElement("div");
meta.className = "preview-meta";
const identity = document.createElement("div");
const name = document.createElement("strong");
const note = document.createElement("small");
name.textContent = device.name;
note.textContent = "MediaMTX WebRTC · 按需读取";
identity.append(name, note);
const stop = document.createElement("button");
stop.type = "button";
stop.className = "button secondary";
stop.textContent = "停止";
stop.addEventListener("click", () => stopPreview(device.id));
meta.append(identity, stop);
card.append(frame, meta);
grid.appendChild(card);
}
switchView("monitor");
renderSelection();
showToast(`已按需启动 ${state.previewing.size} 路预览`);
}
function stopPreview(deviceID) {
state.previewing.delete(deviceID);
const card = $(`[data-device-id="${CSS.escape(deviceID)}"]`);
if (card) {
const iframe = card.querySelector("iframe");
if (iframe) iframe.removeAttribute("src");
card.remove();
}
if (state.previewing.size === 0) renderPreviewEmpty();
renderSelection();
}
function stopAllPreviews() {
for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src");
state.previewing.clear();
renderPreviewEmpty();
renderSelection();
showToast("全部预览已停止");
}
function renderPreviewEmpty() {
const grid = $("#previewGrid");
grid.replaceChildren();
const empty = document.createElement("div");
empty.className = "preview-empty";
const mark = document.createElement("span");
mark.setAttribute("aria-hidden", "true");
mark.textContent = "▷";
const title = document.createElement("strong");
title.textContent = "尚未启动预览";
const note = document.createElement("p");
note.textContent = "前往设备页选择最多 4 路运行中的视频设备。";
empty.append(mark, title, note);
grid.appendChild(empty);
}
function renderHealth() {
const container = $("#overviewHealth");
container.replaceChildren();
if (!state.token || state.items.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有设备事实。" : "建立会话后显示真实状态。";
container.appendChild(empty);
return;
}
const groups = [
["在线且已收敛", state.items.filter((device) => device.actual_state === "online" && device.converged).length, "设备可用于按需预览"],
["等待收敛", state.items.filter((device) => !device.converged).length, "期望 generation 尚未被完整观察"],
["离线或失败", state.items.filter((device) => ["offline", "failed"].includes(device.actual_state)).length, "查看重试时间和稳定错误码"],
];
for (const [title, count, note] of groups) container.appendChild(createHealthItem(title, count, note));
}
function createHealthItem(title, count, note) {
const item = document.createElement("div");
item.className = "health-item";
const text = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = title;
small.textContent = note;
text.append(strong, small);
const value = document.createElement("span");
value.className = "value";
value.textContent = String(count);
item.append(text, value);
return item;
}
function renderOperations() {
const container = $("#operationIssues");
container.replaceChildren();
const issues = state.items.filter(attentionNeeded);
if (issues.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-compact";
empty.textContent = state.token ? "当前页没有已知对账差异;这不代表未加载设备或其他基础设施正常。" : "建立会话后显示。";
container.appendChild(empty);
return;
}
for (const device of issues) {
const message = device.last_error_code ? `错误 ${device.last_error_code}` : `generation ${device.observed_generation || 0} / ${device.generation || 0}`;
container.appendChild(createHealthItem(device.name, device.failure_count || 0, device.next_attempt_at ? `${message} · 下次 ${formatTime(device.next_attempt_at)}` : message));
}
}
function switchView(name) {
$$(".view").forEach((view) => {
const active = view.dataset.page === name;
view.hidden = !active;
view.classList.toggle("active", active);
});
$$('[data-view]').forEach((button) => {
const active = button.dataset.view === name;
button.toggleAttribute("aria-current", active);
});
$("#main-content").focus({ preventScroll: true });
window.scrollTo({ top: 0, behavior: "auto" });
}
function resetPaging() {
state.cursors = [null];
state.pageIndex = 0;
state.nextCursor = null;
}
async function connect(event) {
event.preventDefault();
const siteInput = $("#siteInput");
const tokenInput = $("#tokenInput");
const site = siteInput.value.trim();
const token = tokenInput.value;
const errorBox = $("#contextError");
errorBox.hidden = true;
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(site)) {
errorBox.textContent = "Site ID 格式无效:只能使用 1~64 位字母、数字、点、下划线、冒号或连字符。";
errorBox.hidden = false;
siteInput.focus();
return;
}
if (!token || token.length > 4096) {
errorBox.textContent = "请输入有效的 Bearer token。";
errorBox.hidden = false;
tokenInput.focus();
return;
}
state.siteId = site;
state.token = token;
tokenInput.value = "";
setText("#siteContext", site);
resetPaging();
try {
await loadDevices({ announce: false });
$("#contextDialog").close();
showToast("会话已建立;token 仅驻留当前页面内存");
} catch (error) {
errorBox.textContent = error.status === 401 ? "认证失败,请检查 token 后重试。" : "无法读取该站点,请按页面提示检查权限或服务状态。";
errorBox.hidden = false;
$("#contextDialog").showModal();
}
}
async function initialize() {
try {
const response = await fetch("/sense-console/config", { cache: "no-store" });
if (!response.ok) throw new Error("config unavailable");
state.config = await response.json();
if (state.config.page_size !== 16 || state.config.max_active_previews !== 4 || state.config.recording_and_playback !== false) {
throw new Error("unsafe console bounds");
}
} catch (_) {
showDegraded("控制台配置不可用", "页面保持只读且不会尝试连接视频,请检查 Sense 启动配置。");
$$("button, input, select").forEach((control) => { control.disabled = true; });
return;
}
setConnected(false);
renderAll();
$("#contextDialog").showModal();
}
$$('[data-view]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.view)));
$$('[data-view-target]').forEach((button) => button.addEventListener("click", () => switchView(button.dataset.viewTarget)));
$("#changeContext").addEventListener("click", () => $("#contextDialog").showModal());
$("#closeContext").addEventListener("click", () => $("#contextDialog").close());
$("#cancelContext").addEventListener("click", () => $("#contextDialog").close());
$("#contextForm").addEventListener("submit", connect);
$("#refreshDevices").addEventListener("click", () => loadDevices());
$("#refreshOperations").addEventListener("click", () => loadDevices());
$("#retryLoad").addEventListener("click", () => state.token ? loadDevices() : $("#contextDialog").showModal());
$("#startPreview").addEventListener("click", startSelectedPreviews);
$("#stopAll").addEventListener("click", stopAllPreviews);
$("#deviceSearch").addEventListener("input", renderDevices);
for (const selector of ["#modalityFilter", "#desiredFilter", "#actualFilter"]) {
$(selector).addEventListener("change", () => {
resetPaging();
if (state.token) loadDevices();
});
}
$("#clearFilters").addEventListener("click", () => {
$("#deviceSearch").value = "";
$("#modalityFilter").value = "";
$("#desiredFilter").value = "";
$("#actualFilter").value = "";
resetPaging();
if (state.token) loadDevices(); else renderDevices();
});
$("#nextPage").addEventListener("click", async () => {
if (!state.nextCursor) return;
state.cursors[state.pageIndex + 1] = state.nextCursor;
state.pageIndex += 1;
try { await loadDevices(); } catch (_) { state.pageIndex -= 1; }
});
$("#prevPage").addEventListener("click", async () => {
if (state.pageIndex === 0) return;
state.pageIndex -= 1;
try { await loadDevices(); } catch (_) { state.pageIndex += 1; }
});
window.addEventListener("beforeunload", () => {
state.token = "";
for (const iframe of $$("#previewGrid iframe")) iframe.removeAttribute("src");
});
initialize();
+158
View File
@@ -0,0 +1,158 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>YoVision Sense · 接入运维工作台</title>
<link rel="stylesheet" href="/sense-console/app.css">
<script defer src="/sense-console/app.js"></script>
</head>
<body>
<a class="skip-link" href="#main-content">跳到主要内容</a>
<div class="engineering-banner" role="note">
回环工程控制台 · 非录像/回放 · 不代表生产公网入口
</div>
<div class="shell">
<aside class="sidebar" aria-label="Sense 主导航">
<div class="brand">
<span class="brand-mark" aria-hidden="true">S</span>
<span><strong>YoVision Sense</strong><small>接入运维工作台</small></span>
</div>
<nav class="primary-nav">
<button type="button" data-view="overview" aria-current="page"><span aria-hidden="true">◫</span>运行总览</button>
<button type="button" data-view="monitor"><span aria-hidden="true">▷</span>实时监控 <span class="nav-count" id="previewCount">0/4</span></button>
<button type="button" data-view="devices"><span aria-hidden="true">▣</span>设备 <span class="nav-count" id="deviceCount">0</span></button>
<button type="button" data-view="onboarding"><span aria-hidden="true">⇧</span>接入任务</button>
<button type="button" data-view="operations"><span aria-hidden="true">⌁</span>运维中心 <span class="nav-count" id="issueCount">0</span></button>
</nav>
<div class="sidebar-foot">
<span class="status-dot" aria-hidden="true"></span>
<span id="sessionState">尚未建立会话</span>
</div>
</aside>
<div class="workspace">
<header class="topbar">
<div>
<span class="eyebrow">当前站点</span>
<strong id="siteContext">未选择</strong>
</div>
<div class="topbar-actions">
<span class="connection-pill" id="connectionPill"><span class="connection-dot" aria-hidden="true"></span><span id="connectionText">未连接</span></span>
<button type="button" class="button secondary" id="changeContext">连接设置</button>
</div>
</header>
<div class="degraded-banner" id="degradedBanner" role="status" hidden>
<strong id="degradedTitle">无法读取最新状态</strong>
<span id="degradedMessage">未知不会显示为在线,请检查会话后重试。</span>
<button type="button" class="button ghost" id="retryLoad">重试</button>
</div>
<main id="main-content" tabindex="-1">
<section class="view active" data-page="overview" aria-labelledby="overviewTitle">
<div class="page-heading">
<div><span class="eyebrow">SENSE / OVERVIEW</span><h1 id="overviewTitle">运行总览</h1><p>先看已加载事实,再进入设备或运维中心处理。</p></div>
<button type="button" class="button primary" data-view-target="devices">查看设备</button>
</div>
<div class="metric-grid" aria-label="当前页运行指标">
<article class="metric-card"><span>视频配额</span><strong id="quotaMetric">—</strong><small id="quotaHint">连接后读取 Bell 投影</small></article>
<article class="metric-card"><span>当前页设备</span><strong id="pageMetric">0</strong><small>默认每页 16,不代表站点总数</small></article>
<article class="metric-card"><span>当前页已收敛</span><strong id="convergedMetric">0</strong><small>期望态与实际态分开计算</small></article>
<article class="metric-card warning"><span>当前页需关注</span><strong id="attentionMetric">0</strong><small>失败、离线或尚未收敛</small></article>
</div>
<div class="content-grid">
<article class="panel">
<div class="panel-heading"><div><h2>接入健康</h2><p>只汇总当前设备页,不推断未加载设备。</p></div><button type="button" class="button ghost" data-view-target="operations">进入运维中心</button></div>
<div class="health-list" id="overviewHealth"><div class="empty-compact">建立会话后显示真实状态。</div></div>
</article>
<article class="panel boundary-card">
<span class="boundary-label">本纵切边界</span>
<h2>实时监看,不替代客户 NVR</h2>
<p>常态录像仍留在客户现有 NVR;当前仅验证 Sense 设备管理面和 MediaMTX 按需播放。录像计划、录像索引与回放未实现。</p>
<ul><li>同时最多 4 路预览</li><li>刷新后 Bearer token 自动丢失</li><li>Sense 与播放端均限回环地址</li></ul>
</article>
</div>
</section>
<section class="view" data-page="monitor" aria-labelledby="monitorTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / LIVE</span><h1 id="monitorTitle">实时监控</h1><p>从当前页设备中选择,只有明确在线且已收敛的视频设备可预览。</p></div>
<div class="heading-actions"><button type="button" class="button secondary" id="stopAll">停止全部</button><button type="button" class="button primary" id="startPreview">开始预览</button></div>
</div>
<div class="monitor-toolbar panel">
<div><strong>已选择 <span id="selectionCount">0</span> / 4</strong><span>默认不自动播放,不加载未选择设备。</span></div>
<button type="button" class="button ghost" data-view-target="devices">选择设备</button>
</div>
<div class="preview-grid" id="previewGrid" aria-live="polite">
<div class="preview-empty"><span aria-hidden="true">▷</span><strong>尚未启动预览</strong><p>前往设备页选择最多 4 路运行中的视频设备。</p></div>
</div>
</section>
<section class="view" data-page="devices" aria-labelledby="devicesTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / DEVICES</span><h1 id="devicesTitle">设备</h1><p>统一台账按模态与能力展示;列表只读取脱敏 Control API 字段。</p></div>
<button type="button" class="button primary" id="refreshDevices">刷新状态</button>
</div>
<section class="panel filter-panel" aria-label="设备筛选">
<label><span>当前页搜索</span><input type="search" id="deviceSearch" placeholder="名称 / 序列号" autocomplete="off"></label>
<label><span>模态</span><select id="modalityFilter"><option value="">全部模态</option><option value="video">视频</option><option value="radar">雷达</option><option value="contact">门磁</option><option value="button">按钮</option><option value="wearable">可穿戴</option><option value="other">其他</option></select></label>
<label><span>期望态</span><select id="desiredFilter"><option value="">全部期望态</option><option value="enabled">启用</option><option value="disabled">停用</option></select></label>
<label><span>实际态</span><select id="actualFilter"><option value="">全部实际态</option><option value="online">在线</option><option value="offline">离线</option><option value="failed">失败</option><option value="pending">收敛中</option></select></label>
<button type="button" class="button secondary" id="clearFilters">清除筛选</button>
</section>
<div class="table-panel panel">
<div class="table-status" id="deviceTableStatus" role="status">请先建立会话。</div>
<div class="device-table-wrap">
<table>
<thead><tr><th scope="col">预览</th><th scope="col">设备</th><th scope="col">模态 / 能力</th><th scope="col">状态</th><th scope="col">期望 / 实际</th><th scope="col">Area</th><th scope="col">最后变化</th></tr></thead>
<tbody id="deviceRows"></tbody>
</table>
</div>
<div class="device-cards" id="deviceCards"></div>
<div class="pagination"><span id="pageLabel">第 1 页 · 每页 16</span><div><button type="button" class="button secondary" id="prevPage" disabled>上一页</button><button type="button" class="button secondary" id="nextPage" disabled>下一页</button></div></div>
</div>
</section>
<section class="view" data-page="onboarding" aria-labelledby="onboardingTitle" hidden>
<div class="page-heading"><div><span class="eyebrow">SENSE / ONBOARDING</span><h1 id="onboardingTitle">接入任务</h1><p>批量导入仍是已确认需求,但不属于本次设备与实时监看纵切。</p></div></div>
<article class="panel honest-empty">
<span class="feature-state">尚未实现</span><h2>批量导入与逐项激活将在独立任务完成</h2>
<p>当前 Control API 支持单设备创建和最多 128 项的批量启停,但没有冻结批量 CSV 创建契约。本页不会用假上传或假成功伪装交付。</p>
<div class="next-list"><span><strong>已具备</strong>设备查询、期望态修改、批量启停与结果查询</span><span><strong>后续补齐</strong>模板下载、逐行校验、只重试失败项和任务历史</span></div>
</article>
</section>
<section class="view" data-page="operations" aria-labelledby="operationsTitle" hidden>
<div class="page-heading">
<div><span class="eyebrow">SENSE / OPERATIONS</span><h1 id="operationsTitle">运维中心</h1><p>仅展示接入控制面的事实,不混入 Bell 业务预警。</p></div>
<a class="button secondary" href="/metrics" target="_blank" rel="noreferrer">打开低基数指标</a>
</div>
<div class="operations-grid">
<article class="panel"><div class="panel-heading"><div><h2>当前页对账差异</h2><p>失败、退避与尚未观察到当前 generation。</p></div><button type="button" class="button ghost" id="refreshOperations">刷新</button></div><div id="operationIssues" class="health-list"><div class="empty-compact">建立会话后显示。</div></div></article>
<article class="panel boundary-card"><span class="boundary-label">数据边界</span><h2>完整运维 API 尚未冻结</h2><p>分片、边缘隧道、补传、孤儿扫描和运维告警已经有后端能力或设计,但本纵切只消费设备 Control API;未取到的数据不会显示为正常。</p></article>
</div>
</section>
</main>
</div>
</div>
<nav class="mobile-nav" aria-label="移动端主导航">
<button type="button" data-view="overview" aria-current="page">总览</button><button type="button" data-view="monitor">监控</button><button type="button" data-view="devices">设备</button><button type="button" data-view="onboarding">接入</button><button type="button" data-view="operations">运维</button>
</nav>
<dialog id="contextDialog" aria-labelledby="contextTitle">
<form method="dialog" id="contextForm">
<div class="dialog-heading"><div><span class="eyebrow">LOOPBACK SESSION</span><h2 id="contextTitle">连接 Sense Control API</h2></div><button type="button" class="icon-button" id="closeContext" aria-label="关闭连接设置">×</button></div>
<p>Site ID 只用于当前页面上下文;Bearer token 仅保存在内存,刷新页面后自动丢失。</p>
<label><span>Site ID</span><input id="siteInput" name="site" required maxlength="128" autocomplete="off" placeholder="例如 school-main"></label>
<label><span>Bearer token</span><input id="tokenInput" name="token" type="password" required autocomplete="off" placeholder="不会保存或回显"></label>
<div class="form-error" id="contextError" role="alert" hidden></div>
<div class="dialog-actions"><button type="button" class="button secondary" id="cancelContext">取消</button><button type="submit" class="button primary" id="connectButton">连接并读取</button></div>
</form>
</dialog>
<div class="toast" id="toast" role="status" aria-live="polite" hidden></div>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
// Package console serves the loopback-only Sense engineering console.
package console
import (
"embed"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
//go:embed assets/index.html assets/app.css assets/app.js
var assets embed.FS
type handler struct {
previewBaseURL string
previewOrigin string
}
// NewHandler builds the self-contained console handler. Config validation also
// enforces this boundary; validating here keeps the package safe in isolation.
func NewHandler(previewBaseURL string) (http.Handler, error) {
parsed, err := url.Parse(previewBaseURL)
if err != nil || parsed.Host == "" ||
(parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" ||
(parsed.Path != "" && parsed.Path != "/") {
return nil, errors.New("invalid Sense console WebRTC base URL")
}
host := parsed.Hostname()
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return nil, errors.New("Sense console WebRTC base URL is not loopback")
}
base := strings.TrimRight(parsed.String(), "/")
return &handler{
previewBaseURL: base,
previewOrigin: parsed.Scheme + "://" + parsed.Host,
}, nil
}
func (h *handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
h.securityHeaders(writer)
if request.Method != http.MethodGet && request.Method != http.MethodHead {
writer.Header().Set("Allow", "GET, HEAD")
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
return
}
switch request.URL.Path {
case "/sense-console/":
h.serveAsset(writer, request, "assets/index.html", "text/html; charset=utf-8")
case "/sense-console/app.css":
h.serveAsset(writer, request, "assets/app.css", "text/css; charset=utf-8")
case "/sense-console/app.js":
h.serveAsset(writer, request, "assets/app.js", "text/javascript; charset=utf-8")
case "/sense-console/config":
writer.Header().Set("Content-Type", "application/json")
if request.Method == http.MethodHead {
writer.WriteHeader(http.StatusOK)
return
}
_ = json.NewEncoder(writer).Encode(map[string]any{
"webrtc_base_url": h.previewBaseURL,
"page_size": 16,
"max_active_previews": 4,
"recording_and_playback": false,
})
default:
http.NotFound(writer, request)
}
}
func (h *handler) securityHeaders(writer http.ResponseWriter) {
writer.Header().Set("Cache-Control", "no-store")
writer.Header().Set("Content-Security-Policy", fmt.Sprintf(
"default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-src %s; img-src 'self' data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
h.previewOrigin,
))
writer.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
writer.Header().Set("Referrer-Policy", "no-referrer")
writer.Header().Set("X-Content-Type-Options", "nosniff")
writer.Header().Set("X-Frame-Options", "DENY")
}
func (h *handler) serveAsset(writer http.ResponseWriter, request *http.Request, path, contentType string) {
contents, err := assets.ReadFile(path)
if err != nil {
http.Error(writer, "asset unavailable", http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", contentType)
writer.Header().Set("Content-Length", fmt.Sprintf("%d", len(contents)))
writer.WriteHeader(http.StatusOK)
if request.Method == http.MethodGet {
_, _ = writer.Write(contents)
}
}
+95
View File
@@ -0,0 +1,95 @@
package console
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestHandlerServesSelfContainedConsoleWithSecurityHeaders(t *testing.T) {
handler, err := NewHandler("http://127.0.0.1:8889/")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest(http.MethodGet, "/sense-console/", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("unexpected status: %d", response.Code)
}
body := response.Body.String()
for _, required := range []string{"YoVision Sense", "/sense-console/app.css", "/sense-console/app.js"} {
if !strings.Contains(body, required) {
t.Fatalf("console HTML lacks %q", required)
}
}
for _, forbidden := range []string{"http://", "https://", "<script>", "<style>"} {
if strings.Contains(body, forbidden) {
t.Fatalf("console HTML contains forbidden inline/external marker %q", forbidden)
}
}
csp := response.Header().Get("Content-Security-Policy")
if !strings.Contains(csp, "frame-src http://127.0.0.1:8889") ||
!strings.Contains(csp, "frame-ancestors 'none'") {
t.Fatalf("unexpected CSP: %s", csp)
}
if response.Header().Get("Cache-Control") != "no-store" ||
response.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("security headers are incomplete")
}
}
func TestHandlerReturnsBoundedRuntimeConfig(t *testing.T) {
handler, err := NewHandler("http://localhost:8889")
if err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/sense-console/config", nil))
var payload struct {
WebRTCBaseURL string `json:"webrtc_base_url"`
PageSize int `json:"page_size"`
MaxActivePreviews int `json:"max_active_previews"`
RecordingAndPlayback bool `json:"recording_and_playback"`
}
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload.WebRTCBaseURL != "http://localhost:8889" || payload.PageSize != 16 ||
payload.MaxActivePreviews != 4 || payload.RecordingAndPlayback {
t.Fatalf("unexpected runtime config: %+v", payload)
}
}
func TestHandlerRejectsRemoteOrCredentialedPreviewBase(t *testing.T) {
for _, value := range []string{
"https://media.example", "http://user@127.0.0.1:8889", "http://127.0.0.1:8889/path",
} {
if _, err := NewHandler(value); err == nil {
t.Fatalf("invalid preview base was accepted: %s", value)
}
}
}
func TestHandlerRejectsWritesAndUnknownAssets(t *testing.T) {
handler, err := NewHandler("http://127.0.0.1:8889")
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
method string
path string
want int
}{
{http.MethodPost, "/sense-console/", http.StatusMethodNotAllowed},
{http.MethodGet, "/sense-console/missing", http.StatusNotFound},
} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(test.method, test.path, nil))
if response.Code != test.want {
t.Fatalf("%s %s: got %d, want %d", test.method, test.path, response.Code, test.want)
}
}
}
+4 -2
View File
@@ -40,7 +40,7 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI
## 当前阶段
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Brain/Bell 本地事件链开发。
当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay、Brain 单路可视化工程原型,并完成 T-018 Sense 回环 NVR 管理面纵切**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置;当前执行 T-019,建立 Brain→Bell 可靠业务事件 ingress。
优先路径:
@@ -92,8 +92,10 @@ go -C Sense build ./...
go -C Bell test ./...
go -C Bell vet ./...
go -C Bell build ./...
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
```
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。
日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、Brain 单元/编译、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;T-018 控制台必须在完成 PostgreSQL/Control API 外部安全配置后显式设置 `SENSE_CONSOLE_ENABLED=true`,再访问回环 `/sense-console/`。Brain 合成工程原型启动为 `python -m Brain.yovision_brain --source synthetic`。工程页面默认只允许回环,具体配置、版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md)、[`../Sense/README.md`](../Sense/README.md) 和 [`../Brain/README.md`](../Brain/README.md)。
本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
+26 -2
View File
@@ -71,6 +71,22 @@ T-015 不冻结 Brain→Bell transport,也不产生可部署 Bell API 二进
T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、SHA-256、base64url 和 constant-time compare,数据库继续使用已冻结的 PostgreSQL 17.10/pgx。`cmd/bell-api` 只提供回环 health/ready 和 Sense 审计内部端点;非回环监听必须同时提供绝对路径 TLS 证书/私钥。HMAC key 使用仓库外 version 1 JSON 文件,secret 至少 32 字节;该适配器不替代未来 Bell 公共 JWT/OIDC。
### 1.5 Brain 单路工程原型(T-017)
| 组件 | 冻结版本 | 许可证 / 校验 | 使用与退出路线 |
| --- | --- | --- | --- |
| Python | `3.10.11` | PSF License;本机 `python --version` 已验证 | 仅冻结 T-017 工程原型和测试语法基线,不等于生产 Savant/DeepStream Python 版本;生产脚手架冻结时通过标准模块边界迁移 |
| NumPy | `1.26.4` | BSD-3-Clause;本机 `numpy.__version__` 已验证 | 只在 frame fixture/OpenCV 数组边界使用;不把 NumPy 类型写入事件候选或 HTTP JSON |
| OpenCV Python | `opencv-python 4.9.0.80` | wheel 构建脚本 MIT、OpenCV Apache-2.0;Windows amd64 wheel SHA-256 `3f16f08e02b2a2da44259c7cc712e779eff1dd8b55fdb0323e8cab09548086c0` | 内置 HOG/SVM 只作为可替换 `Detector` 的匿名人员检测演示,不是生产模型;生产退出时替换 detector port,区域判定、候选事实和 UI 状态不依赖 HOG 类型。不得在同一环境混装标准/headless/contrib wheel |
T-017 不冻结 CUDA、Savant/DeepStream、ONNX Runtime、Ultralytics、跟踪/ReID 或 `max_sources`。默认合成 fixture 可重复展示工程链路,并必须标识为“非模型输出”;真实流只从仓库外绝对 URL 文件读取,优先消费 Sense 管理的 MediaMTX path。`/brain-demo` 只监听显式回环地址,事件候选最多保留 100 项内存环,不形成 Brain 业务数据库。OpenCV wheel 是 CPU-only;本机存在 NVIDIA GPU 也不能据此宣称 GPU 推理已接入。
### 1.6 Sense 回环 NVR 管理面纵切(T-018)
T-018 不增加生产或前端依赖:Go 使用标准库 `embed`/`net/http` 提供自包含 HTML、CSS 和原生 JavaScript,页面直接消费已冻结的 Sense Control API v1;实时预览使用同版 MediaMTX v1.19.3 自带浏览器 WebRTC 页面,不 vendor `reader.js`、不引入 CDN/播放器库,也不让 Sense 代理媒体字节。
该选择只适用于默认关闭的回环工程控制台,不冻结 Bell 最终前端框架。页面默认 16 项 cursor 分页、最多 4 路按需预览且不自动加载视频;Sense HTTP 与 WebRTC 基地址必须同时是显式回环地址。非回环 HTTPS/JWT/OIDC、MediaMTX 外部认证、录像/回放和正式客户会话均保持待冻结,不能从 T-018 的本地演示外推为生产安全或 NVR 存储能力。
## 2. 外部项目边界
- MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。
@@ -91,7 +107,7 @@ T-016 不增加第三方依赖:两端使用 Go 标准库 HTTP、HMAC-SHA256、
## 4. 当前标准入口
Sense M1 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
Sense 骨架建立后,根目录脚本同步 Go 依赖并执行治理与 Sense 验证:
```powershell
./init.ps1
@@ -123,6 +139,14 @@ go -C Bell vet ./...
go -C Bell build ./...
```
Brain 单路工程原型单独执行:
```powershell
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
python -m Brain.yovision_brain --source synthetic
```
直接验证:
```powershell
@@ -139,7 +163,7 @@ python scripts/validate_harness_governance.py
| `docs/raw/contracts/` | JSON Schema 校验 + 契约代码断言(实现后补命令) | schema/示例/mapper 任一变化 | 生产者与消费者联合评审 |
| Sense Go | `go -C Sense generate ./internal/mtx ./internal/controlapi`、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...` | ONVIF、存储、MediaMTX、对账或公共 API 变化 | T-006 使用 1 路指定实机 + 4 路独立合成源;T-007 才要求客户/借用/租赁的真实多路矩阵 |
| PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 |
| Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 |
| Brain Python | 当前工程原型:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain` | source、detector port、track、判定状态机、mapper 或模型接口变化 | T-017 合成 fixture 只验工程闭环;命中真实模型任务时另用冻结数据集和目标硬件,不能用 fixture/HOG 结果替代 |
| Bell Go/Web | 当前后端:`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`;前端命令待脚手架冻结 | schema、RBAC、预警状态机或公共 UI 变化 | P0 UI 流程由产品/值班角色验收;纯事件存储不需要 UI 人工验收 |
| 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 |
+6 -3
View File
@@ -47,7 +47,7 @@ Sense ── 视频流/触发信号 ──> Brain
1. Bell 持有站点、Area、配额与 `capture_policy`;首期在同一 PostgreSQL 实例内发布 `bell.site_quota_v1` 和 `bell.area_policy_v1` 两个版本化只读视图。T-009/T-010 已实现 Bell 源表/视图、最小权限和 Sense PostgreSQL repository;Sense 按 Area→Site 的固定 advisory-lock 顺序执行策略与配额准入并记录所用版本。未来分库必须发布新版本契约,不能静默改变 v1 语义。
2. Sense 维护设备期望态,通过 MediaMTX API 和对账器收敛实际态;PostgreSQL 多实例以数据库时钟短租约和 fencing token 领取 due row,过期 worker 不得提交结果。
3. Brain 消费视频与触发信号,产生符合 v0.1 的事件。
3. Brain 消费视频与触发信号,产生符合冻结契约的事件候选。T-017 已先建立单路 frame source、可替换 detector、轻量 track、多边形进入判定和回环可视化工程原型;合成 fixture 与 OpenCV HOG 均明确不是生产模型,候选只含 `source_event_id`,不自报平台 `id`。T-018 的 Sense 回环控制台只读取设备事实并按需嵌入 MediaMTX WebRTC 页面,不代理媒体、不参与推理或事件生成。
4. Bell 做 schema 与代码级断言,生成平台 ULID,保存不可变事件。T-015 已实现内部 candidate→final event factory、append-only PostgreSQL repository 和独立 outcome 事实;Brain→Bell transport、认证和公共 API 仍未冻结。
5. 规则命中后创建独立 Alert,先落库再投递,等待 ack 并按策略升级。
6. Bell 发起 pre-roll 证据回捞,Sense 提供切片接口。
@@ -79,6 +79,8 @@ Sense ── 视频流/触发信号 ──> Brain
- 单分片故障不能扩散到其他分片。
- 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。
T-018 先实现其中的回环工程纵切:设备 cursor 默认每页 16 项,同时预览最多 4 路且默认不播放;只有 `video_capture + enabled + online + converged` 的设备允许打开预览。该上限是浏览器工程台保护值,不是站点视频配额或 MediaMTX 分片容量。常态录像仍由客户现有 NVR 承担,Sense 不因提供监看页而变成媒体代理或录像真相源。
T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。
## 7. 一致性与失败处理
@@ -92,6 +94,7 @@ T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、
- Bell 先验证时间窗、nonce 和 constant-time HMAC,再逐项校验 v1/v2 事件;同 nonce/同摘要重放原结果,同 nonce/不同摘要拒绝。`bell.audit_events` 只追加且不自动清理,只有 10 分钟幂等收据允许 Bell runtime 删除过期行。
- Bell 最终事件写入 `bell.events`;同平台 ID/同摘要仅视为幂等重放,同 ID/不同摘要拒绝。`bell_runtime` 只有 `SELECT/INSERT`,事件与 outcome 的 UPDATE/DELETE 另由数据库 trigger 拒绝;后续人工/自动 outcome 追加到独立表,不改写事件 payload。
- Brain 投递失败落本地队列重试,不阻塞实时推理主链路。
- T-017 的 100 项内存事件环只服务单路工程演示,重启可丢失且不等同于上述投递队列;Brain→Bell 后续建议 T-019 必须另行实现有界持久 Outbox、身份映射、认证和幂等确认,不能把 demo 内存状态升级为生产 transport。T-018 先实现 Sense 回环 NVR 管理面纵切,不改变该事件链边界。
- Alert 先落库再投递,进程重启恢复未完成升级链。
- 值班排班发布前必须按 Site 时区校验班次空档、重叠、联系人停用和通道验证;排班以新版本和未来生效时间发布,不原地改写历史。交接班是进行中 Alert 的显式责任转移事件,不替代排班版本变更。
- 事件证据技术默认保留 30 天并按生命周期删除;客户/法务在 M3 生产上线前确认法规适用性和最终期限,技术默认值不能覆盖其结论。
@@ -118,14 +121,14 @@ Bell/{web,packs,contracts}
deploy/postgres/{001_roles.sql,...,015_privileges_audit_relay.sql,tests}
```
Sense 脚手架和 PostgreSQL `001`~`015` 已实现;Bell 已有事件校验/不可变存储 Go 基础和只面向 Sense 审计 relay 的最小 `bell-api`,但没有公共管理 API,Brain 仍为目录占位。
Sense 脚手架和 PostgreSQL `001`~`015` 已实现;Bell 已有事件校验/不可变存储 Go 基础和只面向 Sense 审计 relay 的最小 `bell-api`,但没有公共管理 API。Brain 已有 T-017 单路工程原型,包括合成/RTSP source、HOG 演示 detector、track、zone entry、回环服务与自包含 UI;尚无生产模型、GPU pipeline、持久 Outbox 或 Bell ingress。
## 10. 开发顺序
- M0 不写生产代码。
- M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。
- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。
- M3 Brain 与 Bell 同时起步,事件契约首次被真实使用;按项目负责人调整,T-017 后先补 Sense 回环 NVR 管理面纵切,再继续 Brain→Bell 事件 ingress。
- M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。
M3 先执行不少于 2 周的 dry-run,冻结现场标注集,按规则报告召回率和每路每天误报数;现场基线评审后才把数值阈值写入站点验收附件。算法效果指标与系统 SLA 分开验收。
+3
View File
@@ -38,6 +38,9 @@
- Bell 事件校验、不可变存储和 ULID。
- T-015:建立 Bell Go 事件域基础,复制并校验冻结 v0.1 schema,由 Bell 生成平台 ULID,执行六项代码断言,并以 `bell_runtime` 最小权限保存不可变事件和 append-only outcome;不冻结 Brain transport 或公共 API。
- T-016:冻结并实现 Sense Outbox → Bell 内部审计 relay;使用 HMAC、nonce 收据、数据库时钟 lease/fencing、逐项确认和 dead letter,在不共享 schema 权限的前提下写入 Bell 不可变全局审计事实。
- T-017:建立 Brain 单路匿名区域事件工程原型;默认合成 fixture、可选 MediaMTX/RTSP,展示 detector port、track、多边形进入判定和不含平台 ID 的候选事实,不把 HOG/fixture 宣称为生产模型或效果证据。
- T-018:优先建立 Sense NVR 管理面首个可运行纵切;复用 Control API 和 MediaMTX,以回环工程控制台展示设备、配额、收敛状态和最多 4 路按需 WebRTC 预览,不包含录像/回放或非回环生产认证。
- T-019:建立 Brain→Bell 可靠业务事件 ingress;独立冻结数字事件身份到 Bell 逻辑身份的绑定、HMAC 认证、SQLite 持久 Outbox、Bell 永久来源收据和跨重启幂等确认,不得把 T-017 内存事件环当作生产投递。
- 规则引擎、场景包加载、预警状态机与双路径投递。
- 最小 Web/App 处置流程、RBAC 与审计。
- 现场误报基线和反馈队列。
+8
View File
@@ -106,6 +106,14 @@
- 验收:联系人维护身份、角色、值班组和已验证通道,不出现排班轮换字段;排班维护时区、班次、周轮换、生效日期、临时替班和版本;发布前发现空档/重叠并阻止发布,可预览当前及未来值班人;投递固化收件人、通道与排班版本快照;交接进行中 Alert 不静默修改未来排班。
- 关联:RQ-C-24~RQ-C-27、RQ-C-33,IX-012、IX-023。
## US-014 演示单路匿名区域事件链
- 角色:售前/实施工程师、客户项目负责人。
- 目标:在没有完整 Bell 业务闭环前,直接看到一条视频源如何形成匿名人员框、track、区域命中和事件候选,并能当场调整多边形区域。
- 价值:尽早验证客户能理解的主链路,同时把“工程已连通”和“生产模型效果已验收”严格分开。
- 验收:默认合成回放无外部条件即可重复运行且始终标识为 fixture;真实流只通过仓库外配置接入且页面不显示 URL/凭据;HOG 适配器明确不是生产模型,无检测时不伪造人员框;区域支持画布加点和键盘坐标等效路径;候选事实只有 `source_event_id`,不伪造 Bell 平台 ID、Alert 或处置状态。
- 关联:RQ-C-11~RQ-C-15,IX-017、IX-024;T-017。
## 追溯规则
新增 P0 UI 任务必须引用至少一个 US 和一个 IX;若没有 UI,任务文件明确写“不适用”。需求变化先更新用户故事和交互清单,再改页面。
+1
View File
@@ -27,6 +27,7 @@
| IX-021 | 值班交接 | 清单覆盖未 ack、处置中和升级中的 Alert,显示交班人、接班人、备注及下一次升级时间;接班确认写审计,交接期间不暂停或重置升级链,未确认时不转移责任 | US-011 | M3 |
| IX-022 | 规则验收报表 | 按规则版本与冻结样本窗口展示召回率、每路每天误报数和计算样本量;支持保留口径的导出;不提供跨场景统一准确率 | US-012 | M4 |
| IX-023 | 联系人与值班排班 | 在“升级链”内部以升级策略、值班与排班、联系人和通道三个二级模块统一设计;联系人与排班共享人员/值班组/已验证通道主数据但分对象维护;排班覆盖站点时区、周轮换、生效日期、临时替班、空档/重叠冲突、当前/未来值班人预览和版本发布审计;交接班只转移进行中 Alert,不暗改未来排班 | US-005、US-011、US-013 | M3/M4 |
| IX-024 | Brain 单路工程演示 | 固定展示来源类型、fixture/真实检测边界、连接状态、detector 名称、帧序号、处理耗时、匿名 track、区域内外和最近候选;合成/离线数据不得伪装为模型输出。区域支持画布加点、撤销、清空、保存和坐标表单等效路径;保存失败保留草稿。页面不得展示流 URL、凭据、平台事件 ID、Alert 或虚构准确率 | US-014 | M3 |
## 全局状态
+16 -5
View File
@@ -4,19 +4,21 @@
## 当前阶段
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储及 Sense→Bell 全局审计 relay 基础。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、可选签名 relay、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含事件 v0.1 校验/不可变存储、append-only outcome,以及只服务 Sense 审计的最小 `bell-api` 和全局审计 repository,但仍没有 Brain 事件 ingress、公共管理服务/JWT、规则/Alert 或 Web/H5。
- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;M3 已建立 Bell 不可变事件存储、Sense→Bell 全局审计 relay 及 Brain 单路匿名区域事件工程原型。客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。
- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、可选签名 relay、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 已包含事件 v0.1 校验/不可变存储、append-only outcome,以及只服务 Sense 审计的最小 `bell-api` 和全局审计 repository。Brain 已包含单路 source/detector/track/zone-entry 流水线和回环可视化页,但仍没有生产模型/GPU pipeline、Brain 事件 ingress、公共管理服务/JWT、规则/Alert 或正式 Web/H5。
- 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。
## 仓库现实
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 内部审计 receiver;`Brain/` 仍只有目录占位。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。
- `Sense/` 已有 Go module 与 `cmd/sense-api`;`Bell/` 已有事件域 Go module 和最小 `cmd/bell-api` 内部审计 receiver;`Brain/` 已有 Python 3.10 单路工程原型、19 项单元/HTTP/UI 契约测试和 `docs/design/brain/index.html` 自包含页面。Bell/Sense migration 统一位于根目录 `deploy/postgres/`。
- Sense 设备模型使用 `modality + capabilities`,SQLite 执行 v1 migration;视频配额默认 16、允许 1~128,17/128/129、新增/启用和“降低配额不关闭已有流”均有测试。
- T-009 冻结 PostgreSQL `17.10` 和 `pgx/v5 v5.10.0`,实现 `bell`/`sense` schema、NOLOGIN 权限角色、Bell Site 版本 trigger、`bell.site_quota_v1` 和 Sense PostgreSQL repository;同站点并发准入用事务级 advisory lock,配额缺失/越界/版本回退时失败关闭且不改变已有流。
- T-010 增量实现 `bell.areas`、`bell.area_policy_v1`、Area 版本观察和 `sense.device_operation_outbox`;`non_imaging_only` 拒绝成像设备创建/启用,失败不改变已有设备。设备创建/期望态受理与脱敏 Outbox 同事务,相同期望态不增加 generation 但仍审计。
- Windows 隔离测试使用 `D:\pgsql17\bin` 启动随机回环端口临时集群,`001`~`015` migration 可重放;Sense Outbox fencing、Bell event/global-audit repository、nonce 收据、权限、幂等冲突与不可变性测试通过后自动清理,现有 `D:\pgsql17\data` 和 5432 服务未被读取、停止或修改。
- T-015 冻结 Bell Go 1.26.5、JSON Schema v6.0.2 和 ULID v2.1.2;Bell 拒绝上游自报平台 ID,在内部 candidate 组装后执行冻结 v0.1 schema 与六项语义断言。`bell_runtime` 只允许追加/读取 `bell.events`、`bell.event_outcomes`;Brain transport、整数事件 ID 与现有文本逻辑 ID 的跨系统映射、公共 API 和生产隐私 resolver 仍未冻结,不能把内部 factory 当成已上线入口。
- T-016 冻结 `sense-audit-relay-v1`:每批 1~100 项、1 MiB、10 秒 deadline、300 秒时钟窗、600 秒 nonce 收据、30 秒数据库 lease、1~300 秒退避。Sense 使用 `FOR UPDATE SKIP LOCKED` 和 fencing token;Bell constant-time 校验 HMAC,逐项返回 accepted/duplicate/rejected,并把全局事实追加到不可变 `bell.audit_events`。relay 默认关闭,非回环两端必须 HTTPS/TLS,key 只从仓库外文件读取。
- T-017 冻结的只是工程原型:Python 3.10.11、NumPy 1.26.4、OpenCV 4.9.0.80;默认 2 FPS 合成 fixture,可选从仓库外文件读取 MediaMTX/RTSP。合成框不是模型输出,HOG/SVM 不是生产 detector,100 项内存事件环不是可靠投递;候选不含 Bell 平台 ID。
- T-018 Sense 回环工程控制台已通过项目负责人产品验收:默认关闭并强制 Sense/MediaMTX 播放端显式回环,直接读取 Control API v1 的设备、配额、期望态/实际态/收敛事实;设备 cursor 每页 16 项,只有成像、启用、在线且已收敛的设备可选,最多 4 路按需嵌入 MediaMTX WebRTC 页面。token 只驻留页面内存,刷新即丢失;该验收不包含录像/回放、非回环生产认证、真实 16 机或生产 SLA。
- MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。
- T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。
- T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。
@@ -53,6 +55,14 @@ go -C Bell vet ./...
go -C Bell build ./...
```
Brain 单路工程原型验证与启动:
```powershell
python -m unittest discover -s Brain/tests -p "test_*.py" -v
python -m compileall -q Brain
python -m Brain.yovision_brain --source synthetic
```
跨平台直接验证:
```powershell
@@ -86,17 +96,18 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针
- S2 真实生产试点的未成年人影像、公共安全视频法规适用性和最终留存政策仍需客户/法务确认,阻塞 M3 上线但不阻塞 M1 实验室骨架。
- 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。
- 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。
- Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
- 生产 Brain 的 Python/Savant/DeepStream 精确版本、目标硬件和 Bell 前端栈尚未冻结;T-017 的 Python/OpenCV 只适用于工程原型,不能外推为生产选择。Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。
- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-016 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、最小权限登录角色、外部 Control API/relay key 文件、TLS 证书与备份方案。
- 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。
## 下一步
客户网络仍未提供,T-013 WireGuard 继续后置。T-015/T-016 已分别落地 Bell 不可变事件存储和 Sense 全局审计 relay;下一项应独立冻结 Brain→Bell 业务事件 ingress,不把规则/Alert 或公共管理 API 混入。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014~T-016 不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。
客户网络仍未提供,T-013 WireGuard 继续后置。T-018 Sense NVR 管理面已通过产品验收;当前执行 T-019,独立冻结并实现 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁;上述本地任务均不解除 T-007/T-013,也不形成真实多路、模型效果或生产 SLA 承诺。
## 已知风险
- M0 临时 NVR 容易被误当生产基线,必须持续隔离。
- 16 路默认值容易被写死,任务评审需专项搜索和测试边界值 17/128/129。
- 未经真实硬件压测不能给出 GPU 路数承诺。
- 合成 fixture 和 HOG 演示框容易被误读为生产 AI 效果;页面、日志和交付说明必须持续显示“非模型输出/非生产模型”,效果验收必须使用冻结模型、数据集和目标硬件。
- Gitea 使用 HTTP;token 传输风险只在本机私有配置中接受,不把 token 或实例配置写入仓库。
+476
View File
@@ -0,0 +1,476 @@
<!doctype html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="brain-demo-token" content="__BRAIN_DEMO_TOKEN__">
<link rel="icon" href="data:,">
<title>YoVision Brain · 单路区域事件工程原型</title>
<style>
:root {
--bg: #0b1120;
--surface: #111a2b;
--surface-2: #172236;
--surface-3: #1e2c43;
--text: #f5f7fb;
--muted: #aebbd0;
--subtle: #8291a9;
--line: #2b3a52;
--primary: #8b7cf6;
--primary-strong: #7567e8;
--cyan: #38bdf8;
--success: #34d399;
--warning: #fbbf24;
--danger: #fb7185;
--focus: #b8e5ff;
--radius: 14px;
--shadow: 0 18px 48px rgba(0, 0, 0, .28);
}
* { box-sizing: border-box; }
html { min-width: 320px; background: var(--bg); }
body { margin: 0; min-height: 100dvh; background: var(--bg); color: var(--text); font: 14px/1.55 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; }
button, input, textarea { font: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, textarea:focus-visible, a:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
.skip-link { position: fixed; z-index: 100; top: 8px; left: 8px; padding: 10px 14px; border-radius: 8px; background: white; color: #101827; transform: translateY(-150%); }
.skip-link:focus { transform: none; }
.prototype-banner { min-height: 30px; padding: 5px 16px; display: flex; align-items: center; justify-content: center; gap: 8px; background: #f4c84a; color: #241b00; font-size: 12px; font-weight: 800; text-align: center; letter-spacing: .04em; }
.app-shell { width: min(1540px, 100%); margin: 0 auto; padding: 0 18px 24px; }
.topbar { min-height: 72px; display: flex; align-items: center; gap: 18px; border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 11px; min-width: 230px; }
.brand-mark { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: linear-gradient(145deg, var(--primary), var(--cyan)); color: white; font-weight: 900; box-shadow: 0 8px 24px rgba(56, 189, 248, .18); }
.brand strong, .brand span { display: block; }
.brand strong { font-size: 16px; }
.brand span { color: var(--muted); font-size: 12px; }
.top-context { margin-left: auto; display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.chip { min-height: 32px; padding: 5px 10px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--muted); white-space: nowrap; }
.chip strong { color: var(--text); font-variant-numeric: tabular-nums; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--subtle); }
.status-dot.online { background: var(--success); box-shadow: 0 0 0 4px rgba(52, 211, 153, .12); }
.status-dot.warning { background: var(--warning); box-shadow: 0 0 0 4px rgba(251, 191, 36, .12); }
main { padding-top: 18px; }
.page-head { margin-bottom: 14px; display: flex; align-items: flex-start; justify-content: space-between; gap: 18px; }
.page-head h1 { margin: 0 0 4px; font-size: clamp(22px, 2.4vw, 30px); line-height: 1.25; }
.page-head p { max-width: 800px; margin: 0; color: var(--muted); }
.mode-badge { min-height: 36px; padding: 7px 11px; border: 1px solid rgba(56, 189, 248, .35); border-radius: 9px; background: rgba(56, 189, 248, .09); color: #b8e5ff; font-weight: 750; white-space: nowrap; }
.grid { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(330px, .65fr); gap: 14px; }
.card { min-width: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); overflow: hidden; }
.card-head { min-height: 54px; padding: 10px 14px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--line); }
.card-head h2 { margin: 0; font-size: 15px; }
.card-head p { margin: 2px 0 0; color: var(--muted); font-size: 12px; }
.toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.btn { min-height: 44px; padding: 8px 13px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); font-weight: 750; transition: border-color .18s ease, background .18s ease, opacity .18s ease; }
.btn:hover { border-color: var(--primary); background: var(--surface-3); }
.btn:disabled { cursor: not-allowed; opacity: .45; }
.btn-primary { border-color: var(--primary-strong); background: var(--primary-strong); color: white; }
.btn-quiet { background: transparent; }
.icon { width: 18px; height: 18px; flex: 0 0 auto; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.video-stage { position: relative; aspect-ratio: 16/9; background: #050912; overflow: hidden; }
.video-stage img, .video-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.video-stage img { object-fit: contain; background: #050912; }
.video-stage canvas { touch-action: manipulation; }
.video-label { position: absolute; z-index: 2; top: 12px; left: 12px; max-width: calc(100% - 24px); padding: 7px 10px; border-radius: 8px; background: rgba(3, 8, 18, .78); color: #dce6f5; font-size: 12px; backdrop-filter: blur(8px); pointer-events: none; }
.video-footer { min-height: 48px; padding: 8px 13px; display: flex; align-items: center; gap: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
.video-footer strong { color: var(--text); font-variant-numeric: tabular-nums; }
.legend { margin-left: auto; display: flex; flex-wrap: wrap; gap: 12px; }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend i { width: 12px; height: 3px; display: inline-block; background: var(--cyan); }
.legend .zone-key { background: var(--warning); }
.metrics { padding: 12px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; border-top: 1px solid var(--line); }
.metric { min-height: 82px; padding: 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.metric span { display: block; color: var(--muted); font-size: 12px; }
.metric strong { display: block; margin-top: 8px; font-size: 20px; font-variant-numeric: tabular-nums; }
.side-stack { display: grid; align-content: start; gap: 14px; }
.form-body { padding: 14px; display: grid; gap: 12px; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 12px; font-weight: 750; }
input, textarea { width: 100%; min-height: 44px; padding: 9px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface-2); color: var(--text); }
textarea { min-height: 124px; resize: vertical; font: 13px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; }
.helper { margin: 0; color: var(--subtle); font-size: 12px; }
.form-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.inline-state { min-height: 42px; padding: 9px 10px; border-left: 3px solid var(--cyan); background: rgba(56, 189, 248, .07); color: var(--muted); font-size: 12px; }
.event-list { max-height: 330px; padding: 8px; display: grid; gap: 7px; overflow-y: auto; }
.event { padding: 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface-2); }
.event-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.event strong { font-size: 13px; }
.event time, .event code { color: var(--muted); font-size: 12px; }
.event code { display: block; margin-top: 6px; overflow-wrap: anywhere; }
.event-tag { padding: 2px 7px; border: 1px solid rgba(251, 191, 36, .35); border-radius: 999px; background: rgba(251, 191, 36, .08); color: #ffe18a; font-size: 11px; }
.empty { min-height: 120px; padding: 26px 16px; display: grid; place-items: center; color: var(--muted); text-align: center; }
.architecture-note { margin-top: 14px; padding: 13px 15px; display: grid; grid-template-columns: auto 1fr; gap: 11px; border: 1px solid rgba(139, 124, 246, .30); border-radius: 12px; background: rgba(139, 124, 246, .07); color: var(--muted); }
.architecture-note strong { color: var(--text); }
.architecture-note p { margin: 2px 0 0; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 980px) {
.grid { grid-template-columns: 1fr; }
.side-stack { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
}
@media (max-width: 700px) {
.app-shell { padding-inline: 12px; }
.topbar { align-items: flex-start; flex-direction: column; padding: 12px 0; }
.top-context { margin-left: 0; justify-content: flex-start; }
.page-head { flex-direction: column; }
.mode-badge { white-space: normal; }
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.side-stack { grid-template-columns: 1fr; }
.card-head { align-items: flex-start; flex-direction: column; }
.toolbar { width: 100%; }
.toolbar .btn { flex: 1; }
.video-footer { align-items: flex-start; flex-direction: column; }
.legend { margin-left: 0; }
}
@media (max-width: 420px) {
.prototype-banner { letter-spacing: 0; }
.chip { width: 100%; justify-content: flex-start; }
.toolbar { display: grid; grid-template-columns: 1fr; }
.toolbar .btn { width: 100%; min-width: 0; }
.metrics { grid-template-columns: 1fr 1fr; }
.metric strong { font-size: 17px; }
.form-actions { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
}
</style>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<div class="prototype-banner">
工程原型 · 合成回放不等于模型效果 · 不用于生产告警或准确率承诺
</div>
<div class="app-shell">
<header class="topbar">
<div class="brand" aria-label="YoVision Brain">
<div class="brand-mark" aria-hidden="true">Y</div>
<div><strong>YoVision Brain</strong><span>单路匿名区域事件原型</span></div>
</div>
<div class="top-context" aria-label="运行上下文">
<span class="chip"><i id="connection-dot" class="status-dot warning" aria-hidden="true"></i><span id="connection-text">正在连接</span></span>
<span class="chip">来源 <strong id="source-label">合成回放</strong></span>
<span class="chip">区域版本 <strong id="zone-version">v1</strong></span>
</div>
</header>
<main id="main" tabindex="-1">
<div class="page-head">
<div>
<h1>单路检测与区域判定</h1>
<p>验证从画面、匿名人员框、track 到区域进入候选事实的工程闭环。平台事件 ID、持久投递和 Alert 由后续 Bell 链路负责。</p>
</div>
<div id="mode-badge" class="mode-badge">加载运行状态</div>
</div>
<div class="grid">
<section class="card" aria-labelledby="preview-title">
<div class="card-head">
<div><h2 id="preview-title">实时预览</h2><p id="preview-description">检测框与区域均使用归一化坐标叠加</p></div>
<div class="toolbar">
<button id="toggle-preview" class="btn btn-quiet" type="button" aria-pressed="false">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<span>暂停刷新</span>
</button>
<button id="edit-zone" class="btn" type="button" aria-pressed="false">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m14 4 6 6L8 22H2v-6zM12 6l6 6"/></svg>
<span>画布加点</span>
</button>
</div>
</div>
<div class="video-stage">
<img id="video-frame" alt="单路视频画面;检测框与区域由叠加层描述" width="960" height="540">
<canvas id="overlay" width="960" height="540" role="img" aria-label="匿名人员检测框与危险区域叠加;键盘用户可使用右侧坐标表单编辑区域"></canvas>
<div id="video-label" class="video-label">等待首帧</div>
</div>
<div class="video-footer">
<span>帧序号 <strong id="sequence">—</strong></span>
<span>采集时间 <strong id="captured-at">—</strong></span>
<div class="legend" aria-label="画面图例">
<span><i aria-hidden="true"></i>匿名人员框</span>
<span><i class="zone-key" aria-hidden="true"></i>活动区域</span>
</div>
</div>
<div class="metrics" aria-label="推理摘要">
<article class="metric"><span>目标处理帧率</span><strong id="fps">2.0 FPS</strong></article>
<article class="metric"><span>单帧耗时</span><strong id="latency">—</strong></article>
<article class="metric"><span>当前人员</span><strong id="person-count">0</strong></article>
<article class="metric"><span>区域内人员</span><strong id="inside-count">0</strong></article>
</div>
</section>
<aside class="side-stack" aria-label="区域配置与事件候选">
<section class="card" aria-labelledby="zone-title">
<div class="card-head">
<div><h2 id="zone-title">活动区域</h2><p>3–32 个归一化顶点;保存后重置 track 区域状态</p></div>
</div>
<form id="zone-form" class="form-body">
<label for="zone-name">区域名称
<input id="zone-name" name="zone-name" maxlength="80" autocomplete="off" value="楼梯口危险区">
</label>
<label for="zone-points">顶点坐标(每行 x,y)
<textarea id="zone-points" name="zone-points" spellcheck="false" aria-describedby="zone-helper">0.55,0.30&#10;0.90,0.30&#10;0.90,0.88&#10;0.55,0.88</textarea>
</label>
<p id="zone-helper" class="helper">坐标范围 0.00–1.00。可直接输入,或启用“画布加点”后点击画面;无需精确鼠标操作。</p>
<div class="form-actions">
<button id="undo-point" class="btn" type="button">撤销一点</button>
<button id="clear-points" class="btn" type="button">清空顶点</button>
</div>
<button id="save-zone" class="btn btn-primary" type="submit">保存区域版本</button>
<div id="form-state" class="inline-state" role="status" aria-live="polite">区域尚未修改。</div>
</form>
</section>
<section class="card" aria-labelledby="event-title">
<div class="card-head">
<div><h2 id="event-title">最近候选事实</h2><p>只产生 source_event_id;Bell 才生成平台 ULID</p></div>
<span id="event-count" class="chip"><strong>0</strong> 项</span>
</div>
<div id="event-list" class="event-list" aria-live="polite">
<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>
</div>
</section>
</aside>
</div>
<section class="architecture-note" aria-label="系统边界说明">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>
<div><strong>边界清楚比动画更重要</strong><p>Sense/MediaMTX 负责稳定供流;本页只展示 Brain 的匿名检测与判定;录像、事件存储、处置和通知仍归 Bell。真实 URL、凭据和客户标识不会出现在页面或日志。</p></div>
</section>
<div id="announcer" class="sr-only" aria-live="polite"></div>
</main>
</div>
<script>
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const frame = $("video-frame");
const canvas = $("overlay");
const context = canvas.getContext("2d");
const token = document.querySelector('meta[name="brain-demo-token"]').content;
let runtimeAvailable = location.protocol === "http:" || location.protocol === "https:";
let paused = false;
let editMode = false;
let state = null;
let offlineSequence = 0;
let offlineTriggered = false;
const offlineState = () => {
offlineSequence += 1;
const phase = ((offlineSequence - 1) % 160) / 159;
const center = -0.04 + phase * 1.08;
const bbox = [Math.max(0, center - .04), .34, Math.min(1, center + .04), .82];
const inside = center >= .55 && center <= .90;
const events = state && state.events ? state.events : [];
if (inside && !offlineTriggered) {
offlineTriggered = true;
events.unshift({source_event_id: `BRN-OFFLINE-${String(offlineSequence).padStart(6, "0")}`, kind: "zone_entry", track_id: "P-DEMO-001", zone_id: "zone-demo-01", occurred_at: new Date().toISOString(), fixture: true});
}
if (!inside && center < .50) offlineTriggered = false;
return {
prototype: true,
notice: "离线 HTML 原型数据;不是服务端或模型输出。",
source: {mode: "offline", label: "离线原型回放", fixture: true, connected: true, ref: "prototype-only"},
detector: {name: "scripted_prototype", production_ready: false},
frame: {sequence: offlineSequence, width: 960, height: 540, captured_at: new Date().toISOString()},
inference: {target_fps: 2, latency_ms: 0},
zone: state && state.zone ? state.zone : {id: "zone-demo-01", name: "楼梯口危险区", version: 1, points: parsePoints(false)},
detections: bbox[2] - bbox[0] > .01 ? [{track_id: "P-DEMO-001", class: "person", bbox, detector_score: null, inside_zone: inside}] : [],
events,
last_error_code: null
};
};
function parsePoints(showError = true) {
try {
const points = $("zone-points").value.split(/\r?\n/).filter(line => line.trim()).map(line => {
const parts = line.split(",").map(value => Number(value.trim()));
if (parts.length !== 2 || parts.some(value => !Number.isFinite(value) || value < 0 || value > 1)) throw new Error();
return {x: parts[0], y: parts[1]};
});
if (points.length < 3 || points.length > 32) throw new Error();
return points;
} catch (_) {
if (showError) setFormState("请输入 3–32 行有效坐标,每个值须在 0.00–1.00。", true);
return [];
}
}
function setPoints(points) {
$("zone-points").value = points.map(point => `${Number(point.x).toFixed(3)},${Number(point.y).toFixed(3)}`).join("\n");
}
function setFormState(message, error = false) {
const target = $("form-state");
target.textContent = message;
target.style.borderLeftColor = error ? "var(--danger)" : "var(--cyan)";
}
function formatTime(value) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "—" : new Intl.DateTimeFormat("zh-CN", {hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false}).format(date);
}
function drawOfflineBackground() {
context.fillStyle = "#111a2b";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#1e2c43";
context.fillRect(0, canvas.height * .72, canvas.width, canvas.height * .28);
context.strokeStyle = "#2b3a52";
for (let x = 0; x < canvas.width; x += 80) {
context.beginPath(); context.moveTo(x, canvas.height * .72); context.lineTo(x + 80, canvas.height); context.stroke();
}
context.fillStyle = "#7dd3fc";
context.font = "700 18px Segoe UI";
context.fillText("OFFLINE PROTOTYPE DATA", 24, 38);
}
function drawOverlay() {
if (!state) return;
context.clearRect(0, 0, canvas.width, canvas.height);
if (!runtimeAvailable) drawOfflineBackground();
const points = parsePoints(false).length ? parsePoints(false) : state.zone.points;
if (points.length >= 3) {
context.beginPath();
points.forEach((point, index) => {
const x = point.x * canvas.width, y = point.y * canvas.height;
if (index === 0) context.moveTo(x, y); else context.lineTo(x, y);
});
context.closePath();
context.fillStyle = "rgba(251, 191, 36, .10)";
context.fill();
context.strokeStyle = "#fbbf24";
context.lineWidth = 3;
context.stroke();
points.forEach((point, index) => {
context.beginPath(); context.arc(point.x * canvas.width, point.y * canvas.height, 6, 0, Math.PI * 2);
context.fillStyle = "#fbbf24"; context.fill();
context.fillStyle = "#111827"; context.font = "700 10px Segoe UI"; context.fillText(String(index + 1), point.x * canvas.width - 3, point.y * canvas.height + 3);
});
}
state.detections.forEach(item => {
const [x1, y1, x2, y2] = item.bbox;
const x = x1 * canvas.width, y = y1 * canvas.height, width = (x2 - x1) * canvas.width, height = (y2 - y1) * canvas.height;
context.strokeStyle = item.inside_zone ? "#fb7185" : "#38bdf8";
context.lineWidth = 3;
context.strokeRect(x, y, width, height);
const label = `${item.track_id} · ${item.inside_zone ? "区域内" : "区域外"}`;
context.font = "700 14px Segoe UI";
const labelWidth = context.measureText(label).width + 14;
context.fillStyle = item.inside_zone ? "#be4058" : "#0878a4";
context.fillRect(x, Math.max(0, y - 25), labelWidth, 24);
context.fillStyle = "#fff";
context.fillText(label, x + 7, Math.max(17, y - 8));
});
}
function renderEvents(events) {
$("event-count").innerHTML = `<strong>${events.length}</strong> 项`;
if (!events.length) {
$("event-list").innerHTML = '<div class="empty">人员从区域外进入后,这里会出现一次候选事实。</div>';
return;
}
$("event-list").replaceChildren(...events.slice(0, 100).map(event => {
const article = document.createElement("article"); article.className = "event";
const head = document.createElement("div"); head.className = "event-head";
const title = document.createElement("strong"); title.textContent = `${event.track_id} 进入 ${state.zone.name}`;
const tag = document.createElement("span"); tag.className = "event-tag"; tag.textContent = event.fixture ? "夹具候选" : "检测候选";
head.append(title, tag);
const time = document.createElement("time"); time.dateTime = event.occurred_at; time.textContent = `${formatTime(event.occurred_at)} · ${event.kind}`;
const code = document.createElement("code"); code.textContent = event.source_event_id;
article.append(head, time, code); return article;
}));
}
function render(next) {
state = next;
$("connection-dot").className = `status-dot ${next.source.connected ? "online" : "warning"}`;
$("connection-text").textContent = next.source.connected ? "画面在线" : "来源不可用";
$("source-label").textContent = next.source.label;
$("zone-version").textContent = `v${next.zone.version}`;
$("mode-badge").textContent = next.source.fixture ? "夹具模式 · 非模型输出" : "HOG 演示适配器 · 非生产模型";
$("sequence").textContent = String(next.frame.sequence);
$("captured-at").textContent = formatTime(next.frame.captured_at);
$("fps").textContent = `${Number(next.inference.target_fps).toFixed(1)} FPS`;
$("latency").textContent = next.inference.latency_ms == null ? "—" : `${Number(next.inference.latency_ms).toFixed(1)} ms`;
$("person-count").textContent = String(next.detections.length);
$("inside-count").textContent = String(next.detections.filter(item => item.inside_zone).length);
$("video-label").textContent = `${next.source.label} · ${next.detector.name} · ${next.notice}`;
if (document.activeElement !== $("zone-name") && document.activeElement !== $("zone-points")) {
$("zone-name").value = next.zone.name; setPoints(next.zone.points);
}
renderEvents(next.events);
drawOverlay();
}
async function poll() {
if (paused) return;
if (runtimeAvailable) {
try {
const response = await fetch("/api/v1/state", {cache: "no-store"});
if (!response.ok) throw new Error();
const next = await response.json();
frame.hidden = false;
frame.src = `/api/v1/frame.jpg?t=${encodeURIComponent(next.frame.sequence)}`;
render(next);
return;
} catch (_) {
runtimeAvailable = false;
frame.hidden = true;
setFormState("未连接本地 Brain 服务,已切换为离线原型数据。", false);
}
}
render(offlineState());
}
frame.addEventListener("load", drawOverlay);
$("toggle-preview").addEventListener("click", event => {
paused = !paused;
event.currentTarget.setAttribute("aria-pressed", String(paused));
event.currentTarget.querySelector("span").textContent = paused ? "继续刷新" : "暂停刷新";
$("announcer").textContent = paused ? "预览刷新已暂停" : "预览刷新已继续";
if (!paused) poll();
});
$("edit-zone").addEventListener("click", event => {
editMode = !editMode;
event.currentTarget.setAttribute("aria-pressed", String(editMode));
event.currentTarget.querySelector("span").textContent = editMode ? "结束加点" : "画布加点";
setFormState(editMode ? "画布加点已启用;键盘用户可继续使用坐标表单。" : "画布加点已结束。", false);
});
canvas.addEventListener("pointerdown", event => {
if (!editMode) return;
const points = parsePoints(false);
if (points.length >= 32) { setFormState("区域最多 32 个顶点。", true); return; }
const rect = canvas.getBoundingClientRect();
points.push({x: Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), y: Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height))});
setPoints(points); drawOverlay(); setFormState(`已添加第 ${points.length} 个顶点,尚未保存。`);
});
$("undo-point").addEventListener("click", () => {
const points = parsePoints(false); if (points.length) points.pop(); setPoints(points); drawOverlay(); setFormState("已撤销最后一个顶点,尚未保存。", false);
});
$("clear-points").addEventListener("click", () => { $("zone-points").value = ""; drawOverlay(); setFormState("顶点已清空;至少输入 3 个顶点后才能保存。", false); });
$("zone-points").addEventListener("input", drawOverlay);
$("zone-form").addEventListener("submit", async event => {
event.preventDefault();
const points = parsePoints(true); if (!points.length) return;
const name = $("zone-name").value.trim();
if (!name) { setFormState("请输入区域名称。", true); $("zone-name").focus(); return; }
const button = $("save-zone"); button.disabled = true; button.textContent = "正在保存";
if (runtimeAvailable) {
try {
const response = await fetch("/api/v1/zones/active", {method: "PUT", headers: {"Content-Type": "application/json", "X-Brain-Demo-Token": token}, body: JSON.stringify({name, points})});
if (!response.ok) throw new Error();
const zone = await response.json();
state.zone = zone; render(state); setFormState(`区域 v${zone.version} 已保存;track 区域状态已重新建立。`);
} catch (_) { setFormState("区域保存失败,请检查本地服务后重试;草稿仍保留。", true); }
} else {
state.zone = {...state.zone, name, points, version: state.zone.version + 1}; render(state); setFormState(`离线原型区域 v${state.zone.version} 已在当前页面保存,刷新后不会保留。`);
}
button.disabled = false; button.textContent = "保存区域版本";
});
poll();
window.setInterval(poll, 500);
})();
</script>
</body>
</html>
+10 -1
View File
@@ -20,6 +20,15 @@
这些是页面职责占位,不等于已冻结 URL;实现任务须更新本文件后再编码。
## 工程演示路由
| 路由 | 所有者 | 关键约束 |
| --- | --- | --- |
| `/brain-demo` | Brain T-017 回环工程服务 | 只允许显式回环监听;可独立打开 `docs/design/brain/index.html` 使用标识清楚的离线原型数据。不是 Bell 公共业务路由,不展示流 URL/凭据,不产生平台事件 ID、Alert 或处置状态 |
| `/sense-console/` | Sense T-018 回环工程服务 | 默认关闭;复用 Control API v1,设备分页默认 16 项,最多 4 路按需 WebRTC 预览。只允许 Sense 与 MediaMTX 播放端均为显式回环地址;不是录像/回放或 Bell 公共业务路由 |
这些路由只为开发、售前和实施联调。正式客户值班端仍由 Bell `/events`、`/alerts` 等受认证路由承载;不得把 Brain 工程页或 T-018 Sense 回环控制台暴露到非可信网络或嵌入客户公共系统。
## App 候选导航
- 预警:待确认与升级中的事件。
@@ -44,6 +53,6 @@ Bell 响应式管理端的底部主导航最多 5 项;Site/Area、RBAC 与审
## 组件归属
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain。
- 业务组件放 `Bell/web/`,不放进 Sense 或 Brain;T-017 的 `/brain-demo` 是明确隔离的回环工程页,不改变该归属。
- 流状态只通过 Bell/Sense 的受控业务 API 展示,不直接把 MediaMTX 管理端暴露给业务用户。
- 共用筛选、分页、批量结果和状态时间线组件在前端脚手架确定后再分层,不提前臆造目录。
+10 -6
View File
@@ -3,12 +3,12 @@ id: T-017
title: 建立 Brain 单路匿名区域事件可视化工程原型
phase: 3
deps: [T-016]
status: TODO
status: DONE
created: 2026-08-11
issue: null
context_ref: null
claim_branch: null
work_branch: null
issue: 59
context_ref: 2e047379228a30841da7ceed8cfb86275b3c330e
claim_branch: claims/T-017
work_branch: agent/codex/T-017
write_paths:
- docs/tasks/T-017.md
- Brain/
@@ -74,4 +74,8 @@ Sense 已能稳定提供 MediaMTX 路径,Bell 已有不可变事件存储,
## 执行记录
- 2026-08-11:按用户指定顺序建立 T-017;实现尚未开始。主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。
- 2026-08-11:按用户指定顺序建立 T-017;主分支 `./init.ps1` 基线通过(68 项根测试,Sense/Bell test/vet/build 通过)。
- 2026-08-11:建立 `FrameSource → Detector → Tracker → ZoneEntryEvaluator → EventCandidate` 单路流水线。默认合成 fixture 可重复运行;可选真实流只从仓库外绝对路径文件读取 RTSP(S) URL,OpenCV HOG 仅作为非生产演示 detector port。区域进入只在 `outside → inside` 时触发,候选不含 Bell 平台 `id`。
- 2026-08-11:实现只允许显式回环地址的工程服务和自包含 `/brain-demo` 页面;HTTP body 上限 64 KiB,区域写入使用进程级临时 token,响应带 CSP、拒绝 framing、禁止缓存且不记录请求路径。桌面截图已目检;Edge CDP 在 375×900 设备视口实测 `innerWidth=clientWidth=scrollWidth=375`,可见按钮最小高度 44 px,移动端工具栏单列且无横向溢出。截图仅在系统临时目录用于 QA,未提交。
- 2026-08-11:独立 CLI 合成模式回环 smoke 通过:`health/state/frame/page/zone PUT` 均为 HTTP 200,source 为 `synthetic`、`fixture=true`、JPEG 可读、区域版本由 v1 递增至 v2。使用标准入口 `./init.ps1` 通过 68 项根测试、19 项 Brain 测试、Brain compileall,以及 Sense/Bell generate/test/vet/build;`git diff --check` 通过。
- 边界:未连接真实摄像头或目标 GPU,未引入生产模型、Brain→Bell transport、持久 Outbox、规则/Alert 或客户产品 UI;这些结果只关闭匿名单路工程原型,不构成算法效果、真实多路或生产 SLA 验收。
+103
View File
@@ -0,0 +1,103 @@
---
id: T-018
title: 建立 Sense NVR 管理面设备与实时监看纵切
phase: 3
deps: [T-004, T-014]
status: DONE
created: 2026-08-11
issue: 63
context_ref: a4427a6d2b08e6e8a86d913b24abb61ebcfff906
claim_branch: claims/T-018
work_branch: agent/codex/T-018
write_paths:
- docs/tasks/T-018.md
- Sense/cmd/sense-api/
- Sense/internal/config/
- Sense/internal/console/
- Sense/README.md
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/routes.md
- docs/current-state.md
- tests/test_sense_console_contract.py
---
## 问题 / 背景
Sense 已有 ONVIF/RTSP 接入、MediaMTX 控制、Control API v1、对账、探活和本地 16 路容量证据,但客户目前只能通过 API、脚本或静态原型理解这些能力。T-004 已确认 Sense 的五入口信息架构;本任务把其中最能证明 NVR 管理面价值的“设备 + 实时监看”落成可运行纵切,并保留总览、接入任务和运维中心的诚实入口。
本任务不是完整 NVR:常态录像仍由客户现有 NVR 承担,YoVision 暂不实现录像计划、录像索引或回放;MediaMTX 继续作为独立数据面,Sense 只提供管理面和受限播放入口。为避免在 JWT/OIDC、反向代理和 MediaMTX 外部认证尚未冻结时暴露视频,本版控制台只允许回环工程环境启用。
## 关联需求与交互(如适用)
- 用户故事:US-001、US-002、US-008、US-009;本任务只完成其中设备查询、状态理解和按需监看的首个纵切,不宣称批量导入、完整能力探测或全量运维 API 已完成。
- 交互清单:IX-002~IX-004、IX-013~IX-015、IX-019、IX-020,以及“桌面与大屏”中的按需预览和 128 路分页约束。
- 相关页面 / 路由:已确认原型 `docs/design/sense/index.html`;新增工程路由 `/sense-console/`,不冻结为 Bell 客户公共路由。
## 方案
1. 在 `Sense/internal/console/` 增加由 Go `embed` 提供的自包含 HTML/CSS/JavaScript 控制台,不引入前端框架、CDN、字体或第三方播放器依赖;外观沿用 T-004 已确认的深色接入运维台,不重新设计信息架构。
2. 控制台复用同源 Sense Control API v1。操作者输入 Site ID 和 Bearer token 后加载设备页;token 只保存在当前 JavaScript 内存中,刷新即丢失,不进入 URL、DOM 可见文本、`localStorage`、`sessionStorage`、cookie 或日志。
3. 设备列表默认每页 16 项,使用服务端 cursor,支持模态、期望态和实际态筛选;显示配额、期望态/实际态、收敛、失败码、重试时间和策略投影版本。页面只把当前页统计标为“当前页”,不伪造站点总量。
4. 实时监看只允许从当前已授权设备中选择具有 `video_capture` 能力的设备,最多同时打开 4 路;默认不自动播放。播放使用 MediaMTX v1.19.3 官方浏览器 WebRTC 页面,地址由仓库外环境配置提供,UI 不显示 RTSP/ONVIF、凭据或完整播放地址。
5. `/sense-console/` 默认关闭。启用时 Sense HTTP 监听与 MediaMTX WebRTC 基地址都必须是显式回环地址;配置含 userinfo、查询、fragment、非 HTTP(S) 或非回环主机时启动失败。页面添加 CSP、`frame-ancestors 'none'`、`nosniff`、`no-store` 和严格 Referrer Policy。
6. 总览、接入任务和运维中心继续保留为五入口结构:总览只汇总已加载事实;接入任务明确标识本纵切尚未实现批量导入;运维中心显示当前页未收敛项并提供受限 `/metrics` 入口,不伪造分片、隧道或告警数据。
7. 添加 Go handler/config/UI 契约测试;在 Edge 以 1440×900、375×812 和 812×375 检查焦点、无横向溢出、44px 触控目标、空态/鉴权失败/依赖失败和 `prefers-reduced-motion`。实流人工验收可继续使用一台已准入海康相机,不要求新增摄像头。
## 不可变约束
- 阈值 / 数值边界:站点默认 16 路、可配置 1~128;设备列表默认页长 16;本任务浏览器同时预览上限固定为 4,128 路不得一次加载全部详情或视频。
- 判定式 / 状态转换:Control API 写入受理不等于实际态收敛;未知或请求失败不得显示在线;只有 `video_capture + desired_state=enabled + actual_state=online + converged=true` 的设备可启动预览。
- 安全边界:控制台和 WebRTC 基地址仅回环;默认关闭;Bearer token 仅驻留页面内存;不得显示或持久化摄像头凭据、完整 RTSP/ONVIF URI、MediaMTX source、token、客户数据或真实视频证据。非回环/TLS/JWT/OIDC/MediaMTX 外部认证另立任务。
- 既有契约:不修改 Sense Control API v1、事件 v0.1、Bell schema 或 MediaMTX 生成客户端;MediaMTX 仍是独立二进制,Sense 不代理媒体字节;T-004 原型保持产品结构权威,本任务只实现其明确子集。
## 验收要点
- 任务相关验证:`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`python -m unittest discover -s tests -p "test_sense_console_contract.py" -v`;覆盖默认关闭、回环限制、配置拒绝、路由/安全头、内存 token、16 项 cursor 页面、最多 4 路按需预览和状态降级。
- 完整门禁:运行 `./init.ps1`、`git diff --check`。本任务不改变 PostgreSQL migration、Control API OpenAPI 或 MediaMTX 生成源,因此不触发 `scripts/test_postgres.ps1` 和生成客户端漂移以外的新增 schema 门禁;根入口仍会执行既有 generate/test/vet/build。
- 人工 / 设备验收:必需。项目负责人打开实际 `/sense-console/`,确认与 T-004 五入口一致、设备/配额/状态可理解、最多 4 路按需监看、错误状态不伪装在线;可使用一台已准入海康设备或 T-014 合成流。未确认前任务文件保持 `DOING`,Issue 使用 `status/review`,不得标 `DONE`。
- 构建产物:`Sense` Go 二进制内嵌控制台静态资源;`go -C Sense build ./...` 可重现。无独立前端构建产物。
## 边界(不改什么)
不实现录像计划、常态录像、回放、证据切片、批量 CSV 导入、Site/Area/RBAC CRUD、Bell 业务预警、Brain 推理、非回环部署或生产会话认证;不修改 `_reference/`,不复制 MiBeeNvr 或 MediaMTX UI/播放器源码,不改已确认的 `docs/design/sense/index.html`。
## 协作约束
- 责任 Agent:codex。
- 唯一写入者:codex。
- 委派:不启用。
- Gitea:主 Issue 为 #63;领取时填写 `context_ref`、`claims/T-018` 与 `agent/codex/T-018`。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
### 2026-08-11 任务定义
- 项目负责人将开发优先级从 Brain/Bell 后续链调整为先做 Sense;T-018 因此定义为 Sense NVR 管理面首个可运行纵切,原 Brain→Bell ingress 顺延为后续建议任务。
- 复用 T-004 已确认的信息架构和现有 Control API/MediaMTX 基础,不新增前端或媒体依赖;录像/回放、非回环认证和完整运维 API 明确排除。
- 基线 `./init.ps1` 通过:68 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿。
- 任务定义已合入默认分支并创建唯一 Gitea Issue #63;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。
### 2026-08-11 领取与基线
- dispatcher `ila` 从默认分支 `a4427a6d2b08e6e8a86d913b24abb61ebcfff906` 创建并读回 `claims/T-018`、`agent/codex/T-018`;Issue #63 已分配给 `ila`,标签为 `status/doing`,结构化 CLAIM 评论与本文件 `write_paths` 一致。
- T-007 仍为 `status/waiting`,不属于活跃写路径预留;T-017 的残留 PR 元数据异常不对应活跃 Issue,默认分支已包含其合并提交。
### 2026-08-11 实现与自动化验收
- `sense-api` 新增默认关闭的 `/sense-console/` 内嵌路由和运行时配置;配置层与 handler 双重拒绝非回环、userinfo、路径、query、fragment 和非 HTTP(S) 的播放基地址,控制台只能与已开启的回环 PostgreSQL Control API 一起运行。
- 自包含 HTML/CSS/原生 JavaScript 保留 T-004 已确认的运行总览、实时监控、设备、接入任务、运维中心五入口。设备页使用服务端 cursor 和固定 16 项页长;期望态、实际态、收敛与错误分别呈现,未知/请求失败不显示在线。接入任务明确标为未实现,不用假上传伪装交付。
- Bearer token 只保存于页面内存,输入后立即清空且刷新后会话清零;页面不使用 local/session storage、cookie、URL token 或 `innerHTML`。只有 `video_capture + enabled + online + converged` 可选,选择和已启动预览均硬限制为 4,iframe 停止时移除 `src`。
- Edge headless + 本地脱敏 Control API fixture 完成浏览器 QA:1440×900 显示 16 项和 `16 / 128` 配额,无横向溢出且 cursor 下一页可用;第五路选择被拒绝,实际 iframe 保持 4;375×812 切换为设备卡片和五入口底栏,无横向溢出、最小可见按钮 44 px;844×390 无横向溢出。刷新后连接对话框重新打开、token 输入为空、会话回到未建立。该 QA 只验证 UI/嵌入 URL,不替代真实 MediaMTX 解码验收。
- 浏览器 QA 首轮发现加载完成后分页按钮仍保留禁用态;已在 `setLoading(false)` 时重新计算前后页状态并复验通过。
- 任务验证通过:`node --check Sense/internal/console/assets/app.js`;`python -m unittest discover -s tests -p "test_sense_console_contract.py" -v` 4 项;`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`;`./init.ps1` 72 项根测试、19 项 Brain 测试及 Sense/Bell generate/test/vet/build 全绿;`git diff --check` 在提交前复核。
- 人工门禁已满足:项目负责人于 2026-08-11 明确回复“验收通过”,接受实际 `/sense-console/` 的五入口、设备/配额/收敛语义和 MediaMTX WebRTC 播放纵切。该确认不扩大到录像/回放、非回环生产认证、真实 16 机或生产 SLA。
### 2026-08-11 产品验收
- 项目负责人在本轮明确确认 T-018 验收通过;任务状态更新为 `DONE`,允许合并 PR #65 并关闭 Issue #63。
- 交付边界保持不变:这是默认关闭的 Sense 回环 NVR 管理面纵切,常态录像仍由客户既有 NVR 承担,后续 Brain→Bell ingress 仍需独立 T-019。
+86
View File
@@ -0,0 +1,86 @@
---
id: T-019
title: 建立 Brain 到 Bell 的可靠事件入站链路
phase: 3
deps: [T-015, T-017]
status: TODO
created: 2026-08-11
issue: 67
context_ref: null
claim_branch: null
work_branch: null
write_paths:
- docs/tasks/T-019.md
- docs/contracts/
- Brain/
- Bell/
- deploy/postgres/
- scripts/test_postgres.ps1
- docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/06-tasks.md
- docs/api.md
- docs/current-state.md
- tests/test_brain_event_ingress_contract.py
- tests/test_postgres_contract.py
---
## 问题 / 背景
T-017 已能产生匿名区域事件候选,T-015 已能在 Bell 内部校验并保存不可变事件,但两者之间没有可部署的传输、认证、逻辑身份映射或跨重启幂等确认。直接把 T-017 的 100 项内存事件环当作投递队列,会在进程退出、网络中断或 Bell 已接收但 Brain 尚未确认时丢失或重复事件;让 Brain 自报 Bell 平台 `id` 又会破坏冻结事件契约的所有权。
本任务建立一个默认关闭、可本地联调的可靠纵切:Brain 把完整 v0.1 候选先写入 SQLite Outbox,再通过内部 HMAC HTTP 端点投递;Bell 在一个 PostgreSQL 事务中验证身份、Area 隐私策略、候选语义并保存事件与永久来源收据。该纵切为后续证据切片、规则、Alert 与 ack 提供可信事件入口,但不提前实现这些能力。
## 关联需求与交互(如适用)
- 用户故事:US-005 的“判定结果形成事件”基础链路;不在本任务内实现业务预警、处置或误报反馈。
- 交互清单:不适用。本任务是内部事件 ingress 和后台可靠投递,不新增客户页面或公共路由。
- 相关页面 / 路由:新增内部 `POST /internal/v1/event-candidates`;T-017 `/brain-demo` 只增加脱敏投递状态,不把内部地址、key 或 payload 暴露给页面。
## 方案
1. 在 `docs/contracts/brain-event-ingress-v1.openapi.json` 冻结单事件 envelope、响应、稳定错误码和 HMAC 规则。请求包含 `schema_version=1`、`producer_id` 与不含 Bell `id` 的完整 event v0.1 candidate;Brain 提供 `source_event_id`,Bell 生成并返回平台 `evt_` ULID。正文上限 1 MiB,deadline 10 秒。
2. 复用 T-016 已验证的 HMAC-SHA256 canonical 形式:method、path、Unix 秒、随机 nonce、body SHA-256 以换行连接;使用独立的仓库外 key 文件,并把每个 key 绑定到一个 `producer_id`。允许 300 秒时钟偏差,nonce 防重收据保留 600 秒。回环可用 HTTP,非回环必须 HTTPS;事件 key 与审计 relay key 不混用。
3. 新增 Bell 所有的 `event_ingress_bindings`,把 `(producer_id, tenant_id, site_id, device_id)` 数字事件身份绑定到现有 Bell tenant/site/device/area 逻辑身份和 `video` 模态。入站必须 fail closed:绑定缺失、禁用、站点/Area 已删除、设备或 Area 归属不一致、非视频或 `capture_policy != video_allowed` 均拒绝。首版只提供受控 SQL 配置方式,不新增公共绑定管理 API。
4. 新增永久 append-only `event_ingress_receipts`,以 `(producer_id, source_event_id)` 唯一,保存 canonical candidate SHA-256 与 Bell event ID;另用短期 `event_ingress_nonces` 防请求重放。Bell 用单事务和事务级 advisory lock 完成 nonce、来源收据、事件创建与响应:同来源且同 payload 返回原 Bell ID 和 `duplicate`,同来源不同 payload 返回 `source_event_conflict`,不得产生第二条事件。
5. `bell-api` 增加默认关闭的事件 ingress 配置和独立 handler。handler 使用现有 event factory、数据库隐私策略与证据授权校验;候选无法通过 schema、代码级语义或隐私断言时返回不可重试 4xx,数据库暂时失败返回可重试 503。Bell 事件和来源收据无 UPDATE/DELETE 权限;只有 nonce 表允许按 TTL 清理。
6. Brain 使用 Python 标准库实现 v0.1 mapper、SQLite Outbox、HTTP client 和 worker,不新增生产 ML 或消息总线依赖。启用配置只从仓库外绝对路径 JSON 读取,secret 另从仓库外文件读取;SQLite 文件也必须是仓库外绝对路径。默认关闭时 T-017 行为不变。
7. mapper 将 `EventCandidate` 转为完整 v0.1 candidate,不发送 `source_ref`、摄像头 URI、凭据或 Bell 平台 `id`。fixture 明确映射为 `outcome=test/outcome_source=auto`,真实源先保持 `outcome=unknown`;主传感器固定为已绑定的视频设备,区域、track、配置版本和候选版本只进入契约允许字段。
8. Outbox 先持久化后暴露成功,WAL 模式,有界待处理量 10,000、单 payload 1 MiB、1~300 秒指数退避、最多 100 次尝试。`accepted/duplicate` 标记 delivered;稳定校验/冲突进入 dead letter;网络、5xx 和认证故障重试直至预算耗尽。Bell 成功后 Brain 在本地 ack 前崩溃,重启重投必须得到同一个 Bell ID。
9. 添加 Python mapper/outbox/client/worker/运行时测试、Go auth/handler/store 测试、OpenAPI 静态契约测试,以及隔离 PostgreSQL migration replay、最小权限、并发幂等和重启重试联调。代码图 MCP 本轮不可用时,将定向读取/`rg` 的降级和实际验证结果记录在执行证据中。
## 不可变约束
- 阈值 / 数值边界:正文与单 Outbox payload 最大 1 MiB;HTTP deadline 10 秒;时钟偏差 300 秒;nonce 保留至少 600 秒;Brain 待投递上限 10,000,重试 1~300 秒、最多 100 次。默认站点 16 路、可扩展 128 路不变,这些投递阈值不得成为设备路数硬上限。
- 判定式 / 状态转换:`queued -> delivering -> delivered | dead_letter`;只有 Bell `accepted/duplicate` 可进入 delivered。`(producer_id, source_event_id, candidate_hash)` 相同返回原 Bell ID;来源相同但 hash 不同永久冲突。事件、来源收据 append-only,Bell 平台 ID 只能由 Bell 生成。
- 安全边界:key、连接串、Brain 配置、SQLite Outbox、摄像头 URI/凭据和真实事件数据都在仓库外;非回环必须 HTTPS;producer 不能自报未绑定身份;未知/删除/策略拒绝必须 fail closed。响应、日志、页面和提交物不得回显 secret、完整 URI、原始 payload 或客户信息。
- 既有契约:最终事件必须严格符合 `docs/raw/contracts/event-v0.1.schema.json` 与语义 README;未知顶层字段拒绝,只能通过 `ext` 扩展。Sense/Bell 现有逻辑身份、Area `capture_policy`、T-015 Bell ULID 与不可变存储、T-016 审计 relay 均保持兼容;事件 ingress 使用独立端点、表和 key。
## 验收要点
- 任务相关验证:`python -m unittest discover -s Brain/tests -p "test_*.py" -v`、`python -m compileall -q Brain`、`go -C Bell test ./...`、`go -C Bell vet ./...`、`go -C Bell build ./...`、`python -m unittest discover -s tests -p "test_brain_event_ingress_contract.py" -v`、`./scripts/test_postgres.ps1`。
- 完整门禁:运行 `./init.ps1`、三项文档治理基线与 `git diff --check`。PostgreSQL migration 必须从空库执行两遍且旧 migration 指纹不漂移;并发同源投递只落一条 event 和一条永久 receipt,运行角色不能更新/删除 event 或 receipt。
- 人工 / 设备验收:不需要新增摄像头或目标 GPU。使用 T-017 synthetic fixture 和本机隔离 PostgreSQL 完成端到端 smoke:首次返回 accepted、模拟 ack 前崩溃后重投返回 duplicate 且 Bell ID 相同;伪造签名、过期时间、重放冲突、未绑定身份和 Area 隐私拒绝均按契约失败。结果不构成真实多路、算法效果或生产 SLA。
- 构建产物:Bell `bell-api` 可执行程序与 Brain 源码 worker;OpenAPI、migration 和仓库外配置/key 示例写入各 README。不得提交运行时数据库、真实 key 或事件样本。
## 边界(不改什么)
不实现录像/证据切片、对象存储、业务规则、Alert、ack/升级、误报反馈、公共 JWT/OIDC、绑定管理 UI/API、消息总线、生产模型、GPU pipeline 或 64/128 路压测;不修改 `_reference/`、MiBeeNvr、MediaMTX 数据面或冻结 event v0.1 文件。T-016 审计链路保持独立。
## 协作约束
- 责任 Agent:codex。
- 唯一写入者:codex。
- 委派:不启用。
- Gitea:Issue、`context_ref`、claim 与工作分支在双向映射及 dispatcher 分配后回填。
任何新增写路径先检查与其他活跃任务是否重叠;同一时刻只有一个 Agent 修改本任务的 `write_paths`。
## 执行记录
### 2026-08-11 任务定义
- T-019 独立冻结 Brain→Bell 业务事件身份、持久重试、认证和幂等 ingress;不复用 T-017 内存事件环,也不扩张到规则、Alert 或证据链。
- 当前会话未暴露 codebase-memory MCP 图工具,代码发现按仓库规则降级为定向读取与 `rg`;任务定义前主分支 `./init.ps1` 基线通过:72 项根测试、19 项 Brain 测试,以及 Sense/Bell generate/test/vet/build 全绿。
- 任务定义已合入默认分支并创建唯一 Gitea Issue #67;本映射提交合入后才添加 `status/todo` 并允许 dispatcher 分配。
+2 -2
View File
@@ -10,9 +10,9 @@
$ErrorActionPreference = "Stop"
Set-Location -Path $PSScriptRoot
# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。
$InstallCmd = "go -C Sense mod download; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell mod download"
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
$VerifyCmd = "python scripts/validate_agent_context.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s tests -p 'test_*.py'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python scripts/validate_harness_governance.py; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -c 'from importlib.metadata import version; assert version(`"numpy`") == `"1.26.4`"; assert version(`"opencv-python`") == `"4.9.0.80`"'; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m unittest discover -s Brain/tests -p 'test_*.py' -v; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; python -m compileall -q Brain; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense generate ./internal/mtx ./internal/controlapi; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Sense build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell test ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell vet ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }; go -C Bell build ./...; if (`$LASTEXITCODE -ne 0) { exit `$LASTEXITCODE }"
$StartCmd = "go -C Sense run ./cmd/sense-api"
function Assert-Configured {
+2 -2
View File
@@ -12,9 +12,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
# Sense/Bell 使用锁定 Go toolchain/module;生成漂移、测试、vet 与构建均进入标准门禁。
# Sense/Bell 使用锁定 Go toolchain/module;Brain 原型依赖只做精确版本检查,不自动污染全局 Python 环境。
INSTALL_CMD=(bash -lc "go -C Sense mod download && go -C Bell mod download")
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...")
VERIFY_CMD=(bash -lc "python3 scripts/validate_agent_context.py && python3 -m unittest discover -s tests -p 'test_*.py' && python3 scripts/validate_harness_governance.py && python3 -c 'from importlib.metadata import version; assert version(\"numpy\") == \"1.26.4\"; assert version(\"opencv-python\") == \"4.9.0.80\"' && python3 -m unittest discover -s Brain/tests -p 'test_*.py' -v && python3 -m compileall -q Brain && go -C Sense generate ./internal/mtx ./internal/controlapi && git diff --exit-code -- Sense/internal/mtx/generated/client.gen.go Sense/internal/controlapi/generated.gen.go && go -C Sense test ./... && go -C Sense vet ./... && go -C Sense build ./... && go -C Bell test ./... && go -C Bell vet ./... && go -C Bell build ./...")
START_CMD=(go -C Sense run ./cmd/sense-api)
ensure_configured() {
+65
View File
@@ -0,0 +1,65 @@
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ASSETS = ROOT / "Sense" / "internal" / "console" / "assets"
class SenseConsoleContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.html = (ASSETS / "index.html").read_text(encoding="utf-8")
cls.css = (ASSETS / "app.css").read_text(encoding="utf-8")
cls.javascript = (ASSETS / "app.js").read_text(encoding="utf-8")
def test_console_is_self_contained_and_preserves_five_entry_ia(self):
self.assertIn('href="/sense-console/app.css"', self.html)
self.assertIn('src="/sense-console/app.js"', self.html)
self.assertNotRegex(self.html, r"https?://")
self.assertNotIn("<script>", self.html)
self.assertNotIn("<style>", self.html)
for view in ("overview", "monitor", "devices", "onboarding", "operations"):
self.assertGreaterEqual(self.html.count(f'data-view="{view}"'), 2)
self.assertIn(f'data-page="{view}"', self.html)
def test_capacity_and_preview_bounds_are_explicit(self):
self.assertIn('params.set("limit", String(state.config.page_size))', self.javascript)
self.assertIn("state.selected.size >= state.config.max_active_previews", self.javascript)
self.assertIn('state.config.page_size !== 16', self.javascript)
self.assertIn('state.config.max_active_previews !== 4', self.javascript)
self.assertIn('state.config.recording_and_playback !== false', self.javascript)
self.assertIn('$("#nextPage").disabled = loading || !state.nextCursor', self.javascript)
self.assertIn('$("#prevPage").disabled = loading || state.pageIndex === 0', self.javascript)
self.assertNotRegex(self.javascript, r"\.slice\(\s*0\s*,\s*16\s*\)")
def test_token_is_memory_only_and_sensitive_addresses_are_not_rendered(self):
for forbidden in ("local" + "Storage", "session" + "Storage", "document.cookie"):
self.assertNotIn(forbidden, self.javascript)
self.assertIn('state.token = ""', self.javascript)
self.assertIn('tokenInput.value = ""', self.javascript)
self.assertNotRegex(self.html, r"rtsp://|onvif://|/whep")
self.assertNotRegex(self.javascript, r"rtsp://|onvif://")
self.assertNotIn("innerHTML", self.javascript)
def test_accessibility_and_responsive_guards_are_present(self):
self.assertIn('href="#main-content"', self.html)
self.assertIn('aria-live="polite"', self.html)
self.assertIn('aria-current="page"', self.html)
self.assertIn("@media (max-width: 760px)", self.css)
self.assertIn("min-height: 44px", self.css)
self.assertIn("prefers-reduced-motion", self.css)
self.assertIn(":focus-visible", self.css)
ids = re.findall(r'\bid="([^"]+)"', self.html)
self.assertEqual(len(ids), len(set(ids)))
for attributes, content in re.findall(r"<button\b([^>]*)>(.*?)</button>", self.html, re.S):
visible_text = re.sub(r"<[^>]+>", "", content).strip()
self.assertTrue(
"aria-label=" in attributes or visible_text,
f"button lacks an accessible name: {attributes}",
)
if __name__ == "__main__":
unittest.main()