@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.
- package/dist/addon.js +957 -74
- package/dist/addon.mjs +957 -74
- 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) {
|
|
@@ -12749,7 +12751,26 @@ var DiscoveryCandidateSchema = object({
|
|
|
12749
12751
|
* identity ahead of adoption. Rendering metadata (unit, precision)
|
|
12750
12752
|
* flows live through the cap STATUS SLICE after adoption.
|
|
12751
12753
|
*/
|
|
12752
|
-
sourceInfo: SourceInfoSchema.optional()
|
|
12754
|
+
sourceInfo: SourceInfoSchema.optional(),
|
|
12755
|
+
/**
|
|
12756
|
+
* Set when this candidate is a device the provider ALREADY owns.
|
|
12757
|
+
*
|
|
12758
|
+
* A scan cannot generally produce the identity a device was onboarded under
|
|
12759
|
+
* (Reolink keys on `mac-<mac>`, learned at adopt time), so a stableId
|
|
12760
|
+
* comparison never matches and an owned device looks addable. Re-adopting one
|
|
12761
|
+
* overwrites its config with scan-derived values — that is how a Home Hub's
|
|
12762
|
+
* Baichuan port was overwritten with its ONVIF port, taking the hub and its
|
|
12763
|
+
* three child cameras offline for four hours.
|
|
12764
|
+
*
|
|
12765
|
+
* A provider that can recognise its own devices says so here. Absent means
|
|
12766
|
+
* "not recognised", which is not the same as "known to be new" — a provider
|
|
12767
|
+
* that cannot tell simply never sets it.
|
|
12768
|
+
*/
|
|
12769
|
+
alreadyOnboarded: boolean().optional(),
|
|
12770
|
+
/** Numeric id of the device this candidate was matched to. Set with `alreadyOnboarded`. */
|
|
12771
|
+
onboardedDeviceId: number().optional(),
|
|
12772
|
+
/** Operator-facing name of the matched device, so the UI can say WHICH one it is. */
|
|
12773
|
+
onboardedName: string().optional()
|
|
12753
12774
|
});
|
|
12754
12775
|
/**
|
|
12755
12776
|
* Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
|
|
@@ -28711,6 +28732,26 @@ var privacyMaskCapability = {
|
|
|
28711
28732
|
* whether persisting is worth a SQLite commit. */
|
|
28712
28733
|
volatileStateFields: ["lastFetchedAt"]
|
|
28713
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
|
+
}
|
|
28714
28755
|
var PtzPresetSchema = object({
|
|
28715
28756
|
id: string(),
|
|
28716
28757
|
name: string()
|
|
@@ -33377,6 +33418,22 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33377
33418
|
* failed — logged at ERROR with `tags.deviceId` and surfaced via
|
|
33378
33419
|
* `getStatus().error`.
|
|
33379
33420
|
*/
|
|
33421
|
+
/**
|
|
33422
|
+
* Repair a row's PERSISTED config blob immediately before it is restored.
|
|
33423
|
+
* Default: no-op — most providers have nothing to heal.
|
|
33424
|
+
*
|
|
33425
|
+
* This exists because a restored device self-hydrates from the DB: `create()`
|
|
33426
|
+
* passes `{}` and `BaseDevice` parses the stored blob against the device
|
|
33427
|
+
* schema. A blob that lost a REQUIRED field therefore fails restore forever,
|
|
33428
|
+
* and no later pass revisits it — a hub-adopted Reolink camera whose blob had
|
|
33429
|
+
* been emptied failed all four bounded attempts against fields
|
|
33430
|
+
* (`host`, `password`) it inherits from its parent and never dials itself.
|
|
33431
|
+
*
|
|
33432
|
+
* Implementations get every saved row, so a child can read its parent's blob.
|
|
33433
|
+
* A heal that throws is treated like any other restore failure: retried under
|
|
33434
|
+
* the bound, then reported — never swallowed.
|
|
33435
|
+
*/
|
|
33436
|
+
async healSavedConfig(_saved, _allSaved) {}
|
|
33380
33437
|
async onRestoreDevices(savedDevices) {
|
|
33381
33438
|
const restored = /* @__PURE__ */ new Set();
|
|
33382
33439
|
const failures = [];
|
|
@@ -33385,6 +33442,7 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33385
33442
|
const Class = this.deviceClasses[saved.type];
|
|
33386
33443
|
if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
|
|
33387
33444
|
if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
|
|
33445
|
+
await this.healSavedConfig(saved, savedDevices);
|
|
33388
33446
|
await this.ctx.kernel.devices.create(saved.stableId, Class, {}, saved.parentDeviceId);
|
|
33389
33447
|
restored.add(saved.id);
|
|
33390
33448
|
};
|
|
@@ -42251,6 +42309,17 @@ var AmcrestCgiClient = class AmcrestCgiClient {
|
|
|
42251
42309
|
return AmcrestCgiClient.parseTable(await this.getText(`/cgi-bin/configManager.cgi?action=getConfig&name=${encodeURIComponent(name)}`));
|
|
42252
42310
|
}
|
|
42253
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
|
+
/**
|
|
42254
42323
|
* Write config values. Keys are raw Dahua paths (e.g.
|
|
42255
42324
|
* `VideoColor[0][0].Brightness`); brackets are passed through unescaped
|
|
42256
42325
|
* (Dahua requires them literal) and only values are URL-encoded.
|
|
@@ -42273,40 +42342,63 @@ var AmcrestCgiClient = class AmcrestCgiClient {
|
|
|
42273
42342
|
*/
|
|
42274
42343
|
async openEventStream(codes, signal) {
|
|
42275
42344
|
const path = `/cgi-bin/eventManager.cgi?action=attach&codes=${codes}`;
|
|
42276
|
-
const
|
|
42277
|
-
|
|
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({
|
|
42278
42369
|
state: this.digestState,
|
|
42279
42370
|
method: "GET",
|
|
42280
42371
|
uri: path,
|
|
42281
42372
|
username: this.username,
|
|
42282
42373
|
password: this.password
|
|
42283
42374
|
});
|
|
42284
|
-
|
|
42285
|
-
|
|
42286
|
-
|
|
42287
|
-
|
|
42288
|
-
|
|
42289
|
-
|
|
42290
|
-
|
|
42291
|
-
|
|
42292
|
-
|
|
42293
|
-
|
|
42294
|
-
|
|
42295
|
-
|
|
42296
|
-
|
|
42297
|
-
|
|
42298
|
-
|
|
42299
|
-
|
|
42300
|
-
|
|
42301
|
-
});
|
|
42302
|
-
res = await fetch(url, {
|
|
42303
|
-
method: "GET",
|
|
42304
|
-
headers: retryHeader ? { authorization: retryHeader } : {},
|
|
42305
|
-
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"] ?? ""
|
|
42306
42392
|
});
|
|
42307
|
-
}
|
|
42308
|
-
|
|
42309
|
-
|
|
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
|
+
});
|
|
42310
42402
|
}
|
|
42311
42403
|
async getEncodeConfig() {
|
|
42312
42404
|
return this.getConfig("Encode");
|
|
@@ -42396,19 +42488,20 @@ function subscribeAmcrestEvents(client, codes, handlers) {
|
|
|
42396
42488
|
const controller = new AbortController();
|
|
42397
42489
|
(async () => {
|
|
42398
42490
|
try {
|
|
42399
|
-
const
|
|
42400
|
-
if (
|
|
42401
|
-
|
|
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}`));
|
|
42402
42495
|
return;
|
|
42403
42496
|
}
|
|
42404
|
-
const ct =
|
|
42497
|
+
const ct = opened.contentType;
|
|
42405
42498
|
const boundary = parseBoundary(ct);
|
|
42406
42499
|
if (!boundary) {
|
|
42407
42500
|
handlers.onError(/* @__PURE__ */ new Error(`event stream missing multipart boundary in Content-Type: "${ct}"`));
|
|
42408
42501
|
return;
|
|
42409
42502
|
}
|
|
42410
42503
|
handlers.onConnected?.();
|
|
42411
|
-
await pumpEventStream(res
|
|
42504
|
+
await pumpEventStream(opened.res, boundary, handlers);
|
|
42412
42505
|
if (!controller.signal.aborted) handlers.onError(/* @__PURE__ */ new Error("event stream ended"));
|
|
42413
42506
|
} catch (err) {
|
|
42414
42507
|
if (controller.signal.aborted) return;
|
|
@@ -42422,28 +42515,34 @@ function parseBoundary(contentType) {
|
|
|
42422
42515
|
return m ? m[1].trim() : null;
|
|
42423
42516
|
}
|
|
42424
42517
|
async function pumpEventStream(stream, boundary, handlers) {
|
|
42425
|
-
const reader = stream.getReader();
|
|
42426
42518
|
const dashBoundary = `--${boundary}`;
|
|
42427
42519
|
let bufferedBytes = new Uint8Array(0);
|
|
42520
|
+
let preambleConsumed = false;
|
|
42428
42521
|
try {
|
|
42429
|
-
|
|
42430
|
-
const
|
|
42431
|
-
if (
|
|
42432
|
-
|
|
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)));
|
|
42433
42526
|
bufferedBytes = concatBytes(bufferedBytes, value);
|
|
42434
|
-
const parts = splitOnBoundary(bufferedBytes, dashBoundary);
|
|
42527
|
+
const parts = splitOnBoundary(bufferedBytes, dashBoundary, preambleConsumed);
|
|
42528
|
+
if (parts.blocks.length > 0 || parts.tail !== bufferedBytes) preambleConsumed = true;
|
|
42435
42529
|
bufferedBytes = parts.tail;
|
|
42436
42530
|
for (const block of parts.blocks) {
|
|
42437
42531
|
const parsed = parseMultipartBlock(block);
|
|
42438
|
-
if (!parsed)
|
|
42532
|
+
if (!parsed) {
|
|
42533
|
+
handlers.onUnparsedBlock?.("block", new TextDecoder().decode(block).slice(0, 160));
|
|
42534
|
+
continue;
|
|
42535
|
+
}
|
|
42439
42536
|
const ev = parseAmcrestEventBody(parsed.bodyText);
|
|
42440
|
-
if (ev)
|
|
42537
|
+
if (!ev) {
|
|
42538
|
+
handlers.onUnparsedBlock?.("body", parsed.bodyText.slice(0, 160));
|
|
42539
|
+
continue;
|
|
42540
|
+
}
|
|
42541
|
+
handlers.onEvent(ev);
|
|
42441
42542
|
}
|
|
42442
42543
|
}
|
|
42443
42544
|
} finally {
|
|
42444
|
-
|
|
42445
|
-
reader.releaseLock();
|
|
42446
|
-
} catch {}
|
|
42545
|
+
stream.destroy();
|
|
42447
42546
|
}
|
|
42448
42547
|
}
|
|
42449
42548
|
function concatBytes(a, b) {
|
|
@@ -42452,14 +42551,32 @@ function concatBytes(a, b) {
|
|
|
42452
42551
|
out.set(b, a.byteLength);
|
|
42453
42552
|
return out;
|
|
42454
42553
|
}
|
|
42455
|
-
|
|
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) {
|
|
42456
42573
|
const parts = Buffer.from(buf).toString("binary").split(dashBoundary);
|
|
42457
42574
|
if (parts.length <= 1) return {
|
|
42458
42575
|
blocks: [],
|
|
42459
42576
|
tail: buf
|
|
42460
42577
|
};
|
|
42461
42578
|
const tail = Buffer.from(parts.pop(), "binary");
|
|
42462
|
-
parts.shift();
|
|
42579
|
+
if (!preambleConsumed) parts.shift();
|
|
42463
42580
|
return {
|
|
42464
42581
|
blocks: parts.filter((p) => p.length > 0 && !p.startsWith("--")).map((p) => Buffer.from(p, "binary")),
|
|
42465
42582
|
tail
|
|
@@ -42506,6 +42623,466 @@ function parseAmcrestEventBody(body) {
|
|
|
42506
42623
|
raw: body
|
|
42507
42624
|
};
|
|
42508
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
|
+
}
|
|
42509
43086
|
//#endregion
|
|
42510
43087
|
//#region src/schema.ts
|
|
42511
43088
|
/** Addon id — must match `package.json`'s `camstack.addons[0].id`. */
|
|
@@ -42525,7 +43102,14 @@ var amcrestDeviceCacheSchema = object({
|
|
|
42525
43102
|
processor: string(),
|
|
42526
43103
|
mac: string(),
|
|
42527
43104
|
hasPtz: boolean(),
|
|
42528
|
-
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
|
|
42529
43113
|
}).partial();
|
|
42530
43114
|
/**
|
|
42531
43115
|
* Amcrest/Dahua camera hardware config. `name`/`location` are base
|
|
@@ -42612,6 +43196,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
42612
43196
|
static MOTION_KEEPALIVE_MS = 1e4;
|
|
42613
43197
|
/** Active event-stream subscription; aborted on dispose / conn change. */
|
|
42614
43198
|
eventStreamController = null;
|
|
43199
|
+
/** Dedicated CGI client for the long-poll — see {@link ensureEventClient}. */
|
|
43200
|
+
eventClient = null;
|
|
42615
43201
|
/** Reconnect attempt counter — drives exponential backoff. */
|
|
42616
43202
|
eventStreamReconnectAttempts = 0;
|
|
42617
43203
|
/** Pending reconnect timer. */
|
|
@@ -42640,23 +43226,77 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
42640
43226
|
}
|
|
42641
43227
|
ensureClient() {
|
|
42642
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() {
|
|
42643
43253
|
const https = this.config.get("https") ?? false;
|
|
42644
|
-
|
|
43254
|
+
return new AmcrestCgiClient({
|
|
42645
43255
|
host: this.config.get("host"),
|
|
42646
43256
|
port: this.config.get("port") ?? (https ? 443 : 80),
|
|
42647
43257
|
https,
|
|
42648
43258
|
username: this.config.get("username") ?? "admin",
|
|
42649
43259
|
password: this.config.get("password")
|
|
42650
43260
|
});
|
|
42651
|
-
return this.client;
|
|
42652
43261
|
}
|
|
42653
|
-
/**
|
|
42654
|
-
*
|
|
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
|
+
*/
|
|
42655
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
|
+
});
|
|
42656
43295
|
await super.applySettingsPatch(patch);
|
|
42657
43296
|
if ("host" in patch || "port" in patch || "https" in patch || "username" in patch || "password" in patch) {
|
|
42658
43297
|
this.clearPtzAutoStop();
|
|
42659
43298
|
this.client = null;
|
|
43299
|
+
this.eventClient = null;
|
|
42660
43300
|
const hadStream = this.eventStreamController !== null || this.eventStreamReconnectTimer !== null;
|
|
42661
43301
|
this.teardownEventSubscription();
|
|
42662
43302
|
if (hadStream) this.ensureEventSubscription();
|
|
@@ -42699,14 +43339,42 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
42699
43339
|
/** Idempotently open the eventManager attach stream. */
|
|
42700
43340
|
ensureEventSubscription() {
|
|
42701
43341
|
if (this.eventStreamController) return;
|
|
42702
|
-
const client = this.
|
|
43342
|
+
const client = this.ensureEventClient();
|
|
42703
43343
|
this.eventStreamController = subscribeAmcrestEvents(client, AmcrestCamera.MOTION_EVENT_CODES, {
|
|
42704
43344
|
onConnected: () => {
|
|
42705
43345
|
this.eventStreamReconnectAttempts = 0;
|
|
42706
43346
|
if (this.motionActive) this.onMotionStop(Date.now());
|
|
42707
43347
|
this.ctx.logger.info("amcrest: event stream connected", { tags: { deviceId: this.id } });
|
|
42708
43348
|
},
|
|
42709
|
-
onEvent: (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
|
+
},
|
|
42710
43378
|
onError: (err) => {
|
|
42711
43379
|
this.ctx.logger.warn("amcrest: event stream error — will reconnect", {
|
|
42712
43380
|
tags: { deviceId: this.id },
|
|
@@ -42797,6 +43465,133 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
42797
43465
|
async onActivate() {
|
|
42798
43466
|
this.ensureEventSubscription();
|
|
42799
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);
|
|
42800
43595
|
}
|
|
42801
43596
|
/** Start the control-plane reachability poll: a Dahua CGI `getDeviceInfo`
|
|
42802
43597
|
* round-trip every 30s drives `device.online`, with hysteresis. Replaces
|
|
@@ -43435,7 +44230,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43435
44230
|
if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
|
|
43436
44231
|
const promise = (async () => {
|
|
43437
44232
|
try {
|
|
43438
|
-
const table = await this.ensureClient().
|
|
44233
|
+
const table = await this.ensureClient().getConfigs(["PrivacyMasking", "Encode"]);
|
|
43439
44234
|
const regions = [];
|
|
43440
44235
|
for (let i = 0; i < maxRegions; i++) {
|
|
43441
44236
|
const prefix = `PrivacyMasking[0][${i}]`;
|
|
@@ -43456,10 +44251,11 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43456
44251
|
}
|
|
43457
44252
|
});
|
|
43458
44253
|
}
|
|
44254
|
+
const audioProfiles = collectAudioEnableKeys(table).map((key) => ({ audioEnabled: table.get(key) === "true" }));
|
|
43459
44255
|
const next = {
|
|
43460
44256
|
enabled: regions.length > 0,
|
|
43461
44257
|
regions,
|
|
43462
|
-
audioEnabled:
|
|
44258
|
+
audioEnabled: summarisePrivacyAudio(audioProfiles),
|
|
43463
44259
|
lastFetchedAt: Date.now()
|
|
43464
44260
|
};
|
|
43465
44261
|
this.runtimeState.setCapState(CAP_NAME, next);
|
|
@@ -43500,10 +44296,27 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43500
44296
|
supportsAudioMute: false
|
|
43501
44297
|
};
|
|
43502
44298
|
if (deviceId !== this.id) return cold;
|
|
43503
|
-
return
|
|
44299
|
+
return {
|
|
44300
|
+
...cold,
|
|
44301
|
+
supportsAudioMute: true
|
|
44302
|
+
};
|
|
43504
44303
|
},
|
|
43505
|
-
setAudioEnabled: async () => {
|
|
43506
|
-
|
|
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();
|
|
43507
44320
|
},
|
|
43508
44321
|
setMask: async ({ deviceId, patch }) => {
|
|
43509
44322
|
if (deviceId !== this.id) return;
|
|
@@ -43602,8 +44415,6 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43602
44415
|
});
|
|
43603
44416
|
this.ctx.logger.info("amcrest: osd cap registered", { tags: { deviceId: this.id } });
|
|
43604
44417
|
}
|
|
43605
|
-
static DAY_NIGHT_SENSITIVITY_NATIVE_MAX = 3;
|
|
43606
|
-
static DAY_NIGHT_DELAY_MAX_SEC = 600;
|
|
43607
44418
|
registerDayNightCap() {
|
|
43608
44419
|
const CAP_NAME = "day-night";
|
|
43609
44420
|
const STALE_MS = 1e4;
|
|
@@ -43615,10 +44426,10 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43615
44426
|
const sensitivityNative = parseIntOrUndefined(table.get("VideoInDayNight[0][0].Sensitivity"));
|
|
43616
44427
|
const delaySec = parseIntOrUndefined(table.get("VideoInDayNight[0][0].Delay"));
|
|
43617
44428
|
const next = {
|
|
43618
|
-
mode: "
|
|
44429
|
+
mode: amcrestDayNightModeToCap(table.get("VideoInDayNight[0][0].Mode")),
|
|
43619
44430
|
lastFetchedAt: Date.now()
|
|
43620
44431
|
};
|
|
43621
|
-
if (sensitivityNative !== void 0) next.sensitivity =
|
|
44432
|
+
if (sensitivityNative !== void 0) next.sensitivity = dayNightSensitivityToPercent(sensitivityNative);
|
|
43622
44433
|
if (delaySec !== void 0) next.switchDelaySec = delaySec;
|
|
43623
44434
|
this.runtimeState.setCapState(CAP_NAME, next);
|
|
43624
44435
|
} catch (err) {
|
|
@@ -43656,7 +44467,11 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43656
44467
|
};
|
|
43657
44468
|
if (deviceId !== this.id) return cold;
|
|
43658
44469
|
return {
|
|
43659
|
-
modes: [
|
|
44470
|
+
modes: [
|
|
44471
|
+
"auto",
|
|
44472
|
+
"day",
|
|
44473
|
+
"night"
|
|
44474
|
+
],
|
|
43660
44475
|
supportsSensitivity: true,
|
|
43661
44476
|
sensitivity: {
|
|
43662
44477
|
min: 0,
|
|
@@ -43665,8 +44480,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43665
44480
|
},
|
|
43666
44481
|
supportsSwitchDelay: true,
|
|
43667
44482
|
switchDelaySec: {
|
|
43668
|
-
min:
|
|
43669
|
-
max:
|
|
44483
|
+
min: 2,
|
|
44484
|
+
max: 10,
|
|
43670
44485
|
step: 1
|
|
43671
44486
|
}
|
|
43672
44487
|
};
|
|
@@ -43674,13 +44489,16 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43674
44489
|
setSettings: async ({ deviceId, settings }) => {
|
|
43675
44490
|
if (deviceId !== this.id) return;
|
|
43676
44491
|
const params = {};
|
|
43677
|
-
if (settings.mode !== void 0
|
|
43678
|
-
|
|
43679
|
-
|
|
43680
|
-
|
|
43681
|
-
|
|
43682
|
-
|
|
43683
|
-
|
|
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);
|
|
43684
44502
|
if (Object.keys(params).length > 0) await this.ensureClient().setConfig(params);
|
|
43685
44503
|
await refreshFromCamera();
|
|
43686
44504
|
}
|
|
@@ -43700,15 +44518,34 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43700
44518
|
if (this.imageSettingsRefreshInFlight) return this.imageSettingsRefreshInFlight;
|
|
43701
44519
|
const promise = (async () => {
|
|
43702
44520
|
try {
|
|
43703
|
-
const table = await this.ensureClient().
|
|
44521
|
+
const table = await this.ensureClient().getConfigs([
|
|
44522
|
+
"VideoColor",
|
|
44523
|
+
"VideoInSharpness",
|
|
44524
|
+
"VideoInOptions",
|
|
44525
|
+
"VideoImageControl",
|
|
44526
|
+
"VideoInExposure",
|
|
44527
|
+
"VideoInBacklight"
|
|
44528
|
+
]);
|
|
43704
44529
|
const brightness = parseIntOrUndefined(table.get("VideoColor[0][0].Brightness"));
|
|
43705
44530
|
const contrast = parseIntOrUndefined(table.get("VideoColor[0][0].Contrast"));
|
|
43706
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"));
|
|
43707
44538
|
if (brightness === void 0 && contrast === void 0 && saturation === void 0) return;
|
|
43708
44539
|
const next = {
|
|
43709
44540
|
...brightness !== void 0 ? { brightness } : {},
|
|
43710
44541
|
...contrast !== void 0 ? { contrast } : {},
|
|
43711
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 } : {},
|
|
43712
44549
|
lastFetchedAt: Date.now()
|
|
43713
44550
|
};
|
|
43714
44551
|
this.runtimeState.setCapState(CAP_NAME, next);
|
|
@@ -43758,7 +44595,23 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43758
44595
|
supportsContrast: true,
|
|
43759
44596
|
contrast: NORMALIZED_RANGE,
|
|
43760
44597
|
supportsSaturation: true,
|
|
43761
|
-
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
|
+
]
|
|
43762
44615
|
};
|
|
43763
44616
|
},
|
|
43764
44617
|
setSettings: async ({ deviceId, settings }) => {
|
|
@@ -43767,6 +44620,19 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
|
|
|
43767
44620
|
if (settings.brightness !== void 0) params["VideoColor[0][0].Brightness"] = settings.brightness;
|
|
43768
44621
|
if (settings.contrast !== void 0) params["VideoColor[0][0].Contrast"] = settings.contrast;
|
|
43769
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);
|
|
43770
44636
|
if (Object.keys(params).length > 0) await this.ensureClient().setConfig(params);
|
|
43771
44637
|
await refreshFromCamera();
|
|
43772
44638
|
}
|
|
@@ -43789,6 +44655,23 @@ function parseIntOrUndefined(raw) {
|
|
|
43789
44655
|
const n = Number.parseInt(raw, 10);
|
|
43790
44656
|
return Number.isFinite(n) ? n : void 0;
|
|
43791
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
|
+
}
|
|
43792
44675
|
//#endregion
|
|
43793
44676
|
//#region ../../node_modules/xml2js/lib/defaults.js
|
|
43794
44677
|
var require_defaults = /* @__PURE__ */ __commonJSMin(((exports) => {
|