@camstack/system 1.2.66 → 1.2.68
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/blocks-integration.d.ts +24 -0
- package/dist/builtins/core-blocks/core-blocks.addon.d.ts +14 -0
- package/dist/builtins/core-blocks/core-blocks.addon.js +99 -2
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +99 -2
- package/dist/builtins/device-manager/device-manager.addon.js +3 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +3 -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 +339 -2
- package/dist/builtins/snapshot/index.mjs +339 -2
- package/dist/builtins/snapshot/snapshot-link-url.d.ts +66 -0
- package/dist/builtins/snapshot/snapshot.addon.d.ts +79 -0
- 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-DqPyKwVB.mjs → dist-D2J8RJ7m.mjs} +1959 -1492
- package/dist/{dist-CIkGOAcz.js → dist-Dhpte164.js} +1965 -1486
- package/dist/index.js +28 -4
- package/dist/index.mjs +28 -4
- package/dist/kernel/cap-router-builder.d.ts +19 -0
- package/dist/kernel/capability-registry.d.ts +14 -1
- package/package.json +1 -1
|
@@ -3,7 +3,10 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
require("../../chunk-Cek0wNdY.js");
|
|
6
|
-
const require_dist = require("../../dist-
|
|
6
|
+
const require_dist = require("../../dist-Dhpte164.js");
|
|
7
|
+
let _camstack_types_node = require("@camstack/types/node");
|
|
8
|
+
let zod = require("zod");
|
|
9
|
+
let node_crypto = require("node:crypto");
|
|
7
10
|
let node_child_process = require("node:child_process");
|
|
8
11
|
//#region src/builtins/snapshot/snapshot-coalescing.ts
|
|
9
12
|
/**
|
|
@@ -645,7 +648,146 @@ var SnapshotSubscriptions = class {
|
|
|
645
648
|
}
|
|
646
649
|
};
|
|
647
650
|
//#endregion
|
|
651
|
+
//#region src/builtins/snapshot/snapshot-link-url.ts
|
|
652
|
+
/**
|
|
653
|
+
* Signed, expiring links to a CLIENT-SIZED snapshot frame.
|
|
654
|
+
*
|
|
655
|
+
* ## Why a link plane exists at all
|
|
656
|
+
*
|
|
657
|
+
* The authenticated `/addon/snapshot/media/<id>.jpg` plane works, and it is not
|
|
658
|
+
* going away. What it cannot do is guarantee that a client asking for a tile
|
|
659
|
+
* actually REACHES the server — and that turned out to be the whole bug.
|
|
660
|
+
*
|
|
661
|
+
* Under D93 the image URL is versioned by the frame identity, and an image
|
|
662
|
+
* request is what enrols a camera in the keep-warm loop. Both halves are
|
|
663
|
+
* satisfied by the client's own image cache: `expo-image` is URL-keyed and never
|
|
664
|
+
* revalidates, so a URL the app painted in a previous session is served from
|
|
665
|
+
* disk with **zero network**. Measured on the live hub, reopening the app after
|
|
666
|
+
* two minutes idle painted 15 of 16 tiles from disk — frames **168 s old**, with
|
|
667
|
+
* not one HTTP request, therefore not one enrolment, therefore nothing warming.
|
|
668
|
+
* The operator's report ("gli snapshot sono vecchi, devo aggiornare più volte")
|
|
669
|
+
* is that measurement.
|
|
670
|
+
*
|
|
671
|
+
* A minted link breaks the loop from both ends. It is produced by an RPC —
|
|
672
|
+
* `snapshot.getSnapshotLinks` — which no image cache can answer, so the demand
|
|
673
|
+
* signal always lands; and it carries the capture identity the RPC just WAITED
|
|
674
|
+
* for, rather than one a cache-only poll happened to be holding.
|
|
675
|
+
*
|
|
676
|
+
* ## What is signed, and what is only a cache key
|
|
677
|
+
*
|
|
678
|
+
* The signature covers `"<deviceId>:<width>"` and the expiry. The width is
|
|
679
|
+
* inside it deliberately: a leaked 240 px tile link must not be replayable as a
|
|
680
|
+
* request for the full 4 K frame. `v` (the capture identity) is NOT signed — it
|
|
681
|
+
* exists only to key the client's image cache, so an unchanged frame is a
|
|
682
|
+
* byte-identical URL and costs no bytes, and a new frame is a new URL and costs
|
|
683
|
+
* exactly one fetch.
|
|
684
|
+
*
|
|
685
|
+
* `exp` is bucketed rather than exact. A URL that were unique per mint would
|
|
686
|
+
* defeat the client cache completely — correct for freshness, and it would make
|
|
687
|
+
* a phone re-download every tile on every 5 s poll whether or not anything
|
|
688
|
+
* changed. Bucketing means the URL moves when the FRAME moves, and otherwise at
|
|
689
|
+
* most once per bucket.
|
|
690
|
+
*
|
|
691
|
+
* Pure and side-effect-free; the addon owns the secret and the clock. Unit
|
|
692
|
+
* tested in `__tests__/snapshot-link-url.spec.ts`.
|
|
693
|
+
*/
|
|
694
|
+
/**
|
|
695
|
+
* How long a minted link stays valid.
|
|
696
|
+
*
|
|
697
|
+
* A snapshot is a live view of the operator's home, so this is short on purpose
|
|
698
|
+
* — the exposure of a leaked link is bounded by it. Two minutes is long enough
|
|
699
|
+
* that a page renders, re-renders and survives a brief backgrounding on the URL
|
|
700
|
+
* it was given, and short enough that a link pasted somewhere is dead before it
|
|
701
|
+
* is useful. The client re-mints on every overview poll (5 s), so it never
|
|
702
|
+
* depends on the tail of this window.
|
|
703
|
+
*/
|
|
704
|
+
var SNAPSHOT_LINK_TTL_MS = 12e4;
|
|
705
|
+
/**
|
|
706
|
+
* Quantum the expiry is rounded UP to, so a link is stable between mints.
|
|
707
|
+
*
|
|
708
|
+
* Without it every mint produces a different `exp`, hence a different URL, hence
|
|
709
|
+
* a full re-download of an unchanged frame on every poll. With it the URL is a
|
|
710
|
+
* pure function of (device, width, frame, bucket) — so a tile fetches when its
|
|
711
|
+
* frame moves, and at most once more per bucket.
|
|
712
|
+
*/
|
|
713
|
+
var SNAPSHOT_LINK_EXP_BUCKET_MS = 3e4;
|
|
714
|
+
/** The token the signature is computed over. Width is part of the identity so a
|
|
715
|
+
* tile link cannot be escalated into a full-frame request. */
|
|
716
|
+
function snapshotLinkId(deviceId, width) {
|
|
717
|
+
return `${String(deviceId)}:${width === void 0 ? "full" : String(width)}`;
|
|
718
|
+
}
|
|
719
|
+
/** The bucketed expiry for a link minted at `nowMs`. Always ≥ `nowMs + TTL`. */
|
|
720
|
+
function snapshotLinkExpiry(nowMs, ttlMs = SNAPSHOT_LINK_TTL_MS, bucketMs = SNAPSHOT_LINK_EXP_BUCKET_MS) {
|
|
721
|
+
return Math.ceil((nowMs + ttlMs) / bucketMs) * bucketMs;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* The link, as a ROOT-RELATIVE path.
|
|
725
|
+
*
|
|
726
|
+
* Deliberately not absolute. The artifact and HA planes must mint absolute URLs
|
|
727
|
+
* because the fetcher is a phone or a notifier backend that has no idea where
|
|
728
|
+
* the hub is — and picking that base is the `hubUrl: localhost` trap the Alexa
|
|
729
|
+
* work paid for. Here the fetcher is a client that is already connected to the
|
|
730
|
+
* hub and holds its own `serverUrl`, so the correct base is the one it used to
|
|
731
|
+
* make the call. Returning a path makes it impossible to hand a client a link
|
|
732
|
+
* pointing somewhere it cannot reach.
|
|
733
|
+
*/
|
|
734
|
+
function buildSnapshotLinkUrl(input) {
|
|
735
|
+
const base = (input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`).replace(/\/+$/, "");
|
|
736
|
+
const id = snapshotLinkId(input.deviceId, input.width);
|
|
737
|
+
const sig = (0, _camstack_types_node.signExpiringUrl)(input.secret, id, input.expMs);
|
|
738
|
+
const params = new URLSearchParams();
|
|
739
|
+
if (input.width !== void 0) params.set("w", String(input.width));
|
|
740
|
+
if (input.capturedAt !== null) params.set("v", String(input.capturedAt));
|
|
741
|
+
params.set("exp", String(input.expMs));
|
|
742
|
+
params.set("sig", sig);
|
|
743
|
+
return `${base}/${String(input.deviceId)}.jpg?${params.toString()}`;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Parse and VERIFY a link request in one step, so a caller cannot accidentally
|
|
747
|
+
* use the device id before checking the signature. Null = refuse (404/403);
|
|
748
|
+
* there is deliberately no way to distinguish "bad signature" from "expired"
|
|
749
|
+
* from "malformed" at this boundary, so a public route cannot be probed.
|
|
750
|
+
*/
|
|
751
|
+
function parseVerifiedSnapshotLink(input) {
|
|
752
|
+
const qIdx = input.url.indexOf("?");
|
|
753
|
+
const rawPath = qIdx === -1 ? input.url : input.url.slice(0, qIdx);
|
|
754
|
+
const query = qIdx === -1 ? "" : input.url.slice(qIdx + 1);
|
|
755
|
+
const segment = rawPath.replace(/^\/+/, "");
|
|
756
|
+
if (segment.length === 0 || segment.includes("/")) return null;
|
|
757
|
+
const idPart = segment.replace(/\.jpe?g$/i, "");
|
|
758
|
+
if (!/^\d+$/.test(idPart)) return null;
|
|
759
|
+
const deviceId = Number.parseInt(idPart, 10);
|
|
760
|
+
if (!Number.isSafeInteger(deviceId) || deviceId <= 0) return null;
|
|
761
|
+
const params = new URLSearchParams(query);
|
|
762
|
+
const rawWidth = params.get("w");
|
|
763
|
+
if (rawWidth !== null && !/^\d+$/.test(rawWidth)) return null;
|
|
764
|
+
const width = rawWidth === null ? void 0 : Number.parseInt(rawWidth, 10);
|
|
765
|
+
if (width !== void 0 && (!Number.isSafeInteger(width) || width <= 0)) return null;
|
|
766
|
+
return (0, _camstack_types_node.verifyExpiringUrl)({
|
|
767
|
+
secret: input.secret,
|
|
768
|
+
id: snapshotLinkId(deviceId, width),
|
|
769
|
+
exp: params.get("exp") ?? void 0,
|
|
770
|
+
sig: params.get("sig") ?? void 0,
|
|
771
|
+
nowMs: input.nowMs
|
|
772
|
+
}) ? {
|
|
773
|
+
deviceId,
|
|
774
|
+
width
|
|
775
|
+
} : null;
|
|
776
|
+
}
|
|
777
|
+
//#endregion
|
|
648
778
|
//#region src/builtins/snapshot/snapshot.addon.ts
|
|
779
|
+
/**
|
|
780
|
+
* How long `getSnapshotLinks` waits for the capture it triggered before minting
|
|
781
|
+
* the link over whatever frame it has.
|
|
782
|
+
*
|
|
783
|
+
* Sized against the paint budget, not against the slowest camera. A cold fleet
|
|
784
|
+
* is paced by `MAX_CONCURRENT_GRABS`, so waiting for the last of sixteen would
|
|
785
|
+
* hold the whole grid for tens of seconds — and the frame that lands late is not
|
|
786
|
+
* lost, it populates the cache and the client's next 5 s poll picks it up. The
|
|
787
|
+
* value matches `SWR_STALE_WAIT_MS`'s intent: long enough that a warm broker
|
|
788
|
+
* answers inside it, short enough that a dead camera cannot stall the batch.
|
|
789
|
+
*/
|
|
790
|
+
var LINK_MINT_DEADLINE_MS = 2500;
|
|
649
791
|
/** Default cache window for non-battery cams (seconds). 10s feels live. */
|
|
650
792
|
var NON_BATTERY_DEFAULT_MAX_AGE_S = 10;
|
|
651
793
|
/** Default cache window for battery cams (seconds). 1h ≈ "don't wake the cam unless asked". */
|
|
@@ -712,6 +854,17 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
712
854
|
*/
|
|
713
855
|
mediaDataPlane = null;
|
|
714
856
|
/**
|
|
857
|
+
* The PUBLIC, signature-authenticated twin of the media plane. Null when the
|
|
858
|
+
* host has no `ctx.dataPlane` facility, or before the per-install secret has
|
|
859
|
+
* been minted — in which case `getSnapshotLinks` reports `url: null` and the
|
|
860
|
+
* client keeps using the authenticated plane exactly as before.
|
|
861
|
+
*/
|
|
862
|
+
linkDataPlane = null;
|
|
863
|
+
/** Per-install HMAC secret for link signatures. Minted once and persisted: a
|
|
864
|
+
* secret that rolled on every boot would invalidate every link a client is
|
|
865
|
+
* currently holding, which is a fleet-wide blank grid after each restart. */
|
|
866
|
+
linkSecret = null;
|
|
867
|
+
/**
|
|
715
868
|
* Who is being looked at, inferred from data-plane traffic. The keep-warm
|
|
716
869
|
* loop's entire bound — see `snapshot-keep-warm.ts`.
|
|
717
870
|
*/
|
|
@@ -743,9 +896,11 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
743
896
|
getDeviceLiveContribution: async () => null,
|
|
744
897
|
applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),
|
|
745
898
|
getStatus: async (input) => this.getStatus(input.deviceId),
|
|
746
|
-
getSnapshotOverview: (input) => this.getSnapshotOverview(input)
|
|
899
|
+
getSnapshotOverview: (input) => this.getSnapshotOverview(input),
|
|
900
|
+
getSnapshotLinks: (input) => this.getSnapshotLinks(input)
|
|
747
901
|
};
|
|
748
902
|
await this.serveMediaDataPlane();
|
|
903
|
+
await this.serveLinkDataPlane();
|
|
749
904
|
this.scheduleWarmTick();
|
|
750
905
|
return [{
|
|
751
906
|
capability: require_dist.snapshotCapability,
|
|
@@ -779,6 +934,184 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
779
934
|
}
|
|
780
935
|
}
|
|
781
936
|
/**
|
|
937
|
+
* Serve the PUBLIC link plane: `GET /addon/snapshot/link/<deviceId>.jpg?w&v&exp&sig`.
|
|
938
|
+
*
|
|
939
|
+
* `access: 'public'` is deliberate and is the only interesting decision here.
|
|
940
|
+
* The authority is the HMAC, exactly as for the notification artifact plane
|
|
941
|
+
* and the Home Assistant media plane — but unlike those, what sits behind it
|
|
942
|
+
* is a LIVE view of the operator's home, so the compensating choices are
|
|
943
|
+
* explicit: a short TTL (`SNAPSHOT_LINK_TTL_MS`), the width inside the signed
|
|
944
|
+
* identity so a leaked tile cannot be escalated to the full frame, and
|
|
945
|
+
* **`force` is not honoured on this route at all** — a public URL must never
|
|
946
|
+
* be able to wake a battery camera.
|
|
947
|
+
*
|
|
948
|
+
* A leaked link therefore grants: one camera, at one thumbnail width, for at
|
|
949
|
+
* most the TTL. No token, no other device, no session.
|
|
950
|
+
*/
|
|
951
|
+
async serveLinkDataPlane() {
|
|
952
|
+
try {
|
|
953
|
+
const secret = await this.ensureLinkSecret();
|
|
954
|
+
if (secret === null) return;
|
|
955
|
+
const prefix = this.linkRoutePrefix();
|
|
956
|
+
const handler = createSnapshotMediaHandler({
|
|
957
|
+
getMedia: (deviceId, streamId, _force, width) => this.resolveSnapshotMedia(deviceId, streamId, false, width),
|
|
958
|
+
peekFresh: (deviceId, streamId) => this.peekFreshFrame(deviceId, streamId)
|
|
959
|
+
});
|
|
960
|
+
this.linkDataPlane = await this.ctx.dataPlane?.serve({
|
|
961
|
+
prefix: "link",
|
|
962
|
+
access: "public",
|
|
963
|
+
handler: (req, res) => this.handleLinkRequest(req, res, secret, handler)
|
|
964
|
+
}) ?? null;
|
|
965
|
+
this.ctx.logger.info("snapshot link data-plane served", { meta: {
|
|
966
|
+
served: this.linkDataPlane !== null,
|
|
967
|
+
prefix
|
|
968
|
+
} });
|
|
969
|
+
} catch (err) {
|
|
970
|
+
this.ctx.logger.warn("snapshot link data-plane failed to serve", { meta: { error: require_dist.errMsg(err) } });
|
|
971
|
+
this.linkDataPlane = null;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Verify first, then hand the request to the ordinary media handler with a
|
|
976
|
+
* REWRITTEN url carrying only the signed `(deviceId, width)`.
|
|
977
|
+
*
|
|
978
|
+
* Rewriting rather than passing the original through is what stops an unsigned
|
|
979
|
+
* query parameter reaching the capture path: `force`, `streamId` and any other
|
|
980
|
+
* lever the authenticated plane honours are simply not present in what the
|
|
981
|
+
* handler parses.
|
|
982
|
+
*/
|
|
983
|
+
async handleLinkRequest(req, res, secret, inner) {
|
|
984
|
+
const verified = parseVerifiedSnapshotLink({
|
|
985
|
+
url: req.url ?? "/",
|
|
986
|
+
secret,
|
|
987
|
+
nowMs: Date.now()
|
|
988
|
+
});
|
|
989
|
+
if (verified === null) {
|
|
990
|
+
res.writeHead(404).end();
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
const rewritten = `/${String(verified.deviceId)}.jpg${verified.width === void 0 ? "" : `?w=${String(verified.width)}`}`;
|
|
994
|
+
const original = req.url;
|
|
995
|
+
try {
|
|
996
|
+
req.url = rewritten;
|
|
997
|
+
await inner(req, res);
|
|
998
|
+
} finally {
|
|
999
|
+
req.url = original;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
linkRoutePrefix() {
|
|
1003
|
+
return `/addon/${require_dist.bareAddonId(this.ctx.id)}/link`;
|
|
1004
|
+
}
|
|
1005
|
+
/** Mint-once-and-persist the signing secret. Null if it cannot be stored — a
|
|
1006
|
+
* secret held only in memory would break every outstanding link on restart,
|
|
1007
|
+
* so no plane is better than a plane whose links die unpredictably. */
|
|
1008
|
+
async ensureLinkSecret() {
|
|
1009
|
+
if (this.linkSecret !== null) return this.linkSecret;
|
|
1010
|
+
try {
|
|
1011
|
+
const state = this.state("snapshotLinkSecret", zod.z.string(), "");
|
|
1012
|
+
let secret = await state.get();
|
|
1013
|
+
if (secret === "") {
|
|
1014
|
+
secret = (0, node_crypto.randomUUID)().replace(/-/g, "");
|
|
1015
|
+
await state.set(secret);
|
|
1016
|
+
}
|
|
1017
|
+
this.linkSecret = secret;
|
|
1018
|
+
return secret;
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
this.ctx.logger.warn("snapshot: could not establish link signing secret", { meta: { error: require_dist.errMsg(err) } });
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Mint signed links for the tiles a surface is rendering — and, in the same
|
|
1026
|
+
* call, make those tiles CURRENT.
|
|
1027
|
+
*
|
|
1028
|
+
* This is the demand signal the design was missing. Under D93 enrolment rode
|
|
1029
|
+
* on the image request, and D93/D94/D95 each fixed a different way for that
|
|
1030
|
+
* request not to happen; none of them could fix the last one, because the
|
|
1031
|
+
* client's own image cache answers a repeated URL with no network at all. An
|
|
1032
|
+
* RPC has no such cache in front of it.
|
|
1033
|
+
*
|
|
1034
|
+
* So: note every target (creating a subscription, which `getSnapshotOverview`
|
|
1035
|
+
* deliberately cannot), then join the ordinary non-forced capture path and
|
|
1036
|
+
* wait a BOUNDED time for it. What comes back is the identity of the frame the
|
|
1037
|
+
* link will actually serve.
|
|
1038
|
+
*
|
|
1039
|
+
* The bound matters more than its exact value. A cold fleet is paced by
|
|
1040
|
+
* `MAX_CONCURRENT_GRABS`, so sixteen cameras cannot all capture at once and
|
|
1041
|
+
* the last of them would hold the whole batch well past any sensible paint
|
|
1042
|
+
* budget. Devices that miss the bound return their real, older `capturedAt` —
|
|
1043
|
+
* honest rather than blocking — and the client's next poll carries them
|
|
1044
|
+
* forward.
|
|
1045
|
+
*/
|
|
1046
|
+
async getSnapshotLinks(input) {
|
|
1047
|
+
const secret = await this.ensureLinkSecret();
|
|
1048
|
+
const now = Date.now();
|
|
1049
|
+
const expMs = snapshotLinkExpiry(now, SNAPSHOT_LINK_TTL_MS);
|
|
1050
|
+
const served = this.linkDataPlane !== null && secret !== null;
|
|
1051
|
+
const prefix = this.linkRoutePrefix();
|
|
1052
|
+
const rows = await Promise.all(input.targets.map(async (target) => {
|
|
1053
|
+
const { deviceId } = target;
|
|
1054
|
+
const width = target.width === void 0 ? void 0 : snapSnapshotWidth(target.width);
|
|
1055
|
+
this.subscriptions.note(deviceId, void 0, width, now);
|
|
1056
|
+
const sleeping = await this.isSleepingBatteryDevice(deviceId);
|
|
1057
|
+
if (!sleeping) await this.awaitFreshEnough(deviceId);
|
|
1058
|
+
const capturedAt = this.cache.latest(deviceId)?.ts ?? null;
|
|
1059
|
+
const answeredAt = Date.now();
|
|
1060
|
+
return {
|
|
1061
|
+
deviceId,
|
|
1062
|
+
url: served && secret !== null ? buildSnapshotLinkUrl({
|
|
1063
|
+
routePrefix: prefix,
|
|
1064
|
+
deviceId,
|
|
1065
|
+
width,
|
|
1066
|
+
capturedAt,
|
|
1067
|
+
secret,
|
|
1068
|
+
expMs
|
|
1069
|
+
}) : null,
|
|
1070
|
+
capturedAt,
|
|
1071
|
+
ageMs: capturedAt === null ? null : answeredAt - capturedAt,
|
|
1072
|
+
expiresAt: served ? expMs : null,
|
|
1073
|
+
width: width ?? null,
|
|
1074
|
+
neverCaptured: capturedAt === null,
|
|
1075
|
+
sleeping
|
|
1076
|
+
};
|
|
1077
|
+
}));
|
|
1078
|
+
this.ctx.logger.debug("snapshot: minted links", { meta: {
|
|
1079
|
+
targets: input.targets.length,
|
|
1080
|
+
served,
|
|
1081
|
+
stale: rows.filter((r) => r.ageMs !== null && r.ageMs > 15e3).length
|
|
1082
|
+
} });
|
|
1083
|
+
return rows;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Join the ordinary capture path for one device and wait, bounded, for it.
|
|
1087
|
+
*
|
|
1088
|
+
* Deliberately `getSnapshot` with `force: false` and nothing else: every gate
|
|
1089
|
+
* that already owns this decision — the per-device max-age, the single flight,
|
|
1090
|
+
* the grab pool, the sleeping-battery refusal — applies unchanged. A fresh
|
|
1091
|
+
* cache returns immediately; a stale one starts the refresh. There is no
|
|
1092
|
+
* second freshness policy here, which is the mistake D93 called out.
|
|
1093
|
+
*/
|
|
1094
|
+
async awaitFreshEnough(deviceId) {
|
|
1095
|
+
if (!(await raceForResult(this.getSnapshot({
|
|
1096
|
+
deviceId,
|
|
1097
|
+
force: false
|
|
1098
|
+
}).catch(() => null), LINK_MINT_DEADLINE_MS)).settled) this.ctx.logger.debug("snapshot: link mint gave up waiting; serving the older frame", {
|
|
1099
|
+
tags: { deviceId },
|
|
1100
|
+
meta: { deadlineMs: LINK_MINT_DEADLINE_MS }
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
/** True only for a BATTERY device that is currently asleep. Kept as one
|
|
1104
|
+
* question so the link path cannot accidentally treat "battery" as "asleep"
|
|
1105
|
+
* and stop refreshing a camera that is awake and streaming. */
|
|
1106
|
+
async isSleepingBatteryDevice(deviceId) {
|
|
1107
|
+
try {
|
|
1108
|
+
if ((await this.lookupDeviceMeta(deviceId))?.isBattery !== true) return false;
|
|
1109
|
+
return await this.isDeviceSleeping(deviceId);
|
|
1110
|
+
} catch {
|
|
1111
|
+
return false;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
782
1115
|
* Resolve a device (+ optional stream, + force) to renderable JPEG bytes for
|
|
783
1116
|
* the HTTP endpoint. Runs the same coalesced capture ladder as the tRPC
|
|
784
1117
|
* `getSnapshot` (so every consumer shares one upstream pull), then decodes the
|
|
@@ -975,6 +1308,10 @@ var SnapshotAddon = class SnapshotAddon extends require_dist.BaseAddon {
|
|
|
975
1308
|
await this.mediaDataPlane.dispose().catch(() => void 0);
|
|
976
1309
|
this.mediaDataPlane = null;
|
|
977
1310
|
}
|
|
1311
|
+
if (this.linkDataPlane) {
|
|
1312
|
+
await this.linkDataPlane.dispose().catch(() => void 0);
|
|
1313
|
+
this.linkDataPlane = null;
|
|
1314
|
+
}
|
|
978
1315
|
this.cache.clear();
|
|
979
1316
|
this.variants.clear();
|
|
980
1317
|
this.captureFlight.clear();
|