Add read-only camera settings page and dashboard stream summary

Read-only settings and stream URLs (vrek gol-2nxgqjn), shaped by a
read-only probe of the real cameras (findings fnd-*) and decisions
dec-s205dvs and dec-gtw493s.

- src/lib/camera-settings.ts: reads streams, image, time/NTP and
  network over ONVIF behind a CameraSettingsSource interface, so an
  ISAPI source can be added later. Sections are read independently
  and fail on their own with fixed text. RTSP URLs have credentials
  stripped and use the registry host. camera.ts exports its cached
  connect() and the profile helpers it shares with it (iss-7eaywtf).
- /cameras/[id]: server-rendered page with an access check, 404 for
  unknown ids or non-private hosts, and a streamed Suspense section.
  Shows streams (with copyable RTSP URLs and an ONVIF-login note),
  image, time/NTP with a drift warning, and network, plus Refresh
  and back. Copy falls back to execCommand over plain LAN HTTP,
  where the clipboard API is unavailable (iss-s935a4n).
- Dashboard card: the name links to the camera page; a main-stream
  summary line ("Main 4096×1860 · H.264 · 20 fps"); a manufacturer
  of "ONVIF" is hidden (iss-pv2bvzj).

522 tests, 99.9% line coverage. Checked by the user in a browser
against both cameras. Refreshes the vrek export (the icon principle
now covers the page's new icons).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 13:00:37 -05:00
parent b05047d8e5
commit 0ec434ca26
16 changed files with 2009 additions and 10 deletions

View File

@ -423,3 +423,85 @@
{"id":"evt-0aznmpcdcg5p","type":"edge.added","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-8ev5m89","to":"gol-146a3cb"},"at":"2026-09-19T17:35:04.938Z","parents":["evt-20am9fjb935n"],"hash":"3a3810eedfea771a59f6c16952efbf7486403f311c578f2b0aa722195231fa54"}
{"id":"evt-gatzwhb87efd","type":"edge.added","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-8ev5m89","to":"gol-wqf95dq"},"at":"2026-09-19T17:35:04.939Z","parents":["evt-0aznmpcdcg5p"],"hash":"a9535734194fc658c2fb9c0094e8b8bcf6be3e3f1f0015f5c3ab985ccba7765c"}
{"id":"evt-n0bphgm2fydj","type":"edge.added","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"pri-8ev5m89","to":"dec-1vnz5fw"},"at":"2026-09-19T17:35:09.439Z","parents":["evt-gatzwhb87efd"],"hash":"4594421840fb035833e09ea618381aea810035288b5410e209bd321fae616bc4"}
{"id":"evt-60ngdx4pts6d","type":"node.created","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"goal","title":"Read-only camera settings and stream URLs on the dashboard","body":"Each camera card shows its key settings and how to reach its video, without changing anything on the camera. Settings: per stream (main/sub) codec, resolution, frame rate and bitrate; image basics such as day/night mode; clock and time sync (NTP) state; model, firmware and serial. Stream URLs: the RTSP address per stream, ready to paste into VLC or an NVR, with credentials never embedded in the URL or sent to the browser. Read over ONVIF where it's enough and over Hikvision ISAPI (shared by Annke) where it isn't, behind a vendor module so the UI never branches on brand (pri-p9h51hx). Read-only by design: nothing here writes to a camera. Picks up the 'key settings' part of the retired gol-146a3cb; changing settings is gol for camera configuration.","status":"active","owner":null,"attrs":{},"weight":null,"target":null,"direction":"up","unit":null},"at":"2026-09-19T17:37:49.859Z","parents":["evt-n0bphgm2fydj"],"hash":"a817a6b82651a1af75d87e7e9dd17eefeb1631733966399fb21d2346febe2d49"}
{"id":"evt-a7bezmmb9qc0","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"gol-2nxgqjn","to":"gol-6q1q5mr"},"at":"2026-09-19T17:37:49.860Z","parents":["evt-60ngdx4pts6d"],"hash":"c3ebe8aaca2e78a038d59deae99912a7031833d0de0739575aab071c3cc5210a"}
{"id":"evt-erpc1hn1d6ay","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-01np850"},"at":"2026-09-19T17:37:49.861Z","parents":["evt-a7bezmmb9qc0"],"hash":"44719ea94206797eeff9a188ba733c6bc1118c638a621c640a7fd13d2d87a141"}
{"id":"evt-9bh86smy574k","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-p9h51hx"},"at":"2026-09-19T17:37:49.862Z","parents":["evt-erpc1hn1d6ay"],"hash":"fb0c753fc9654a394ef7dac64ff439d80c1ddfc0586eea7612dda1e264e6c641"}
{"id":"evt-1rzt5s87wpb9","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-tyrxdz9"},"at":"2026-09-19T17:37:49.863Z","parents":["evt-9bh86smy574k"],"hash":"fa13d2d915a77581eede322859e714109c34a7fbdef1671a353883ead693be94"}
{"id":"evt-y34bdvfbdb8k","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-e14bahk"},"at":"2026-09-19T17:37:49.864Z","parents":["evt-1rzt5s87wpb9"],"hash":"c5a51b9fae66d91a3809921b705ce30fbbef9ecc5b8821e4abfda9f2bd862b59"}
{"id":"evt-eg049qzbbwgb","type":"edge.added","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-2nxgqjn","to":"pri-be2smzk"},"at":"2026-09-19T17:37:49.865Z","parents":["evt-y34bdvfbdb8k"],"hash":"05ec19d38ecef1fc9484de0d0a7eacdc4c3387e87b282de01a0028419347e9d1"}
{"id":"evt-9by655qt0xf7","type":"node.created","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"goal","title":"Configure cameras from the dashboard","body":"Change camera settings from the same screen instead of each camera's own web page: e.g. stream encoding (resolution, frame rate, bitrate), image and day/night settings, clock and NTP, and actions such as reboot; pan/tilt/zoom where supported. Writes go through Server Actions or validated route handlers, over ONVIF or Hikvision ISAPI (Annke shares it) behind the vendor module. Every change is validated with zod, confirmed for disruptive actions (reboot, anything that drops the stream), re-read from the camera afterwards to show what actually applied, and reported as a clear success or error. Requires an admin session (gol-wqf95dq). Picks up the 'changes made from the same screen' part of the retired gol-146a3cb, and builds on the read-only settings goal.","status":"active","owner":null,"attrs":{},"weight":null,"target":null,"direction":"up","unit":null},"at":"2026-09-19T17:37:54.299Z","parents":["evt-eg049qzbbwgb"],"hash":"5381314045a8c4f5449d14c15de474bac263102458791da199daa16bf9d2b8e2"}
{"id":"evt-hgjd5n8q793c","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"gol-8v1wqvp","to":"gol-6q1q5mr"},"at":"2026-09-19T17:37:54.300Z","parents":["evt-9by655qt0xf7"],"hash":"a19dea7cc9e625db9195110b3cb1dfeb903b31f50aa79ab6be1d5d73876dc338"}
{"id":"evt-af4q8j0hgsye","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-01np850"},"at":"2026-09-19T17:37:54.301Z","parents":["evt-hgjd5n8q793c"],"hash":"76c5c362ea623fb8465308f30c0803afcc01be04b3529f7b720ced31e3f3ed3a"}
{"id":"evt-vrkawxerr38x","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-p9h51hx"},"at":"2026-09-19T17:37:54.302Z","parents":["evt-af4q8j0hgsye"],"hash":"4d7f8beaba527b3f6a68afe4564cd49cc53debe1302169f2bd14ee6b734a45a2"}
{"id":"evt-6j5vdg23ttb6","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-tyrxdz9"},"at":"2026-09-19T17:37:54.303Z","parents":["evt-vrkawxerr38x"],"hash":"7b739238e317346073baa36d3e28ca80b46c09e8bd7bee54ec300fef8af26537"}
{"id":"evt-5vm4bzt7kyew","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-m1csgrm"},"at":"2026-09-19T17:37:54.304Z","parents":["evt-6j5vdg23ttb6"],"hash":"96ec81d9b47ce74a43efbaa432de702b3d11d4191aa9d022cb335c872d88a1a7"}
{"id":"evt-b6n8hpkdqjsy","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-qqrp49f"},"at":"2026-09-19T17:37:54.305Z","parents":["evt-5vm4bzt7kyew"],"hash":"0c66482887917c79db820bfbb904004831e192d22e6f78feddf71a29866f7554"}
{"id":"evt-qd98rwj32rde","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-e14bahk"},"at":"2026-09-19T17:37:54.306Z","parents":["evt-b6n8hpkdqjsy"],"hash":"c748376638a82e71e4f7ed8bfe6700f55a9cd4a497fde714555ceea89d722eb6"}
{"id":"evt-gjs051d8727f","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"upholds","from":"gol-8v1wqvp","to":"pri-be2smzk"},"at":"2026-09-19T17:37:54.307Z","parents":["evt-qd98rwj32rde"],"hash":"738203f17d37395ced7d66cfa843fa68117aa9fce1e389d5ec6b7e0a1b1a0734"}
{"id":"evt-nykjsnmwvxzj","type":"node.updated","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Each camera card shows its key settings and how to reach its video, without changing anything on the camera. Settings: per stream (main/sub) codec, resolution, frame rate and bitrate; image basics such as day/night mode; clock and time sync (NTP) state; model, firmware and serial. Stream URLs: the RTSP address per stream, ready to paste into VLC or an NVR, with credentials never embedded in the URL or sent to the browser. Read over ONVIF where it's enough and over Hikvision ISAPI (shared by Annke) where it isn't, behind a vendor module so the UI never branches on brand (pri-p9h51hx). Read-only by design: nothing here writes to a camera. Picks up the 'key settings' part of the retired gol-146a3cb; changing settings belongs to gol-8v1wqvp."}},"at":"2026-09-19T17:38:01.500Z","parents":["evt-gjs051d8727f"],"hash":"c7163c8951f71cd4873ca19cf0461d4713f4c62c23885d7827525284b8dc0af6"}
{"id":"evt-c7h5r80v9ceq","type":"edge.added","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"depends_on","from":"gol-8v1wqvp","to":"gol-2nxgqjn"},"at":"2026-09-19T17:38:02.692Z","parents":["evt-nykjsnmwvxzj"],"hash":"bb333c60431c0d49158a6e565ae959c617244aff8a13c4c9cf910a1ed981ae8c"}
{"id":"evt-ss7yazceygd9","type":"node.status_changed","subject":"gol-146a3cb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"active","to":"retired"},"at":"2026-09-19T17:38:04.010Z","parents":["evt-c7h5r80v9ceq"],"hash":"2e31be3b571523013547401bed8137756fe71f0293a40de3406a8cacc3ace43e"}
{"id":"evt-3ezkdkzyhrk2","type":"node.updated","subject":"gol-2nxgqjn","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"weight":1}},"at":"2026-09-19T17:38:38.157Z","parents":["evt-ss7yazceygd9"],"hash":"6865d5928a06a2c9577ba47023f0bb7531f48fae0ff85ec380eadd408a92f2d6"}
{"id":"evt-cqg4b4159vxk","type":"node.updated","subject":"gol-8v1wqvp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"weight":0.9}},"at":"2026-09-19T17:38:39.226Z","parents":["evt-3ezkdkzyhrk2"],"hash":"a5554cd400a167ac0ae20a142663a1c50b3216cde1b2c936f2852c0a31a78456"}
{"id":"evt-ejj3adqg8gn1","type":"node.created","subject":"dec-s205dvs","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Read-only settings: ONVIF first (ISAPI later), credential-free stream URLs, per-camera page","body":"User's choices, 2026-09-19, for gol-2nxgqjn. (1) Source: ONVIF only for now, using the stored Operator ONVIF login. The camera-reading code goes behind a vendor module so Hikvision ISAPI can be added later as its own goal, once we know what's missing. Rejected for now: ONVIF + ISAPI, which likely needs a second, device-level login stored per camera. (2) Stream URLs are shown without credentials (e.g. rtsp://host:554/Streaming/Channels/101), with a copy button and a note on which camera account to enter in VLC or an NVR; passwords never reach the browser (pri-tyrxdz9). Rejected: a USERNAME:PASSWORD placeholder URL. (3) Layout: a per-camera page /cameras/[id] holds all settings, and the dashboard stays an overview; this page is also the natural home for configuration (gol-8v1wqvp). Rejected: an expandable details panel on the card, and showing everything on the card. (4) The user permitted a one-off read-only probe of the real cameras (ONVIF reads, RTSP DESCRIBE, ISAPI GET; nothing written) to see what they actually report. Automated tests still use fakes (pri-e14bahk).","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:41:35.925Z","parents":["evt-cqg4b4159vxk"],"hash":"b769a2494005167f9bf489455c4c1d84b03a33b78f79e8c3e00ad7b4b79f42b0"}
{"id":"evt-br7qz2w9t36s","type":"edge.added","subject":"dec-s205dvs","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-s205dvs","to":"gol-2nxgqjn"},"at":"2026-09-19T17:41:35.926Z","parents":["evt-ejj3adqg8gn1"],"hash":"d01a6d86536dc481dffdd4ac9cee5646916fef4c646c42f8c57d4668a13ccbc2"}
{"id":"evt-k85dbe7d3ftj","type":"node.created","subject":"fnd-j1s725e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Both cameras report full per-stream encoding settings and RTSP URIs over ONVIF with the stored Operator login","body":"Two I91ET cameras, firmware V5.8.10, at 192.168.17.129 and 192.168.31.2. Profile_1 'mainStream': H.264 Main, 4096×1860, 20 fps limit, variable bitrate (ConstantBitRate=false), bitrateLimit 10240 kbps, GOP 50, quality 3. Profile_2 'subStream': H.264 Main, 1200×536, 20 fps, VBR 4096 kbps, GOP 50, quality 3. GetStreamUri (RTSP) returns rtsp://<host>/Streaming/Channels/101 (main) and /102 (sub), with ?transportmode=unicast&profile=Profile_N; no credentials are embedded and the port is the default 554. The app already receives bitrate, GOP and quality in the profile data but doesn't show them.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"one-off read-only probe run by Claude on 2026-09-19 with the user's permission; ONVIF GetProfiles/GetStreamUri"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:40.302Z","parents":["evt-br7qz2w9t36s"],"hash":"db4d5a9b9545811b76df90a5c169128dcdd806e3bbcf0777e8d0979025ae4589"}
{"id":"evt-mr233cpb084k","type":"edge.added","subject":"fnd-j1s725e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-j1s725e","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:40.303Z","parents":["evt-k85dbe7d3ftj"],"hash":"226479c8604b5b4265e93e8ef862167a82876a3e6c7b04f6ad1e2bf58da19d70"}
{"id":"evt-ccxmzx4prcmt","type":"edge.added","subject":"fnd-j1s725e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-j1s725e","to":"gol-8v1wqvp"},"at":"2026-09-19T17:42:40.304Z","parents":["evt-mr233cpb084k"],"hash":"9594c0b1e1d8d431e3c6b1059e5d87310854c6db3910fdd6fc5ebb8fe97daf6c"}
{"id":"evt-9wfmkcc39hqm","type":"node.created","subject":"fnd-g4z7c6d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"ONVIF imaging settings are readable: day/night (IR-cut), exposure, white balance, WDR, BLC, picture levels, noise reduction","body":"GetImagingSettings(VideoSource_1) on both cameras: irCutFilter AUTO (day/night switching automatic); brightness, contrast, colorSaturation and sharpness all 50; exposure AUTO (10–33333 µs, gain 0–100); whiteBalance AUTO; wideDynamicRange OFF; backlightCompensation OFF; noiseReduction level 0.5 (nested under extension.extension.extension). So the goal's 'day/night mode' and image basics need no ISAPI.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; ONVIF GetImagingSettings"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:43.116Z","parents":["evt-ccxmzx4prcmt"],"hash":"bb5f691a87f061edd875104735aa44f4b44add6d7173519d8fabe17c8e99e24f"}
{"id":"evt-9yvajdtwc985","type":"edge.added","subject":"fnd-g4z7c6d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-g4z7c6d","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:43.117Z","parents":["evt-9wfmkcc39hqm"],"hash":"fce71707585af783791666255828b115a2446973dd15addb64e7e66d0272a409"}
{"id":"evt-vbfksk3rgsev","type":"edge.added","subject":"fnd-g4z7c6d","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-g4z7c6d","to":"gol-8v1wqvp"},"at":"2026-09-19T17:42:43.118Z","parents":["evt-9yvajdtwc985"],"hash":"7e398c7e19cf86ddf4aff7f18d47b694a266ddd46b7d654325134e886ad51801"}
{"id":"evt-yfwzyk1kn6cf","type":"node.created","subject":"fnd-b73cav4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Clock, NTP and network settings are readable over ONVIF","body":"Clock drift versus the server is within 1 s on both. NTP is manual, via DNS name time.windows.com (not from DHCP). Network: eth0, IPv4 by DHCP with a /20 prefix (192.168.16.0/20, gateway 192.168.16.1), 100 Mb full duplex, MACs d0:3b:f4:04:af:2e and d0:3b:f4:04:af:54. IPv6 is enabled with a link-local and a global SLAAC address (2001:470:…). The hostname reports 'localhost', which isn't useful as a display name.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; ONVIF GetSystemDateAndTime, GetNTP, GetNetworkInterfaces, GetNetworkDefaultGateway, GetHostname"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:46.597Z","parents":["evt-vbfksk3rgsev"],"hash":"01ba415c8e3edeb4c8e5c1e285597fa58a2525dc357164aae1521042035edfb5"}
{"id":"evt-xvfjkb6gfz5f","type":"edge.added","subject":"fnd-b73cav4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-b73cav4","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:46.599Z","parents":["evt-yfwzyk1kn6cf"],"hash":"d6d7c590946c8cfb4ead73c403b8ea71d19ed57a85d32388e2d49547889d204b"}
{"id":"evt-esfr9ypd97ks","type":"node.created","subject":"fnd-nj8n90g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"The RTSP stream accepts the stored ONVIF user on both cameras","body":"An RTSP DESCRIBE on Channels/101 with Digest auth as the stored ONVIF Operator user 'camera' returned 'RTSP/1.0 200 OK' on both cameras. So the credential-free stream URLs can tell users to sign in with that same ONVIF account in VLC or an NVR; no device (web) account is needed for streaming.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; RTSP DESCRIBE only, no PLAY"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:48.834Z","parents":["evt-xvfjkb6gfz5f"],"hash":"0d9bf4669e8f7879d9e6f8cfe6daa29d42970fe4db9f1a8d3e6e60fa341d9d05"}
{"id":"evt-6h02q06frnwr","type":"edge.added","subject":"fnd-nj8n90g","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-nj8n90g","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:48.835Z","parents":["evt-esfr9ypd97ks"],"hash":"3be08e13456aee6a89b6141871b05c8982ab97af38ef28d228384f20f529a781"}
{"id":"evt-a0ntbmwd27pv","type":"node.created","subject":"fnd-hcnbvht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"ISAPI with the ONVIF user is inconsistent: 401 on one camera, 200 on the other","body":"GET /ISAPI/System/deviceInfo with Digest auth as 'camera': 192.168.17.129 → HTTP 401; 192.168.31.2 → HTTP 200 (model I91ET). Likely the second camera also has a device (web) account named 'camera' with the same password, or its ONVIF and device users are configured differently. Implication for the future ISAPI goal: ISAPI access can't be assumed from the ONVIF login; it needs per-camera verification or its own credentials.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; ISAPI GET only"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:51.646Z","parents":["evt-6h02q06frnwr"],"hash":"65dd78de0461f84ac721fd77b40800b21cd2c00cbca3a442e62a9c16a44e2faa"}
{"id":"evt-x059s99sa5x3","type":"edge.added","subject":"fnd-hcnbvht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-hcnbvht","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:51.647Z","parents":["evt-a0ntbmwd27pv"],"hash":"bc0dadfdc5a916b998b74643392ab0e2d94619da5fb6e18cf93833153a644bd2"}
{"id":"evt-b6j6tnjvkcpj","type":"edge.added","subject":"fnd-hcnbvht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-hcnbvht","to":"gol-8v1wqvp"},"at":"2026-09-19T17:42:51.648Z","parents":["evt-x059s99sa5x3"],"hash":"00d7d68951b7026d979cef0b170d2fd07fa4a12d19cde6b654924e28db8348b5"}
{"id":"evt-xby2yehfxjfv","type":"node.created","subject":"fnd-msm9prc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"The cameras report their manufacturer as 'ONVIF', not a brand","body":"GetDeviceInformation returns manufacturer 'ONVIF', model 'I91ET', firmware 'V5.8.10', serials I91ET20251117AAWRGK8004273 and …8004311, hardwareId 88. The card currently shows 'ONVIF I91ET · fw V5.8.10'; 'ONVIF' as a manufacturer is noise to users. Brand detection (Hikvision vs Annke) can't rely on this field.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"read-only probe 2026-09-19; ONVIF GetDeviceInformation"}],"as_of":"2026-09-19"}},"at":"2026-09-19T17:42:53.435Z","parents":["evt-b6j6tnjvkcpj"],"hash":"287226826bdedb2338f1adcba16d0f35b594520b9a98a3b7017a9cd138fccce0"}
{"id":"evt-tfxg7dxw16f6","type":"edge.added","subject":"fnd-msm9prc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-msm9prc","to":"gol-2nxgqjn"},"at":"2026-09-19T17:42:53.437Z","parents":["evt-xby2yehfxjfv"],"hash":"a3a1a47b8e2c4314fc168f5e71526ad13f1705394afd200e618c01d8beb6d035"}
{"id":"evt-234mkjnf06xr","type":"node.created","subject":"dec-gtw493s","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Camera page: streams+URLs, image, time/NTP, network; read on open with Refresh; name links; hide 'ONVIF' brand","body":"User's choices, 2026-09-19, for gol-2nxgqjn (following the probe findings fnd-j1s725e, fnd-g4z7c6d, fnd-b73cav4, fnd-nj8n90g, fnd-hcnbvht, fnd-msm9prc). (1) /cameras/[id] shows four sections: Streams + URLs (per profile: codec and profile, resolution, fps, bitrate with VBR/CBR, GOP, quality; RTSP URL with a copy button and a note to use the camera's ONVIF login, which RTSP accepts); Image (IR-cut day/night, exposure, white balance, WDR, BLC, picture levels, noise reduction); Time & NTP (camera clock, drift from the server with a warning above 30 s, NTP server and source); Network (IPv4 address/prefix, DHCP or static, gateway, MAC, link speed/duplex, IPv6). (2) Freshness: read once when the page opens, plus a Refresh button; no polling. Rejected: polling every 30 s. (3) Navigation: the camera name on each dashboard card links to its page, and the card gains a one-line summary (e.g. 'Main 4096×1860 · H.264 · 20 fps'). Rejected: a Details button only, or a fully clickable card. (4) A manufacturer of 'ONVIF' (or empty) counts as unknown, so the card shows 'I91ET · fw V5.8.10'; real brand names still show. Rejected: showing it as reported, or a per-camera brand field.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:44:33.625Z","parents":["evt-tfxg7dxw16f6"],"hash":"f53838266524af249d7562b7f14b969b9d51ff1525cc1811af24953d1bf59b5c"}
{"id":"evt-3hqybbe8wprr","type":"edge.added","subject":"dec-gtw493s","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-gtw493s","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:33.626Z","parents":["evt-234mkjnf06xr"],"hash":"ca6c5aaeec39f901974615a60903fae3f2486d71055be3c318c3505017ecf44d"}
{"id":"evt-875z735p1rhh","type":"node.created","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Per-camera settings page /cameras/[id] (read-only)","body":"Parent for gol-2nxgqjn, following dec-s205dvs and the camera-page decision. Done when every child is closed and the user has checked the page against both real cameras. Coverage stays at or above 95% (pri-be2smzk); tests use fakes (pri-e14bahk); icons follow pri-8ev5m89.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:44:35.653Z","parents":["evt-3hqybbe8wprr"],"hash":"f9374d2ba8e93f556efe5581d0e0b727ab92b0864caf4720baf071ae5aa6e8da"}
{"id":"evt-w0c4q5njj8xn","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-cngxt98","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:35.654Z","parents":["evt-875z735p1rhh"],"hash":"bff45c0b24fe36ffc7aa46d94abfa5c6a2e7fcc75fe3e827a021c010b6132b6b"}
{"id":"evt-zyb1vp2fxmng","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-cngxt98","to":"area:settings"},"at":"2026-09-19T17:44:35.655Z","parents":["evt-w0c4q5njj8xn"],"hash":"2d305815f1abed34a844dd73ee9253e7117fc168dd92b650116a69e1f2b3bad1"}
{"id":"evt-nvh5gb4t22nq","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-cngxt98","to":"area:ui"},"at":"2026-09-19T17:44:35.656Z","parents":["evt-zyb1vp2fxmng"],"hash":"a9f0fa118a168ca441ffbf1407b48eac143b548fe96d7fdef4f58c0ccd746599"}
{"id":"evt-gqgzzcb0xfxq","type":"node.created","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Camera settings reader: ONVIF implementation behind a vendor-neutral interface","body":"src/lib/camera-settings.ts (server-only). getCameraSettings(target) returns a client-safe DTO: streams[] (token, name, codec, H.264/H.265 profile, width, height, fps, bitrate kbps, constantBitRate, GOP, quality, rtspUrl), image (irCut, exposure, whiteBalance, wdr, blc, brightness, contrast, saturation, sharpness, noiseReduction), time (cameraTime, driftSeconds, ntp: source and servers), network (per interface: name, MAC, IPv4 address/prefix, dhcp, IPv6 addresses, speed/duplex; default gateway). Implemented with ONVIF calls on the cached connection from camera.ts (GetProfiles data, GetStreamUri, GetImagingSettings, GetSystemDateAndTime, GetNTP, GetNetworkInterfaces, GetNetworkDefaultGateway), shaped by a documented `CameraSettingsSource` interface so an ISAPI source can be added later (pri-p9h51hx). Each section is read independently: one failing call (e.g. imaging unsupported) yields that section as 'unavailable' without failing the page. RTSP URLs are sanitized: any userinfo is stripped, and the host is forced to the registry address. Auth and inactive errors map as in camera.ts. Tests use a scripted fake Cam with shapes taken from the probe findings (fnd-j1s725e, fnd-g4z7c6d, fnd-b73cav4), including Media2-style and missing fields.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:44:44.220Z","parents":["evt-nvh5gb4t22nq"],"hash":"a57e15bc20bd231f5aa1d420e4e2985fa96391fc9b3a0cb84a434c7b73ffbf2b"}
{"id":"evt-z274n0kxst0r","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-7eaywtf","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:44.221Z","parents":["evt-gqgzzcb0xfxq"],"hash":"154896f66f69f28067b7dc40effbb4481005e5a216c91359dfa9d6e78f613c80"}
{"id":"evt-gqk010w07854","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7eaywtf","to":"src/lib/camera-settings.ts"},"at":"2026-09-19T17:44:44.222Z","parents":["evt-z274n0kxst0r"],"hash":"c7a0bd30401e64c5158a7c492ee5c94af2a25edd548c264cab724625d26a5e04"}
{"id":"evt-373vmvxm7sq2","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7eaywtf","to":"src/lib/camera.ts"},"at":"2026-09-19T17:44:44.223Z","parents":["evt-gqk010w07854"],"hash":"75bb6c815ea0224375627575604f5890e42171f9fd0401e333f8ce4b4d2ecaa1"}
{"id":"evt-cxetb15ctatx","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-cngxt98","to":"iss-7eaywtf"},"at":"2026-09-19T17:44:44.224Z","parents":["evt-373vmvxm7sq2"],"hash":"069ab9298c441d45a1e1ddba8caa2db1c1e870dd42492ac5324d1db5a4ae906a"}
{"id":"evt-9qsbjnz19hyv","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-7eaywtf","to":"area:settings"},"at":"2026-09-19T17:44:44.225Z","parents":["evt-cxetb15ctatx"],"hash":"23f5f07f16477dbef80599212cfbd2ce8cb0e9ba0e09a783f1eebaa581b0f272"}
{"id":"evt-p21jybbv90p8","type":"node.created","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Build the /cameras/[id] page: four sections, Refresh, copyable RTSP URLs","body":"A Server Component page (pri-01np850) that calls requirePageAccess(), resolves the camera from the registry by id (404 for unknown or invalid ids, and only private-IP hosts per isAllowedHost), and calls getCameraSettings directly, with no fetch to our own API. Its four sections sit inside <Suspense> so the header renders immediately. Streams: a table per profile, plus the RTSP URL with a small client 'Copy' leaf (Copy → Check icon) and the note 'Sign in with the camera's ONVIF login (e.g. camera)'. Image: day/night shown as 'Auto (IR-cut)', etc. Time & NTP: camera clock in the browser's locale, drift with a warning at over 30 s, and the NTP server and its source. Network: IPv4, DHCP or static, gateway, MAC, link, IPv6. Unavailable sections say so without failing the page, and auth or inactive problems show the same guidance as the dashboard card. A Refresh button (a client leaf calling router.refresh()) and a back link to the dashboard. Icons per pri-8ev5m89, adding mappings for new concepts (e.g. ArrowLeft = back, Copy/Check = copy, Video = streams, Image or Sun/Moon = image, Clock = time, Network = network). Tests render with a faked settings reader.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:44:52.989Z","parents":["evt-9qsbjnz19hyv"],"hash":"c556d9d2a1a89d005c502dc08b3cc639ff45e011d0c522e251737ef500a8343b"}
{"id":"evt-7qyyxx2mq7sc","type":"edge.added","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-s935a4n","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:52.990Z","parents":["evt-p21jybbv90p8"],"hash":"73ad7d119a584f2c57fd54aaa685b2264b22ec454bdd4eaaf958665acdbf4aa1"}
{"id":"evt-wvb3vge5chvp","type":"edge.added","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-s935a4n","to":"src/app/cameras/[id]/"},"at":"2026-09-19T17:44:52.991Z","parents":["evt-7qyyxx2mq7sc"],"hash":"7d47fb03f9123077747a4219f47f04c53ab59d5e7c1fd014d908dbcb08b6147c"}
{"id":"evt-2nyqg79f2m78","type":"edge.added","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-7eaywtf","to":"iss-s935a4n"},"at":"2026-09-19T17:44:52.992Z","parents":["evt-wvb3vge5chvp"],"hash":"6a3c77756b61e535baaea73d6fb78f57a37e2a428b9022ca610caddfbdf52de2"}
{"id":"evt-2r58e73geybc","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-cngxt98","to":"iss-s935a4n"},"at":"2026-09-19T17:44:52.993Z","parents":["evt-2nyqg79f2m78"],"hash":"76081b066c331a0bb846d7902846dd84d9b8c20dfab774868f7bca3fac01519e"}
{"id":"evt-akxgbvb58mwv","type":"edge.added","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-s935a4n","to":"area:settings"},"at":"2026-09-19T17:44:52.994Z","parents":["evt-2r58e73geybc"],"hash":"94cab7c9e8055d67b2624bed255b0c423a86e89ca571976b97220e5d826d39d1"}
{"id":"evt-7h2qm57jgzkx","type":"edge.added","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-s935a4n","to":"area:ui"},"at":"2026-09-19T17:44:52.995Z","parents":["evt-akxgbvb58mwv"],"hash":"abb24a1d00a444152618262d308f7cb9f035fdd4c253ba89593140fcc6d31d66"}
{"id":"evt-yw0mq8fcfzzr","type":"node.created","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Dashboard card: link the name to the camera page, add a stream summary, hide 'ONVIF' as a brand","body":"On each camera card: the camera name becomes a next/link to /cameras/[id]; a one-line summary is built from the info the card already loads, e.g. 'Main 4096×1860 · H.264 · 20 fps' (main = the first or highest-resolution profile); the device line shows 'I91ET · fw V5.8.10', treating manufacturer 'ONVIF' or empty as unknown (fnd-msm9prc) while still showing real brand names. No new device calls. Tests: link href, summary text, and the brand rule for 'ONVIF', empty, and 'Hikvision'.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T17:44:56.153Z","parents":["evt-7h2qm57jgzkx"],"hash":"d45c80cf8e68882686fd6b262c5ae7efeeac92c1661afb694dc9ccaf689958ae"}
{"id":"evt-99qp337rvxfe","type":"edge.added","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-pv2bvzj","to":"gol-2nxgqjn"},"at":"2026-09-19T17:44:56.155Z","parents":["evt-yw0mq8fcfzzr"],"hash":"73ac24a1372f32c3a15acc1548f575594be1f11f872d8ce823b6122fa931f9b7"}
{"id":"evt-7r964b8eckvn","type":"edge.added","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-pv2bvzj","to":"src/app/camera-card.tsx"},"at":"2026-09-19T17:44:56.156Z","parents":["evt-99qp337rvxfe"],"hash":"0e54ab18305617b3d21c627f0fb2de19465348eb0a07f25b13764eae19b8f1a1"}
{"id":"evt-egqgafamqg6x","type":"edge.added","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-cngxt98","to":"iss-pv2bvzj"},"at":"2026-09-19T17:44:56.157Z","parents":["evt-7r964b8eckvn"],"hash":"91429b74b8ca1f3e18342bbe0a3e07cc7c6508a93d30d49230605a88a1f6b64f"}
{"id":"evt-422kqj834328","type":"edge.added","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-pv2bvzj","to":"area:ui"},"at":"2026-09-19T17:44:56.158Z","parents":["evt-egqgafamqg6x"],"hash":"2469b54f3efc7f49df6fd1c903f86520b355b71a4178f368c2dfccb49c3ea032"}
{"id":"evt-872wg16xwxe3","type":"node.created","subject":"ver-az11m1q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"getCameraSettings(target) returns a typed, client-safe DTO with four independently read sections (streams incl. credential-free RTSP URLs on the registry host, image, time/NTP with drift, network with gateways) over ONVIF, behind the CameraSettingsSource interface; one failing section is 'unavailable' with a fixed message while the others still load; connection and login errors propagate; only get* calls are made.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:48:09.526Z","parents":["evt-422kqj834328"],"hash":"66fc13edb2d27d89ae914660aab4fd2620a9b3f55931aff8e8ae37d698008dd9"}
{"id":"evt-dnd7z4yspnmd","type":"edge.added","subject":"ver-az11m1q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-az11m1q","to":"iss-7eaywtf"},"at":"2026-09-19T17:48:09.528Z","parents":["evt-872wg16xwxe3"],"hash":"24383e89462dfb9f2fe29bdda9b81dd8fa48f891f9bcfe24222d2384d60a7ca9"}
{"id":"evt-zpmx1vjh23mq","type":"verification.recorded","subject":"ver-az11m1q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/camera-settings.ts; camera.ts now exports connect(), encoderConfig() and profileToken(), with toProfile() reusing them. Tests in src/lib/camera-settings.test.ts (25) use a scripted fake camera with the exact shapes from the real-camera probe (fnd-j1s725e, fnd-g4z7c6d, fnd-b73cav4), plus Media2 profiles, stringly typed numbers and booleans, a single non-array interface, static IPv4, disabled IPv6, NTP from DHCP by IP, a bare profile, no profiles, each section failing on its own (no 'hunter2' leak), and sanitizeRtspUrl (strips credentials, forces the host, keeps port and path, accepts rtsps, rejects http and garbage); they also assert only get* methods are called. 100% lines and functions and 95% branches for the module. Full suite 441/441, coverage 99.87%, tsc, eslint and next build clean, 2026-09-19. Not run against the real cameras through this module (the probe used the same calls); the parent iss-cngxt98 requires the user's check of the page."},"at":"2026-09-19T17:48:09.529Z","parents":["evt-dnd7z4yspnmd"],"hash":"1a15b1b4ea232301c2144d17ae437ece764bf924e1c529dac90cafcf47a7b8ba"}
{"id":"evt-9mk55qmy6amx","type":"node.status_changed","subject":"iss-7eaywtf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:48:10.674Z","parents":["evt-zpmx1vjh23mq"],"hash":"2cac4816dbc162eeda4bcb7fe6251ec6a5f19b7ab8fae7e821acd7723ee70f92"}
{"id":"evt-x6cbjt1gx94r","type":"node.updated","subject":"pri-8ev5m89","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Standing rule derived from dec-1vnz5fw (Lucide chosen 2026-09-19) and applied in iss-scj42jq, iss-chrdd0v, iss-egy082x and iss-s935a4n.\n\n1. Source: icons come from lucide-react, imported by name (`import { KeyRound } from \"lucide-react\"`); Next 16 optimizes those imports by default. No other icon library, icon font or hand-drawn SVG icon without a new dependency question (pri-mz2jxpb). Check that a name exists in the installed version before using it.\n2. Meaning: an icon never carries meaning alone. Status, warnings and actions always have visible text (e.g. the status badge's 'Needs login'), and state is never conveyed by color or icon alone.\n3. Accessibility: icons next to text are decorative and get `aria-hidden`. A control with only an icon must have an `aria-label` that says what it does. Warnings keep role=\"alert\" and their text. Adding an icon must not change a control's accessible name.\n4. Sizing and layout: size with Tailwind classes, not the `size` prop: `size-3.5` in small text and badges, `size-4` in buttons, inline messages and section headings, `size-5` in banners, `size-6` in page headings. Lay out with `inline-flex items-center gap-1…2`, and use `shrink-0` beside wrapping text.\n5. Motion and color: spinners use LoaderCircle with `animate-spin motion-reduce:animate-none`. Colored icons and badges have `dark:` variants.\n6. Consistency: one concept, one icon across the app. Current mapping: Cctv = a camera (live status; camera page heading); LoaderCircle = in progress; KeyRound = login; ShieldAlert = unsecured or not activated; ShieldCheck = secured or signed in; WifiOff = offline; CircleAlert = error message; TriangleAlert = needs attention (setup needed, clock drift); RefreshCw = retry or refresh; Radar = scan; Timer = refresh rate; Trash2 = forget; Lock = save or sign-in heading; LogIn/LogOut = session; ExternalLink = opens another site in a new tab; ArrowLeft = back to the dashboard; Video = streams; SunMoon = image and day/night settings; Clock = time and NTP; Network = network settings; Copy → Check = copy to clipboard / copied. Reuse these; if a new concept needs an icon, add it here.\n7. Tests: assert icons by their `lucide-<name>` class with toContain, since Lucide also adds alias classes (Trash2 → 'trash' and 'trash-2'), and assert every rendered `svg.lucide` is aria-hidden (see src/app/icons.test.tsx).\n\nWhy: icons should make the dashboard faster to scan without making it less accessible or less consistent. Rules out: icon-only status, icons that change accessible names, ad-hoc SVGs, and mixing icon sets."}},"at":"2026-09-19T17:53:58.530Z","parents":["evt-9mk55qmy6amx"],"hash":"234f807d29c1359f6029e44ed4a88d4079f67f5f0d06f107cfea68151deeefe5"}
{"id":"evt-sw81v28rq819","type":"node.created","subject":"ver-ps9ex17","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"/cameras/[id] is a Server Component that checks access, 404s bad or unknown ids and non-private hosts, and renders the header immediately with the four settings sections streamed via Suspense (streams with copyable credential-free RTSP URLs and an ONVIF-login note; image; time and NTP with a >30 s drift warning; network), plus Refresh and back links; unavailable sections and camera errors show fixed text only.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:54:03.061Z","parents":["evt-x6cbjt1gx94r"],"hash":"d450a94ebcd51f8dc26d2a00b6d5b64789a2edc46d8221e79559cf7090eeef5a"}
{"id":"evt-rxj3dgznmqjb","type":"edge.added","subject":"ver-ps9ex17","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-ps9ex17","to":"iss-s935a4n"},"at":"2026-09-19T17:54:03.062Z","parents":["evt-sw81v28rq819"],"hash":"9ad696673ed8f995f367dfea23226ed47dc0a8d1bb02b6b2f9689a7bd3bc1c67"}
{"id":"evt-pxs1rddg4b2m","type":"verification.recorded","subject":"ver-ps9ex17","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Built by a parallel agent, reviewed by Claude. src/app/cameras/[id]/{page.tsx, settings-sections.tsx, client-controls.tsx, format.ts} + 4 test files, 100% coverage on the new files. Claude's follow-up fix: the copy button now falls back to a hidden textarea + execCommand('copy') outside a secure context, because navigator.clipboard is unavailable when the app is opened over plain HTTP on a LAN IP (it would always have said 'Copy failed'); 3 new tests cover the fallback, refusal and throw paths and cleanup. New icons were added to pri-8ev5m89. Combined suite 522/522, coverage 99.89%, tsc, eslint and next build clean (the route list includes ƒ /cameras/[id]), 2026-09-19. Not yet viewed in a browser or run against the real cameras; tracked on the parent iss-cngxt98."},"at":"2026-09-19T17:54:03.063Z","parents":["evt-rxj3dgznmqjb"],"hash":"3731fef863384af1bc1597edc4c7172bd8f1c2f32b9517bd54b67067ee79360f"}
{"id":"evt-dngy3dgedbx5","type":"node.created","subject":"ver-kmkh9c4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The dashboard card's name links to /cameras/<id>; a summary line shows the main (highest-resolution) stream, e.g. 'Main 4096×1860 · H.264 · 20 fps'; the device line hides a manufacturer of 'ONVIF' or empty ('I91ET · fw V5.8.10') and keeps real brands; no new device calls.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:54:06.076Z","parents":["evt-pxs1rddg4b2m"],"hash":"0d08c24227697ca72921d7e64073e7cfee04de903975e550ea41bc0bbe20bd2e"}
{"id":"evt-jv9fdfdw5cwz","type":"edge.added","subject":"ver-kmkh9c4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-kmkh9c4","to":"iss-pv2bvzj"},"at":"2026-09-19T17:54:06.079Z","parents":["evt-dngy3dgedbx5"],"hash":"5ab0761924edb26f38a82b9a21374e2745d8f8f93620f6102ddcb893bbb5a46e"}
{"id":"evt-1y4b5zgp33ph","type":"verification.recorded","subject":"ver-kmkh9c4","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Built by a parallel agent, reviewed by Claude. src/app/camera-summary.ts (pure: mainStream, codecLabel, streamSummary, displayBrand, deviceLine) with camera-summary.test.ts, plus 3 new tests in camera-card.test.tsx (link href; probe-like profiles give 'I91ET · fw V5.8.10' and the summary, with no 'ONVIF'; nothing before info loads). Existing 'Hikvision DS-2CD · fw V5.7' test unchanged and passing. Summary covers the main stream only; no '+N more'. Combined suite 522/522, 2026-09-19. Not yet viewed in a browser."},"at":"2026-09-19T17:54:06.080Z","parents":["evt-jv9fdfdw5cwz"],"hash":"72f691f9f289c71e6c32284ec53f121784047b70f7676a778f510c48b6629fb9"}
{"id":"evt-2qgsw9d64b3g","type":"node.status_changed","subject":"iss-s935a4n","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:54:07.127Z","parents":["evt-1y4b5zgp33ph"],"hash":"4bd51a8c2627040dad1b79f354d51b6916280f4ff051548af1f1152340cf3bd3"}
{"id":"evt-t8jhasgkfb5a","type":"node.status_changed","subject":"iss-pv2bvzj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:54:08.288Z","parents":["evt-2qgsw9d64b3g"],"hash":"c030d4aa2a94fad669d5c2cfbc72b347f8f1fd607def5c66bcf50d72d34feaae"}
{"id":"evt-wbe1mkb54hck","type":"node.created","subject":"ver-1fbexnj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser against both real cameras: open a camera's page from its name on the dashboard; the four sections show the values the probe found; Copy puts the RTSP URL on the clipboard (over the LAN URL you normally use); Refresh works; the card shows the summary line and 'I91ET · fw V5.8.10'.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:54:09.508Z","parents":["evt-t8jhasgkfb5a"],"hash":"02a1c3a22a3890dbd88b40d3248254a59deb780d5a756418360a839fd2d6b5ac"}
{"id":"evt-hmkqz65wra0a","type":"edge.added","subject":"ver-1fbexnj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-1fbexnj","to":"iss-cngxt98"},"at":"2026-09-19T17:54:09.511Z","parents":["evt-wbe1mkb54hck"],"hash":"dd94aefa435b1a8a0325f8da753a2610a8fd27316d3671604e445b469109f0dd"}
{"id":"evt-mxv72kxw35fw","type":"verification.recorded","subject":"ver-1fbexnj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"Awaiting the user's manual check; Claude doesn't run dev servers here, and this module has only been exercised against fakes shaped like the probe results."},"at":"2026-09-19T17:54:09.512Z","parents":["evt-hmkqz65wra0a"],"hash":"eb0547b2d061037e896cf001df716bdba9cced08b4e3c24dac4fb9c93258a917"}
{"id":"evt-9syzrzp8ng8g","type":"node.created","subject":"ver-sgvq7se","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"In a real browser against both real cameras: open a camera's page from its name on the dashboard; the four sections show the values the probe found; Copy puts the RTSP URL on the clipboard (over the LAN URL you normally use); Refresh works; the card shows the summary line and 'I91ET · fw V5.8.10'.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T17:58:41.419Z","parents":["evt-mxv72kxw35fw"],"hash":"9dda8fb9a81aaa525b37c2ee0d094865400e814502032862388d9c60478f7a31"}
{"id":"evt-42geta3461gv","type":"edge.added","subject":"ver-sgvq7se","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-sgvq7se","to":"iss-cngxt98"},"at":"2026-09-19T17:58:41.421Z","parents":["evt-9syzrzp8ng8g"],"hash":"f5e9b08be9d7db2f9adad826e1ba44608c6c33478e83089cc918232db408caa4"}
{"id":"evt-k2c8c78v7jzf","type":"verification.recorded","subject":"ver-sgvq7se","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"Manual check by the user (Michael Mainguy) on 2026-09-19: \"checked, looks good\". This resolves pending ver-1fbexnj."},"at":"2026-09-19T17:58:41.422Z","parents":["evt-42geta3461gv"],"hash":"6e4b445b64c8448f454780a1dc817cd83e8b61394369e436f484d72d2c54252a"}
{"id":"evt-9nwcj1gp32rt","type":"node.status_changed","subject":"iss-cngxt98","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T17:58:42.661Z","parents":["evt-k2c8c78v7jzf"],"hash":"596c33315f0e7cfae98d4dc3e8cdcec9baa15d45726580d7e88373e6ddceac1c"}

View File

@ -248,6 +248,41 @@ describe("CameraCard", () => {
});
});
describe("name link and summary", () => {
it("links the camera name to its page", async () => {
stubFetch({ [`GET ${base}/info`]: () => json(info), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
const link = screen.getByRole("link", { name: "Porch" });
expect(link.getAttribute("href")).toBe(`/cameras/${cam.id}`);
await screen.findByText("Hikvision DS-2CD · fw V5.7");
});
it("summarizes the main stream and hides the ONVIF placeholder brand", async () => {
const probeInfo = {
manufacturer: "ONVIF",
model: "I91ET",
firmwareVersion: "V5.8.10",
profiles: [
{ token: "Profile_1", name: "mainStream", encoding: "H264", width: 4096, height: 1860, fps: 20 },
{ token: "Profile_2", name: "subStream", encoding: "H264", width: 1200, height: 536, fps: 20 },
],
};
stubFetch({ [`GET ${base}/info`]: () => json(probeInfo), [`GET ${base}/snapshot`]: frame });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(await screen.findByText("I91ET · fw V5.8.10")).toBeTruthy();
expect(screen.getByText("Main 4096×1860 · H.264 · 20 fps")).toBeTruthy();
expect(screen.queryByText(/ONVIF/)).toBeNull();
});
it("shows no summary or device line before info loads", () => {
stubFetch({ [`GET ${base}/info`]: () => new Promise(() => {}) });
renderWithQuery(<CameraCard cam={cam} intervalMs={60_000} />);
expect(screen.queryByText(/^Main /)).toBeNull();
expect(screen.queryByText(/ · fw /)).toBeNull();
});
});
describe("status badge", () => {
const badge = () => document.querySelector("[data-status]")!;

View File

@ -11,6 +11,7 @@ import {
Trash2,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import type { CameraSummary } from "@/lib/camera-registry";
import {
@ -23,6 +24,7 @@ import {
type CameraProblem,
} from "./camera-queries";
import { cameraStatus, StatusBadge } from "./camera-status";
import { deviceLine, streamSummary } from "./camera-summary";
const DEFAULT_USERNAME = "camera";
@ -230,6 +232,9 @@ export default function CameraCard({
resetCamera();
}
const device = info.data ? deviceLine(info.data) : null;
const summary = info.data ? streamSummary(info.data.profiles) : null;
const showSetup = problem?.kind === "inactive";
const showLogin = !showSetup && (problem?.kind === "auth" || editingLogin);
@ -239,7 +244,9 @@ export default function CameraCard({
<div>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{cam.name ?? info.data?.model ?? "Unnamed camera"}</span>
<Link href={`/cameras/${cam.id}`} className="font-medium hover:underline">
{cam.name ?? info.data?.model ?? "Unnamed camera"}
</Link>
<StatusBadge
status={cameraStatus({ problem: problem?.kind ?? null, hasFrame: snapshot.data !== undefined })}
/>
@ -261,11 +268,8 @@ export default function CameraCard({
)}
</span>
</div>
{info.data?.manufacturer && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{info.data.manufacturer} {info.data.model} · fw {info.data.firmwareVersion}
</div>
)}
{device && <div className="text-sm text-zinc-600 dark:text-zinc-400">{device}</div>}
{summary && <div className="text-sm text-zinc-600 dark:text-zinc-400">{summary}</div>}
{cam.location && (
<div className="text-sm text-zinc-600 dark:text-zinc-400">{cam.location}</div>
)}

View File

@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import type { CameraProfile } from "@/lib/camera";
import { codecLabel, deviceLine, displayBrand, mainStream, streamSummary } from "./camera-summary";
const main: CameraProfile = { token: "p1", name: "mainStream", encoding: "H264", width: 4096, height: 1860, fps: 20 };
const sub: CameraProfile = { token: "p2", name: "subStream", encoding: "H264", width: 1200, height: 536, fps: 20 };
describe("mainStream", () => {
it("picks the highest resolution regardless of order", () => {
expect(mainStream([sub, main])).toBe(main);
expect(mainStream([main, sub])).toBe(main);
});
it("keeps the first on a tie, and treats missing sizes as 0", () => {
const twin = { ...main, token: "p3" };
expect(mainStream([main, twin])).toBe(main);
const bare = { token: "b" };
expect(mainStream([bare, sub])).toBe(sub);
expect(mainStream([bare])).toBe(bare);
});
it("is undefined with no profiles", () => {
expect(mainStream([])).toBeUndefined();
});
});
describe("streamSummary", () => {
it("describes the main stream of the probed cameras", () => {
expect(streamSummary([main, sub])).toBe("Main 4096×1860 · H.264 · 20 fps");
});
it("omits missing parts", () => {
expect(streamSummary([{ token: "x", encoding: "H265" }])).toBe("Main · H.265");
expect(streamSummary([{ token: "x", width: 640, height: 480 }])).toBe("Main 640×480");
expect(streamSummary([{ token: "x" }])).toBe("Main");
});
it("is null with no profiles", () => {
expect(streamSummary([])).toBeNull();
});
});
describe("codecLabel", () => {
it.each([
["H264", "H.264"],
["h265", "H.265"],
["JPEG", "JPEG"],
["MPEG4", "MPEG4"],
])("%s → %s", (encoding, label) => {
expect(codecLabel(encoding)).toBe(label);
});
});
describe("displayBrand", () => {
it.each([
["ONVIF", undefined],
["onvif ", undefined],
["", undefined],
[" ", undefined],
[undefined, undefined],
["Hikvision", "Hikvision"],
[" Annke ", "Annke"],
])("%j → %j", (manufacturer, brand) => {
expect(displayBrand(manufacturer)).toBe(brand);
});
});
describe("deviceLine", () => {
it("hides the ONVIF placeholder brand", () => {
expect(deviceLine({ manufacturer: "ONVIF", model: "I91ET", firmwareVersion: "V5.8.10" })).toBe(
"I91ET · fw V5.8.10",
);
});
it("keeps a real brand", () => {
expect(deviceLine({ manufacturer: "Hikvision", model: "DS-2CD", firmwareVersion: "V5.7" })).toBe(
"Hikvision DS-2CD · fw V5.7",
);
});
it("omits missing parts", () => {
expect(deviceLine({ model: "I91ET" })).toBe("I91ET");
expect(deviceLine({ firmwareVersion: "V1" })).toBe("fw V1");
expect(deviceLine({ manufacturer: "Annke" })).toBe("Annke");
});
it("is null when nothing is known", () => {
expect(deviceLine({})).toBeNull();
expect(deviceLine({ manufacturer: "ONVIF" })).toBeNull();
});
});

48
src/app/camera-summary.ts Normal file
View File

@ -0,0 +1,48 @@
import type { CameraInfo, CameraProfile } from "@/lib/camera";
/**
* One-line descriptions for a dashboard card, built only from info the card already has
* (vrek dec-gtw493s). Pure functions, no device calls.
*/
/** The highest-resolution profile (first wins a tie), or undefined with no profiles. */
export function mainStream(profiles: CameraProfile[]): CameraProfile | undefined {
let best: CameraProfile | undefined;
for (const p of profiles) {
const area = (p.width ?? 0) * (p.height ?? 0);
if (!best || area > (best.width ?? 0) * (best.height ?? 0)) best = p;
}
return best;
}
const CODEC_LABELS: Record<string, string> = { H264: "H.264", H265: "H.265" };
export function codecLabel(encoding: string): string {
return CODEC_LABELS[encoding.toUpperCase()] ?? encoding;
}
/** e.g. "Main 4096×1860 · H.264 · 20 fps"; null with no profiles. */
export function streamSummary(profiles: CameraProfile[]): string | null {
const main = mainStream(profiles);
if (!main) return null;
const parts = [
main.width && main.height ? `Main ${main.width}×${main.height}` : "Main",
main.encoding ? codecLabel(main.encoding) : undefined,
main.fps ? `${main.fps} fps` : undefined,
];
return parts.filter(Boolean).join(" · ");
}
/** The manufacturer, unless it's empty or the placeholder "ONVIF" (vrek fnd-msm9prc). */
export function displayBrand(manufacturer: string | undefined): string | undefined {
const brand = manufacturer?.trim();
return brand && brand.toUpperCase() !== "ONVIF" ? brand : undefined;
}
/** e.g. "Hikvision DS-2CD · fw V5.7" or "I91ET · fw V5.8.10"; null if nothing is known. */
export function deviceLine(info: Pick<CameraInfo, "manufacturer" | "model" | "firmwareVersion">): string | null {
const name = [displayBrand(info.manufacturer), info.model].filter(Boolean).join(" ");
const parts = [name || undefined, info.firmwareVersion ? `fw ${info.firmwareVersion}` : undefined];
const line = parts.filter(Boolean).join(" · ");
return line || null;
}

View File

@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const refresh = vi.fn();
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh }) }));
const { CopyButton, LocalTime, RefreshButton } = await import("./client-controls");
afterEach(() => {
cleanup();
vi.useRealTimers();
});
beforeEach(() => refresh.mockReset());
const iconIn = (el: Element) =>
[...(el.querySelector("svg")?.getAttribute("class") ?? "").matchAll(/lucide-([a-z0-9-]+)/g)].map((m) => m[1]);
describe("RefreshButton", () => {
it("re-renders the server page to re-read the camera", () => {
render(<RefreshButton />);
const button = screen.getByRole("button", { name: "Refresh" });
expect(iconIn(button)).toContain("refresh-cw");
fireEvent.click(button);
expect(refresh).toHaveBeenCalledTimes(1);
expect(button.querySelector("svg")!.getAttribute("aria-hidden")).toBe("true");
});
});
describe("CopyButton", () => {
const URL_TEXT = "rtsp://192.168.17.129/Streaming/Channels/101?transportmode=unicast";
const setSecureContext = (value: boolean) =>
Object.defineProperty(window, "isSecureContext", { value, configurable: true });
const stubExecCommand = (result: boolean) => {
const exec = vi.fn(() => {
// What's selected at copy time is what lands on the clipboard.
copiedText = document.querySelector("textarea")?.value ?? null;
return result;
});
Object.defineProperty(document, "execCommand", { value: exec, configurable: true });
return exec;
};
let copiedText: string | null = null;
it("copies the URL, says so, then resets after two seconds", async () => {
setSecureContext(true);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.assign(navigator, { clipboard: { writeText } });
vi.useFakeTimers();
render(<CopyButton text={URL_TEXT} />);
const button = screen.getByRole("button", { name: "Copy" });
expect(iconIn(button)).toContain("copy");
await act(async () => {
fireEvent.click(button);
});
expect(writeText).toHaveBeenCalledWith(URL_TEXT);
expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy();
expect(iconIn(screen.getByRole("button", { name: "Copied" }))).toContain("check");
act(() => vi.advanceTimersByTime(2000));
expect(screen.getByRole("button", { name: "Copy" })).toBeTruthy();
});
it("still copies over plain HTTP on the LAN, via a temporary text field", async () => {
setSecureContext(false);
Object.assign(navigator, { clipboard: undefined });
const exec = stubExecCommand(true);
render(<CopyButton text={URL_TEXT} />);
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy());
expect(exec).toHaveBeenCalledWith("copy");
expect(copiedText).toBe(URL_TEXT);
expect(document.querySelector("textarea")).toBeNull();
});
it("uses the fallback when a secure page has no clipboard API", async () => {
setSecureContext(true);
Object.assign(navigator, { clipboard: undefined });
const exec = stubExecCommand(true);
render(<CopyButton text={URL_TEXT} />);
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Copied" })).toBeTruthy());
expect(exec).toHaveBeenCalled();
});
it.each([
["the browser refuses the copy command", () => stubExecCommand(false)],
[
"the copy command throws",
() =>
Object.defineProperty(document, "execCommand", {
value: () => {
throw new Error("not allowed");
},
configurable: true,
}),
],
])("says Copy failed when %s, and cleans up", async (_label, arrange) => {
setSecureContext(false);
arrange();
render(<CopyButton text={URL_TEXT} />);
fireEvent.click(screen.getByRole("button", { name: "Copy" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Copy failed" })).toBeTruthy());
expect(document.querySelector("textarea")).toBeNull();
});
});
describe("LocalTime", () => {
const iso = "2026-09-19T17:42:15.000Z";
it("renders the ISO time on the server, where the viewer's locale is unknown", () => {
expect(renderToString(<LocalTime iso={iso} />)).toBe(`<time dateTime="${iso}">${iso}</time>`);
});
it("shows local time in the browser, keeping the machine-readable value", () => {
render(<LocalTime iso={iso} />);
const time = document.querySelector("time")!;
expect(time.getAttribute("datetime")).toBe(iso);
expect(time.textContent).toBe(new Date(iso).toLocaleString());
});
});

View File

@ -0,0 +1,94 @@
"use client";
import { Check, Copy, LoaderCircle, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useSyncExternalStore, useTransition } from "react";
/** Re-reads the camera's settings by re-rendering the server page (no polling). */
export function RefreshButton() {
const router = useRouter();
const [pending, startTransition] = useTransition();
return (
<button
type="button"
onClick={() => startTransition(() => router.refresh())}
disabled={pending}
className="inline-flex items-center gap-1.5 rounded-md border border-zinc-300 px-3 py-1.5 text-sm disabled:opacity-50 dark:border-zinc-700"
>
{pending ? (
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
) : (
<RefreshCw aria-hidden className="size-4" />
)}
{pending ? "Refreshing…" : "Refresh"}
</button>
);
}
/**
* Copies text. The async clipboard API only exists on HTTPS or localhost, and this app is
* usually opened over plain HTTP on the LAN, so fall back to copying from a hidden
* textarea, which browsers still allow in response to a click.
*/
export async function writeClipboard(text: string): Promise<void> {
if (window.isSecureContext && navigator.clipboard) {
await navigator.clipboard.writeText(text);
return;
}
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.focus();
area.select();
try {
// Deprecated, but the only option outside a secure context.
if (!document.execCommand("copy")) throw new Error("copy command refused");
} finally {
area.remove();
}
}
/** Copies a stream URL; says so for two seconds. */
export function CopyButton({ text }: { text: string }) {
const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
useEffect(() => {
if (state === "idle") return;
const timer = setTimeout(() => setState("idle"), 2000);
return () => clearTimeout(timer);
}, [state]);
async function copy() {
try {
await writeClipboard(text);
setState("copied");
} catch {
setState("failed");
}
}
return (
<button
type="button"
onClick={copy}
className="inline-flex shrink-0 items-center gap-1 rounded border border-zinc-300 px-2 py-0.5 text-xs dark:border-zinc-700"
>
{state === "copied" ? <Check aria-hidden className="size-3.5" /> : <Copy aria-hidden className="size-3.5" />}
{state === "copied" ? "Copied" : state === "failed" ? "Copy failed" : "Copy"}
</button>
);
}
const noSubscription = () => () => {};
/**
* The camera's clock in the viewer's own locale and time zone. The server can't know the
* viewer's locale, so it renders the ISO timestamp and the browser swaps in local time.
*/
export function LocalTime({ iso }: { iso: string }) {
const inBrowser = useSyncExternalStore(noSubscription, () => true, () => false);
return <time dateTime={iso}>{inBrowser ? new Date(iso).toLocaleString() : iso}</time>;
}

View File

@ -0,0 +1,94 @@
import { describe, expect, it } from "vitest";
import {
bitrateLabel,
codecLabel,
dayNightLabel,
DRIFT_WARNING_SECONDS,
driftIsLarge,
driftLabel,
linkLabel,
ntpLabel,
resolutionLabel,
titleCase,
valueOrUnknown,
} from "./format";
describe("format", () => {
it.each([
["H264", "Main", "H.264 Main"],
["H265", undefined, "H.265"],
["h264", undefined, "H.264"],
["JPEG", undefined, "JPEG"],
[undefined, undefined, "Unknown codec"],
])("codecLabel(%j, %j) → %s", (codec, profile, label) => {
expect(codecLabel(codec, profile)).toBe(label);
});
it("formats resolution", () => {
expect(resolutionLabel(4096, 1860)).toBe("4096×1860");
expect(resolutionLabel(undefined, 1860)).toBe("Unknown");
});
it.each([
[10240, false, "10,240 kbps · variable"],
[4096, true, "4,096 kbps · constant"],
[512, undefined, "512 kbps"],
[undefined, false, "Unknown bitrate · variable"],
])("bitrateLabel(%j, %j) → %s", (kbps, cbr, label) => {
expect(bitrateLabel(kbps, cbr)).toBe(label);
});
it.each([
["AUTO", "Auto"],
["OFF", "Off"],
["IR_CUT_AUTO", "Ir cut auto"],
[undefined, "Unknown"],
["", "Unknown"],
])("titleCase(%j) → %s", (mode, label) => {
expect(titleCase(mode)).toBe(label);
});
it.each([
["AUTO", "Auto"],
["ON", "Day (IR-cut on)"],
["OFF", "Night (IR-cut off)"],
["off", "Night (IR-cut off)"],
["SCHEDULE", "Schedule"],
[undefined, "Unknown"],
])("dayNightLabel(%j) → %s", (mode, label) => {
expect(dayNightLabel(mode)).toBe(label);
});
it("describes drift and flags more than 30 s", () => {
expect(driftLabel(0)).toBe("in sync (0 s)");
expect(driftLabel(-1)).toBe("−1 s behind");
expect(driftLabel(45)).toBe("+45 s ahead");
expect(DRIFT_WARNING_SECONDS).toBe(30);
expect(driftIsLarge(30)).toBe(false);
expect(driftIsLarge(-30)).toBe(false);
expect(driftIsLarge(31)).toBe(true);
expect(driftIsLarge(-31)).toBe(true);
});
it("describes NTP", () => {
expect(ntpLabel({ source: "manual", servers: ["time.windows.com"] })).toBe("Manual: time.windows.com");
expect(ntpLabel({ source: "dhcp", servers: ["192.168.16.1", "fe80::1"] })).toBe(
"From DHCP: 192.168.16.1, fe80::1",
);
expect(ntpLabel({ source: "manual", servers: [] })).toBe("Manual: no server set");
expect(ntpLabel(null)).toBe("Not reported");
});
it("describes the link", () => {
expect(linkLabel(100, "Full")).toBe("100 Mb/s Full duplex");
expect(linkLabel(1000)).toBe("1000 Mb/s");
expect(linkLabel(undefined, "Half")).toBe("Half duplex");
expect(linkLabel()).toBe("Unknown");
});
it("shows unknown values", () => {
expect(valueOrUnknown(0)).toBe("0");
expect(valueOrUnknown(0.5)).toBe("0.5");
expect(valueOrUnknown(undefined)).toBe("Unknown");
});
});

View File

@ -0,0 +1,74 @@
/**
* Display formatting for the camera settings page. Pure functions, no React, so they are
* trivially testable and shared by server and client components.
*/
/** "H264" → "H.264", "H265" → "H.265"; other codecs as reported. */
export function codecLabel(codec?: string, profile?: string): string {
const name = codec?.replace(/^H(26[45])$/i, "H.$1") ?? "Unknown codec";
return profile ? `${name} ${profile}` : name;
}
export function resolutionLabel(width?: number, height?: number): string {
return width && height ? `${width}×${height}` : "Unknown";
}
/** "10,240 kbps · variable" */
export function bitrateLabel(kbps?: number, constantBitRate?: boolean): string {
const rate = kbps === undefined ? "Unknown bitrate" : `${kbps.toLocaleString("en-US")} kbps`;
if (constantBitRate === undefined) return rate;
return `${rate} · ${constantBitRate ? "constant" : "variable"}`;
}
/** "AUTO" → "Auto", "OFF" → "Off", "IR_CUT" → "Ir cut". */
export function titleCase(mode?: string): string {
if (!mode) return "Unknown";
const words = mode.replace(/_/g, " ").toLowerCase();
return words.charAt(0).toUpperCase() + words.slice(1);
}
/**
* ONVIF IrCutFilter: ON = filter in place = colour (day) image; OFF = filter removed =
* night (IR) image; AUTO = the camera switches.
*/
export function dayNightLabel(irCut?: string): string {
switch (irCut?.toUpperCase()) {
case "AUTO":
return "Auto";
case "ON":
return "Day (IR-cut on)";
case "OFF":
return "Night (IR-cut off)";
default:
return titleCase(irCut);
}
}
/** Drift beyond this many seconds is worth a warning (vrek dec-gtw493s). */
export const DRIFT_WARNING_SECONDS = 30;
export function driftLabel(seconds: number): string {
if (seconds === 0) return "in sync (0 s)";
return `${seconds > 0 ? "+" : "−"}${Math.abs(seconds)} s ${seconds > 0 ? "ahead" : "behind"}`;
}
export function driftIsLarge(seconds: number): boolean {
return Math.abs(seconds) > DRIFT_WARNING_SECONDS;
}
export function ntpLabel(ntp: { source: "dhcp" | "manual"; servers: string[] } | null): string {
if (!ntp) return "Not reported";
const servers = ntp.servers.length ? ntp.servers.join(", ") : "no server set";
return `${ntp.source === "dhcp" ? "From DHCP" : "Manual"}: ${servers}`;
}
export function linkLabel(speedMbps?: number, duplex?: string): string {
const parts = [];
if (speedMbps !== undefined) parts.push(`${speedMbps} Mb/s`);
if (duplex) parts.push(`${duplex} duplex`);
return parts.length ? parts.join(" ") : "Unknown";
}
export function valueOrUnknown(value?: number | string): string {
return value === undefined ? "Unknown" : String(value);
}

View File

@ -0,0 +1,90 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraRecord } from "@/lib/camera-registry";
const requirePageAccess = vi.fn();
vi.mock("@/lib/access", () => ({ requirePageAccess }));
const getCameraRecord = vi.fn<(id: string) => Promise<CameraRecord | null>>();
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
getCameraRecord,
}));
// The async sections are tested on their own; here they are a marker carrying the target.
vi.mock("./settings-sections", () => ({
CameraSettingsSections: ({ target }: { target: object }) => (
<div data-testid="sections">{JSON.stringify(target)}</div>
),
}));
class NotFound extends Error {}
vi.mock("next/navigation", () => ({
notFound: () => {
throw new NotFound();
},
useRouter: () => ({ refresh: vi.fn() }),
}));
const { default: CameraPage } = await import("./page");
const ID = "22ec0000-8b90-11b5-845d-d03bf404af2e";
const record: CameraRecord = {
id: ID,
urn: `urn:uuid:${ID}`,
host: "192.168.17.129",
port: 80,
name: "I91ET",
location: "Front door",
lastSeen: "2026-09-19T00:00:00.000Z",
};
const page = (id = ID) => CameraPage({ params: Promise.resolve({ id }), searchParams: Promise.resolve({}) });
beforeEach(() => {
requirePageAccess.mockReset().mockResolvedValue(undefined);
getCameraRecord.mockReset().mockResolvedValue(record);
});
describe("/cameras/[id]", () => {
it("checks access before anything else", async () => {
requirePageAccess.mockRejectedValue(new Error("redirect /login"));
await expect(page()).rejects.toThrow("redirect /login");
expect(getCameraRecord).not.toHaveBeenCalled();
});
it("renders the header, controls, and the sections for the registry address", async () => {
const html = renderToStaticMarkup(await page());
expect(requirePageAccess).toHaveBeenCalled();
expect(getCameraRecord).toHaveBeenCalledWith(ID);
expect(html).toContain("I91ET</h1>");
expect(html).toContain("192.168.17.129:80");
expect(html).toContain("Front door");
expect(html).toContain('href="/"');
expect(html).toContain("All cameras");
expect(html).toContain(">Refresh</button>");
expect(html).toContain(JSON.stringify({ id: ID, host: "192.168.17.129", port: 80 }).replace(/"/g, "&quot;"));
for (const svg of html.match(/<svg[^>]*>/g)!) expect(svg).toContain('aria-hidden="true"');
});
it("names an unnamed camera, without a location line", async () => {
getCameraRecord.mockResolvedValue({ ...record, name: undefined, location: undefined });
const html = renderToStaticMarkup(await page());
expect(html).toContain("Unnamed camera</h1>");
expect(html).not.toContain(" · ");
});
it.each([
["a malformed id", "../../etc", null],
["an unknown camera", ID, null],
["a registry entry outside the private network", ID, { ...record, host: "8.8.8.8" }],
])("is not found for %s", async (_l, id, found) => {
getCameraRecord.mockResolvedValue(found);
await expect(page(id)).rejects.toBeInstanceOf(NotFound);
});
it("never looks up a malformed id", async () => {
await expect(page("not-an-id")).rejects.toBeInstanceOf(NotFound);
expect(getCameraRecord).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,58 @@
import { ArrowLeft, Cctv, LoaderCircle } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { requirePageAccess } from "@/lib/access";
import { isAllowedHost } from "@/lib/camera";
import { getCameraRecord, isValidCameraId } from "@/lib/camera-registry";
import { RefreshButton } from "./client-controls";
import { CameraSettingsSections } from "./settings-sections";
/**
* One camera's read-only settings (vrek gol-2nxgqjn, iss-s935a4n). The camera address comes
* from the server-side registry, never from the request, and must be a private IPv4
* address, so this page can't be used to reach arbitrary hosts (pri-m1csgrm).
*/
export default async function CameraPage({ params }: PageProps<"/cameras/[id]">) {
await requirePageAccess();
const { id } = await params;
if (!isValidCameraId(id)) notFound();
const record = await getCameraRecord(id);
if (!record || !isAllowedHost(record.host)) notFound();
const target = { id: record.id, host: record.host, port: record.port };
return (
<main className="mx-auto w-full max-w-4xl flex-1 px-4 py-8 font-sans">
<Link href="/" className="inline-flex items-center gap-1 text-sm text-zinc-600 underline dark:text-zinc-400">
<ArrowLeft aria-hidden className="size-4" />
All cameras
</Link>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Cctv aria-hidden className="size-6" />
{record.name ?? "Unnamed camera"}
</h1>
<p className="font-mono text-sm text-zinc-600 dark:text-zinc-400">
{record.host}:{record.port}
{record.location ? <span className="font-sans"> · {record.location}</span> : null}
</p>
</div>
<RefreshButton />
</div>
<div className="mt-6">
<Suspense
fallback={
<p className="flex items-center gap-2 text-sm text-zinc-500">
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
Reading settings from the camera…
</p>
}
>
<CameraSettingsSections target={target} />
</Suspense>
</div>
</main>
);
}

View File

@ -0,0 +1,252 @@
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraSettings } from "@/lib/camera-settings";
/**
* Renders the settings sections with the camera reader faked (no real camera, pri-e14bahk),
* using the values the real I91ET cameras reported in the probe (vrek fnd-j1s725e,
* fnd-g4z7c6d, fnd-b73cav4).
*/
const getCameraSettings = vi.fn<() => Promise<CameraSettings>>();
vi.mock("@/lib/camera-settings", () => ({ getCameraSettings }));
const { CameraSettingsSections, webUiUrl } = await import("./settings-sections");
const { CameraAuthError, CameraInactiveError } = await import("@/lib/camera");
const { CredentialStoreError } = await import("@/lib/credential-store");
const target = { id: "cam", host: "192.168.17.129", port: 80 };
const LEAK = "SOAP fault from http://admin:hunter2@192.168.17.129/onvif";
const probe: CameraSettings = {
streams: {
ok: true,
value: [
{
token: "Profile_1",
name: "mainStream",
codec: "H264",
profile: "Main",
width: 4096,
height: 1860,
fps: 20,
bitrateKbps: 10240,
constantBitRate: false,
gop: 50,
quality: 3,
rtspUrl: "rtsp://192.168.17.129/Streaming/Channels/101?transportmode=unicast",
},
{ token: "Profile_2", rtspUrl: null },
],
},
image: {
ok: true,
value: {
dayNight: "AUTO",
exposure: "AUTO",
whiteBalance: "AUTO",
wideDynamicRange: "OFF",
backlightCompensation: "OFF",
brightness: 50,
contrast: 50,
saturation: 50,
sharpness: 50,
noiseReduction: 0.5,
},
},
time: {
ok: true,
value: {
cameraTime: "2026-09-19T17:42:15.000Z",
driftSeconds: -1,
ntp: { source: "manual", servers: ["time.windows.com"] },
},
},
network: {
ok: true,
value: {
interfaces: [
{
name: "eth0",
mac: "d0:3b:f4:04:af:2e",
enabled: true,
ipv4: { address: "192.168.17.129", prefixLength: 20, dhcp: true },
ipv6: [
{ address: "fe80::d23b:f4ff:fe04:af2e", prefixLength: 64 },
{ address: "2001:470:1f11:137:d23b:f4ff:fe04:af2e" },
],
speedMbps: 100,
duplex: "Full",
},
{ ipv6: [] },
],
gateways: ["192.168.16.1"],
},
},
};
async function render(settings: Partial<CameraSettings> = {}) {
getCameraSettings.mockResolvedValue({ ...probe, ...settings });
return renderToStaticMarkup(await CameraSettingsSections({ target }));
}
/** The text of a definition row: "<dt>label</dt><dd>value</dd>". */
const row = (html: string, label: string) =>
new RegExp(`<dt[^>]*>${label}</dt><dd>(.*?)</dd>`).exec(html)?.[1].replace(/<[^>]+>/g, "");
beforeEach(() => {
getCameraSettings.mockReset();
vi.spyOn(console, "error").mockImplementation(() => {});
});
describe("CameraSettingsSections", () => {
it("reads the camera it was given", async () => {
await render();
expect(getCameraSettings).toHaveBeenCalledWith(target);
});
it("shows streams with codec, resolution, rate, bitrate, GOP, quality and a copyable URL", async () => {
const html = await render();
expect(html).toContain("mainStream");
expect(row(html, "Codec")).toBe("H.264 Main");
expect(row(html, "Resolution")).toBe("4096×1860");
expect(row(html, "Frame rate")).toBe("20 fps");
expect(row(html, "Bitrate")).toBe("10,240 kbps · variable");
expect(row(html, "Keyframe interval")).toBe("50 frames");
expect(row(html, "Quality")).toBe("3");
expect(html).toContain(
'<code class="break-all font-mono text-xs">rtsp://192.168.17.129/Streaming/Channels/101?transportmode=unicast</code>',
);
expect(html).toContain(">Copy</button>");
expect(html).toContain("sign in with the camera&#x27;s ONVIF login (the one saved for this camera)");
});
it("names a stream by token and marks unknowns when the camera says little", async () => {
const html = await render();
expect(html).toContain(">Profile_2</h3>");
expect(html).toContain("No stream URL reported");
expect(html).toContain("Unknown codec");
expect(html).toContain("Unknown bitrate");
});
it("shows image settings", async () => {
const html = await render();
expect(row(html, "Day/night")).toBe("Auto");
expect(row(html, "Exposure")).toBe("Auto");
expect(row(html, "Wide dynamic range")).toBe("Off");
expect(row(html, "Backlight compensation")).toBe("Off");
expect(row(html, "Brightness")).toBe("50");
expect(row(html, "Noise reduction")).toBe("0.5");
});
it("shows the camera clock, drift and NTP", async () => {
const html = await render();
expect(html).toContain('<time dateTime="2026-09-19T17:42:15.000Z">');
expect(row(html, "Drift from server")).toBe("−1 s behind");
expect(row(html, "NTP")).toBe("Manual: time.windows.com");
expect(html).not.toContain("check NTP");
});
it("warns when the clock drifts more than 30 s", async () => {
const html = await render({
time: { ok: true, value: { cameraTime: "2026-09-19T17:42:15.000Z", driftSeconds: 45, ntp: null } },
});
expect(row(html, "Drift from server")).toBe("+45 s ahead: check NTP");
expect(html).toMatch(/lucide-triangle-alert/);
expect(row(html, "NTP")).toBe("Not reported");
});
it("shows network interfaces and gateways", async () => {
const html = await render();
expect(html).toContain(">eth0</h3>");
expect(row(html, "MAC")).toBe("d0:3b:f4:04:af:2e");
expect(row(html, "IPv4")).toBe("192.168.17.129/20 (DHCP)");
expect(html).toContain("<li>fe80::d23b:f4ff:fe04:af2e/64</li>");
expect(html).toContain("<li>2001:470:1f11:137:d23b:f4ff:fe04:af2e</li>");
expect(row(html, "Link")).toBe("100 Mb/s Full duplex");
expect(row(html, "Gateway")).toBe("192.168.16.1");
// A second, bare interface.
expect(html).toContain(">Interface 2</h3>");
});
it("shows a static address without a prefix, and no gateway", async () => {
const html = await render({
network: {
ok: true,
value: { interfaces: [{ name: "eth1", ipv4: { address: "10.0.0.5", dhcp: false }, ipv6: [] }], gateways: [] },
},
});
expect(row(html, "IPv4")).toBe("10.0.0.5 (static)");
expect(row(html, "MAC")).toBe("Unknown");
expect(row(html, "Gateway")).toBe("None reported");
});
it("says when the camera reports no streams or interfaces", async () => {
const html = await render({
streams: { ok: true, value: [] },
network: { ok: true, value: { interfaces: [], gateways: [] } },
});
expect(html).toContain("No video streams reported.");
expect(html).toContain("No network interfaces reported.");
});
it("marks each unavailable section without failing the page", async () => {
const off = { ok: false as const, error: "Not available from this camera" };
const html = await render({ streams: off, image: off, time: off, network: off });
expect(html.match(/Not available from this camera\./g)).toHaveLength(4);
for (const title of ["Streams", "Image", "Time &amp; NTP", "Network"]) expect(html).toContain(title);
});
it("marks every icon decorative", async () => {
const html = await render();
const svgs = html.match(/<svg[^>]*>/g)!;
expect(svgs.length).toBeGreaterThan(4);
for (const svg of svgs) expect(svg).toContain('aria-hidden="true"');
});
describe("when the camera can't be read", () => {
const failWith = async (err: unknown) => {
getCameraSettings.mockRejectedValue(err);
return renderToStaticMarkup(await CameraSettingsSections({ target }));
};
it("explains a missing login", async () => {
const html = await failWith(new CameraAuthError(LEAK, true));
expect(html).toContain("No login is saved for this camera.");
expect(html).toContain('href="/"');
expect(html).not.toContain("hunter2");
});
it("explains a rejected login", async () => {
const html = await failWith(new CameraAuthError(LEAK));
expect(html).toContain("The camera rejected its saved login.");
expect(html).toMatch(/lucide-key-round/);
expect(html).not.toContain("hunter2");
});
it("explains an unactivated camera, linking to its web page", async () => {
const html = await failWith(new CameraInactiveError(LEAK));
expect(html).toContain("hasn&#x27;t been activated yet");
expect(html).toContain(`href="http://192.168.17.129/"`);
expect(html).not.toContain("hunter2");
});
it.each([
["a credential store error", new CredentialStoreError(LEAK)],
["any other error", new Error(LEAK)],
])("shows a fixed message for %s and logs it server-side", async (_l, err) => {
const html = await failWith(err);
expect(html).toContain('role="alert"');
expect(html).toContain("Couldn&#x27;t read this camera&#x27;s settings.");
expect(html).not.toContain("hunter2");
expect(html).not.toContain("SOAP");
expect(console.error).toHaveBeenCalledWith("Camera settings failed:", err);
});
});
});
describe("webUiUrl", () => {
it("omits port 80", () => {
expect(webUiUrl({ id: "x", host: "10.0.0.2", port: 80 })).toBe("http://10.0.0.2/");
expect(webUiUrl({ id: "x", host: "10.0.0.2", port: 8080 })).toBe("http://10.0.0.2:8080/");
});
});

View File

@ -0,0 +1,268 @@
import {
CircleAlert,
Clock,
ExternalLink,
KeyRound,
Network,
ShieldAlert,
SunMoon,
TriangleAlert,
Video,
type LucideIcon,
} from "lucide-react";
import Link from "next/link";
import { CameraAuthError, CameraInactiveError, type CameraTarget } from "@/lib/camera";
import {
getCameraSettings,
type ImageSettings,
type NetworkSettings,
type Section,
type StreamSettings,
type TimeSettings,
} from "@/lib/camera-settings";
import { CopyButton, LocalTime } from "./client-controls";
import {
bitrateLabel,
codecLabel,
dayNightLabel,
driftIsLarge,
driftLabel,
linkLabel,
ntpLabel,
resolutionLabel,
titleCase,
valueOrUnknown,
} from "./format";
/**
* The four read-only settings sections (vrek iss-s935a4n, dec-gtw493s). A Server Component
* that reads the camera directly through lib/camera-settings (pri-01np850); the page wraps
* it in <Suspense> so the header shows while the camera answers.
*/
export function webUiUrl({ host, port }: CameraTarget): string {
return `http://${host}${port === 80 ? "" : `:${port}`}/`;
}
function Panel({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: React.ReactNode }) {
return (
<section className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<h2 className="flex items-center gap-2 font-medium">
<Icon aria-hidden className="size-4 shrink-0" />
{title}
</h2>
<div className="mt-3 text-sm">{children}</div>
</section>
);
}
function Rows({ rows }: { rows: [string, React.ReactNode][] }) {
return (
<dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-1">
{rows.map(([label, value]) => (
<div key={label} className="contents">
<dt className="text-zinc-500">{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
);
}
function Unavailable() {
return <p className="text-zinc-500">Not available from this camera.</p>;
}
function Streams({ section }: { section: Section<StreamSettings[]> }) {
if (!section.ok) return <Unavailable />;
if (section.value.length === 0) return <p className="text-zinc-500">No video streams reported.</p>;
return (
<div className="flex flex-col gap-4">
{section.value.map((s) => (
<div key={s.token}>
<h3 className="font-medium">{s.name ?? s.token}</h3>
<Rows
rows={[
["Codec", codecLabel(s.codec, s.profile)],
["Resolution", resolutionLabel(s.width, s.height)],
["Frame rate", s.fps === undefined ? "Unknown" : `${s.fps} fps`],
["Bitrate", bitrateLabel(s.bitrateKbps, s.constantBitRate)],
["Keyframe interval", s.gop === undefined ? "Unknown" : `${s.gop} frames`],
["Quality", valueOrUnknown(s.quality)],
[
"Stream URL",
s.rtspUrl ? (
<span className="flex flex-wrap items-center gap-2">
<code className="break-all font-mono text-xs">{s.rtspUrl}</code>
<CopyButton text={s.rtspUrl} />
</span>
) : (
<span className="text-zinc-500">No stream URL reported</span>
),
],
]}
/>
</div>
))}
<p className="text-xs text-zinc-500">
In VLC or an NVR, sign in with the camera&apos;s ONVIF login (the one saved for this camera).
</p>
</div>
);
}
function ImageInfo({ section }: { section: Section<ImageSettings> }) {
if (!section.ok) return <Unavailable />;
const i = section.value;
return (
<Rows
rows={[
["Day/night", dayNightLabel(i.dayNight)],
["Exposure", titleCase(i.exposure)],
["White balance", titleCase(i.whiteBalance)],
["Wide dynamic range", titleCase(i.wideDynamicRange)],
["Backlight compensation", titleCase(i.backlightCompensation)],
["Brightness", valueOrUnknown(i.brightness)],
["Contrast", valueOrUnknown(i.contrast)],
["Saturation", valueOrUnknown(i.saturation)],
["Sharpness", valueOrUnknown(i.sharpness)],
["Noise reduction", valueOrUnknown(i.noiseReduction)],
]}
/>
);
}
function Time({ section }: { section: Section<TimeSettings> }) {
if (!section.ok) return <Unavailable />;
const t = section.value;
return (
<Rows
rows={[
["Camera clock", <LocalTime key="clock" iso={t.cameraTime} />],
[
"Drift from server",
driftIsLarge(t.driftSeconds) ? (
<span className="inline-flex items-center gap-1.5 text-amber-700 dark:text-amber-400">
<TriangleAlert aria-hidden className="size-4 shrink-0" />
{driftLabel(t.driftSeconds)}: check NTP
</span>
) : (
driftLabel(t.driftSeconds)
),
],
["NTP", ntpLabel(t.ntp)],
]}
/>
);
}
function NetworkInfo({ section }: { section: Section<NetworkSettings> }) {
if (!section.ok) return <Unavailable />;
const { interfaces, gateways } = section.value;
return (
<div className="flex flex-col gap-4">
{interfaces.map((i, n) => (
<div key={i.name ?? n}>
<h3 className="font-medium">{i.name ?? `Interface ${n + 1}`}</h3>
<Rows
rows={[
["MAC", i.mac ? <code key="mac" className="font-mono text-xs">{i.mac}</code> : "Unknown"],
[
"IPv4",
i.ipv4
? `${i.ipv4.address}${i.ipv4.prefixLength === undefined ? "" : `/${i.ipv4.prefixLength}`} (${i.ipv4.dhcp ? "DHCP" : "static"})`
: "None",
],
[
"IPv6",
i.ipv6.length ? (
<ul key="v6" className="font-mono text-xs">
{i.ipv6.map((a) => (
<li key={a.address}>
{a.address}
{a.prefixLength === undefined ? "" : `/${a.prefixLength}`}
</li>
))}
</ul>
) : (
"None"
),
],
["Link", linkLabel(i.speedMbps, i.duplex)],
]}
/>
</div>
))}
{interfaces.length === 0 && <p className="text-zinc-500">No network interfaces reported.</p>}
<Rows rows={[["Gateway", gateways.length ? gateways.join(", ") : "None reported"]]} />
</div>
);
}
function Problem({ icon: Icon, children }: { icon: LucideIcon; children: React.ReactNode }) {
return (
<div role="alert" className="flex items-start gap-2 rounded-lg border border-amber-400/60 p-4 text-sm">
<Icon aria-hidden className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div>{children}</div>
</div>
);
}
/**
* Reads and renders the camera's settings. Camera error text is never shown: only fixed
* guidance per error type (pri-tyrxdz9); unexpected errors go to the server log.
*/
export async function CameraSettingsSections({ target }: { target: CameraTarget }) {
let settings;
try {
settings = await getCameraSettings(target);
} catch (err) {
if (err instanceof CameraAuthError) {
return (
<Problem icon={KeyRound}>
{err.missingLogin ? "No login is saved for this camera." : "The camera rejected its saved login."}{" "}
Set the camera&apos;s ONVIF login from its card on the{" "}
<Link href="/" className="underline">
dashboard
</Link>
.
</Problem>
);
}
if (err instanceof CameraInactiveError) {
return (
<Problem icon={ShieldAlert}>
This camera hasn&apos;t been activated yet.{" "}
<a href={webUiUrl(target)} target="_blank" rel="noopener noreferrer" className="underline">
Open the camera&apos;s web page
<ExternalLink aria-hidden className="ml-0.5 inline size-3 align-[-0.1em]" />
</a>{" "}
to activate it, then follow the setup steps on the dashboard.
</Problem>
);
}
console.error("Camera settings failed:", err);
return <Problem icon={CircleAlert}>Couldn&apos;t read this camera&apos;s settings. See the server log for details.</Problem>;
}
return (
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Panel icon={Video} title="Streams">
<Streams section={settings.streams} />
</Panel>
</div>
<Panel icon={SunMoon} title="Image">
<ImageInfo section={settings.image} />
</Panel>
<Panel icon={Clock} title="Time & NTP">
<Time section={settings.time} />
</Panel>
<div className="md:col-span-2">
<Panel icon={Network} title="Network">
<NetworkInfo section={settings.network} />
</Panel>
</div>
</div>
);
}

View File

@ -0,0 +1,384 @@
/**
* FAKE NETWORK: no real camera is contacted (vrek principle pri-e14bahk). camera.ts's
* connect() is replaced with a scripted camera whose answers use the exact shapes the real
* I91ET cameras returned in the read-only probe (vrek fnd-j1s725e, fnd-g4z7c6d, fnd-b73cav4).
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CameraTarget } from "./camera";
type Cb = (err: Error | null, value?: unknown) => void;
type Script = Record<string, (args: unknown[]) => { err?: Error; value?: unknown }>;
const fake = vi.hoisted(() => ({
connect: vi.fn(),
calls: [] as { method: string; args: unknown[] }[],
}));
vi.mock("./camera", async (importOriginal) => ({
...(await importOriginal<typeof import("./camera")>()),
connect: fake.connect,
}));
const { getCameraSettings, onvifSettingsSource, sanitizeRtspUrl, UNAVAILABLE } = await import(
"./camera-settings"
);
const target: CameraTarget = { id: "cam", host: "192.168.17.129", port: 80 };
// --- shapes from the probe ---------------------------------------------------------------
const probeProfiles = [
{
token: "Profile_1",
name: "mainStream",
videoSourceConfiguration: { sourceToken: "VideoSource_1" },
videoEncoderConfiguration: {
encoding: "H264",
resolution: { width: 4096, height: 1860 },
quality: 3,
rateControl: { $: { ConstantBitRate: false }, frameRateLimit: 20, bitrateLimit: 10240 },
H264: { govLength: 50, H264Profile: "Main" },
},
},
{
token: "Profile_2",
name: "subStream",
videoSourceConfiguration: { sourceToken: "VideoSource_1" },
videoEncoderConfiguration: {
encoding: "H264",
resolution: { width: 1200, height: 536 },
quality: 3,
rateControl: { $: { ConstantBitRate: false }, frameRateLimit: 20, bitrateLimit: 4096 },
H264: { govLength: 50, H264Profile: "Main" },
},
},
];
const probeImaging = {
backlightCompensation: { mode: "OFF" },
brightness: 50,
colorSaturation: 50,
contrast: 50,
exposure: { mode: "AUTO", minExposureTime: 10, maxExposureTime: 33333 },
irCutFilter: "AUTO",
sharpness: 50,
wideDynamicRange: { mode: "OFF" },
whiteBalance: { mode: "AUTO" },
extension: { extension: { extension: { noiseReduction: { level: 0.5 } } } },
};
const probeNetwork = [
{
$: { token: "eth0" },
enabled: true,
info: { name: "eth0", hwAddress: "d0:3b:f4:04:af:2e", MTU: 1500 },
link: { operSettings: { autoNegotiation: true, speed: 100, duplex: "Full" } },
IPv4: { enabled: true, config: { fromDHCP: { address: "192.168.17.129", prefixLength: 20 }, DHCP: true } },
IPv6: {
enabled: true,
config: {
DHCP: "Off",
linkLocal: { address: "fe80::d23b:f4ff:fe04:af2e", prefixLength: 64 },
fromDHCP: { address: "fe80::d23b:f4ff:fe04:af2e", prefixLength: 64 },
fromRA: { address: "2001:470:1f11:137:d23b:f4ff:fe04:af2e", prefixLength: 64 },
},
},
},
];
function camera(overrides: Script = {}, profiles: unknown[] = probeProfiles) {
const script: Script = {
getStreamUri: ([opts]) => ({
value: {
uri: `rtsp://192.168.17.129/Streaming/Channels/${(opts as { profileToken: string }).profileToken === "Profile_1" ? 101 : 102}?transportmode=unicast`,
},
}),
getImagingSettings: () => ({ value: probeImaging }),
getSystemDateAndTime: () => ({ value: new Date(Date.now() - 1000) }),
getNTP: () => ({ value: { fromDHCP: false, NTPManual: { type: "DNS", DNSname: "time.windows.com" } } }),
getNetworkInterfaces: () => ({ value: probeNetwork }),
getNetworkDefaultGateway: () => ({ value: { IPv4Address: "192.168.16.1", IPv6Address: "::" } }),
...overrides,
};
const cam: Record<string, unknown> = { profiles };
for (const [method, answer] of Object.entries(script)) {
cam[method] = (...args: unknown[]) => {
const cb = args.pop() as Cb;
fake.calls.push({ method, args });
const { err, value } = answer(args);
cb(err ?? null, value);
};
}
fake.connect.mockResolvedValue(cam);
}
const failing = (message = "SOAP Fault from http://admin:hunter2@cam") => () => ({ err: new Error(message) });
beforeEach(() => {
fake.calls.length = 0;
fake.connect.mockReset();
vi.spyOn(console, "warn").mockImplementation(() => {});
});
// --- tests -------------------------------------------------------------------------------
describe("getCameraSettings with the probed I91ET cameras", () => {
it("reads every section", async () => {
camera();
const s = await getCameraSettings(target);
expect(fake.connect).toHaveBeenCalledWith(target);
expect(s.streams).toEqual({
ok: true,
value: [
{
token: "Profile_1",
name: "mainStream",
codec: "H264",
profile: "Main",
width: 4096,
height: 1860,
fps: 20,
bitrateKbps: 10240,
constantBitRate: false,
gop: 50,
quality: 3,
rtspUrl: "rtsp://192.168.17.129/Streaming/Channels/101?transportmode=unicast",
},
expect.objectContaining({ token: "Profile_2", width: 1200, height: 536, bitrateKbps: 4096 }),
],
});
expect(s.image).toEqual({
ok: true,
value: {
dayNight: "AUTO",
exposure: "AUTO",
whiteBalance: "AUTO",
wideDynamicRange: "OFF",
backlightCompensation: "OFF",
brightness: 50,
contrast: 50,
saturation: 50,
sharpness: 50,
noiseReduction: 0.5,
},
});
expect(s.time.ok && s.time.value).toMatchObject({
driftSeconds: -1,
ntp: { source: "manual", servers: ["time.windows.com"] },
});
expect(s.network).toEqual({
ok: true,
value: {
interfaces: [
{
name: "eth0",
mac: "d0:3b:f4:04:af:2e",
enabled: true,
ipv4: { address: "192.168.17.129", prefixLength: 20, dhcp: true },
ipv6: [
{ address: "fe80::d23b:f4ff:fe04:af2e", prefixLength: 64 },
{ address: "2001:470:1f11:137:d23b:f4ff:fe04:af2e", prefixLength: 64 },
],
speedMbps: 100,
duplex: "Full",
},
],
gateways: ["192.168.16.1"],
},
});
});
it("asks for RTSP URIs per profile and imaging for the profiles' video source", async () => {
camera();
await getCameraSettings(target);
expect(fake.calls.filter((c) => c.method === "getStreamUri").map((c) => c.args[0])).toEqual([
{ protocol: "RTSP", profileToken: "Profile_1" },
{ protocol: "RTSP", profileToken: "Profile_2" },
]);
expect(fake.calls.find((c) => c.method === "getImagingSettings")!.args[0]).toEqual({
token: "VideoSource_1",
});
});
it("only ever reads: no set/create/reboot calls", async () => {
camera();
await getCameraSettings(target);
expect(fake.calls.every((c) => c.method.startsWith("get"))).toBe(true);
});
});
describe("other firmware shapes", () => {
it("reads Media2 profiles, attribute values and numbers sent as strings", async () => {
camera({}, [
{
$: { token: "p2" },
name: "h265",
configurations: {
videoSource: { sourceToken: "VS2" },
videoEncoder: {
encoding: "H265",
$: { GovLength: "60", Profile: "Main10" },
resolution: { width: "2560", height: "1440" },
quality: "4",
rateControl: { $: { ConstantBitRate: "true", FrameRateLimit: "25", BitrateLimit: "6144" } },
},
},
},
]);
const s = await getCameraSettings(target);
expect(s.streams.ok && s.streams.value[0]).toMatchObject({
token: "p2",
codec: "H265",
profile: "Main10",
width: 2560,
height: 1440,
fps: 25,
bitrateKbps: 6144,
constantBitRate: true,
gop: 60,
quality: 4,
});
expect(fake.calls.find((c) => c.method === "getImagingSettings")!.args[0]).toEqual({ token: "VS2" });
});
it("tolerates a profile with nothing but a token", async () => {
camera({}, [{ token: "bare" }]);
const s = await getCameraSettings(target);
expect(s.streams.ok && s.streams.value[0]).toMatchObject({ token: "bare", codec: undefined, gop: undefined });
});
it("reports no streams for a camera with no profiles", async () => {
camera({}, []);
const { streams } = await getCameraSettings(target);
expect(streams).toEqual({ ok: true, value: [] });
});
it("reads NTP servers from DHCP, by IP", async () => {
camera({
getNTP: () => ({
value: {
fromDHCP: "true",
NTPFromDHCP: [{ type: "IPv4", IPv4Address: "192.168.16.1" }, { type: "IPv6", IPv6Address: "fe80::1" }, {}],
},
}),
});
const { time } = await getCameraSettings(target);
expect(time.ok && time.value.ntp).toEqual({ source: "dhcp", servers: ["192.168.16.1", "fe80::1"] });
});
it("reads a static IPv4 address, a single (non-array) interface, and disabled IPv6", async () => {
camera({
getNetworkInterfaces: () => ({
value: {
$: { token: "eth1" },
enabled: "true",
link: { adminSettings: { speed: "1000", duplex: "Full" } },
IPv4: { config: { DHCP: false, manual: [{ address: "10.0.0.20", prefixLength: "24" }] } },
IPv6: { enabled: false, config: { linkLocal: { address: "fe80::9" } } },
},
}),
getNetworkDefaultGateway: () => ({ value: { IPv4Address: ["10.0.0.1", "0.0.0.0"] } }),
});
const { network } = await getCameraSettings(target);
expect(network).toEqual({
ok: true,
value: {
interfaces: [
{
name: "eth1",
mac: undefined,
enabled: true,
ipv4: { address: "10.0.0.20", prefixLength: 24, dhcp: false },
ipv6: [],
speedMbps: 1000,
duplex: "Full",
},
],
gateways: ["10.0.0.1"],
},
});
});
it("leaves out an interface's IPv4 when it has no address", async () => {
camera({ getNetworkInterfaces: () => ({ value: [{ info: { name: "wlan0" }, IPv4: { config: { DHCP: true } } }] }) });
const { network } = await getCameraSettings(target);
expect(network.ok && network.value.interfaces[0]).toMatchObject({ name: "wlan0", ipv4: undefined });
});
});
describe("partial failures", () => {
it("shows a missing stream URL as null, keeping the rest", async () => {
camera({ getStreamUri: failing() });
const { streams } = await getCameraSettings(target);
expect(streams.ok && streams.value.map((s) => [s.token, s.rtspUrl, s.width])).toEqual([
["Profile_1", null, 4096],
["Profile_2", null, 1200],
]);
});
it.each([
["image", { getImagingSettings: failing() }],
["time", { getSystemDateAndTime: failing() }],
["time", { getSystemDateAndTime: () => ({ value: new Date("nonsense") }) }],
["network", { getNetworkInterfaces: failing() }],
] as const)("marks only %s unavailable, with a fixed message, when its call fails", async (name, overrides) => {
camera(overrides as Script);
const s = await getCameraSettings(target);
expect(s[name]).toEqual({ ok: false, error: UNAVAILABLE });
expect(JSON.stringify(s)).not.toContain("hunter2");
for (const other of (["streams", "image", "time", "network"] as const).filter((k) => k !== name)) {
expect(s[other].ok).toBe(true);
}
expect(console.warn).toHaveBeenCalledWith(`[settings] ${name} unavailable:`, expect.anything());
});
it("keeps the clock when NTP can't be read", async () => {
camera({ getNTP: failing() });
const { time } = await getCameraSettings(target);
expect(time.ok && time.value.ntp).toBeNull();
});
it("keeps the interfaces when the gateway can't be read", async () => {
camera({ getNetworkDefaultGateway: failing() });
const { network } = await getCameraSettings(target);
expect(network.ok && network.value.gateways).toEqual([]);
});
it("treats an empty imaging answer as all-unknown", async () => {
camera({ getImagingSettings: () => ({ value: undefined }) });
const { image } = await getCameraSettings(target);
expect(image).toEqual({ ok: true, value: expect.objectContaining({ dayNight: undefined, brightness: undefined }) });
});
it("lets connection and login errors propagate for the page to explain", async () => {
const err = new Error("login rejected");
fake.connect.mockRejectedValue(err);
await expect(getCameraSettings(target)).rejects.toBe(err);
});
});
describe("sanitizeRtspUrl", () => {
it("strips credentials and points at the registry address, keeping port and path", () => {
expect(sanitizeRtspUrl("rtsp://admin:hunter2@cam.internal:8554/Streaming/Channels/101?x=1", "192.168.1.10")).toBe(
"rtsp://192.168.1.10:8554/Streaming/Channels/101?x=1",
);
});
it("allows rtsps", () => {
expect(sanitizeRtspUrl("rtsps://cam/live", "10.0.0.2")).toBe("rtsps://10.0.0.2/live");
});
it.each([["http://cam/stream"], ["not a url"], [undefined], [42]])("rejects %j", (uri) => {
expect(sanitizeRtspUrl(uri, "10.0.0.2")).toBeNull();
});
});
describe("sources", () => {
it("uses the ONVIF source by default, and accepts another (e.g. a future ISAPI one)", async () => {
const settings = { streams: { ok: true, value: [] } } as never;
const isapi = { read: vi.fn().mockResolvedValue(settings) };
expect(await getCameraSettings(target, isapi)).toBe(settings);
expect(isapi.read).toHaveBeenCalledWith(target);
expect(onvifSettingsSource.read).toBeTypeOf("function");
});
});

290
src/lib/camera-settings.ts Normal file
View File

@ -0,0 +1,290 @@
import "server-only";
import type { Cam, CamProfile } from "onvif";
import { connect, encoderConfig, profileToken, type CameraTarget } from "./camera";
/**
* A camera's current settings, read-only (vrek gol-2nxgqjn, dec-s205dvs, dec-gtw493s).
*
* The page asks for settings through `CameraSettingsSource`; today the only source is ONVIF
* on the cached connection from camera.ts. A Hikvision ISAPI source can be added later
* behind the same interface without the page changing (pri-p9h51hx).
*
* Each section is read independently: a camera that doesn't support, say, imaging still
* shows its streams. Failed sections carry a fixed message; the camera's own error text
* only goes to the server log (pri-tyrxdz9).
*/
export type Section<T> = { ok: true; value: T } | { ok: false; error: string };
export interface StreamSettings {
token: string;
name?: string;
codec?: string;
/** Codec profile, e.g. "Main" for H.264. */
profile?: string;
width?: number;
height?: number;
fps?: number;
bitrateKbps?: number;
constantBitRate?: boolean;
/** Keyframe interval (GOP length) in frames. */
gop?: number;
quality?: number;
/** RTSP URL with no credentials, pointed at the camera's registry address. */
rtspUrl: string | null;
}
export interface ImageSettings {
/** IR-cut filter mode: "AUTO" switches between day and night. */
dayNight?: string;
exposure?: string;
whiteBalance?: string;
wideDynamicRange?: string;
backlightCompensation?: string;
brightness?: number;
contrast?: number;
saturation?: number;
sharpness?: number;
noiseReduction?: number;
}
export interface TimeSettings {
/** ISO timestamp from the camera's clock. */
cameraTime: string;
/** Camera clock minus server clock, in seconds. */
driftSeconds: number;
/** Null when the camera won't say. */
ntp: { source: "dhcp" | "manual"; servers: string[] } | null;
}
export interface NetworkInterfaceSettings {
name?: string;
mac?: string;
enabled?: boolean;
ipv4?: { address: string; prefixLength?: number; dhcp: boolean };
ipv6: { address: string; prefixLength?: number }[];
speedMbps?: number;
duplex?: string;
}
export interface NetworkSettings {
interfaces: NetworkInterfaceSettings[];
gateways: string[];
}
export interface CameraSettings {
streams: Section<StreamSettings[]>;
image: Section<ImageSettings>;
time: Section<TimeSettings>;
network: Section<NetworkSettings>;
}
/** Where settings come from. ONVIF today; ISAPI can implement this later. */
export interface CameraSettingsSource {
read(target: CameraTarget): Promise<CameraSettings>;
}
export const UNAVAILABLE = "Not available from this camera";
// --- parsing helpers: onvif returns loosely shaped, partly stringly typed XML-as-JSON ----
type Loose = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
const toArray = <T>(v: T | T[] | undefined | null): T[] => (v == null ? [] : Array.isArray(v) ? v : [v]);
function num(v: unknown): number | undefined {
const n = typeof v === "string" ? Number(v) : v;
return typeof n === "number" && Number.isFinite(n) ? n : undefined;
}
function bool(v: unknown): boolean | undefined {
if (typeof v === "boolean") return v;
if (v === "true") return true;
if (v === "false") return false;
return undefined;
}
const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
/** First value of `key` anywhere inside `obj` (vendors nest extensions deeply). */
function findDeep(obj: unknown, key: string): unknown {
if (!obj || typeof obj !== "object") return undefined;
if (key in obj) return (obj as Loose)[key];
for (const v of Object.values(obj)) {
const found = findDeep(v, key);
if (found !== undefined) return found;
}
return undefined;
}
/** Credential-free RTSP URL on the registry address; null if it isn't an RTSP URL. */
export function sanitizeRtspUrl(uri: unknown, host: string): string | null {
if (typeof uri !== "string") return null;
let url: URL;
try {
url = new URL(uri);
} catch {
return null;
}
if (url.protocol !== "rtsp:" && url.protocol !== "rtsps:") return null;
url.username = "";
url.password = "";
url.hostname = host;
return url.toString();
}
const call = <T>(fn: (cb: (err: Error | null, value?: T) => void) => void) =>
new Promise<T | undefined>((resolve, reject) => fn((err, value) => (err ? reject(err) : resolve(value))));
async function section<T>(name: string, read: () => Promise<T>): Promise<Section<T>> {
try {
return { ok: true, value: await read() };
} catch (err) {
console.warn(`[settings] ${name} unavailable:`, err instanceof Error ? err.message : err);
return { ok: false, error: UNAVAILABLE };
}
}
// --- sections --------------------------------------------------------------------------
async function readStream(cam: Cam, p: CamProfile, host: string): Promise<StreamSettings> {
const enc: Loose = encoderConfig(p) ?? {};
const rate: Loose = enc.rateControl ?? {};
const token = profileToken(p);
const uri = await call<{ uri: string }>((cb) =>
cam.getStreamUri({ protocol: "RTSP", profileToken: token }, cb),
).catch((err) => {
console.warn(`[settings] stream URI for ${token} unavailable:`, err?.message ?? err);
return undefined;
});
return {
token,
name: str(p.name),
codec: str(enc.encoding),
profile: str(enc.H264?.H264Profile ?? enc.H265?.H265Profile ?? enc.$?.Profile),
width: num(enc.resolution?.width),
height: num(enc.resolution?.height),
fps: num(rate.frameRateLimit ?? rate.$?.FrameRateLimit),
bitrateKbps: num(rate.bitrateLimit ?? rate.$?.BitrateLimit),
constantBitRate: bool(rate.$?.ConstantBitRate ?? rate.constantBitRate),
gop: num(enc.H264?.govLength ?? enc.H265?.govLength ?? enc.govLength ?? enc.$?.GovLength),
quality: num(enc.quality),
rtspUrl: sanitizeRtspUrl(uri?.uri, host),
};
}
function videoSourceToken(profiles: CamProfile[]): string | undefined {
for (const p of profiles) {
const token = p.videoSourceConfiguration?.sourceToken ?? p.configurations?.videoSource?.sourceToken;
if (token) return token;
}
return undefined;
}
async function readImage(cam: Cam, profiles: CamProfile[]): Promise<ImageSettings> {
const s: Loose =
(await call<Loose>((cb) => cam.getImagingSettings({ token: videoSourceToken(profiles) }, cb))) ?? {};
return {
dayNight: str(s.irCutFilter),
exposure: str(s.exposure?.mode),
whiteBalance: str(s.whiteBalance?.mode),
wideDynamicRange: str(s.wideDynamicRange?.mode),
backlightCompensation: str(s.backlightCompensation?.mode),
brightness: num(s.brightness),
contrast: num(s.contrast),
saturation: num(s.colorSaturation),
sharpness: num(s.sharpness),
noiseReduction: num((findDeep(s, "noiseReduction") as Loose | undefined)?.level),
};
}
function ntpServer(entry: Loose): string | undefined {
return str(entry.DNSname) ?? str(entry.IPv4Address) ?? str(entry.IPv6Address);
}
async function readTime(cam: Cam): Promise<TimeSettings> {
const date = await call<Date>((cb) => cam.getSystemDateAndTime(cb));
if (!(date instanceof Date) || Number.isNaN(date.getTime())) throw new Error("no camera time");
const driftSeconds = Math.round((date.getTime() - Date.now()) / 1000);
const ntp: Loose | undefined = await call<Loose>((cb) => cam.getNTP(cb)).catch(() => undefined);
const fromDhcp = bool(ntp?.fromDHCP) ?? false;
const entries = toArray<Loose>(fromDhcp ? ntp?.NTPFromDHCP : ntp?.NTPManual);
return {
cameraTime: date.toISOString(),
driftSeconds,
ntp: ntp
? {
source: fromDhcp ? "dhcp" : "manual",
servers: entries.map(ntpServer).filter((s): s is string => Boolean(s)),
}
: null,
};
}
function ipv6Addresses(config: Loose | undefined) {
const seen = new Map<string, number | undefined>();
for (const key of ["manual", "linkLocal", "fromDHCP", "fromRA"]) {
for (const a of toArray<Loose>(config?.[key])) {
const address = str(a.address);
if (address && !seen.has(address)) seen.set(address, num(a.prefixLength));
}
}
return [...seen].map(([address, prefixLength]) => ({ address, prefixLength }));
}
function toInterface(i: Loose): NetworkInterfaceSettings {
const v4: Loose | undefined = i.IPv4?.config;
const dhcp = bool(v4?.DHCP) ?? false;
// Prefer the address matching the configured mode, but show whichever one is reported.
const [preferred, other] = dhcp ? [v4?.fromDHCP, v4?.manual] : [v4?.manual, v4?.fromDHCP];
const v4addr: Loose | undefined = toArray<Loose>(preferred)[0] ?? toArray<Loose>(other)[0];
const link: Loose | undefined = i.link?.operSettings ?? i.link?.adminSettings;
return {
name: str(i.info?.name) ?? str(i.$?.token),
mac: str(i.info?.hwAddress),
enabled: bool(i.enabled),
ipv4: v4addr && str(v4addr.address)
? { address: v4addr.address, prefixLength: num(v4addr.prefixLength), dhcp }
: undefined,
ipv6: bool(i.IPv6?.enabled) === false ? [] : ipv6Addresses(i.IPv6?.config),
speedMbps: num(link?.speed),
duplex: str(link?.duplex),
};
}
async function readNetwork(cam: Cam): Promise<NetworkSettings> {
const interfaces = toArray<Loose>(await call<Loose | Loose[]>((cb) => cam.getNetworkInterfaces(cb)));
const gw: Loose | undefined = await call<Loose>((cb) => cam.getNetworkDefaultGateway(cb)).catch(
() => undefined,
);
const gateways = [...toArray(gw?.IPv4Address), ...toArray(gw?.IPv6Address)]
.map(str)
.filter((a): a is string => Boolean(a) && a !== "::" && a !== "0.0.0.0");
return { interfaces: interfaces.map(toInterface), gateways };
}
// --- the ONVIF source ------------------------------------------------------------------
export const onvifSettingsSource: CameraSettingsSource = {
async read(target) {
// Connection and login errors (CameraAuthError, CameraInactiveError, …) propagate: the
// page shows the same guidance as the dashboard card.
const cam = await connect(target);
const profiles = cam.profiles ?? [];
const [streams, image, time, network] = await Promise.all([
section("streams", () => Promise.all(profiles.map((p) => readStream(cam, p, target.host)))),
section("image", () => readImage(cam, profiles)),
section("time", () => readTime(cam)),
section("network", () => readNetwork(cam)),
]);
return { streams, image, time, network };
},
};
export function getCameraSettings(
target: CameraTarget,
source: CameraSettingsSource = onvifSettingsSource,
): Promise<CameraSettings> {
return source.read(target);
}

View File

@ -122,7 +122,8 @@ function openCam(host: string, port: number, creds: Credentials | null): Promise
});
}
function connect(target: CameraTarget): Promise<Cam> {
/** The cached, logged-in connection for a camera (shared with camera-settings.ts). */
export function connect(target: CameraTarget): Promise<Cam> {
const key = `${target.host}:${target.port}`;
let pending = cams.get(key);
if (!pending) {
@ -147,12 +148,21 @@ export function resetConnection({ host, port }: CameraTarget) {
}
}
/** A profile's video encoder: Media1 uses videoEncoderConfiguration, Media2 configurations.videoEncoder. */
export function encoderConfig(p: CamProfile) {
return p.videoEncoderConfiguration ?? p.configurations?.videoEncoder;
}
/** A profile's token: an element in Media1, an attribute in Media2. */
export function profileToken(p: CamProfile): string {
return p.token ?? p.$?.token;
}
function toProfile(p: CamProfile): CameraProfile {
// Media1 uses videoEncoderConfiguration; Media2 profiles use configurations.videoEncoder.
const enc = p.videoEncoderConfiguration ?? p.configurations?.videoEncoder;
const enc = encoderConfig(p);
const resolution = enc?.resolution;
return {
token: p.token ?? p.$?.token,
token: profileToken(p),
name: p.name,
encoding: enc?.encoding,
width: resolution?.width,