@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
|
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
enumerable: true
|
|
25
25
|
}) : target, mod));
|
|
26
26
|
//#endregion
|
|
27
|
-
const require_dist = require("../dist-
|
|
27
|
+
const require_dist = require("../dist-DVNChk7-.js");
|
|
28
28
|
let node_crypto = require("node:crypto");
|
|
29
29
|
require("node:zlib");
|
|
30
30
|
let node_fs = require("node:fs");
|
|
@@ -3223,6 +3223,10 @@ var CAPS_NOT_EXPORTED = [
|
|
|
3223
3223
|
{
|
|
3224
3224
|
cap: "zone-rules",
|
|
3225
3225
|
reason: "Per-stage zone rules — configuration the pipeline consumes, with no value an automation reads."
|
|
3226
|
+
},
|
|
3227
|
+
{
|
|
3228
|
+
cap: "heating-valve",
|
|
3229
|
+
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."
|
|
3226
3230
|
}
|
|
3227
3231
|
];
|
|
3228
3232
|
var UNNEGOTIATED_PLAN_OPTIONS = { platforms: UNNEGOTIATED_SUPPORT };
|
|
@@ -4763,14 +4767,15 @@ var PushClient = class {
|
|
|
4763
4767
|
* is flushed first — a value for an entity HA has not built yet is a
|
|
4764
4768
|
* value it discards.
|
|
4765
4769
|
*/
|
|
4766
|
-
async publishEntityChange(deviceKey, cmps, dev) {
|
|
4767
|
-
if (this.disposed) return;
|
|
4770
|
+
async publishEntityChange(deviceKey, cmps, dev, full = false) {
|
|
4771
|
+
if (this.disposed) return false;
|
|
4768
4772
|
await this.flush();
|
|
4769
|
-
|
|
4773
|
+
return this.send({
|
|
4770
4774
|
type: "entity_change",
|
|
4771
4775
|
device_id: deviceKey,
|
|
4772
4776
|
cmps,
|
|
4773
|
-
dev
|
|
4777
|
+
dev,
|
|
4778
|
+
...full ? { full: true } : {}
|
|
4774
4779
|
});
|
|
4775
4780
|
}
|
|
4776
4781
|
/** Send everything buffered now. Called before any structure change. */
|
|
@@ -4787,12 +4792,15 @@ var PushClient = class {
|
|
|
4787
4792
|
items
|
|
4788
4793
|
});
|
|
4789
4794
|
}
|
|
4795
|
+
/** `true` when Home Assistant took the message. */
|
|
4790
4796
|
async send(message) {
|
|
4791
4797
|
try {
|
|
4792
4798
|
await this.transport(message);
|
|
4793
4799
|
this.onReachable();
|
|
4800
|
+
return true;
|
|
4794
4801
|
} catch (err) {
|
|
4795
4802
|
this.onUnreachable(message, err);
|
|
4803
|
+
return false;
|
|
4796
4804
|
}
|
|
4797
4805
|
}
|
|
4798
4806
|
onReachable() {
|
|
@@ -4906,6 +4914,130 @@ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
|
|
|
4906
4914
|
return reason === "structure-changed" && fingerprint === lastFingerprint;
|
|
4907
4915
|
}
|
|
4908
4916
|
//#endregion
|
|
4917
|
+
//#region src/ha-export/held-reads.ts
|
|
4918
|
+
var ReadMirror = class {
|
|
4919
|
+
keyOf;
|
|
4920
|
+
entries = /* @__PURE__ */ new Map();
|
|
4921
|
+
constructor(keyOf) {
|
|
4922
|
+
this.keyOf = keyOf;
|
|
4923
|
+
}
|
|
4924
|
+
resolve(deviceId, outcome) {
|
|
4925
|
+
const entry = this.entries.get(deviceId);
|
|
4926
|
+
if (outcome.kind === "failed") {
|
|
4927
|
+
if (entry === void 0) return {
|
|
4928
|
+
status: "unknown",
|
|
4929
|
+
reason: outcome.reason
|
|
4930
|
+
};
|
|
4931
|
+
return {
|
|
4932
|
+
status: "held-failed",
|
|
4933
|
+
items: entry.confirmed,
|
|
4934
|
+
reason: outcome.reason
|
|
4935
|
+
};
|
|
4936
|
+
}
|
|
4937
|
+
if (entry === void 0) {
|
|
4938
|
+
this.entries.set(deviceId, {
|
|
4939
|
+
confirmed: outcome.items,
|
|
4940
|
+
pendingDrop: null
|
|
4941
|
+
});
|
|
4942
|
+
return {
|
|
4943
|
+
status: "fresh",
|
|
4944
|
+
items: outcome.items
|
|
4945
|
+
};
|
|
4946
|
+
}
|
|
4947
|
+
const incoming = new Set(outcome.items.map(this.keyOf));
|
|
4948
|
+
const dropped = entry.confirmed.map(this.keyOf).filter((key) => !incoming.has(key)).toSorted();
|
|
4949
|
+
if (dropped.length === 0) {
|
|
4950
|
+
entry.confirmed = outcome.items;
|
|
4951
|
+
entry.pendingDrop = null;
|
|
4952
|
+
return {
|
|
4953
|
+
status: "fresh",
|
|
4954
|
+
items: outcome.items
|
|
4955
|
+
};
|
|
4956
|
+
}
|
|
4957
|
+
const vote = dropped.join("\0");
|
|
4958
|
+
if (entry.pendingDrop === vote) {
|
|
4959
|
+
entry.confirmed = outcome.items;
|
|
4960
|
+
entry.pendingDrop = null;
|
|
4961
|
+
return {
|
|
4962
|
+
status: "fresh",
|
|
4963
|
+
items: outcome.items
|
|
4964
|
+
};
|
|
4965
|
+
}
|
|
4966
|
+
entry.pendingDrop = vote;
|
|
4967
|
+
return {
|
|
4968
|
+
status: "held-removal",
|
|
4969
|
+
items: entry.confirmed,
|
|
4970
|
+
reason: `an answered read dropped ${dropped.join(", ")}; waiting for a second answer to agree`
|
|
4971
|
+
};
|
|
4972
|
+
}
|
|
4973
|
+
};
|
|
4974
|
+
/**
|
|
4975
|
+
* The async sources `readCameraFunctions` could not obtain, or `null` when
|
|
4976
|
+
* every one answered. Their absence turns a function switch into "not
|
|
4977
|
+
* available", which removes its entity — a failure must not look like that.
|
|
4978
|
+
* `brokerAudioMuted` is only a read at all when the camera has a source node,
|
|
4979
|
+
* and `privacy` is a snapshot slice (absent = the cap is not there).
|
|
4980
|
+
*/
|
|
4981
|
+
function cameraFunctionReadsFailed(reads, sourceNodeId) {
|
|
4982
|
+
const failed = [];
|
|
4983
|
+
if (reads.bindableCapNames === null) failed.push("bindableCapNames");
|
|
4984
|
+
if (reads.wrappedCapNames === null) failed.push("wrappedCapNames");
|
|
4985
|
+
if (reads.recordingEnabled === null) failed.push("recordingEnabled");
|
|
4986
|
+
if (reads.notificationsMuted === null) failed.push("notificationsMuted");
|
|
4987
|
+
if (sourceNodeId !== null && reads.brokerAudioMuted === null) failed.push("brokerAudioMuted");
|
|
4988
|
+
return failed.length === 0 ? null : `did not answer: ${failed.join(", ")}`;
|
|
4989
|
+
}
|
|
4990
|
+
//#endregion
|
|
4991
|
+
//#region src/ha-export/resync-gate.ts
|
|
4992
|
+
/**
|
|
4993
|
+
* When the exporter must send a FULL authoritative announce rather than let an
|
|
4994
|
+
* unchanged structure short-circuit.
|
|
4995
|
+
*
|
|
4996
|
+
* A gap is any moment after which the hub can no longer vouch for what Home
|
|
4997
|
+
* Assistant holds: the first pass after a start (the receiver may have been
|
|
4998
|
+
* running while this addon was down), a broker link that was just opened, an
|
|
4999
|
+
* announce or state push that was not delivered. The next pass after a gap is
|
|
5000
|
+
* a full resync, once, whatever its reason — an event-debounced pass included,
|
|
5001
|
+
* which would otherwise skip on an identical fingerprint and leave the
|
|
5002
|
+
* receiver unrepaired until the periodic pass.
|
|
5003
|
+
*
|
|
5004
|
+
* Passes for 'boot', 'link-restored' and 'periodic' are full by their nature.
|
|
5005
|
+
*/
|
|
5006
|
+
/** Reasons whose pass is a full resync whether or not a gap was noted. */
|
|
5007
|
+
var ALWAYS_FULL = new Set([
|
|
5008
|
+
"boot",
|
|
5009
|
+
"link-restored",
|
|
5010
|
+
"periodic"
|
|
5011
|
+
]);
|
|
5012
|
+
var ResyncGate = class {
|
|
5013
|
+
gap = true;
|
|
5014
|
+
lastReason = "start";
|
|
5015
|
+
/** Note that the receiver may be out of step; the next pass will be full. */
|
|
5016
|
+
markGap(reason) {
|
|
5017
|
+
this.gap = true;
|
|
5018
|
+
this.lastReason = reason;
|
|
5019
|
+
}
|
|
5020
|
+
/** `true` while a gap is waiting for a pass. */
|
|
5021
|
+
pending() {
|
|
5022
|
+
return this.gap;
|
|
5023
|
+
}
|
|
5024
|
+
/** Why the gap was opened (for the log line). */
|
|
5025
|
+
gapReason() {
|
|
5026
|
+
return this.gap ? this.lastReason : null;
|
|
5027
|
+
}
|
|
5028
|
+
/**
|
|
5029
|
+
* Called once at the start of a pass. Returns whether the pass is a full
|
|
5030
|
+
* resync and consumes the gap noted so far; a gap noted DURING the pass is
|
|
5031
|
+
* kept for the next one.
|
|
5032
|
+
*/
|
|
5033
|
+
take(reason) {
|
|
5034
|
+
const full = this.gap || ALWAYS_FULL.has(reason);
|
|
5035
|
+
this.gap = false;
|
|
5036
|
+
this.lastReason = null;
|
|
5037
|
+
return full;
|
|
5038
|
+
}
|
|
5039
|
+
};
|
|
5040
|
+
//#endregion
|
|
4909
5041
|
//#region src/ha-export/rule-triggers.ts
|
|
4910
5042
|
function reduceOne(row) {
|
|
4911
5043
|
return {
|
|
@@ -5960,9 +6092,9 @@ function projectSynthetic(input, nowMs) {
|
|
|
5960
6092
|
topic: stateTopic(srv, `${slug}_memory`),
|
|
5961
6093
|
value: String(Math.round(node.memoryPercent))
|
|
5962
6094
|
});
|
|
5963
|
-
if (node.online && node.
|
|
6095
|
+
if (node.online && node.uptimeMs > 0) values.push({
|
|
5964
6096
|
topic: stateTopic(srv, `${slug}_uptime`),
|
|
5965
|
-
value:
|
|
6097
|
+
value: new Date(nowMs - node.uptimeMs).toISOString()
|
|
5966
6098
|
});
|
|
5967
6099
|
if (node.footageUsedBytes !== null) values.push({
|
|
5968
6100
|
topic: stateTopic(srv, `${slug}_footage_used_bytes`),
|
|
@@ -6028,6 +6160,8 @@ var PUSH_PATH = "/api/camstack/push";
|
|
|
6028
6160
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
6029
6161
|
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
6030
6162
|
var DOORBELL_PULSE_MS = 5e3;
|
|
6163
|
+
/** How soon a pass with a held-back read is retried. */
|
|
6164
|
+
var READ_RETRY_MS = 3e4;
|
|
6031
6165
|
/**
|
|
6032
6166
|
* The earliest timestamp a real change could plausibly carry. Below this,
|
|
6033
6167
|
* a slice field is a placeholder or a seeded "known value, unknown change
|
|
@@ -6074,6 +6208,24 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6074
6208
|
* still holds through the periodic pass.
|
|
6075
6209
|
*/
|
|
6076
6210
|
lastStructureFingerprint;
|
|
6211
|
+
/**
|
|
6212
|
+
* When the next pass must be a FULL authoritative announce (`full: true` on
|
|
6213
|
+
* the wire) whatever the fingerprint says: a start, a new broker link, an
|
|
6214
|
+
* undelivered push, a failed pass. See `resync-gate.ts`.
|
|
6215
|
+
*/
|
|
6216
|
+
resync = new ResyncGate();
|
|
6217
|
+
/**
|
|
6218
|
+
* The last EXPORTED set of each part of a device's component set. A read
|
|
6219
|
+
* that failed (or a provider not registered yet) holds it; a shrink needs
|
|
6220
|
+
* two agreeing answers. See `held-reads.ts`.
|
|
6221
|
+
*/
|
|
6222
|
+
boundCapsMirror = new ReadMirror((cap) => cap);
|
|
6223
|
+
zonesMirror = new ReadMirror((zone) => zone.id);
|
|
6224
|
+
streamsMirror = new ReadMirror((stream) => stream.id);
|
|
6225
|
+
presetsMirror = new ReadMirror((preset) => preset);
|
|
6226
|
+
switchesMirror = new ReadMirror((fn) => fn.id);
|
|
6227
|
+
/** A held-back read is retried soon, not at the next 300 s pass. */
|
|
6228
|
+
readTimer = null;
|
|
6077
6229
|
entityCount = 0;
|
|
6078
6230
|
/** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
|
|
6079
6231
|
lastDoorbellPressAt = /* @__PURE__ */ new Map();
|
|
@@ -6150,6 +6302,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6150
6302
|
for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
|
|
6151
6303
|
this.doorbellReleaseTimers.clear();
|
|
6152
6304
|
this.reachability.dispose();
|
|
6305
|
+
if (this.readTimer !== null) clearTimeout(this.readTimer);
|
|
6306
|
+
this.readTimer = null;
|
|
6153
6307
|
for (const link of this.links.values()) await link.client.dispose();
|
|
6154
6308
|
this.links.clear();
|
|
6155
6309
|
});
|
|
@@ -6622,6 +6776,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6622
6776
|
const startedAt = Date.now();
|
|
6623
6777
|
try {
|
|
6624
6778
|
await this.refreshBrokers();
|
|
6779
|
+
const fullResync = this.resync.take(reason);
|
|
6780
|
+
let readsHeld = false;
|
|
6625
6781
|
await this.refreshMediaBaseUrl();
|
|
6626
6782
|
await this.refreshComponentSupport();
|
|
6627
6783
|
const enabled = this.enabledBrokerIds();
|
|
@@ -6684,16 +6840,22 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6684
6840
|
* used to call the deprecated `getCameraSwitches` separately for the
|
|
6685
6841
|
* same answer.
|
|
6686
6842
|
*/
|
|
6687
|
-
const
|
|
6843
|
+
const sourceNodeId = this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null;
|
|
6844
|
+
const functionReads = device.type === "camera" ? await this.readCameraFunctions({
|
|
6688
6845
|
deviceId: numericId,
|
|
6689
6846
|
deviceType: device.type,
|
|
6690
6847
|
deviceDisabled: device.disabled,
|
|
6691
6848
|
privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
|
|
6692
|
-
sourceNodeId
|
|
6849
|
+
sourceNodeId,
|
|
6693
6850
|
bindableCapsByType,
|
|
6694
6851
|
mutedDeviceIds
|
|
6695
|
-
})
|
|
6696
|
-
const
|
|
6852
|
+
}) : null;
|
|
6853
|
+
const cameraFunctions = functionReads !== null ? deriveCameraFunctions(functionReads) : null;
|
|
6854
|
+
const functionsFailure = functionReads !== null ? cameraFunctionReadsFailed(functionReads, sourceNodeId) : null;
|
|
6855
|
+
const built = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions, functionsFailure);
|
|
6856
|
+
if (built.heldBack) readsHeld = true;
|
|
6857
|
+
const catalogDevice = built.device;
|
|
6858
|
+
if (catalogDevice === null) continue;
|
|
6697
6859
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
6698
6860
|
const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
|
|
6699
6861
|
/**
|
|
@@ -6764,7 +6926,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6764
6926
|
this.entityCount = entityCount;
|
|
6765
6927
|
this.unclassified = unclassifiedCaps([...allCaps]);
|
|
6766
6928
|
const fingerprint = structureFingerprint(exported);
|
|
6767
|
-
if (
|
|
6929
|
+
if (readsHeld) this.scheduleReadRetry();
|
|
6930
|
+
if (!fullResync && shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
|
|
6768
6931
|
this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
|
|
6769
6932
|
devices: exported.size,
|
|
6770
6933
|
entities: entityCount
|
|
@@ -6782,11 +6945,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6782
6945
|
entities: line.entities
|
|
6783
6946
|
}
|
|
6784
6947
|
});
|
|
6785
|
-
await this.announce(exported);
|
|
6948
|
+
const delivered = await this.announce(exported, fullResync);
|
|
6786
6949
|
await this.pushFullState(exported, snapshots);
|
|
6787
6950
|
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
6951
|
+
if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
|
|
6952
|
+
this.resync.markGap("push-not-delivered");
|
|
6953
|
+
this.lastStructureFingerprint = void 0;
|
|
6954
|
+
this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
|
|
6955
|
+
}
|
|
6788
6956
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6789
6957
|
reason,
|
|
6958
|
+
fullResync,
|
|
6790
6959
|
brokers: enabled.length,
|
|
6791
6960
|
devices: exported.size,
|
|
6792
6961
|
entities: entityCount,
|
|
@@ -6797,6 +6966,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6797
6966
|
this.lastError = void 0;
|
|
6798
6967
|
} catch (err) {
|
|
6799
6968
|
this.lastError = errMsg(err);
|
|
6969
|
+
this.resync.markGap("reconcile-failed");
|
|
6800
6970
|
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
6801
6971
|
}
|
|
6802
6972
|
}
|
|
@@ -6995,7 +7165,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6995
7165
|
online: node.isOnline,
|
|
6996
7166
|
cpuPercent: node.cpuPercent,
|
|
6997
7167
|
memoryPercent: node.memoryPercent,
|
|
6998
|
-
|
|
7168
|
+
uptimeMs: node.uptime,
|
|
6999
7169
|
isHub: node.isHub,
|
|
7000
7170
|
footageUsedBytes: footage.footageUsedBytes,
|
|
7001
7171
|
footageFreePercent: footage.footageFreePercent,
|
|
@@ -7014,13 +7184,16 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7014
7184
|
for (const value of values) link.client.publishState(value.topic, value.value);
|
|
7015
7185
|
}
|
|
7016
7186
|
}
|
|
7017
|
-
|
|
7187
|
+
/** `false` when any announce was not delivered. */
|
|
7188
|
+
async announce(exported, full) {
|
|
7189
|
+
let allDelivered = true;
|
|
7018
7190
|
for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
|
|
7019
7191
|
const link = this.links.get(brokerId);
|
|
7020
7192
|
if (link === void 0) continue;
|
|
7021
7193
|
try {
|
|
7022
|
-
await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
|
|
7194
|
+
if (!await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev, full)) allDelivered = false;
|
|
7023
7195
|
} catch (err) {
|
|
7196
|
+
allDelivered = false;
|
|
7024
7197
|
this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
|
|
7025
7198
|
tags: { deviceId: entry.deviceId },
|
|
7026
7199
|
meta: {
|
|
@@ -7030,6 +7203,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7030
7203
|
});
|
|
7031
7204
|
}
|
|
7032
7205
|
}
|
|
7206
|
+
return allDelivered;
|
|
7033
7207
|
}
|
|
7034
7208
|
async pushFullState(exported, snapshots) {
|
|
7035
7209
|
for (const entry of exported.values()) try {
|
|
@@ -7147,46 +7321,105 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7147
7321
|
}
|
|
7148
7322
|
return values;
|
|
7149
7323
|
}
|
|
7150
|
-
|
|
7324
|
+
/**
|
|
7325
|
+
* `device: null` = a part of this device's component set cannot be read and
|
|
7326
|
+
* nothing was ever exported for it, so the device is not announced this pass
|
|
7327
|
+
* (an empty part would make Home Assistant delete what it holds). `heldBack`
|
|
7328
|
+
* = at least one part is not a fresh answer, so a retry is worth arming.
|
|
7329
|
+
*/
|
|
7330
|
+
async buildCatalogDevice(device, snapshotRaw, cameraFunctions, functionsFailure) {
|
|
7151
7331
|
const snapshot = toSnapshot(snapshotRaw);
|
|
7152
|
-
|
|
7153
|
-
|
|
7332
|
+
let heldBack = false;
|
|
7333
|
+
let unknown = false;
|
|
7334
|
+
const settle = (part, mirror, outcome) => {
|
|
7335
|
+
const resolution = mirror.resolve(device.id, outcome);
|
|
7336
|
+
if (resolution.status !== "fresh") heldBack = true;
|
|
7337
|
+
if (resolution.status === "unknown") unknown = true;
|
|
7338
|
+
this.logHeldRead(device.id, part, resolution);
|
|
7339
|
+
return resolution.status === "unknown" ? [] : resolution.items;
|
|
7340
|
+
};
|
|
7341
|
+
const boundCaps = settle("boundCaps", this.boundCapsMirror, await this.loadBoundCaps(device.id));
|
|
7342
|
+
const zones = device.type === "camera" ? settle("zones", this.zonesMirror, await this.loadZones(device.id)) : [];
|
|
7154
7343
|
/**
|
|
7155
7344
|
* Every camera function, available or not — `entity-catalog.ts` is the
|
|
7156
7345
|
* one place that filters to `available`, exactly as it did for
|
|
7157
|
-
* `getCameraSwitches`'s answer.
|
|
7346
|
+
* `getCameraSwitches`'s answer. A function that did not answer is
|
|
7347
|
+
* "unavailable", which removes its entity, so the set goes through the
|
|
7348
|
+
* mirror: held when a source failed, shrunk only on two agreeing passes.
|
|
7158
7349
|
*/
|
|
7159
|
-
const
|
|
7350
|
+
const mappedSwitches = (cameraFunctions ?? []).map((fn) => ({
|
|
7160
7351
|
id: fn.id,
|
|
7161
7352
|
label: fn.label,
|
|
7162
7353
|
available: fn.available
|
|
7163
7354
|
}));
|
|
7164
|
-
const
|
|
7165
|
-
|
|
7355
|
+
const switches = cameraFunctions === null ? mappedSwitches : settle("cameraFunctions", this.switchesMirror, functionsFailure !== null ? {
|
|
7356
|
+
kind: "failed",
|
|
7357
|
+
reason: functionsFailure
|
|
7358
|
+
} : {
|
|
7359
|
+
kind: "answered",
|
|
7360
|
+
items: mappedSwitches.filter((fn) => fn.available)
|
|
7361
|
+
});
|
|
7362
|
+
const ptzPresets = device.type === "camera" ? settle("ptzPresets", this.presetsMirror, boundCaps.includes("ptz") ? await this.loadPresets(device.id) : {
|
|
7363
|
+
kind: "answered",
|
|
7364
|
+
items: []
|
|
7365
|
+
}) : [];
|
|
7366
|
+
const streams = device.type === "camera" ? settle("streams", this.streamsMirror, boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : {
|
|
7367
|
+
kind: "answered",
|
|
7368
|
+
items: []
|
|
7369
|
+
}) : [];
|
|
7370
|
+
if (unknown) return {
|
|
7371
|
+
device: null,
|
|
7372
|
+
heldBack
|
|
7373
|
+
};
|
|
7166
7374
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
7167
7375
|
const model = readString(device.metadata ?? {}, "model");
|
|
7168
7376
|
return {
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7377
|
+
device: {
|
|
7378
|
+
id: device.id,
|
|
7379
|
+
name: device.name,
|
|
7380
|
+
type: device.type,
|
|
7381
|
+
...manufacturer !== null ? { manufacturer } : {},
|
|
7382
|
+
...model !== null ? { model } : {},
|
|
7383
|
+
features: device.features,
|
|
7384
|
+
boundCaps,
|
|
7385
|
+
zones,
|
|
7386
|
+
switches,
|
|
7387
|
+
ptzPresets,
|
|
7388
|
+
streams,
|
|
7389
|
+
slices: Object.keys(snapshot),
|
|
7390
|
+
/**
|
|
7391
|
+
* The slice VALUES, for the native platforms only: a native
|
|
7392
|
+
* `climate` announces the modes the device accepts, and
|
|
7393
|
+
* `availableModes` is on the slice and nowhere else. See
|
|
7394
|
+
* `CatalogDevice.capSlices`.
|
|
7395
|
+
*/
|
|
7396
|
+
capSlices: snapshot
|
|
7397
|
+
},
|
|
7398
|
+
heldBack
|
|
7188
7399
|
};
|
|
7189
7400
|
}
|
|
7401
|
+
/** One WARN per held part, with the device tag and the reason. */
|
|
7402
|
+
logHeldRead(deviceId, part, resolution) {
|
|
7403
|
+
if (resolution.status === "fresh") return;
|
|
7404
|
+
const kept = resolution.status === "unknown" ? 0 : resolution.items.length;
|
|
7405
|
+
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";
|
|
7406
|
+
this.ctx.logger.warn(message, {
|
|
7407
|
+
tags: { deviceId },
|
|
7408
|
+
meta: {
|
|
7409
|
+
part,
|
|
7410
|
+
reason: resolution.reason,
|
|
7411
|
+
kept
|
|
7412
|
+
}
|
|
7413
|
+
});
|
|
7414
|
+
}
|
|
7415
|
+
/** Retry soon after a held-back read: the provider usually registers seconds later. */
|
|
7416
|
+
scheduleReadRetry() {
|
|
7417
|
+
if (this.readTimer !== null) return;
|
|
7418
|
+
this.readTimer = setTimeout(() => {
|
|
7419
|
+
this.readTimer = null;
|
|
7420
|
+
this.reconcile("read-retry");
|
|
7421
|
+
}, READ_RETRY_MS);
|
|
7422
|
+
}
|
|
7190
7423
|
/**
|
|
7191
7424
|
* Every stream of the camera, as the hub's own picker lists them.
|
|
7192
7425
|
*
|
|
@@ -7204,45 +7437,54 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7204
7437
|
*/
|
|
7205
7438
|
async loadStreamChoices(deviceId) {
|
|
7206
7439
|
try {
|
|
7207
|
-
return
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7212
|
-
|
|
7440
|
+
return {
|
|
7441
|
+
kind: "answered",
|
|
7442
|
+
items: (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
|
|
7443
|
+
id: choice.id,
|
|
7444
|
+
label: choice.label,
|
|
7445
|
+
target: choice.target,
|
|
7446
|
+
...choice.resolution === null ? {} : { height: choice.resolution.height }
|
|
7447
|
+
}))
|
|
7448
|
+
};
|
|
7213
7449
|
} catch (err) {
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7217
|
-
}
|
|
7218
|
-
return [];
|
|
7450
|
+
return {
|
|
7451
|
+
kind: "failed",
|
|
7452
|
+
reason: errMsg(err)
|
|
7453
|
+
};
|
|
7219
7454
|
}
|
|
7220
7455
|
}
|
|
7221
7456
|
async loadBoundCaps(deviceId) {
|
|
7222
7457
|
try {
|
|
7223
|
-
return
|
|
7458
|
+
return {
|
|
7459
|
+
kind: "answered",
|
|
7460
|
+
items: (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName)
|
|
7461
|
+
};
|
|
7224
7462
|
} catch (err) {
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
}
|
|
7229
|
-
return [];
|
|
7463
|
+
return {
|
|
7464
|
+
kind: "failed",
|
|
7465
|
+
reason: errMsg(err)
|
|
7466
|
+
};
|
|
7230
7467
|
}
|
|
7231
7468
|
}
|
|
7232
7469
|
async loadZones(deviceId) {
|
|
7233
7470
|
try {
|
|
7234
7471
|
const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
|
|
7235
|
-
if (snapshot === null) return
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7472
|
+
if (snapshot === null) return {
|
|
7473
|
+
kind: "answered",
|
|
7474
|
+
items: []
|
|
7475
|
+
};
|
|
7476
|
+
return {
|
|
7477
|
+
kind: "answered",
|
|
7478
|
+
items: snapshot.zones.map((zone) => ({
|
|
7479
|
+
id: zone.zoneId,
|
|
7480
|
+
name: zone.zoneName
|
|
7481
|
+
}))
|
|
7482
|
+
};
|
|
7240
7483
|
} catch (err) {
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
}
|
|
7245
|
-
return [];
|
|
7484
|
+
return {
|
|
7485
|
+
kind: "failed",
|
|
7486
|
+
reason: errMsg(err)
|
|
7487
|
+
};
|
|
7246
7488
|
}
|
|
7247
7489
|
}
|
|
7248
7490
|
/**
|
|
@@ -7361,14 +7603,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7361
7603
|
async loadPresets(deviceId) {
|
|
7362
7604
|
try {
|
|
7363
7605
|
const device = await this.ctx.fetchDevice(deviceId);
|
|
7364
|
-
if (device.ptz === void 0) return
|
|
7365
|
-
|
|
7606
|
+
if (device.ptz === void 0) return {
|
|
7607
|
+
kind: "answered",
|
|
7608
|
+
items: []
|
|
7609
|
+
};
|
|
7610
|
+
return {
|
|
7611
|
+
kind: "answered",
|
|
7612
|
+
items: (await device.ptz.getPresets({})).map((preset) => preset.name)
|
|
7613
|
+
};
|
|
7366
7614
|
} catch (err) {
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
}
|
|
7371
|
-
return [];
|
|
7615
|
+
return {
|
|
7616
|
+
kind: "failed",
|
|
7617
|
+
reason: errMsg(err)
|
|
7618
|
+
};
|
|
7372
7619
|
}
|
|
7373
7620
|
}
|
|
7374
7621
|
/**
|
|
@@ -7447,6 +7694,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7447
7694
|
onLinkRestored: (brokerId) => this.reachability.request(brokerId)
|
|
7448
7695
|
});
|
|
7449
7696
|
client.start();
|
|
7697
|
+
this.resync.markGap("broker-connected");
|
|
7450
7698
|
this.links.set(broker.id, {
|
|
7451
7699
|
brokerId: broker.id,
|
|
7452
7700
|
name: broker.name,
|