@camstack/addon-provider-amcrest 0.2.62 → 0.2.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +920 -73
  2. package/dist/addon.mjs +920 -73
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -3,6 +3,8 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
4
4
  //#endregion
5
5
  let node_crypto = require("node:crypto");
6
+ let node_http = require("node:http");
7
+ let node_https = require("node:https");
6
8
  let node_os = require("node:os");
7
9
  //#region ../types/dist/event-category-BZL-fdNj.mjs
8
10
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
@@ -28730,6 +28732,26 @@ var privacyMaskCapability = {
28730
28732
  * whether persisting is worth a SQLite commit. */
28731
28733
  volatileStateFields: ["lastFetchedAt"]
28732
28734
  };
28735
+ /**
28736
+ * Collapse a camera's PER-PROFILE audio flags into the one answer
28737
+ * {@link PrivacyMaskStatusSchema.shape.audioEnabled} promises.
28738
+ *
28739
+ * Both firmwares this cap talks to store the flag per stream profile, and
28740
+ * both let those profiles disagree. The rule is `some`, not `every`: privacy
28741
+ * is only satisfied when NOTHING is carrying sound, so a camera whose sub
28742
+ * stream is still audible must read as `true` and be switchable off — not as
28743
+ * `false` because the main stream happens to be muted already.
28744
+ *
28745
+ * An empty list is `null` ("this camera reported no audio flag at all"),
28746
+ * never `false`.
28747
+ *
28748
+ * Lives here rather than in each provider so the rule the schema documents
28749
+ * and the rule the providers apply cannot drift apart.
28750
+ */
28751
+ function summarisePrivacyAudio(profiles) {
28752
+ if (profiles.length === 0) return null;
28753
+ return profiles.some((p) => p.audioEnabled);
28754
+ }
28733
28755
  var PtzPresetSchema = object({
28734
28756
  id: string(),
28735
28757
  name: string()
@@ -42287,6 +42309,17 @@ var AmcrestCgiClient = class AmcrestCgiClient {
42287
42309
  return AmcrestCgiClient.parseTable(await this.getText(`/cgi-bin/configManager.cgi?action=getConfig&name=${encodeURIComponent(name)}`));
42288
42310
  }
42289
42311
  /**
42312
+ * Read SEVERAL named config blocks in one round-trip — the Dahua CGI
42313
+ * accepts repeated `name=` params and concatenates the tables
42314
+ * (verified live: `getConfig&name=LightGlobal&name=NTP` returns both).
42315
+ * Keys across blocks never collide (each is prefixed with its table
42316
+ * name), so one flat map holds them all.
42317
+ */
42318
+ async getConfigs(names) {
42319
+ const query = names.map((n) => `name=${encodeURIComponent(n)}`).join("&");
42320
+ return AmcrestCgiClient.parseTable(await this.getText(`/cgi-bin/configManager.cgi?action=getConfig&${query}`));
42321
+ }
42322
+ /**
42290
42323
  * Write config values. Keys are raw Dahua paths (e.g.
42291
42324
  * `VideoColor[0][0].Brightness`); brackets are passed through unescaped
42292
42325
  * (Dahua requires them literal) and only values are URL-encoded.
@@ -42309,40 +42342,63 @@ var AmcrestCgiClient = class AmcrestCgiClient {
42309
42342
  */
42310
42343
  async openEventStream(codes, signal) {
42311
42344
  const path = `/cgi-bin/eventManager.cgi?action=attach&codes=${codes}`;
42312
- const url = `${this.scheme}://${this.host}:${this.port}${path}`;
42313
- const firstHeader = buildAuthHeader({
42345
+ const first = await this.dialEventStream(path, signal);
42346
+ if (first.res.statusCode !== 401) return first;
42347
+ first.res.resume();
42348
+ const challenge = parseChallenge(first.res.headers["www-authenticate"] ?? null);
42349
+ if (!challenge) return first;
42350
+ this.digestState.challenge = challenge;
42351
+ this.digestState.nc = 0;
42352
+ return this.dialEventStream(path, signal);
42353
+ }
42354
+ /**
42355
+ * One `node:http` GET for the long-poll attach stream.
42356
+ *
42357
+ * Deliberately NOT `fetch`. Two things this needs that the fetch path could
42358
+ * not give: the raw socket, so `setKeepAlive` can be armed (a silently
42359
+ * half-open long-poll is indistinguishable from a quiet camera), and a
42360
+ * connection that is never taken from the shared keep-alive pool the other
42361
+ * CGI calls use. Measured 2026-09-04 against an IP4M-1041B: with `fetch`
42362
+ * the request answered 200 with a valid multipart boundary and the socket
42363
+ * stayed ESTABLISHED, yet not one byte of body ever arrived — while two
42364
+ * hand-rolled subscriptions to the SAME camera, with the same `codes`
42365
+ * filter and at the same moment, both received the events.
42366
+ */
42367
+ dialEventStream(path, signal) {
42368
+ const authHeader = buildAuthHeader({
42314
42369
  state: this.digestState,
42315
42370
  method: "GET",
42316
42371
  uri: path,
42317
42372
  username: this.username,
42318
42373
  password: this.password
42319
42374
  });
42320
- let res = await fetch(url, {
42321
- method: "GET",
42322
- headers: firstHeader ? { authorization: firstHeader } : {},
42323
- signal
42324
- });
42325
- if (res.status === 401) {
42326
- await res.body?.cancel().catch(() => {});
42327
- const challenge = parseChallenge(res.headers.get("www-authenticate"));
42328
- if (challenge) {
42329
- this.digestState.challenge = challenge;
42330
- this.digestState.nc = 0;
42331
- const retryHeader = buildAuthHeader({
42332
- state: this.digestState,
42333
- method: "GET",
42334
- uri: path,
42335
- username: this.username,
42336
- password: this.password
42337
- });
42338
- res = await fetch(url, {
42339
- method: "GET",
42340
- headers: retryHeader ? { authorization: retryHeader } : {},
42341
- signal
42375
+ const headers = { connection: "keep-alive" };
42376
+ if (authHeader) headers["authorization"] = authHeader;
42377
+ const doRequest = this.scheme === "https" ? node_https.request : node_http.request;
42378
+ return new Promise((resolve, reject) => {
42379
+ const req = doRequest({
42380
+ host: this.host,
42381
+ port: this.port,
42382
+ path,
42383
+ method: "GET",
42384
+ headers,
42385
+ timeout: 0,
42386
+ ...this.scheme === "https" ? { rejectUnauthorized: false } : {}
42387
+ }, (res) => {
42388
+ resolve({
42389
+ res,
42390
+ statusCode: res.statusCode ?? 0,
42391
+ contentType: res.headers["content-type"] ?? ""
42342
42392
  });
42343
- }
42344
- }
42345
- return res;
42393
+ });
42394
+ req.on("socket", (socket) => {
42395
+ socket.setKeepAlive(true, 1e4);
42396
+ socket.setNoDelay(true);
42397
+ });
42398
+ req.on("error", (err) => reject(err));
42399
+ signal.addEventListener("abort", () => req.destroy(), { once: true });
42400
+ req.end();
42401
+ });
42346
42402
  }
42347
42403
  async getEncodeConfig() {
42348
42404
  return this.getConfig("Encode");
@@ -42432,19 +42488,20 @@ function subscribeAmcrestEvents(client, codes, handlers) {
42432
42488
  const controller = new AbortController();
42433
42489
  (async () => {
42434
42490
  try {
42435
- const res = await client.openEventStream(codes, controller.signal);
42436
- if (!res.ok || !res.body) {
42437
- handlers.onError(/* @__PURE__ */ new Error(`event stream HTTP ${res.status} ${res.statusText}`));
42491
+ const opened = await client.openEventStream(codes, controller.signal);
42492
+ if (opened.statusCode < 200 || opened.statusCode >= 300) {
42493
+ opened.res.resume();
42494
+ handlers.onError(/* @__PURE__ */ new Error(`event stream HTTP ${opened.statusCode}`));
42438
42495
  return;
42439
42496
  }
42440
- const ct = res.headers.get("content-type") ?? "";
42497
+ const ct = opened.contentType;
42441
42498
  const boundary = parseBoundary(ct);
42442
42499
  if (!boundary) {
42443
42500
  handlers.onError(/* @__PURE__ */ new Error(`event stream missing multipart boundary in Content-Type: "${ct}"`));
42444
42501
  return;
42445
42502
  }
42446
42503
  handlers.onConnected?.();
42447
- await pumpEventStream(res.body, boundary, handlers);
42504
+ await pumpEventStream(opened.res, boundary, handlers);
42448
42505
  if (!controller.signal.aborted) handlers.onError(/* @__PURE__ */ new Error("event stream ended"));
42449
42506
  } catch (err) {
42450
42507
  if (controller.signal.aborted) return;
@@ -42458,28 +42515,34 @@ function parseBoundary(contentType) {
42458
42515
  return m ? m[1].trim() : null;
42459
42516
  }
42460
42517
  async function pumpEventStream(stream, boundary, handlers) {
42461
- const reader = stream.getReader();
42462
42518
  const dashBoundary = `--${boundary}`;
42463
42519
  let bufferedBytes = new Uint8Array(0);
42520
+ let preambleConsumed = false;
42464
42521
  try {
42465
- while (true) {
42466
- const { value, done } = await reader.read();
42467
- if (done) break;
42468
- if (!value || value.byteLength === 0) continue;
42522
+ for await (const chunk of stream) {
42523
+ const value = chunk instanceof Uint8Array ? chunk : new Uint8Array(0);
42524
+ if (value.byteLength === 0) continue;
42525
+ handlers.onBytes?.(value.byteLength, new TextDecoder().decode(value.slice(0, 120)));
42469
42526
  bufferedBytes = concatBytes(bufferedBytes, value);
42470
- const parts = splitOnBoundary(bufferedBytes, dashBoundary);
42527
+ const parts = splitOnBoundary(bufferedBytes, dashBoundary, preambleConsumed);
42528
+ if (parts.blocks.length > 0 || parts.tail !== bufferedBytes) preambleConsumed = true;
42471
42529
  bufferedBytes = parts.tail;
42472
42530
  for (const block of parts.blocks) {
42473
42531
  const parsed = parseMultipartBlock(block);
42474
- if (!parsed) continue;
42532
+ if (!parsed) {
42533
+ handlers.onUnparsedBlock?.("block", new TextDecoder().decode(block).slice(0, 160));
42534
+ continue;
42535
+ }
42475
42536
  const ev = parseAmcrestEventBody(parsed.bodyText);
42476
- if (ev) handlers.onEvent(ev);
42537
+ if (!ev) {
42538
+ handlers.onUnparsedBlock?.("body", parsed.bodyText.slice(0, 160));
42539
+ continue;
42540
+ }
42541
+ handlers.onEvent(ev);
42477
42542
  }
42478
42543
  }
42479
42544
  } finally {
42480
- try {
42481
- reader.releaseLock();
42482
- } catch {}
42545
+ stream.destroy();
42483
42546
  }
42484
42547
  }
42485
42548
  function concatBytes(a, b) {
@@ -42488,14 +42551,32 @@ function concatBytes(a, b) {
42488
42551
  out.set(b, a.byteLength);
42489
42552
  return out;
42490
42553
  }
42491
- function splitOnBoundary(buf, dashBoundary) {
42554
+ /**
42555
+ * Split the running buffer on the multipart boundary.
42556
+ *
42557
+ * `preambleConsumed` is NOT optional bookkeeping. RFC 1521 says the bytes before
42558
+ * the FIRST boundary are a preamble to discard — but that is true exactly once
42559
+ * per stream. This function used to `shift()` unconditionally on every pump
42560
+ * cycle, and from the second cycle on the leading fragment is not a preamble at
42561
+ * all: it is the body of the part the newly-arrived boundary just CLOSED. So
42562
+ * every completed block was thrown away as preamble, `blocks` came back empty
42563
+ * forever, and the caller's loop never ran — no event, and no dropped-block
42564
+ * warning either, because there was nothing in the array to warn about.
42565
+ *
42566
+ * Cost of that one line: this camera's onboard motion never reached the
42567
+ * pipeline. It streamed video fine, reported `enabled: true`, held an
42568
+ * ESTABLISHED socket, and delivered `--myboundary` chunks that were parsed into
42569
+ * nothing. Two hand-rolled probes against the same camera worked throughout —
42570
+ * because they regex the raw lines and never split on the boundary at all.
42571
+ */
42572
+ function splitOnBoundary(buf, dashBoundary, preambleConsumed) {
42492
42573
  const parts = Buffer.from(buf).toString("binary").split(dashBoundary);
42493
42574
  if (parts.length <= 1) return {
42494
42575
  blocks: [],
42495
42576
  tail: buf
42496
42577
  };
42497
42578
  const tail = Buffer.from(parts.pop(), "binary");
42498
- parts.shift();
42579
+ if (!preambleConsumed) parts.shift();
42499
42580
  return {
42500
42581
  blocks: parts.filter((p) => p.length > 0 && !p.startsWith("--")).map((p) => Buffer.from(p, "binary")),
42501
42582
  tail
@@ -42542,6 +42623,466 @@ function parseAmcrestEventBody(body) {
42542
42623
  raw: body
42543
42624
  };
42544
42625
  }
42626
+ var DAY_NIGHT_NATIVE_TO_CAP = {
42627
+ Brightness: "auto",
42628
+ Color: "day",
42629
+ BlackWhite: "night"
42630
+ };
42631
+ var DAY_NIGHT_CAP_TO_NATIVE = {
42632
+ auto: "Brightness",
42633
+ day: "Color",
42634
+ night: "BlackWhite",
42635
+ schedule: void 0
42636
+ };
42637
+ /** Native `Mode` token → cap mode. Unknown tokens read as `auto` — the
42638
+ * status must never throw on firmware drift, and `auto` is the only
42639
+ * mode every Dahua ships in. */
42640
+ function amcrestDayNightModeToCap(raw) {
42641
+ if (raw === void 0) return "auto";
42642
+ return DAY_NIGHT_NATIVE_TO_CAP[raw] ?? "auto";
42643
+ }
42644
+ /** Cap mode → native token, or `undefined` when no verified native
42645
+ * exists (the caller warns and skips rather than guessing). */
42646
+ function capDayNightModeToAmcrest(mode) {
42647
+ return DAY_NIGHT_CAP_TO_NATIVE[mode];
42648
+ }
42649
+ /** Native 0..7 → normalized 0..100. */
42650
+ function dayNightSensitivityToPercent(native) {
42651
+ return Math.round(native / 7 * 100);
42652
+ }
42653
+ /** Normalized 0..100 → native 0..7, clamped to what the firmware accepts. */
42654
+ function percentToDayNightSensitivity(percent) {
42655
+ const native = Math.round(percent / 100 * 7);
42656
+ return Math.max(0, Math.min(7, native));
42657
+ }
42658
+ /** Clamp a requested switch delay into the firmware's 2..10 s window —
42659
+ * an out-of-window write is rejected wholesale (HTTP 400), which would
42660
+ * silently drop the ROUND TRIP, not just this field. */
42661
+ function clampDayNightDelaySec(sec) {
42662
+ return Math.max(2, Math.min(10, Math.round(sec)));
42663
+ }
42664
+ /** Native `Rotate90` int → cap degrees-clockwise. 1 was verified live
42665
+ * (snapshot flipped to 1440×2560); 2 is the documented counterpart
42666
+ * (90° ccw = 270° cw). Anything else (the field stores e.g. `3`
42667
+ * verbatim) has no defined meaning → `undefined`. */
42668
+ function amcrestRotateToCap(raw) {
42669
+ if (raw === 0) return "0";
42670
+ if (raw === 1) return "90";
42671
+ if (raw === 2) return "270";
42672
+ }
42673
+ /** Cap rotation → native `Rotate90` int. `'180'` has no Dahua
42674
+ * equivalent on this family (the vendor UI composes mirror+flip) —
42675
+ * `undefined` tells the caller to skip the write. */
42676
+ function capRotateToAmcrest(rotate) {
42677
+ if (rotate === "0") return 0;
42678
+ if (rotate === "90") return 1;
42679
+ if (rotate === "270") return 2;
42680
+ }
42681
+ var BACKLIGHT_NATIVE_TO_CAP = {
42682
+ Off: "off",
42683
+ Backlight: "blc",
42684
+ WideDynamic: "wdr",
42685
+ GlareInhibition: "hlc"
42686
+ };
42687
+ var BACKLIGHT_CAP_TO_NATIVE = {
42688
+ off: "Off",
42689
+ blc: "Backlight",
42690
+ wdr: "WideDynamic",
42691
+ hlc: "GlareInhibition"
42692
+ };
42693
+ /** Native backlight token → cap mode. The field is NOT validated by the
42694
+ * firmware (a bogus token is stored verbatim), so an unknown read maps
42695
+ * to `undefined` — reported as "no answer", never guessed. */
42696
+ function amcrestBacklightToCap(raw) {
42697
+ if (raw === void 0) return void 0;
42698
+ return BACKLIGHT_NATIVE_TO_CAP[raw];
42699
+ }
42700
+ function capBacklightToAmcrest(mode) {
42701
+ return BACKLIGHT_CAP_TO_NATIVE[mode];
42702
+ }
42703
+ /** Native exposure mode int → cap mode. The field IS validated by the
42704
+ * firmware (99 → HTTP 400). 0=Auto, 4=Manual (verified functionally:
42705
+ * Mode=4 + Value1/2=1 ms produced a near-black frame). The natives in
42706
+ * between are vendor presets (low-noise / low-motion-blur) the cap's
42707
+ * two-value enum cannot express → `undefined`, so the UI shows the
42708
+ * select unset instead of lying `auto`. */
42709
+ function amcrestExposureModeToCap(raw) {
42710
+ if (raw === 0) return "auto";
42711
+ if (raw === 4) return "manual";
42712
+ }
42713
+ function capExposureModeToAmcrest(mode) {
42714
+ return mode === "manual" ? 4 : 0;
42715
+ }
42716
+ //#endregion
42717
+ //#region src/extra-settings.ts
42718
+ /**
42719
+ * Driver-authored device-settings sections for the camera facts that no
42720
+ * vendor-neutral cap models: the IR illuminator, the status LED, NTP /
42721
+ * timezone, and the auto-reboot schedule. Taxonomy (section ids + tabs)
42722
+ * matches the Reolink driver (`ir-lights` on `image`, `time-ntp` on
42723
+ * `network`, `auto-reboot` on `advanced`) so the operator sees the same
42724
+ * layout regardless of vendor.
42725
+ *
42726
+ * D62 discipline: none of these keys is stored in the device config —
42727
+ * the CAMERA is the authority. `applySettingsPatch` maps them onto
42728
+ * `configManager.cgi?action=setConfig` writes (via
42729
+ * {@link extrasPatchToCgiParams}) and re-reads the camera into the
42730
+ * `deviceCache.extras` snapshot; `config.setAll` strips them because
42731
+ * they are not in the schema.
42732
+ *
42733
+ * Every Dahua key, token and bound below was verified against the live
42734
+ * IP4M-1041B (firmware 2.800.1ENN001.0.R) on 2026-09-04:
42735
+ * - `Lighting[0][0].Mode` — Auto/Manual/Off. The firmware stores ANY
42736
+ * string verbatim (even `TotallyBogusMode`), so THIS module is the
42737
+ * validator. `Manual` + `MiddleLight[0].Light=100` was proven
42738
+ * functionally: with the IR-cut forced open, the dark half of the
42739
+ * frame lit up; `Off` left it dark.
42740
+ * - `LightGlobal[0].Enable` — status-LED switch, boolean round-trip.
42741
+ * - `NTP.*` — `TimeZone` accepts 0..33 (60 → HTTP 400); `Enable`,
42742
+ * `Address`, `Port`, `UpdatePeriod` (minutes) all round-trip.
42743
+ * - `AutoMaintain.AutoReboot{Enable,Day,Hour,Minute}` — `Day` accepts
42744
+ * -1..7 (8 → HTTP 400); 0..6 = Sunday..Saturday, 7 = every day.
42745
+ */
42746
+ /** Read-projection of the four extras config tables. All fields optional
42747
+ * — a table the camera omitted stays absent (unknown, never fabricated). */
42748
+ var amcrestExtrasSnapshotSchema = object({
42749
+ /** `Lighting[0][0].Mode` — only the verified enum; an unknown stored
42750
+ * token reads as absent. */
42751
+ irLightMode: _enum([
42752
+ "Auto",
42753
+ "Manual",
42754
+ "Off"
42755
+ ]),
42756
+ /** `Lighting[0][0].MiddleLight[0].Light` 0..100. */
42757
+ irLightBrightness: number(),
42758
+ /** `LightGlobal[0].Enable`. */
42759
+ statusLedEnabled: boolean(),
42760
+ ntpEnabled: boolean(),
42761
+ ntpServer: string(),
42762
+ ntpPort: number(),
42763
+ /** `NTP.UpdatePeriod`, minutes. */
42764
+ ntpUpdatePeriodMin: number(),
42765
+ /** Dahua timezone index (`NTP.TimeZone`), 0..33. */
42766
+ ntpTimeZoneIndex: number(),
42767
+ autoRebootEnabled: boolean(),
42768
+ /** 0..6 = Sunday..Saturday, 7 = every day. */
42769
+ autoRebootDay: number(),
42770
+ autoRebootHour: number(),
42771
+ autoRebootMinute: number(),
42772
+ /** Wall-clock ms of the read that produced this snapshot. */
42773
+ fetchedAt: number()
42774
+ }).partial();
42775
+ /** The config tables one multi-name `getConfig` call reads to build the
42776
+ * snapshot (the CGI accepts repeated `name=` params — verified live). */
42777
+ var AMCREST_EXTRAS_CONFIG_NAMES = [
42778
+ "Lighting",
42779
+ "LightGlobal",
42780
+ "NTP",
42781
+ "AutoMaintain"
42782
+ ];
42783
+ var IR_LIGHT_MODES = [
42784
+ "Auto",
42785
+ "Manual",
42786
+ "Off"
42787
+ ];
42788
+ function isIrLightMode(value) {
42789
+ return IR_LIGHT_MODES.includes(value);
42790
+ }
42791
+ function intOrUndefined(raw) {
42792
+ if (raw === void 0) return void 0;
42793
+ const n = Number.parseInt(raw, 10);
42794
+ return Number.isFinite(n) ? n : void 0;
42795
+ }
42796
+ function boolOrUndefined(raw) {
42797
+ if (raw === "true") return true;
42798
+ if (raw === "false") return false;
42799
+ }
42800
+ /** Project the flat Dahua table (multi-name getConfig result) into the
42801
+ * extras snapshot. Absent keys stay absent. */
42802
+ function parseExtrasSnapshot(table, now) {
42803
+ const out = { fetchedAt: now };
42804
+ const modeRaw = table.get("Lighting[0][0].Mode");
42805
+ if (modeRaw !== void 0 && isIrLightMode(modeRaw)) out.irLightMode = modeRaw;
42806
+ const light = intOrUndefined(table.get("Lighting[0][0].MiddleLight[0].Light"));
42807
+ if (light !== void 0) out.irLightBrightness = light;
42808
+ const led = boolOrUndefined(table.get("LightGlobal[0].Enable"));
42809
+ if (led !== void 0) out.statusLedEnabled = led;
42810
+ const ntpEnabled = boolOrUndefined(table.get("NTP.Enable"));
42811
+ if (ntpEnabled !== void 0) out.ntpEnabled = ntpEnabled;
42812
+ const ntpServer = table.get("NTP.Address");
42813
+ if (ntpServer !== void 0) out.ntpServer = ntpServer;
42814
+ const ntpPort = intOrUndefined(table.get("NTP.Port"));
42815
+ if (ntpPort !== void 0) out.ntpPort = ntpPort;
42816
+ const period = intOrUndefined(table.get("NTP.UpdatePeriod"));
42817
+ if (period !== void 0) out.ntpUpdatePeriodMin = period;
42818
+ const tz = intOrUndefined(table.get("NTP.TimeZone"));
42819
+ if (tz !== void 0) out.ntpTimeZoneIndex = tz;
42820
+ const rebootEnabled = boolOrUndefined(table.get("AutoMaintain.AutoRebootEnable"));
42821
+ if (rebootEnabled !== void 0) out.autoRebootEnabled = rebootEnabled;
42822
+ const rebootDay = intOrUndefined(table.get("AutoMaintain.AutoRebootDay"));
42823
+ if (rebootDay !== void 0) out.autoRebootDay = rebootDay;
42824
+ const rebootHour = intOrUndefined(table.get("AutoMaintain.AutoRebootHour"));
42825
+ if (rebootHour !== void 0) out.autoRebootHour = rebootHour;
42826
+ const rebootMinute = intOrUndefined(table.get("AutoMaintain.AutoRebootMinute"));
42827
+ if (rebootMinute !== void 0) out.autoRebootMinute = rebootMinute;
42828
+ return out;
42829
+ }
42830
+ /**
42831
+ * Build the three extras sections from the last camera read. NO snapshot
42832
+ * → NO sections: rendering fabricated defaults for a camera nobody has
42833
+ * reached would make not-yet-known look like known (D337-shape), and a
42834
+ * Save against those phantoms would overwrite real camera state.
42835
+ */
42836
+ function buildExtrasSections(snap) {
42837
+ if (snap === void 0) return [];
42838
+ return [
42839
+ {
42840
+ id: "ir-lights",
42841
+ tab: "image",
42842
+ title: "IR + status LED",
42843
+ description: "IR illuminator (`Lighting[0][0]`, separate from the IR-cut filter on the Image tab) + the visible status LED (`LightGlobal[0].Enable`). `Auto` lets the camera drive the IR LEDs from ambient light; `Manual` forces them on at the set brightness; `Off` disables them. All pushed over Dahua CGI `setConfig` on save.",
42844
+ columns: 2,
42845
+ fields: [
42846
+ {
42847
+ type: "select",
42848
+ key: "irLightMode",
42849
+ label: "IR illuminator",
42850
+ default: snap.irLightMode ?? "Auto",
42851
+ options: [
42852
+ {
42853
+ value: "Auto",
42854
+ label: "Auto (camera decides)"
42855
+ },
42856
+ {
42857
+ value: "Manual",
42858
+ label: "Always on"
42859
+ },
42860
+ {
42861
+ value: "Off",
42862
+ label: "Always off"
42863
+ }
42864
+ ]
42865
+ },
42866
+ {
42867
+ type: "slider",
42868
+ key: "irLightBrightness",
42869
+ label: "IR brightness",
42870
+ description: "IR LED output (`MiddleLight[0].Light`, 0–100). Applied in Auto and Manual modes.",
42871
+ min: 0,
42872
+ max: 100,
42873
+ step: 5,
42874
+ default: snap.irLightBrightness ?? 50,
42875
+ showValue: true,
42876
+ showStepper: true
42877
+ },
42878
+ {
42879
+ type: "boolean",
42880
+ key: "statusLedEnabled",
42881
+ label: "Status LED",
42882
+ description: "The visible indicator LED on the camera body (`LightGlobal[0].Enable`).",
42883
+ default: snap.statusLedEnabled ?? true,
42884
+ style: "switch",
42885
+ span: 2
42886
+ }
42887
+ ]
42888
+ },
42889
+ {
42890
+ id: "time-ntp",
42891
+ tab: "network",
42892
+ title: "Time / NTP",
42893
+ description: "Network time sync (`NTP.*` over Dahua CGI). The timezone is the Dahua numeric index (0–33) exactly as listed in the camera web UI — 26 is the GMT+8 factory default. NOTE: with NTP disabled this camera drifts badly (it currently believes the year is 2000).",
42894
+ columns: 2,
42895
+ fields: [
42896
+ {
42897
+ type: "boolean",
42898
+ key: "ntpEnabled",
42899
+ label: "NTP sync",
42900
+ default: snap.ntpEnabled ?? false,
42901
+ style: "switch",
42902
+ span: 2
42903
+ },
42904
+ {
42905
+ type: "text",
42906
+ key: "ntpServer",
42907
+ label: "NTP server",
42908
+ default: snap.ntpServer ?? "pool.ntp.org",
42909
+ placeholder: "pool.ntp.org",
42910
+ span: 2
42911
+ },
42912
+ {
42913
+ type: "number",
42914
+ key: "ntpPort",
42915
+ label: "NTP port",
42916
+ default: snap.ntpPort ?? 123,
42917
+ min: 1,
42918
+ max: 65535,
42919
+ step: 1
42920
+ },
42921
+ {
42922
+ type: "number",
42923
+ key: "ntpUpdatePeriodMin",
42924
+ label: "Sync interval (min)",
42925
+ default: snap.ntpUpdatePeriodMin ?? 10,
42926
+ min: 1,
42927
+ max: 65535,
42928
+ step: 1
42929
+ },
42930
+ {
42931
+ type: "number",
42932
+ key: "ntpTimeZoneIndex",
42933
+ label: "Timezone index (Dahua, 0–33)",
42934
+ description: "Numeric index from the camera web UI timezone list — the firmware rejects anything above 33. Not an IANA zone: the Dahua CGI only speaks this index. 26 = GMT+8 (factory default).",
42935
+ default: snap.ntpTimeZoneIndex ?? 26,
42936
+ min: 0,
42937
+ max: 33,
42938
+ step: 1,
42939
+ span: 2
42940
+ }
42941
+ ]
42942
+ },
42943
+ {
42944
+ id: "auto-reboot",
42945
+ tab: "advanced",
42946
+ title: "Auto reboot",
42947
+ description: "Scheduled automatic reboot (`AutoMaintain.AutoReboot*`). Choose a day (or every day) + time; the camera reboots once per matching window.",
42948
+ columns: 2,
42949
+ fields: [
42950
+ {
42951
+ type: "boolean",
42952
+ key: "autoRebootEnabled",
42953
+ label: "Auto reboot",
42954
+ default: snap.autoRebootEnabled ?? false,
42955
+ style: "switch",
42956
+ span: 2
42957
+ },
42958
+ {
42959
+ type: "select",
42960
+ key: "autoRebootDay",
42961
+ label: "Day",
42962
+ default: String(snap.autoRebootDay ?? 7),
42963
+ span: 2,
42964
+ options: [
42965
+ {
42966
+ value: "7",
42967
+ label: "Every day"
42968
+ },
42969
+ {
42970
+ value: "0",
42971
+ label: "Sunday"
42972
+ },
42973
+ {
42974
+ value: "1",
42975
+ label: "Monday"
42976
+ },
42977
+ {
42978
+ value: "2",
42979
+ label: "Tuesday"
42980
+ },
42981
+ {
42982
+ value: "3",
42983
+ label: "Wednesday"
42984
+ },
42985
+ {
42986
+ value: "4",
42987
+ label: "Thursday"
42988
+ },
42989
+ {
42990
+ value: "5",
42991
+ label: "Friday"
42992
+ },
42993
+ {
42994
+ value: "6",
42995
+ label: "Saturday"
42996
+ }
42997
+ ]
42998
+ },
42999
+ {
43000
+ type: "number",
43001
+ key: "autoRebootHour",
43002
+ label: "Hour (0-23)",
43003
+ default: snap.autoRebootHour ?? 2,
43004
+ min: 0,
43005
+ max: 23,
43006
+ step: 1
43007
+ },
43008
+ {
43009
+ type: "number",
43010
+ key: "autoRebootMinute",
43011
+ label: "Minute (0-59)",
43012
+ default: snap.autoRebootMinute ?? 30,
43013
+ min: 0,
43014
+ max: 59,
43015
+ step: 1
43016
+ }
43017
+ ]
43018
+ }
43019
+ ];
43020
+ }
43021
+ function clampInt(value, min, max) {
43022
+ return Math.max(min, Math.min(max, Math.round(value)));
43023
+ }
43024
+ /** Coerce a select/number value that may arrive as a numeric string. */
43025
+ function asNumber(value) {
43026
+ if (typeof value === "number" && Number.isFinite(value)) return value;
43027
+ if (typeof value === "string" && value.trim() !== "") {
43028
+ const n = Number(value);
43029
+ if (Number.isFinite(n)) return n;
43030
+ }
43031
+ }
43032
+ /**
43033
+ * Map an extras settings patch onto Dahua `setConfig` params. Wrongly
43034
+ * typed values are DROPPED, and every numeric is clamped into its
43035
+ * live-verified writable window — an out-of-window value would fail the
43036
+ * whole multi-key CGI write with HTTP 400, silently losing the other
43037
+ * fields in the same save.
43038
+ */
43039
+ function extrasPatchToCgiParams(patch) {
43040
+ const params = {};
43041
+ const handled = [];
43042
+ const consume = (key) => {
43043
+ if (key in patch) handled.push(key);
43044
+ };
43045
+ consume("irLightMode");
43046
+ const irMode = patch["irLightMode"];
43047
+ if (typeof irMode === "string" && isIrLightMode(irMode)) params["Lighting[0][0].Mode"] = irMode;
43048
+ consume("irLightBrightness");
43049
+ const irBrightness = asNumber(patch["irLightBrightness"]);
43050
+ if (irBrightness !== void 0) params["Lighting[0][0].MiddleLight[0].Light"] = clampInt(irBrightness, 0, 100);
43051
+ consume("statusLedEnabled");
43052
+ const led = patch["statusLedEnabled"];
43053
+ if (typeof led === "boolean") params["LightGlobal[0].Enable"] = led ? "true" : "false";
43054
+ consume("ntpEnabled");
43055
+ const ntpEnabled = patch["ntpEnabled"];
43056
+ if (typeof ntpEnabled === "boolean") params["NTP.Enable"] = ntpEnabled ? "true" : "false";
43057
+ consume("ntpServer");
43058
+ const ntpServer = patch["ntpServer"];
43059
+ if (typeof ntpServer === "string" && ntpServer.trim() !== "") params["NTP.Address"] = ntpServer.trim();
43060
+ consume("ntpPort");
43061
+ const ntpPort = asNumber(patch["ntpPort"]);
43062
+ if (ntpPort !== void 0) params["NTP.Port"] = clampInt(ntpPort, 1, 65535);
43063
+ consume("ntpUpdatePeriodMin");
43064
+ const ntpPeriod = asNumber(patch["ntpUpdatePeriodMin"]);
43065
+ if (ntpPeriod !== void 0) params["NTP.UpdatePeriod"] = clampInt(ntpPeriod, 1, 65535);
43066
+ consume("ntpTimeZoneIndex");
43067
+ const tz = asNumber(patch["ntpTimeZoneIndex"]);
43068
+ if (tz !== void 0) params["NTP.TimeZone"] = clampInt(tz, 0, 33);
43069
+ consume("autoRebootEnabled");
43070
+ const rebootEnabled = patch["autoRebootEnabled"];
43071
+ if (typeof rebootEnabled === "boolean") params["AutoMaintain.AutoRebootEnable"] = rebootEnabled ? "true" : "false";
43072
+ consume("autoRebootDay");
43073
+ const rebootDay = asNumber(patch["autoRebootDay"]);
43074
+ if (rebootDay !== void 0) params["AutoMaintain.AutoRebootDay"] = clampInt(rebootDay, 0, 7);
43075
+ consume("autoRebootHour");
43076
+ const rebootHour = asNumber(patch["autoRebootHour"]);
43077
+ if (rebootHour !== void 0) params["AutoMaintain.AutoRebootHour"] = clampInt(rebootHour, 0, 23);
43078
+ consume("autoRebootMinute");
43079
+ const rebootMinute = asNumber(patch["autoRebootMinute"]);
43080
+ if (rebootMinute !== void 0) params["AutoMaintain.AutoRebootMinute"] = clampInt(rebootMinute, 0, 59);
43081
+ return {
43082
+ params,
43083
+ handled
43084
+ };
43085
+ }
42545
43086
  //#endregion
42546
43087
  //#region src/schema.ts
42547
43088
  /** Addon id — must match `package.json`'s `camstack.addons[0].id`. */
@@ -42561,7 +43102,14 @@ var amcrestDeviceCacheSchema = object({
42561
43102
  processor: string(),
42562
43103
  mac: string(),
42563
43104
  hasPtz: boolean(),
42564
- probedAt: number()
43105
+ probedAt: number(),
43106
+ /**
43107
+ * Last camera read of the driver-authored extras (IR illuminator,
43108
+ * status LED, NTP, auto-reboot). Seeds the settings-form defaults —
43109
+ * the CAMERA stays the authority (D62): saves write CGI and re-read
43110
+ * into this snapshot, never into standalone config keys.
43111
+ */
43112
+ extras: amcrestExtrasSnapshotSchema
42565
43113
  }).partial();
42566
43114
  /**
42567
43115
  * Amcrest/Dahua camera hardware config. `name`/`location` are base
@@ -42648,6 +43196,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
42648
43196
  static MOTION_KEEPALIVE_MS = 1e4;
42649
43197
  /** Active event-stream subscription; aborted on dispose / conn change. */
42650
43198
  eventStreamController = null;
43199
+ /** Dedicated CGI client for the long-poll — see {@link ensureEventClient}. */
43200
+ eventClient = null;
42651
43201
  /** Reconnect attempt counter — drives exponential backoff. */
42652
43202
  eventStreamReconnectAttempts = 0;
42653
43203
  /** Pending reconnect timer. */
@@ -42676,23 +43226,77 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
42676
43226
  }
42677
43227
  ensureClient() {
42678
43228
  if (this.client) return this.client;
43229
+ this.client = this.buildClient();
43230
+ return this.client;
43231
+ }
43232
+ /**
43233
+ * A SECOND client, used only by the event-stream long-poll.
43234
+ *
43235
+ * `AmcrestCgiClient` carries one `DigestState` — a nonce plus a monotonic
43236
+ * nonce-count — and reuses it across every request so a warm session skips
43237
+ * the 401 round-trip. That is right for the ~20 short CGI calls this device
43238
+ * makes (snapshot, encode config, PTZ caps, OSD, …) and wrong for a
43239
+ * subscription that must stay authenticated for hours: the short calls keep
43240
+ * advancing `nc` on the shared state underneath it.
43241
+ *
43242
+ * The long-poll therefore gets its own client and its own digest state, which
43243
+ * is the last remaining difference between the provider's subscription and
43244
+ * the hand-rolled probes that DID receive events from this camera — same
43245
+ * host, same `codes` filter, same moment, three connections, only ours silent.
43246
+ */
43247
+ ensureEventClient() {
43248
+ if (this.eventClient) return this.eventClient;
43249
+ this.eventClient = this.buildClient();
43250
+ return this.eventClient;
43251
+ }
43252
+ buildClient() {
42679
43253
  const https = this.config.get("https") ?? false;
42680
- this.client = new AmcrestCgiClient({
43254
+ return new AmcrestCgiClient({
42681
43255
  host: this.config.get("host"),
42682
43256
  port: this.config.get("port") ?? (https ? 443 : 80),
42683
43257
  https,
42684
43258
  username: this.config.get("username") ?? "admin",
42685
43259
  password: this.config.get("password")
42686
43260
  });
42687
- return this.client;
42688
43261
  }
42689
- /** Drop the cached CGI client so the next call rebuilds it from the
42690
- * (possibly just-edited) credentials/host. */
43262
+ /**
43263
+ * Settings-save dispatcher. Two kinds of keys arrive here:
43264
+ *
43265
+ * - Extras keys (IR illuminator / status LED / NTP / auto-reboot) —
43266
+ * camera-authoritative (D62). They are pushed to the camera over
43267
+ * CGI `setConfig` and re-read into `deviceCache.extras`; they are
43268
+ * NOT in the config schema, so `config.setAll` strips them and no
43269
+ * second store ever exists.
43270
+ * - Config keys (host / credentials / channel) — persisted by the
43271
+ * base path; a connection-shape change additionally drops the
43272
+ * cached CGI clients so the next call rebuilds them.
43273
+ */
42691
43274
  async applySettingsPatch(patch) {
43275
+ const { params: extrasParams, handled: extrasKeys } = extrasPatchToCgiParams(patch);
43276
+ if (Object.keys(extrasParams).length > 0) {
43277
+ await this.ensureClient().setConfig(extrasParams);
43278
+ this.ctx.logger.info("amcrest: extras settings written to camera", {
43279
+ tags: { deviceId: this.id },
43280
+ meta: {
43281
+ keys: extrasKeys,
43282
+ cgiKeys: Object.keys(extrasParams)
43283
+ }
43284
+ });
43285
+ await this.refreshExtrasSnapshot().catch((err) => {
43286
+ this.ctx.logger.warn("amcrest: extras snapshot re-read after save failed", {
43287
+ tags: { deviceId: this.id },
43288
+ meta: { error: err instanceof Error ? err.message : String(err) }
43289
+ });
43290
+ });
43291
+ } else if (extrasKeys.length > 0) this.ctx.logger.warn("amcrest: extras settings patch carried no writable value — dropped", {
43292
+ tags: { deviceId: this.id },
43293
+ meta: { keys: extrasKeys }
43294
+ });
42692
43295
  await super.applySettingsPatch(patch);
42693
43296
  if ("host" in patch || "port" in patch || "https" in patch || "username" in patch || "password" in patch) {
42694
43297
  this.clearPtzAutoStop();
42695
43298
  this.client = null;
43299
+ this.eventClient = null;
42696
43300
  const hadStream = this.eventStreamController !== null || this.eventStreamReconnectTimer !== null;
42697
43301
  this.teardownEventSubscription();
42698
43302
  if (hadStream) this.ensureEventSubscription();
@@ -42735,14 +43339,42 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
42735
43339
  /** Idempotently open the eventManager attach stream. */
42736
43340
  ensureEventSubscription() {
42737
43341
  if (this.eventStreamController) return;
42738
- const client = this.ensureClient();
43342
+ const client = this.ensureEventClient();
42739
43343
  this.eventStreamController = subscribeAmcrestEvents(client, AmcrestCamera.MOTION_EVENT_CODES, {
42740
43344
  onConnected: () => {
42741
43345
  this.eventStreamReconnectAttempts = 0;
42742
43346
  if (this.motionActive) this.onMotionStop(Date.now());
42743
43347
  this.ctx.logger.info("amcrest: event stream connected", { tags: { deviceId: this.id } });
42744
43348
  },
42745
- onEvent: (ev) => this.handleAmcrestEvent(ev),
43349
+ onEvent: (ev) => {
43350
+ this.ctx.logger.info("amcrest: firmware event received", {
43351
+ tags: { deviceId: this.id },
43352
+ meta: {
43353
+ code: ev.code,
43354
+ action: ev.action,
43355
+ index: ev.index
43356
+ }
43357
+ });
43358
+ this.handleAmcrestEvent(ev);
43359
+ },
43360
+ onBytes: (byteLength, preview) => {
43361
+ this.ctx.logger.info("amcrest: event stream bytes", {
43362
+ tags: { deviceId: this.id },
43363
+ meta: {
43364
+ byteLength,
43365
+ preview
43366
+ }
43367
+ });
43368
+ },
43369
+ onUnparsedBlock: (reason, preview) => {
43370
+ this.ctx.logger.warn("amcrest: event block dropped — could not be parsed", {
43371
+ tags: { deviceId: this.id },
43372
+ meta: {
43373
+ reason,
43374
+ preview
43375
+ }
43376
+ });
43377
+ },
42746
43378
  onError: (err) => {
42747
43379
  this.ctx.logger.warn("amcrest: event stream error — will reconnect", {
42748
43380
  tags: { deviceId: this.id },
@@ -42833,6 +43465,133 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
42833
43465
  async onActivate() {
42834
43466
  this.ensureEventSubscription();
42835
43467
  this.startReachabilityPolling();
43468
+ this.refreshExtrasSnapshot().catch(() => {});
43469
+ }
43470
+ /** Single-flight guard for the extras snapshot refresh. */
43471
+ extrasRefreshInFlight = null;
43472
+ /** Debounce for the serve-and-revalidate kick on form open. */
43473
+ lastExtrasRevalidateKickAt = 0;
43474
+ static EXTRAS_REVALIDATE_MIN_MS = 3e4;
43475
+ /**
43476
+ * Read the four extras tables in one multi-name CGI round-trip and
43477
+ * persist the projection into `deviceCache.extras`. The camera stays
43478
+ * the authority — this snapshot only seeds form defaults (D62/D224).
43479
+ */
43480
+ async refreshExtrasSnapshot() {
43481
+ if (this.extrasRefreshInFlight) return this.extrasRefreshInFlight;
43482
+ const promise = (async () => {
43483
+ try {
43484
+ const extras = parseExtrasSnapshot(await this.ensureClient().getConfigs([...AMCREST_EXTRAS_CONFIG_NAMES]), Date.now());
43485
+ const cache = this.config.get("deviceCache") ?? {};
43486
+ await this.config.setAll({ deviceCache: {
43487
+ ...cache,
43488
+ extras
43489
+ } });
43490
+ } catch (err) {
43491
+ this.ctx.logger.warn("amcrest: extras snapshot refresh failed — keeping last snapshot", {
43492
+ tags: { deviceId: this.id },
43493
+ meta: { error: err instanceof Error ? err.message : String(err) }
43494
+ });
43495
+ }
43496
+ })();
43497
+ this.extrasRefreshInFlight = promise;
43498
+ try {
43499
+ await promise;
43500
+ } finally {
43501
+ this.extrasRefreshInFlight = null;
43502
+ }
43503
+ }
43504
+ /**
43505
+ * Driver settings form: the Connection card (same taxonomy as the
43506
+ * Hikvision/Reolink drivers) + the extras sections built from the last
43507
+ * camera read. Extras values are served from the persisted snapshot
43508
+ * and revalidated BEHIND the open (serve-and-revalidate, D224) — this
43509
+ * method is sync and must never dial the camera on the render path.
43510
+ */
43511
+ getSettingsUISchema() {
43512
+ const extras = this.config.get("deviceCache")?.extras;
43513
+ const now = Date.now();
43514
+ if ((extras?.fetchedAt === void 0 || now - extras.fetchedAt > 6e5) && now - this.lastExtrasRevalidateKickAt > AmcrestCamera.EXTRAS_REVALIDATE_MIN_MS) {
43515
+ this.lastExtrasRevalidateKickAt = now;
43516
+ this.refreshExtrasSnapshot().catch(() => {});
43517
+ }
43518
+ const schema = { sections: [{
43519
+ id: "connection",
43520
+ tab: "general",
43521
+ title: "Connection",
43522
+ description: "Dahua CGI HTTP(S) endpoint + Digest credentials. RTSP is always port 554.",
43523
+ columns: 2,
43524
+ fields: [
43525
+ {
43526
+ type: "text",
43527
+ key: "host",
43528
+ label: "Camera IP",
43529
+ required: true,
43530
+ placeholder: "192.168.1.100"
43531
+ },
43532
+ {
43533
+ type: "number",
43534
+ key: "port",
43535
+ label: "Port",
43536
+ default: 80,
43537
+ min: 1,
43538
+ max: 65535,
43539
+ step: 1
43540
+ },
43541
+ {
43542
+ type: "boolean",
43543
+ key: "https",
43544
+ label: "Use HTTPS",
43545
+ default: false,
43546
+ style: "switch"
43547
+ },
43548
+ {
43549
+ type: "number",
43550
+ key: "channel",
43551
+ label: "Channel (1-based)",
43552
+ default: 1,
43553
+ min: 1,
43554
+ max: 64,
43555
+ step: 1
43556
+ },
43557
+ {
43558
+ type: "text",
43559
+ key: "username",
43560
+ label: "Username",
43561
+ default: "admin"
43562
+ },
43563
+ {
43564
+ type: "password",
43565
+ key: "password",
43566
+ label: "Password",
43567
+ required: true,
43568
+ showToggle: true
43569
+ }
43570
+ ]
43571
+ }, ...buildExtrasSections(extras)] };
43572
+ const values = {
43573
+ host: this.config.get("host"),
43574
+ port: this.config.get("port"),
43575
+ https: this.config.get("https"),
43576
+ channel: this.config.get("channel"),
43577
+ username: this.config.get("username"),
43578
+ password: this.config.get("password")
43579
+ };
43580
+ if (extras !== void 0) {
43581
+ if (extras.irLightMode !== void 0) values["irLightMode"] = extras.irLightMode;
43582
+ if (extras.irLightBrightness !== void 0) values["irLightBrightness"] = extras.irLightBrightness;
43583
+ if (extras.statusLedEnabled !== void 0) values["statusLedEnabled"] = extras.statusLedEnabled;
43584
+ if (extras.ntpEnabled !== void 0) values["ntpEnabled"] = extras.ntpEnabled;
43585
+ if (extras.ntpServer !== void 0) values["ntpServer"] = extras.ntpServer;
43586
+ if (extras.ntpPort !== void 0) values["ntpPort"] = extras.ntpPort;
43587
+ if (extras.ntpUpdatePeriodMin !== void 0) values["ntpUpdatePeriodMin"] = extras.ntpUpdatePeriodMin;
43588
+ if (extras.ntpTimeZoneIndex !== void 0) values["ntpTimeZoneIndex"] = extras.ntpTimeZoneIndex;
43589
+ if (extras.autoRebootEnabled !== void 0) values["autoRebootEnabled"] = extras.autoRebootEnabled;
43590
+ if (extras.autoRebootDay !== void 0) values["autoRebootDay"] = String(extras.autoRebootDay);
43591
+ if (extras.autoRebootHour !== void 0) values["autoRebootHour"] = extras.autoRebootHour;
43592
+ if (extras.autoRebootMinute !== void 0) values["autoRebootMinute"] = extras.autoRebootMinute;
43593
+ }
43594
+ return hydrateSchema(schema, values);
42836
43595
  }
42837
43596
  /** Start the control-plane reachability poll: a Dahua CGI `getDeviceInfo`
42838
43597
  * round-trip every 30s drives `device.online`, with hysteresis. Replaces
@@ -43471,7 +44230,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43471
44230
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
43472
44231
  const promise = (async () => {
43473
44232
  try {
43474
- const table = await this.ensureClient().getConfig("PrivacyMasking");
44233
+ const table = await this.ensureClient().getConfigs(["PrivacyMasking", "Encode"]);
43475
44234
  const regions = [];
43476
44235
  for (let i = 0; i < maxRegions; i++) {
43477
44236
  const prefix = `PrivacyMasking[0][${i}]`;
@@ -43492,10 +44251,11 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43492
44251
  }
43493
44252
  });
43494
44253
  }
44254
+ const audioProfiles = collectAudioEnableKeys(table).map((key) => ({ audioEnabled: table.get(key) === "true" }));
43495
44255
  const next = {
43496
44256
  enabled: regions.length > 0,
43497
44257
  regions,
43498
- audioEnabled: null,
44258
+ audioEnabled: summarisePrivacyAudio(audioProfiles),
43499
44259
  lastFetchedAt: Date.now()
43500
44260
  };
43501
44261
  this.runtimeState.setCapState(CAP_NAME, next);
@@ -43536,10 +44296,27 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43536
44296
  supportsAudioMute: false
43537
44297
  };
43538
44298
  if (deviceId !== this.id) return cold;
43539
- return cold;
44299
+ return {
44300
+ ...cold,
44301
+ supportsAudioMute: true
44302
+ };
43540
44303
  },
43541
- setAudioEnabled: async () => {
43542
- throw new Error(`device ${String(this.id)}: the amcrest provider does not implement the camera microphone switch`);
44304
+ setAudioEnabled: async ({ deviceId, enabled }) => {
44305
+ if (deviceId !== this.id) return;
44306
+ const client = this.ensureClient();
44307
+ const keys = collectAudioEnableKeys(await client.getEncodeConfig());
44308
+ if (keys.length === 0) throw new Error(`device ${String(this.id)}: camera reported no Encode AudioEnable flags — microphone switch unavailable`);
44309
+ const params = {};
44310
+ for (const key of keys) params[key] = enabled ? "true" : "false";
44311
+ await client.setConfig(params);
44312
+ this.ctx.logger.info("amcrest: camera microphone flags written", {
44313
+ tags: { deviceId: this.id },
44314
+ meta: {
44315
+ enabled,
44316
+ profileCount: keys.length
44317
+ }
44318
+ });
44319
+ await refreshFromCamera();
43543
44320
  },
43544
44321
  setMask: async ({ deviceId, patch }) => {
43545
44322
  if (deviceId !== this.id) return;
@@ -43638,8 +44415,6 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43638
44415
  });
43639
44416
  this.ctx.logger.info("amcrest: osd cap registered", { tags: { deviceId: this.id } });
43640
44417
  }
43641
- static DAY_NIGHT_SENSITIVITY_NATIVE_MAX = 3;
43642
- static DAY_NIGHT_DELAY_MAX_SEC = 600;
43643
44418
  registerDayNightCap() {
43644
44419
  const CAP_NAME = "day-night";
43645
44420
  const STALE_MS = 1e4;
@@ -43651,10 +44426,10 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43651
44426
  const sensitivityNative = parseIntOrUndefined(table.get("VideoInDayNight[0][0].Sensitivity"));
43652
44427
  const delaySec = parseIntOrUndefined(table.get("VideoInDayNight[0][0].Delay"));
43653
44428
  const next = {
43654
- mode: "auto",
44429
+ mode: amcrestDayNightModeToCap(table.get("VideoInDayNight[0][0].Mode")),
43655
44430
  lastFetchedAt: Date.now()
43656
44431
  };
43657
- if (sensitivityNative !== void 0) next.sensitivity = Math.round(sensitivityNative / AmcrestCamera.DAY_NIGHT_SENSITIVITY_NATIVE_MAX * 100);
44432
+ if (sensitivityNative !== void 0) next.sensitivity = dayNightSensitivityToPercent(sensitivityNative);
43658
44433
  if (delaySec !== void 0) next.switchDelaySec = delaySec;
43659
44434
  this.runtimeState.setCapState(CAP_NAME, next);
43660
44435
  } catch (err) {
@@ -43692,7 +44467,11 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43692
44467
  };
43693
44468
  if (deviceId !== this.id) return cold;
43694
44469
  return {
43695
- modes: ["auto"],
44470
+ modes: [
44471
+ "auto",
44472
+ "day",
44473
+ "night"
44474
+ ],
43696
44475
  supportsSensitivity: true,
43697
44476
  sensitivity: {
43698
44477
  min: 0,
@@ -43701,8 +44480,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43701
44480
  },
43702
44481
  supportsSwitchDelay: true,
43703
44482
  switchDelaySec: {
43704
- min: 0,
43705
- max: AmcrestCamera.DAY_NIGHT_DELAY_MAX_SEC,
44483
+ min: 2,
44484
+ max: 10,
43706
44485
  step: 1
43707
44486
  }
43708
44487
  };
@@ -43710,13 +44489,16 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43710
44489
  setSettings: async ({ deviceId, settings }) => {
43711
44490
  if (deviceId !== this.id) return;
43712
44491
  const params = {};
43713
- if (settings.mode !== void 0 && settings.mode !== "auto") this.ctx.logger.warn("amcrest: day-night mode other than \"auto\" is not wired (unverified Dahua enum) — ignored", {
43714
- tags: { deviceId: this.id },
43715
- meta: { requestedMode: settings.mode }
43716
- });
43717
- else if (settings.mode === "auto") params["VideoInDayNight[0][0].Mode"] = "Brightness";
43718
- if (settings.sensitivity !== void 0) params["VideoInDayNight[0][0].Sensitivity"] = Math.round(settings.sensitivity / 100 * AmcrestCamera.DAY_NIGHT_SENSITIVITY_NATIVE_MAX);
43719
- if (settings.switchDelaySec !== void 0) params["VideoInDayNight[0][0].Delay"] = settings.switchDelaySec;
44492
+ if (settings.mode !== void 0) {
44493
+ const nativeMode = capDayNightModeToAmcrest(settings.mode);
44494
+ if (nativeMode === void 0) this.ctx.logger.warn("amcrest: day-night mode has no verified Dahua token — ignored", {
44495
+ tags: { deviceId: this.id },
44496
+ meta: { requestedMode: settings.mode }
44497
+ });
44498
+ else params["VideoInDayNight[0][0].Mode"] = nativeMode;
44499
+ }
44500
+ if (settings.sensitivity !== void 0) params["VideoInDayNight[0][0].Sensitivity"] = percentToDayNightSensitivity(settings.sensitivity);
44501
+ if (settings.switchDelaySec !== void 0) params["VideoInDayNight[0][0].Delay"] = clampDayNightDelaySec(settings.switchDelaySec);
43720
44502
  if (Object.keys(params).length > 0) await this.ensureClient().setConfig(params);
43721
44503
  await refreshFromCamera();
43722
44504
  }
@@ -43736,15 +44518,34 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43736
44518
  if (this.imageSettingsRefreshInFlight) return this.imageSettingsRefreshInFlight;
43737
44519
  const promise = (async () => {
43738
44520
  try {
43739
- const table = await this.ensureClient().getConfig("VideoColor");
44521
+ const table = await this.ensureClient().getConfigs([
44522
+ "VideoColor",
44523
+ "VideoInSharpness",
44524
+ "VideoInOptions",
44525
+ "VideoImageControl",
44526
+ "VideoInExposure",
44527
+ "VideoInBacklight"
44528
+ ]);
43740
44529
  const brightness = parseIntOrUndefined(table.get("VideoColor[0][0].Brightness"));
43741
44530
  const contrast = parseIntOrUndefined(table.get("VideoColor[0][0].Contrast"));
43742
44531
  const saturation = parseIntOrUndefined(table.get("VideoColor[0][0].Saturation"));
44532
+ const sharpness = parseIntOrUndefined(table.get("VideoInSharpness[0][0].Sharpness"));
44533
+ const mirror = parseBoolOrUndefined(table.get("VideoInOptions[0].Mirror"));
44534
+ const flip = parseBoolOrUndefined(table.get("VideoInOptions[0].Flip"));
44535
+ const rotate = amcrestRotateToCap(parseIntOrUndefined(table.get("VideoImageControl[0].Rotate90")));
44536
+ const exposureMode = amcrestExposureModeToCap(parseIntOrUndefined(table.get("VideoInExposure[0][0].Mode")));
44537
+ const backlightMode = amcrestBacklightToCap(table.get("VideoInBacklight[0][0].Mode"));
43743
44538
  if (brightness === void 0 && contrast === void 0 && saturation === void 0) return;
43744
44539
  const next = {
43745
44540
  ...brightness !== void 0 ? { brightness } : {},
43746
44541
  ...contrast !== void 0 ? { contrast } : {},
43747
44542
  ...saturation !== void 0 ? { saturation } : {},
44543
+ ...sharpness !== void 0 ? { sharpness } : {},
44544
+ ...mirror !== void 0 ? { mirror } : {},
44545
+ ...flip !== void 0 ? { flip } : {},
44546
+ ...rotate !== void 0 ? { rotate } : {},
44547
+ ...exposureMode !== void 0 ? { exposureMode } : {},
44548
+ ...backlightMode !== void 0 ? { backlightMode } : {},
43748
44549
  lastFetchedAt: Date.now()
43749
44550
  };
43750
44551
  this.runtimeState.setCapState(CAP_NAME, next);
@@ -43794,7 +44595,23 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43794
44595
  supportsContrast: true,
43795
44596
  contrast: NORMALIZED_RANGE,
43796
44597
  supportsSaturation: true,
43797
- saturation: NORMALIZED_RANGE
44598
+ saturation: NORMALIZED_RANGE,
44599
+ supportsSharpness: true,
44600
+ sharpness: NORMALIZED_RANGE,
44601
+ supportsMirror: true,
44602
+ supportsFlip: true,
44603
+ rotateOptions: [
44604
+ "0",
44605
+ "90",
44606
+ "270"
44607
+ ],
44608
+ exposureModes: ["auto", "manual"],
44609
+ backlightModes: [
44610
+ "off",
44611
+ "blc",
44612
+ "wdr",
44613
+ "hlc"
44614
+ ]
43798
44615
  };
43799
44616
  },
43800
44617
  setSettings: async ({ deviceId, settings }) => {
@@ -43803,6 +44620,19 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
43803
44620
  if (settings.brightness !== void 0) params["VideoColor[0][0].Brightness"] = settings.brightness;
43804
44621
  if (settings.contrast !== void 0) params["VideoColor[0][0].Contrast"] = settings.contrast;
43805
44622
  if (settings.saturation !== void 0) params["VideoColor[0][0].Saturation"] = settings.saturation;
44623
+ if (settings.sharpness !== void 0) params["VideoInSharpness[0][0].Sharpness"] = settings.sharpness;
44624
+ if (settings.mirror !== void 0) params["VideoInOptions[0].Mirror"] = settings.mirror ? "true" : "false";
44625
+ if (settings.flip !== void 0) params["VideoInOptions[0].Flip"] = settings.flip ? "true" : "false";
44626
+ if (settings.rotate !== void 0) {
44627
+ const native = capRotateToAmcrest(settings.rotate);
44628
+ if (native === void 0) this.ctx.logger.warn("amcrest: rotate value has no Dahua equivalent — ignored", {
44629
+ tags: { deviceId: this.id },
44630
+ meta: { requestedRotate: settings.rotate }
44631
+ });
44632
+ else params["VideoImageControl[0].Rotate90"] = native;
44633
+ }
44634
+ if (settings.exposureMode !== void 0) params["VideoInExposure[0][0].Mode"] = capExposureModeToAmcrest(settings.exposureMode);
44635
+ if (settings.backlightMode !== void 0) params["VideoInBacklight[0][0].Mode"] = capBacklightToAmcrest(settings.backlightMode);
43806
44636
  if (Object.keys(params).length > 0) await this.ensureClient().setConfig(params);
43807
44637
  await refreshFromCamera();
43808
44638
  }
@@ -43825,6 +44655,23 @@ function parseIntOrUndefined(raw) {
43825
44655
  const n = Number.parseInt(raw, 10);
43826
44656
  return Number.isFinite(n) ? n : void 0;
43827
44657
  }
44658
+ function parseBoolOrUndefined(raw) {
44659
+ if (raw === "true") return true;
44660
+ if (raw === "false") return false;
44661
+ }
44662
+ /**
44663
+ * Every `Encode[0].*.AudioEnable` key present in an `Encode` table read
44664
+ * — the per-profile microphone flags (`MainFormat[0..2]` +
44665
+ * `ExtraFormat[0..2]` on the IP4M-1041B, but the slot count is
44666
+ * firmware-defined so the keys are DISCOVERED, never enumerated).
44667
+ * Feeds `summarisePrivacyAudio` on the read path and names the exact
44668
+ * keys the mute write must cover.
44669
+ */
44670
+ function collectAudioEnableKeys(table) {
44671
+ const out = [];
44672
+ for (const key of table.keys()) if (/^Encode\[0\]\.(?:MainFormat|ExtraFormat)\[\d+\]\.AudioEnable$/.test(key)) out.push(key);
44673
+ return out.toSorted();
44674
+ }
43828
44675
  //#endregion
43829
44676
  //#region ../../node_modules/xml2js/lib/defaults.js
43830
44677
  var require_defaults = /* @__PURE__ */ __commonJSMin(((exports) => {