@camstack/system 1.2.78 → 1.2.80
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/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +253 -296
- package/dist/builtins/snapshot/index.mjs +252 -296
- package/dist/builtins/snapshot/snapshot-courtesy.d.ts +36 -0
- package/dist/builtins/snapshot/snapshot-resize.d.ts +43 -11
- package/dist/builtins/snapshot/snapshot.addon.d.ts +23 -45
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-cINpngi1.js → dist-CA2mim74.js} +48 -16
- package/dist/{dist-CIIFhXqm.mjs → dist-DZOZtn-0.mjs} +48 -16
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/package.json +1 -1
- package/dist/builtins/snapshot/snapshot-keep-warm.d.ts +0 -86
|
@@ -2,12 +2,14 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
5
|
+
const require_chunk = require("../../chunk-Cek0wNdY.js");
|
|
6
|
+
const require_dist = require("../../dist-CA2mim74.js");
|
|
7
7
|
let zod = require("zod");
|
|
8
8
|
let node_crypto = require("node:crypto");
|
|
9
9
|
let _camstack_types_node = require("@camstack/types/node");
|
|
10
10
|
let node_child_process = require("node:child_process");
|
|
11
|
+
let sharp = require("sharp");
|
|
12
|
+
sharp = require_chunk.__toESM(sharp);
|
|
11
13
|
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
12
14
|
/**
|
|
13
15
|
* Pure, side-effect-free coalescing / stale-while-revalidate / bounded-pool
|
|
@@ -360,7 +362,7 @@ function createSnapshotMediaHandler(deps) {
|
|
|
360
362
|
* per-device preference decides what it captures, and conflating it with an
|
|
361
363
|
* explicit request is how the original bug read. */
|
|
362
364
|
var AUTO = "auto";
|
|
363
|
-
function keyOf
|
|
365
|
+
function keyOf(deviceId, streamId) {
|
|
364
366
|
return `${deviceId}:${streamId ?? AUTO}`;
|
|
365
367
|
}
|
|
366
368
|
var SnapshotCache = class {
|
|
@@ -368,10 +370,10 @@ var SnapshotCache = class {
|
|
|
368
370
|
/** deviceId → its live keys, so invalidation is O(streams) not O(cache). */
|
|
369
371
|
keysByDevice = /* @__PURE__ */ new Map();
|
|
370
372
|
get(deviceId, streamId) {
|
|
371
|
-
return this.byKey.get(keyOf
|
|
373
|
+
return this.byKey.get(keyOf(deviceId, streamId));
|
|
372
374
|
}
|
|
373
375
|
set(deviceId, streamId, entry) {
|
|
374
|
-
const key = keyOf
|
|
376
|
+
const key = keyOf(deviceId, streamId);
|
|
375
377
|
this.byKey.set(key, entry);
|
|
376
378
|
const keys = this.keysByDevice.get(deviceId) ?? /* @__PURE__ */ new Set();
|
|
377
379
|
keys.add(key);
|
|
@@ -413,80 +415,75 @@ var SnapshotCache = class {
|
|
|
413
415
|
* ffmpeg filter chain to hook into. Resizing the finished bytes gives both the
|
|
414
416
|
* same behaviour with one implementation.
|
|
415
417
|
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
418
|
+
* ── Why sharp and not ffmpeg (2026-08-11) ─────────────────────────────────
|
|
419
|
+
* This used to `spawn('ffmpeg')` per resize. The snapshot wrapper is a system
|
|
420
|
+
* builtin, so it loads in the hub's ROOT process: sampled on the live hub,
|
|
421
|
+
* ~1.6 of those children were running at any instant, every one of them
|
|
422
|
+
* parented by the root PID.
|
|
423
|
+
*
|
|
424
|
+
* Be precise about what that cost the root process, because it is easy to
|
|
425
|
+
* overstate. The transcode itself ran in the CHILD and was charged to ffmpeg
|
|
426
|
+
* (16 % + 13 % of a core in that same sample), not to the parent. What the
|
|
427
|
+
* parent paid was the fork, the exec, the JPEG written into one pipe and read
|
|
428
|
+
* back out of the other, and the base64 — real event-loop work, on the loop
|
|
429
|
+
* that also serves the tRPC API, but NOT the 52 ms below.
|
|
430
|
+
*
|
|
431
|
+
* Benchmarked in that container, 2560×1440 → 640 wide, wall-clock per resize:
|
|
419
432
|
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
433
|
+
* ffmpeg 52.0 ms (fork + exec + pipe round-trip + teardown)
|
|
434
|
+
* sharp 4.8 ms (in-process libvips, on its own threadpool)
|
|
435
|
+
*
|
|
436
|
+
* So this removes ~29 % of a core of container CPU and the per-resize process
|
|
437
|
+
* churn from the root process. It was NOT verified to be the cause of that
|
|
438
|
+
* process sitting at 90 % — nobody has profiled it.
|
|
439
|
+
*
|
|
440
|
+
* `sharp` is not a new dependency: it is already a host-external
|
|
441
|
+
* (`HOST_EXTERNAL_SPECIFIERS`), already resolved from the framework closure at
|
|
442
|
+
* runtime, and already used by the terminal frame renderer. The build preset
|
|
443
|
+
* keeps it out of the bundle, so this import costs nothing at pack time.
|
|
444
|
+
*
|
|
445
|
+
* The cost is still one resize per (device, stream, width) per cache window —
|
|
446
|
+
* not one per request. What changed is what a resize COSTS.
|
|
422
447
|
*/
|
|
423
448
|
var RESIZE_TIMEOUT_MS = 1e4;
|
|
424
449
|
/**
|
|
425
|
-
*
|
|
426
|
-
* mjpeg
|
|
450
|
+
* Encode quality for a derived thumbnail. Matches what the ffmpeg path
|
|
451
|
+
* produced (`-q:v 5` on the mjpeg encoder) closely enough that no card visibly
|
|
452
|
+
* changes — this migration is about COST, not about re-tuning the picture.
|
|
453
|
+
*/
|
|
454
|
+
var JPEG_QUALITY = 82;
|
|
455
|
+
/**
|
|
456
|
+
* Scale to `width`, preserving aspect ratio.
|
|
457
|
+
*
|
|
458
|
+
* Rejects on failure or timeout. It NEVER falls back to the original silently —
|
|
459
|
+
* a caller that quietly served the 4K frame when the resize failed would
|
|
460
|
+
* reproduce exactly the bug this whole module exists to fix, and nobody would
|
|
461
|
+
* see it. The caller decides, and logs.
|
|
427
462
|
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
463
|
+
* It DOES upscale a source narrower than `width`, and that is deliberate. A
|
|
464
|
+
* `withoutEnlargement: true` was tried first — it is the obvious saving, since
|
|
465
|
+
* upscaling pays encode cost for a blurrier, larger image. But the caller
|
|
466
|
+
* stamps the response ETag from the width it asked for, and its resize-FAILURE
|
|
467
|
+
* path already returns `width: undefined` specifically so a response can never
|
|
468
|
+
* "claim a width the bytes do not have". Silently returning 320 px bytes for a
|
|
469
|
+
* `w=640` request breaks that invariant on the SUCCESS path, where nobody is
|
|
470
|
+
* looking. Honest output width beats a few saved pixels; revisit only together
|
|
471
|
+
* with the ETag.
|
|
432
472
|
*/
|
|
433
473
|
function resizeJpeg(bytes, width, timeoutMs = RESIZE_TIMEOUT_MS) {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
"5",
|
|
448
|
-
"-f",
|
|
449
|
-
"image2pipe",
|
|
450
|
-
"-vcodec",
|
|
451
|
-
"mjpeg",
|
|
452
|
-
"pipe:1"
|
|
453
|
-
], { stdio: [
|
|
454
|
-
"pipe",
|
|
455
|
-
"pipe",
|
|
456
|
-
"pipe"
|
|
457
|
-
] });
|
|
458
|
-
const out = [];
|
|
459
|
-
let err = "";
|
|
460
|
-
let settled = false;
|
|
461
|
-
const timer = setTimeout(() => {
|
|
462
|
-
if (settled) return;
|
|
463
|
-
settled = true;
|
|
464
|
-
child.kill("SIGKILL");
|
|
465
|
-
reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`));
|
|
466
|
-
}, timeoutMs);
|
|
467
|
-
child.stdout.on("data", (chunk) => out.push(chunk));
|
|
468
|
-
child.stderr.on("data", (chunk) => {
|
|
469
|
-
err += chunk.toString();
|
|
470
|
-
});
|
|
471
|
-
child.on("error", (e) => {
|
|
472
|
-
if (settled) return;
|
|
473
|
-
settled = true;
|
|
474
|
-
clearTimeout(timer);
|
|
475
|
-
reject(e);
|
|
476
|
-
});
|
|
477
|
-
child.on("close", (code) => {
|
|
478
|
-
if (settled) return;
|
|
479
|
-
settled = true;
|
|
480
|
-
clearTimeout(timer);
|
|
481
|
-
const buf = Buffer.concat(out);
|
|
482
|
-
if (code !== 0 || buf.length === 0) {
|
|
483
|
-
reject(/* @__PURE__ */ new Error(`snapshot resize failed (code ${String(code)}): ${err.trim() || "no output"}`));
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
resolve(buf);
|
|
487
|
-
});
|
|
488
|
-
child.stdin.on("error", () => {});
|
|
489
|
-
child.stdin.end(bytes);
|
|
474
|
+
const work = (0, sharp.default)(bytes).resize({ width }).jpeg({
|
|
475
|
+
quality: JPEG_QUALITY,
|
|
476
|
+
mozjpeg: false
|
|
477
|
+
}).toBuffer().then((out) => {
|
|
478
|
+
if (out.length === 0) throw new Error("snapshot resize produced no bytes");
|
|
479
|
+
return out;
|
|
480
|
+
});
|
|
481
|
+
let timer;
|
|
482
|
+
const bound = new Promise((_, reject) => {
|
|
483
|
+
timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`snapshot resize timed out after ${String(timeoutMs)}ms`)), timeoutMs);
|
|
484
|
+
});
|
|
485
|
+
return Promise.race([work, bound]).finally(() => {
|
|
486
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
490
487
|
});
|
|
491
488
|
}
|
|
492
489
|
/**
|
|
@@ -531,122 +528,107 @@ var SnapshotVariantCache = class SnapshotVariantCache {
|
|
|
531
528
|
}
|
|
532
529
|
};
|
|
533
530
|
//#endregion
|
|
534
|
-
//#region src/builtins/snapshot/snapshot-
|
|
531
|
+
//#region src/builtins/snapshot/snapshot-courtesy.ts
|
|
535
532
|
/**
|
|
536
|
-
*
|
|
533
|
+
* A courtesy frame for a camera that CANNOT produce one.
|
|
534
|
+
*
|
|
535
|
+
* ── Why this exists ───────────────────────────────────────────────────────
|
|
536
|
+
* A disabled, offline or sleeping camera has no frame, and until 2026-08-11 the
|
|
537
|
+
* snapshot service simply had nothing to say about it: the media route answered
|
|
538
|
+
* **404** and the client painted "Unavailable". Measured on the live grid that
|
|
539
|
+
* day, 10 of 26 tiles were 404s — one genuinely disabled camera plus nine dead
|
|
540
|
+
* legacy rows. A 404 is indistinguishable from a broken camera, so an operator
|
|
541
|
+
* who deliberately switched a camera off saw the same thing as a fault ([D62]:
|
|
542
|
+
* "an off switch is REPORTED off; disabled must never look like broken").
|
|
537
543
|
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
541
|
-
* time somebody looked. Measured on the live hub: 15 of 16 cameras cached, all
|
|
542
|
-
* captured in one 55 ms burst, all 10 minutes old and not moving.
|
|
544
|
+
* So the service answers with a frame that SAYS what is going on, carrying the
|
|
545
|
+
* camera's own name. The client gets a valid image, the tile paints, and the
|
|
546
|
+
* state is legible instead of inferred from an error.
|
|
543
547
|
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
* on
|
|
548
|
+
* ── Why sharp and not ffmpeg ──────────────────────────────────────────────
|
|
549
|
+
* The first draft of this file shelled out to `ffmpeg` with a `drawtext`
|
|
550
|
+
* filter. That was written before the resize path was measured, and it was the
|
|
551
|
+
* wrong call for the same reason: this runs in the snapshot wrapper, a system
|
|
552
|
+
* builtin loaded in the hub's ROOT process, so every render was a fork + exec
|
|
553
|
+
* on the loop that serves the API — 52 ms against sharp's 4.8 ms in that
|
|
554
|
+
* container, to draw two lines of text.
|
|
550
555
|
*
|
|
551
|
-
*
|
|
552
|
-
* the
|
|
553
|
-
*
|
|
556
|
+
* `terminal-frame-renderer.ts` already renders text this way (SVG → sharp), so
|
|
557
|
+
* this is the house pattern rather than a new one. It also drops the font-PATH
|
|
558
|
+
* probing the ffmpeg version needed: an SVG names a font FAMILY and fontconfig
|
|
559
|
+
* resolves it — verified in the hub image, where `fc-match "DejaVu Sans Mono"`
|
|
560
|
+
* answers with the real file.
|
|
561
|
+
*
|
|
562
|
+
* Rendering is pure input → bytes with no I/O of its own, and the geometry and
|
|
563
|
+
* escaping are separated out so they stay testable without rasterizing.
|
|
554
564
|
*/
|
|
555
565
|
/**
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
* the next tick is scheduled only after the previous one settles, so a slow
|
|
560
|
-
* fleet cannot stack ticks on top of each other.
|
|
566
|
+
* Font stack for the rendered text. Family names, not paths — librsvg resolves
|
|
567
|
+
* them through fontconfig, and the trailing generics keep a host without DejaVu
|
|
568
|
+
* rendering something legible instead of nothing.
|
|
561
569
|
*/
|
|
562
|
-
var
|
|
570
|
+
var COURTESY_FONT_STACK = "DejaVu Sans,DejaVu Sans Mono,Helvetica,Arial,sans-serif";
|
|
571
|
+
/** The word the frame carries. Deliberately the operator's vocabulary. */
|
|
572
|
+
function courtesyLabel(reason) {
|
|
573
|
+
switch (reason) {
|
|
574
|
+
case "disabled": return "Disabled";
|
|
575
|
+
case "offline": return "Offline";
|
|
576
|
+
case "sleeping": return "Sleeping";
|
|
577
|
+
}
|
|
578
|
+
}
|
|
563
579
|
/**
|
|
564
|
-
*
|
|
565
|
-
*
|
|
566
|
-
* phone locking briefly) does not go cold, short enough that a closed app stops
|
|
567
|
-
* costing captures well before anyone notices.
|
|
580
|
+
* Background per reason. A disabled camera is a DELIBERATE state and must not
|
|
581
|
+
* read as an alarm, so it is neutral grey; offline is a fault and is warmer.
|
|
568
582
|
*/
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
583
|
+
function courtesyBackground(reason) {
|
|
584
|
+
switch (reason) {
|
|
585
|
+
case "disabled": return "#2b2b31";
|
|
586
|
+
case "offline": return "#3a2b2b";
|
|
587
|
+
case "sleeping": return "#232b3a";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/** XML escaping for text placed inside an SVG `<text>` node. */
|
|
591
|
+
function escapeCourtesyText(value) {
|
|
592
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
572
593
|
}
|
|
573
594
|
/**
|
|
574
|
-
*
|
|
595
|
+
* The SVG the frame is rasterized from: the state large and centred, the
|
|
596
|
+
* camera's name under it, smaller and dimmer.
|
|
575
597
|
*
|
|
576
|
-
*
|
|
577
|
-
*
|
|
578
|
-
* traffic, so a filter-only read would leave every device ever requested in
|
|
579
|
-
* memory for the life of the process.
|
|
598
|
+
* Sizes derive from the width so a 240 px grid tile and a 1920 px full-bleed
|
|
599
|
+
* frame read the same, with a floor so a thumbnail stays legible.
|
|
580
600
|
*/
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
* the frame identity changes, and the identity changes only because the warm
|
|
613
|
-
* loop captured — so 120 s after the last image request the subscription
|
|
614
|
-
* expired, warming stopped, the identity froze, the client never asked again,
|
|
615
|
-
* and the tile was stuck. Ages went 13 s → 49.7 s at exactly t+120 s.
|
|
616
|
-
*
|
|
617
|
-
* The client's `getSnapshotOverview` poll is the heartbeat: asking "what is
|
|
618
|
-
* the state of these cameras" IS the statement that somebody is looking. It
|
|
619
|
-
* deliberately cannot CREATE a subscription, because that poll is app-wide —
|
|
620
|
-
* if it did, opening the app would warm every camera on the install, which is
|
|
621
|
-
* the blind poll this whole design avoids. Only a rendered surface subscribes.
|
|
622
|
-
*/
|
|
623
|
-
renew(deviceIds, nowMs) {
|
|
624
|
-
for (const deviceId of deviceIds) for (const entry of this.entries.values()) if (entry.deviceId === deviceId) entry.lastRequestedAt = nowMs;
|
|
625
|
-
}
|
|
626
|
-
/** The (device, stream) pairs still inside the warm window, dropping the rest. */
|
|
627
|
-
active(nowMs, windowMs = WARM_WINDOW_MS) {
|
|
628
|
-
const targets = [];
|
|
629
|
-
for (const [key, entry] of this.entries) {
|
|
630
|
-
if (nowMs - entry.lastRequestedAt >= windowMs) {
|
|
631
|
-
this.entries.delete(key);
|
|
632
|
-
continue;
|
|
633
|
-
}
|
|
634
|
-
for (const [width, at] of entry.widths) if (nowMs - at >= windowMs) entry.widths.delete(width);
|
|
635
|
-
targets.push({
|
|
636
|
-
deviceId: entry.deviceId,
|
|
637
|
-
streamId: entry.streamId,
|
|
638
|
-
widths: [...entry.widths.keys()].sort((a, b) => a - b)
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
return targets;
|
|
642
|
-
}
|
|
643
|
-
get size() {
|
|
644
|
-
return this.entries.size;
|
|
645
|
-
}
|
|
646
|
-
clear() {
|
|
647
|
-
this.entries.clear();
|
|
648
|
-
}
|
|
649
|
-
};
|
|
601
|
+
function buildCourtesySvg(spec) {
|
|
602
|
+
const stateSize = Math.max(12, Math.round(spec.width / 12));
|
|
603
|
+
const nameSize = Math.max(9, Math.round(spec.width / 26));
|
|
604
|
+
const state = escapeCourtesyText(courtesyLabel(spec.reason));
|
|
605
|
+
const name = escapeCourtesyText(spec.deviceName);
|
|
606
|
+
const midY = spec.height / 2;
|
|
607
|
+
return [
|
|
608
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${String(spec.width)}" height="${String(spec.height)}">`,
|
|
609
|
+
`<rect width="100%" height="100%" fill="${courtesyBackground(spec.reason)}"/>`,
|
|
610
|
+
`<g font-family="${COURTESY_FONT_STACK}" text-anchor="middle">`,
|
|
611
|
+
`<text x="50%" y="${String(Math.round(midY))}" font-size="${String(stateSize)}" fill="#e8e8ee">${state}</text>`,
|
|
612
|
+
`<text x="50%" y="${String(Math.round(midY + stateSize))}" font-size="${String(nameSize)}" fill="#9a9aa8">${name}</text>`,
|
|
613
|
+
`</g></svg>`
|
|
614
|
+
].join("");
|
|
615
|
+
}
|
|
616
|
+
/** Cache key — a courtesy frame is a pure function of these four. */
|
|
617
|
+
function courtesyCacheKey(spec) {
|
|
618
|
+
return `${spec.reason}:${String(spec.width)}x${String(spec.height)}:${spec.deviceName}`;
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Render the frame.
|
|
622
|
+
*
|
|
623
|
+
* Rejects on failure — the caller decides what to do, exactly as `resizeJpeg`
|
|
624
|
+
* does, so a broken courtesy path is never mistaken for a broken camera.
|
|
625
|
+
*/
|
|
626
|
+
function renderCourtesyJpeg(spec) {
|
|
627
|
+
return (0, sharp.default)(Buffer.from(buildCourtesySvg(spec))).jpeg({ quality: 82 }).toBuffer().then((bytes) => {
|
|
628
|
+
if (bytes.length === 0) throw new Error("courtesy frame produced no bytes");
|
|
629
|
+
return bytes;
|
|
630
|
+
});
|
|
631
|
+
}
|
|
650
632
|
//#endregion
|
|
651
633
|
//#region src/builtins/snapshot/snapshot-link-url.ts
|
|
652
634
|
/**
|
|
@@ -659,14 +641,20 @@ var SnapshotSubscriptions = class {
|
|
|
659
641
|
* actually REACHES the server — and that turned out to be the whole bug.
|
|
660
642
|
*
|
|
661
643
|
* Under D93 the image URL is versioned by the frame identity, and an image
|
|
662
|
-
* request is what
|
|
663
|
-
*
|
|
644
|
+
* request is what signalled demand for a camera. Both halves are satisfied by
|
|
645
|
+
* the client's own image cache: `expo-image` is URL-keyed and never
|
|
664
646
|
* revalidates, so a URL the app painted in a previous session is served from
|
|
665
647
|
* disk with **zero network**. Measured on the live hub, reopening the app after
|
|
666
648
|
* two minutes idle painted 15 of 16 tiles from disk — frames **168 s old**, with
|
|
667
|
-
* not one HTTP request, therefore
|
|
668
|
-
*
|
|
669
|
-
*
|
|
649
|
+
* not one HTTP request, therefore no demand, therefore no capture. The
|
|
650
|
+
* operator's report ("gli snapshot sono vecchi, devo aggiornare più volte") is
|
|
651
|
+
* that measurement.
|
|
652
|
+
*
|
|
653
|
+
* That gap used to be covered on BOTH sides — by this link plane and by a
|
|
654
|
+
* server-side keep-warm timer. The timer was removed on 2026-08-11 (operator
|
|
655
|
+
* directive: snapshots are on-demand, always), which makes this plane the only
|
|
656
|
+
* thing standing between a client cache and a frozen tile. It carries the whole
|
|
657
|
+
* job now.
|
|
670
658
|
*
|
|
671
659
|
* A minted link breaks the loop from both ends. It is produced by an RPC —
|
|
672
660
|
* `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
|
|
@@ -836,6 +824,15 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
836
824
|
* against the source frame's timestamp. */
|
|
837
825
|
variants = new SnapshotVariantCache();
|
|
838
826
|
/**
|
|
827
|
+
* Rendered courtesy frames, by (reason, size, device name).
|
|
828
|
+
*
|
|
829
|
+
* A courtesy frame is a pure function of those three, so it is rendered once
|
|
830
|
+
* and reused for as long as the process lives — a disabled camera must not
|
|
831
|
+
* cost an ffmpeg run per poll. Unbounded on purpose: the key space is
|
|
832
|
+
* (3 reasons × this node's own cameras), not user input.
|
|
833
|
+
*/
|
|
834
|
+
courtesyFrames = /* @__PURE__ */ new Map();
|
|
835
|
+
/**
|
|
839
836
|
* De-dupes concurrent captures per `${deviceId}:${streamId}` and holds a
|
|
840
837
|
* settled SUCCESS for COALESCE_MS so a grid-mount burst (and a row of refresh
|
|
841
838
|
* buttons) collapses into one capture instead of one dial per tile.
|
|
@@ -874,17 +871,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
874
871
|
* currently holding, which is a fleet-wide blank grid after each restart. */
|
|
875
872
|
linkSecret = null;
|
|
876
873
|
/**
|
|
877
|
-
* Who is being looked at, inferred from data-plane traffic. The keep-warm
|
|
878
|
-
* loop's entire bound — see `snapshot-keep-warm.ts`.
|
|
879
|
-
*/
|
|
880
|
-
subscriptions = new SnapshotSubscriptions();
|
|
881
|
-
/** Chained warm-tick timer (never `setInterval` — see `scheduleWarmTick`). */
|
|
882
|
-
warmTimer = null;
|
|
883
|
-
/** Set while a warm tick is running so ticks can never stack. */
|
|
884
|
-
warmTickRunning = false;
|
|
885
|
-
/** Flipped on shutdown so an in-flight tick does not reschedule itself. */
|
|
886
|
-
stopped = false;
|
|
887
|
-
/**
|
|
888
874
|
* How a full frame becomes a card-sized one. Defaults to the real ffmpeg
|
|
889
875
|
* resize; injectable so the wrapper's own logic is testable without spawning a
|
|
890
876
|
* process for every fixture. `JpegResizer` was declared for exactly this and
|
|
@@ -910,7 +896,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
910
896
|
};
|
|
911
897
|
await this.serveMediaDataPlane();
|
|
912
898
|
await this.serveLinkDataPlane();
|
|
913
|
-
this.scheduleWarmTick();
|
|
914
899
|
return [{
|
|
915
900
|
capability: require_dist.snapshotCapability,
|
|
916
901
|
provider
|
|
@@ -1056,14 +1041,11 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1056
1041
|
*/
|
|
1057
1042
|
async getSnapshotLinks(input) {
|
|
1058
1043
|
const secret = await this.ensureLinkSecret();
|
|
1059
|
-
const
|
|
1060
|
-
const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
|
|
1044
|
+
const expMs = snapshotLinkExpiry(Date.now(), SNAPSHOT_LINK_TTL_MS);
|
|
1061
1045
|
const served = this.linkDataPlane !== null && secret !== null;
|
|
1062
1046
|
const prefix = this.linkRoutePrefix();
|
|
1063
1047
|
const sleepingByDevice = /* @__PURE__ */ new Map();
|
|
1064
1048
|
const refreshes = input.targets.map(async (target) => {
|
|
1065
|
-
const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
|
|
1066
|
-
this.subscriptions.note(target.deviceId, void 0, width, now);
|
|
1067
1049
|
const sleeping = await this.isSleepingBatteryDevice(target.deviceId);
|
|
1068
1050
|
sleepingByDevice.set(target.deviceId, sleeping);
|
|
1069
1051
|
if (!sleeping) await this.awaitFreshEnough(target.deviceId);
|
|
@@ -1112,7 +1094,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1112
1094
|
await this.getSnapshot({
|
|
1113
1095
|
deviceId,
|
|
1114
1096
|
force: false
|
|
1115
|
-
},
|
|
1097
|
+
}, LINK_MINT_DEADLINE_MS, LINK_CURRENT_MAX_AGE_MS).catch(() => null);
|
|
1116
1098
|
const current = this.cache.latest(deviceId)?.ts ?? null;
|
|
1117
1099
|
if (alreadyCurrent || current !== null && (before === null || current > before)) return;
|
|
1118
1100
|
this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
|
|
@@ -1143,7 +1125,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1143
1125
|
* effective per-device `maxAgeS` for `Cache-Control`. Null → 404.
|
|
1144
1126
|
*/
|
|
1145
1127
|
async resolveSnapshotMedia(deviceId, streamId, force, width) {
|
|
1146
|
-
this.subscriptions.note(deviceId, streamId, width, Date.now());
|
|
1147
1128
|
const image = await this.getSnapshot({
|
|
1148
1129
|
deviceId,
|
|
1149
1130
|
...streamId !== void 0 ? { streamId } : {},
|
|
@@ -1189,92 +1170,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1189
1170
|
};
|
|
1190
1171
|
}
|
|
1191
1172
|
/**
|
|
1192
|
-
* Schedule the next warm tick. A CHAINED timer, not `setInterval`: the next
|
|
1193
|
-
* tick is armed only after the previous one settles, so a fleet that is slower
|
|
1194
|
-
* than the interval degrades to "as fast as it can" instead of stacking ticks
|
|
1195
|
-
* on top of each other and exhausting the ffmpeg pool.
|
|
1196
|
-
*
|
|
1197
|
-
* `unref` so the loop never holds the process open.
|
|
1198
|
-
*/
|
|
1199
|
-
scheduleWarmTick() {
|
|
1200
|
-
if (this.stopped) return;
|
|
1201
|
-
this.warmTimer = setTimeout(() => {
|
|
1202
|
-
this.runWarmTick().finally(() => {
|
|
1203
|
-
this.scheduleWarmTick();
|
|
1204
|
-
});
|
|
1205
|
-
}, WARM_INTERVAL_MS);
|
|
1206
|
-
this.warmTimer.unref?.();
|
|
1207
|
-
}
|
|
1208
|
-
/**
|
|
1209
|
-
* Refresh the cache for every camera somebody is currently looking at.
|
|
1210
|
-
*
|
|
1211
|
-
* Deliberately NOT a blind poll of the whole fleet: with nobody watching this
|
|
1212
|
-
* walks an empty list and costs nothing, which is the entire difference
|
|
1213
|
-
* between this and a background poller.
|
|
1214
|
-
*
|
|
1215
|
-
* Battery cameras need no special case here and must not get one. A warm tick
|
|
1216
|
-
* is a plain non-forced `getSnapshot`, so it meets the two gates that already
|
|
1217
|
-
* exist — the 3600 s battery max-age (which makes the tick a cache hit) and
|
|
1218
|
-
* the sleeping-battery gate (which refuses to capture without `force`). A
|
|
1219
|
-
* second battery check here would be a rule that disagrees with the authority
|
|
1220
|
-
* that already owns it.
|
|
1221
|
-
*/
|
|
1222
|
-
async runWarmTick() {
|
|
1223
|
-
if (this.warmTickRunning || this.stopped) return;
|
|
1224
|
-
this.warmTickRunning = true;
|
|
1225
|
-
const logger = this.ctx.logger;
|
|
1226
|
-
try {
|
|
1227
|
-
const targets = this.subscriptions.active(Date.now());
|
|
1228
|
-
if (targets.length === 0) return;
|
|
1229
|
-
const failed = (await Promise.allSettled(targets.map((t) => this.warmOne(t, logger)))).filter((r) => r.status === "rejected").length;
|
|
1230
|
-
logger.debug("snapshot: keep-warm tick", { meta: {
|
|
1231
|
-
watched: targets.length,
|
|
1232
|
-
failed
|
|
1233
|
-
} });
|
|
1234
|
-
} catch (err) {
|
|
1235
|
-
logger.warn("snapshot: keep-warm tick failed", { meta: { error: require_dist.errMsg(err) } });
|
|
1236
|
-
} finally {
|
|
1237
|
-
this.warmTickRunning = false;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
/**
|
|
1241
|
-
* Warm one (device, stream): refresh the frame, then pre-derive the widths
|
|
1242
|
-
* that were actually requested.
|
|
1243
|
-
*
|
|
1244
|
-
* Deriving the variants matters as much as the capture. Warming only the full
|
|
1245
|
-
* frame would leave every grid tile still paying an ffmpeg resize on the
|
|
1246
|
-
* request path — the cache would be fresh and the tile would still be slow.
|
|
1247
|
-
*/
|
|
1248
|
-
async warmOne(target, logger) {
|
|
1249
|
-
const work = this.warmOneUnbounded(target, logger);
|
|
1250
|
-
work.catch(() => void 0);
|
|
1251
|
-
if (!(await raceForResult(work, 5e3)).settled) logger.debug("snapshot: keep-warm still in flight past the tick — leaving it to land", { tags: { deviceId: target.deviceId } });
|
|
1252
|
-
}
|
|
1253
|
-
async warmOneUnbounded(target, logger) {
|
|
1254
|
-
const { deviceId, streamId, widths } = target;
|
|
1255
|
-
try {
|
|
1256
|
-
if (this.stopped) return;
|
|
1257
|
-
const image = await this.getSnapshot({
|
|
1258
|
-
deviceId,
|
|
1259
|
-
...streamId !== void 0 ? { streamId } : {},
|
|
1260
|
-
force: false
|
|
1261
|
-
}, WARM_INTERVAL_MS);
|
|
1262
|
-
if (!image) return;
|
|
1263
|
-
const capturedAt = this.cache.get(deviceId, streamId)?.ts;
|
|
1264
|
-
if (capturedAt === void 0) return;
|
|
1265
|
-
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1266
|
-
for (const width of widths) {
|
|
1267
|
-
if (this.stopped) return;
|
|
1268
|
-
await this.thumbnailBytes(deviceId, streamId, width, capturedAt, image, prefs);
|
|
1269
|
-
}
|
|
1270
|
-
} catch (err) {
|
|
1271
|
-
logger.debug("snapshot: keep-warm failed for device", {
|
|
1272
|
-
tags: { deviceId },
|
|
1273
|
-
meta: { error: require_dist.errMsg(err) }
|
|
1274
|
-
});
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
/**
|
|
1278
1173
|
* The frame at a card-sized width, derived once per (device, stream, width)
|
|
1279
1174
|
* per capture.
|
|
1280
1175
|
*
|
|
@@ -1321,12 +1216,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1321
1216
|
}
|
|
1322
1217
|
}
|
|
1323
1218
|
async onShutdown() {
|
|
1324
|
-
this.stopped = true;
|
|
1325
|
-
if (this.warmTimer !== null) {
|
|
1326
|
-
clearTimeout(this.warmTimer);
|
|
1327
|
-
this.warmTimer = null;
|
|
1328
|
-
}
|
|
1329
|
-
this.subscriptions.clear();
|
|
1330
1219
|
if (this.mediaDataPlane) {
|
|
1331
1220
|
await this.mediaDataPlane.dispose().catch(() => void 0);
|
|
1332
1221
|
this.mediaDataPlane = null;
|
|
@@ -1369,7 +1258,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1369
1258
|
})]
|
|
1370
1259
|
}] });
|
|
1371
1260
|
}
|
|
1372
|
-
async getSnapshot(input,
|
|
1261
|
+
async getSnapshot(input, minimumCaptureWaitMs = 0, maximumCacheAgeMs = Number.POSITIVE_INFINITY) {
|
|
1373
1262
|
const { deviceId } = input;
|
|
1374
1263
|
const force = input.force === true;
|
|
1375
1264
|
const meta = await this.lookupDeviceMeta(deviceId);
|
|
@@ -1380,6 +1269,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1380
1269
|
...deviceName ? { deviceName } : {}
|
|
1381
1270
|
});
|
|
1382
1271
|
const now = Date.now();
|
|
1272
|
+
if (meta?.disabled === true) return await this.courtesyImage(deviceId, "disabled", deviceName);
|
|
1383
1273
|
const prefs = await this.readDeviceSettings(deviceId).catch(() => ({}));
|
|
1384
1274
|
const rawPref = prefs.snapshotStreamId;
|
|
1385
1275
|
const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== "auto" ? rawPref : void 0);
|
|
@@ -1388,7 +1278,7 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1388
1278
|
meta: { stream: effectiveStreamId ?? "auto" }
|
|
1389
1279
|
});
|
|
1390
1280
|
const hit = this.cache.get(deviceId, effectiveStreamId);
|
|
1391
|
-
const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3
|
|
1281
|
+
const effectiveMaxAgeMs = Math.min(maximumCacheAgeMs, Math.max(0, effectiveMaxAgeS(prefs, isBatteryDevice) * 1e3));
|
|
1392
1282
|
const decision = decideSnapshotServe({
|
|
1393
1283
|
now,
|
|
1394
1284
|
cachedAt: hit?.ts ?? null,
|
|
@@ -1414,7 +1304,8 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1414
1304
|
ageMs: hit ? now - hit.ts : null
|
|
1415
1305
|
}
|
|
1416
1306
|
});
|
|
1417
|
-
|
|
1307
|
+
if (hit) return hit.data;
|
|
1308
|
+
return await this.courtesyImage(deviceId, "sleeping", deviceName);
|
|
1418
1309
|
}
|
|
1419
1310
|
const flightKey = `${deviceId}:${effectiveStreamId ?? "auto"}`;
|
|
1420
1311
|
const flight = this.captureFlight.run(flightKey, () => this.captureFresh({
|
|
@@ -1426,7 +1317,17 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1426
1317
|
}));
|
|
1427
1318
|
flight.catch(() => void 0);
|
|
1428
1319
|
const raced = await raceForResult(flight, Math.max(decision.waitMs, minimumCaptureWaitMs));
|
|
1429
|
-
if (raced.settled)
|
|
1320
|
+
if (raced.settled) try {
|
|
1321
|
+
const resolved = this.resolveOutcome(raced.value, deviceId, hit, log);
|
|
1322
|
+
if (resolved !== null) return resolved;
|
|
1323
|
+
return meta?.online === false ? await this.courtesyImage(deviceId, "offline", deviceName) : null;
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
if (meta?.online === false) {
|
|
1326
|
+
const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
|
|
1327
|
+
if (courtesy !== null) return courtesy;
|
|
1328
|
+
}
|
|
1329
|
+
throw err;
|
|
1330
|
+
}
|
|
1430
1331
|
if (decision.staleFallback && hit) {
|
|
1431
1332
|
if (prefs.snapshotDebug) log.debug("snapshot: SWR — returning stale frame; refresh continues in background", {
|
|
1432
1333
|
tags: { deviceId },
|
|
@@ -1437,6 +1338,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1437
1338
|
});
|
|
1438
1339
|
return hit.data;
|
|
1439
1340
|
}
|
|
1341
|
+
if (meta?.online === false) {
|
|
1342
|
+
const courtesy = await this.courtesyImage(deviceId, "offline", deviceName);
|
|
1343
|
+
if (courtesy !== null) return courtesy;
|
|
1344
|
+
}
|
|
1440
1345
|
return null;
|
|
1441
1346
|
}
|
|
1442
1347
|
/**
|
|
@@ -1750,7 +1655,6 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1750
1655
|
*/
|
|
1751
1656
|
getSnapshotOverview(input) {
|
|
1752
1657
|
const now = Date.now();
|
|
1753
|
-
this.subscriptions.renew(input.deviceIds, now);
|
|
1754
1658
|
const rows = input.deviceIds.map((deviceId) => {
|
|
1755
1659
|
const hit = this.cache.latest(deviceId);
|
|
1756
1660
|
if (!hit) return {
|
|
@@ -1839,6 +1743,56 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1839
1743
|
}] };
|
|
1840
1744
|
}
|
|
1841
1745
|
/**
|
|
1746
|
+
* The frame a camera that CANNOT capture answers with.
|
|
1747
|
+
*
|
|
1748
|
+
* Returns null only if rendering itself fails — the caller then falls through
|
|
1749
|
+
* to its previous behaviour, so a broken courtesy path degrades to the old
|
|
1750
|
+
* 404 rather than masking a real capture failure as a placeholder.
|
|
1751
|
+
*
|
|
1752
|
+
* Rendered at the full tile size; the existing resize ladder derives the
|
|
1753
|
+
* card-sized variants from it exactly as it does for a real frame, so no
|
|
1754
|
+
* downstream consumer needs to know this frame is synthetic.
|
|
1755
|
+
*/
|
|
1756
|
+
async courtesyFrame(deviceId, reason, deviceName) {
|
|
1757
|
+
const width = 640;
|
|
1758
|
+
const height = Math.round(width * 9 / 16);
|
|
1759
|
+
const key = courtesyCacheKey({
|
|
1760
|
+
deviceName,
|
|
1761
|
+
reason,
|
|
1762
|
+
width,
|
|
1763
|
+
height
|
|
1764
|
+
});
|
|
1765
|
+
const cached = this.courtesyFrames.get(key);
|
|
1766
|
+
if (cached !== void 0) return cached;
|
|
1767
|
+
try {
|
|
1768
|
+
const bytes = await renderCourtesyJpeg({
|
|
1769
|
+
deviceName,
|
|
1770
|
+
reason,
|
|
1771
|
+
width,
|
|
1772
|
+
height
|
|
1773
|
+
});
|
|
1774
|
+
this.courtesyFrames.set(key, bytes);
|
|
1775
|
+
return bytes;
|
|
1776
|
+
} catch (err) {
|
|
1777
|
+
this.ctx.logger.warn("snapshot: courtesy frame failed — falling back to no image", {
|
|
1778
|
+
tags: { deviceId },
|
|
1779
|
+
meta: {
|
|
1780
|
+
reason,
|
|
1781
|
+
error: require_dist.errMsg(err)
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
return null;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
/** A courtesy frame shaped as the cap's response, or null when unrenderable. */
|
|
1788
|
+
async courtesyImage(deviceId, reason, deviceName) {
|
|
1789
|
+
const bytes = await this.courtesyFrame(deviceId, reason, deviceName ?? `#${String(deviceId)}`);
|
|
1790
|
+
return bytes === null ? null : {
|
|
1791
|
+
base64: bytes.toString("base64"),
|
|
1792
|
+
contentType: "image/jpeg"
|
|
1793
|
+
};
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1842
1796
|
* Single-trip device lookup against device-manager. Returns the
|
|
1843
1797
|
* fields the wrapper actually consults — name (logging) + battery
|
|
1844
1798
|
* flag (cache window + broker-fallback gate). Sourced from the
|
|
@@ -1858,10 +1812,13 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
1858
1812
|
if (!found) return null;
|
|
1859
1813
|
const features = found.features ?? [];
|
|
1860
1814
|
const rawType = found.type;
|
|
1815
|
+
const row = found;
|
|
1861
1816
|
return {
|
|
1862
1817
|
...found.name ? { name: found.name } : {},
|
|
1863
1818
|
isBattery: features.includes(require_dist.DeviceFeature.BatteryOperated),
|
|
1864
|
-
...rawType ? { type: rawType } : {}
|
|
1819
|
+
...rawType ? { type: rawType } : {},
|
|
1820
|
+
disabled: row.disabled === true,
|
|
1821
|
+
online: row.online !== false
|
|
1865
1822
|
};
|
|
1866
1823
|
} catch (err) {
|
|
1867
1824
|
this.ctx.logger.debug("deviceManager.getDevice failed during snapshot", {
|