@camstack/addon-terminal 0.1.12 → 0.1.13
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 +95 -18
- package/dist/addon.mjs +95 -18
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -18428,24 +18428,28 @@ var snapshotCapability = {
|
|
|
18428
18428
|
*
|
|
18429
18429
|
* `getSnapshotOverview` is cache-only by contract: it answers from whatever
|
|
18430
18430
|
* the wrapper happens to hold and never captures. Under D93 the client
|
|
18431
|
-
* versions its image URL on that answer, and an image REQUEST
|
|
18432
|
-
*
|
|
18433
|
-
*
|
|
18434
|
-
*
|
|
18435
|
-
*
|
|
18436
|
-
*
|
|
18437
|
-
*
|
|
18438
|
-
*
|
|
18431
|
+
* versions its image URL on that answer, and an image REQUEST was the only
|
|
18432
|
+
* demand signal. Both of those are satisfiable by the client's own image
|
|
18433
|
+
* cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
|
|
18434
|
+
* in a previous session comes off disk with no network, no demand, and no
|
|
18435
|
+
* capture. Measured on the live hub: reopening after two minutes idle
|
|
18436
|
+
* painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
|
|
18437
|
+
* fleet only recovered because a later poll happened to observe a different
|
|
18438
|
+
* identity.
|
|
18439
18439
|
*
|
|
18440
18440
|
* ## The two properties that fix it
|
|
18441
18441
|
*
|
|
18442
18442
|
* **It is an RPC, so no client cache can answer it.** The demand signal
|
|
18443
|
-
* always reaches the wrapper. This method therefore
|
|
18444
|
-
*
|
|
18445
|
-
*
|
|
18446
|
-
*
|
|
18447
|
-
*
|
|
18448
|
-
*
|
|
18443
|
+
* always reaches the wrapper. This method therefore CAPTURES, where
|
|
18444
|
+
* `getSnapshotOverview` must never (D93) — the distinction is not "one is
|
|
18445
|
+
* newer" but that the overview poll is app-wide (a capturing overview would
|
|
18446
|
+
* dial every camera on the install) while this is called by a rendered
|
|
18447
|
+
* surface naming the tiles it is actually painting, at the width it is
|
|
18448
|
+
* painting them.
|
|
18449
|
+
*
|
|
18450
|
+
* Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
|
|
18451
|
+
* server-side keep-warm loop was removed (operator directive — on-demand,
|
|
18452
|
+
* always), so a camera nobody is looking at costs nothing at all.
|
|
18449
18453
|
*
|
|
18450
18454
|
* **It waits, briefly and boundedly, for the capture it triggered.** The
|
|
18451
18455
|
* returned `capturedAt` is the frame the link will serve, not the frame the
|
|
@@ -27414,9 +27418,10 @@ var DeclaredDevices = class {
|
|
|
27414
27418
|
}
|
|
27415
27419
|
const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
|
|
27416
27420
|
const index = await this.readIndex();
|
|
27421
|
+
const live = await this.readLiveByStableId();
|
|
27417
27422
|
const outcomes = [];
|
|
27418
27423
|
for (const declaration of spec.devices) {
|
|
27419
|
-
const outcome = await this.applyDeclaration(declaration, integrationId, index);
|
|
27424
|
+
const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
|
|
27420
27425
|
if (outcome !== null) outcomes.push(outcome);
|
|
27421
27426
|
}
|
|
27422
27427
|
return {
|
|
@@ -27462,6 +27467,26 @@ var DeclaredDevices = class {
|
|
|
27462
27467
|
return new Map(rows.map((row) => [row.stableId, row]));
|
|
27463
27468
|
}
|
|
27464
27469
|
/**
|
|
27470
|
+
* Devices this kernel already has CONSTRUCTED, by stableId.
|
|
27471
|
+
*
|
|
27472
|
+
* Distinct from {@link readIndex}, and the distinction is the bug: the index
|
|
27473
|
+
* is persisted rows, this is live objects. A row without an object must be
|
|
27474
|
+
* adopted; an object must be left exactly as it is.
|
|
27475
|
+
*
|
|
27476
|
+
* Failure is non-fatal and deliberately so — an empty map degrades to the
|
|
27477
|
+
* previous behaviour (attempt the adopt) rather than skipping a device that
|
|
27478
|
+
* genuinely needs bringing up.
|
|
27479
|
+
*/
|
|
27480
|
+
async readLiveByStableId() {
|
|
27481
|
+
try {
|
|
27482
|
+
const devices = await this.ports.devices.getAll();
|
|
27483
|
+
return new Map(devices.map((device) => [device.stableId, device]));
|
|
27484
|
+
} catch (err) {
|
|
27485
|
+
this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
27486
|
+
return /* @__PURE__ */ new Map();
|
|
27487
|
+
}
|
|
27488
|
+
}
|
|
27489
|
+
/**
|
|
27465
27490
|
* One declaration: adopt what exists, create what does not.
|
|
27466
27491
|
*
|
|
27467
27492
|
* The create branch is the destructive one — it seeds `initialMeta`, and
|
|
@@ -27470,8 +27495,15 @@ var DeclaredDevices = class {
|
|
|
27470
27495
|
* the declared name over the operator's rename. D49: that branch needs a
|
|
27471
27496
|
* second read to agree.
|
|
27472
27497
|
*/
|
|
27473
|
-
async applyDeclaration(declaration, integrationId, index) {
|
|
27498
|
+
async applyDeclaration(declaration, integrationId, index, live) {
|
|
27474
27499
|
try {
|
|
27500
|
+
const alreadyLive = live.get(declaration.stableId);
|
|
27501
|
+
if (alreadyLive !== void 0) return {
|
|
27502
|
+
stableId: declaration.stableId,
|
|
27503
|
+
deviceId: alreadyLive.id,
|
|
27504
|
+
device: alreadyLive,
|
|
27505
|
+
created: false
|
|
27506
|
+
};
|
|
27475
27507
|
let existing = index.get(declaration.stableId);
|
|
27476
27508
|
if (existing === void 0) {
|
|
27477
27509
|
existing = (await this.readIndex()).get(declaration.stableId);
|
|
@@ -33478,9 +33510,20 @@ function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
|
|
|
33478
33510
|
function escapeXml(value) {
|
|
33479
33511
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
33480
33512
|
}
|
|
33481
|
-
/**
|
|
33513
|
+
/**
|
|
33514
|
+
* Render already-interpreted terminal rows into a compact MJPEG frame.
|
|
33515
|
+
*
|
|
33516
|
+
* `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
|
|
33517
|
+
* runs of whitespace by default, and a terminal's entire column alignment IS
|
|
33518
|
+
* runs of whitespace — Glances pads every field with spaces. Without it the
|
|
33519
|
+
* frame drew each line at roughly half its true width, crammed into the
|
|
33520
|
+
* top-left of a mostly-black image, while the SAME session over `attach`
|
|
33521
|
+
* looked perfect — which is exactly how the operator reported it. Measured in
|
|
33522
|
+
* the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
|
|
33523
|
+
* collapsed against 178 px preserved.
|
|
33524
|
+
*/
|
|
33482
33525
|
async function renderTerminalJpeg(lines) {
|
|
33483
|
-
const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
|
|
33526
|
+
const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
|
|
33484
33527
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="#0b0d10"/><g fill="#d7dce2" font-family="DejaVu Sans Mono,monospace" font-size="13">${renderedLines}</g></svg>`;
|
|
33485
33528
|
return (0, sharp.default)(Buffer.from(svg)).jpeg({
|
|
33486
33529
|
quality: 82,
|
|
@@ -39608,12 +39651,46 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39608
39651
|
this.migratedTerminalCameraConfigIds.add(outcome.device.id);
|
|
39609
39652
|
}
|
|
39610
39653
|
}
|
|
39654
|
+
if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
|
|
39611
39655
|
const nodeId = outcome.device.config.get("nodeId");
|
|
39612
39656
|
const profileId = outcome.device.config.get("profileId");
|
|
39613
39657
|
const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
|
|
39614
39658
|
outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
|
|
39615
39659
|
}
|
|
39616
39660
|
}
|
|
39661
|
+
/**
|
|
39662
|
+
* A Terminal camera is a rendered screen. Object detection on it finds
|
|
39663
|
+
* nothing, forever, at full cost.
|
|
39664
|
+
*
|
|
39665
|
+
* Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
|
|
39666
|
+
* through the detection pipeline at ~61 ms of inference each, plus 115
|
|
39667
|
+
* capture-scheduler requests a minute — against `detections=0`. Multiply by
|
|
39668
|
+
* one terminal per node and it is a standing tax on a hub that was already
|
|
39669
|
+
* shedding 86 % of its capture queue.
|
|
39670
|
+
*
|
|
39671
|
+
* Written through `setCameraSwitch`, which is the authority that already owns
|
|
39672
|
+
* this function — [D62] forbids a second store that disagrees with it. And
|
|
39673
|
+
* written ONLY on creation: an operator who deliberately turns detection back
|
|
39674
|
+
* on for a terminal must win, and a reconcile that re-asserted every pass
|
|
39675
|
+
* would silently overrule them once a minute.
|
|
39676
|
+
*/
|
|
39677
|
+
async silenceAnalysisFor(deviceId) {
|
|
39678
|
+
for (const switchId of ["object-detection", "audio-analysis"]) try {
|
|
39679
|
+
await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
|
|
39680
|
+
deviceId,
|
|
39681
|
+
switchId,
|
|
39682
|
+
enabled: false
|
|
39683
|
+
});
|
|
39684
|
+
} catch (err) {
|
|
39685
|
+
this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
|
|
39686
|
+
tags: { deviceId },
|
|
39687
|
+
meta: {
|
|
39688
|
+
switchId,
|
|
39689
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39690
|
+
}
|
|
39691
|
+
});
|
|
39692
|
+
}
|
|
39693
|
+
}
|
|
39617
39694
|
terminalInstanceControl() {
|
|
39618
39695
|
return {
|
|
39619
39696
|
listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
|
package/dist/addon.mjs
CHANGED
|
@@ -18406,24 +18406,28 @@ var snapshotCapability = {
|
|
|
18406
18406
|
*
|
|
18407
18407
|
* `getSnapshotOverview` is cache-only by contract: it answers from whatever
|
|
18408
18408
|
* the wrapper happens to hold and never captures. Under D93 the client
|
|
18409
|
-
* versions its image URL on that answer, and an image REQUEST
|
|
18410
|
-
*
|
|
18411
|
-
*
|
|
18412
|
-
*
|
|
18413
|
-
*
|
|
18414
|
-
*
|
|
18415
|
-
*
|
|
18416
|
-
*
|
|
18409
|
+
* versions its image URL on that answer, and an image REQUEST was the only
|
|
18410
|
+
* demand signal. Both of those are satisfiable by the client's own image
|
|
18411
|
+
* cache — `expo-image` is URL-keyed and never revalidates — so a URL painted
|
|
18412
|
+
* in a previous session comes off disk with no network, no demand, and no
|
|
18413
|
+
* capture. Measured on the live hub: reopening after two minutes idle
|
|
18414
|
+
* painted 15 of 16 tiles at **168 s old** with zero HTTP requests, and the
|
|
18415
|
+
* fleet only recovered because a later poll happened to observe a different
|
|
18416
|
+
* identity.
|
|
18417
18417
|
*
|
|
18418
18418
|
* ## The two properties that fix it
|
|
18419
18419
|
*
|
|
18420
18420
|
* **It is an RPC, so no client cache can answer it.** The demand signal
|
|
18421
|
-
* always reaches the wrapper. This method therefore
|
|
18422
|
-
*
|
|
18423
|
-
*
|
|
18424
|
-
*
|
|
18425
|
-
*
|
|
18426
|
-
*
|
|
18421
|
+
* always reaches the wrapper. This method therefore CAPTURES, where
|
|
18422
|
+
* `getSnapshotOverview` must never (D93) — the distinction is not "one is
|
|
18423
|
+
* newer" but that the overview poll is app-wide (a capturing overview would
|
|
18424
|
+
* dial every camera on the install) while this is called by a rendered
|
|
18425
|
+
* surface naming the tiles it is actually painting, at the width it is
|
|
18426
|
+
* painting them.
|
|
18427
|
+
*
|
|
18428
|
+
* Since 2026-08-11 this is the ONLY thing that refreshes a snapshot: the
|
|
18429
|
+
* server-side keep-warm loop was removed (operator directive — on-demand,
|
|
18430
|
+
* always), so a camera nobody is looking at costs nothing at all.
|
|
18427
18431
|
*
|
|
18428
18432
|
* **It waits, briefly and boundedly, for the capture it triggered.** The
|
|
18429
18433
|
* returned `capturedAt` is the frame the link will serve, not the frame the
|
|
@@ -27392,9 +27396,10 @@ var DeclaredDevices = class {
|
|
|
27392
27396
|
}
|
|
27393
27397
|
const integrationId = spec.integrationId ?? await this.ensureIntegration(spec.integrationName);
|
|
27394
27398
|
const index = await this.readIndex();
|
|
27399
|
+
const live = await this.readLiveByStableId();
|
|
27395
27400
|
const outcomes = [];
|
|
27396
27401
|
for (const declaration of spec.devices) {
|
|
27397
|
-
const outcome = await this.applyDeclaration(declaration, integrationId, index);
|
|
27402
|
+
const outcome = await this.applyDeclaration(declaration, integrationId, index, live);
|
|
27398
27403
|
if (outcome !== null) outcomes.push(outcome);
|
|
27399
27404
|
}
|
|
27400
27405
|
return {
|
|
@@ -27440,6 +27445,26 @@ var DeclaredDevices = class {
|
|
|
27440
27445
|
return new Map(rows.map((row) => [row.stableId, row]));
|
|
27441
27446
|
}
|
|
27442
27447
|
/**
|
|
27448
|
+
* Devices this kernel already has CONSTRUCTED, by stableId.
|
|
27449
|
+
*
|
|
27450
|
+
* Distinct from {@link readIndex}, and the distinction is the bug: the index
|
|
27451
|
+
* is persisted rows, this is live objects. A row without an object must be
|
|
27452
|
+
* adopted; an object must be left exactly as it is.
|
|
27453
|
+
*
|
|
27454
|
+
* Failure is non-fatal and deliberately so — an empty map degrades to the
|
|
27455
|
+
* previous behaviour (attempt the adopt) rather than skipping a device that
|
|
27456
|
+
* genuinely needs bringing up.
|
|
27457
|
+
*/
|
|
27458
|
+
async readLiveByStableId() {
|
|
27459
|
+
try {
|
|
27460
|
+
const devices = await this.ports.devices.getAll();
|
|
27461
|
+
return new Map(devices.map((device) => [device.stableId, device]));
|
|
27462
|
+
} catch (err) {
|
|
27463
|
+
this.ports.logger.warn("could not read live devices — falling back to adopt-by-row", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
27464
|
+
return /* @__PURE__ */ new Map();
|
|
27465
|
+
}
|
|
27466
|
+
}
|
|
27467
|
+
/**
|
|
27443
27468
|
* One declaration: adopt what exists, create what does not.
|
|
27444
27469
|
*
|
|
27445
27470
|
* The create branch is the destructive one — it seeds `initialMeta`, and
|
|
@@ -27448,8 +27473,15 @@ var DeclaredDevices = class {
|
|
|
27448
27473
|
* the declared name over the operator's rename. D49: that branch needs a
|
|
27449
27474
|
* second read to agree.
|
|
27450
27475
|
*/
|
|
27451
|
-
async applyDeclaration(declaration, integrationId, index) {
|
|
27476
|
+
async applyDeclaration(declaration, integrationId, index, live) {
|
|
27452
27477
|
try {
|
|
27478
|
+
const alreadyLive = live.get(declaration.stableId);
|
|
27479
|
+
if (alreadyLive !== void 0) return {
|
|
27480
|
+
stableId: declaration.stableId,
|
|
27481
|
+
deviceId: alreadyLive.id,
|
|
27482
|
+
device: alreadyLive,
|
|
27483
|
+
created: false
|
|
27484
|
+
};
|
|
27453
27485
|
let existing = index.get(declaration.stableId);
|
|
27454
27486
|
if (existing === void 0) {
|
|
27455
27487
|
existing = (await this.readIndex()).get(declaration.stableId);
|
|
@@ -33456,9 +33488,20 @@ function needsTerminalCameraConfigMigration(persistedConfig, declaration) {
|
|
|
33456
33488
|
function escapeXml(value) {
|
|
33457
33489
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll("\"", """).replaceAll("'", "'");
|
|
33458
33490
|
}
|
|
33459
|
-
/**
|
|
33491
|
+
/**
|
|
33492
|
+
* Render already-interpreted terminal rows into a compact MJPEG frame.
|
|
33493
|
+
*
|
|
33494
|
+
* `xml:space="preserve"` is load-bearing, not tidiness. SVG `<text>` collapses
|
|
33495
|
+
* runs of whitespace by default, and a terminal's entire column alignment IS
|
|
33496
|
+
* runs of whitespace — Glances pads every field with spaces. Without it the
|
|
33497
|
+
* frame drew each line at roughly half its true width, crammed into the
|
|
33498
|
+
* top-left of a mostly-black image, while the SAME session over `attach`
|
|
33499
|
+
* looked perfect — which is exactly how the operator reported it. Measured in
|
|
33500
|
+
* the hub's own rasterizer 2026-08-11: `AAA<10 spaces>BBB<3>CCC` drew 92 px
|
|
33501
|
+
* collapsed against 178 px preserved.
|
|
33502
|
+
*/
|
|
33460
33503
|
async function renderTerminalJpeg(lines) {
|
|
33461
|
-
const renderedLines = lines.slice(0, 40).map((line, index) => `<text x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
|
|
33504
|
+
const renderedLines = lines.slice(0, 40).map((line, index) => `<text xml:space="preserve" x="8" y="${String(18 + index * 15)}">${escapeXml(line.slice(0, 120))}</text>`).join("");
|
|
33462
33505
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${String(960)}" height="${String(640)}"><rect width="100%" height="100%" fill="#0b0d10"/><g fill="#d7dce2" font-family="DejaVu Sans Mono,monospace" font-size="13">${renderedLines}</g></svg>`;
|
|
33463
33506
|
return sharp(Buffer.from(svg)).jpeg({
|
|
33464
33507
|
quality: 82,
|
|
@@ -39586,12 +39629,46 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
39586
39629
|
this.migratedTerminalCameraConfigIds.add(outcome.device.id);
|
|
39587
39630
|
}
|
|
39588
39631
|
}
|
|
39632
|
+
if (outcome.created) await this.silenceAnalysisFor(outcome.device.id);
|
|
39589
39633
|
const nodeId = outcome.device.config.get("nodeId");
|
|
39590
39634
|
const profileId = outcome.device.config.get("profileId");
|
|
39591
39635
|
const profileAvailable = this.cameraProfilesByNode.get(nodeId)?.some((profile) => profile.profileId === profileId) ?? false;
|
|
39592
39636
|
outcome.device.setNodeOnline((onlineByNode.get(nodeId) ?? false) && profileAvailable);
|
|
39593
39637
|
}
|
|
39594
39638
|
}
|
|
39639
|
+
/**
|
|
39640
|
+
* A Terminal camera is a rendered screen. Object detection on it finds
|
|
39641
|
+
* nothing, forever, at full cost.
|
|
39642
|
+
*
|
|
39643
|
+
* Measured on the hub 2026-08-11, for ONE terminal camera: 19 frames per 10 s
|
|
39644
|
+
* through the detection pipeline at ~61 ms of inference each, plus 115
|
|
39645
|
+
* capture-scheduler requests a minute — against `detections=0`. Multiply by
|
|
39646
|
+
* one terminal per node and it is a standing tax on a hub that was already
|
|
39647
|
+
* shedding 86 % of its capture queue.
|
|
39648
|
+
*
|
|
39649
|
+
* Written through `setCameraSwitch`, which is the authority that already owns
|
|
39650
|
+
* this function — [D62] forbids a second store that disagrees with it. And
|
|
39651
|
+
* written ONLY on creation: an operator who deliberately turns detection back
|
|
39652
|
+
* on for a terminal must win, and a reconcile that re-asserted every pass
|
|
39653
|
+
* would silently overrule them once a minute.
|
|
39654
|
+
*/
|
|
39655
|
+
async silenceAnalysisFor(deviceId) {
|
|
39656
|
+
for (const switchId of ["object-detection", "audio-analysis"]) try {
|
|
39657
|
+
await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
|
|
39658
|
+
deviceId,
|
|
39659
|
+
switchId,
|
|
39660
|
+
enabled: false
|
|
39661
|
+
});
|
|
39662
|
+
} catch (err) {
|
|
39663
|
+
this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
|
|
39664
|
+
tags: { deviceId },
|
|
39665
|
+
meta: {
|
|
39666
|
+
switchId,
|
|
39667
|
+
error: err instanceof Error ? err.message : String(err)
|
|
39668
|
+
}
|
|
39669
|
+
});
|
|
39670
|
+
}
|
|
39671
|
+
}
|
|
39595
39672
|
terminalInstanceControl() {
|
|
39596
39673
|
return {
|
|
39597
39674
|
listInstances: async () => this.terminalInstances().map((instance) => this.instanceInfo(instance)),
|