Add image and stream editing with audit log, manual exposure controls
- Per-section Edit/Save for image settings and streams (Server Actions), validated against the camera's own ranges; stream saves ask to confirm the restart. Results report applied or adjusted values. - Every write is appended to a local audit log (.data/audit.jsonl). - In Manual exposure, edit exposure time (with approximate shutter speed) and gain; the read-only page shows them, and a hint explains that Brightness mainly works in Auto. - Pin onvif to the mikemainguy fork for complete imaging/encoder setters; set turbopack.root automatically when onvif is a linked checkout. - Export vrek log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0ec434ca26
commit
f4d40dbea6
152
.vrek/log.ndjson
152
.vrek/log.ndjson
@ -505,3 +505,155 @@
|
|||||||
{"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-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-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"}
|
{"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"}
|
||||||
|
{"id":"evt-xnpebsz5e53a","type":"node.created","subject":"dec-kgm44qw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Network identifiers in the committed vrek export stay for now; the user will redact later","body":"Recorded 2026-09-19 as input to a future process for handling sensitive information that lands in version control. What happened: commit 0ec434c (pushed to origin main at https://git.dasfad.com/mmainguy/cameras.git) includes .vrek/log.ndjson with the read-only probe findings (fnd-j1s725e, fnd-b73cav4, fnd-msm9prc, fnd-hcnbvht). Those findings contain camera LAN IPv4 addresses (192.168.17.129, 192.168.31.2), the gateway and subnet (192.168.16.1, /20), MAC addresses, a global IPv6 address (2001:470:1f11:137:…), serial numbers, and the ONVIF username 'camera'. The export contains no passwords, credential-store keys or password hashes (checked with a grep before each commit). Claude flagged it after the push. Decision (by the user): leave it in place for now; the user will handle redaction later. Consequences: vrek is append-only, so redacting means superseding the findings with redacted versions and, if the history must be cleaned, rewriting git history and force-pushing, which is the user's call. Until then, anyone with read access to the repo sees the network layout and device identifiers. Rejected for now: immediate redaction by Claude. Open question for the process: what counts as sensitive (credentials always; addresses, MACs, serials, usernames?) and when to check (before export, before commit, before push).","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:04:43.106Z","parents":["evt-9nwcj1gp32rt"],"hash":"aad6b583cde0012427d178b7854ae78e6747cd9476a040d93182630a3e205b6e"}
|
||||||
|
{"id":"evt-vh2bjsh1fea8","type":"edge.added","subject":"dec-kgm44qw","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-kgm44qw","to":"gol-6q1q5mr"},"at":"2026-09-19T18:04:43.108Z","parents":["evt-xnpebsz5e53a"],"hash":"0a0f5f04ecea19392081eb3b80ba7b9717dac133bcfc806a203eb3b6b0b69db8"}
|
||||||
|
{"id":"evt-1rw34fg20fq8","type":"node.created","subject":"iss-1bxj0nh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Redact network identifiers from the vrek export, and define a process for sensitive information in commits","body":"Owned by the user (deferred at their request). Two parts. (1) Redaction: supersede findings fnd-j1s725e, fnd-b73cav4, fnd-msm9prc and fnd-hcnbvht with versions that replace LAN and IPv6 addresses, MACs and serials with placeholders; re-export; decide whether to scrub git history (commit 0ec434c onward) and force-push. (2) Process: define what counts as sensitive (credentials, keys and hashes always; network identifiers, serials and usernames?), when it's checked (before vrek_export, before commit, before push; possibly a pre-commit hook), and what to do on a leak (record a decision, rotate anything secret, redact, clean history). The context is in the decision recorded alongside this item.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:04:47.041Z","parents":["evt-vh2bjsh1fea8"],"hash":"894aacb5f4ffdeeb7b1ce925181b0a536f601c6b1ad85fa8fc21c7d9b6ea36c1"}
|
||||||
|
{"id":"evt-a4npb2w88vte","type":"edge.added","subject":"iss-1bxj0nh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-1bxj0nh","to":"gol-6q1q5mr"},"at":"2026-09-19T18:04:47.043Z","parents":["evt-1rw34fg20fq8"],"hash":"a519313f26ea772abd6254d3ed8d38bfc6844dde653f713ee99f830e232f4e8f"}
|
||||||
|
{"id":"evt-tsmr6cd0ghvr","type":"edge.added","subject":"iss-1bxj0nh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-1bxj0nh","to":".vrek/log.ndjson"},"at":"2026-09-19T18:04:47.044Z","parents":["evt-a4npb2w88vte"],"hash":"fe544aed510c73c22580af3bc50763e4e0880ee208913309943fee807bb369e6"}
|
||||||
|
{"id":"evt-7t03qn3fbpx6","type":"edge.added","subject":"iss-1bxj0nh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-1bxj0nh","to":"area:security"},"at":"2026-09-19T18:04:47.045Z","parents":["evt-tsmr6cd0ghvr"],"hash":"8ad77db3813c2713b1c9c44be9c5b83c88082140d1150cd21f56d7dfb5a6e49f"}
|
||||||
|
{"id":"evt-nnnmtac6gnyt","type":"edge.added","subject":"iss-1bxj0nh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-1bxj0nh","to":"area:process"},"at":"2026-09-19T18:04:47.046Z","parents":["evt-7t03qn3fbpx6"],"hash":"7fd17b7ecd54e87854d301a612f4310b7dca7a16302a21165ffed67dd44793c1"}
|
||||||
|
{"id":"evt-gpmjravvh5va","type":"edge.added","subject":"iss-1bxj0nh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"iss-1bxj0nh","to":"dec-kgm44qw"},"at":"2026-09-19T18:04:50.915Z","parents":["evt-nnnmtac6gnyt"],"hash":"da511ca2fcb79c0c13a0ad469434da57989d4c1162e9a949028151db912b2190"}
|
||||||
|
{"id":"evt-n35a8pje6x9q","type":"node.updated","subject":"iss-1bxj0nh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"attrs":{"revisit":null,"deferred_because":"dec-kgm44qw: the user will handle redaction and define the process later; not a priority now."}}},"at":"2026-09-19T18:04:52.064Z","parents":["evt-gpmjravvh5va"],"hash":"8a29fa5aa9b10a696847c829d94324f7a9d90798d06f84917eaf4626ba5a675b"}
|
||||||
|
{"id":"evt-f2pb9y02c8s4","type":"node.status_changed","subject":"iss-1bxj0nh","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"deferred"},"at":"2026-09-19T18:04:52.066Z","parents":["evt-n35a8pje6x9q"],"hash":"9c2dae2516d21e9ffd325f0dfbb07c516c4f00b40ab237a9f9266365c9b86169"}
|
||||||
|
{"id":"evt-f70ry47s80d4","type":"node.created","subject":"dec-84ghhqr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Configuration, first slice: image and stream settings, Operator login only, per-section Edit → Save","body":"User's choices, 2026-09-19, for gol-8v1wqvp. (1) Scope of the first slice: Image (day/night IR-cut, brightness/contrast/saturation/sharpness, WDR, backlight compensation, exposure and white balance modes) and Streams (resolution, fps, bitrate, VBR/CBR, GOP, quality per stream, limited to values the camera reports as supported). Deferred: time/NTP and reboot, and network (highest risk of making a camera unreachable). (2) Privilege: keep using the stored ONVIF Operator login (least privilege). Detect per camera which writes it may make, offer only those, and explain how to grant more; don't upgrade the user or store a second admin login. (3) Editing: each section on /cameras/[id] gets Edit → Save / Cancel. Changes are validated (zod, and against the camera's reported options), disruptive changes (anything that restarts a stream) are confirmed, and after saving the settings are re-read from the camera to show what actually applied. Rejected: apply-immediately controls, and a single page-wide edit mode. (4) The user permitted a no-op permission probe (write back current imaging and encoder values; no reboot or network). It is ON HOLD: the library's setters aren't faithful no-ops (see the findings informing this goal), so the probe waits until faithful serializers exist, or the user picks a narrower probe.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:07:36.746Z","parents":["evt-f2pb9y02c8s4"],"hash":"11b356dae7ccaa058db017561b8b03d22221fe6830edcda849e4150bbb476444"}
|
||||||
|
{"id":"evt-s4764fcq6b76","type":"edge.added","subject":"dec-84ghhqr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-84ghhqr","to":"gol-8v1wqvp"},"at":"2026-09-19T18:07:36.749Z","parents":["evt-f70ry47s80d4"],"hash":"bd29f604a432097f6c02d04dcd3510e5455b4fda32f71a9201fe04411b7b108a"}
|
||||||
|
{"id":"evt-bgqw4c6py8yf","type":"node.created","subject":"fnd-sx2k2bg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"onvif's setVideoEncoderConfiguration drops the H.264 profile when you pass back the read configuration","body":"In node_modules/onvif/lib/media.js (onvif 0.8.x): reads return the profile as H264.H264Profile, but the setter only serializes H264.profile, so passing back the read object omits <H264Profile>. ONVIF's schema requires it, so a camera may reject the write or apply a default profile. Also: it skips Quality when it is 0 (a truthiness check); it always sends a ConstantBitRate attribute (false if absent); it sends ForcePersistence=true on Media1; and after success it re-reads the configuration itself. Implication: encoder writes need our own mapping (H264Profile → profile, quality 0 allowed) or our own SOAP body; never pass the read object back as-is.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"source reading of node_modules/onvif/lib/media.js Cam.prototype.setVideoEncoderConfiguration, 2026-09-19"}],"as_of":"2026-09-19"}},"at":"2026-09-19T18:07:40.789Z","parents":["evt-s4764fcq6b76"],"hash":"9fdf7f8982956cd64684efce5ec0368c89d8b6eeadba4f2d1ec55eb33ef04d1d"}
|
||||||
|
{"id":"evt-ff0x71pg7jv5","type":"edge.added","subject":"fnd-sx2k2bg","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-sx2k2bg","to":"gol-8v1wqvp"},"at":"2026-09-19T18:07:40.792Z","parents":["evt-bgqw4c6py8yf"],"hash":"53db260d9b47b94423ba41df1fa0cc3ca3f7a577e4e022c887303ab27b16173a"}
|
||||||
|
{"id":"evt-e25v6gt6n548","type":"node.created","subject":"fnd-jzmm3nz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"onvif's setImagingSettings can't write WDR, backlight compensation, white balance or noise reduction, and skips zero values","body":"In node_modules/onvif/lib/imaging.js, SetImagingSettings serializes only brightness, colorSaturation, contrast, sharpness, irCutFilter, exposure (mode, priority, min/max exposure time, gain, iris, …) and focus. WideDynamicRange, BacklightCompensation, WhiteBalance and noise reduction (an Imaging extension) are never sent. Each field is included only if truthy, so 0 (e.g. brightness 0, minGain 0) can't be written. ONVIF leaves the handling of omitted optional elements to the device (unchanged vs. reset), so a partial write isn't guaranteed to be a no-op. Implication: covering the chosen image scope needs our own SetImagingSettings SOAP body (via the camera's authenticated request path) that sends the complete settings, including zeros.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-s205dvs","note":"source reading of node_modules/onvif/lib/imaging.js Cam.prototype.setImagingSettings, 2026-09-19"}],"as_of":"2026-09-19"}},"at":"2026-09-19T18:07:44.036Z","parents":["evt-ff0x71pg7jv5"],"hash":"d2f7a9e7c623f8bca413fdb7885744af34936b55d8fbbe9db52a32a897d568d7"}
|
||||||
|
{"id":"evt-ahvdrsqbgc0n","type":"edge.added","subject":"fnd-jzmm3nz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-jzmm3nz","to":"gol-8v1wqvp"},"at":"2026-09-19T18:07:44.037Z","parents":["evt-e25v6gt6n548"],"hash":"7ebc45134bc20a0a254efc16154605f928463c4d3820958c325b4bc7b36243bf"}
|
||||||
|
{"id":"evt-dnw56xh92ekj","type":"node.created","subject":"dec-n4e8jah","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Configuration writes: probe after our writers exist, confirm stream saves, local audit log","body":"User's choices, 2026-09-19, for gol-8v1wqvp (following dec-84ghhqr). (1) The no-op permission probe runs only after complete, tested writers exist, re-sending each camera's full current image and stream settings through them; this is a faithful no-op and also proves the writers against the real firmware. Rejected: a brightness-only probe now through the library (unknown handling of omitted fields), or skipping the probe. (2) Saving stream settings shows a confirmation dialog: 'This restarts the <main/sub> stream; recorders and viewers reconnect'. Image saves need no dialog. Rejected: confirming every save, or never confirming. (3) Every configuration change is appended to a local audit log, .data/audit.jsonl (gitignored, 0600): time, admin user, camera id, section, setting old → new, and result (applied / refused / camera applied a different value). Passwords are never logged. Rejected: server console only, or no logging. How writes are implemented is still open: the user asked for a patch that's easy to upstream (see the question about patching onvif).","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:10:29.639Z","parents":["evt-ahvdrsqbgc0n"],"hash":"d47981efc1afdd7dc3ec8da652edfc37f7127fdf0c66bb0189d982bfed75ee44"}
|
||||||
|
{"id":"evt-qaea58xkvdyb","type":"edge.added","subject":"dec-n4e8jah","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-n4e8jah","to":"gol-8v1wqvp"},"at":"2026-09-19T18:10:29.640Z","parents":["evt-dnw56xh92ekj"],"hash":"f47c634477012112b4522d6639f580b700a6f36489ac1e8d06701e09c6c19281"}
|
||||||
|
{"id":"evt-mxg0w1b0ym0x","type":"node.created","subject":"que-xbw1xgq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"question","title":"How should we fix onvif's incomplete setters so the fixes are easy to send upstream (agsh/onvif)?","body":"Context: fnd-sx2k2bg (setVideoEncoderConfiguration drops H264Profile, skips quality 0) and fnd-jzmm3nz (setImagingSettings can't write WDR, BLC, white balance or noise reduction, and skips zeros). The user wants a patch that is easy to submit to the upstream maintainer. npm has no built-in patch command. Dependency rule: pri-mz2jxpb. Upstream: https://github.com/agsh/onvif, currently 0.8.3, modified 2026-09-09.","status":"open","owner":null,"attrs":{"options":[{"option":"Fork + git dependency (recommended)","consequence":"Fork agsh/onvif; fix the setters on a branch with tests in upstream's style; package.json points at the fork branch (commit pinned in the lockfile). The branch is the upstream PR. No new dependency; we maintain the fork until upstream releases, then switch back to npm."},{"option":"patch-package (new dev dependency)","consequence":"Edit node_modules/onvif, commit patches/onvif+0.8.3.patch, reapply on postinstall. The .patch file becomes the upstream PR. Adds patch-package 8.0.1 (MIT, 14 direct deps) as a dev dependency; requires this question to be answered per pri-mz2jxpb."},{"option":"git apply postinstall script","consequence":"Keep .patch files in the repo and apply them with git apply on postinstall. No new package, but a hand-rolled version of patch-package that's more fragile."}],"revisit":null}},"at":"2026-09-19T18:10:35.415Z","parents":["evt-qaea58xkvdyb"],"hash":"b5161adbaa22a8d837510f0e5f4c6e1cbcaa40f80b6a3753019c742cea11a420"}
|
||||||
|
{"id":"evt-03h90anmj8vv","type":"edge.added","subject":"que-xbw1xgq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"que-xbw1xgq","to":"gol-8v1wqvp"},"at":"2026-09-19T18:10:35.416Z","parents":["evt-mxg0w1b0ym0x"],"hash":"4241a535897522bd0567d33187b5de5bbd5c070e129dd9a53b6bd913b435e8a4"}
|
||||||
|
{"id":"evt-g9aj9t0k14dn","type":"edge.added","subject":"pri-mz2jxpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-mz2jxpb","to":"que-xbw1xgq"},"at":"2026-09-19T18:10:35.417Z","parents":["evt-03h90anmj8vv"],"hash":"b9be9c5d5565787dea3629d55a74dbabbfcf830d248dd43e1018d34a409a36b2"}
|
||||||
|
{"id":"evt-zzceh57jjpth","type":"edge.added","subject":"pri-p9h51hx","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"guides","from":"pri-p9h51hx","to":"que-xbw1xgq"},"at":"2026-09-19T18:10:35.418Z","parents":["evt-g9aj9t0k14dn"],"hash":"a822e7578f79be01333d2f1840c5ecd7658684257a2504df1201509be638e59a"}
|
||||||
|
{"id":"evt-s0j5mh6xz49t","type":"node.created","subject":"que-ph8eak5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"question","title":"Do the current I91ET cameras have a microphone, and do their streams carry audio?","body":"Raised by the user on 2026-09-19; the user asked to record it and do nothing yet. How to find out: (1) ONVIF GetAudioSources (is there an audio input?), GetAudioEncoderConfigurations and each profile's audioEncoderConfiguration (is audio configured: codec, bitrate, sample rate?); (2) the SDP from an RTSP DESCRIBE on /Streaming/Channels/101 and /102 (an m=audio line means audio is in that stream); (3) the camera web page under Configuration → Video/Audio (stream type 'Video' vs 'Video & Audio'; an Audio tab exists if there's a mic). All read-only.","status":"open","owner":null,"attrs":{"options":[],"revisit":null}},"at":"2026-09-19T18:10:38.186Z","parents":["evt-zzceh57jjpth"],"hash":"c4db15ad420e1c46adf9eacb7d938c92efae0fc7d8e8dd595679087af7df5eb8"}
|
||||||
|
{"id":"evt-phq90201zv32","type":"edge.added","subject":"que-ph8eak5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"que-ph8eak5","to":"gol-2nxgqjn"},"at":"2026-09-19T18:10:38.188Z","parents":["evt-s0j5mh6xz49t"],"hash":"68edd12a1ae0012a6496a7e86df0fa40141c9d3d0b5463f9215e3a6f4afd57ca"}
|
||||||
|
{"id":"evt-ka1bgsndfcb2","type":"edge.added","subject":"que-ph8eak5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"que-ph8eak5","to":"gol-6q1q5mr"},"at":"2026-09-19T18:10:38.189Z","parents":["evt-phq90201zv32"],"hash":"8b39dc1ed7b06ade4a4d964f24c93f154704a2783fac223d5e682ecacbbdae0e"}
|
||||||
|
{"id":"evt-pptprvh4vqdd","type":"node.created","subject":"iss-td8y1cc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Test audio streaming on the current cameras","body":"Deferred at the user's request (2026-09-19): record only, don't start. When picked up: read-only checks per camera. (a) ONVIF: GetAudioSources, GetAudioEncoderConfigurations, and the profiles' audioEncoderConfiguration. (b) RTSP DESCRIBE on Channels/101 and /102, looking for m=audio in the SDP, with the codec (e.g. G.711/AAC) and clock rate. (c) Optionally, pull a few seconds of the stream to confirm audio packets actually flow. Record the results as findings answering the audio question. If audio exists, follow up with whether the camera page should show audio settings (read-only) and whether stream URLs should mention it. Any probe needs the user's go-ahead at the time (as with dec-s205dvs), and redaction rules from dec-kgm44qw apply to what gets recorded.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:10:41.975Z","parents":["evt-ka1bgsndfcb2"],"hash":"ee65a7d19090f62ac443facba2541eb63062397c7b7707cddecd51b66494a17b"}
|
||||||
|
{"id":"evt-4trzvpcfgfpp","type":"edge.added","subject":"iss-td8y1cc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-td8y1cc","to":"gol-2nxgqjn"},"at":"2026-09-19T18:10:41.977Z","parents":["evt-pptprvh4vqdd"],"hash":"aa3c6e9cf939263053da0889503f5d37302656d7adba4d7717016b37b2f0919c"}
|
||||||
|
{"id":"evt-ghmty51vfe3m","type":"edge.added","subject":"iss-td8y1cc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-td8y1cc","to":"area:audio"},"at":"2026-09-19T18:10:41.978Z","parents":["evt-4trzvpcfgfpp"],"hash":"e4277056f8a944ce164eae19a8b445fab84281fcebad9fd4c457d5cec5313b3f"}
|
||||||
|
{"id":"evt-vh1n0k158144","type":"edge.added","subject":"iss-td8y1cc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-td8y1cc","to":"area:settings"},"at":"2026-09-19T18:10:41.979Z","parents":["evt-ghmty51vfe3m"],"hash":"ba2f3318728a448182774325a7fbf613a4d222dc5a6f08da87e3b087ca8495f8"}
|
||||||
|
{"id":"evt-bredmkzts5gb","type":"node.updated","subject":"iss-td8y1cc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"attrs":{"revisit":null,"deferred_because":"User asked to record the audio test and not start it yet (2026-09-19)."}}},"at":"2026-09-19T18:10:47.915Z","parents":["evt-vh1n0k158144"],"hash":"08a2308b0e4ee9a0e8b4da810c65e9b483db585f08ec4e5a099ee40913188623"}
|
||||||
|
{"id":"evt-ntsjbkdrh9e3","type":"node.status_changed","subject":"iss-td8y1cc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"deferred"},"at":"2026-09-19T18:10:47.917Z","parents":["evt-bredmkzts5gb"],"hash":"4dee9f0bf893504ec16e189f92e6a7049e4fe1cc89e823fa6f75e7410ba2a472"}
|
||||||
|
{"id":"evt-q8b1ec1esd2n","type":"node.updated","subject":"que-ph8eak5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"attrs":{"revisit":null,"deferred_because":"To be answered by iss-td8y1cc when the user picks it up; recorded only for now (2026-09-19)."}}},"at":"2026-09-19T18:10:49.283Z","parents":["evt-ntsjbkdrh9e3"],"hash":"6fb5060e89189199cf32d4ed832dc30f6178ab9958ca8b12cc87a665344b35bd"}
|
||||||
|
{"id":"evt-hmt0mv1brkkw","type":"node.status_changed","subject":"que-ph8eak5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"deferred"},"at":"2026-09-19T18:10:49.285Z","parents":["evt-q8b1ec1esd2n"],"hash":"90e8b2f33e530b7998f93228eabc5b3e2d38b8a9f0e6290a4a6cf9a369c62139"}
|
||||||
|
{"id":"evt-nanz1yr33rce","type":"edge.added","subject":"iss-td8y1cc","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"iss-td8y1cc","to":"que-ph8eak5"},"at":"2026-09-19T18:10:50.653Z","parents":["evt-hmt0mv1brkkw"],"hash":"b26069cb622af73200089aa263f14b96ec45a5b4030a678aebc84f974de02d83"}
|
||||||
|
{"id":"evt-1fc2mpgvmk0a","type":"node.created","subject":"dec-2myt5ht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Fix onvif's setters in the user's GitHub fork (mikemainguy/onvif), consumed as a git dependency until upstream merges","body":"Chosen by the user on 2026-09-19. The fork already exists at https://github.com/mikemainguy/onvif (fork of agsh/onvif). Fixes for fnd-sx2k2bg and fnd-jzmm3nz go on a topic branch with tests in upstream's own style, so the branch can be opened as a PR against agsh/onvif unchanged. package.json points \"onvif\" at the fork by commit (github:mikemainguy/onvif#<sha>), pinned in the lockfile. When upstream releases the fixes, switch back to the npm version. No new dependency (pri-mz2jxpb). Rejected: patch-package (new dev dependency) and a hand-rolled git-apply postinstall. Pushing branches to the fork and opening the upstream PR are outward-facing, so Claude asks before each.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:13:28.947Z","parents":["evt-nanz1yr33rce"],"hash":"99719ba9705b8d73eb54f2bd12e28a35645147de4cf0530e695636158feac28d"}
|
||||||
|
{"id":"evt-jq8hkdpdynqg","type":"edge.added","subject":"dec-2myt5ht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"answers","from":"dec-2myt5ht","to":"que-xbw1xgq"},"at":"2026-09-19T18:13:28.948Z","parents":["evt-1fc2mpgvmk0a"],"hash":"fa3a30b3db1ecb90f8c45d65559ed2547db697ee1d6daa14378f833271246c66"}
|
||||||
|
{"id":"evt-4mmhvv4yg0fj","type":"question.answered","subject":"que-xbw1xgq","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"answer":"dec-2myt5ht"},"at":"2026-09-19T18:13:28.949Z","parents":["evt-jq8hkdpdynqg"],"hash":"0205c6c6f2ec3e7ee54d938cc5caa57883a33f895b612be740ced1bcf1b73d8b"}
|
||||||
|
{"id":"evt-8z140shxdty5","type":"edge.added","subject":"dec-2myt5ht","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-2myt5ht","to":"gol-8v1wqvp"},"at":"2026-09-19T18:13:28.950Z","parents":["evt-4mmhvv4yg0fj"],"hash":"c133e4eeab629f242c8206117447e19f22ac1111c13c29f274ab06bd9838c05a"}
|
||||||
|
{"id":"evt-yz3ptpy1wmdm","type":"node.created","subject":"fnd-0xcw70x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Upstream onvif has a v1 TypeScript rewrite (1.0.0-rc.2) whose setters build complete typed structures; the 0.8.x line lives on the v0.x branch","body":"agsh/onvif master (last commit 2026-09-14) is v1: TypeScript in src/, promise-based API split into services (cam.imaging.setImagingSettings({videoSourceToken, imagingSettings, forcePersistence}), media.setVideoEncoderConfiguration({configuration, forcePersistence})), Jest tests against a HappyTime ONVIF server, and builds to build/. Its setImagingSettings serializes the whole ImagingSettings through Imaging.imagingSettingsToBuild(), and setVideoEncoderConfiguration maps every field explicitly, so the gaps in fnd-sx2k2bg and fnd-jzmm3nz probably don't exist there (not yet verified field by field). npm dist-tags: latest 0.8.3, rc 1.0.0-rc.2, legacy 0.8.1. The 0.8.x JavaScript line (the one this app uses) is the upstream v0.x branch, whose head is exactly the v0.8.3 tag (last commit 2026-09-07), with mocha tests and a mock server. So a PR fixing the 0.8 setters would target v0.x; moving to v1 would mean migrating our camera.ts, onvif.ts and camera-settings.ts to the new API.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"url":"https://github.com/agsh/onvif","note":"branches master and v0.x, src/imaging.ts setImagingSettings, src/media.ts setVideoEncoderConfiguration, read 2026-09-19 via a clone of the fork"},{"url":"https://www.npmjs.com/package/onvif","note":"dist-tags, 2026-09-19"}],"as_of":"2026-09-19"}},"at":"2026-09-19T18:14:20.243Z","parents":["evt-8z140shxdty5"],"hash":"0f0a5643391d48e22518cf84009fa15a4d9b50507b9bb4ba426865b2c3ee6e29"}
|
||||||
|
{"id":"evt-8ah09j3n0thq","type":"edge.added","subject":"fnd-0xcw70x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0xcw70x","to":"que-xbw1xgq"},"at":"2026-09-19T18:14:20.244Z","parents":["evt-yz3ptpy1wmdm"],"hash":"e548ddb1c585d32f4e896929348cd4d1b45714d5f328778eb526e96c8e940bb9"}
|
||||||
|
{"id":"evt-mf19tnbw4xbd","type":"edge.added","subject":"fnd-0xcw70x","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-0xcw70x","to":"gol-8v1wqvp"},"at":"2026-09-19T18:14:20.245Z","parents":["evt-8ah09j3n0thq"],"hash":"093a3e027db618425e3771418592f640327d2edb2cbd2ca9ace0157bc84fe4e6"}
|
||||||
|
{"id":"evt-jv625t6fq7m6","type":"node.created","subject":"fnd-a9y3w97","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"onvif v1 (1.0.0-rc.2) fixes the encoder's H.264 profile and quality, and adds WDR/BLC/white balance, but still skips zero values, omits CBR/VBR and passes the imaging extension through raw","body":"Field-by-field reading of agsh/onvif master src/imaging.ts and src/media.ts (2026-09-19). Imaging.imagingSettingsToBuild: brightness, colorSaturation, contrast and sharpness use `x && {…}`, so 0 is dropped; exposureToBuild drops minGain, maxGain, gain, min/max exposure time and iris when 0 (our cameras report minGain 0 per fnd-g4z7c6d); wideDynamicRangeToBuild drops level 0; whiteBalanceToBuild drops crGain/cbGain 0; backlightCompensation level uses !== undefined (correct). Extension is passed through as read (camelCase keys such as noiseReduction/level), which likely serializes the wrong element names, so noise reduction probably isn't writable correctly. ForcePersistence is only sent when true. Media.setVideoEncoderConfiguration: maps H264Profile correctly (fixes fnd-sx2k2bg's main bug), sends Quality unconditionally (0 allowed), but RateControl omits the ConstantBitRate attribute, so CBR/VBR can't be changed through Media1 in v1. Conclusion: both v0.8.3 and v1 need upstream fixes for this app's configuration scope; migrating to v1 alone wouldn't be enough.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"url":"https://github.com/agsh/onvif/blob/master/src/imaging.ts","note":"imagingSettingsToBuild, exposureToBuild, wideDynamicRangeToBuild, whiteBalanceToBuild, backlightCompensationToBuild, setImagingSettings; read at master ac284ae via the fork clone"},{"url":"https://github.com/agsh/onvif/blob/master/src/media.ts","note":"setVideoEncoderConfiguration around line 1177"}],"as_of":"2026-09-19"}},"at":"2026-09-19T18:15:42.267Z","parents":["evt-mf19tnbw4xbd"],"hash":"634a9365f2e5e06ffc35ea087f26ce8a148d8c1b58a486141f1f845b197b17e6"}
|
||||||
|
{"id":"evt-cpk4rjq3dhjq","type":"edge.added","subject":"fnd-a9y3w97","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-a9y3w97","to":"que-xbw1xgq"},"at":"2026-09-19T18:15:42.270Z","parents":["evt-jv625t6fq7m6"],"hash":"97d5d1c3de41f8245179ad71b2d085cf2299b54ac1389e8a0f37ede8e706b21b"}
|
||||||
|
{"id":"evt-1nn8wxjq3s3j","type":"edge.added","subject":"fnd-a9y3w97","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-a9y3w97","to":"gol-8v1wqvp"},"at":"2026-09-19T18:15:42.271Z","parents":["evt-cpk4rjq3dhjq"],"hash":"9c9b807bc2ddf623b81d45804b34b0aa32325f11fc6515c892be91cceb218e2a"}
|
||||||
|
{"id":"evt-427ysa5scptf","type":"node.created","subject":"dec-70qga6k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Fix onvif v0.x in the fork now and consume it; send equivalent fixes to v1 master as a second PR","body":"User's choice, 2026-09-19, after verifying v1 (fnd-0xcw70x, fnd-a9y3w97). Refines dec-2myt5ht. (1) In mikemainguy/onvif, branch from upstream/v0.x; fix setVideoEncoderConfiguration (accept the read shape H264.H264Profile as well as H264.profile; send Quality 0) and setImagingSettings (send 0 values; add WideDynamicRange, BacklightCompensation, WhiteBalance, and the NoiseReduction extension with correct element names), with mocha tests in the branch's existing style. Open a PR against agsh/onvif v0.x, and point this app's package.json at the fork commit. (2) Separately, branch from upstream master and fix the same classes of bug in v1 (zero values in imaging builders, the ConstantBitRate attribute on RateControl, proper serialization of the imaging extension), with Jest tests, and open a PR against master. No migration to v1 now. Rejected: fixing v0.x only; migrating to v1 now. Pushes to the fork and opening either PR need the user's go-ahead each time; the gh CLI is logged in as 'immersiveidea', not 'mikemainguy', so push access must be confirmed.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:16:27.329Z","parents":["evt-1nn8wxjq3s3j"],"hash":"8410a6357cf5c6b510d6c6cad58cc4acd108f3d0c10b4274238045edf2901b9b"}
|
||||||
|
{"id":"evt-4h1zrzy0bm84","type":"edge.added","subject":"dec-70qga6k","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-70qga6k","to":"gol-8v1wqvp"},"at":"2026-09-19T18:16:27.330Z","parents":["evt-427ysa5scptf"],"hash":"eae35c1ff86ff5d0db97cb83935dba5e978210c10c5743c7bf12b460f6fc0c8e"}
|
||||||
|
{"id":"evt-jg2sm72x1hfq","type":"node.created","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Camera configuration, first slice: image and stream settings (Edit → Save)","body":"Parent for gol-8v1wqvp, following dec-84ghhqr, dec-n4e8jah, dec-2myt5ht and the v0.x-plus-v1 decision. Done when every child is closed, the no-op permission probe has passed on both cameras, and the user has changed an image and a stream setting on a real camera and seen them re-read correctly.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:16:28.961Z","parents":["evt-4h1zrzy0bm84"],"hash":"7b477be10ede1932a951f1a98f722d22c127e87fe95f74631fce75f7ace63e56"}
|
||||||
|
{"id":"evt-026n1aa67s46","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-3g6m4rz","to":"gol-8v1wqvp"},"at":"2026-09-19T18:16:28.962Z","parents":["evt-jg2sm72x1hfq"],"hash":"868e8eaf530f9a0358459a6ed8937577148a6413d3bcf17070e2e61d012f2a07"}
|
||||||
|
{"id":"evt-k6h5mp2472r6","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-3g6m4rz","to":"area:config"},"at":"2026-09-19T18:16:28.963Z","parents":["evt-026n1aa67s46"],"hash":"761a2f96ec50989c86e0186256ddc0fec144373fef6d06ad02a2e88250f72e11"}
|
||||||
|
{"id":"evt-20g7fxpp4900","type":"node.created","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Fork (v0.x): fix setVideoEncoderConfiguration and setImagingSettings, with mocha tests","body":"In ~/WebstormProjects/onvif (clone of mikemainguy/onvif; upstream remote = agsh/onvif), branch fix/v0-complete-setters from upstream/v0.x. Encoder: accept H264.H264Profile (the read shape) as well as H264.profile; send Quality even when 0; H265 isn't in Media1, so no change there. Imaging: send numeric fields when !== undefined (0 allowed); add WideDynamicRange (Mode, Level), BacklightCompensation (Mode, Level), WhiteBalance (Mode, CrGain, CbGain), and Extension/NoiseReduction/Level with correct ONVIF element names and namespaces; keep existing fields and behaviour unchanged otherwise. Tests: mocha in the branch's test/ style against its server mockup, asserting the serialized SOAP for each fix (and that unchanged inputs produce the same XML as before). Run the branch's lint and tests. Commit with upstream-style messages. Don't push until the user says so.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:16:35.796Z","parents":["evt-k6h5mp2472r6"],"hash":"fd9dbeb8f069fb3e5fbcbdc8a11e04300df913f5637a2eb4e3c8b4b9d249769e"}
|
||||||
|
{"id":"evt-bsakdsecmdg0","type":"edge.added","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-tsq9zc3","to":"gol-8v1wqvp"},"at":"2026-09-19T18:16:35.798Z","parents":["evt-20g7fxpp4900"],"hash":"dad84d8edbab1ed5149d175ed96104cedff341d812e51463b6151ca6adc27250"}
|
||||||
|
{"id":"evt-pggr49rmjs62","type":"edge.added","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-tsq9zc3","to":"~/WebstormProjects/onvif"},"at":"2026-09-19T18:16:35.799Z","parents":["evt-bsakdsecmdg0"],"hash":"fb5bfe448ea94787ad8971423647710dbdf728a3e4bb1b541ed6242a5b530fe8"}
|
||||||
|
{"id":"evt-f1p7atpb974x","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-3g6m4rz","to":"iss-tsq9zc3"},"at":"2026-09-19T18:16:35.800Z","parents":["evt-pggr49rmjs62"],"hash":"5c636d9ff04b87bbb5953adb100768cc999729a423361d19b06c53c076884eb4"}
|
||||||
|
{"id":"evt-e13x6f4nk47x","type":"edge.added","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-tsq9zc3","to":"area:config"},"at":"2026-09-19T18:16:35.801Z","parents":["evt-f1p7atpb974x"],"hash":"be9e666f7f34552af5d630fa3abc546ffd51e10af65151977c53a01ddff8acc6"}
|
||||||
|
{"id":"evt-qmvax8eckyx2","type":"edge.added","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-tsq9zc3","to":"area:upstream"},"at":"2026-09-19T18:16:35.802Z","parents":["evt-e13x6f4nk47x"],"hash":"6c5be829e1523ed27f6c03e968141681d650da8549df7561a760e8af21a951cd"}
|
||||||
|
{"id":"evt-je73jw2y5hna","type":"node.created","subject":"iss-7p7pt6q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Point the app at the fixed fork commit and re-verify","body":"After the v0.x fix branch is pushed (with the user's go-ahead): set \"onvif\": \"github:mikemainguy/onvif#<commit>\" in package.json, install, and confirm that serverExternalPackages still works, the full suite and build pass, and discovery, the dashboard and the camera page still work. Record the upstream PR URL in vrek once it's opened. When agsh/onvif releases a 0.8.x with the fixes, switch back to the npm version (tracked here).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:16:42.367Z","parents":["evt-qmvax8eckyx2"],"hash":"bf0577776469bccdddf26c3990e0a3291db7770261108684e5f965215f139826"}
|
||||||
|
{"id":"evt-v4rm08zx9x8y","type":"edge.added","subject":"iss-7p7pt6q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-7p7pt6q","to":"gol-8v1wqvp"},"at":"2026-09-19T18:16:42.373Z","parents":["evt-je73jw2y5hna"],"hash":"6c6c94d32c15a8ea08cf1ba5960e166bfcd56b0cc22ebddc1bea2212cfcb080e"}
|
||||||
|
{"id":"evt-hje66bwxj4s0","type":"edge.added","subject":"iss-7p7pt6q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7p7pt6q","to":"package.json"},"at":"2026-09-19T18:16:42.374Z","parents":["evt-v4rm08zx9x8y"],"hash":"2015b9c3207ec13e147e4a9be3559956dd87b7276f51bfeb050fb5a9658d61f4"}
|
||||||
|
{"id":"evt-hjrk78ajr8w9","type":"edge.added","subject":"iss-7p7pt6q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-7p7pt6q","to":"package-lock.json"},"at":"2026-09-19T18:16:42.375Z","parents":["evt-hje66bwxj4s0"],"hash":"18defebc972a3e07dcf6aee62d885c3ca4614049fff5d0edcae5dc0e69446d6d"}
|
||||||
|
{"id":"evt-fdkje7h2ba0p","type":"edge.added","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-tsq9zc3","to":"iss-7p7pt6q"},"at":"2026-09-19T18:16:42.376Z","parents":["evt-hjrk78ajr8w9"],"hash":"45de4d9f4b02a88a68e358e511f4e8b9133f5dcfa8b37dd846ee08d4e36c5773"}
|
||||||
|
{"id":"evt-0rhzmfs3hn4w","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-3g6m4rz","to":"iss-7p7pt6q"},"at":"2026-09-19T18:16:42.377Z","parents":["evt-fdkje7h2ba0p"],"hash":"850b049378660791db2d0d0b0c5698a8dda102979f90aaef7d600a512bccddc1"}
|
||||||
|
{"id":"evt-1bd88fydb5jx","type":"edge.added","subject":"iss-7p7pt6q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-7p7pt6q","to":"area:config"},"at":"2026-09-19T18:16:42.378Z","parents":["evt-0rhzmfs3hn4w"],"hash":"ae3c562256bc622aee4c59c6c4eb632b975ae2a67ee9838f04843202a53bc689"}
|
||||||
|
{"id":"evt-ja6x5wbpese9","type":"edge.added","subject":"iss-7p7pt6q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-7p7pt6q","to":"area:upstream"},"at":"2026-09-19T18:16:42.379Z","parents":["evt-1bd88fydb5jx"],"hash":"08273a651c76a12b78697261f3e408957b72dc2eeb94e6f7653087a95f1d0097"}
|
||||||
|
{"id":"evt-msp648h4d957","type":"node.created","subject":"iss-h5e97mf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Fork (v1 master): fix zero values, ConstantBitRate and the imaging extension, with Jest tests","body":"Per fnd-a9y3w97: branch from upstream master in the fork. imagingSettingsToBuild and its sub-builders send numeric fields when !== undefined; RateControl gets the ConstantBitRate attribute when provided; the imaging extension (NoiseReduction etc.) is serialized with ONVIF element names rather than passed through raw. Jest tests in the repo's style (unit tests of the builders where possible, as the full suite needs the HappyTime server). Separate PR against agsh/onvif master. Lower priority than the v0.x fix; this app doesn't consume it.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:16:45.236Z","parents":["evt-ja6x5wbpese9"],"hash":"c01d75640d3fb4318d533d85d1ee023409c8a7a905077850a3fa76ca82eafacc"}
|
||||||
|
{"id":"evt-w6n6gbdend0a","type":"edge.added","subject":"iss-h5e97mf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-h5e97mf","to":"gol-8v1wqvp"},"at":"2026-09-19T18:16:45.238Z","parents":["evt-msp648h4d957"],"hash":"dd5892daca7544b9dccd6ee84daa74dfd6a5f83e13fc30165066ef5363660c59"}
|
||||||
|
{"id":"evt-amqsddmd1tby","type":"edge.added","subject":"iss-h5e97mf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-h5e97mf","to":"~/WebstormProjects/onvif"},"at":"2026-09-19T18:16:45.239Z","parents":["evt-w6n6gbdend0a"],"hash":"0465f92759e78b24f45ef6913f3ee979b5dffee6be8064b5f154b4a7da2ff112"}
|
||||||
|
{"id":"evt-jcvzxdqasysg","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-3g6m4rz","to":"iss-h5e97mf"},"at":"2026-09-19T18:16:45.240Z","parents":["evt-amqsddmd1tby"],"hash":"0a306ca16b02a444c5695463dc7852c56c30e64b85e4f04a70f6cd374fc5aa8c"}
|
||||||
|
{"id":"evt-hb3damgv2t3g","type":"edge.added","subject":"iss-h5e97mf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-h5e97mf","to":"area:upstream"},"at":"2026-09-19T18:16:45.241Z","parents":["evt-jcvzxdqasysg"],"hash":"2a5758870ccf05e0dc43cc56da314164b27dfb28d8af1876f8f248cf8ccf88cb"}
|
||||||
|
{"id":"evt-3prg2e2pdk60","type":"node.created","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Camera configuration writers: validated image and stream saves, re-read, audit log","body":"src/lib/camera-config.ts (server-only), alongside camera-settings.ts behind the same source interface (pri-p9h51hx). It reads the allowed values (Imaging GetOptions, GetVideoEncoderConfigurationOptions) so the UI offers only supported values. It writes image settings (full current settings merged with the change) and a stream's encoder configuration (current configuration merged with the change) through the fixed onvif setters. Every input is validated with zod plus the camera's options. After writing it re-reads and reports applied / camera-adjusted / refused. Permission detection: a 'not authorized' fault marks that section read-only for that camera. src/lib/audit-log.ts appends JSON lines to .data/audit.jsonl (0600, with a turbopackIgnore'd path), per dec-n4e8jah. Tests use a fake camera (pri-e14bahk).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:16:49.910Z","parents":["evt-hb3damgv2t3g"],"hash":"1fb0a7328e5ab5cbc301df0976c0a4c17f2ad6ae0f74d22de3ac4e76ae81819d"}
|
||||||
|
{"id":"evt-bm8bdkfgxhvh","type":"edge.added","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-a6j9jg5","to":"gol-8v1wqvp"},"at":"2026-09-19T18:16:49.913Z","parents":["evt-3prg2e2pdk60"],"hash":"141b3c0355a1bd21f3e00ebcd2e04693135c268118c1c3ef173fe587e3ed6693"}
|
||||||
|
{"id":"evt-ce32nbjsvy9r","type":"edge.added","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-a6j9jg5","to":"src/lib/camera-config.ts"},"at":"2026-09-19T18:16:49.914Z","parents":["evt-bm8bdkfgxhvh"],"hash":"fa3273034716888c999cfd7c3ac46ee711b7c52630d4e1806aa8262ae596f4c7"}
|
||||||
|
{"id":"evt-5n364n7wfk1e","type":"edge.added","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-a6j9jg5","to":"src/lib/audit-log.ts"},"at":"2026-09-19T18:16:49.915Z","parents":["evt-ce32nbjsvy9r"],"hash":"d5c06c6f2867a1917603a1805199fbd426f44ff38eb765b4e132fdbe88522dd9"}
|
||||||
|
{"id":"evt-kwrtye8899jt","type":"edge.added","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-tsq9zc3","to":"iss-a6j9jg5"},"at":"2026-09-19T18:16:49.916Z","parents":["evt-5n364n7wfk1e"],"hash":"c60733eb7a95ac79125fd8bb573ec3c830e1bd62a5e449b32e8c51c05443d607"}
|
||||||
|
{"id":"evt-8fgphyztja8t","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-3g6m4rz","to":"iss-a6j9jg5"},"at":"2026-09-19T18:16:49.917Z","parents":["evt-kwrtye8899jt"],"hash":"f1034c2d13df9e48f10f15641742292cac06f7709ec6c9783fe91e51df797570"}
|
||||||
|
{"id":"evt-tsk3nxs2vjb7","type":"edge.added","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-a6j9jg5","to":"area:config"},"at":"2026-09-19T18:16:49.918Z","parents":["evt-8fgphyztja8t"],"hash":"94a288bfba4e35f1a1214834ba1130aa2d636aaf216dfee585c0ac6c1bc2db40"}
|
||||||
|
{"id":"evt-e8az9263f7aj","type":"node.created","subject":"iss-j8zzg1r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Camera page: Edit → Save for the image and stream sections, with stream-restart confirmation","body":"On /cameras/[id], the Image and each Stream section get Edit, with Save and Cancel. Controls are limited to the values the camera supports; Server Actions validate and call the writers. Saving a stream first shows a confirmation dialog ('This restarts the main stream; recorders and viewers reconnect'). After saving, the section shows what the camera actually applied, including values it adjusted, with a clear message. Sections the Operator login may not change show as read-only, with a note on how to grant more. Icons per pri-8ev5m89. Tests cover validation, confirmation, and the applied/adjusted/refused outcomes.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:16:53.981Z","parents":["evt-tsk3nxs2vjb7"],"hash":"09111be4620a27d3471fc91b1265325a6e7c613a63689b9604e6c494c8f140ae"}
|
||||||
|
{"id":"evt-z78njn7mvkyv","type":"edge.added","subject":"iss-j8zzg1r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-j8zzg1r","to":"gol-8v1wqvp"},"at":"2026-09-19T18:16:53.983Z","parents":["evt-e8az9263f7aj"],"hash":"4dca7902481b04ba9c72323755382eab0ef7a8c7427cd446014fe25100031bd1"}
|
||||||
|
{"id":"evt-9j5dvcy3tyqj","type":"edge.added","subject":"iss-j8zzg1r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-j8zzg1r","to":"src/app/cameras/[id]/"},"at":"2026-09-19T18:16:53.985Z","parents":["evt-z78njn7mvkyv"],"hash":"cf277acfae8103049f1dea4c61064935bb87d079e0cd28022d3c8866d0bb2f1b"}
|
||||||
|
{"id":"evt-eqbpfnyte9z7","type":"edge.added","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-tsq9zc3","to":"iss-j8zzg1r"},"at":"2026-09-19T18:16:53.986Z","parents":["evt-9j5dvcy3tyqj"],"hash":"c8fbbb3f270159af25d68bab4708a860b53c2ea4be86fc27fe4e7624494aa786"}
|
||||||
|
{"id":"evt-0we7fpf23hc0","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-3g6m4rz","to":"iss-j8zzg1r"},"at":"2026-09-19T18:16:53.987Z","parents":["evt-eqbpfnyte9z7"],"hash":"6b06fd3a8658a07e79bbd89257091c53efccad134a3bc8f3c7edf81d432a9082"}
|
||||||
|
{"id":"evt-wmmgy41r4h1q","type":"edge.added","subject":"iss-j8zzg1r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-j8zzg1r","to":"area:config"},"at":"2026-09-19T18:16:53.988Z","parents":["evt-0we7fpf23hc0"],"hash":"2c6aa91a793a2b6335b32f197d396c792808bcd6ea413d9138c34310736affff"}
|
||||||
|
{"id":"evt-0bj1hrcm6ws7","type":"edge.added","subject":"iss-j8zzg1r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-j8zzg1r","to":"area:ui"},"at":"2026-09-19T18:16:53.989Z","parents":["evt-wmmgy41r4h1q"],"hash":"6d407bee0c98a7cbeaeb863b38091e1d7e946248adff4b94b2261414335ea463"}
|
||||||
|
{"id":"evt-h7ce8q60jzfk","type":"node.created","subject":"iss-hg50epa","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"No-op permission probe on both cameras with the finished writers","body":"Per dec-n4e8jah: with the fixed writers, re-send each camera's full current image settings and each stream's current encoder configuration, then re-read and confirm nothing changed. Record which writes the Operator login may make as findings, redacted per dec-kgm44qw. Needs the user's go-ahead at the time. Re-sending a stream's configuration may briefly restart that stream.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T18:16:56.111Z","parents":["evt-0bj1hrcm6ws7"],"hash":"7bd2ce10d89758cf8d2e1883412086a8b74be1aebdbc0452cb57e18bcd886254"}
|
||||||
|
{"id":"evt-nct8wwnarnjp","type":"edge.added","subject":"iss-hg50epa","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-hg50epa","to":"gol-8v1wqvp"},"at":"2026-09-19T18:16:56.112Z","parents":["evt-h7ce8q60jzfk"],"hash":"d08cf7dcd95e0ba9f27f3598001ded250755065e6d707c6128e0158e36973c23"}
|
||||||
|
{"id":"evt-7x22r3fmxyh1","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-3g6m4rz","to":"iss-hg50epa"},"at":"2026-09-19T18:16:56.113Z","parents":["evt-nct8wwnarnjp"],"hash":"39c848f0273e9f6ccf4946039682299855a10a33c97736102fa05f54d5bf20de"}
|
||||||
|
{"id":"evt-n371g6x9k4j3","type":"edge.added","subject":"iss-hg50epa","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-hg50epa","to":"area:config"},"at":"2026-09-19T18:16:56.114Z","parents":["evt-7x22r3fmxyh1"],"hash":"be6f4bb03938435753b801f3d97ee413f08af91e1f0825d3a6dde9862ba2b3d4"}
|
||||||
|
{"id":"evt-qzcb8kh8m28k","type":"edge.added","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-a6j9jg5","to":"iss-hg50epa"},"at":"2026-09-19T18:17:00.344Z","parents":["evt-n371g6x9k4j3"],"hash":"0b037544dac3642405f81657a44eb2187d53dde8f21d7e2bc7b4ebcc4972ef82"}
|
||||||
|
{"id":"evt-7gstdrfz7sy9","type":"edge.added","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"blocks","from":"iss-a6j9jg5","to":"iss-j8zzg1r"},"at":"2026-09-19T18:17:01.720Z","parents":["evt-qzcb8kh8m28k"],"hash":"1e05a711228705c8be3561a2227f819e9005938ed7b6be0c1d4e58a810398315"}
|
||||||
|
{"id":"evt-dhwh7n6qhya3","type":"node.status_changed","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T18:19:00.735Z","parents":["evt-7gstdrfz7sy9"],"hash":"f8b5e55e8c727ebe5aa7a85f0e1f43cce5b1241d835d226961d11ee54d922f14"}
|
||||||
|
{"id":"evt-vnn5sk9nw6g0","type":"node.created","subject":"ver-5a37qc7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"On branch fix/v0-complete-setters (from upstream/v0.x) in the fork clone: setImagingSettings sends 0 values, emits elements in ImagingSettings20 order, and adds BLC/WDR/white balance and the NoiseReduction extension (read shape round-trips); setVideoEncoderConfiguration accepts H264.H264Profile and MPEG4.Mpeg4Profile (and emits the schema name Mpeg4Profile) and sends quality 0; upstream's lint and full mocha suite pass; the new tests fail against the unfixed code.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:30:34.353Z","parents":["evt-dhwh7n6qhya3"],"hash":"6aca83ce0f431f972592347c2dbaf22c71d783321435f12226c06b7bbd8d2d8c"}
|
||||||
|
{"id":"evt-7wzxpz1hxkr1","type":"edge.added","subject":"ver-5a37qc7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-5a37qc7","to":"iss-tsq9zc3"},"at":"2026-09-19T18:30:34.355Z","parents":["evt-vnn5sk9nw6g0"],"hash":"fb9fed9ed42173a32c8cba192f25f1d31ca80a68b999560c2b5dee92c4b1aeea"}
|
||||||
|
{"id":"evt-sqm38spn1bps","type":"verification.recorded","subject":"ver-5a37qc7","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"~/WebstormProjects/onvif, local commits 9a5bd635 (imaging) and bfda12fd (media) on fix/v0-complete-setters, 2 ahead of upstream/v0.x (4ac697bc = v0.8.3). `npm test` (eslint + nyc mocha against the repo's serverMockup): 178 passing (168 before plus 10 new serialization tests that capture the SOAP via the rawRequest event). With lib/ stashed back to v0.8.3, 7 of the 10 new tests fail (the 3 that pass cover unchanged behaviour: omit absent fields, round-trip, legacy H264.profile). Extra fix found along the way: the MPEG4 element was misnamed MPEG4Profile (schema: Mpeg4Profile, as in v1). NOT pushed; waiting for the user's go-ahead. 2026-09-19."},"at":"2026-09-19T18:30:34.356Z","parents":["evt-7wzxpz1hxkr1"],"hash":"8fb99eacabef51f5bfd21403a24b458024f25139bdd63baac0e9d5f6aad2fdab"}
|
||||||
|
{"id":"evt-ecfvyr1056a7","type":"node.status_changed","subject":"iss-tsq9zc3","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T18:30:35.465Z","parents":["evt-sqm38spn1bps"],"hash":"7e8545bceb6cc61f624221d8784ec7f6366a964cf6a9a2afbc0a479956ab92aa"}
|
||||||
|
{"id":"evt-e76m90p50fh9","type":"node.updated","subject":"iss-7p7pt6q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Interim state (2026-09-19): the app uses the local fork checkout via `npm install ../onvif --no-save`, so node_modules/onvif is a symlink to ~/WebstormProjects/onvif (branch fix/v0-complete-setters, commits 9a5bd635 and bfda12fd). package.json and package-lock.json still say onvif ^0.8.3 from npm, and a plain `npm install` or `npm ci` restores the npm version; re-run `npm install ../onvif --no-save` to relink. next.config.ts now detects a linked onvif outside the project and sets turbopack.root to the nearest common parent, as the Next 16 docs require for linked packages; with a normal install it does nothing. The user will push the branch and open the upstream PR themselves, then tell Claude to switch. Remaining: set \"onvif\": \"github:mikemainguy/onvif#<commit>\" in package.json, npm install (the lockfile pins the commit), confirm node_modules/onvif is a real install (no symlink), re-run the full suite and build, and record the upstream PR URL here. When agsh/onvif releases a 0.8.x with the fixes, switch back to the npm version."}},"at":"2026-09-19T18:35:11.871Z","parents":["evt-ecfvyr1056a7"],"hash":"3ab5191fd07505abe3526769d24597a660b7cd4e2f94fc7f8eecd2420ba98b85"}
|
||||||
|
{"id":"evt-gppppbprhqhv","type":"node.created","subject":"ver-0rqfwy8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Interim: the app runs against the local fixed fork without changing package.json or the lockfile; the full suite, tsc, eslint and next build pass.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:35:14.933Z","parents":["evt-e76m90p50fh9"],"hash":"b897e7da3748e53934439629aace8d473a07429c6fb4f1d5619d5c267d8975bf"}
|
||||||
|
{"id":"evt-cvhvvpjdfgpd","type":"edge.added","subject":"ver-0rqfwy8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-0rqfwy8","to":"iss-7p7pt6q"},"at":"2026-09-19T18:35:14.934Z","parents":["evt-gppppbprhqhv"],"hash":"6e40e895ebb399a4b0729b9c2eab70927430631e958be700ec016ef0f6af5b14"}
|
||||||
|
{"id":"evt-0xvwtj5cvrk4","type":"verification.recorded","subject":"ver-0rqfwy8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"2026-09-19: `npm install ../onvif --no-save` → node_modules/onvif -> ../../onvif (require.resolve points at ~/WebstormProjects/onvif/lib/onvif.js; lib/imaging.js contains the new schemaValue helper); git status clean apart from next.config.ts. Vitest 522/522 (99.89% lines), tsc and eslint clean. next build first failed with \"Can't resolve 'onvif'\" (Turbopack doesn't resolve outside its root); fixed with the automatic turbopack.root in next.config.ts, after which the build compiles cleanly. Not yet exercised against the real cameras with the linked copy. The final switch to the GitHub commit is still open."},"at":"2026-09-19T18:35:14.935Z","parents":["evt-cvhvvpjdfgpd"],"hash":"40e2884a2ff00bd646d67bd25fbb66e7270dd810a3ca667bdef70d69dd254293"}
|
||||||
|
{"id":"evt-z00bf305k585","type":"node.status_changed","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T18:52:31.512Z","parents":["evt-0xvwtj5cvrk4"],"hash":"fe11293cda9edbcf51fb3285c8b1c5fb8bfc994b3484a6b90fd3ddc37567df15"}
|
||||||
|
{"id":"evt-1gmn972mpzv6","type":"node.updated","subject":"iss-h5e97mf","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"body":"Owned by the user, who works on the fork in a separate process. Claude must not change ~/WebstormProjects/onvif from the cameras session (user instruction, 2026-09-19). Per fnd-a9y3w97: branch from upstream master in the fork. imagingSettingsToBuild and its sub-builders send numeric fields when !== undefined; RateControl gets the ConstantBitRate attribute when provided; the imaging extension (NoiseReduction etc.) is serialized with ONVIF element names rather than passed through raw. Jest tests in the repo's style. Separate PR against agsh/onvif master. Lower priority than the v0.x fix; this app doesn't consume it.","owner":"mikemainguy"}},"at":"2026-09-19T18:55:47.316Z","parents":["evt-z00bf305k585"],"hash":"d84e61c63a82ea7c07f68e5fed2c4476cd2243a849c521447f52405e794aef86"}
|
||||||
|
{"id":"evt-rwknn9nza9n0","type":"node.created","subject":"dec-gan94zb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"The onvif fork is changed only by the user; the cameras session only verifies the app against it","body":"User instruction, 2026-09-19: \"Don't ever change code in the onvif repo from here, I'll do in another process. Just verify our code here against the fork.\" Applies to ~/WebstormProjects/onvif (clone of mikemainguy/onvif, linked into cameras with `npm install ../onvif --no-save`). From the cameras session: read files there, and run the cameras suite, tsc and build against it; no edits, commits, branch changes, installs or test runs inside the fork. If cameras needs a library change, describe it to the user. Context: while the user was rewriting the fix commits (now 6efbc258 and cc348ee1 on fix/v0-complete-setters) and fixing other bugs, Claude committed a typings change (8cdb8701, index.d.ts only) and ran the fork's test suite, which clashed on the mock server port. The user decides whether to keep 8cdb8701. Refines dec-70qga6k: the fork parts of iss-tsq9zc3 and iss-h5e97mf are the user's.","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:55:50.987Z","parents":["evt-1gmn972mpzv6"],"hash":"e8fabb6b2d0497a0825bf40f1150c0df0fa01b793a63f3da08c45476ac3f9649"}
|
||||||
|
{"id":"evt-6znzd7szxckj","type":"edge.added","subject":"dec-gan94zb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-gan94zb","to":"gol-8v1wqvp"},"at":"2026-09-19T18:55:50.990Z","parents":["evt-rwknn9nza9n0"],"hash":"abafd6ec2dee73dc4b8bf51a0733e991d51b68c1231014875c4d5763629e4e1a"}
|
||||||
|
{"id":"evt-tkwqn3eyh756","type":"node.created","subject":"ver-gmdmcap","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"camera-config.ts reads each camera's allowed image and stream values, validates patches (zod shape + the camera's options), merges the change into the current full settings, writes through the fork's setters, re-reads and reports each field as applied or adjusted; refusals are classified not-permitted vs failed with fixed text; stream writes drop the stale cached connection; every attempted write is appended to .data/audit.jsonl (0600, folder 0700) without secrets.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T18:59:36.493Z","parents":["evt-6znzd7szxckj"],"hash":"f058fe2e9ffe5cf1c50a12516fde82097e68442bad1714793e4b91845718a873"}
|
||||||
|
{"id":"evt-rppn517b2twa","type":"edge.added","subject":"ver-gmdmcap","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-gmdmcap","to":"iss-a6j9jg5"},"at":"2026-09-19T18:59:36.501Z","parents":["evt-tkwqn3eyh756"],"hash":"76ddf47e81d9810fb2fe1963ea944ad9c430d4f2c44421d03299a3288b1b12da"}
|
||||||
|
{"id":"evt-vevkkr666gw4","type":"verification.recorded","subject":"ver-gmdmcap","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/camera-config.ts (readConfigOptions, updateImageSettings, updateStreamSettings, parse*/validate* helpers) and src/lib/audit-log.ts (AUDIT_LOG_FILE, default .data/audit.jsonl with the turbopackIgnore marker). Tests: camera-config.test.ts (an in-memory fake camera that stores settings; covers merge-preserves-everything incl. minGain 0 and noise reduction, nested WDR/BLC/exposure/WB, camera-adjusted values, text booleans, per-option validation for every field, shape/unknown-field/empty rejection before connecting, unsupported options, not-permitted vs failed with no 'hunter2' leak, login errors propagating, unknown or bare streams, Media2 declined, resetConnection on success and failure, options parsing incl. ONVIF Min/Max, single values and extension H264) and audit-log.test.ts (JSONL, modes 0600/0700, timestamps). camera-config.ts 99.4% lines. Full suite 564/564, coverage 99.81%, tsc, eslint and next build clean, run against the linked fork (node_modules/onvif -> ~/WebstormProjects/onvif), 2026-09-19. Not yet exercised against the real cameras: that's the no-op probe iss-hg50epa, which needs the user's go-ahead."},"at":"2026-09-19T18:59:36.502Z","parents":["evt-rppn517b2twa"],"hash":"c59d5f3c21f240452de159ff78bce8e579825c2606f103fcf425fdae72a21bf8"}
|
||||||
|
{"id":"evt-1dgapbwj4x52","type":"node.status_changed","subject":"iss-a6j9jg5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T18:59:38.800Z","parents":["evt-vevkkr666gw4"],"hash":"8dd62ea375354c9b85c2e1638c72f8920ac7379c2e00667e4d05fd77c9da23d0"}
|
||||||
|
{"id":"evt-64685bn85a6x","type":"node.status_changed","subject":"iss-j8zzg1r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T19:04:53.623Z","parents":["evt-1dgapbwj4x52"],"hash":"a2fbc3214647959733ee6d01a574ccdc01a194a9f2c69290a5581caf5669eddb"}
|
||||||
|
{"id":"evt-zasar55qysy2","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, iss-s935a4n and iss-j8zzg1r.\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; CircleCheck = success message; TriangleAlert = needs attention (setup needed, clock drift, stream-restart warning, read-only note); RefreshCw = retry or refresh; Radar = scan; Timer = refresh rate; Trash2 = forget; Lock = save a login, or the sign-in heading; Save = save settings; Pencil = edit; X = cancel; 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-19T19:09:23.636Z","parents":["evt-64685bn85a6x"],"hash":"5bb2518e98f7a9eaab43076af5d489da30bb7b360f114c068f2fc9fc4f34ee58"}
|
||||||
|
{"id":"evt-vs08kwh1xr4y","type":"node.created","subject":"ver-c00gkf5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"On /cameras/[id], Image and each stream with camera-reported options show Edit; forms offer only supported values (ranges in labels, browser-enforced min/max), send only changed fields, require an inline confirmation before a stream save, show applied/adjusted/invalid/not-permitted/session/camera outcomes, refresh the page on success, and turn a section read-only with guidance when the login isn't permitted; Server Actions check access, resolve the camera from the registry, record the actor, and map errors to fixed text.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T19:09:31.001Z","parents":["evt-zasar55qysy2"],"hash":"2bd201c522d4a26ee152aa23be1256cb49d132b69870f8d96402b6d35d856fcb"}
|
||||||
|
{"id":"evt-pb5q49zdvyz0","type":"edge.added","subject":"ver-c00gkf5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-c00gkf5","to":"iss-j8zzg1r"},"at":"2026-09-19T19:09:31.003Z","parents":["evt-vs08kwh1xr4y"],"hash":"b4f53ffdbe9782ffb8df0816cb7905d079ed1164ccc935bf3aa5e7afb1bc9337"}
|
||||||
|
{"id":"evt-g4qev4pkp7n5","type":"verification.recorded","subject":"ver-c00gkf5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/app/cameras/[id]/{actions.ts, save-result.ts, edit-forms.tsx}; settings-sections.tsx loads readConfigOptions alongside the settings (null means read-only). camera-settings.ts now also reads the WDR and BLC levels. Tests: actions.test.ts (actor, unsecured actor, signed-out, bad id/unknown/non-private host, fixed error text with no leak, stream token validation), edit-forms.test.tsx (supported values only, browser range blocking, changed-fields-only, no-change close, cancel reset, adjusted message, invalid issues keep the form, not-permitted → read-only, session message, hidden unsupported controls, stream confirm/back-out/no-change-no-warning, spinner, icons aria-hidden), settings-sections.test.tsx (Edit only where options exist; read-only when options fail). The confirmation is an inline role=alertdialog rather than a native <dialog>, which jsdom can't open. Full suite 603/603, coverage 99.83%, tsc, eslint and next build clean against the linked fork, 2026-09-19. Not yet used in a real browser: saving writes to a real camera."},"at":"2026-09-19T19:09:31.005Z","parents":["evt-pb5q49zdvyz0"],"hash":"d5708991b26b394ad788fe3964645e7baa4bfe77a60b62954abd778104874609"}
|
||||||
|
{"id":"evt-6qsfagkwfnp6","type":"node.status_changed","subject":"iss-j8zzg1r","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"in_progress","to":"done"},"at":"2026-09-19T19:09:33.608Z","parents":["evt-g4qev4pkp7n5"],"hash":"cd04f3d5466b3322dfc9b41217769e04de0699ce92a5cadd5ff22e30e4202bdb"}
|
||||||
|
{"id":"evt-xsbkdj3w1rfd","type":"node.created","subject":"ver-8vzn88e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"package.json pins onvif to the user's pushed fork commit; installs work without SSH keys; the installed library sends zero values, WDR/BLC/white balance/noise reduction in schema order, and the encoder H264Profile, quality 0 and ConstantBitRate; the full suite, tsc, eslint and next build pass on a real (non-symlink) install.","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T19:18:11.387Z","parents":["evt-6qsfagkwfnp6"],"hash":"b478e0dfd11de884aa4c96caf3ab9fbc51932b6b55728b1ca21f1c4cc50c8cb7"}
|
||||||
|
{"id":"evt-tzp396yw6eqv","type":"edge.added","subject":"ver-8vzn88e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-8vzn88e","to":"iss-7p7pt6q"},"at":"2026-09-19T19:18:11.391Z","parents":["evt-xsbkdj3w1rfd"],"hash":"38639974535edaf4ae76c3c26c9da88cf605b78617354aa7bcd7024fafde2e05"}
|
||||||
|
{"id":"evt-yshbbgrh4bb4","type":"verification.recorded","subject":"ver-8vzn88e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"2026-09-19: the user pushed fix/v0-complete-setters to github.com/mikemainguy/onvif as a single commit e8c340fdf97cf4a42da23ce810b4ecdd8c93dd4b (git ls-remote; matches the local fork HEAD). package.json: \"onvif\": \"github:mikemainguy/onvif#e8c340fd…\"; package-lock resolved git+ssh://…#e8c340fd…, but `GIT_SSH_COMMAND=/usr/bin/false npm pack github:…#e8c340fd` fetched https://codeload.github.com/mikemainguy/onvif/tar.gz/e8c340fd… (200), so no SSH key is needed. node_modules/onvif is a real directory. The installed library's XML, captured with a stubbed _request (no network): Brightness/ColorSaturation/Contrast/Sharpness/MinGain 0 sent; BacklightCompensation(Mode ON, Level 0), WideDynamicRange(ON, 50), WhiteBalance(MANUAL, CrGain 0, CbGain 64), NoiseReduction(Level 0.5); ImagingSettings20 order true; encoder Quality 0, H264Profile Main, GovLength 50, BitrateLimit 10240, ConstantBitRate=\"true\". Vitest 603/603 (99.83%), tsc, eslint and next build clean. The upstream PR URL isn't known yet (the user is opening it)."},"at":"2026-09-19T19:18:11.392Z","parents":["evt-tzp396yw6eqv"],"hash":"0dd271a699d3bf203622733e83131c9d9cee4e8079d958c995e32d8f3b109796"}
|
||||||
|
{"id":"evt-gzc0f3j2yxpq","type":"node.status_changed","subject":"iss-7p7pt6q","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T19:18:13.781Z","parents":["evt-yshbbgrh4bb4"],"hash":"bad4fc99981d78e094a6e64af93ad951b5d4004289964f3924287b5069236e6a"}
|
||||||
|
{"id":"evt-88ym20js8h46","type":"node.created","subject":"iss-pkp09k8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Track the upstream onvif PR and switch back to the npm release when it ships","body":"The app is pinned to github:mikemainguy/onvif#e8c340fd (the user's fork, fix/v0-complete-setters). The user opens the PR against agsh/onvif v0.x themselves. When known, record the PR URL here. When agsh/onvif publishes a 0.8.x containing the fixes, change package.json back to the npm version, reinstall, and re-verify: the XML capture of zero values, WDR/BLC/WB/NR, H264Profile and CBR; the full suite and build. The fork itself is only changed by the user (dec-gan94zb).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T19:18:16.291Z","parents":["evt-gzc0f3j2yxpq"],"hash":"ab1a0850ef44dae1099c7c2ce0ef41690aadcb6cb0ba464cc7e36b3910671fee"}
|
||||||
|
{"id":"evt-9nxje0x317x3","type":"edge.added","subject":"iss-pkp09k8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-pkp09k8","to":"gol-8v1wqvp"},"at":"2026-09-19T19:18:16.294Z","parents":["evt-88ym20js8h46"],"hash":"a4a41806e66b45bdb1b8a9cde6b8101845aaef49b818e9655402a30a0140a096"}
|
||||||
|
{"id":"evt-88tca49p5s8x","type":"edge.added","subject":"iss-pkp09k8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-pkp09k8","to":"package.json"},"at":"2026-09-19T19:18:16.295Z","parents":["evt-9nxje0x317x3"],"hash":"643b7991ca155b00322e12f46103e590190ce900dce487a7778b14626b75e3e7"}
|
||||||
|
{"id":"evt-n8w66k6rzm32","type":"edge.added","subject":"iss-pkp09k8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-pkp09k8","to":"package-lock.json"},"at":"2026-09-19T19:18:16.298Z","parents":["evt-88tca49p5s8x"],"hash":"49e82affa4a579892df11caf9a4ef819b84446a1a393fcbe022d58a8029cfec6"}
|
||||||
|
{"id":"evt-zzaefas56b3h","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-3g6m4rz","to":"iss-pkp09k8"},"at":"2026-09-19T19:18:16.299Z","parents":["evt-n8w66k6rzm32"],"hash":"a5ac6518875f36afa8c8e62aec41bda85127164fcbcab70157f7e6baa767d972"}
|
||||||
|
{"id":"evt-93p86as1f298","type":"edge.added","subject":"iss-pkp09k8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-pkp09k8","to":"area:upstream"},"at":"2026-09-19T19:18:16.300Z","parents":["evt-zzaefas56b3h"],"hash":"eb42d06e9518d67c55eb37e54a1647e6dd91d7b0cdcacd02bf16861f21cf8d20"}
|
||||||
|
{"id":"evt-kb2qn15pc02p","type":"node.updated","subject":"iss-pkp09k8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"attrs":{"revisit":null,"deferred_because":"Waiting on the user's upstream PR and an agsh/onvif 0.8.x release; nothing to do until then."}}},"at":"2026-09-19T19:18:21.727Z","parents":["evt-93p86as1f298"],"hash":"33ee7d6b0d1da40ceca8ddfeb290ffa3c09c5bf2829b51bd578ed3b651857bb1"}
|
||||||
|
{"id":"evt-7t2g4efw1f8j","type":"node.status_changed","subject":"iss-pkp09k8","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"deferred"},"at":"2026-09-19T19:18:21.730Z","parents":["evt-kb2qn15pc02p"],"hash":"d9153bc187cb56b54ed43d18d489fc77c0fc18004a358dd67b3634ca2a8856aa"}
|
||||||
|
{"id":"evt-zmqdfxf58qkx","type":"node.created","subject":"fnd-cw8aw95","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"On the I91ET, Brightness only affects the picture in Auto exposure; in Manual exposure it has no visible effect","body":"Reported by the user on 2026-09-19 after editing camera …af54 (192.168.31.2) through the new Edit → Save UI. The audit log shows every write was applied exactly as requested (exposure AUTO↔MANUAL several times, brightness and contrast 1–90, backlight compensation OFF), so the values aren't being inverted by the app. With exposure AUTO, changing brightness visibly changes the image; with exposure MANUAL it doesn't. Consistent with ONVIF exposure semantics: in MANUAL the camera uses fixed ExposureTime/Gain (Iris) rather than aiming at a brightness target. The edit form currently offers only the exposure *mode*, not exposure time or gain, so in Manual it offers no control that changes the image. Not yet verified: which exposure time and gain the camera holds in MANUAL, and the ranges it reports in Imaging GetOptions (needs a read-only check).","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"iss-j8zzg1r","note":"user's hands-on test of the Edit UI, 2026-09-19; .data/audit.jsonl entries 19:25–19:29Z for camera …af54"}],"as_of":"2026-09-19"}},"at":"2026-09-19T19:33:22.532Z","parents":["evt-7t2g4efw1f8j"],"hash":"4984eca7f3841252aefb7f9c90ee31c080e19dadbe5a9840a34d2cfe7fe8a116"}
|
||||||
|
{"id":"evt-krfypg1ybg9n","type":"edge.added","subject":"fnd-cw8aw95","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-cw8aw95","to":"gol-8v1wqvp"},"at":"2026-09-19T19:33:22.535Z","parents":["evt-zmqdfxf58qkx"],"hash":"1985e6cdfc7f22cdd06bdb5beea2d27865f13217816358eeafa98ebf26aa1fab"}
|
||||||
|
{"id":"evt-6hf42apcp1xn","type":"node.created","subject":"dec-6hbye54","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"decision","title":"Manual exposure gets exposure time and gain controls; Brightness is noted as mainly an Auto-mode setting","body":"User's choice, 2026-09-19, after fnd-cw8aw95. When Exposure is Manual, the Image edit form shows Exposure time and Gain, limited to the ranges the camera reports in Imaging GetOptions, and notes that Brightness mainly affects the picture in Auto exposure. Rejected: a hint only; hiding Manual. The user permitted a read-only check of camera …af54's exposure settings and options to learn the real ranges and units (recorded redacted per dec-kgm44qw).","status":"recorded","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T19:35:13.650Z","parents":["evt-krfypg1ybg9n"],"hash":"aec09b8ae4b5ea049d7cceaf23a0afcebfaa4b3953cf88a532a0d06ee8d381c7"}
|
||||||
|
{"id":"evt-2m4zs2werqer","type":"edge.added","subject":"dec-6hbye54","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"dec-6hbye54","to":"gol-8v1wqvp"},"at":"2026-09-19T19:35:13.653Z","parents":["evt-6hf42apcp1xn"],"hash":"17a2b97005592e0a882b96538eada62f2cb0bea681d8babef5f7e4a289fb2809"}
|
||||||
|
{"id":"evt-528ny7rbxmf5","type":"node.created","subject":"fnd-qe3heme","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Camera B (I91ET) reports manual exposure ranges of exposure time 10–1,000,000 µs and gain 0–100, but no current manual values while in Auto","body":"Read-only check, 2026-09-19, with the user's permission (dec-6hbye54), on the second I91ET (identifiers omitted per dec-kgm44qw). GetImagingSettings while in AUTO: exposure {mode AUTO, minExposureTime 10, maxExposureTime 33333, minGain 0, maxGain 100}, with no ExposureTime or Gain fields; brightness and contrast 50. Imaging GetOptions exposure: mode [MANUAL, AUTO]; minExposureTime 10–333333; maxExposureTime 33–1000000; minGain 0–0; maxGain 0–100; exposureTime 10–1000000; gain 0–100. ONVIF exposure time is in microseconds (33333 µs ≈ 1/30 s). Implication: switching to MANUAL without sending ExposureTime/Gain leaves the camera on internal manual values the app can't see; the Manual form must offer exposure time (µs) and gain within these ranges.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"node":"dec-6hbye54","note":"read-only GetImagingSettings + Imaging GetOptions via a scratch script using the app's credential store; nothing written"}],"as_of":"2026-09-19"}},"at":"2026-09-19T19:35:40.340Z","parents":["evt-2m4zs2werqer"],"hash":"95160f7cc1886c065cf730b8a6e3e8df324c6c4083c5e425c72b003d9c9b78b6"}
|
||||||
|
{"id":"evt-t2zp5280rn8w","type":"edge.added","subject":"fnd-qe3heme","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-qe3heme","to":"gol-8v1wqvp"},"at":"2026-09-19T19:35:40.342Z","parents":["evt-528ny7rbxmf5"],"hash":"fab4c6182eefcd737b30bb57227cef20c4835d437d115ab7b3329cf0e4906da3"}
|
||||||
|
{"id":"evt-c4ap15mmw52s","type":"node.created","subject":"iss-tgk8sgp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Manual exposure: exposure time and gain controls, plus a Brightness hint","body":"Per dec-6hbye54 and fnd-cw8aw95. Read exposureTime/gain (settings) and their ranges (options.exposure.exposureTime/gain). Patch fields exposureTime (µs) and gain, validated against the ranges and allowed only when the resulting exposure mode is MANUAL. In the Image form, when Exposure is Manual, show 'Exposure time (µs)' with an approximate shutter speed (e.g. ≈1/30 s) and 'Gain', and a note that Brightness mainly works in Auto. On the read-only page, show exposure time and gain when in Manual. Tests with a fake camera.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T19:35:43.614Z","parents":["evt-t2zp5280rn8w"],"hash":"51439638adc150cfbf507f3f4c06e5f52fcc030c4ab9e62d9ce9ff8c7f36f77b"}
|
||||||
|
{"id":"evt-qdygw3ytgt9s","type":"edge.added","subject":"iss-tgk8sgp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-tgk8sgp","to":"gol-8v1wqvp"},"at":"2026-09-19T19:35:43.615Z","parents":["evt-c4ap15mmw52s"],"hash":"f69baaa2cb46e7f9abf7b8df69aaaa79f4e0eeb29164580ff8a902f237d1bb81"}
|
||||||
|
{"id":"evt-wk1wg83thy5f","type":"edge.added","subject":"iss-tgk8sgp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-tgk8sgp","to":"src/lib/camera-config.ts"},"at":"2026-09-19T19:35:43.616Z","parents":["evt-qdygw3ytgt9s"],"hash":"dec41409c6bb98659ec85c778569b2e32ea30915838a91d3fc21a6052accceff"}
|
||||||
|
{"id":"evt-a3tazav34bqf","type":"edge.added","subject":"iss-tgk8sgp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-tgk8sgp","to":"src/lib/camera-settings.ts"},"at":"2026-09-19T19:35:43.617Z","parents":["evt-wk1wg83thy5f"],"hash":"58ffb5202a9134f3660e9781fd4f00d5efcb2523c9de48ca55ebb64a59cf8c39"}
|
||||||
|
{"id":"evt-pk564v4t2wct","type":"edge.added","subject":"iss-tgk8sgp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-tgk8sgp","to":"src/app/cameras/[id]/"},"at":"2026-09-19T19:35:43.618Z","parents":["evt-a3tazav34bqf"],"hash":"f8e29269b199d1110ce142eb6b5d503c4e60eb8e576b9d0d9b67cc6eb7538fd2"}
|
||||||
|
{"id":"evt-0xreayvmgbbx","type":"edge.added","subject":"iss-3g6m4rz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"parent_of","from":"iss-3g6m4rz","to":"iss-tgk8sgp"},"at":"2026-09-19T19:35:43.619Z","parents":["evt-pk564v4t2wct"],"hash":"218259baf86a1197b50cc129a4a3d2452327e6ce0fcb45a6b1c3efda168f22c2"}
|
||||||
|
{"id":"evt-avy8vfpkpcq8","type":"edge.added","subject":"iss-tgk8sgp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-tgk8sgp","to":"area:config"},"at":"2026-09-19T19:35:43.620Z","parents":["evt-0xreayvmgbbx"],"hash":"6db27db0e3a90f71849a8ec76eb92ca26490fe8eeefb137bfd4f83d44f73cad8"}
|
||||||
|
{"id":"evt-9s26vvdzc2x4","type":"edge.added","subject":"iss-tgk8sgp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-tgk8sgp","to":"area:ui"},"at":"2026-09-19T19:35:43.621Z","parents":["evt-avy8vfpkpcq8"],"hash":"f440827328599a428fcaeb432d78546fc6a22fc5b0e7d9ba5ec5a71fc7204c4b"}
|
||||||
|
{"id":"evt-r6rfyn75st1t","type":"node.created","subject":"ver-558kxkj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Exposure time and gain are read, validated against the camera's ranges, refused outside Manual, and edited only in Manual (with the Brightness hint); the settings page shows them in Manual; suite, lint, type-check and build are clean","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T19:39:16.724Z","parents":["evt-9s26vvdzc2x4"],"hash":"309b8f8c9aebb28c9bddcd6948c1df72e5133702d68349ea3fc7506a4767545e"}
|
||||||
|
{"id":"evt-rhjtpn32v25k","type":"edge.added","subject":"ver-558kxkj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-558kxkj","to":"iss-tgk8sgp"},"at":"2026-09-19T19:39:16.725Z","parents":["evt-r6rfyn75st1t"],"hash":"bff0ecb0dddc4a83591727605317d1546cf8226728c4bf72bd8e015bcb73346f"}
|
||||||
|
{"id":"evt-7x4acxzsv7z5","type":"verification.recorded","subject":"ver-558kxkj","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"npx vitest run --coverage → 43 files, 621 tests pass (99.83% lines); npx tsc --noEmit and npx eslint src test clean; npx next build compiles. Tests: camera-config.test.ts \"manual exposure (fnd-qe3heme)\", camera-settings.test.ts, format.test.ts shutterLabel, edit-forms.test.tsx \"manual exposure\", settings-sections.test.tsx \"manual exposure\""},"at":"2026-09-19T19:39:16.726Z","parents":["evt-rhjtpn32v25k"],"hash":"53eff9f5ff1c2df4345ac75d5ad35c6712940b1f53928f7612abbcfd20ae677f"}
|
||||||
|
{"id":"evt-0vvcqqxzqrfn","type":"node.created","subject":"ver-hvwbxtk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"On a real camera, switching to Manual and changing exposure time or gain visibly changes image brightness, and the camera applies the values (audit log shows applied or adjusted)","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T19:39:18.254Z","parents":["evt-7x4acxzsv7z5"],"hash":"59ceccf2e4f3e3d7f4ea5f43da7615c739d0ef08beff64bcc7c1f07435e69bbc"}
|
||||||
|
{"id":"evt-s8nde3943p84","type":"edge.added","subject":"ver-hvwbxtk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-hvwbxtk","to":"iss-tgk8sgp"},"at":"2026-09-19T19:39:18.256Z","parents":["evt-0vvcqqxzqrfn"],"hash":"1dee7ac0b11da43a088635c03e6c194a44f3527e633415c6321694c8f4d80bed"}
|
||||||
|
{"id":"evt-a68ygks3mdx4","type":"verification.recorded","subject":"ver-hvwbxtk","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User check in the browser after restarting next start; confirm with .data/audit.jsonl entries for exposureTime/gain"},"at":"2026-09-19T19:39:18.257Z","parents":["evt-s8nde3943p84"],"hash":"f17d70f87c7e602476628ac662da52f91656522530b562790587be99282b3a0b"}
|
||||||
|
{"id":"evt-rzaq1r37f1ms","type":"node.created","subject":"ver-rc4ddd9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"On a real camera, switching to Manual and changing exposure time or gain visibly changes image brightness, and the camera applies the values","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T19:42:39.732Z","parents":["evt-a68ygks3mdx4"],"hash":"59cd5a99bfb38ca054d7383f03daa95a132d6c47df4be5494ce3ea72c8443bb1"}
|
||||||
|
{"id":"evt-4wpwv4znk8dk","type":"edge.added","subject":"ver-rc4ddd9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-rc4ddd9","to":"iss-tgk8sgp"},"at":"2026-09-19T19:42:39.733Z","parents":["evt-rzaq1r37f1ms"],"hash":"57bd2476c8d3e43d1b3aa3044bba681fc59fca90c6b2f8e7c74755e4b45678ce"}
|
||||||
|
{"id":"evt-jyvqv0nkqwjm","type":"verification.recorded","subject":"ver-rc4ddd9","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"User confirmed in the browser on 2026-09-19 (\"works great\"); writes recorded in .data/audit.jsonl"},"at":"2026-09-19T19:42:39.734Z","parents":["evt-4wpwv4znk8dk"],"hash":"634f269dd74fbe34fa6171e2ea5eb66498187114d203d6ea6b37c8c321926ec6"}
|
||||||
|
{"id":"evt-stk5b41r6c4q","type":"node.status_changed","subject":"iss-tgk8sgp","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"done"},"at":"2026-09-19T19:42:41.194Z","parents":["evt-jyvqv0nkqwjm"],"hash":"38edee0c3d01922a86f1169052b15d0932f80dbe34894838c539a935c1c8df8a"}
|
||||||
|
{"id":"evt-crahg3cgq3se","type":"node.created","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Note which image settings mostly affect Auto exposure while Exposure is Manual","body":"In the Image edit form, while Exposure is Manual, show \"Mostly affects Auto exposure\" under Brightness and Backlight compensation. The fields stay visible and editable: the camera keeps their values and uses them again in Auto, and hiding fields would make the form jump around. First test on a real camera in Manual: Brightness 0 vs 100, and BLC off vs on. If one truly changes nothing, the note says \"No effect in Manual\" instead. Contrast, saturation, sharpness, white balance and day/night still apply in Manual and get no note.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T19:43:50.854Z","parents":["evt-stk5b41r6c4q"],"hash":"921f82f1eebb2060f2f8d585ee9d1bb28f2b4c874678d88c51fc1713e52ee6ec"}
|
||||||
|
{"id":"evt-rgxyz1vn75ky","type":"edge.added","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-p7vdbng","to":"gol-8v1wqvp"},"at":"2026-09-19T19:43:50.856Z","parents":["evt-crahg3cgq3se"],"hash":"82b0e99152984b3f31c25e98ff01e25c8bea844775e557216d93ccbb65050fd1"}
|
||||||
|
{"id":"evt-4bnfbpmc0ey6","type":"edge.added","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-p7vdbng","to":"src/app/cameras/[id]/edit-forms.tsx"},"at":"2026-09-19T19:43:50.857Z","parents":["evt-rgxyz1vn75ky"],"hash":"7cb30d41bde30c172326c0ed2c9a78504227cdd4290fe14ef1251d83f712c28d"}
|
||||||
|
{"id":"evt-jgwjmsj36n2n","type":"edge.added","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-p7vdbng","to":"area:ui"},"at":"2026-09-19T19:43:50.858Z","parents":["evt-4bnfbpmc0ey6"],"hash":"25f02f7dc52f4bda1a43a5cdc322999d28fd2ebae314255039894301476c593e"}
|
||||||
|
{"id":"evt-a2q4cc2fn7wc","type":"edge.added","subject":"iss-p7vdbng","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"discovered_from","from":"iss-p7vdbng","to":"iss-tgk8sgp"},"at":"2026-09-19T19:43:50.859Z","parents":["evt-jgwjmsj36n2n"],"hash":"81b034aef5c9e15d27ccf4958a8c65e9c601ba248a3456a397fe983e8776a263"}
|
||||||
|
|||||||
@ -1,8 +1,34 @@
|
|||||||
|
import { realpathSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* While onvif is linked to a local checkout outside this project (e.g. the fork, installed
|
||||||
|
* with `npm install ../onvif --no-save`), Turbopack only resolves files inside its root, so
|
||||||
|
* the root must contain both (Next docs, 03-api-reference/08-turbopack.md "Root directory").
|
||||||
|
* Returns undefined for a normal install, leaving the auto-detected root alone.
|
||||||
|
*/
|
||||||
|
function linkedPackagesRoot(project: string, packages: string[]): string | undefined {
|
||||||
|
let root = project;
|
||||||
|
for (const name of packages) {
|
||||||
|
let target: string;
|
||||||
|
try {
|
||||||
|
target = realpathSync(path.join(project, "node_modules", name));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
while (path.relative(root, target).startsWith("..")) root = path.dirname(root);
|
||||||
|
}
|
||||||
|
return root === project ? undefined : root;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = process.cwd();
|
||||||
|
const turbopackRoot = linkedPackagesRoot(project, ["onvif"]);
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// onvif uses Node's dgram/os modules; load it with native require instead of bundling.
|
// onvif uses Node's dgram/os modules; load it with native require instead of bundling.
|
||||||
serverExternalPackages: ["onvif"],
|
serverExternalPackages: ["onvif"],
|
||||||
|
...(turbopackRoot && { turbopack: { root: turbopackRoot } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -11,7 +11,7 @@
|
|||||||
"@tanstack/react-query": "^5.103.1",
|
"@tanstack/react-query": "^5.103.1",
|
||||||
"lucide-react": "^1.47.0",
|
"lucide-react": "^1.47.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
"onvif": "^0.8.3",
|
"onvif": "github:mikemainguy/onvif#e8c340fdf97cf4a42da23ce810b4ecdd8c93dd4b",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
@ -6594,8 +6594,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/onvif": {
|
"node_modules/onvif": {
|
||||||
"version": "0.8.3",
|
"version": "0.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/onvif/-/onvif-0.8.3.tgz",
|
"resolved": "git+ssh://git@github.com/mikemainguy/onvif.git#e8c340fdf97cf4a42da23ce810b4ecdd8c93dd4b",
|
||||||
"integrity": "sha512-55gkQKRHVehU4tZWR/dy6ALLU9a6A3YfmX6da/joLFfNtH3YSLaw2yuioGLC8H5ZjHqY8I9UDSboPiQqEFL+tw==",
|
"integrity": "sha512-YmbFNWe6IFiO5RknLSlPOwBoZrXdWtOIhwPz4TnRtmIxjiVrr3yV8d0sYdZ+nPUjS4k8dSu7MAelZ3pBF0d44w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"xml2js": "^0.6.2"
|
"xml2js": "^0.6.2"
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
"@tanstack/react-query": "^5.103.1",
|
"@tanstack/react-query": "^5.103.1",
|
||||||
"lucide-react": "^1.47.0",
|
"lucide-react": "^1.47.0",
|
||||||
"next": "16.3.5",
|
"next": "16.3.5",
|
||||||
"onvif": "^0.8.3",
|
"onvif": "github:mikemainguy/onvif#e8c340fdf97cf4a42da23ce810b4ecdd8c93dd4b",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
|
|||||||
121
src/app/cameras/[id]/actions.test.ts
Normal file
121
src/app/cameras/[id]/actions.test.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const m = vi.hoisted(() => ({
|
||||||
|
access: vi.fn(),
|
||||||
|
verifySession: vi.fn(),
|
||||||
|
getCameraRecord: vi.fn(),
|
||||||
|
updateImageSettings: vi.fn(),
|
||||||
|
updateStreamSettings: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/access", () => ({ access: m.access }));
|
||||||
|
vi.mock("@/lib/session", () => ({ verifySession: m.verifySession }));
|
||||||
|
vi.mock("@/lib/camera-registry", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/lib/camera-registry")>()),
|
||||||
|
getCameraRecord: m.getCameraRecord,
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/camera-config", () => ({
|
||||||
|
updateImageSettings: m.updateImageSettings,
|
||||||
|
updateStreamSettings: m.updateStreamSettings,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { saveImageSettings, saveStreamSettings } = await import("./actions");
|
||||||
|
const { SAVE_MESSAGES } = await import("./save-result");
|
||||||
|
const { CameraAuthError, CameraInactiveError } = await import("@/lib/camera");
|
||||||
|
|
||||||
|
const ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
const record = { id: ID, urn: `urn:uuid:${ID}`, host: "192.168.1.10", port: 80, lastSeen: "" };
|
||||||
|
const target = { id: ID, host: "192.168.1.10", port: 80 };
|
||||||
|
const applied = { ok: true, outcome: "applied", fields: [] };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(m)) fn.mockReset();
|
||||||
|
m.access.mockResolvedValue("signed-in");
|
||||||
|
m.verifySession.mockResolvedValue({ username: "admin", expiresAt: 0 });
|
||||||
|
m.getCameraRecord.mockResolvedValue(record);
|
||||||
|
m.updateImageSettings.mockResolvedValue(applied);
|
||||||
|
m.updateStreamSettings.mockResolvedValue(applied);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveImageSettings", () => {
|
||||||
|
it("resolves the camera from the registry and saves as the signed-in admin", async () => {
|
||||||
|
expect(await saveImageSettings(ID, { brightness: 10 })).toBe(applied);
|
||||||
|
expect(m.updateImageSettings).toHaveBeenCalledWith(target, { brightness: 10 }, "admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records the unsecured state as the actor when no admin exists", async () => {
|
||||||
|
m.access.mockResolvedValue("unsecured");
|
||||||
|
m.verifySession.mockResolvedValue(null);
|
||||||
|
await saveImageSettings(ID, { brightness: 10 });
|
||||||
|
expect(m.updateImageSettings).toHaveBeenCalledWith(target, { brightness: 10 }, "(no admin: unsecured)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when signed out, without touching the camera", async () => {
|
||||||
|
m.access.mockResolvedValue("signed-out");
|
||||||
|
expect(await saveImageSettings(ID, { brightness: 10 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "signed-out",
|
||||||
|
message: SAVE_MESSAGES.signedOut,
|
||||||
|
});
|
||||||
|
expect(m.getCameraRecord).not.toHaveBeenCalled();
|
||||||
|
expect(m.updateImageSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a malformed id", "../etc", record],
|
||||||
|
["an unknown camera", ID, null],
|
||||||
|
["a non-private address", ID, { ...record, host: "8.8.8.8" }],
|
||||||
|
])("rejects %s", async (_l, id, rec) => {
|
||||||
|
m.getCameraRecord.mockResolvedValue(rec);
|
||||||
|
expect(await saveImageSettings(id, { brightness: 10 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "invalid",
|
||||||
|
message: SAVE_MESSAGES.unknownCamera,
|
||||||
|
issues: [],
|
||||||
|
});
|
||||||
|
expect(m.updateImageSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["a missing login", new CameraAuthError("x", true), SAVE_MESSAGES.missingLogin],
|
||||||
|
["a rejected login", new CameraAuthError("http://admin:hunter2@cam"), SAVE_MESSAGES.rejectedLogin],
|
||||||
|
["an inactive camera", new CameraInactiveError("x"), SAVE_MESSAGES.inactive],
|
||||||
|
["anything else", new Error("socket hang up http://admin:hunter2@cam"), SAVE_MESSAGES.unreachable],
|
||||||
|
])("explains %s with fixed text", async (_l, err, message) => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
m.updateImageSettings.mockRejectedValue(err);
|
||||||
|
const result = await saveImageSettings(ID, { brightness: 10 });
|
||||||
|
expect(result).toEqual({ ok: false, reason: "camera", message });
|
||||||
|
expect(JSON.stringify(result)).not.toContain("hunter2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("saveStreamSettings", () => {
|
||||||
|
it("saves the named stream", async () => {
|
||||||
|
expect(await saveStreamSettings(ID, "Profile_1", { fps: 10 })).toBe(applied);
|
||||||
|
expect(m.updateStreamSettings).toHaveBeenCalledWith(target, "Profile_1", { fps: 10 }, "admin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[""], ["x".repeat(65)]])("rejects an invalid stream token %j", async (token) => {
|
||||||
|
expect(await saveStreamSettings(ID, token, { fps: 10 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "invalid",
|
||||||
|
message: SAVE_MESSAGES.unknownStream,
|
||||||
|
issues: [],
|
||||||
|
});
|
||||||
|
expect(m.updateStreamSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses when signed out", async () => {
|
||||||
|
m.access.mockResolvedValue("signed-out");
|
||||||
|
expect(await saveStreamSettings(ID, "Profile_1", { fps: 10 })).toMatchObject({ reason: "signed-out" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explains camera errors with fixed text", async () => {
|
||||||
|
m.updateStreamSettings.mockRejectedValue(new CameraInactiveError("x"));
|
||||||
|
expect(await saveStreamSettings(ID, "Profile_1", { fps: 10 })).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "camera",
|
||||||
|
message: SAVE_MESSAGES.inactive,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
76
src/app/cameras/[id]/actions.ts
Normal file
76
src/app/cameras/[id]/actions.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { z } from "zod";
|
||||||
|
import { access } from "@/lib/access";
|
||||||
|
import { CameraAuthError, CameraInactiveError, isAllowedHost, type CameraTarget } from "@/lib/camera";
|
||||||
|
import { updateImageSettings, updateStreamSettings } from "@/lib/camera-config";
|
||||||
|
import { cameraIdSchema, getCameraRecord } from "@/lib/camera-registry";
|
||||||
|
import { verifySession } from "@/lib/session";
|
||||||
|
import { SAVE_MESSAGES, type SaveResult } from "./save-result";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves from the camera page's Edit forms (vrek iss-j8zzg1r). Server Actions are public
|
||||||
|
* POST endpoints, so each checks access and validates its own input (pri-m1csgrm); the
|
||||||
|
* camera address always comes from the registry, never the client. The writers validate
|
||||||
|
* the settings themselves against the camera's options.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const streamTokenSchema = z.string().min(1).max(64);
|
||||||
|
|
||||||
|
async function resolve(cameraId: unknown): Promise<CameraTarget | SaveResult> {
|
||||||
|
if ((await access()) === "signed-out") {
|
||||||
|
return { ok: false, reason: "signed-out", message: SAVE_MESSAGES.signedOut };
|
||||||
|
}
|
||||||
|
const id = cameraIdSchema.safeParse(cameraId);
|
||||||
|
const record = id.success ? await getCameraRecord(id.data) : null;
|
||||||
|
if (!record || !isAllowedHost(record.host)) {
|
||||||
|
return { ok: false, reason: "invalid", message: SAVE_MESSAGES.unknownCamera, issues: [] };
|
||||||
|
}
|
||||||
|
return { id: record.id, host: record.host, port: record.port };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who to record in the audit log: the signed-in admin, or that the app is unsecured. */
|
||||||
|
async function actor(): Promise<string> {
|
||||||
|
return (await verifySession())?.username ?? "(no admin: unsecured)";
|
||||||
|
}
|
||||||
|
|
||||||
|
function cameraProblem(err: unknown): SaveResult {
|
||||||
|
if (err instanceof CameraAuthError) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: "camera",
|
||||||
|
message: err.missingLogin ? SAVE_MESSAGES.missingLogin : SAVE_MESSAGES.rejectedLogin,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (err instanceof CameraInactiveError) return { ok: false, reason: "camera", message: SAVE_MESSAGES.inactive };
|
||||||
|
console.error("Camera settings save failed:", err);
|
||||||
|
return { ok: false, reason: "camera", message: SAVE_MESSAGES.unreachable };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveImageSettings(cameraId: string, patch: unknown): Promise<SaveResult> {
|
||||||
|
const target = await resolve(cameraId);
|
||||||
|
if (!("host" in target)) return target;
|
||||||
|
try {
|
||||||
|
return await updateImageSettings(target, patch, await actor());
|
||||||
|
} catch (err) {
|
||||||
|
return cameraProblem(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveStreamSettings(
|
||||||
|
cameraId: string,
|
||||||
|
streamToken: string,
|
||||||
|
patch: unknown,
|
||||||
|
): Promise<SaveResult> {
|
||||||
|
const target = await resolve(cameraId);
|
||||||
|
if (!("host" in target)) return target;
|
||||||
|
const token = streamTokenSchema.safeParse(streamToken);
|
||||||
|
if (!token.success) {
|
||||||
|
return { ok: false, reason: "invalid", message: SAVE_MESSAGES.unknownStream, issues: [] };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await updateStreamSettings(target, token.data, patch, await actor());
|
||||||
|
} catch (err) {
|
||||||
|
return cameraProblem(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
381
src/app/cameras/[id]/edit-forms.test.tsx
Normal file
381
src/app/cameras/[id]/edit-forms.test.tsx
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ImageOptions, StreamOptions } from "@/lib/camera-config";
|
||||||
|
import type { ImageSettings, StreamSettings } from "@/lib/camera-settings";
|
||||||
|
|
||||||
|
const m = vi.hoisted(() => ({ saveImageSettings: vi.fn(), saveStreamSettings: vi.fn(), refresh: vi.fn() }));
|
||||||
|
vi.mock("./actions", () => ({ saveImageSettings: m.saveImageSettings, saveStreamSettings: m.saveStreamSettings }));
|
||||||
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: m.refresh }) }));
|
||||||
|
|
||||||
|
const { ImageEditor, StreamEditor, changedFields, fieldLabel } = await import("./edit-forms");
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const fn of Object.values(m)) fn.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
const CAMERA = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
|
const image: ImageSettings = {
|
||||||
|
dayNight: "AUTO",
|
||||||
|
exposure: "AUTO",
|
||||||
|
whiteBalance: "AUTO",
|
||||||
|
wideDynamicRange: "OFF",
|
||||||
|
backlightCompensation: "OFF",
|
||||||
|
brightness: 50,
|
||||||
|
contrast: 50,
|
||||||
|
saturation: 50,
|
||||||
|
sharpness: 50,
|
||||||
|
noiseReduction: 0.5,
|
||||||
|
};
|
||||||
|
const imageOptions: ImageOptions = {
|
||||||
|
dayNight: ["ON", "OFF", "AUTO"],
|
||||||
|
exposure: ["AUTO", "MANUAL"],
|
||||||
|
whiteBalance: ["AUTO", "MANUAL"],
|
||||||
|
exposureTime: { min: 10, max: 1000000 },
|
||||||
|
gain: { min: 0, max: 100 },
|
||||||
|
brightness: { min: 0, max: 100 },
|
||||||
|
contrast: { min: 0, max: 100 },
|
||||||
|
saturation: { min: 0, max: 100 },
|
||||||
|
sharpness: { min: 0, max: 100 },
|
||||||
|
wideDynamicRange: { modes: ["OFF", "ON"], level: { min: 0, max: 100 } },
|
||||||
|
backlightCompensation: { modes: ["OFF", "ON"], level: { min: 0, max: 100 } },
|
||||||
|
};
|
||||||
|
const stream: StreamSettings = {
|
||||||
|
token: "Profile_1",
|
||||||
|
name: "mainStream",
|
||||||
|
codec: "H264",
|
||||||
|
profile: "Main",
|
||||||
|
width: 4096,
|
||||||
|
height: 1860,
|
||||||
|
fps: 20,
|
||||||
|
bitrateKbps: 10240,
|
||||||
|
constantBitRate: false,
|
||||||
|
gop: 50,
|
||||||
|
quality: 3,
|
||||||
|
rtspUrl: null,
|
||||||
|
};
|
||||||
|
const streamOptions: StreamOptions = {
|
||||||
|
token: "Profile_1",
|
||||||
|
resolutions: [
|
||||||
|
{ width: 4096, height: 1860 },
|
||||||
|
{ width: 2560, height: 1160 },
|
||||||
|
],
|
||||||
|
fps: { min: 1, max: 25 },
|
||||||
|
gop: { min: 1, max: 400 },
|
||||||
|
quality: { min: 1, max: 6 },
|
||||||
|
bitrateKbps: { min: 32, max: 16384 },
|
||||||
|
h264Profiles: ["Main", "High"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const applied = (fields: { field: string; requested: unknown; actual: unknown; applied: boolean }[] = []) => ({
|
||||||
|
ok: true,
|
||||||
|
outcome: "applied",
|
||||||
|
fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderImage(options: ImageOptions | null = imageOptions) {
|
||||||
|
return render(
|
||||||
|
<ImageEditor cameraId={CAMERA} current={image} options={options}>
|
||||||
|
<p>read-only view</p>
|
||||||
|
</ImageEditor>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStream(options: StreamOptions | undefined = streamOptions) {
|
||||||
|
return render(
|
||||||
|
<StreamEditor cameraId={CAMERA} stream={stream} options={options}>
|
||||||
|
<p>stream view</p>
|
||||||
|
</StreamEditor>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const edit = () => fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
const change = (label: RegExp | string, value: string) =>
|
||||||
|
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||||
|
|
||||||
|
describe("ImageEditor", () => {
|
||||||
|
it("shows the read-only view with Edit, and no Edit without options", () => {
|
||||||
|
renderImage();
|
||||||
|
expect(screen.getByText("read-only view")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Edit" })).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
renderImage(null);
|
||||||
|
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers only the camera's supported values, with ranges in the labels", () => {
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
const dayNight = screen.getByLabelText("Day/night") as HTMLSelectElement;
|
||||||
|
expect([...dayNight.options].map((o) => o.text)).toEqual(["Day (IR-cut on)", "Night (IR-cut off)", "Auto"]);
|
||||||
|
expect(dayNight.value).toBe("AUTO");
|
||||||
|
const brightness = screen.getByLabelText("Brightness (0–100)") as HTMLInputElement;
|
||||||
|
expect([brightness.value, brightness.min, brightness.max]).toEqual(["50", "0", "100"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets the browser block values outside the camera's range before sending", () => {
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
change("Brightness (0–100)", "150");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(m.saveImageSettings).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends only what changed, then shows success and refreshes the page", async () => {
|
||||||
|
m.saveImageSettings.mockResolvedValue(applied([{ field: "brightness", requested: 0, actual: 0, applied: true }]));
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
change("Brightness (0–100)", "0");
|
||||||
|
change("Day/night", "ON");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
expect((await screen.findByRole("status")).textContent).toContain("Saved. The camera applied every change.");
|
||||||
|
expect(m.saveImageSettings).toHaveBeenCalledWith(CAMERA, { brightness: 0, dayNight: "ON" });
|
||||||
|
expect(m.refresh).toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("read-only view")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes without saving when nothing changed", () => {
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(m.saveImageSettings).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("read-only view")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("discards edits on Cancel and starts fresh next time", () => {
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
change("Brightness (0–100)", "90");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(screen.getByText("read-only view")).toBeTruthy();
|
||||||
|
edit();
|
||||||
|
expect((screen.getByLabelText("Brightness (0–100)") as HTMLInputElement).value).toBe("50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says which values the camera adjusted", async () => {
|
||||||
|
m.saveImageSettings.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
outcome: "adjusted",
|
||||||
|
fields: [{ field: "brightness", requested: 95, actual: 90, applied: false }],
|
||||||
|
});
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
change("Brightness (0–100)", "95");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect((await screen.findByRole("status")).textContent).toContain(
|
||||||
|
"Saved, but the camera adjusted: Brightness 90 (asked for 95).",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the form open and lists problems when a value is rejected", async () => {
|
||||||
|
m.saveImageSettings.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
reason: "invalid",
|
||||||
|
message: "Some values aren't supported by this camera",
|
||||||
|
issues: ["Brightness must be between 0 and 100"],
|
||||||
|
});
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
// In range for the form; the camera-side check still rejects it here.
|
||||||
|
change("Brightness (0–100)", "99");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
const alert = await screen.findByRole("alert");
|
||||||
|
expect(alert.textContent).toContain("Brightness must be between 0 and 100");
|
||||||
|
expect(screen.getByRole("form", { name: "Edit image settings" })).toBeTruthy();
|
||||||
|
expect(m.refresh).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns read-only, with guidance, when the login may not change settings", async () => {
|
||||||
|
m.saveImageSettings.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
reason: "not-permitted",
|
||||||
|
message: "The camera's saved login isn't allowed to change these settings.",
|
||||||
|
});
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
change("Contrast (0–100)", "60");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/^Read-only:/)).toBeTruthy());
|
||||||
|
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a session problem without closing the form", async () => {
|
||||||
|
m.saveImageSettings.mockResolvedValue({ ok: false, reason: "signed-out", message: "Your session has ended." });
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
change("Sharpness (0–100)", "70");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toContain("Your session has ended.");
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("manual exposure", () => {
|
||||||
|
it("shows exposure time, gain and the Brightness note only in Manual", () => {
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
expect(screen.queryByLabelText(/Exposure time/)).toBeNull();
|
||||||
|
expect(screen.queryByText(/Brightness has little/)).toBeNull();
|
||||||
|
|
||||||
|
change("Exposure", "MANUAL");
|
||||||
|
expect(screen.getByLabelText("Exposure time (µs) (10–1000000)")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("Gain (0–100)")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Brightness has little effect/)).toBeTruthy();
|
||||||
|
expect(screen.getByText("Blank keeps the camera's current manual value")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the approximate shutter speed and sends time and gain with the mode", async () => {
|
||||||
|
m.saveImageSettings.mockResolvedValue(applied());
|
||||||
|
renderImage();
|
||||||
|
edit();
|
||||||
|
change("Exposure", "MANUAL");
|
||||||
|
change("Exposure time (µs) (10–1000000)", "33333");
|
||||||
|
expect(screen.getByText("33,333 µs (≈1/30 s)")).toBeTruthy();
|
||||||
|
change("Gain (0–100)", "20");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await screen.findByRole("status");
|
||||||
|
expect(m.saveImageSettings).toHaveBeenCalledWith(CAMERA, {
|
||||||
|
exposure: "MANUAL",
|
||||||
|
exposureTime: 33333,
|
||||||
|
gain: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefills the manual values the camera reports", () => {
|
||||||
|
render(
|
||||||
|
<ImageEditor cameraId={CAMERA} current={{ ...image, exposure: "MANUAL", exposureTime: 20000, gain: 5 }} options={imageOptions}>
|
||||||
|
<p>view</p>
|
||||||
|
</ImageEditor>,
|
||||||
|
);
|
||||||
|
edit();
|
||||||
|
expect((screen.getByLabelText(/Exposure time/) as HTMLInputElement).value).toBe("20000");
|
||||||
|
expect((screen.getByLabelText(/^Gain/) as HTMLInputElement).value).toBe("5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the controls the camera supports", () => {
|
||||||
|
renderImage({ dayNight: ["AUTO"], exposure: [], whiteBalance: [] });
|
||||||
|
edit();
|
||||||
|
expect(screen.getByLabelText("Day/night")).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText(/Brightness/)).toBeNull();
|
||||||
|
expect(screen.queryByLabelText("Exposure")).toBeNull();
|
||||||
|
expect(screen.queryByLabelText(/WDR level/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StreamEditor", () => {
|
||||||
|
it("asks to confirm the stream restart before saving, and sends only the changes", async () => {
|
||||||
|
m.saveStreamSettings.mockResolvedValue(applied());
|
||||||
|
renderStream();
|
||||||
|
edit();
|
||||||
|
change("Resolution", "2560x1160");
|
||||||
|
change("Bitrate mode", "true");
|
||||||
|
change("Frame rate (1–25)", "15");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
|
||||||
|
const confirm = screen.getByRole("alertdialog", { name: "Confirm stream restart" });
|
||||||
|
expect(confirm.textContent).toContain("This restarts the mainStream stream; recorders and viewers will reconnect.");
|
||||||
|
expect(m.saveStreamSettings).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(within(confirm).getByRole("button", { name: "Restart stream and save" }));
|
||||||
|
await screen.findByRole("status");
|
||||||
|
expect(m.saveStreamSettings).toHaveBeenCalledWith(CAMERA, "Profile_1", {
|
||||||
|
width: 2560,
|
||||||
|
height: 1160,
|
||||||
|
constantBitRate: true,
|
||||||
|
fps: 15,
|
||||||
|
});
|
||||||
|
expect(m.refresh).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("backs out of the confirmation without saving", () => {
|
||||||
|
renderStream();
|
||||||
|
edit();
|
||||||
|
change("Quality (1–6)", "4");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
fireEvent.click(within(screen.getByRole("alertdialog")).getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
|
expect(m.saveStreamSettings).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("form", { name: "Edit mainStream" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes without the restart warning when nothing changed", () => {
|
||||||
|
renderStream();
|
||||||
|
edit();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
|
expect(screen.getByText("stream view")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers the camera's resolutions and H.264 profiles", () => {
|
||||||
|
renderStream();
|
||||||
|
edit();
|
||||||
|
const res = screen.getByLabelText("Resolution") as HTMLSelectElement;
|
||||||
|
expect([...res.options].map((o) => o.text)).toEqual(["4096×1860", "2560×1160"]);
|
||||||
|
const profile = screen.getByLabelText("H.264 profile") as HTMLSelectElement;
|
||||||
|
expect([...profile.options].map((o) => o.value)).toEqual(["Main", "High"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a spinner while saving", async () => {
|
||||||
|
let finish!: (r: unknown) => void;
|
||||||
|
m.saveStreamSettings.mockReturnValue(new Promise((r) => (finish = r)));
|
||||||
|
renderStream();
|
||||||
|
edit();
|
||||||
|
change("Keyframe interval (1–400)", "25");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Restart stream and save" }));
|
||||||
|
expect(await screen.findByRole("button", { name: "Saving…" })).toBeTruthy();
|
||||||
|
finish(applied());
|
||||||
|
await screen.findByRole("status");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no Edit without options, and turns read-only when not permitted", async () => {
|
||||||
|
render(
|
||||||
|
<StreamEditor cameraId={CAMERA} stream={stream} options={undefined}>
|
||||||
|
<p>stream view</p>
|
||||||
|
</StreamEditor>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
|
||||||
|
cleanup();
|
||||||
|
m.saveStreamSettings.mockResolvedValue({ ok: false, reason: "not-permitted", message: "Not allowed." });
|
||||||
|
renderStream();
|
||||||
|
edit();
|
||||||
|
change("Bitrate (kbps) (32–16384)", "4096");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Restart stream and save" }));
|
||||||
|
await waitFor(() => expect(screen.getByText(/^Read-only:/)).toBeTruthy());
|
||||||
|
expect(screen.queryByRole("button", { name: "Edit" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides controls the camera doesn't report", () => {
|
||||||
|
renderStream({ token: "Profile_1", resolutions: [], h264Profiles: [] });
|
||||||
|
edit();
|
||||||
|
expect(screen.queryByLabelText("Resolution")).toBeNull();
|
||||||
|
expect(screen.queryByLabelText(/Frame rate/)).toBeNull();
|
||||||
|
expect(screen.getByLabelText("Bitrate (kbps)")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("helpers", () => {
|
||||||
|
it("changedFields converts types and skips unchanged or empty values", () => {
|
||||||
|
expect(
|
||||||
|
changedFields(
|
||||||
|
{ resolution: "2560x1160", fps: "20", gop: "", constantBitRate: "false", h264Profile: "High" },
|
||||||
|
{ resolution: "4096x1860", fps: "20", gop: "50", constantBitRate: "true", h264Profile: "Main" },
|
||||||
|
new Set(["fps", "gop"]),
|
||||||
|
),
|
||||||
|
).toEqual({ width: 2560, height: 1160, constantBitRate: false, h264Profile: "High" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fieldLabel names known fields and passes others through", () => {
|
||||||
|
expect(fieldLabel("bitrateKbps")).toBe("Bitrate");
|
||||||
|
expect(fieldLabel("mystery")).toBe("mystery");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every icon rendered is decorative", () => {
|
||||||
|
renderStream();
|
||||||
|
edit();
|
||||||
|
for (const svg of document.querySelectorAll("svg.lucide")) expect(svg.getAttribute("aria-hidden")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
511
src/app/cameras/[id]/edit-forms.tsx
Normal file
511
src/app/cameras/[id]/edit-forms.tsx
Normal file
@ -0,0 +1,511 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CircleAlert, CircleCheck, LoaderCircle, Pencil, Save, TriangleAlert, X } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import type { ImageOptions, Range, StreamOptions } from "@/lib/camera-config";
|
||||||
|
import type { ImageSettings, StreamSettings } from "@/lib/camera-settings";
|
||||||
|
import { saveImageSettings, saveStreamSettings } from "./actions";
|
||||||
|
import { dayNightLabel, shutterLabel, titleCase } from "./format";
|
||||||
|
import type { SaveResult } from "./save-result";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit → Save for the Image section and each stream (vrek iss-j8zzg1r, dec-84ghhqr,
|
||||||
|
* dec-n4e8jah). Only values the camera reports as supported are offered, only changed
|
||||||
|
* fields are sent, stream saves need an explicit confirmation because the stream restarts,
|
||||||
|
* and the outcome shows what the camera actually applied. No camera logic lives here: the
|
||||||
|
* Server Actions and lib/camera-config validate and write (pri-01np850).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FIELD_LABELS: Record<string, string> = {
|
||||||
|
dayNight: "Day/night",
|
||||||
|
exposure: "Exposure",
|
||||||
|
whiteBalance: "White balance",
|
||||||
|
brightness: "Brightness",
|
||||||
|
contrast: "Contrast",
|
||||||
|
saturation: "Saturation",
|
||||||
|
sharpness: "Sharpness",
|
||||||
|
exposureTime: "Exposure time (µs)",
|
||||||
|
gain: "Gain",
|
||||||
|
wideDynamicRange: "Wide dynamic range",
|
||||||
|
wideDynamicRangeLevel: "WDR level",
|
||||||
|
backlightCompensation: "Backlight compensation",
|
||||||
|
backlightCompensationLevel: "Backlight level",
|
||||||
|
width: "Width",
|
||||||
|
height: "Height",
|
||||||
|
fps: "Frame rate",
|
||||||
|
bitrateKbps: "Bitrate",
|
||||||
|
constantBitRate: "Bitrate mode",
|
||||||
|
gop: "Keyframe interval",
|
||||||
|
quality: "Quality",
|
||||||
|
h264Profile: "H.264 profile",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fieldLabel = (field: string) => FIELD_LABELS[field] ?? field;
|
||||||
|
|
||||||
|
const inputClass = "rounded border border-zinc-300 bg-transparent px-2 py-1 dark:border-zinc-700";
|
||||||
|
|
||||||
|
type Values = Record<string, string>;
|
||||||
|
|
||||||
|
/** Fields whose form value differs from the current one, converted to the patch's types. */
|
||||||
|
export function changedFields(
|
||||||
|
form: Values,
|
||||||
|
current: Values,
|
||||||
|
numeric: ReadonlySet<string>,
|
||||||
|
): Record<string, string | number | boolean> {
|
||||||
|
const patch: Record<string, string | number | boolean> = {};
|
||||||
|
for (const [field, value] of Object.entries(form)) {
|
||||||
|
if (value === current[field] || value === "") continue;
|
||||||
|
if (field === "resolution") {
|
||||||
|
const [width, height] = value.split("x").map(Number);
|
||||||
|
patch.width = width;
|
||||||
|
patch.height = height;
|
||||||
|
} else if (field === "constantBitRate") {
|
||||||
|
patch.constantBitRate = value === "true";
|
||||||
|
} else {
|
||||||
|
patch[field] = numeric.has(field) ? Number(value) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shared pieces --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Outcome({ result }: { result: SaveResult }) {
|
||||||
|
if (result.ok) {
|
||||||
|
const adjusted = result.fields.filter((f) => !f.applied);
|
||||||
|
return (
|
||||||
|
<div role="status" className="mt-3 flex items-start gap-1.5 text-sm text-emerald-700 dark:text-emerald-400">
|
||||||
|
<CircleCheck aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||||
|
{adjusted.length === 0 ? (
|
||||||
|
<span>Saved. The camera applied every change.</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Saved, but the camera adjusted:{" "}
|
||||||
|
{adjusted
|
||||||
|
.map((f) => `${fieldLabel(f.field)} ${String(f.actual ?? "unset")} (asked for ${String(f.requested)})`)
|
||||||
|
.join("; ")}
|
||||||
|
.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div role="alert" className="mt-3 flex items-start gap-1.5 text-sm text-red-600">
|
||||||
|
<CircleAlert aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p>{result.message}</p>
|
||||||
|
{result.reason === "invalid" && result.issues.length > 0 && (
|
||||||
|
<ul className="mt-1 list-disc pl-5">
|
||||||
|
{result.issues.map((issue) => (
|
||||||
|
<li key={issue}>{issue}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditButton({ onClick }: { onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="inline-flex items-center gap-1 rounded border border-zinc-300 px-2 py-0.5 text-xs dark:border-zinc-700"
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden className="size-3.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReadOnlyNote() {
|
||||||
|
return (
|
||||||
|
<p className="mt-3 flex items-start gap-1.5 text-xs text-zinc-500">
|
||||||
|
<TriangleAlert aria-hidden className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
Read-only: this camera's saved ONVIF login isn't allowed to change these settings. Raise
|
||||||
|
that user's access level on the camera to edit them here.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormButtons({ pending, onCancel, saveLabel = "Save" }: { pending: boolean; onCancel: () => void; saveLabel?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded bg-black px-3 py-1 text-white disabled:opacity-50 dark:bg-white dark:text-black"
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<LoaderCircle aria-hidden className="size-4 animate-spin motion-reduce:animate-none" />
|
||||||
|
) : (
|
||||||
|
<Save aria-hidden className="size-4" />
|
||||||
|
)}
|
||||||
|
{pending ? "Saving…" : saveLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={pending}
|
||||||
|
className="inline-flex items-center gap-1 rounded px-3 py-1 underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<X aria-hidden className="size-4" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
choices,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
choices: { value: string; label: string }[];
|
||||||
|
onChange: (name: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
{label}
|
||||||
|
<select name={name} value={value} onChange={(e) => onChange(name, e.target.value)} className={inputClass}>
|
||||||
|
{choices.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
range,
|
||||||
|
step,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
range?: Range;
|
||||||
|
step?: number;
|
||||||
|
onChange: (name: string, value: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
{range ? `${label} (${range.min}–${range.max})` : label}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
min={range?.min}
|
||||||
|
max={range?.max}
|
||||||
|
step={step ?? "any"}
|
||||||
|
onChange={(e) => onChange(name, e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edit state shared by both editors: open/closed, pending save, last outcome, read-only. */
|
||||||
|
function useEditor(save: (patch: Record<string, unknown>) => Promise<SaveResult>) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [result, setResult] = useState<SaveResult | null>(null);
|
||||||
|
const [readOnly, setReadOnly] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
function submit(patch: Record<string, unknown>) {
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
setEditing(false);
|
||||||
|
setResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startTransition(async () => {
|
||||||
|
const outcome = await save(patch);
|
||||||
|
setResult(outcome);
|
||||||
|
if (outcome.ok) {
|
||||||
|
setEditing(false);
|
||||||
|
router.refresh();
|
||||||
|
} else if (outcome.reason === "not-permitted") {
|
||||||
|
setEditing(false);
|
||||||
|
setReadOnly(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
editing,
|
||||||
|
readOnly,
|
||||||
|
pending,
|
||||||
|
result,
|
||||||
|
open: () => {
|
||||||
|
setResult(null);
|
||||||
|
setEditing(true);
|
||||||
|
},
|
||||||
|
cancel: () => setEditing(false),
|
||||||
|
submit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const IMAGE_NUMERIC = new Set([
|
||||||
|
"brightness",
|
||||||
|
"contrast",
|
||||||
|
"saturation",
|
||||||
|
"sharpness",
|
||||||
|
"exposureTime",
|
||||||
|
"gain",
|
||||||
|
"wideDynamicRangeLevel",
|
||||||
|
"backlightCompensationLevel",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function imageValues(i: ImageSettings): Values {
|
||||||
|
const s = (v: unknown) => (v === undefined ? "" : String(v));
|
||||||
|
return {
|
||||||
|
dayNight: s(i.dayNight),
|
||||||
|
exposure: s(i.exposure),
|
||||||
|
exposureTime: s(i.exposureTime),
|
||||||
|
gain: s(i.gain),
|
||||||
|
whiteBalance: s(i.whiteBalance),
|
||||||
|
wideDynamicRange: s(i.wideDynamicRange),
|
||||||
|
backlightCompensation: s(i.backlightCompensation),
|
||||||
|
brightness: s(i.brightness),
|
||||||
|
contrast: s(i.contrast),
|
||||||
|
saturation: s(i.saturation),
|
||||||
|
sharpness: s(i.sharpness),
|
||||||
|
wideDynamicRangeLevel: s(i.wideDynamicRangeLevel),
|
||||||
|
backlightCompensationLevel: s(i.backlightCompensationLevel),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const modes = (list: string[] | undefined, label: (m: string) => string = titleCase) =>
|
||||||
|
(list ?? []).map((m) => ({ value: m, label: label(m) }));
|
||||||
|
|
||||||
|
export function ImageEditor({
|
||||||
|
cameraId,
|
||||||
|
current,
|
||||||
|
options,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
cameraId: string;
|
||||||
|
current: ImageSettings;
|
||||||
|
options: ImageOptions | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const editor = useEditor((patch) => saveImageSettings(cameraId, patch));
|
||||||
|
const initial = imageValues(current);
|
||||||
|
const [form, setForm] = useState<Values>(initial);
|
||||||
|
const set = (name: string, value: string) => setForm((f) => ({ ...f, [name]: value }));
|
||||||
|
const canEdit = options !== null && !editor.readOnly;
|
||||||
|
|
||||||
|
if (!editor.editing) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="mb-2 flex justify-end">
|
||||||
|
<EditButton
|
||||||
|
onClick={() => {
|
||||||
|
setForm(initial);
|
||||||
|
editor.open();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{editor.readOnly && <ReadOnlyNote />}
|
||||||
|
{editor.result && <Outcome result={editor.result} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = options!;
|
||||||
|
// Exposure time and gain only matter (and are only accepted) in Manual exposure.
|
||||||
|
const manual = form.exposure === "MANUAL";
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
aria-label="Edit image settings"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
editor.submit(changedFields(form, initial, IMAGE_NUMERIC));
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{o.dayNight.length > 0 && (
|
||||||
|
<SelectField label="Day/night" name="dayNight" value={form.dayNight} choices={modes(o.dayNight, dayNightLabel)} onChange={set} />
|
||||||
|
)}
|
||||||
|
{o.exposure.length > 0 && (
|
||||||
|
<SelectField label="Exposure" name="exposure" value={form.exposure} choices={modes(o.exposure)} onChange={set} />
|
||||||
|
)}
|
||||||
|
{manual && o.exposureTime && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<NumberField label="Exposure time (µs)" name="exposureTime" value={form.exposureTime} range={o.exposureTime} step={1} onChange={set} />
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
{form.exposureTime ? shutterLabel(Number(form.exposureTime)) : "Blank keeps the camera's current manual value"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{manual && o.gain && <NumberField label="Gain" name="gain" value={form.gain} range={o.gain} onChange={set} />}
|
||||||
|
{o.whiteBalance.length > 0 && (
|
||||||
|
<SelectField label="White balance" name="whiteBalance" value={form.whiteBalance} choices={modes(o.whiteBalance)} onChange={set} />
|
||||||
|
)}
|
||||||
|
{o.wideDynamicRange && o.wideDynamicRange.modes.length > 0 && (
|
||||||
|
<SelectField label="Wide dynamic range" name="wideDynamicRange" value={form.wideDynamicRange} choices={modes(o.wideDynamicRange.modes)} onChange={set} />
|
||||||
|
)}
|
||||||
|
{o.wideDynamicRange?.level && (
|
||||||
|
<NumberField label="WDR level" name="wideDynamicRangeLevel" value={form.wideDynamicRangeLevel} range={o.wideDynamicRange.level} onChange={set} />
|
||||||
|
)}
|
||||||
|
{o.backlightCompensation && o.backlightCompensation.modes.length > 0 && (
|
||||||
|
<SelectField label="Backlight compensation" name="backlightCompensation" value={form.backlightCompensation} choices={modes(o.backlightCompensation.modes)} onChange={set} />
|
||||||
|
)}
|
||||||
|
{o.backlightCompensation?.level && (
|
||||||
|
<NumberField label="Backlight level" name="backlightCompensationLevel" value={form.backlightCompensationLevel} range={o.backlightCompensation.level} onChange={set} />
|
||||||
|
)}
|
||||||
|
{o.brightness && <NumberField label="Brightness" name="brightness" value={form.brightness} range={o.brightness} onChange={set} />}
|
||||||
|
{o.contrast && <NumberField label="Contrast" name="contrast" value={form.contrast} range={o.contrast} onChange={set} />}
|
||||||
|
{o.saturation && <NumberField label="Saturation" name="saturation" value={form.saturation} range={o.saturation} onChange={set} />}
|
||||||
|
{o.sharpness && <NumberField label="Sharpness" name="sharpness" value={form.sharpness} range={o.sharpness} onChange={set} />}
|
||||||
|
</div>
|
||||||
|
{manual && (
|
||||||
|
<p className="flex items-start gap-1.5 text-xs text-zinc-500">
|
||||||
|
<TriangleAlert aria-hidden className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
In Manual exposure the camera holds a fixed exposure time and gain, so Brightness has little
|
||||||
|
effect. Adjust exposure time and gain instead, or switch back to Auto.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<FormButtons pending={editor.pending} onCancel={editor.cancel} />
|
||||||
|
{editor.result && <Outcome result={editor.result} />}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Streams ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const STREAM_NUMERIC = new Set(["fps", "bitrateKbps", "gop", "quality"]);
|
||||||
|
|
||||||
|
function streamValues(s: StreamSettings): Values {
|
||||||
|
const v = (x: unknown) => (x === undefined ? "" : String(x));
|
||||||
|
return {
|
||||||
|
resolution: s.width && s.height ? `${s.width}x${s.height}` : "",
|
||||||
|
fps: v(s.fps),
|
||||||
|
bitrateKbps: v(s.bitrateKbps),
|
||||||
|
constantBitRate: v(s.constantBitRate),
|
||||||
|
gop: v(s.gop),
|
||||||
|
quality: v(s.quality),
|
||||||
|
h264Profile: v(s.profile),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreamEditor({
|
||||||
|
cameraId,
|
||||||
|
stream,
|
||||||
|
options,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
cameraId: string;
|
||||||
|
stream: StreamSettings;
|
||||||
|
options: StreamOptions | undefined;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const editor = useEditor((patch) => saveStreamSettings(cameraId, stream.token, patch));
|
||||||
|
const initial = streamValues(stream);
|
||||||
|
const [form, setForm] = useState<Values>(initial);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const set = (name: string, value: string) => setForm((f) => ({ ...f, [name]: value }));
|
||||||
|
const canEdit = options !== undefined && !editor.readOnly;
|
||||||
|
const name = stream.name ?? stream.token;
|
||||||
|
|
||||||
|
if (!editor.editing) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="mb-2 flex justify-end">
|
||||||
|
<EditButton
|
||||||
|
onClick={() => {
|
||||||
|
setForm(initial);
|
||||||
|
setConfirming(false);
|
||||||
|
editor.open();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{editor.readOnly && <ReadOnlyNote />}
|
||||||
|
{editor.result && <Outcome result={editor.result} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const o = options!;
|
||||||
|
const patch = changedFields(form, initial, STREAM_NUMERIC);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
aria-label={`Edit ${name}`}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Nothing changed: close without the restart warning.
|
||||||
|
if (Object.keys(patch).length > 0 && !confirming) setConfirming(true);
|
||||||
|
else {
|
||||||
|
setConfirming(false);
|
||||||
|
editor.submit(patch);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{o.resolutions.length > 0 && (
|
||||||
|
<SelectField
|
||||||
|
label="Resolution"
|
||||||
|
name="resolution"
|
||||||
|
value={form.resolution}
|
||||||
|
choices={o.resolutions.map((r) => ({ value: `${r.width}x${r.height}`, label: `${r.width}×${r.height}` }))}
|
||||||
|
onChange={set}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{o.fps && <NumberField label="Frame rate" name="fps" value={form.fps} range={o.fps} step={1} onChange={set} />}
|
||||||
|
<NumberField label="Bitrate (kbps)" name="bitrateKbps" value={form.bitrateKbps} range={o.bitrateKbps} step={1} onChange={set} />
|
||||||
|
<SelectField
|
||||||
|
label="Bitrate mode"
|
||||||
|
name="constantBitRate"
|
||||||
|
value={form.constantBitRate}
|
||||||
|
choices={[
|
||||||
|
{ value: "false", label: "Variable" },
|
||||||
|
{ value: "true", label: "Constant" },
|
||||||
|
]}
|
||||||
|
onChange={set}
|
||||||
|
/>
|
||||||
|
{o.gop && <NumberField label="Keyframe interval" name="gop" value={form.gop} range={o.gop} step={1} onChange={set} />}
|
||||||
|
{o.quality && <NumberField label="Quality" name="quality" value={form.quality} range={o.quality} onChange={set} />}
|
||||||
|
{o.h264Profiles.length > 0 && (
|
||||||
|
<SelectField label="H.264 profile" name="h264Profile" value={form.h264Profile} choices={modes(o.h264Profiles, (p) => p)} onChange={set} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirming ? (
|
||||||
|
<div role="alertdialog" aria-label="Confirm stream restart" className="flex flex-col gap-2 rounded border border-amber-400/60 p-3 text-sm">
|
||||||
|
<p className="flex items-start gap-1.5">
|
||||||
|
<TriangleAlert aria-hidden className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
This restarts the {name} stream; recorders and viewers will reconnect.
|
||||||
|
</p>
|
||||||
|
<FormButtons pending={editor.pending} onCancel={() => setConfirming(false)} saveLabel="Restart stream and save" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormButtons pending={editor.pending} onCancel={editor.cancel} />
|
||||||
|
)}
|
||||||
|
{editor.result && <Outcome result={editor.result} />}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import {
|
|||||||
bitrateLabel,
|
bitrateLabel,
|
||||||
codecLabel,
|
codecLabel,
|
||||||
dayNightLabel,
|
dayNightLabel,
|
||||||
|
shutterLabel,
|
||||||
DRIFT_WARNING_SECONDS,
|
DRIFT_WARNING_SECONDS,
|
||||||
driftIsLarge,
|
driftIsLarge,
|
||||||
driftLabel,
|
driftLabel,
|
||||||
@ -92,3 +93,15 @@ describe("format", () => {
|
|||||||
expect(valueOrUnknown(undefined)).toBe("Unknown");
|
expect(valueOrUnknown(undefined)).toBe("Unknown");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shutterLabel", () => {
|
||||||
|
it.each([
|
||||||
|
[33333, "33,333 µs (≈1/30 s)"],
|
||||||
|
[10, "10 µs (≈1/100000 s)"],
|
||||||
|
[500000, "500,000 µs (≈1/2 s)"],
|
||||||
|
[1000000, "1,000,000 µs (≈1 s)"],
|
||||||
|
[undefined, "Unknown"],
|
||||||
|
])("%s µs → %s", (us, label) => {
|
||||||
|
expect(shutterLabel(us)).toBe(label);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -44,6 +44,16 @@ export function dayNightLabel(irCut?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ONVIF exposure time (µs) as "33,333 µs (≈1/30 s)". */
|
||||||
|
export function shutterLabel(microseconds?: number): string {
|
||||||
|
if (microseconds === undefined) return "Unknown";
|
||||||
|
const approx =
|
||||||
|
microseconds >= 1_000_000
|
||||||
|
? `${Number((microseconds / 1_000_000).toFixed(1))} s`
|
||||||
|
: `1/${Math.round(1_000_000 / microseconds)} s`;
|
||||||
|
return `${microseconds.toLocaleString("en-US")} µs (≈${approx})`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Drift beyond this many seconds is worth a warning (vrek dec-gtw493s). */
|
/** Drift beyond this many seconds is worth a warning (vrek dec-gtw493s). */
|
||||||
export const DRIFT_WARNING_SECONDS = 30;
|
export const DRIFT_WARNING_SECONDS = 30;
|
||||||
|
|
||||||
|
|||||||
16
src/app/cameras/[id]/save-result.ts
Normal file
16
src/app/cameras/[id]/save-result.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { WriteResult } from "@/lib/camera-config";
|
||||||
|
|
||||||
|
/** What a save on the camera page returns (shared by actions.ts and the edit forms). */
|
||||||
|
export type SaveResult =
|
||||||
|
| WriteResult
|
||||||
|
| { ok: false; reason: "signed-out" | "camera"; message: string };
|
||||||
|
|
||||||
|
export const SAVE_MESSAGES = {
|
||||||
|
signedOut: "Your session has ended. Sign in again to change settings.",
|
||||||
|
unknownCamera: "Unknown camera; scan the network again",
|
||||||
|
unknownStream: "Unknown stream",
|
||||||
|
missingLogin: "No login is saved for this camera. Set it from the dashboard card first.",
|
||||||
|
rejectedLogin: "The camera rejected its saved login. Update it from the dashboard card.",
|
||||||
|
inactive: "This camera hasn't been activated yet.",
|
||||||
|
unreachable: "Couldn't reach the camera. See the server log for details.",
|
||||||
|
} as const;
|
||||||
@ -9,6 +9,11 @@ import type { CameraSettings } from "@/lib/camera-settings";
|
|||||||
*/
|
*/
|
||||||
const getCameraSettings = vi.fn<() => Promise<CameraSettings>>();
|
const getCameraSettings = vi.fn<() => Promise<CameraSettings>>();
|
||||||
vi.mock("@/lib/camera-settings", () => ({ getCameraSettings }));
|
vi.mock("@/lib/camera-settings", () => ({ getCameraSettings }));
|
||||||
|
const readConfigOptions = vi.fn();
|
||||||
|
vi.mock("@/lib/camera-config", () => ({ readConfigOptions }));
|
||||||
|
// The edit forms are client components; here only their read-only rendering matters.
|
||||||
|
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn() }) }));
|
||||||
|
vi.mock("./actions", () => ({ saveImageSettings: vi.fn(), saveStreamSettings: vi.fn() }));
|
||||||
|
|
||||||
const { CameraSettingsSections, webUiUrl } = await import("./settings-sections");
|
const { CameraSettingsSections, webUiUrl } = await import("./settings-sections");
|
||||||
const { CameraAuthError, CameraInactiveError } = await import("@/lib/camera");
|
const { CameraAuthError, CameraInactiveError } = await import("@/lib/camera");
|
||||||
@ -95,7 +100,55 @@ const row = (html: string, label: string) =>
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getCameraSettings.mockReset();
|
getCameraSettings.mockReset();
|
||||||
|
readConfigOptions.mockReset().mockResolvedValue(null);
|
||||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editing", () => {
|
||||||
|
const editButtons = (html: string) => (html.match(/>Edit</g) ?? []).length;
|
||||||
|
|
||||||
|
it("offers Edit on the image section and each stream the camera reports options for", async () => {
|
||||||
|
getCameraSettings.mockResolvedValue(probe);
|
||||||
|
readConfigOptions.mockResolvedValue({
|
||||||
|
image: { dayNight: ["AUTO"], exposure: [], whiteBalance: [] },
|
||||||
|
streams: [{ token: "Profile_1", resolutions: [], h264Profiles: [] }],
|
||||||
|
});
|
||||||
|
const html = renderToStaticMarkup(await CameraSettingsSections({ target }));
|
||||||
|
expect(readConfigOptions).toHaveBeenCalledWith(target);
|
||||||
|
// Image + Profile_1; Profile_2 has no options, so it stays read-only.
|
||||||
|
expect(editButtons(html)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays read-only when the camera's options can't be read", async () => {
|
||||||
|
getCameraSettings.mockResolvedValue(probe);
|
||||||
|
readConfigOptions.mockRejectedValue(new Error("ActionNotSupported"));
|
||||||
|
const html = renderToStaticMarkup(await CameraSettingsSections({ target }));
|
||||||
|
expect(editButtons(html)).toBe(0);
|
||||||
|
expect(html).toContain("mainStream");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no Edit without options", async () => {
|
||||||
|
getCameraSettings.mockResolvedValue(probe);
|
||||||
|
expect(editButtons(renderToStaticMarkup(await CameraSettingsSections({ target })))).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("manual exposure", () => {
|
||||||
|
it("shows exposure time and gain while in Manual, and not in Auto", async () => {
|
||||||
|
const manual = {
|
||||||
|
...probe,
|
||||||
|
image: { ok: true as const, value: { exposure: "MANUAL", exposureTime: 33333, gain: 12 } },
|
||||||
|
};
|
||||||
|
getCameraSettings.mockResolvedValue(manual);
|
||||||
|
const html = renderToStaticMarkup(await CameraSettingsSections({ target }));
|
||||||
|
expect(html).toContain("33,333 µs (≈1/30 s)");
|
||||||
|
expect(html).toContain(">Gain<");
|
||||||
|
|
||||||
|
getCameraSettings.mockResolvedValue(probe);
|
||||||
|
const auto = renderToStaticMarkup(await CameraSettingsSections({ target }));
|
||||||
|
expect(auto).not.toContain("Exposure time");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("CameraSettingsSections", () => {
|
describe("CameraSettingsSections", () => {
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { CameraAuthError, CameraInactiveError, type CameraTarget } from "@/lib/camera";
|
import { CameraAuthError, CameraInactiveError, type CameraTarget } from "@/lib/camera";
|
||||||
|
import { readConfigOptions, type ConfigOptions } from "@/lib/camera-config";
|
||||||
import {
|
import {
|
||||||
getCameraSettings,
|
getCameraSettings,
|
||||||
type ImageSettings,
|
type ImageSettings,
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
type TimeSettings,
|
type TimeSettings,
|
||||||
} from "@/lib/camera-settings";
|
} from "@/lib/camera-settings";
|
||||||
import { CopyButton, LocalTime } from "./client-controls";
|
import { CopyButton, LocalTime } from "./client-controls";
|
||||||
|
import { ImageEditor, StreamEditor } from "./edit-forms";
|
||||||
import {
|
import {
|
||||||
bitrateLabel,
|
bitrateLabel,
|
||||||
codecLabel,
|
codecLabel,
|
||||||
@ -30,6 +32,7 @@ import {
|
|||||||
linkLabel,
|
linkLabel,
|
||||||
ntpLabel,
|
ntpLabel,
|
||||||
resolutionLabel,
|
resolutionLabel,
|
||||||
|
shutterLabel,
|
||||||
titleCase,
|
titleCase,
|
||||||
valueOrUnknown,
|
valueOrUnknown,
|
||||||
} from "./format";
|
} from "./format";
|
||||||
@ -73,13 +76,26 @@ function Unavailable() {
|
|||||||
return <p className="text-zinc-500">Not available from this camera.</p>;
|
return <p className="text-zinc-500">Not available from this camera.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Streams({ section }: { section: Section<StreamSettings[]> }) {
|
function Streams({
|
||||||
|
cameraId,
|
||||||
|
section,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
cameraId: string;
|
||||||
|
section: Section<StreamSettings[]>;
|
||||||
|
options: ConfigOptions | null;
|
||||||
|
}) {
|
||||||
if (!section.ok) return <Unavailable />;
|
if (!section.ok) return <Unavailable />;
|
||||||
if (section.value.length === 0) return <p className="text-zinc-500">No video streams reported.</p>;
|
if (section.value.length === 0) return <p className="text-zinc-500">No video streams reported.</p>;
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{section.value.map((s) => (
|
{section.value.map((s) => (
|
||||||
<div key={s.token}>
|
<StreamEditor
|
||||||
|
key={s.token}
|
||||||
|
cameraId={cameraId}
|
||||||
|
stream={s}
|
||||||
|
options={options?.streams.find((o) => o.token === s.token)}
|
||||||
|
>
|
||||||
<h3 className="font-medium">{s.name ?? s.token}</h3>
|
<h3 className="font-medium">{s.name ?? s.token}</h3>
|
||||||
<Rows
|
<Rows
|
||||||
rows={[
|
rows={[
|
||||||
@ -102,7 +118,7 @@ function Streams({ section }: { section: Section<StreamSettings[]> }) {
|
|||||||
],
|
],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</StreamEditor>
|
||||||
))}
|
))}
|
||||||
<p className="text-xs text-zinc-500">
|
<p className="text-xs text-zinc-500">
|
||||||
In VLC or an NVR, sign in with the camera's ONVIF login (the one saved for this camera).
|
In VLC or an NVR, sign in with the camera's ONVIF login (the one saved for this camera).
|
||||||
@ -111,14 +127,29 @@ function Streams({ section }: { section: Section<StreamSettings[]> }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImageInfo({ section }: { section: Section<ImageSettings> }) {
|
function ImageInfo({
|
||||||
|
cameraId,
|
||||||
|
section,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
cameraId: string;
|
||||||
|
section: Section<ImageSettings>;
|
||||||
|
options: ConfigOptions | null;
|
||||||
|
}) {
|
||||||
if (!section.ok) return <Unavailable />;
|
if (!section.ok) return <Unavailable />;
|
||||||
const i = section.value;
|
const i = section.value;
|
||||||
return (
|
return (
|
||||||
|
<ImageEditor cameraId={cameraId} current={i} options={options?.image ?? null}>
|
||||||
<Rows
|
<Rows
|
||||||
rows={[
|
rows={[
|
||||||
["Day/night", dayNightLabel(i.dayNight)],
|
["Day/night", dayNightLabel(i.dayNight)],
|
||||||
["Exposure", titleCase(i.exposure)],
|
["Exposure", titleCase(i.exposure)],
|
||||||
|
...(i.exposure === "MANUAL"
|
||||||
|
? ([
|
||||||
|
["Exposure time", shutterLabel(i.exposureTime)],
|
||||||
|
["Gain", valueOrUnknown(i.gain)],
|
||||||
|
] as [string, React.ReactNode][])
|
||||||
|
: []),
|
||||||
["White balance", titleCase(i.whiteBalance)],
|
["White balance", titleCase(i.whiteBalance)],
|
||||||
["Wide dynamic range", titleCase(i.wideDynamicRange)],
|
["Wide dynamic range", titleCase(i.wideDynamicRange)],
|
||||||
["Backlight compensation", titleCase(i.backlightCompensation)],
|
["Backlight compensation", titleCase(i.backlightCompensation)],
|
||||||
@ -129,6 +160,7 @@ function ImageInfo({ section }: { section: Section<ImageSettings> }) {
|
|||||||
["Noise reduction", valueOrUnknown(i.noiseReduction)],
|
["Noise reduction", valueOrUnknown(i.noiseReduction)],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</ImageEditor>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,8 +246,16 @@ function Problem({ icon: Icon, children }: { icon: LucideIcon; children: React.R
|
|||||||
*/
|
*/
|
||||||
export async function CameraSettingsSections({ target }: { target: CameraTarget }) {
|
export async function CameraSettingsSections({ target }: { target: CameraTarget }) {
|
||||||
let settings;
|
let settings;
|
||||||
|
let options: ConfigOptions | null;
|
||||||
try {
|
try {
|
||||||
settings = await getCameraSettings(target);
|
// What each section may be changed to; without it the sections stay read-only.
|
||||||
|
[settings, options] = await Promise.all([
|
||||||
|
getCameraSettings(target),
|
||||||
|
readConfigOptions(target).catch((err) => {
|
||||||
|
console.warn("[config] edit options unavailable:", err instanceof Error ? err.message : err);
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof CameraAuthError) {
|
if (err instanceof CameraAuthError) {
|
||||||
return (
|
return (
|
||||||
@ -249,11 +289,11 @@ export async function CameraSettingsSections({ target }: { target: CameraTarget
|
|||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<Panel icon={Video} title="Streams">
|
<Panel icon={Video} title="Streams">
|
||||||
<Streams section={settings.streams} />
|
<Streams cameraId={target.id} section={settings.streams} options={options} />
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
<Panel icon={SunMoon} title="Image">
|
<Panel icon={SunMoon} title="Image">
|
||||||
<ImageInfo section={settings.image} />
|
<ImageInfo cameraId={target.id} section={settings.image} options={options} />
|
||||||
</Panel>
|
</Panel>
|
||||||
<Panel icon={Clock} title="Time & NTP">
|
<Panel icon={Clock} title="Time & NTP">
|
||||||
<Time section={settings.time} />
|
<Time section={settings.time} />
|
||||||
|
|||||||
46
src/lib/audit-log.test.ts
Normal file
46
src/lib/audit-log.test.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { appendAudit, auditLogPath } from "./audit-log";
|
||||||
|
|
||||||
|
let dir: string;
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(path.join(os.tmpdir(), "audit-"));
|
||||||
|
vi.stubEnv("AUDIT_LOG_FILE", path.join(dir, "nested", "audit.jsonl"));
|
||||||
|
});
|
||||||
|
afterEach(() => rm(dir, { recursive: true, force: true }));
|
||||||
|
|
||||||
|
describe("audit log", () => {
|
||||||
|
it("defaults to ./.data/audit.jsonl", () => {
|
||||||
|
expect(auditLogPath({})).toBe(path.join(process.cwd(), ".data", "audit.jsonl"));
|
||||||
|
expect(auditLogPath({ AUDIT_LOG_FILE: "/var/log/cams.jsonl" })).toBe("/var/log/cams.jsonl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends one JSON line per entry, owner-only, in an owner-only folder", async () => {
|
||||||
|
const entry = {
|
||||||
|
actor: "admin",
|
||||||
|
cameraId: "cam-1",
|
||||||
|
section: "image",
|
||||||
|
outcome: "applied" as const,
|
||||||
|
changes: [{ field: "brightness", from: 50, to: 60, actual: 60 }],
|
||||||
|
};
|
||||||
|
await appendAudit(entry, new Date("2026-09-19T12:00:00Z"));
|
||||||
|
await appendAudit({ ...entry, outcome: "adjusted" }, new Date("2026-09-19T12:01:00Z"));
|
||||||
|
|
||||||
|
const file = auditLogPath();
|
||||||
|
const lines = (await readFile(file, "utf8")).trim().split("\n").map((l) => JSON.parse(l));
|
||||||
|
expect(lines).toEqual([
|
||||||
|
{ at: "2026-09-19T12:00:00.000Z", ...entry },
|
||||||
|
{ at: "2026-09-19T12:01:00.000Z", ...entry, outcome: "adjusted" },
|
||||||
|
]);
|
||||||
|
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
||||||
|
expect((await stat(path.dirname(file))).mode & 0o777).toBe(0o700);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps the current time by default", async () => {
|
||||||
|
await appendAudit({ actor: "a", cameraId: "c", section: "image", outcome: "failed", changes: [] });
|
||||||
|
const [line] = (await readFile(auditLogPath(), "utf8")).trim().split("\n");
|
||||||
|
expect(Date.now() - Date.parse(JSON.parse(line).at)).toBeLessThan(5_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/lib/audit-log.ts
Normal file
42
src/lib/audit-log.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { appendFile, mkdir } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append-only record of configuration changes (vrek dec-n4e8jah): who changed which
|
||||||
|
* setting on which camera, from what to what, and what the camera actually applied.
|
||||||
|
* One JSON object per line in AUDIT_LOG_FILE (default ./.data/audit.jsonl), owner-only.
|
||||||
|
* Never contains passwords: callers pass only setting names and values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Runtime data, not a build input: the ignore comment keeps it out of Next's output file
|
||||||
|
// tracing (see camera-registry.ts).
|
||||||
|
const DEFAULT_AUDIT_LOG = path.join(/* turbopackIgnore: true */ process.cwd(), ".data", "audit.jsonl");
|
||||||
|
|
||||||
|
export function auditLogPath(env: Record<string, string | undefined> = process.env): string {
|
||||||
|
return env.AUDIT_LOG_FILE || DEFAULT_AUDIT_LOG;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditChange {
|
||||||
|
field: string;
|
||||||
|
from: unknown;
|
||||||
|
to: unknown;
|
||||||
|
/** What the camera reported after the write; absent when the write didn't happen. */
|
||||||
|
actual?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEntry {
|
||||||
|
at: string;
|
||||||
|
actor: string;
|
||||||
|
cameraId: string;
|
||||||
|
section: string;
|
||||||
|
outcome: "applied" | "adjusted" | "refused" | "not-permitted" | "failed";
|
||||||
|
changes: AuditChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendAudit(entry: Omit<AuditEntry, "at">, now: Date = new Date()): Promise<void> {
|
||||||
|
const file = auditLogPath();
|
||||||
|
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||||
|
const line = JSON.stringify({ at: now.toISOString(), ...entry }) + "\n";
|
||||||
|
await appendFile(file, line, { mode: 0o600 });
|
||||||
|
}
|
||||||
554
src/lib/camera-config.test.ts
Normal file
554
src/lib/camera-config.test.ts
Normal file
@ -0,0 +1,554 @@
|
|||||||
|
/**
|
||||||
|
* FAKE NETWORK: no real camera is contacted (vrek principle pri-e14bahk). camera.ts's
|
||||||
|
* connect() is replaced by an in-memory camera that stores settings, so writes, re-reads,
|
||||||
|
* camera-side adjustments and refusals can all be scripted. Shapes follow the probe of the
|
||||||
|
* real I91ET cameras (fnd-j1s725e, fnd-g4z7c6d) and the ONVIF option structures.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { CameraTarget } from "./camera";
|
||||||
|
|
||||||
|
type Cb = (err: Error | null, value?: unknown) => void;
|
||||||
|
|
||||||
|
const fake = vi.hoisted(() => ({
|
||||||
|
connect: vi.fn(),
|
||||||
|
resetConnection: vi.fn(),
|
||||||
|
appendAudit: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("./camera", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./camera")>()),
|
||||||
|
connect: fake.connect,
|
||||||
|
resetConnection: fake.resetConnection,
|
||||||
|
}));
|
||||||
|
vi.mock("./audit-log", () => ({ appendAudit: fake.appendAudit }));
|
||||||
|
|
||||||
|
const config = await import("./camera-config");
|
||||||
|
const { MESSAGES } = config;
|
||||||
|
|
||||||
|
const target: CameraTarget = { id: "cam-1", host: "192.168.1.10", port: 80 };
|
||||||
|
|
||||||
|
// --- the in-memory camera ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
const imagingOptions = {
|
||||||
|
brightness: { min: 0, max: 100 },
|
||||||
|
colorSaturation: { min: 0, max: 100 },
|
||||||
|
contrast: { min: 0, max: 100 },
|
||||||
|
sharpness: { min: 0, max: 100 },
|
||||||
|
irCutFilterModes: ["ON", "OFF", "AUTO"],
|
||||||
|
exposure: { mode: ["AUTO", "MANUAL"], exposureTime: { min: 10, max: 1000000 }, gain: { min: 0, max: 100 } },
|
||||||
|
whiteBalance: { mode: ["AUTO", "MANUAL"] },
|
||||||
|
wideDynamicRange: { mode: ["OFF", "ON"], level: { min: 0, max: 100 } },
|
||||||
|
backlightCompensation: { mode: ["OFF", "ON"], level: { min: 0, max: 100 } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const encoderOptions = {
|
||||||
|
qualityRange: { min: 1, max: 6 },
|
||||||
|
H264: {
|
||||||
|
resolutionsAvailable: [
|
||||||
|
{ width: 4096, height: 1860 },
|
||||||
|
{ width: 2560, height: 1160 },
|
||||||
|
],
|
||||||
|
govLengthRange: { min: 1, max: 400 },
|
||||||
|
frameRateRange: { min: 1, max: 25 },
|
||||||
|
H264ProfilesSupported: ["Baseline", "Main", "High"],
|
||||||
|
},
|
||||||
|
extension: { H264: { bitrateRange: { min: 32, max: 16384 } } },
|
||||||
|
};
|
||||||
|
|
||||||
|
function initialImaging() {
|
||||||
|
return {
|
||||||
|
brightness: 50,
|
||||||
|
colorSaturation: 50,
|
||||||
|
contrast: 50,
|
||||||
|
sharpness: 50,
|
||||||
|
irCutFilter: "AUTO",
|
||||||
|
exposure: { mode: "AUTO", minExposureTime: 10, maxExposureTime: 33333, minGain: 0, maxGain: 100 },
|
||||||
|
whiteBalance: { mode: "AUTO" },
|
||||||
|
wideDynamicRange: { mode: "OFF" },
|
||||||
|
backlightCompensation: { mode: "OFF" },
|
||||||
|
extension: { extension: { extension: { noiseReduction: { level: 0.5 } } } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialEncoder() {
|
||||||
|
return {
|
||||||
|
$: { token: "VideoEncoderToken_1" },
|
||||||
|
name: "VideoEncoder_1",
|
||||||
|
encoding: "H264",
|
||||||
|
resolution: { width: 4096, height: 1860 },
|
||||||
|
quality: 3,
|
||||||
|
rateControl: { $: { ConstantBitRate: false }, frameRateLimit: 20, encodingInterval: 1, bitrateLimit: 10240 },
|
||||||
|
H264: { govLength: 50, H264Profile: "Main" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Behaviour {
|
||||||
|
/** Change what the camera stores on a write (e.g. clamp a value). */
|
||||||
|
adjust?: (written: Record<string, any>) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
setError?: Error;
|
||||||
|
optionsError?: Error;
|
||||||
|
media2?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function camera(behaviour: Behaviour = {}) {
|
||||||
|
const state = { imaging: initialImaging(), encoder: initialEncoder(), writes: [] as { method: string; value: unknown }[] };
|
||||||
|
const ok = (cb: Cb, value?: unknown) => cb(null, value);
|
||||||
|
const cam = {
|
||||||
|
media2Support: behaviour.media2 ?? false,
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
token: "Profile_1",
|
||||||
|
name: "mainStream",
|
||||||
|
videoSourceConfiguration: { sourceToken: "VideoSource_1" },
|
||||||
|
videoEncoderConfiguration: { $: { token: "VideoEncoderToken_1" } },
|
||||||
|
},
|
||||||
|
{ token: "Profile_bare" },
|
||||||
|
],
|
||||||
|
getImagingSettings: (_o: unknown, cb: Cb) => ok(cb, structuredClone(state.imaging)),
|
||||||
|
getVideoSourceOptions: (_o: unknown, cb: Cb) =>
|
||||||
|
behaviour.optionsError ? cb(behaviour.optionsError) : ok(cb, imagingOptions),
|
||||||
|
setImagingSettings: (value: Record<string, unknown>, cb: Cb) => {
|
||||||
|
state.writes.push({ method: "setImagingSettings", value: structuredClone(value) });
|
||||||
|
if (behaviour.setError) return cb(behaviour.setError);
|
||||||
|
const { token: _token, ...settings } = value; // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
|
state.imaging = structuredClone(settings) as ReturnType<typeof initialImaging>;
|
||||||
|
behaviour.adjust?.(state.imaging);
|
||||||
|
ok(cb, "");
|
||||||
|
},
|
||||||
|
getVideoEncoderConfiguration: (_t: string, cb: Cb) => ok(cb, structuredClone(state.encoder)),
|
||||||
|
getVideoEncoderConfigurationOptions: (_o: unknown, cb: Cb) =>
|
||||||
|
behaviour.optionsError ? cb(behaviour.optionsError) : ok(cb, encoderOptions),
|
||||||
|
setVideoEncoderConfiguration: (value: Record<string, unknown>, cb: Cb) => {
|
||||||
|
state.writes.push({ method: "setVideoEncoderConfiguration", value: structuredClone(value) });
|
||||||
|
if (behaviour.setError) return cb(behaviour.setError);
|
||||||
|
state.encoder = structuredClone(value) as ReturnType<typeof initialEncoder>;
|
||||||
|
behaviour.adjust?.(state.encoder);
|
||||||
|
ok(cb, structuredClone(state.encoder));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fake.connect.mockResolvedValue(cam);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fake.connect.mockReset();
|
||||||
|
fake.resetConnection.mockReset();
|
||||||
|
fake.appendAudit.mockReset().mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const lastAudit = () => fake.appendAudit.mock.calls.at(-1)![0];
|
||||||
|
|
||||||
|
// --- image ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("updateImageSettings", () => {
|
||||||
|
it("merges the change into the full current settings, writes, re-reads and audits", async () => {
|
||||||
|
const state = camera();
|
||||||
|
const result = await config.updateImageSettings(target, { brightness: 0, dayNight: "ON" }, "admin");
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
outcome: "applied",
|
||||||
|
// Fields come back in the schema's order.
|
||||||
|
fields: [
|
||||||
|
{ field: "dayNight", requested: "ON", actual: "ON", applied: true },
|
||||||
|
{ field: "brightness", requested: 0, actual: 0, applied: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Everything else, including exposure limits, zero gain and noise reduction, is resent as-is.
|
||||||
|
expect(state.writes[0].value).toEqual({
|
||||||
|
...initialImaging(),
|
||||||
|
brightness: 0,
|
||||||
|
irCutFilter: "ON",
|
||||||
|
token: "VideoSource_1",
|
||||||
|
});
|
||||||
|
expect(lastAudit()).toEqual({
|
||||||
|
actor: "admin",
|
||||||
|
cameraId: "cam-1",
|
||||||
|
section: "image",
|
||||||
|
outcome: "applied",
|
||||||
|
changes: [
|
||||||
|
{ field: "dayNight", from: "AUTO", to: "ON", actual: "ON" },
|
||||||
|
{ field: "brightness", from: 50, to: 0, actual: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes nested modes and levels (WDR, backlight compensation, exposure, white balance)", async () => {
|
||||||
|
const state = camera();
|
||||||
|
await config.updateImageSettings(
|
||||||
|
target,
|
||||||
|
{ wideDynamicRange: "ON", wideDynamicRangeLevel: 60, backlightCompensation: "ON", backlightCompensationLevel: 0, exposure: "MANUAL", whiteBalance: "MANUAL", saturation: 40, contrast: 55, sharpness: 70 },
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(state.imaging).toMatchObject({
|
||||||
|
wideDynamicRange: { mode: "ON", level: 60 },
|
||||||
|
backlightCompensation: { mode: "ON", level: 0 },
|
||||||
|
exposure: { mode: "MANUAL", minGain: 0 },
|
||||||
|
whiteBalance: { mode: "MANUAL" },
|
||||||
|
colorSaturation: 40,
|
||||||
|
contrast: 55,
|
||||||
|
sharpness: 70,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("manual exposure (fnd-qe3heme)", () => {
|
||||||
|
it("sets exposure time and gain together with switching to Manual", async () => {
|
||||||
|
const state = camera();
|
||||||
|
const result = await config.updateImageSettings(
|
||||||
|
target,
|
||||||
|
{ exposure: "MANUAL", exposureTime: 33333, gain: 0 },
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ ok: true, outcome: "applied" });
|
||||||
|
// The camera's auto limits are kept alongside the manual values.
|
||||||
|
expect(state.imaging.exposure).toEqual({
|
||||||
|
mode: "MANUAL",
|
||||||
|
minExposureTime: 10,
|
||||||
|
maxExposureTime: 33333,
|
||||||
|
minGain: 0,
|
||||||
|
maxGain: 100,
|
||||||
|
exposureTime: 33333,
|
||||||
|
gain: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adjusts exposure time on a camera already in Manual", async () => {
|
||||||
|
const state = camera();
|
||||||
|
state.imaging.exposure.mode = "MANUAL";
|
||||||
|
expect(await config.updateImageSettings(target, { exposureTime: 10000 }, "admin")).toMatchObject({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ exposureTime: 20000 }],
|
||||||
|
[{ gain: 10 }],
|
||||||
|
[{ exposure: "AUTO", gain: 10 }],
|
||||||
|
])("refuses %j while exposure is (or becomes) Auto", async (patch) => {
|
||||||
|
const state = camera();
|
||||||
|
expect(await config.updateImageSettings(target, patch, "admin")).toMatchObject({
|
||||||
|
reason: "invalid",
|
||||||
|
issues: [config.MANUAL_ONLY],
|
||||||
|
});
|
||||||
|
expect(state.writes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ exposure: "MANUAL", exposureTime: 5 }, "Exposure time must be between 10 and 1000000"],
|
||||||
|
[{ exposure: "MANUAL", gain: 101 }, "Gain must be between 0 and 100"],
|
||||||
|
])("checks %j against the camera's ranges", async (patch, issue) => {
|
||||||
|
camera();
|
||||||
|
expect(await config.updateImageSettings(target, patch, "admin")).toMatchObject({ issues: [issue] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the exposure ranges into the options", () => {
|
||||||
|
expect(config.parseImageOptions(imagingOptions)).toMatchObject({
|
||||||
|
exposureTime: { min: 10, max: 1000000 },
|
||||||
|
gain: { min: 0, max: 100 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a value the camera adjusted", async () => {
|
||||||
|
camera({ adjust: (s) => void (s.brightness = 90) });
|
||||||
|
const result = await config.updateImageSettings(target, { brightness: 95 }, "admin");
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
outcome: "adjusted",
|
||||||
|
fields: [{ field: "brightness", requested: 95, actual: 90, applied: false }],
|
||||||
|
});
|
||||||
|
expect(lastAudit()).toMatchObject({ outcome: "adjusted" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ brightness: 101 }, "Brightness must be between 0 and 100"],
|
||||||
|
[{ dayNight: "SOMETIMES" }, "Day/night must be one of ON, OFF, AUTO"],
|
||||||
|
[{ wideDynamicRangeLevel: -1 }, "Wide dynamic range level must be between 0 and 100"],
|
||||||
|
])("rejects %j against the camera's options without writing", async (patch, issue) => {
|
||||||
|
const state = camera();
|
||||||
|
expect(await config.updateImageSettings(target, patch, "admin")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "invalid",
|
||||||
|
message: MESSAGES.invalid,
|
||||||
|
issues: [issue],
|
||||||
|
});
|
||||||
|
expect(state.writes).toHaveLength(0);
|
||||||
|
expect(fake.appendAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["an unknown field", { hue: 3 }],
|
||||||
|
["a wrong type", { brightness: "50" }],
|
||||||
|
["not an object", "brightness=50"],
|
||||||
|
])("rejects %s before contacting the camera", async (_l, input) => {
|
||||||
|
const result = await config.updateImageSettings(target, input, "admin");
|
||||||
|
expect(result).toMatchObject({ ok: false, reason: "invalid" });
|
||||||
|
expect(fake.connect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty change", async () => {
|
||||||
|
expect(await config.updateImageSettings(target, {}, "admin")).toMatchObject({
|
||||||
|
reason: "invalid",
|
||||||
|
issues: ["Nothing to change"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a setting the camera doesn't offer", async () => {
|
||||||
|
const state = camera();
|
||||||
|
const cam = await fake.connect();
|
||||||
|
cam.getVideoSourceOptions = (_o: unknown, cb: Cb) => cb(null, { irCutFilterModes: "AUTO" });
|
||||||
|
fake.connect.mockResolvedValue(cam);
|
||||||
|
expect(await config.updateImageSettings(target, { brightness: 10 }, "admin")).toMatchObject({
|
||||||
|
reason: "invalid",
|
||||||
|
issues: ["Brightness can't be changed on this camera"],
|
||||||
|
});
|
||||||
|
expect(state.writes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for the default video source when no profile names one", async () => {
|
||||||
|
camera();
|
||||||
|
const cam = await fake.connect();
|
||||||
|
cam.profiles = [{ token: "p" }];
|
||||||
|
const tokens: unknown[] = [];
|
||||||
|
const get = cam.getImagingSettings;
|
||||||
|
cam.getImagingSettings = (o: { token?: string }, cb: Cb) => {
|
||||||
|
tokens.push(o.token);
|
||||||
|
get(o, cb);
|
||||||
|
};
|
||||||
|
fake.connect.mockResolvedValue(cam);
|
||||||
|
await config.updateImageSettings(target, { brightness: 10 }, "admin");
|
||||||
|
expect(tokens[0]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a section is unsupported when its options can't be read", async () => {
|
||||||
|
camera({ optionsError: new Error("ActionNotSupported") });
|
||||||
|
expect(await config.updateImageSettings(target, { brightness: 10 }, "admin")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "unsupported",
|
||||||
|
message: MESSAGES.unsupported,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a refusal because of the login's access level, with fixed text, and audits it", async () => {
|
||||||
|
camera({ setError: new Error("ONVIF SOAP Fault: Sender not Authorized — http://admin:hunter2@cam") });
|
||||||
|
const result = await config.updateImageSettings(target, { brightness: 10 }, "admin");
|
||||||
|
expect(result).toEqual({ ok: false, reason: "not-permitted", message: MESSAGES.notPermitted });
|
||||||
|
expect(JSON.stringify(result)).not.toContain("hunter2");
|
||||||
|
expect(lastAudit()).toMatchObject({
|
||||||
|
outcome: "not-permitted",
|
||||||
|
changes: [{ field: "brightness", from: 50, to: 10, actual: undefined }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports other write failures as failed, with fixed text", async () => {
|
||||||
|
camera({ setError: new Error("ONVIF SOAP Fault: InvalidArgVal") });
|
||||||
|
expect(await config.updateImageSettings(target, { brightness: 10 }, "admin")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "failed",
|
||||||
|
message: MESSAGES.failed,
|
||||||
|
});
|
||||||
|
expect(lastAudit()).toMatchObject({ outcome: "failed" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets login and connection errors propagate for the page to explain", async () => {
|
||||||
|
const err = new Error("login rejected");
|
||||||
|
fake.connect.mockRejectedValue(err);
|
||||||
|
await expect(config.updateImageSettings(target, { brightness: 1 }, "admin")).rejects.toBe(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- streams ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("updateStreamSettings", () => {
|
||||||
|
it("merges into the current encoder configuration, writes, audits, and drops the stale connection", async () => {
|
||||||
|
const state = camera();
|
||||||
|
const result = await config.updateStreamSettings(
|
||||||
|
target,
|
||||||
|
"Profile_1",
|
||||||
|
{ width: 2560, height: 1160, fps: 15, bitrateKbps: 4096, constantBitRate: true, gop: 30, quality: 4, h264Profile: "High" },
|
||||||
|
"admin",
|
||||||
|
);
|
||||||
|
expect(result).toMatchObject({ ok: true, outcome: "applied" });
|
||||||
|
expect(state.writes[0].value).toEqual({
|
||||||
|
...initialEncoder(),
|
||||||
|
resolution: { width: 2560, height: 1160 },
|
||||||
|
quality: 4,
|
||||||
|
rateControl: { $: { ConstantBitRate: true }, frameRateLimit: 15, encodingInterval: 1, bitrateLimit: 4096 },
|
||||||
|
H264: { govLength: 30, H264Profile: "High" },
|
||||||
|
});
|
||||||
|
expect(fake.resetConnection).toHaveBeenCalledWith(target);
|
||||||
|
expect(lastAudit()).toMatchObject({ section: "stream:Profile_1", outcome: "applied" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the camera adjusting a bitrate it can't reach", async () => {
|
||||||
|
camera({ adjust: (c) => void (c.rateControl.bitrateLimit = 8192) });
|
||||||
|
const result = await config.updateStreamSettings(target, "Profile_1", { bitrateKbps: 12000 }, "admin");
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: true,
|
||||||
|
outcome: "adjusted",
|
||||||
|
fields: [{ field: "bitrateKbps", requested: 12000, actual: 8192, applied: false }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a camera echoing booleans as text as applied", async () => {
|
||||||
|
camera({ adjust: (c) => void (c.rateControl.$.ConstantBitRate = String(c.rateControl.$.ConstantBitRate)) });
|
||||||
|
for (const constantBitRate of [true, false]) {
|
||||||
|
expect(
|
||||||
|
await config.updateStreamSettings(target, "Profile_1", { constantBitRate }, "admin"),
|
||||||
|
).toMatchObject({ ok: true, outcome: "applied" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ width: 1920, height: 1080 }, "Resolution 1920×1080 isn't supported"],
|
||||||
|
[{ fps: 30 }, "Frame rate must be between 1 and 25"],
|
||||||
|
[{ gop: 500 }, "Keyframe interval must be between 1 and 400"],
|
||||||
|
[{ quality: 9 }, "Quality must be between 1 and 6"],
|
||||||
|
[{ bitrateKbps: 20000 }, "Bitrate must be between 32 and 16384"],
|
||||||
|
[{ h264Profile: "Extended" }, "H.264 profile must be one of Baseline, Main, High"],
|
||||||
|
])("rejects %j against the camera's options without writing", async (patch, issue) => {
|
||||||
|
const state = camera();
|
||||||
|
expect(await config.updateStreamSettings(target, "Profile_1", patch, "admin")).toMatchObject({
|
||||||
|
reason: "invalid",
|
||||||
|
issues: [issue],
|
||||||
|
});
|
||||||
|
expect(state.writes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires width and height together", async () => {
|
||||||
|
expect(await config.updateStreamSettings(target, "Profile_1", { width: 2560 }, "admin")).toMatchObject({
|
||||||
|
reason: "invalid",
|
||||||
|
issues: ["Width and height must be changed together"],
|
||||||
|
});
|
||||||
|
expect(fake.connect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty change", async () => {
|
||||||
|
expect(await config.updateStreamSettings(target, "Profile_1", {}, "admin")).toMatchObject({
|
||||||
|
issues: ["Nothing to change"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Profile_9", "Profile_bare"])("rejects stream %s that has no encoder configuration", async (token) => {
|
||||||
|
camera();
|
||||||
|
expect(await config.updateStreamSettings(target, token, { fps: 10 }, "admin")).toMatchObject({
|
||||||
|
reason: "invalid",
|
||||||
|
issues: [MESSAGES.unknownStream],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't try Media2 cameras yet", async () => {
|
||||||
|
camera({ media2: true });
|
||||||
|
expect(await config.updateStreamSettings(target, "Profile_1", { fps: 10 }, "admin")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "unsupported",
|
||||||
|
message: MESSAGES.unsupported,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says unsupported when the options can't be read", async () => {
|
||||||
|
camera({ optionsError: new Error("nope") });
|
||||||
|
expect(await config.updateStreamSettings(target, "Profile_1", { fps: 10 }, "admin")).toMatchObject({
|
||||||
|
reason: "unsupported",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a not-permitted refusal and still drops the connection", async () => {
|
||||||
|
camera({ setError: new Error("HTTP 401 NotAuthorized") });
|
||||||
|
expect(await config.updateStreamSettings(target, "Profile_1", { fps: 10 }, "admin")).toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "not-permitted",
|
||||||
|
message: MESSAGES.notPermitted,
|
||||||
|
});
|
||||||
|
expect(fake.resetConnection).toHaveBeenCalledWith(target);
|
||||||
|
expect(lastAudit()).toMatchObject({ section: "stream:Profile_1", outcome: "not-permitted" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- options ----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("readConfigOptions", () => {
|
||||||
|
it("returns image and per-stream options for the edit forms", async () => {
|
||||||
|
camera();
|
||||||
|
expect(await config.readConfigOptions(target)).toEqual({
|
||||||
|
image: {
|
||||||
|
dayNight: ["ON", "OFF", "AUTO"],
|
||||||
|
exposure: ["AUTO", "MANUAL"],
|
||||||
|
whiteBalance: ["AUTO", "MANUAL"],
|
||||||
|
exposureTime: { min: 10, max: 1000000 },
|
||||||
|
gain: { min: 0, max: 100 },
|
||||||
|
brightness: { min: 0, max: 100 },
|
||||||
|
contrast: { min: 0, max: 100 },
|
||||||
|
saturation: { min: 0, max: 100 },
|
||||||
|
sharpness: { min: 0, max: 100 },
|
||||||
|
wideDynamicRange: { modes: ["OFF", "ON"], level: { min: 0, max: 100 } },
|
||||||
|
backlightCompensation: { modes: ["OFF", "ON"], level: { min: 0, max: 100 } },
|
||||||
|
},
|
||||||
|
streams: [
|
||||||
|
{
|
||||||
|
token: "Profile_1",
|
||||||
|
resolutions: [
|
||||||
|
{ width: 4096, height: 1860 },
|
||||||
|
{ width: 2560, height: 1160 },
|
||||||
|
],
|
||||||
|
fps: { min: 1, max: 25 },
|
||||||
|
gop: { min: 1, max: 400 },
|
||||||
|
quality: { min: 1, max: 6 },
|
||||||
|
bitrateKbps: { min: 32, max: 16384 },
|
||||||
|
h264Profiles: ["Baseline", "Main", "High"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null image options and no streams when they can't be read", async () => {
|
||||||
|
camera({ optionsError: new Error("nope") });
|
||||||
|
expect(await config.readConfigOptions(target)).toEqual({ image: null, streams: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips streams on Media2 cameras", async () => {
|
||||||
|
camera({ media2: true });
|
||||||
|
expect((await config.readConfigOptions(target)).streams).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("option parsing", () => {
|
||||||
|
it("reads ONVIF's capitalized ranges, single-value lists and numbers as text", () => {
|
||||||
|
expect(
|
||||||
|
config.parseImageOptions({
|
||||||
|
brightness: { Min: "0.00", Max: "255.00" },
|
||||||
|
irCutFilterModes: "AUTO",
|
||||||
|
exposure: { mode: "AUTO" },
|
||||||
|
}),
|
||||||
|
).toMatchObject({ brightness: { min: 0, max: 255 }, dayNight: ["AUTO"], exposure: ["AUTO"], whiteBalance: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads H.264 options from the extension when that's where the camera puts them", () => {
|
||||||
|
expect(
|
||||||
|
config.parseStreamOptions("p", {
|
||||||
|
extension: {
|
||||||
|
H264: {
|
||||||
|
resolutionsAvailable: { width: "1920", height: "1080" },
|
||||||
|
frameRateRange: { min: 1, max: 30 },
|
||||||
|
H264ProfilesSupported: "Main",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
token: "p",
|
||||||
|
resolutions: [{ width: 1920, height: 1080 }],
|
||||||
|
fps: { min: 1, max: 30 },
|
||||||
|
gop: undefined,
|
||||||
|
quality: undefined,
|
||||||
|
bitrateKbps: undefined,
|
||||||
|
h264Profiles: ["Main"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows any bitrate when the camera doesn't report a range", () => {
|
||||||
|
expect(config.validateStreamPatch({ bitrateKbps: 99999 }, config.parseStreamOptions("p", {}))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops resolutions with missing sizes and tolerates no options at all", () => {
|
||||||
|
expect(config.parseStreamOptions("p", { H264: { resolutionsAvailable: [{ width: 1 }] } }).resolutions).toEqual([]);
|
||||||
|
expect(config.parseImageOptions(undefined)).toMatchObject({ dayNight: [], brightness: undefined });
|
||||||
|
});
|
||||||
|
});
|
||||||
468
src/lib/camera-config.ts
Normal file
468
src/lib/camera-config.ts
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
import "server-only";
|
||||||
|
import type { CamProfile } from "onvif";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { appendAudit, type AuditChange } from "./audit-log";
|
||||||
|
import { connect, encoderConfig, profileToken, resetConnection, type CameraTarget } from "./camera";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes a camera's image and stream settings (vrek gol-8v1wqvp, dec-84ghhqr, dec-n4e8jah).
|
||||||
|
*
|
||||||
|
* Every write follows the same steps: read what the camera allows, validate the change
|
||||||
|
* against it, merge the change into the camera's *current* settings (so nothing else is
|
||||||
|
* reset), write, re-read, and report per field whether the camera applied the requested
|
||||||
|
* value or adjusted it. Each attempted write is recorded in the audit log.
|
||||||
|
*
|
||||||
|
* Writes use the fixed setters from the onvif fork (dec-70qga6k), which send complete,
|
||||||
|
* schema-ordered settings including zero values. Errors that reach the UI carry fixed text
|
||||||
|
* only (pri-tyrxdz9); login and connection errors propagate for the page to explain.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Range {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageOptions {
|
||||||
|
dayNight: string[];
|
||||||
|
exposure: string[];
|
||||||
|
whiteBalance: string[];
|
||||||
|
brightness?: Range;
|
||||||
|
contrast?: Range;
|
||||||
|
saturation?: Range;
|
||||||
|
sharpness?: Range;
|
||||||
|
/** Manual exposure time in microseconds, and gain; only used in Manual exposure. */
|
||||||
|
exposureTime?: Range;
|
||||||
|
gain?: Range;
|
||||||
|
wideDynamicRange?: { modes: string[]; level?: Range };
|
||||||
|
backlightCompensation?: { modes: string[]; level?: Range };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamOptions {
|
||||||
|
/** Profile token, as used by camera-settings.ts. */
|
||||||
|
token: string;
|
||||||
|
resolutions: { width: number; height: number }[];
|
||||||
|
fps?: Range;
|
||||||
|
gop?: Range;
|
||||||
|
quality?: Range;
|
||||||
|
bitrateKbps?: Range;
|
||||||
|
h264Profiles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigOptions {
|
||||||
|
image: ImageOptions | null;
|
||||||
|
streams: StreamOptions[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const level = z.number().finite();
|
||||||
|
|
||||||
|
export const imagePatchSchema = z
|
||||||
|
.object({
|
||||||
|
dayNight: z.string().optional(),
|
||||||
|
exposure: z.string().optional(),
|
||||||
|
whiteBalance: z.string().optional(),
|
||||||
|
brightness: level.optional(),
|
||||||
|
contrast: level.optional(),
|
||||||
|
saturation: level.optional(),
|
||||||
|
sharpness: level.optional(),
|
||||||
|
exposureTime: z.number().int().positive().optional(),
|
||||||
|
gain: level.optional(),
|
||||||
|
wideDynamicRange: z.string().optional(),
|
||||||
|
wideDynamicRangeLevel: level.optional(),
|
||||||
|
backlightCompensation: z.string().optional(),
|
||||||
|
backlightCompensationLevel: level.optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const streamPatchSchema = z
|
||||||
|
.object({
|
||||||
|
width: z.number().int().positive().optional(),
|
||||||
|
height: z.number().int().positive().optional(),
|
||||||
|
fps: z.number().positive().optional(),
|
||||||
|
bitrateKbps: z.number().int().positive().optional(),
|
||||||
|
constantBitRate: z.boolean().optional(),
|
||||||
|
gop: z.number().int().positive().optional(),
|
||||||
|
quality: z.number().finite().optional(),
|
||||||
|
h264Profile: z.string().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((p) => (p.width === undefined) === (p.height === undefined), {
|
||||||
|
message: "Width and height must be changed together",
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ImagePatch = z.infer<typeof imagePatchSchema>;
|
||||||
|
export type StreamPatch = z.infer<typeof streamPatchSchema>;
|
||||||
|
|
||||||
|
export interface FieldOutcome {
|
||||||
|
field: string;
|
||||||
|
requested: unknown;
|
||||||
|
/** What the camera reports after the write. */
|
||||||
|
actual: unknown;
|
||||||
|
applied: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WriteResult =
|
||||||
|
| { ok: true; outcome: "applied" | "adjusted"; fields: FieldOutcome[] }
|
||||||
|
| { ok: false; reason: "invalid"; message: string; issues: string[] }
|
||||||
|
| { ok: false; reason: "not-permitted" | "unsupported" | "failed"; message: string };
|
||||||
|
|
||||||
|
export const MESSAGES = {
|
||||||
|
invalid: "Some values aren't supported by this camera",
|
||||||
|
notPermitted:
|
||||||
|
"The camera's saved login isn't allowed to change these settings. Give its ONVIF user a higher access level on the camera to enable this.",
|
||||||
|
unsupported: "This camera doesn't support changing these settings from here",
|
||||||
|
failed: "The camera didn't accept the change",
|
||||||
|
unknownStream: "Unknown stream",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// --- small helpers -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function range(v: Loose | undefined): Range | undefined {
|
||||||
|
const min = num(v?.min ?? v?.Min);
|
||||||
|
const max = num(v?.max ?? v?.Max);
|
||||||
|
return min !== undefined && max !== undefined ? { min, max } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const strings = (v: unknown): string[] => toArray(v).filter((s): s is string => typeof s === "string");
|
||||||
|
|
||||||
|
function getPath(obj: Loose | undefined, path: readonly string[]): unknown {
|
||||||
|
return path.reduce<unknown>((o, key) => (o && typeof o === "object" ? (o as Loose)[key] : undefined), obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPath(obj: Loose, path: readonly string[], value: unknown): void {
|
||||||
|
let o = obj;
|
||||||
|
for (const key of path.slice(0, -1)) {
|
||||||
|
if (!o[key] || typeof o[key] !== "object") o[key] = {};
|
||||||
|
o = o[key];
|
||||||
|
}
|
||||||
|
o[path[path.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function same(a: unknown, b: unknown): boolean {
|
||||||
|
if (typeof a === "boolean" || typeof b === "boolean") return bool(a) === bool(b);
|
||||||
|
const na = num(a);
|
||||||
|
const nb = num(b);
|
||||||
|
if (na !== undefined && nb !== undefined) return na === nb;
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
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))));
|
||||||
|
|
||||||
|
/** A write refused because of the login's access level rather than a bad value. */
|
||||||
|
const NOT_PERMITTED = /not ?authori[sz]ed|sender not authori[sz]ed|not permitted|permission|forbidden|\b40[13]\b/i;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- options -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function parseImageOptions(o: Loose | undefined): ImageOptions {
|
||||||
|
const wdr = o?.wideDynamicRange;
|
||||||
|
const blc = o?.backlightCompensation;
|
||||||
|
return {
|
||||||
|
dayNight: strings(o?.irCutFilterModes),
|
||||||
|
exposure: strings(o?.exposure?.mode),
|
||||||
|
whiteBalance: strings(o?.whiteBalance?.mode),
|
||||||
|
brightness: range(o?.brightness),
|
||||||
|
contrast: range(o?.contrast),
|
||||||
|
saturation: range(o?.colorSaturation),
|
||||||
|
sharpness: range(o?.sharpness),
|
||||||
|
exposureTime: range(o?.exposure?.exposureTime),
|
||||||
|
gain: range(o?.exposure?.gain),
|
||||||
|
wideDynamicRange: wdr ? { modes: strings(wdr.mode), level: range(wdr.level) } : undefined,
|
||||||
|
backlightCompensation: blc ? { modes: strings(blc.mode), level: range(blc.level) } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStreamOptions(token: string, o: Loose | undefined): StreamOptions {
|
||||||
|
const h264: Loose = o?.H264 ?? {};
|
||||||
|
const h264ext: Loose = o?.extension?.H264 ?? {};
|
||||||
|
const resolutions = toArray<Loose>(h264.resolutionsAvailable ?? h264ext.resolutionsAvailable)
|
||||||
|
.map((r) => ({ width: num(r.width), height: num(r.height) }))
|
||||||
|
.filter((r): r is { width: number; height: number } => r.width !== undefined && r.height !== undefined);
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
resolutions,
|
||||||
|
fps: range(h264.frameRateRange ?? h264ext.frameRateRange),
|
||||||
|
gop: range(h264.govLengthRange ?? h264ext.govLengthRange),
|
||||||
|
quality: range(o?.qualityRange),
|
||||||
|
bitrateKbps: range(h264ext.bitrateRange ?? h264.bitrateRange),
|
||||||
|
h264Profiles: strings(h264.H264ProfilesSupported ?? h264ext.H264ProfilesSupported),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What each section may be changed to, for the edit forms. Sections that can't be read are null/empty. */
|
||||||
|
export async function readConfigOptions(target: CameraTarget): Promise<ConfigOptions> {
|
||||||
|
const cam = await connect(target);
|
||||||
|
const profiles = cam.profiles ?? [];
|
||||||
|
const cam2 = cam as unknown as Loose;
|
||||||
|
const image = await call<Loose>((cb) =>
|
||||||
|
cam2.getVideoSourceOptions({ token: videoSourceToken(profiles) }, cb),
|
||||||
|
).then(parseImageOptions, (err) => {
|
||||||
|
console.warn("[config] image options unavailable:", err?.message ?? err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const streams: StreamOptions[] = [];
|
||||||
|
for (const p of profiles) {
|
||||||
|
const token = profileToken(p);
|
||||||
|
const configurationToken = encoderToken(p);
|
||||||
|
if (!configurationToken || cam.media2Support) continue;
|
||||||
|
const o = await call<Loose>((cb) =>
|
||||||
|
cam2.getVideoEncoderConfigurationOptions({ configurationToken, profileToken: token }, cb),
|
||||||
|
).catch((err) => {
|
||||||
|
console.warn(`[config] stream options for ${token} unavailable:`, err?.message ?? err);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
if (o) streams.push(parseStreamOptions(token, o));
|
||||||
|
}
|
||||||
|
return { image, streams };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encoderToken(p: CamProfile): string | undefined {
|
||||||
|
const enc: Loose | undefined = encoderConfig(p);
|
||||||
|
return enc?.$?.token ?? enc?.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- validation against the camera's options ----------------------------------------------------
|
||||||
|
|
||||||
|
function inRange(issues: string[], label: string, value: number | undefined, r: Range | undefined) {
|
||||||
|
if (value === undefined) return;
|
||||||
|
if (!r) issues.push(`${label} can't be changed on this camera`);
|
||||||
|
else if (value < r.min || value > r.max) issues.push(`${label} must be between ${r.min} and ${r.max}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function oneOf(issues: string[], label: string, value: string | undefined, allowed: string[] | undefined) {
|
||||||
|
if (value === undefined) return;
|
||||||
|
if (!allowed?.length) issues.push(`${label} can't be changed on this camera`);
|
||||||
|
else if (!allowed.includes(value)) issues.push(`${label} must be one of ${allowed.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MANUAL_ONLY = "Exposure time and gain only apply in Manual exposure";
|
||||||
|
|
||||||
|
/** `currentExposure` is the camera's exposure mode before the change. */
|
||||||
|
export function validateImagePatch(p: ImagePatch, o: ImageOptions, currentExposure?: string): string[] {
|
||||||
|
const issues: string[] = [];
|
||||||
|
oneOf(issues, "Day/night", p.dayNight, o.dayNight);
|
||||||
|
oneOf(issues, "Exposure", p.exposure, o.exposure);
|
||||||
|
oneOf(issues, "White balance", p.whiteBalance, o.whiteBalance);
|
||||||
|
inRange(issues, "Brightness", p.brightness, o.brightness);
|
||||||
|
inRange(issues, "Contrast", p.contrast, o.contrast);
|
||||||
|
inRange(issues, "Saturation", p.saturation, o.saturation);
|
||||||
|
inRange(issues, "Sharpness", p.sharpness, o.sharpness);
|
||||||
|
inRange(issues, "Exposure time", p.exposureTime, o.exposureTime);
|
||||||
|
inRange(issues, "Gain", p.gain, o.gain);
|
||||||
|
if ((p.exposureTime !== undefined || p.gain !== undefined) && (p.exposure ?? currentExposure) !== "MANUAL") {
|
||||||
|
issues.push(MANUAL_ONLY);
|
||||||
|
}
|
||||||
|
oneOf(issues, "Wide dynamic range", p.wideDynamicRange, o.wideDynamicRange?.modes);
|
||||||
|
inRange(issues, "Wide dynamic range level", p.wideDynamicRangeLevel, o.wideDynamicRange?.level);
|
||||||
|
oneOf(issues, "Backlight compensation", p.backlightCompensation, o.backlightCompensation?.modes);
|
||||||
|
inRange(issues, "Backlight compensation level", p.backlightCompensationLevel, o.backlightCompensation?.level);
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateStreamPatch(p: StreamPatch, o: StreamOptions): string[] {
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (p.width !== undefined && p.height !== undefined) {
|
||||||
|
if (!o.resolutions.some((r) => r.width === p.width && r.height === p.height)) {
|
||||||
|
issues.push(`Resolution ${p.width}×${p.height} isn't supported`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inRange(issues, "Frame rate", p.fps, o.fps);
|
||||||
|
inRange(issues, "Keyframe interval", p.gop, o.gop);
|
||||||
|
inRange(issues, "Quality", p.quality, o.quality);
|
||||||
|
// Cameras must adapt an out-of-range bitrate themselves (ONVIF Media), so only check a known range.
|
||||||
|
if (p.bitrateKbps !== undefined && o.bitrateKbps) inRange(issues, "Bitrate", p.bitrateKbps, o.bitrateKbps);
|
||||||
|
oneOf(issues, "H.264 profile", p.h264Profile, o.h264Profiles);
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- writes -------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Where each patch field lives in the settings the camera returns (and the fork's setters accept). */
|
||||||
|
const IMAGE_FIELDS: Record<keyof ImagePatch, readonly string[]> = {
|
||||||
|
dayNight: ["irCutFilter"],
|
||||||
|
exposure: ["exposure", "mode"],
|
||||||
|
whiteBalance: ["whiteBalance", "mode"],
|
||||||
|
brightness: ["brightness"],
|
||||||
|
contrast: ["contrast"],
|
||||||
|
saturation: ["colorSaturation"],
|
||||||
|
sharpness: ["sharpness"],
|
||||||
|
exposureTime: ["exposure", "exposureTime"],
|
||||||
|
gain: ["exposure", "gain"],
|
||||||
|
wideDynamicRange: ["wideDynamicRange", "mode"],
|
||||||
|
wideDynamicRangeLevel: ["wideDynamicRange", "level"],
|
||||||
|
backlightCompensation: ["backlightCompensation", "mode"],
|
||||||
|
backlightCompensationLevel: ["backlightCompensation", "level"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const STREAM_FIELDS: Record<keyof StreamPatch, readonly string[]> = {
|
||||||
|
width: ["resolution", "width"],
|
||||||
|
height: ["resolution", "height"],
|
||||||
|
fps: ["rateControl", "frameRateLimit"],
|
||||||
|
bitrateKbps: ["rateControl", "bitrateLimit"],
|
||||||
|
constantBitRate: ["rateControl", "$", "ConstantBitRate"],
|
||||||
|
gop: ["H264", "govLength"],
|
||||||
|
quality: ["quality"],
|
||||||
|
h264Profile: ["H264", "H264Profile"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function merge<P extends object>(current: Loose, patch: P, fields: Record<keyof P, readonly string[]>): Loose {
|
||||||
|
const next: Loose = structuredClone(current);
|
||||||
|
for (const [field, value] of Object.entries(patch)) {
|
||||||
|
if (value !== undefined) setPath(next, fields[field as keyof P], value);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare<P extends object>(
|
||||||
|
before: Loose,
|
||||||
|
patch: P,
|
||||||
|
after: Loose | undefined,
|
||||||
|
fields: Record<keyof P, readonly string[]>,
|
||||||
|
): { outcomes: FieldOutcome[]; changes: AuditChange[] } {
|
||||||
|
const outcomes: FieldOutcome[] = [];
|
||||||
|
const changes: AuditChange[] = [];
|
||||||
|
for (const [field, requested] of Object.entries(patch)) {
|
||||||
|
if (requested === undefined) continue;
|
||||||
|
const path = fields[field as keyof P];
|
||||||
|
const actual = getPath(after, path);
|
||||||
|
outcomes.push({ field, requested, actual, applied: same(actual, requested) });
|
||||||
|
changes.push({ field, from: getPath(before, path), to: requested, actual });
|
||||||
|
}
|
||||||
|
return { outcomes, changes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refused(
|
||||||
|
err: unknown,
|
||||||
|
audit: { actor: string; cameraId: string; section: string; changes: AuditChange[] },
|
||||||
|
): Promise<WriteResult> {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const notPermitted = NOT_PERMITTED.test(message);
|
||||||
|
console.warn(`[config] ${audit.section} write on ${audit.cameraId} ${notPermitted ? "not permitted" : "failed"}:`, message);
|
||||||
|
await appendAudit({ ...audit, outcome: notPermitted ? "not-permitted" : "failed" });
|
||||||
|
return notPermitted
|
||||||
|
? { ok: false, reason: "not-permitted", message: MESSAGES.notPermitted }
|
||||||
|
: { ok: false, reason: "failed", message: MESSAGES.failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(issues: string[]): WriteResult {
|
||||||
|
return { ok: false, reason: "invalid", message: MESSAGES.invalid, issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function done(
|
||||||
|
outcomes: FieldOutcome[],
|
||||||
|
audit: { actor: string; cameraId: string; section: string; changes: AuditChange[] },
|
||||||
|
): Promise<WriteResult> {
|
||||||
|
const outcome = outcomes.every((f) => f.applied) ? "applied" : "adjusted";
|
||||||
|
await appendAudit({ ...audit, outcome });
|
||||||
|
return { ok: true, outcome, fields: outcomes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateImageSettings(
|
||||||
|
target: CameraTarget,
|
||||||
|
input: unknown,
|
||||||
|
actor: string,
|
||||||
|
): Promise<WriteResult> {
|
||||||
|
const parsed = imagePatchSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return invalid(parsed.error.issues.map((i) => i.message));
|
||||||
|
const patch = parsed.data;
|
||||||
|
if (Object.values(patch).every((v) => v === undefined)) return invalid(["Nothing to change"]);
|
||||||
|
|
||||||
|
const cam = (await connect(target)) as unknown as Loose;
|
||||||
|
const token = videoSourceToken(cam.profiles ?? []);
|
||||||
|
let current: Loose;
|
||||||
|
let options: ImageOptions;
|
||||||
|
try {
|
||||||
|
current = (await call<Loose>((cb) => cam.getImagingSettings({ token }, cb))) ?? {};
|
||||||
|
options = parseImageOptions(await call<Loose>((cb) => cam.getVideoSourceOptions({ token }, cb)));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[config] image settings unavailable:", err instanceof Error ? err.message : err);
|
||||||
|
return { ok: false, reason: "unsupported", message: MESSAGES.unsupported };
|
||||||
|
}
|
||||||
|
const issues = validateImagePatch(patch, options, current.exposure?.mode);
|
||||||
|
if (issues.length) return invalid(issues);
|
||||||
|
|
||||||
|
const next = merge(current, patch, IMAGE_FIELDS);
|
||||||
|
const audit = { actor, cameraId: target.id, section: "image" };
|
||||||
|
try {
|
||||||
|
await call((cb) => cam.setImagingSettings({ ...next, token }, cb));
|
||||||
|
} catch (err) {
|
||||||
|
return refused(err, { ...audit, changes: compare(current, patch, undefined, IMAGE_FIELDS).changes });
|
||||||
|
}
|
||||||
|
const after = await call<Loose>((cb) => cam.getImagingSettings({ token }, cb));
|
||||||
|
const { outcomes, changes } = compare(current, patch, after, IMAGE_FIELDS);
|
||||||
|
return done(outcomes, { ...audit, changes });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateStreamSettings(
|
||||||
|
target: CameraTarget,
|
||||||
|
streamToken: string,
|
||||||
|
input: unknown,
|
||||||
|
actor: string,
|
||||||
|
): Promise<WriteResult> {
|
||||||
|
const parsed = streamPatchSchema.safeParse(input);
|
||||||
|
if (!parsed.success) return invalid(parsed.error.issues.map((i) => i.message));
|
||||||
|
const patch = parsed.data;
|
||||||
|
if (Object.values(patch).every((v) => v === undefined)) return invalid(["Nothing to change"]);
|
||||||
|
|
||||||
|
const cam = (await connect(target)) as unknown as Loose;
|
||||||
|
const profile = (cam.profiles as CamProfile[] | undefined)?.find((p) => profileToken(p) === streamToken);
|
||||||
|
const configurationToken = profile && encoderToken(profile);
|
||||||
|
if (!profile || !configurationToken) return invalid([MESSAGES.unknownStream]);
|
||||||
|
// The fork's Media2 path isn't covered by these writers yet.
|
||||||
|
if (cam.media2Support) return { ok: false, reason: "unsupported", message: MESSAGES.unsupported };
|
||||||
|
|
||||||
|
let current: Loose;
|
||||||
|
let options: StreamOptions;
|
||||||
|
try {
|
||||||
|
current = (await call<Loose>((cb) => cam.getVideoEncoderConfiguration(configurationToken, cb))) ?? {};
|
||||||
|
options = parseStreamOptions(
|
||||||
|
streamToken,
|
||||||
|
await call<Loose>((cb) =>
|
||||||
|
cam.getVideoEncoderConfigurationOptions({ configurationToken, profileToken: streamToken }, cb),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[config] stream settings unavailable:", err instanceof Error ? err.message : err);
|
||||||
|
return { ok: false, reason: "unsupported", message: MESSAGES.unsupported };
|
||||||
|
}
|
||||||
|
const issues = validateStreamPatch(patch, options);
|
||||||
|
if (issues.length) return invalid(issues);
|
||||||
|
|
||||||
|
const next = merge(current, patch, STREAM_FIELDS);
|
||||||
|
const audit = { actor, cameraId: target.id, section: `stream:${streamToken}` };
|
||||||
|
let after: Loose | undefined;
|
||||||
|
try {
|
||||||
|
// The fork's setter re-reads the configuration itself and returns it.
|
||||||
|
after = await call<Loose>((cb) => cam.setVideoEncoderConfiguration(next, cb));
|
||||||
|
} catch (err) {
|
||||||
|
return refused(err, { ...audit, changes: compare(current, patch, undefined, STREAM_FIELDS).changes });
|
||||||
|
} finally {
|
||||||
|
// Profiles cached on the connection now describe the old encoder settings.
|
||||||
|
resetConnection(target);
|
||||||
|
}
|
||||||
|
const { outcomes, changes } = compare(current, patch, after, STREAM_FIELDS);
|
||||||
|
return done(outcomes, { ...audit, changes });
|
||||||
|
}
|
||||||
@ -344,6 +344,16 @@ describe("partial failures", () => {
|
|||||||
expect(network.ok && network.value.gateways).toEqual([]);
|
expect(network.ok && network.value.gateways).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads manual exposure time and gain when the camera reports them", async () => {
|
||||||
|
camera({
|
||||||
|
getImagingSettings: () => ({
|
||||||
|
value: { ...probeImaging, exposure: { mode: "MANUAL", exposureTime: "33333", gain: 12 } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const { image } = await getCameraSettings(target);
|
||||||
|
expect(image.ok && image.value).toMatchObject({ exposure: "MANUAL", exposureTime: 33333, gain: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
it("treats an empty imaging answer as all-unknown", async () => {
|
it("treats an empty imaging answer as all-unknown", async () => {
|
||||||
camera({ getImagingSettings: () => ({ value: undefined }) });
|
camera({ getImagingSettings: () => ({ value: undefined }) });
|
||||||
const { image } = await getCameraSettings(target);
|
const { image } = await getCameraSettings(target);
|
||||||
|
|||||||
@ -38,9 +38,14 @@ export interface ImageSettings {
|
|||||||
/** IR-cut filter mode: "AUTO" switches between day and night. */
|
/** IR-cut filter mode: "AUTO" switches between day and night. */
|
||||||
dayNight?: string;
|
dayNight?: string;
|
||||||
exposure?: string;
|
exposure?: string;
|
||||||
|
/** Manual exposure time in microseconds (only reported in Manual on some cameras). */
|
||||||
|
exposureTime?: number;
|
||||||
|
gain?: number;
|
||||||
whiteBalance?: string;
|
whiteBalance?: string;
|
||||||
wideDynamicRange?: string;
|
wideDynamicRange?: string;
|
||||||
|
wideDynamicRangeLevel?: number;
|
||||||
backlightCompensation?: string;
|
backlightCompensation?: string;
|
||||||
|
backlightCompensationLevel?: number;
|
||||||
brightness?: number;
|
brightness?: number;
|
||||||
contrast?: number;
|
contrast?: number;
|
||||||
saturation?: number;
|
saturation?: number;
|
||||||
@ -187,9 +192,13 @@ async function readImage(cam: Cam, profiles: CamProfile[]): Promise<ImageSetting
|
|||||||
return {
|
return {
|
||||||
dayNight: str(s.irCutFilter),
|
dayNight: str(s.irCutFilter),
|
||||||
exposure: str(s.exposure?.mode),
|
exposure: str(s.exposure?.mode),
|
||||||
|
exposureTime: num(s.exposure?.exposureTime),
|
||||||
|
gain: num(s.exposure?.gain),
|
||||||
whiteBalance: str(s.whiteBalance?.mode),
|
whiteBalance: str(s.whiteBalance?.mode),
|
||||||
wideDynamicRange: str(s.wideDynamicRange?.mode),
|
wideDynamicRange: str(s.wideDynamicRange?.mode),
|
||||||
|
wideDynamicRangeLevel: num(s.wideDynamicRange?.level),
|
||||||
backlightCompensation: str(s.backlightCompensation?.mode),
|
backlightCompensation: str(s.backlightCompensation?.mode),
|
||||||
|
backlightCompensationLevel: num(s.backlightCompensation?.level),
|
||||||
brightness: num(s.brightness),
|
brightness: num(s.brightness),
|
||||||
contrast: num(s.contrast),
|
contrast: num(s.contrast),
|
||||||
saturation: num(s.colorSaturation),
|
saturation: num(s.colorSaturation),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user