@camstack/addon-provider-amcrest 0.2.61 → 0.2.63

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