Show the camera's own advanced image settings over ISAPI

- Read /ISAPI/Image/channels/1 and its capabilities with digest auth and
  the camera's stored login, behind a vendor module.
- New "Advanced image" panel: shutter and its limits, gain limit, WDR,
  highlight and backlight compensation, noise reduction, white balance,
  day/night switching, lens distortion correction, digital zoom, the
  camera's lights and power line frequency.
- Availability comes from the camera's own capabilities, so only settings
  it advertises are shown; a refused login says a camera web account is
  needed, anything else says the camera doesn't offer more. Answers are
  cached per camera and path for five minutes.
- Parser and options are tested against real documents from a camera,
  kept as fixtures (imaging data only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2026-09-19 18:18:35 -05:00
parent e319b0198f
commit 8e87fd9d3d
9 changed files with 1057 additions and 2 deletions

View File

@ -922,3 +922,40 @@
{"id":"evt-cdb3qf9z1qq2","type":"node.created","subject":"fnd-5jk0ghz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"ISAPI works with an activated Operator account, but the alert stream needs more rights and main-stream stills are refused on both cameras","body":"Tested on 2026-09-19 on camera …af2e with a purpose-made Operator account, created under the camera's own User Management and activated. Results: /ISAPI/System/deviceInfo 200; /ISAPI/System/capabilities 200 (12 KB); /ISAPI/Security/userCheck 200; /ISAPI/Streaming/channels/101/picture 503 \"Device Busy\"; /ISAPI/Event/notification/alertStream 403 Forbidden (statusCode 4). Two lessons. (1) 403 on alertStream is an authorization limit, not a login problem, so the alert stream needs a higher level than Operator on this firmware; ONVIF PullPoint already gives us motion with an Operator login (dec-d50j69a), so there is no reason to raise the level for that. (2) 503 on the main-stream still appears with every account tried, on both cameras, so it is a camera or firmware limit, confirming fnd-k1jyt83. Before activation the same account returned 401 everywhere: an account that has not been activated cannot authenticate at all, which is easy to mistake for a rights problem. Also: on camera …af54 the ONVIF user \"camera\" is accepted by ISAPI (deviceInfo 200), while on …af2e it is not, so accounts and their reach differ between two cameras of the same model; the ONVIF user list and the web user list are separate, and an entry in one does not imply the other. Capabilities advertised by …af2e: 91 isSupport flags, 46 true, including MotionDetection, TamperDetection, ROI, Snapshot, PanoramaCalibration, DeviceAudioCapture, VoiceTalkEvent, ConfigFileExport/Import, OnlineUpgradeTask, UsersPermissionConfig and ONVIFUserManagement; sections cover Audio, Device, Event, IO, Network, Racm (storage/playback), Security, Serial, Smart, Test, Video and WLAlarm. That document is the map for what ISAPI could add beyond ONVIF.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"url":"http://localhost/ISAPI/System/capabilities","note":"Cameras …af2e and …af54, addresses redacted per dec-kgm44qw; read-only curl with digest auth, no settings changed"}],"as_of":"2026-09-19"}},"at":"2026-09-19T22:32:38.646Z","parents":["evt-bkz4xcsdhpg3"],"hash":"b8fba9dcd5772018e4ad46e9f7f6ae167040a8113f6372d2009586b3f96c0925"}
{"id":"evt-zx81hkmwz8tw","type":"edge.added","subject":"fnd-5jk0ghz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-5jk0ghz","to":"gol-7ajfexk"},"at":"2026-09-19T22:32:38.648Z","parents":["evt-cdb3qf9z1qq2"],"hash":"4e4b6e5e99bfc8e81e4a4410506702098ecf0a0ce5dd4c06324a84975e3a8a8a"}
{"id":"evt-vena3jmyy9t4","type":"edge.added","subject":"fnd-5jk0ghz","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-5jk0ghz","to":"iss-6f346cm"},"at":"2026-09-19T22:32:38.649Z","parents":["evt-zx81hkmwz8tw"],"hash":"d0c1d800b312001d80c2aca49ab1a6d902a5f752077a9310cd6c8d08b9317b8c"}
{"id":"evt-6ch89xwctq4h","type":"node.created","subject":"fnd-qa5n7s5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"ISAPI offers far more than ONVIF on these cameras, and an Operator account can read almost all of it","body":"Read-only survey of camera …af2e on 2026-09-19 with an activated Operator account; nothing was changed. Endpoints that answered 200: /ISAPI/System/deviceInfo, /System/capabilities, /System/time, /Security/users, /Security/userCheck, /ContentMgmt/Storage, /ContentMgmt/record/tracks, /Streaming/channels (and /101 plus its capabilities), /Image/channels/1 (plus capabilities), /System/Video/inputs/channels/1 (plus overlays), /Event/notification/httpHosts. Refused: /Event/triggers (500 Device Error), /Event/notification/alertStream (403, needs more than Operator), /Streaming/channels/101/picture (503 Device Busy, a camera limit).\n\nImaging is the big gain over ONVIF. /Image/channels/1 exposes, with ranges from its capabilities document: WDR level 0-100, sharpness 0-100, gain level and a separate gain limit 0-100, shutter level with explicit min and max shutter limits, brightness, contrast and saturation 0-100, HLC 0-100, two noise-reduction levels 0-100, white balance style plus red and blue 0-255 with fine tuning 0-100, IR-cut filter day/night/auto/schedule with a night-to-day threshold 0-7 and delay 5-120 s, backlight compensation with a direction (up, down, left, right, centre), exposure type manual or auto, power line frequency 50/60 Hz, lens distortion correction, digital zoom, overexposure suppression, palettes, and supplement lighting: IR and white-light brightness 0-100 with modes. Several of these have no ONVIF equivalent at all, in particular the shutter limits, gain limit, HLC, lens distortion correction and the lighting controls.\n\nStreaming channel 101 reports what ONVIF also shows (H.264 Main, 4096x1860, VBR 10240 kbps, GOP 50, 20 fps) plus audio G.711 ulaw on the main stream, and carries SmartCodec and SVC elements, which ONVIF does not describe. Storage: hddList is empty, so this camera has no SD card, and on-camera recording or playback is not available on it. /Security/users returns only the requesting Operator account, so an Operator cannot enumerate other users. /Event/notification/httpHosts exists, which means the camera can push events to an HTTP endpoint of ours rather than us polling, worth weighing against ONVIF PullPoint.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"url":"http://localhost/ISAPI/Image/channels/1/capabilities","note":"Camera …af2e, address redacted per dec-kgm44qw; read-only digest GETs, no settings changed"}],"as_of":"2026-09-19"}},"at":"2026-09-19T22:37:28.286Z","parents":["evt-vena3jmyy9t4"],"hash":"071fc1436e2e1682ca2e13d9b73a68c84204bee69520699f6176fb1a8752398e"}
{"id":"evt-97ygefxzcenz","type":"edge.added","subject":"fnd-qa5n7s5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-qa5n7s5","to":"gol-7ajfexk"},"at":"2026-09-19T22:37:28.288Z","parents":["evt-6ch89xwctq4h"],"hash":"3aee392e63232f6a583e4001bc9fb7214b83d99f123decf76370b8446beebed3"}
{"id":"evt-tv30h3fn3mca","type":"edge.added","subject":"fnd-qa5n7s5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-qa5n7s5","to":"iss-6f346cm"},"at":"2026-09-19T22:37:28.289Z","parents":["evt-97ygefxzcenz"],"hash":"66e93ac460855284be275c9639d773dcae7acc096ac5ec56ad05b5615114151c"}
{"id":"evt-ewra0hxv3d2k","type":"edge.added","subject":"fnd-qa5n7s5","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-qa5n7s5","to":"gol-6q1q5mr"},"at":"2026-09-19T22:37:28.290Z","parents":["evt-tv30h3fn3mca"],"hash":"6390cfea6c3fa5a16677dbbdca607ec0b7a9de008094b51eb60b1803c6ea1b96"}
{"id":"evt-nbwjp2wfcy1m","type":"node.created","subject":"fnd-9c9dshr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"finding","title":"Both cameras expose the same 34 ISAPI imaging settings; their own web page is a modern bundled app that blocks framing","body":"Read-only checks on 2026-09-19; nothing changed on either camera. (1) /ISAPI/Image/channels/1/capabilities is byte-for-byte identical on …af2e and …af54: 4352 bytes, the same 34 settings with the same ranges and choices. So one ISAPI imaging module would serve both, and by extension other cameras of this model; only the account differs (…af2e needs the purpose-made web Operator account, …af54 accepts its ONVIF user). (2) The camera's own web interface: GET / returns 334 bytes that redirect by script to /doc/index.html, which is a single-page app, 7 KB of HTML pulling 19 scripts and 12 stylesheets with webpack-style hashed names (root-application, vendors~root-application), so a recent rewrite rather than the old frames-and-ActiveX design, though its text still mentions plugin and ActiveX, presumably for older browsers or for features like two-way audio. Every response carries X-Frame-Options: SAMEORIGIN and no Content-Security-Policy, so the page cannot be embedded in ours directly; a proxy that strips that header could embed it, at the cost of rewriting asset URLs and carrying its session, and it would put the camera's full admin surface behind our own login. No login was needed to fetch the shell pages; authentication happens inside the app, so how its session works is still unknown and is the next thing to look at for the embedding route.","status":"current","owner":"prn-q80g8mz","attrs":{"sources":[{"url":"http://localhost/doc/index.html","note":"Cameras …af2e and …af54, addresses redacted per dec-kgm44qw; read-only GETs of the web shell and the ISAPI imaging capabilities"}],"as_of":"2026-09-19"}},"at":"2026-09-19T23:04:53.155Z","parents":["evt-ewra0hxv3d2k"],"hash":"fe986ccb8d73aa629d2292e4d6240a6a0132998712bccb5db72103450f6bf23f"}
{"id":"evt-3p8sd93kx37m","type":"edge.added","subject":"fnd-9c9dshr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-9c9dshr","to":"gol-7ajfexk"},"at":"2026-09-19T23:04:53.157Z","parents":["evt-nbwjp2wfcy1m"],"hash":"935e7e791334551a5e90382bdb2c96a18ccecc7637e45c95834f5c455110c37d"}
{"id":"evt-w45wpzxtqc16","type":"edge.added","subject":"fnd-9c9dshr","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"informs","from":"fnd-9c9dshr","to":"iss-6f346cm"},"at":"2026-09-19T23:04:53.158Z","parents":["evt-3p8sd93kx37m"],"hash":"f755d1bee0fb323e2137d4716667447ba98c44ca7bbc2bc7c613ed4afadf8d5e"}
{"id":"evt-ems2fv4qefyf","type":"node.created","subject":"iss-b41wnpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Read the ISAPI-only imaging settings and show them on the camera page","body":"Per fnd-qa5n7s5 and fnd-9c9dshr: /ISAPI/Image/channels/1 exposes 34 settings, identical on both cameras, many with no ONVIF equivalent. Read-only first slice: a vendor module (pri-p9h51hx) that does digest GETs of /ISAPI/Image/channels/1 and its capabilities, parses them, and adds a section to the camera page for what ONVIF can't show: shutter level with its minimum and maximum limits, gain limit, HLC level, WDR level, backlight direction, noise reduction (frame and inter-frame), white balance style with red/blue and fine tuning, IR-cut mode including schedule with its threshold and delay, lens distortion correction, digital zoom, and supplement lighting (IR and white-light brightness and modes). Credentials: try the camera's stored login; it works for ISAPI on …af54 but not on …af2e, which needs the camera's own web account, so when ISAPI answers 401 the section says the extra settings need a camera web account rather than failing the page. Everything stays read-only here; editing is a separate issue. Tests use a fake HTTP camera, never a real one (pri-e14bahk).","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T23:07:02.158Z","parents":["evt-w45wpzxtqc16"],"hash":"ba578f692b0cd9744894ff3a08030345f6ab024d1e1d21d935ececb9fc0077a8"}
{"id":"evt-nqc3ea89pfh2","type":"edge.added","subject":"iss-b41wnpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-b41wnpb","to":"gol-7ajfexk"},"at":"2026-09-19T23:07:02.159Z","parents":["evt-ems2fv4qefyf"],"hash":"74e1b93806a33c5598a4afb4836acdc8fea7b0f40d188d6e12442d9b9e04bb3b"}
{"id":"evt-rrkkb0kgq8gf","type":"edge.added","subject":"iss-b41wnpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-b41wnpb","to":"gol-6q1q5mr"},"at":"2026-09-19T23:07:02.160Z","parents":["evt-nqc3ea89pfh2"],"hash":"e17c53c33e6da4bb21415affb0bf5e690e54d94fc49225f848e49abc4e3088ef"}
{"id":"evt-6edtkebcqj1h","type":"edge.added","subject":"iss-b41wnpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-b41wnpb","to":"src/lib/isapi.ts"},"at":"2026-09-19T23:07:02.161Z","parents":["evt-rrkkb0kgq8gf"],"hash":"b1c3987ed2006d2332b5d8a87b49773c6cb60a2ebac94b95c3a3a147cfb8f256"}
{"id":"evt-gqenyev52kej","type":"edge.added","subject":"iss-b41wnpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-b41wnpb","to":"src/app/cameras/[id]/settings-sections.tsx"},"at":"2026-09-19T23:07:02.162Z","parents":["evt-6edtkebcqj1h"],"hash":"ce83015a4ee5401ecd380d01537217702a4023f0424a4dd248533cd657041f79"}
{"id":"evt-qvsddppypdwg","type":"edge.added","subject":"iss-b41wnpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-b41wnpb","to":"area:device"},"at":"2026-09-19T23:07:02.163Z","parents":["evt-gqenyev52kej"],"hash":"c3da8d11deee06a3e9aab6010eae7e6c783f8767e63d4c7929657085529421d5"}
{"id":"evt-38exeed0tfza","type":"node.created","subject":"iss-n7aa43e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Edit the ISAPI-only imaging settings from the camera page","body":"Follows the read-only slice. Add Edit and Save for the settings ONVIF can't reach: shutter minimum and maximum limits, gain limit, HLC, WDR level, backlight direction, noise reduction, white balance fine tuning, IR-cut schedule, lens distortion correction and supplement lighting. Each write is a PUT of the full /ISAPI/Image/channels/1 document with the change merged in, validated with zod against the ranges in the camera's own capabilities, re-read afterwards to report what actually applied, and appended to the audit log, exactly as the ONVIF writers do (dec-84ghhqr). First question to settle with a no-op write on one camera: whether an Operator account may write imaging at all, or whether this needs a higher level. Needs the user's go-ahead, because it changes a camera.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T23:07:07.379Z","parents":["evt-qvsddppypdwg"],"hash":"2cf15697e40fbf3c25e63f16ad2969b618644a7e20b0c89c85419e6a00e7e990"}
{"id":"evt-hpdpv36jn3bx","type":"edge.added","subject":"iss-n7aa43e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-n7aa43e","to":"gol-7ajfexk"},"at":"2026-09-19T23:07:07.380Z","parents":["evt-38exeed0tfza"],"hash":"f97d45bcf58b1f38e81d9f7f6c74540fbd1fe0ecf42b469f4766b264c1e27e4a"}
{"id":"evt-kk3dxgacc2md","type":"edge.added","subject":"iss-n7aa43e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-n7aa43e","to":"gol-6q1q5mr"},"at":"2026-09-19T23:07:07.381Z","parents":["evt-hpdpv36jn3bx"],"hash":"172636b8f3ffdc5971374c56c15e945266fdccc32da71e2c596f1b377944bba2"}
{"id":"evt-n89m4zh1qsr1","type":"edge.added","subject":"iss-n7aa43e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-n7aa43e","to":"src/lib/isapi.ts"},"at":"2026-09-19T23:07:07.382Z","parents":["evt-kk3dxgacc2md"],"hash":"b01b63fd6dfd378cf917a6d27e4d2314c4e2317a446aacb66cd3b370edcbaeff"}
{"id":"evt-s2h2p4zt2sf1","type":"edge.added","subject":"iss-n7aa43e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"touches","from":"iss-n7aa43e","to":"src/app/cameras/[id]/edit-forms.tsx"},"at":"2026-09-19T23:07:07.383Z","parents":["evt-n89m4zh1qsr1"],"hash":"909ab5f64cdc25759350536c126693ccc2bd80ea2c803753abacf8a9591a3ad1"}
{"id":"evt-jhj7bq8jpb2r","type":"edge.added","subject":"iss-n7aa43e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-n7aa43e","to":"area:device"},"at":"2026-09-19T23:07:07.384Z","parents":["evt-s2h2p4zt2sf1"],"hash":"c436abf46b2d1f79a3e7857a8964d8ac63177092d36f1aa959a274b6f4e19b2d"}
{"id":"evt-hwa4d6p2kgkt","type":"edge.added","subject":"iss-n7aa43e","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-n7aa43e","to":"area:config"},"at":"2026-09-19T23:07:07.385Z","parents":["evt-jhj7bq8jpb2r"],"hash":"1a962fa106bdf4b4bcbbbd09a911f12219a1d2a666c1500865954e8e13197f27"}
{"id":"evt-tpv317k0x84m","type":"node.created","subject":"iss-f6n6ejt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"issue","title":"Explore making first-time camera setup easier, perhaps through ISAPI","body":"Setting a camera up by hand today means: activate it, create an ONVIF user under Integration Protocol, enable ONVIF, and for ISAPI also create and activate a web user under User Management. Today's research showed why this is fiddly: the two user lists are separate, an unactivated account fails with 401 as if its rights were wrong, and the two cameras ended up configured differently (fnd-5jk0ghz, fnd-qa5n7s5). Worth exploring: the capabilities document advertises UsersConfig, UsersPermissionConfig and ONVIFUserManagement, which suggests that, given one administrator login, the app could do the whole thing itself: activate the camera, create its own least-privilege accounts and switch on the integration protocol, so the user only ever types the admin password once, at discovery. Read-only exploration first: which endpoints exist, what an Operator may and may not do, what activation needs, and whether Annke firmware behaves the same. Then a recommendation, including whether an admin password should ever be held, even briefly, and whether it should be kept or discarded after setup. Also worth weighing against embedding the camera's own page for setup only, which would avoid holding an admin login at all. Nothing that changes a camera without the user's go-ahead.","status":"open","owner":null,"attrs":{}},"at":"2026-09-19T23:07:18.400Z","parents":["evt-hwa4d6p2kgkt"],"hash":"2270cb7e2d4329a597199e06f20bc8fcc4fcd6d2da9a3b24a3863bd468fcd5eb"}
{"id":"evt-dqmrk8xg9ee9","type":"edge.added","subject":"iss-f6n6ejt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-f6n6ejt","to":"gol-7ajfexk"},"at":"2026-09-19T23:07:18.401Z","parents":["evt-tpv317k0x84m"],"hash":"8d945e0a7cb42aabbd33e13f3991eb60adc51889f770c6fd98c2ce3d9eab0c76"}
{"id":"evt-5yw8dey82b9v","type":"edge.added","subject":"iss-f6n6ejt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"serves","from":"iss-f6n6ejt","to":"gol-6q1q5mr"},"at":"2026-09-19T23:07:18.402Z","parents":["evt-dqmrk8xg9ee9"],"hash":"75a922df93d1fc9f9bca66d685da24474f9d5a1b3d7d164256da8927e59d7dfa"}
{"id":"evt-5p6zr8bhfhps","type":"edge.added","subject":"iss-f6n6ejt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-f6n6ejt","to":"area:device"},"at":"2026-09-19T23:07:18.403Z","parents":["evt-5yw8dey82b9v"],"hash":"f4039c58208761f4b58e5b874d4be31558e643c60f16c6447a57f39455fa3bf9"}
{"id":"evt-56jbxp9hq2pq","type":"edge.added","subject":"iss-f6n6ejt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"tagged","from":"iss-f6n6ejt","to":"area:research"},"at":"2026-09-19T23:07:18.404Z","parents":["evt-5p6zr8bhfhps"],"hash":"f395536d44d7293f7fbe2e699ac33a64dc31d73bcb51eea4cd33100b9cd8c72a"}
{"id":"evt-8ygxcqng452a","type":"node.updated","subject":"iss-f6n6ejt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"patch":{"attrs":{"revisit":"2026-09-26","deferred_because":"User asked on 2026-09-19 to add the easy capabilities first and come back to whether a different approach would make setup easier."}}},"at":"2026-09-19T23:07:23.610Z","parents":["evt-56jbxp9hq2pq"],"hash":"78607d324de93e5d49399af6029670c429d62247fd2a9869dcf1f26630812860"}
{"id":"evt-zrf1vnhpxqs3","type":"node.status_changed","subject":"iss-f6n6ejt","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"deferred"},"at":"2026-09-19T23:07:23.611Z","parents":["evt-8ygxcqng452a"],"hash":"dbb66ba145f23655cdfb7e55d825a4eb987cf1cc3d02cb8c6cd5ffde7c9f312b"}
{"id":"evt-td3txgjw832m","type":"node.created","subject":"ver-h1tv32h","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"The camera page shows an Advanced image panel with the settings ONVIF can't reach, read over ISAPI with the camera's stored login; a refused login says a camera web account is needed, any other failure says unavailable, and neither breaks the page","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T23:13:24.343Z","parents":["evt-zrf1vnhpxqs3"],"hash":"145931cce2152a78e2d7864f9344cabe96e570524523c3b016c34c8c1573c1b6"}
{"id":"evt-b8axg0rewnke","type":"edge.added","subject":"ver-h1tv32h","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-h1tv32h","to":"iss-b41wnpb"},"at":"2026-09-19T23:13:24.345Z","parents":["evt-td3txgjw832m"],"hash":"a70c7504512c48544f01e2645297a2402960a418e1daec0d55cfd1d1a25a759f"}
{"id":"evt-174yn4p50c71","type":"verification.recorded","subject":"ver-h1tv32h","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/isapi.test.ts (digest against a loopback server, 401/403 as a login refusal, timeouts), src/lib/isapi-image.test.ts (parser checked against test/fixtures/isapi-image.xml, a real document from one of the cameras), settings-sections.test.tsx (\"Advanced image\"). Full suite 882 tests pass, 99.68% lines; tsc and eslint clean; next build compiles. Not yet seen in a browser."},"at":"2026-09-19T23:13:24.346Z","parents":["evt-b8axg0rewnke"],"hash":"ff287bea2ad34526649fc42f70a8e5af6c95e81c3baca30f3b53538d82c1822f"}
{"id":"evt-1rv9vc2tgz5r","type":"node.created","subject":"ver-0xsz79j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Real check by the user: the Advanced image panel shows values on …af2e, and says a camera web account is needed on …af54 (whose stored ONVIF login ISAPI accepts, so it may show values there too)","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T23:13:26.554Z","parents":["evt-174yn4p50c71"],"hash":"5bbfcc0ed8c8da48fe1376e04c43ea6f8e688194a00ea1bbfaa99457ba89b6b1"}
{"id":"evt-3g11g9z250b7","type":"edge.added","subject":"ver-0xsz79j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-0xsz79j","to":"iss-b41wnpb"},"at":"2026-09-19T23:13:26.558Z","parents":["evt-1rv9vc2tgz5r"],"hash":"25106802b69828eb5320fa36a22ea5ce4093c438158b57f2f8c35255015d44e7"}
{"id":"evt-q4cdsp40wwzq","type":"verification.recorded","subject":"ver-0xsz79j","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pending","evidence":"User opens each camera's page after restarting the server"},"at":"2026-09-19T23:13:26.562Z","parents":["evt-3g11g9z250b7"],"hash":"dfe738086af6bec856bc90f63e0dff710ee406471d1fd1081d62dc7d3c0674dc"}
{"id":"evt-9f61zmymr0rj","type":"node.status_changed","subject":"iss-b41wnpb","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"from":"open","to":"in_progress"},"at":"2026-09-19T23:13:28.509Z","parents":["evt-q4cdsp40wwzq"],"hash":"e877c2759c2406079f56228f3a283635516b486c17cdabde31ec65aec5547226"}
{"id":"evt-gazexh8y4pwg","type":"node.created","subject":"ver-7t5zt61","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"verification","title":"Availability is probed from the camera's own capabilities rather than inferred: the panel lists only settings the camera advertises, with a line saying how many were found, and the answer (including a refusal) is cached per camera and path for five minutes","body":"","status":"pending","owner":"prn-q80g8mz","attrs":{}},"at":"2026-09-19T23:17:59.576Z","parents":["evt-9f61zmymr0rj"],"hash":"f52843abc56f17622eeb74b62c8bc005f3eaf64e838dbac841458b50b34a032b"}
{"id":"evt-wfexe355p48g","type":"edge.added","subject":"ver-7t5zt61","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"kind":"evidence_for","from":"ver-7t5zt61","to":"iss-b41wnpb"},"at":"2026-09-19T23:17:59.579Z","parents":["evt-gazexh8y4pwg"],"hash":"f60a32704101b91a37263e11285de25682f10c4a380d3b86047c23f17344459c"}
{"id":"evt-rrnm0qtbwbn5","type":"verification.recorded","subject":"ver-7t5zt61","actor":"prn-q80g8mz","actor_kind":"agent","session":null,"payload":{"result":"pass","evidence":"src/lib/isapi-image.test.ts parseImageOptions against test/fixtures/isapi-image-capabilities.xml (a real 4352-byte document from camera …af2e) and readAdvancedImage; src/lib/isapi.test.ts isapiGetCached (one request then memory, expiry, per-camera and per-path separation, refusals cached, resetIsapiCache); settings-sections.test.tsx \"leaves out settings this camera doesn't advertise\". Full suite 890 tests pass, 99.69% lines; tsc, eslint and next build clean."},"at":"2026-09-19T23:17:59.580Z","parents":["evt-wfexe355p48g"],"hash":"cbf1597d8c40c593ef1696c092b095707476214fbeedf59f514db1d168b9fc5d"}

View File

@ -11,6 +11,8 @@ const getCameraSettings = vi.fn<() => Promise<CameraSettings>>();
vi.mock("@/lib/camera-settings", () => ({ getCameraSettings }));
const readConfigOptions = vi.fn();
vi.mock("@/lib/camera-config", () => ({ readConfigOptions }));
const readAdvancedImage = vi.fn();
vi.mock("@/lib/isapi-image", () => ({ readAdvancedImage }));
// 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() }));
@ -101,6 +103,7 @@ const row = (html: string, label: string) =>
beforeEach(() => {
getCameraSettings.mockReset();
readConfigOptions.mockReset().mockResolvedValue(null);
readAdvancedImage.mockReset().mockResolvedValue({ ok: false, reason: "unavailable" });
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
@ -246,7 +249,9 @@ describe("CameraSettingsSections", () => {
const off = { ok: false as const, error: "Not available from this camera" };
const html = await render({ streams: off, image: off, time: off, network: off });
expect(html.match(/Not available from this camera\./g)).toHaveLength(4);
for (const title of ["Streams", "Image", "Time &amp; NTP", "Network"]) expect(html).toContain(title);
for (const title of ["Streams", "Image", "Advanced image", "Time &amp; NTP", "Network"]) {
expect(html).toContain(title);
}
});
it("marks every icon decorative", async () => {
@ -303,3 +308,105 @@ describe("webUiUrl", () => {
expect(webUiUrl({ id: "x", host: "10.0.0.2", port: 8080 })).toBe("http://10.0.0.2:8080/");
});
});
describe("Advanced image (iss-b41wnpb)", () => {
const show = async () => {
getCameraSettings.mockResolvedValue(probe);
return renderToStaticMarkup(await CameraSettingsSections({ target }));
};
const advanced = {
exposureMode: "auto",
shutter: "1/30",
minShutter: "1/100000",
maxShutter: "1/30",
gain: 0,
gainLimit: 100,
wideDynamicRange: "close",
wdrLevel: 50,
highlightCompensation: "false",
hlcLevel: 50,
backlightCompensation: "true",
backlightDirection: "CENTER",
noiseReduction: "general",
noiseReductionLevel: 50,
whiteBalanceStyle: "auto1",
whiteBalanceRed: 50,
whiteBalanceBlue: 50,
irCutMode: "auto",
nightToDayLevel: 4,
nightToDaySeconds: 5,
lensDistortionCorrection: "true",
digitalZoom: "1x",
supplementLight: "eventIntelligence",
whiteLightBrightness: 100,
irLightBrightness: 100,
powerLineFrequency: "60hz",
};
const options = {
ExposureType: { choices: ["manual", "auto"] },
ShutterLevel: { choices: ["1/30"] },
minShutterLevelLimit: { choices: ["1/100000"] },
maxShutterLevelLimit: { choices: ["1/30"] },
GainLimit: { min: 0, max: 100 },
WDRLevel: { min: 0, max: 100 },
HLCLevel: { min: 0, max: 100 },
BLCMode: { choices: ["CLOSE", "CENTER"] },
generalLevel: { min: 0, max: 100 },
WhiteBalanceStyle: { choices: ["auto1"] },
WhiteBalanceRed: { min: 0, max: 255 },
IrcutFilterType: { choices: ["day", "night", "auto", "schedule"] },
accurateLevel: { min: 0, max: 100 },
whiteLightBrightness: { min: 0, max: 100 },
irLightBrightness: { min: 0, max: 100 },
powerLineFrequencyMode: { choices: ["50hz", "60hz"] },
};
it("shows the settings ONVIF can't, read from the camera's own interface", async () => {
readAdvancedImage.mockResolvedValue({ ok: true, value: { settings: advanced, options } });
const html = await show();
expect(readAdvancedImage).toHaveBeenCalledWith(target);
expect(html).toContain("Advanced image");
expect(html).toContain("1/100000 to 1/30");
expect(html).toContain("0 of 100 (limit 100)");
expect(html).toContain("Center"); // backlight direction
expect(html).toContain("sensitivity 4, delay 5 s");
expect(html).toContain("white 100 of 100, infrared 100 of 100");
expect(html).toContain("60hz");
});
it("leaves out settings this camera doesn't advertise", async () => {
readAdvancedImage.mockResolvedValue({
ok: true,
value: { settings: advanced, options: { WDRLevel: { min: 0, max: 100 } } },
});
const html = await show();
expect(html).toContain("Wide dynamic range");
expect(html).not.toContain("Camera lights");
expect(html).not.toContain("Shutter limits");
// Digital zoom is reported as a value rather than advertised, so it shows too.
expect(html).toContain("2 settings beyond");
});
it("explains that a camera web login is needed when the camera refuses ours", async () => {
readAdvancedImage.mockResolvedValue({ ok: false, reason: "login" });
const html = await show();
expect(html).toContain("Needs a camera login.");
expect(html).toContain("User Management");
});
it("says nothing more than unavailable for a camera without this interface", async () => {
readAdvancedImage.mockResolvedValue({ ok: false, reason: "unavailable" });
const html = await show();
expect(html).toContain("Advanced image");
expect(html).toContain("doesn&#x27;t offer settings beyond");
expect(html).not.toContain("User Management");
});
it("keeps the page up if reading it throws", async () => {
readAdvancedImage.mockRejectedValue(new Error("socket hang up"));
const html = await show();
expect(html).toContain("Advanced image");
});
});

View File

@ -5,6 +5,7 @@ import {
KeyRound,
Network,
ShieldAlert,
Sliders,
SunMoon,
TriangleAlert,
Video,
@ -21,6 +22,8 @@ import {
type StreamSettings,
type TimeSettings,
} from "@/lib/camera-settings";
import { readAdvancedImage, type AdvancedImage as AdvancedImageData } from "@/lib/isapi-image";
import type { IsapiResult } from "@/lib/isapi";
import { CopyButton, LocalTime } from "./client-controls";
import { ImageEditor, StreamEditor } from "./edit-forms";
import {
@ -244,17 +247,104 @@ function Problem({ icon: Icon, children }: { icon: LucideIcon; children: React.R
* Reads and renders the camera's settings. Camera error text is never shown: only fixed
* guidance per error type (pri-tyrxdz9); unexpected errors go to the server log.
*/
/**
* Settings the camera's own interface exposes and ONVIF doesn't (vrek iss-b41wnpb). Shown
* only when the camera answers; a camera whose saved login isn't accepted there says so,
* because that is a setup step the user can act on.
*/
function AdvancedImage({ result }: { result: IsapiResult<AdvancedImageData> }) {
if (!result.ok) {
return result.reason === "login" ? (
<p className="text-zinc-500">
<span className="font-medium text-zinc-700 dark:text-zinc-300">Needs a camera login.</span> This camera
offers more settings through its own web interface. Add a user there under User Management, activate it,
and save that login for this camera on the dashboard.
</p>
) : (
<p className="text-zinc-500">This camera doesn&apos;t offer settings beyond the ones above.</p>
);
}
const { settings: i, options } = result.value;
const level = (value?: number) => (value === undefined ? "Unknown" : `${value} of 100`);
const has = (...names: string[]) => names.some((name) => name in options);
// Every row the camera advertises; one it doesn't is left out rather than shown as unknown.
const candidates: [string, boolean, React.ReactNode][] = [
["Exposure", has("ExposureType"), titleCase(i.exposureMode)],
["Shutter", has("ShutterLevel"), valueOrUnknown(i.shutter)],
[
"Shutter limits",
has("minShutterLevelLimit", "maxShutterLevelLimit"),
i.minShutter && i.maxShutter ? `${i.minShutter} to ${i.maxShutter}` : "Unknown",
],
[
"Gain",
has("GainLimit"),
i.gain === undefined ? "Unknown" : `${i.gain} of 100 (limit ${valueOrUnknown(i.gainLimit)})`,
],
["Wide dynamic range", has("WDRLevel"), `${titleCase(i.wideDynamicRange)} · ${level(i.wdrLevel)}`],
[
"Highlight compensation",
has("HLCLevel"),
`${i.highlightCompensation === "true" ? "On" : "Off"} · ${level(i.hlcLevel)}`,
],
[
"Backlight compensation",
has("BLCMode"),
i.backlightCompensation === "true" ? titleCase(i.backlightDirection ?? "on") : "Off",
],
[
"Noise reduction",
has("generalLevel", "FrameNoiseReduceLevel"),
`${titleCase(i.noiseReduction)} · ${level(i.noiseReductionLevel)}`,
],
[
"White balance",
has("WhiteBalanceStyle", "WhiteBalanceRed"),
`${titleCase(i.whiteBalanceStyle)} · red ${valueOrUnknown(i.whiteBalanceRed)}, blue ${valueOrUnknown(i.whiteBalanceBlue)}`,
],
[
"Day/night switching",
has("IrcutFilterType"),
`${titleCase(i.irCutMode)} · sensitivity ${valueOrUnknown(i.nightToDayLevel)}, delay ${
i.nightToDaySeconds === undefined ? "unknown" : `${i.nightToDaySeconds} s`
}`,
],
["Lens distortion correction", has("accurateLevel"), i.lensDistortionCorrection === "true" ? "On" : "Off"],
["Digital zoom", i.digitalZoom !== undefined, valueOrUnknown(i.digitalZoom)],
[
"Camera lights",
has("whiteLightBrightness", "irLightBrightness"),
`${titleCase(i.supplementLight)} · white ${level(i.whiteLightBrightness)}, infrared ${level(i.irLightBrightness)}`,
],
["Power line frequency", has("powerLineFrequencyMode"), valueOrUnknown(i.powerLineFrequency)],
];
const rows = candidates.filter(([, supported]) => supported).map(([label, , value]): [string, React.ReactNode] => [label, value]);
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-zinc-500">
Read from the camera&apos;s own interface; {rows.length} setting{rows.length === 1 ? "" : "s"} beyond what
the Image section shows.
</p>
{rows.length > 0 ? <Rows rows={rows} /> : <Unavailable />}
</div>
);
}
export async function CameraSettingsSections({ target }: { target: CameraTarget }) {
let settings;
let options: ConfigOptions | null;
let advanced: IsapiResult<AdvancedImageData>;
try {
// What each section may be changed to; without it the sections stay read-only.
[settings, options] = await Promise.all([
[settings, options, advanced] = await Promise.all([
getCameraSettings(target),
readConfigOptions(target).catch((err) => {
console.warn("[config] edit options unavailable:", err instanceof Error ? err.message : err);
return null;
}),
readAdvancedImage(target).catch(() => ({ ok: false, reason: "unavailable" }) as const),
]);
} catch (err) {
if (err instanceof CameraAuthError) {
@ -295,6 +385,9 @@ export async function CameraSettingsSections({ target }: { target: CameraTarget
<Panel icon={SunMoon} title="Image">
<ImageInfo cameraId={target.id} section={settings.image} options={options} />
</Panel>
<Panel icon={Sliders} title="Advanced image">
<AdvancedImage result={advanced} />
</Panel>
<Panel icon={Clock} title="Time & NTP">
<Time section={settings.time} />
</Panel>

166
src/lib/isapi-image.test.ts Normal file
View File

@ -0,0 +1,166 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
const isapiGetCached = vi.fn();
vi.mock("./isapi", async (importOriginal) => ({
...(await importOriginal<typeof import("./isapi")>()),
isapiGetCached,
}));
const { parseImageOptions, parseImageSettings, readAdvancedImage, section, tag } = await import("./isapi-image");
/**
* The fixture is a real /ISAPI/Image/channels/1 document from one of the cameras, so the
* parser is checked against what the hardware actually sends (vrek fnd-qa5n7s5). It holds
* image settings only: no address, serial or login.
*/
const real = readFileSync(path.join(import.meta.dirname, "../../test/fixtures/isapi-image.xml"), "utf8");
describe("tag and section", () => {
const xml = "<Outer version=\"2.0\"><mode>auto</mode><Inner><mode>manual</mode></Inner></Outer>";
it("reads the first value of an element", () => {
expect(tag(xml, "mode")).toBe("auto");
expect(tag(xml, "missing")).toBeUndefined();
expect(tag("<a> spaced </a>", "a")).toBe("spaced");
expect(tag("<a></a>", "a")).toBeUndefined();
});
it("narrows to one section, so the same name elsewhere isn't picked up", () => {
expect(tag(section(xml, "Inner"), "mode")).toBe("manual");
expect(section(xml, "Outer")).toBe(xml);
expect(section(xml, "Nope")).toBe("");
});
});
describe("parseImageSettings", () => {
it("reads the settings ONVIF can't from a real camera document", () => {
expect(parseImageSettings(real)).toEqual({
exposureMode: "auto",
shutter: "1/30",
maxShutter: "1/30",
minShutter: "1/100000",
gain: 0,
gainLimit: 100,
wideDynamicRange: "close",
wdrLevel: 50,
highlightCompensation: "false",
hlcLevel: 50,
backlightCompensation: "false",
backlightDirection: undefined,
noiseReduction: "general",
noiseReductionLevel: 50,
whiteBalanceStyle: "auto1",
whiteBalanceRed: 50,
whiteBalanceBlue: 50,
irCutMode: "auto",
nightToDayLevel: 4,
nightToDaySeconds: 5,
lensDistortionCorrection: "true",
digitalZoom: "1x",
supplementLight: "eventIntelligence",
whiteLightBrightness: 100,
irLightBrightness: 100,
powerLineFrequency: "60hz",
});
});
it("takes each value from its own section", () => {
const changed = real
.replace("<IrcutFilterType>auto<", "<IrcutFilterType>schedule<")
.replace("<WDRLevel>50<", "<WDRLevel>70<")
.replace("<GainLimit>100<", "<GainLimit>60<");
const settings = parseImageSettings(changed);
expect(settings).toMatchObject({ irCutMode: "schedule", wdrLevel: 70, gainLimit: 60 });
// Unchanged neighbours keep their own values.
expect(settings).toMatchObject({ gain: 0, wideDynamicRange: "close", nightToDayLevel: 4 });
});
it("leaves out what a camera doesn't report, rather than guessing", () => {
expect(parseImageSettings("<ImageChannel><id>1</id></ImageChannel>")).toEqual({
exposureMode: undefined,
shutter: undefined,
minShutter: undefined,
maxShutter: undefined,
gain: undefined,
gainLimit: undefined,
wideDynamicRange: undefined,
wdrLevel: undefined,
highlightCompensation: undefined,
hlcLevel: undefined,
backlightCompensation: undefined,
backlightDirection: undefined,
noiseReduction: undefined,
noiseReductionLevel: undefined,
whiteBalanceStyle: undefined,
whiteBalanceRed: undefined,
whiteBalanceBlue: undefined,
irCutMode: undefined,
nightToDayLevel: undefined,
nightToDaySeconds: undefined,
lensDistortionCorrection: undefined,
digitalZoom: undefined,
supplementLight: undefined,
whiteLightBrightness: undefined,
irLightBrightness: undefined,
powerLineFrequency: undefined,
});
});
it("ignores a value that isn't a number", () => {
expect(parseImageSettings("<Gain><GainLevel>high</GainLevel></Gain>").gain).toBeUndefined();
});
});
describe("parseImageOptions", () => {
const capabilities = readFileSync(
path.join(import.meta.dirname, "../../test/fixtures/isapi-image-capabilities.xml"),
"utf8",
);
it("reads each setting's range or choices from a real capabilities document", () => {
const options = parseImageOptions(capabilities);
expect(options.WDRLevel).toEqual({ min: 0, max: 100 });
expect(options.GainLimit).toEqual({ min: 0, max: 100 });
expect(options.WhiteBalanceRed).toEqual({ min: 0, max: 255 });
expect(options.IrcutFilterType).toEqual({ choices: ["day", "night", "auto", "schedule"] });
expect(options.ExposureType).toEqual({ choices: ["manual", "auto"] });
expect(Object.keys(options).length).toBeGreaterThan(20);
});
it("keeps the first description of a name and ignores plain elements", () => {
const xml = '<A min="0" max="10">5</A><B>7</B><A min="0" max="99">1</A><C opt="x,y" />';
expect(parseImageOptions(xml)).toEqual({ A: { min: 0, max: 10 }, C: { choices: ["x", "y"] } });
});
});
describe("readAdvancedImage", () => {
const target = { id: "11111111-2222-3333-4444-555555555555", host: "192.168.1.10", port: 80 };
const capabilities = '<ImageCap><WDRLevel min="0" max="100">50</WDRLevel></ImageCap>';
it("reads the settings and what the camera says it supports", async () => {
isapiGetCached.mockImplementation(async (_t: unknown, p: string) =>
p.endsWith("/capabilities") ? { ok: true, value: capabilities } : { ok: true, value: real },
);
const result = await readAdvancedImage(target);
expect(isapiGetCached).toHaveBeenCalledWith(target, "/ISAPI/Image/channels/1");
expect(isapiGetCached).toHaveBeenCalledWith(target, "/ISAPI/Image/channels/1/capabilities");
expect(result).toMatchObject({
ok: true,
value: { settings: { shutter: "1/30" }, options: { WDRLevel: { min: 0, max: 100 } } },
});
});
it("still reports the settings when the camera won't describe them", async () => {
isapiGetCached.mockImplementation(async (_t: unknown, p: string) =>
p.endsWith("/capabilities") ? { ok: false, reason: "unavailable" } : { ok: true, value: real },
);
expect(await readAdvancedImage(target)).toMatchObject({ ok: true, value: { options: {} } });
});
it.each([["login"], ["unavailable"]])("passes on a %s refusal unchanged", async (reason) => {
isapiGetCached.mockResolvedValue({ ok: false, reason });
expect(await readAdvancedImage(target)).toEqual({ ok: false, reason });
});
});

148
src/lib/isapi-image.ts Normal file
View File

@ -0,0 +1,148 @@
import "server-only";
import type { CameraTarget } from "./camera";
import {
ISAPI_IMAGE_CAPABILITIES_PATH,
ISAPI_IMAGE_PATH,
isapiGetCached,
type IsapiResult,
} from "./isapi";
/**
* The image settings ISAPI exposes that ONVIF doesn't (vrek fnd-qa5n7s5): shutter and gain
* limits, highlight compensation, backlight direction, noise reduction, white balance fine
* tuning, the day/night schedule and the camera's own lights.
*
* The documents are small and flat, so they're read with targeted matches rather than a
* new XML dependency (pri-mz2jxpb).
*/
export interface IsapiImageSettings {
exposureMode?: string;
/** Shutter speeds are strings on these cameras, e.g. "1/30". */
shutter?: string;
minShutter?: string;
maxShutter?: string;
gain?: number;
gainLimit?: number;
wideDynamicRange?: string;
wdrLevel?: number;
highlightCompensation?: string;
hlcLevel?: number;
backlightCompensation?: string;
backlightDirection?: string;
noiseReduction?: string;
noiseReductionLevel?: number;
whiteBalanceStyle?: string;
whiteBalanceRed?: number;
whiteBalanceBlue?: number;
irCutMode?: string;
nightToDayLevel?: number;
nightToDaySeconds?: number;
lensDistortionCorrection?: string;
digitalZoom?: string;
supplementLight?: string;
whiteLightBrightness?: number;
irLightBrightness?: number;
powerLineFrequency?: string;
}
/** The first value of an element, e.g. tag("<a>1</a>", "a") → "1". */
export function tag(xml: string, name: string): string | undefined {
return new RegExp(`<${name}>([^<]*)</${name}>`).exec(xml)?.[1]?.trim() || undefined;
}
/** The contents of a section, so a name inside it isn't confused with the same name elsewhere. */
export function section(xml: string, name: string): string {
return new RegExp(`<${name}[ >][\\s\\S]*?</${name}>`).exec(xml)?.[0] ?? "";
}
const number = (value?: string) => {
const parsed = Number(value);
return value !== undefined && Number.isFinite(parsed) ? parsed : undefined;
};
export function parseImageSettings(xml: string): IsapiImageSettings {
const exposure = section(xml, "Exposure");
const shutter = section(xml, "Shutter");
const gain = section(xml, "Gain");
const wdr = section(xml, "WDR");
const hlc = section(xml, "HLC");
const blc = section(xml, "BLC");
const noise = section(xml, "NoiseReduce");
const balance = section(xml, "WhiteBalance");
const irCut = section(xml, "IrcutFilter");
const lens = section(xml, "LensDistortionCorrection");
return {
exposureMode: tag(exposure, "ExposureType"),
shutter: tag(shutter, "ShutterLevel"),
minShutter: tag(shutter, "minShutterLevelLimit"),
maxShutter: tag(shutter, "maxShutterLevelLimit"),
gain: number(tag(gain, "GainLevel")),
gainLimit: number(tag(gain, "GainLimit")),
wideDynamicRange: tag(wdr, "mode"),
wdrLevel: number(tag(wdr, "WDRLevel")),
highlightCompensation: tag(hlc, "enabled"),
hlcLevel: number(tag(hlc, "HLCLevel")),
backlightCompensation: tag(blc, "enabled"),
backlightDirection: tag(blc, "BLCMode"),
noiseReduction: tag(noise, "mode"),
noiseReductionLevel: number(tag(noise, "generalLevel")),
whiteBalanceStyle: tag(balance, "WhiteBalanceStyle"),
whiteBalanceRed: number(tag(balance, "WhiteBalanceRed")),
whiteBalanceBlue: number(tag(balance, "WhiteBalanceBlue")),
irCutMode: tag(irCut, "IrcutFilterType"),
nightToDayLevel: number(tag(irCut, "nightToDayFilterLevel")),
nightToDaySeconds: number(tag(irCut, "nightToDayFilterTime")),
lensDistortionCorrection: tag(lens, "enabled"),
digitalZoom: tag(section(xml, "DigitalZoom"), "ZoomRatio"),
supplementLight: tag(xml, "supplementLightMode"),
whiteLightBrightness: number(tag(xml, "whiteLightBrightness")),
irLightBrightness: number(tag(xml, "irLightBrightness")),
powerLineFrequency: tag(section(xml, "powerLineFrequency"), "powerLineFrequencyMode"),
};
}
/** What one setting may be set to, as the camera itself describes it. */
export type IsapiOption = { min: number; max: number } | { choices: string[] };
/** Every setting the camera advertises, by element name, e.g. "WDRLevel" → 0-100. */
export type IsapiImageOptions = Record<string, IsapiOption>;
export function parseImageOptions(xml: string): IsapiImageOptions {
const options: IsapiImageOptions = {};
for (const match of xml.matchAll(/<(\w+)\s+([^>]*?)\/?>/g)) {
const [, name, attributes] = match;
const range = /min="(-?\d+)"\s+max="(-?\d+)"/.exec(attributes);
const choices = /opt="([^"]+)"/.exec(attributes);
if (range) options[name] ??= { min: Number(range[1]), max: Number(range[2]) };
else if (choices) options[name] ??= { choices: choices[1].split(",").map((c) => c.trim()) };
}
return options;
}
export interface AdvancedImage {
settings: IsapiImageSettings;
/** Which settings this camera has, and their limits; drives what the UI shows. */
options: IsapiImageOptions;
}
/**
* Reads the camera's extra image settings together with what it says they support, or
* says why it couldn't: "login" means ISAPI is there but refused this login, which is a
* setup step; "unavailable" means the camera doesn't offer it.
*/
export async function readAdvancedImage(target: CameraTarget): Promise<IsapiResult<AdvancedImage>> {
const [settings, capabilities] = await Promise.all([
isapiGetCached(target, ISAPI_IMAGE_PATH),
isapiGetCached(target, ISAPI_IMAGE_CAPABILITIES_PATH),
]);
if (!settings.ok) return settings;
return {
ok: true,
value: {
settings: parseImageSettings(settings.value),
options: capabilities.ok ? parseImageOptions(capabilities.value) : {},
},
};
}

159
src/lib/isapi.test.ts Normal file
View File

@ -0,0 +1,159 @@
// A loopback HTTP server stands in for the camera; no real device is contacted
// (vrek pri-e14bahk).
import { createHash } from "node:crypto";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { Credentials } from "./credential-store";
const getCredentials = vi.fn<(id: string) => Promise<Credentials | null>>();
vi.mock("./credential-store", () => ({ getCredentials }));
const { digestHeader, isapiGet, isapiGetCached, resetIsapiCache } = await import("./isapi");
const LOGIN = { username: "claude", password: "s3cret" };
const REALM = "IP Camera";
const NONCE = "abc123";
type Handler = (req: http.IncomingMessage, res: http.ServerResponse) => void;
let handler: Handler;
const requests: { path?: string; authorization?: string }[] = [];
const server = http.createServer((req, res) => {
requests.push({ path: req.url, authorization: req.headers.authorization });
handler(req, res);
});
let port: number;
beforeAll(async () => {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
port = (server.address() as AddressInfo).port;
});
afterAll(() => new Promise<void>((resolve) => server.close(() => resolve())));
const target = () => ({ id: "11111111-2222-3333-4444-555555555555", host: "127.0.0.1", port });
const md5 = (v: string) => createHash("md5").update(v).digest("hex");
/** Answers the first request with a digest challenge, then checks the response. */
function digestCamera(body: string, status = 200, qop = "auth"): Handler {
return (req, res) => {
if (!req.headers.authorization) {
res.writeHead(401, {
"WWW-Authenticate": `Digest realm="${REALM}", nonce="${NONCE}"${qop ? `, qop="${qop}"` : ""}`,
});
return res.end();
}
res.writeHead(status, { "Content-Type": "application/xml" });
res.end(body);
};
}
beforeEach(() => {
requests.length = 0;
getCredentials.mockReset().mockResolvedValue(LOGIN);
});
describe("digestHeader", () => {
it("computes an RFC 2617 qop=auth response", () => {
const header = digestHeader(`Digest realm="${REALM}", nonce="${NONCE}", qop="auth"`, "/x", "u", "p", "cn");
const expected = md5(`${md5(`u:${REALM}:p`)}:${NONCE}:00000001:cn:auth:${md5("GET:/x")}`);
expect(header).toContain(`response="${expected}"`);
expect(header).toContain('qop=auth, nc=00000001, cnonce="cn"');
});
it("falls back to the older form when the camera offers no qop, and echoes opaque", () => {
const header = digestHeader(`Digest realm="${REALM}", nonce="${NONCE}", opaque="op"`, "/x", "u", "p");
expect(header).toContain(`response="${md5(`${md5(`u:${REALM}:p`)}:${NONCE}:${md5("GET:/x")}`)}"`);
expect(header).not.toContain("qop=");
expect(header).toContain('opaque="op"');
});
});
describe("isapiGet", () => {
it("answers the camera's digest challenge and returns the document", async () => {
handler = digestCamera("<ImageChannel><id>1</id></ImageChannel>");
const result = await isapiGet(target(), "/ISAPI/Image/channels/1");
expect(result).toEqual({ ok: true, value: "<ImageChannel><id>1</id></ImageChannel>" });
expect(requests).toHaveLength(2);
expect(requests[0].authorization).toBeUndefined();
expect(requests[1].authorization).toContain('username="claude"');
});
it("reports a refused login, whether the camera says 401 or 403", async () => {
handler = digestCamera("denied", 403);
expect(await isapiGet(target(), "/x")).toEqual({ ok: false, reason: "login" });
handler = (_req, res) => {
res.writeHead(401, { "WWW-Authenticate": `Digest realm="${REALM}", nonce="${NONCE}"` });
res.end();
};
expect(await isapiGet(target(), "/x")).toEqual({ ok: false, reason: "login" });
});
it("reports anything else as unavailable", async () => {
handler = digestCamera("busy", 503);
expect(await isapiGet(target(), "/x")).toEqual({ ok: false, reason: "unavailable" });
});
it("needs no second request when the camera doesn't ask for a login", async () => {
handler = (_req, res) => res.end("<open/>");
expect(await isapiGet(target(), "/x")).toEqual({ ok: true, value: "<open/>" });
expect(requests).toHaveLength(1);
});
it("says login when nothing is saved for the camera, without contacting it", async () => {
getCredentials.mockResolvedValue(null);
expect(await isapiGet(target(), "/x")).toEqual({ ok: false, reason: "login" });
expect(requests).toHaveLength(0);
});
it("survives a camera that never answers, without leaking its address", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const result = await isapiGet({ ...target(), port: 9 }, "/x");
expect(result).toEqual({ ok: false, reason: "unavailable" });
expect(warn).toHaveBeenCalled();
});
});
describe("isapiGetCached", () => {
beforeEach(() => resetIsapiCache());
it("asks the camera once, then answers from memory for a few minutes", async () => {
handler = digestCamera("<a/>");
const first = await isapiGetCached(target(), "/x", 1000);
expect(first).toEqual({ ok: true, value: "<a/>" });
const asked = requests.length;
expect(await isapiGetCached(target(), "/x", 1000 + 4 * 60_000)).toEqual(first);
expect(requests).toHaveLength(asked);
});
it("asks again once the answer is old", async () => {
handler = digestCamera("<a/>");
await isapiGetCached(target(), "/x", 1000);
const asked = requests.length;
await isapiGetCached(target(), "/x", 1000 + 6 * 60_000);
expect(requests.length).toBeGreaterThan(asked);
});
it("keeps each path and camera apart, and forgets one camera on request", async () => {
handler = digestCamera("<a/>");
await isapiGetCached(target(), "/x", 1000);
await isapiGetCached(target(), "/y", 1000);
const other = { ...target(), id: "22222222-2222-3333-4444-555555555555" };
await isapiGetCached(other, "/x", 1000);
const asked = requests.length;
resetIsapiCache(target().id);
await isapiGetCached(other, "/x", 1000); // still remembered
expect(requests).toHaveLength(asked);
await isapiGetCached(target(), "/x", 1000); // forgotten, so asked again
expect(requests.length).toBeGreaterThan(asked);
});
it("remembers a refusal too, so a camera without ISAPI isn't asked repeatedly", async () => {
handler = digestCamera("denied", 403);
expect(await isapiGetCached(target(), "/x", 1000)).toEqual({ ok: false, reason: "login" });
const asked = requests.length;
await isapiGetCached(target(), "/x", 1000);
expect(requests).toHaveLength(asked);
});
});

134
src/lib/isapi.ts Normal file
View File

@ -0,0 +1,134 @@
import "server-only";
import { createHash, randomBytes } from "node:crypto";
import http from "node:http";
import type { CameraTarget } from "./camera";
import { getCredentials } from "./credential-store";
/**
* Hikvision ISAPI, the cameras' own HTTP interface, for the settings ONVIF doesn't expose
* (vrek iss-b41wnpb, fnd-qa5n7s5). Vendor-specific code lives here, behind plain types, so
* pages and routes never branch on brand (pri-p9h51hx). Read-only for now.
*
* Authentication is HTTP digest with the camera's stored login. That login reaches ISAPI on
* some cameras and not others, so a refusal is a reportable state, not an error.
*/
export const ISAPI_IMAGE_PATH = "/ISAPI/Image/channels/1";
export const ISAPI_IMAGE_CAPABILITIES_PATH = "/ISAPI/Image/channels/1/capabilities";
const TIMEOUT_MS = 10_000;
/** These documents are a few kilobytes; anything much larger isn't one. */
const MAX_BYTES = 512 * 1024;
export type IsapiResult<T> = { ok: true; value: T } | { ok: false; reason: "login" | "unavailable" };
interface HttpAnswer {
status: number;
challenge: string;
body: string;
}
function request(target: CameraTarget, path: string, authorization?: string): Promise<HttpAnswer> {
return new Promise((resolve, reject) => {
const req = http.request(
{
host: target.host,
port: target.port,
path,
method: "GET",
timeout: TIMEOUT_MS,
headers: authorization ? { authorization } : {},
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk: string) => {
body += chunk;
if (body.length > MAX_BYTES) req.destroy(new Error("ISAPI response too large"));
});
res.on("end", () =>
resolve({ status: res.statusCode ?? 0, challenge: String(res.headers["www-authenticate"] ?? ""), body }),
);
},
);
req.on("timeout", () => req.destroy(new Error("ISAPI request timed out")));
req.on("error", reject);
req.end();
});
}
const md5 = (value: string) => createHash("md5").update(value).digest("hex");
/** One digest response for this challenge; the camera only offers MD5. */
export function digestHeader(
challenge: string,
path: string,
username: string,
password: string,
cnonce = randomBytes(8).toString("hex"),
): string {
const fields = Object.fromEntries(
[...challenge.matchAll(/(\w+)="?([^",]*)"?/g)].map((match) => [match[1], match[2]]),
);
const nc = "00000001";
const qop = fields.qop?.split(",")[0];
const ha1 = md5(`${username}:${fields.realm}:${password}`);
const ha2 = md5(`GET:${path}`);
const response = qop
? md5(`${ha1}:${fields.nonce}:${nc}:${cnonce}:${qop}:${ha2}`)
: md5(`${ha1}:${fields.nonce}:${ha2}`);
return (
`Digest username="${username}", realm="${fields.realm}", nonce="${fields.nonce}", uri="${path}", response="${response}"` +
(qop ? `, qop=${qop}, nc=${nc}, cnonce="${cnonce}"` : "") +
(fields.opaque ? `, opaque="${fields.opaque}"` : "")
);
}
/** A GET with the camera's stored login, or why it couldn't be made. */
export async function isapiGet(target: CameraTarget, path: string): Promise<IsapiResult<string>> {
// The address comes from the registry and was checked with isAllowedHost by the page or
// route that resolved it (pri-m1csgrm), as it is for ONVIF in camera.ts.
const login = await getCredentials(target.id);
if (!login) return { ok: false, reason: "login" };
try {
const first = await request(target, path);
const answer =
first.status === 401 && first.challenge
? await request(target, path, digestHeader(first.challenge, path, login.username, login.password))
: first;
if (answer.status === 401 || answer.status === 403) return { ok: false, reason: "login" };
if (answer.status !== 200) return { ok: false, reason: "unavailable" };
return { ok: true, value: answer.body };
} catch (err) {
// The message can carry the camera's address; keep it in the log only.
console.warn(`[isapi] ${path} failed:`, err instanceof Error ? err.message : err);
return { ok: false, reason: "unavailable" };
}
}
/**
* What a camera supports changes only when someone reconfigures it, so the answer is kept
* for a few minutes: a page load shouldn't re-probe, and a camera that doesn't speak ISAPI
* shouldn't be asked on every render.
*/
const CACHE_MS = 5 * 60 * 1000;
const cache = new Map<string, { at: number; answer: IsapiResult<string> }>();
export async function isapiGetCached(
target: CameraTarget,
path: string,
now = Date.now(),
): Promise<IsapiResult<string>> {
const key = `${target.id}${path}`;
const hit = cache.get(key);
if (hit && now - hit.at < CACHE_MS) return hit.answer;
const answer = await isapiGet(target, path);
cache.set(key, { at: now, answer });
return answer;
}
/** Forgets what a camera answered, e.g. after its login changes. */
export function resetIsapiCache(cameraId?: string) {
if (!cameraId) return cache.clear();
for (const key of cache.keys()) if (key.startsWith(cameraId)) cache.delete(key);
}

View File

@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8"?>
<ImageChannel version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<id>1</id>
<enabled>true</enabled>
<videoInputID>1</videoInputID>
<WDR>
<mode opt="close,open,auto">close</mode>
<WDRLevel min="0" max="100">50</WDRLevel>
</WDR>
<BLC>
<enabled opt="true,false">false</enabled>
<BLCMode opt="CLOSE,UP,DOWN,LEFT,RIGHT,CENTER">CLOSE</BLCMode>
</BLC>
<IrcutFilter>
<IrcutFilterType opt="day,night,auto,schedule">auto</IrcutFilterType>
<nightToDayFilterLevel opt="0,1,2,3,4,5,6,7">4</nightToDayFilterLevel>
<nightToDayFilterTime min="5" max="120">5</nightToDayFilterTime>
<Schedule>
<scheduleType>day</scheduleType>
<TimeRange>
<beginTime>06:00:00</beginTime>
<endTime>18:00:00</endTime>
</TimeRange>
</Schedule>
</IrcutFilter>
<WhiteBalance>
<WhiteBalanceStyle opt="manual,auto1,auto2,locked,daylightLamp,incandescentlight,warmlight,naturallight">auto1</WhiteBalanceStyle>
<WhiteBalanceRed min="0" max="255">50</WhiteBalanceRed>
<WhiteBalanceBlue min="0" max="255">50</WhiteBalanceBlue>
<WhiteBalanceRedFineTuning min="0" max="100">50</WhiteBalanceRedFineTuning>
<WhiteBalanceBlueFineTuning min="0" max="100">50</WhiteBalanceBlueFineTuning>
</WhiteBalance>
<Exposure>
<ExposureType opt="manual,auto">auto</ExposureType>
<OverexposeSuppress>
<enabled opt="true,false">false</enabled>
</OverexposeSuppress>
</Exposure>
<Sharpness>
<SharpnessLevel min="0" max="100">50</SharpnessLevel>
</Sharpness>
<Shutter>
<ShutterLevel opt="1/1,1/3,1/6,1/15,1/24,1/30,1/60,1/90,1/120,1/125,1/150,1/175,1/200,1/225,1/250,1/300,1/400,1/500,1/725,1/1000,1/2000,1/4000,1/10000,1/30000,1/100000">1/30</ShutterLevel>
<maxShutterLevelLimit opt="1/1,1/3,1/6,1/15,1/24,1/30,1/60,1/90,1/120,1/125,1/150,1/175,1/200,1/225,1/250,1/300,1/400,1/500,1/725,1/1000,1/2000,1/4000,1/10000,1/30000,1/100000">1/30</maxShutterLevelLimit>
<minShutterLevelLimit opt="1/1,1/3,1/6,1/15,1/24,1/30,1/60,1/90,1/120,1/125,1/150,1/175,1/200,1/225,1/250,1/300,1/400,1/500,1/725,1/1000,1/2000,1/4000,1/10000,1/30000,1/100000">1/100000</minShutterLevelLimit>
</Shutter>
<powerLineFrequency>
<powerLineFrequencyMode opt="50hz,60hz">60hz</powerLineFrequencyMode>
</powerLineFrequency>
<Gain>
<GainLimit min="0" max="100">100</GainLimit>
</Gain>
<Color>
<brightnessLevel min="0" max="100">50</brightnessLevel>
<contrastLevel min="0" max="100">50</contrastLevel>
<saturationLevel min="0" max="100">50</saturationLevel>
</Color>
<NoiseReduce>
<mode opt="close,general,advanced">general</mode>
<GeneralMode>
<generalLevel min="0" max="100">50</generalLevel>
</GeneralMode>
<AdvancedMode>
<FrameNoiseReduceLevel min="0" max="100">50</FrameNoiseReduceLevel>
<InterFrameNoiseReduceLevel min="0" max="100">50</InterFrameNoiseReduceLevel>
</AdvancedMode>
</NoiseReduce>
<HLC>
<enabled opt="true,false">false</enabled>
<HLCLevel min="0" max="100">50</HLCLevel>
</HLC>
<corridor>
<enabled opt="true,false">false</enabled>
</corridor>
<LensDistortionCorrection>
<enabled opt="true,false">true</enabled>
<accurateLevel min="0" max="100">6</accurateLevel>
</LensDistortionCorrection>
<SupplementLight>
<supplementLightMode opt="eventIntelligence,colorVuWhiteLight,irLight,close">eventIntelligence</supplementLightMode>
<mixedLightBrightnessRegulatMode opt="auto,manual">auto</mixedLightBrightnessRegulatMode>
<whiteLightBrightness min="0" max="100">100</whiteLightBrightness>
<irLightBrightness min="0" max="100">100</irLightBrightness>
<colorVuWhiteLightModeCfg>
<whiteLightbrightLimit min="0" max="100">100</whiteLightbrightLimit>
</colorVuWhiteLightModeCfg>
<IrLightModeCfg>
<irLightbrightLimit min="0" max="100">100</irLightbrightLimit>
</IrLightModeCfg>
<EventIntelligenceModeCfg>
<brightnessRegulatMode opt="auto,manual">auto</brightnessRegulatMode>
<whiteLightBrightness min="0" max="100">100</whiteLightBrightness>
<irLightBrightness min="0" max="100">100</irLightBrightness>
<associatedVMDHuman>true</associatedVMDHuman>
<irLightbrightLimit min="0" max="100">100</irLightbrightLimit>
<whiteLightbrightLimit min="0" max="100">100</whiteLightbrightLimit>
</EventIntelligenceModeCfg>
</SupplementLight>
<isSupportAntiBandingParams>true</isSupportAntiBandingParams>
<BLCWebDisplay>
<mode opt="close,BLC,WDR,autoWDR,HLC">close</mode>
</BLCWebDisplay>
</ImageChannel>

108
test/fixtures/isapi-image.xml vendored Normal file
View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<ImageChannel version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<id>1</id>
<enabled>true</enabled>
<videoInputID>1</videoInputID>
<ImageFlip version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<enabled>false</enabled>
</ImageFlip>
<IrcutFilter version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<IrcutFilterType>auto</IrcutFilterType>
<nightToDayFilterLevel>4</nightToDayFilterLevel>
<nightToDayFilterTime>5</nightToDayFilterTime>
</IrcutFilter>
<Exposure version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<ExposureType>auto</ExposureType>
<OverexposeSuppress>
<enabled>false</enabled>
</OverexposeSuppress>
</Exposure>
<powerLineFrequency version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<powerLineFrequencyMode>60hz</powerLineFrequencyMode>
</powerLineFrequency>
<PTZ version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<enabled>false</enabled>
</PTZ>
<corridor version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<enabled>false</enabled>
</corridor>
<CaptureMode version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<mode>close</mode>
</CaptureMode>
<LaserLight version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<mode>manual</mode>
<brightnessLevel>0</brightnessLevel>
<laserangle>0</laserangle>
</LaserLight>
<LensDistortionCorrection>
<enabled>true</enabled>
<accurateLevel min="0" max="100">6</accurateLevel>
</LensDistortionCorrection>
<WDR version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<mode>close</mode>
<WDRLevel>50</WDRLevel>
</WDR>
<BLC version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<enabled>false</enabled>
</BLC>
<NoiseReduce version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<mode>general</mode>
<GeneralMode>
<generalLevel>50</generalLevel>
</GeneralMode>
</NoiseReduce>
<WhiteBalance version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<WhiteBalanceStyle>auto1</WhiteBalanceStyle>
<WhiteBalanceRed>50</WhiteBalanceRed>
<WhiteBalanceBlue>50</WhiteBalanceBlue>
<WhiteBalanceRedFineTuning>50</WhiteBalanceRedFineTuning>
<WhiteBalanceBlueFineTuning>50</WhiteBalanceBlueFineTuning>
</WhiteBalance>
<Sharpness version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<SharpnessLevel>50</SharpnessLevel>
</Sharpness>
<Gain version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<GainLevel>0</GainLevel>
<GainLimit>100</GainLimit>
</Gain>
<Shutter version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<ShutterLevel>1/30</ShutterLevel>
<maxShutterLevelLimit>1/30</maxShutterLevelLimit>
<minShutterLevelLimit>1/100000</minShutterLevelLimit>
</Shutter>
<Color version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<brightnessLevel>50</brightnessLevel>
<contrastLevel>50</contrastLevel>
<saturationLevel>50</saturationLevel>
</Color>
<HLC version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<enabled>false</enabled>
<HLCLevel>50</HLCLevel>
</HLC>
<DigitalZoom>
<ZoomRatio>1x</ZoomRatio>
</DigitalZoom>
<Palettes>
<mode>WhiteHot</mode>
</Palettes>
<SupplementLight version="2.0" xmlns="http://www.std-cgi.com/ver20/XMLSchema">
<supplementLightMode>eventIntelligence</supplementLightMode>
<mixedLightBrightnessRegulatMode>auto</mixedLightBrightnessRegulatMode>
<whiteLightBrightness>100</whiteLightBrightness>
<irLightBrightness>100</irLightBrightness>
<colorVuWhiteLightModeCfg>
<whiteLightbrightLimit>100</whiteLightbrightLimit>
</colorVuWhiteLightModeCfg>
<IrLightModeCfg>
<irLightbrightLimit>100</irLightbrightLimit>
</IrLightModeCfg>
<EventIntelligenceModeCfg>
<brightnessRegulatMode>auto</brightnessRegulatMode>
<whiteLightBrightness>100</whiteLightBrightness>
<irLightBrightness>100</irLightBrightness>
<associatedVMDHuman>true</associatedVMDHuman>
<whiteLightbrightLimit>100</whiteLightbrightLimit>
<irLightbrightLimit>100</irLightbrightLimit>
</EventIntelligenceModeCfg>
</SupplementLight>
</ImageChannel>