@camstack/addon-provider-homeassistant 1.2.601 → 1.2.603
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 +2 -1
- package/dist/addon.mjs +2 -1
- package/dist/{dist-BQLjEZxA.mjs → dist-CM5Z-Rdj.mjs} +367 -9
- package/dist/{dist-BXMUyWXo.js → dist-DVNChk7-.js} +367 -9
- package/dist/ha-export/ha-export.addon.js +324 -76
- package/dist/ha-export/ha-export.addon.mjs +324 -76
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as oauthIntegrationCapability, Bt as EventCategory, C as buildAddonRouteProvider, Et as nodePin, I as deviceExportCapability, It as string, a as COCO_TO_MACRO, bt as BaseAddon, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema } from "../dist-
|
|
1
|
+
import { $ as oauthIntegrationCapability, Bt as EventCategory, C as buildAddonRouteProvider, Et as nodePin, I as deviceExportCapability, It as string, a as COCO_TO_MACRO, bt as BaseAddon, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema } from "../dist-CM5Z-Rdj.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import "node:zlib";
|
|
4
4
|
import "node:fs";
|
|
@@ -3195,6 +3195,10 @@ var CAPS_NOT_EXPORTED = [
|
|
|
3195
3195
|
{
|
|
3196
3196
|
cap: "zone-rules",
|
|
3197
3197
|
reason: "Per-stage zone rules — configuration the pipeline consumes, with no value an automation reads."
|
|
3198
|
+
},
|
|
3199
|
+
{
|
|
3200
|
+
cap: "heating-valve",
|
|
3201
|
+
reason: "Radiator valve position telemetry (0–100 %). Shown on the CamStack thermostat row; no Home Assistant / voice-assistant entity mapping is defined yet — export would mint a sensor with no agreed device_class or control surface."
|
|
3198
3202
|
}
|
|
3199
3203
|
];
|
|
3200
3204
|
var UNNEGOTIATED_PLAN_OPTIONS = { platforms: UNNEGOTIATED_SUPPORT };
|
|
@@ -4735,14 +4739,15 @@ var PushClient = class {
|
|
|
4735
4739
|
* is flushed first — a value for an entity HA has not built yet is a
|
|
4736
4740
|
* value it discards.
|
|
4737
4741
|
*/
|
|
4738
|
-
async publishEntityChange(deviceKey, cmps, dev) {
|
|
4739
|
-
if (this.disposed) return;
|
|
4742
|
+
async publishEntityChange(deviceKey, cmps, dev, full = false) {
|
|
4743
|
+
if (this.disposed) return false;
|
|
4740
4744
|
await this.flush();
|
|
4741
|
-
|
|
4745
|
+
return this.send({
|
|
4742
4746
|
type: "entity_change",
|
|
4743
4747
|
device_id: deviceKey,
|
|
4744
4748
|
cmps,
|
|
4745
|
-
dev
|
|
4749
|
+
dev,
|
|
4750
|
+
...full ? { full: true } : {}
|
|
4746
4751
|
});
|
|
4747
4752
|
}
|
|
4748
4753
|
/** Send everything buffered now. Called before any structure change. */
|
|
@@ -4759,12 +4764,15 @@ var PushClient = class {
|
|
|
4759
4764
|
items
|
|
4760
4765
|
});
|
|
4761
4766
|
}
|
|
4767
|
+
/** `true` when Home Assistant took the message. */
|
|
4762
4768
|
async send(message) {
|
|
4763
4769
|
try {
|
|
4764
4770
|
await this.transport(message);
|
|
4765
4771
|
this.onReachable();
|
|
4772
|
+
return true;
|
|
4766
4773
|
} catch (err) {
|
|
4767
4774
|
this.onUnreachable(message, err);
|
|
4775
|
+
return false;
|
|
4768
4776
|
}
|
|
4769
4777
|
}
|
|
4770
4778
|
onReachable() {
|
|
@@ -4878,6 +4886,130 @@ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
|
|
|
4878
4886
|
return reason === "structure-changed" && fingerprint === lastFingerprint;
|
|
4879
4887
|
}
|
|
4880
4888
|
//#endregion
|
|
4889
|
+
//#region src/ha-export/held-reads.ts
|
|
4890
|
+
var ReadMirror = class {
|
|
4891
|
+
keyOf;
|
|
4892
|
+
entries = /* @__PURE__ */ new Map();
|
|
4893
|
+
constructor(keyOf) {
|
|
4894
|
+
this.keyOf = keyOf;
|
|
4895
|
+
}
|
|
4896
|
+
resolve(deviceId, outcome) {
|
|
4897
|
+
const entry = this.entries.get(deviceId);
|
|
4898
|
+
if (outcome.kind === "failed") {
|
|
4899
|
+
if (entry === void 0) return {
|
|
4900
|
+
status: "unknown",
|
|
4901
|
+
reason: outcome.reason
|
|
4902
|
+
};
|
|
4903
|
+
return {
|
|
4904
|
+
status: "held-failed",
|
|
4905
|
+
items: entry.confirmed,
|
|
4906
|
+
reason: outcome.reason
|
|
4907
|
+
};
|
|
4908
|
+
}
|
|
4909
|
+
if (entry === void 0) {
|
|
4910
|
+
this.entries.set(deviceId, {
|
|
4911
|
+
confirmed: outcome.items,
|
|
4912
|
+
pendingDrop: null
|
|
4913
|
+
});
|
|
4914
|
+
return {
|
|
4915
|
+
status: "fresh",
|
|
4916
|
+
items: outcome.items
|
|
4917
|
+
};
|
|
4918
|
+
}
|
|
4919
|
+
const incoming = new Set(outcome.items.map(this.keyOf));
|
|
4920
|
+
const dropped = entry.confirmed.map(this.keyOf).filter((key) => !incoming.has(key)).toSorted();
|
|
4921
|
+
if (dropped.length === 0) {
|
|
4922
|
+
entry.confirmed = outcome.items;
|
|
4923
|
+
entry.pendingDrop = null;
|
|
4924
|
+
return {
|
|
4925
|
+
status: "fresh",
|
|
4926
|
+
items: outcome.items
|
|
4927
|
+
};
|
|
4928
|
+
}
|
|
4929
|
+
const vote = dropped.join("\0");
|
|
4930
|
+
if (entry.pendingDrop === vote) {
|
|
4931
|
+
entry.confirmed = outcome.items;
|
|
4932
|
+
entry.pendingDrop = null;
|
|
4933
|
+
return {
|
|
4934
|
+
status: "fresh",
|
|
4935
|
+
items: outcome.items
|
|
4936
|
+
};
|
|
4937
|
+
}
|
|
4938
|
+
entry.pendingDrop = vote;
|
|
4939
|
+
return {
|
|
4940
|
+
status: "held-removal",
|
|
4941
|
+
items: entry.confirmed,
|
|
4942
|
+
reason: `an answered read dropped ${dropped.join(", ")}; waiting for a second answer to agree`
|
|
4943
|
+
};
|
|
4944
|
+
}
|
|
4945
|
+
};
|
|
4946
|
+
/**
|
|
4947
|
+
* The async sources `readCameraFunctions` could not obtain, or `null` when
|
|
4948
|
+
* every one answered. Their absence turns a function switch into "not
|
|
4949
|
+
* available", which removes its entity — a failure must not look like that.
|
|
4950
|
+
* `brokerAudioMuted` is only a read at all when the camera has a source node,
|
|
4951
|
+
* and `privacy` is a snapshot slice (absent = the cap is not there).
|
|
4952
|
+
*/
|
|
4953
|
+
function cameraFunctionReadsFailed(reads, sourceNodeId) {
|
|
4954
|
+
const failed = [];
|
|
4955
|
+
if (reads.bindableCapNames === null) failed.push("bindableCapNames");
|
|
4956
|
+
if (reads.wrappedCapNames === null) failed.push("wrappedCapNames");
|
|
4957
|
+
if (reads.recordingEnabled === null) failed.push("recordingEnabled");
|
|
4958
|
+
if (reads.notificationsMuted === null) failed.push("notificationsMuted");
|
|
4959
|
+
if (sourceNodeId !== null && reads.brokerAudioMuted === null) failed.push("brokerAudioMuted");
|
|
4960
|
+
return failed.length === 0 ? null : `did not answer: ${failed.join(", ")}`;
|
|
4961
|
+
}
|
|
4962
|
+
//#endregion
|
|
4963
|
+
//#region src/ha-export/resync-gate.ts
|
|
4964
|
+
/**
|
|
4965
|
+
* When the exporter must send a FULL authoritative announce rather than let an
|
|
4966
|
+
* unchanged structure short-circuit.
|
|
4967
|
+
*
|
|
4968
|
+
* A gap is any moment after which the hub can no longer vouch for what Home
|
|
4969
|
+
* Assistant holds: the first pass after a start (the receiver may have been
|
|
4970
|
+
* running while this addon was down), a broker link that was just opened, an
|
|
4971
|
+
* announce or state push that was not delivered. The next pass after a gap is
|
|
4972
|
+
* a full resync, once, whatever its reason — an event-debounced pass included,
|
|
4973
|
+
* which would otherwise skip on an identical fingerprint and leave the
|
|
4974
|
+
* receiver unrepaired until the periodic pass.
|
|
4975
|
+
*
|
|
4976
|
+
* Passes for 'boot', 'link-restored' and 'periodic' are full by their nature.
|
|
4977
|
+
*/
|
|
4978
|
+
/** Reasons whose pass is a full resync whether or not a gap was noted. */
|
|
4979
|
+
var ALWAYS_FULL = new Set([
|
|
4980
|
+
"boot",
|
|
4981
|
+
"link-restored",
|
|
4982
|
+
"periodic"
|
|
4983
|
+
]);
|
|
4984
|
+
var ResyncGate = class {
|
|
4985
|
+
gap = true;
|
|
4986
|
+
lastReason = "start";
|
|
4987
|
+
/** Note that the receiver may be out of step; the next pass will be full. */
|
|
4988
|
+
markGap(reason) {
|
|
4989
|
+
this.gap = true;
|
|
4990
|
+
this.lastReason = reason;
|
|
4991
|
+
}
|
|
4992
|
+
/** `true` while a gap is waiting for a pass. */
|
|
4993
|
+
pending() {
|
|
4994
|
+
return this.gap;
|
|
4995
|
+
}
|
|
4996
|
+
/** Why the gap was opened (for the log line). */
|
|
4997
|
+
gapReason() {
|
|
4998
|
+
return this.gap ? this.lastReason : null;
|
|
4999
|
+
}
|
|
5000
|
+
/**
|
|
5001
|
+
* Called once at the start of a pass. Returns whether the pass is a full
|
|
5002
|
+
* resync and consumes the gap noted so far; a gap noted DURING the pass is
|
|
5003
|
+
* kept for the next one.
|
|
5004
|
+
*/
|
|
5005
|
+
take(reason) {
|
|
5006
|
+
const full = this.gap || ALWAYS_FULL.has(reason);
|
|
5007
|
+
this.gap = false;
|
|
5008
|
+
this.lastReason = null;
|
|
5009
|
+
return full;
|
|
5010
|
+
}
|
|
5011
|
+
};
|
|
5012
|
+
//#endregion
|
|
4881
5013
|
//#region src/ha-export/rule-triggers.ts
|
|
4882
5014
|
function reduceOne(row) {
|
|
4883
5015
|
return {
|
|
@@ -5932,9 +6064,9 @@ function projectSynthetic(input, nowMs) {
|
|
|
5932
6064
|
topic: stateTopic(srv, `${slug}_memory`),
|
|
5933
6065
|
value: String(Math.round(node.memoryPercent))
|
|
5934
6066
|
});
|
|
5935
|
-
if (node.online && node.
|
|
6067
|
+
if (node.online && node.uptimeMs > 0) values.push({
|
|
5936
6068
|
topic: stateTopic(srv, `${slug}_uptime`),
|
|
5937
|
-
value:
|
|
6069
|
+
value: new Date(nowMs - node.uptimeMs).toISOString()
|
|
5938
6070
|
});
|
|
5939
6071
|
if (node.footageUsedBytes !== null) values.push({
|
|
5940
6072
|
topic: stateTopic(srv, `${slug}_footage_used_bytes`),
|
|
@@ -6000,6 +6132,8 @@ var PUSH_PATH = "/api/camstack/push";
|
|
|
6000
6132
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
6001
6133
|
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
6002
6134
|
var DOORBELL_PULSE_MS = 5e3;
|
|
6135
|
+
/** How soon a pass with a held-back read is retried. */
|
|
6136
|
+
var READ_RETRY_MS = 3e4;
|
|
6003
6137
|
/**
|
|
6004
6138
|
* The earliest timestamp a real change could plausibly carry. Below this,
|
|
6005
6139
|
* a slice field is a placeholder or a seeded "known value, unknown change
|
|
@@ -6046,6 +6180,24 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6046
6180
|
* still holds through the periodic pass.
|
|
6047
6181
|
*/
|
|
6048
6182
|
lastStructureFingerprint;
|
|
6183
|
+
/**
|
|
6184
|
+
* When the next pass must be a FULL authoritative announce (`full: true` on
|
|
6185
|
+
* the wire) whatever the fingerprint says: a start, a new broker link, an
|
|
6186
|
+
* undelivered push, a failed pass. See `resync-gate.ts`.
|
|
6187
|
+
*/
|
|
6188
|
+
resync = new ResyncGate();
|
|
6189
|
+
/**
|
|
6190
|
+
* The last EXPORTED set of each part of a device's component set. A read
|
|
6191
|
+
* that failed (or a provider not registered yet) holds it; a shrink needs
|
|
6192
|
+
* two agreeing answers. See `held-reads.ts`.
|
|
6193
|
+
*/
|
|
6194
|
+
boundCapsMirror = new ReadMirror((cap) => cap);
|
|
6195
|
+
zonesMirror = new ReadMirror((zone) => zone.id);
|
|
6196
|
+
streamsMirror = new ReadMirror((stream) => stream.id);
|
|
6197
|
+
presetsMirror = new ReadMirror((preset) => preset);
|
|
6198
|
+
switchesMirror = new ReadMirror((fn) => fn.id);
|
|
6199
|
+
/** A held-back read is retried soon, not at the next 300 s pass. */
|
|
6200
|
+
readTimer = null;
|
|
6049
6201
|
entityCount = 0;
|
|
6050
6202
|
/** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
|
|
6051
6203
|
lastDoorbellPressAt = /* @__PURE__ */ new Map();
|
|
@@ -6122,6 +6274,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6122
6274
|
for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
|
|
6123
6275
|
this.doorbellReleaseTimers.clear();
|
|
6124
6276
|
this.reachability.dispose();
|
|
6277
|
+
if (this.readTimer !== null) clearTimeout(this.readTimer);
|
|
6278
|
+
this.readTimer = null;
|
|
6125
6279
|
for (const link of this.links.values()) await link.client.dispose();
|
|
6126
6280
|
this.links.clear();
|
|
6127
6281
|
});
|
|
@@ -6594,6 +6748,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6594
6748
|
const startedAt = Date.now();
|
|
6595
6749
|
try {
|
|
6596
6750
|
await this.refreshBrokers();
|
|
6751
|
+
const fullResync = this.resync.take(reason);
|
|
6752
|
+
let readsHeld = false;
|
|
6597
6753
|
await this.refreshMediaBaseUrl();
|
|
6598
6754
|
await this.refreshComponentSupport();
|
|
6599
6755
|
const enabled = this.enabledBrokerIds();
|
|
@@ -6656,16 +6812,22 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6656
6812
|
* used to call the deprecated `getCameraSwitches` separately for the
|
|
6657
6813
|
* same answer.
|
|
6658
6814
|
*/
|
|
6659
|
-
const
|
|
6815
|
+
const sourceNodeId = this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null;
|
|
6816
|
+
const functionReads = device.type === "camera" ? await this.readCameraFunctions({
|
|
6660
6817
|
deviceId: numericId,
|
|
6661
6818
|
deviceType: device.type,
|
|
6662
6819
|
deviceDisabled: device.disabled,
|
|
6663
6820
|
privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
|
|
6664
|
-
sourceNodeId
|
|
6821
|
+
sourceNodeId,
|
|
6665
6822
|
bindableCapsByType,
|
|
6666
6823
|
mutedDeviceIds
|
|
6667
|
-
})
|
|
6668
|
-
const
|
|
6824
|
+
}) : null;
|
|
6825
|
+
const cameraFunctions = functionReads !== null ? deriveCameraFunctions(functionReads) : null;
|
|
6826
|
+
const functionsFailure = functionReads !== null ? cameraFunctionReadsFailed(functionReads, sourceNodeId) : null;
|
|
6827
|
+
const built = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions, functionsFailure);
|
|
6828
|
+
if (built.heldBack) readsHeld = true;
|
|
6829
|
+
const catalogDevice = built.device;
|
|
6830
|
+
if (catalogDevice === null) continue;
|
|
6669
6831
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
6670
6832
|
const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
|
|
6671
6833
|
/**
|
|
@@ -6736,7 +6898,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6736
6898
|
this.entityCount = entityCount;
|
|
6737
6899
|
this.unclassified = unclassifiedCaps([...allCaps]);
|
|
6738
6900
|
const fingerprint = structureFingerprint(exported);
|
|
6739
|
-
if (
|
|
6901
|
+
if (readsHeld) this.scheduleReadRetry();
|
|
6902
|
+
if (!fullResync && shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
|
|
6740
6903
|
this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
|
|
6741
6904
|
devices: exported.size,
|
|
6742
6905
|
entities: entityCount
|
|
@@ -6754,11 +6917,17 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6754
6917
|
entities: line.entities
|
|
6755
6918
|
}
|
|
6756
6919
|
});
|
|
6757
|
-
await this.announce(exported);
|
|
6920
|
+
const delivered = await this.announce(exported, fullResync);
|
|
6758
6921
|
await this.pushFullState(exported, snapshots);
|
|
6759
6922
|
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
6923
|
+
if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
|
|
6924
|
+
this.resync.markGap("push-not-delivered");
|
|
6925
|
+
this.lastStructureFingerprint = void 0;
|
|
6926
|
+
this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
|
|
6927
|
+
}
|
|
6760
6928
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6761
6929
|
reason,
|
|
6930
|
+
fullResync,
|
|
6762
6931
|
brokers: enabled.length,
|
|
6763
6932
|
devices: exported.size,
|
|
6764
6933
|
entities: entityCount,
|
|
@@ -6769,6 +6938,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6769
6938
|
this.lastError = void 0;
|
|
6770
6939
|
} catch (err) {
|
|
6771
6940
|
this.lastError = errMsg(err);
|
|
6941
|
+
this.resync.markGap("reconcile-failed");
|
|
6772
6942
|
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
6773
6943
|
}
|
|
6774
6944
|
}
|
|
@@ -6967,7 +7137,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6967
7137
|
online: node.isOnline,
|
|
6968
7138
|
cpuPercent: node.cpuPercent,
|
|
6969
7139
|
memoryPercent: node.memoryPercent,
|
|
6970
|
-
|
|
7140
|
+
uptimeMs: node.uptime,
|
|
6971
7141
|
isHub: node.isHub,
|
|
6972
7142
|
footageUsedBytes: footage.footageUsedBytes,
|
|
6973
7143
|
footageFreePercent: footage.footageFreePercent,
|
|
@@ -6986,13 +7156,16 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6986
7156
|
for (const value of values) link.client.publishState(value.topic, value.value);
|
|
6987
7157
|
}
|
|
6988
7158
|
}
|
|
6989
|
-
|
|
7159
|
+
/** `false` when any announce was not delivered. */
|
|
7160
|
+
async announce(exported, full) {
|
|
7161
|
+
let allDelivered = true;
|
|
6990
7162
|
for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
|
|
6991
7163
|
const link = this.links.get(brokerId);
|
|
6992
7164
|
if (link === void 0) continue;
|
|
6993
7165
|
try {
|
|
6994
|
-
await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
|
|
7166
|
+
if (!await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev, full)) allDelivered = false;
|
|
6995
7167
|
} catch (err) {
|
|
7168
|
+
allDelivered = false;
|
|
6996
7169
|
this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
|
|
6997
7170
|
tags: { deviceId: entry.deviceId },
|
|
6998
7171
|
meta: {
|
|
@@ -7002,6 +7175,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7002
7175
|
});
|
|
7003
7176
|
}
|
|
7004
7177
|
}
|
|
7178
|
+
return allDelivered;
|
|
7005
7179
|
}
|
|
7006
7180
|
async pushFullState(exported, snapshots) {
|
|
7007
7181
|
for (const entry of exported.values()) try {
|
|
@@ -7119,46 +7293,105 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7119
7293
|
}
|
|
7120
7294
|
return values;
|
|
7121
7295
|
}
|
|
7122
|
-
|
|
7296
|
+
/**
|
|
7297
|
+
* `device: null` = a part of this device's component set cannot be read and
|
|
7298
|
+
* nothing was ever exported for it, so the device is not announced this pass
|
|
7299
|
+
* (an empty part would make Home Assistant delete what it holds). `heldBack`
|
|
7300
|
+
* = at least one part is not a fresh answer, so a retry is worth arming.
|
|
7301
|
+
*/
|
|
7302
|
+
async buildCatalogDevice(device, snapshotRaw, cameraFunctions, functionsFailure) {
|
|
7123
7303
|
const snapshot = toSnapshot(snapshotRaw);
|
|
7124
|
-
|
|
7125
|
-
|
|
7304
|
+
let heldBack = false;
|
|
7305
|
+
let unknown = false;
|
|
7306
|
+
const settle = (part, mirror, outcome) => {
|
|
7307
|
+
const resolution = mirror.resolve(device.id, outcome);
|
|
7308
|
+
if (resolution.status !== "fresh") heldBack = true;
|
|
7309
|
+
if (resolution.status === "unknown") unknown = true;
|
|
7310
|
+
this.logHeldRead(device.id, part, resolution);
|
|
7311
|
+
return resolution.status === "unknown" ? [] : resolution.items;
|
|
7312
|
+
};
|
|
7313
|
+
const boundCaps = settle("boundCaps", this.boundCapsMirror, await this.loadBoundCaps(device.id));
|
|
7314
|
+
const zones = device.type === "camera" ? settle("zones", this.zonesMirror, await this.loadZones(device.id)) : [];
|
|
7126
7315
|
/**
|
|
7127
7316
|
* Every camera function, available or not — `entity-catalog.ts` is the
|
|
7128
7317
|
* one place that filters to `available`, exactly as it did for
|
|
7129
|
-
* `getCameraSwitches`'s answer.
|
|
7318
|
+
* `getCameraSwitches`'s answer. A function that did not answer is
|
|
7319
|
+
* "unavailable", which removes its entity, so the set goes through the
|
|
7320
|
+
* mirror: held when a source failed, shrunk only on two agreeing passes.
|
|
7130
7321
|
*/
|
|
7131
|
-
const
|
|
7322
|
+
const mappedSwitches = (cameraFunctions ?? []).map((fn) => ({
|
|
7132
7323
|
id: fn.id,
|
|
7133
7324
|
label: fn.label,
|
|
7134
7325
|
available: fn.available
|
|
7135
7326
|
}));
|
|
7136
|
-
const
|
|
7137
|
-
|
|
7327
|
+
const switches = cameraFunctions === null ? mappedSwitches : settle("cameraFunctions", this.switchesMirror, functionsFailure !== null ? {
|
|
7328
|
+
kind: "failed",
|
|
7329
|
+
reason: functionsFailure
|
|
7330
|
+
} : {
|
|
7331
|
+
kind: "answered",
|
|
7332
|
+
items: mappedSwitches.filter((fn) => fn.available)
|
|
7333
|
+
});
|
|
7334
|
+
const ptzPresets = device.type === "camera" ? settle("ptzPresets", this.presetsMirror, boundCaps.includes("ptz") ? await this.loadPresets(device.id) : {
|
|
7335
|
+
kind: "answered",
|
|
7336
|
+
items: []
|
|
7337
|
+
}) : [];
|
|
7338
|
+
const streams = device.type === "camera" ? settle("streams", this.streamsMirror, boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : {
|
|
7339
|
+
kind: "answered",
|
|
7340
|
+
items: []
|
|
7341
|
+
}) : [];
|
|
7342
|
+
if (unknown) return {
|
|
7343
|
+
device: null,
|
|
7344
|
+
heldBack
|
|
7345
|
+
};
|
|
7138
7346
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
7139
7347
|
const model = readString(device.metadata ?? {}, "model");
|
|
7140
7348
|
return {
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7349
|
+
device: {
|
|
7350
|
+
id: device.id,
|
|
7351
|
+
name: device.name,
|
|
7352
|
+
type: device.type,
|
|
7353
|
+
...manufacturer !== null ? { manufacturer } : {},
|
|
7354
|
+
...model !== null ? { model } : {},
|
|
7355
|
+
features: device.features,
|
|
7356
|
+
boundCaps,
|
|
7357
|
+
zones,
|
|
7358
|
+
switches,
|
|
7359
|
+
ptzPresets,
|
|
7360
|
+
streams,
|
|
7361
|
+
slices: Object.keys(snapshot),
|
|
7362
|
+
/**
|
|
7363
|
+
* The slice VALUES, for the native platforms only: a native
|
|
7364
|
+
* `climate` announces the modes the device accepts, and
|
|
7365
|
+
* `availableModes` is on the slice and nowhere else. See
|
|
7366
|
+
* `CatalogDevice.capSlices`.
|
|
7367
|
+
*/
|
|
7368
|
+
capSlices: snapshot
|
|
7369
|
+
},
|
|
7370
|
+
heldBack
|
|
7160
7371
|
};
|
|
7161
7372
|
}
|
|
7373
|
+
/** One WARN per held part, with the device tag and the reason. */
|
|
7374
|
+
logHeldRead(deviceId, part, resolution) {
|
|
7375
|
+
if (resolution.status === "fresh") return;
|
|
7376
|
+
const kept = resolution.status === "unknown" ? 0 : resolution.items.length;
|
|
7377
|
+
const message = resolution.status === "unknown" ? "ha-export: a read did not answer and nothing was exported yet, the device is not announced this pass" : resolution.status === "held-failed" ? "ha-export: a read did not answer, keeping the last exported components" : "ha-export: a read dropped components, keeping them until a second answer agrees";
|
|
7378
|
+
this.ctx.logger.warn(message, {
|
|
7379
|
+
tags: { deviceId },
|
|
7380
|
+
meta: {
|
|
7381
|
+
part,
|
|
7382
|
+
reason: resolution.reason,
|
|
7383
|
+
kept
|
|
7384
|
+
}
|
|
7385
|
+
});
|
|
7386
|
+
}
|
|
7387
|
+
/** Retry soon after a held-back read: the provider usually registers seconds later. */
|
|
7388
|
+
scheduleReadRetry() {
|
|
7389
|
+
if (this.readTimer !== null) return;
|
|
7390
|
+
this.readTimer = setTimeout(() => {
|
|
7391
|
+
this.readTimer = null;
|
|
7392
|
+
this.reconcile("read-retry");
|
|
7393
|
+
}, READ_RETRY_MS);
|
|
7394
|
+
}
|
|
7162
7395
|
/**
|
|
7163
7396
|
* Every stream of the camera, as the hub's own picker lists them.
|
|
7164
7397
|
*
|
|
@@ -7176,45 +7409,54 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7176
7409
|
*/
|
|
7177
7410
|
async loadStreamChoices(deviceId) {
|
|
7178
7411
|
try {
|
|
7179
|
-
return
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7412
|
+
return {
|
|
7413
|
+
kind: "answered",
|
|
7414
|
+
items: (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
|
|
7415
|
+
id: choice.id,
|
|
7416
|
+
label: choice.label,
|
|
7417
|
+
target: choice.target,
|
|
7418
|
+
...choice.resolution === null ? {} : { height: choice.resolution.height }
|
|
7419
|
+
}))
|
|
7420
|
+
};
|
|
7185
7421
|
} catch (err) {
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
}
|
|
7190
|
-
return [];
|
|
7422
|
+
return {
|
|
7423
|
+
kind: "failed",
|
|
7424
|
+
reason: errMsg(err)
|
|
7425
|
+
};
|
|
7191
7426
|
}
|
|
7192
7427
|
}
|
|
7193
7428
|
async loadBoundCaps(deviceId) {
|
|
7194
7429
|
try {
|
|
7195
|
-
return
|
|
7430
|
+
return {
|
|
7431
|
+
kind: "answered",
|
|
7432
|
+
items: (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName)
|
|
7433
|
+
};
|
|
7196
7434
|
} catch (err) {
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
}
|
|
7201
|
-
return [];
|
|
7435
|
+
return {
|
|
7436
|
+
kind: "failed",
|
|
7437
|
+
reason: errMsg(err)
|
|
7438
|
+
};
|
|
7202
7439
|
}
|
|
7203
7440
|
}
|
|
7204
7441
|
async loadZones(deviceId) {
|
|
7205
7442
|
try {
|
|
7206
7443
|
const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
|
|
7207
|
-
if (snapshot === null) return
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7444
|
+
if (snapshot === null) return {
|
|
7445
|
+
kind: "answered",
|
|
7446
|
+
items: []
|
|
7447
|
+
};
|
|
7448
|
+
return {
|
|
7449
|
+
kind: "answered",
|
|
7450
|
+
items: snapshot.zones.map((zone) => ({
|
|
7451
|
+
id: zone.zoneId,
|
|
7452
|
+
name: zone.zoneName
|
|
7453
|
+
}))
|
|
7454
|
+
};
|
|
7212
7455
|
} catch (err) {
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
}
|
|
7217
|
-
return [];
|
|
7456
|
+
return {
|
|
7457
|
+
kind: "failed",
|
|
7458
|
+
reason: errMsg(err)
|
|
7459
|
+
};
|
|
7218
7460
|
}
|
|
7219
7461
|
}
|
|
7220
7462
|
/**
|
|
@@ -7333,14 +7575,19 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7333
7575
|
async loadPresets(deviceId) {
|
|
7334
7576
|
try {
|
|
7335
7577
|
const device = await this.ctx.fetchDevice(deviceId);
|
|
7336
|
-
if (device.ptz === void 0) return
|
|
7337
|
-
|
|
7578
|
+
if (device.ptz === void 0) return {
|
|
7579
|
+
kind: "answered",
|
|
7580
|
+
items: []
|
|
7581
|
+
};
|
|
7582
|
+
return {
|
|
7583
|
+
kind: "answered",
|
|
7584
|
+
items: (await device.ptz.getPresets({})).map((preset) => preset.name)
|
|
7585
|
+
};
|
|
7338
7586
|
} catch (err) {
|
|
7339
|
-
|
|
7340
|
-
|
|
7341
|
-
|
|
7342
|
-
}
|
|
7343
|
-
return [];
|
|
7587
|
+
return {
|
|
7588
|
+
kind: "failed",
|
|
7589
|
+
reason: errMsg(err)
|
|
7590
|
+
};
|
|
7344
7591
|
}
|
|
7345
7592
|
}
|
|
7346
7593
|
/**
|
|
@@ -7419,6 +7666,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7419
7666
|
onLinkRestored: (brokerId) => this.reachability.request(brokerId)
|
|
7420
7667
|
});
|
|
7421
7668
|
client.start();
|
|
7669
|
+
this.resync.markGap("broker-connected");
|
|
7422
7670
|
this.links.set(broker.id, {
|
|
7423
7671
|
brokerId: broker.id,
|
|
7424
7672
|
name: broker.name,
|