@camstack/addon-provider-homeassistant 1.2.602 → 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-Dcum8auE.mjs → dist-CM5Z-Rdj.mjs} +87 -3
- package/dist/{dist-xuu2eG_W.js → dist-DVNChk7-.js} +87 -3
- package/dist/ha-export/ha-export.addon.js +320 -76
- package/dist/ha-export/ha-export.addon.mjs +320 -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";
|
|
@@ -4739,14 +4739,15 @@ var PushClient = class {
|
|
|
4739
4739
|
* is flushed first — a value for an entity HA has not built yet is a
|
|
4740
4740
|
* value it discards.
|
|
4741
4741
|
*/
|
|
4742
|
-
async publishEntityChange(deviceKey, cmps, dev) {
|
|
4743
|
-
if (this.disposed) return;
|
|
4742
|
+
async publishEntityChange(deviceKey, cmps, dev, full = false) {
|
|
4743
|
+
if (this.disposed) return false;
|
|
4744
4744
|
await this.flush();
|
|
4745
|
-
|
|
4745
|
+
return this.send({
|
|
4746
4746
|
type: "entity_change",
|
|
4747
4747
|
device_id: deviceKey,
|
|
4748
4748
|
cmps,
|
|
4749
|
-
dev
|
|
4749
|
+
dev,
|
|
4750
|
+
...full ? { full: true } : {}
|
|
4750
4751
|
});
|
|
4751
4752
|
}
|
|
4752
4753
|
/** Send everything buffered now. Called before any structure change. */
|
|
@@ -4763,12 +4764,15 @@ var PushClient = class {
|
|
|
4763
4764
|
items
|
|
4764
4765
|
});
|
|
4765
4766
|
}
|
|
4767
|
+
/** `true` when Home Assistant took the message. */
|
|
4766
4768
|
async send(message) {
|
|
4767
4769
|
try {
|
|
4768
4770
|
await this.transport(message);
|
|
4769
4771
|
this.onReachable();
|
|
4772
|
+
return true;
|
|
4770
4773
|
} catch (err) {
|
|
4771
4774
|
this.onUnreachable(message, err);
|
|
4775
|
+
return false;
|
|
4772
4776
|
}
|
|
4773
4777
|
}
|
|
4774
4778
|
onReachable() {
|
|
@@ -4882,6 +4886,130 @@ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
|
|
|
4882
4886
|
return reason === "structure-changed" && fingerprint === lastFingerprint;
|
|
4883
4887
|
}
|
|
4884
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
|
|
4885
5013
|
//#region src/ha-export/rule-triggers.ts
|
|
4886
5014
|
function reduceOne(row) {
|
|
4887
5015
|
return {
|
|
@@ -5936,9 +6064,9 @@ function projectSynthetic(input, nowMs) {
|
|
|
5936
6064
|
topic: stateTopic(srv, `${slug}_memory`),
|
|
5937
6065
|
value: String(Math.round(node.memoryPercent))
|
|
5938
6066
|
});
|
|
5939
|
-
if (node.online && node.
|
|
6067
|
+
if (node.online && node.uptimeMs > 0) values.push({
|
|
5940
6068
|
topic: stateTopic(srv, `${slug}_uptime`),
|
|
5941
|
-
value:
|
|
6069
|
+
value: new Date(nowMs - node.uptimeMs).toISOString()
|
|
5942
6070
|
});
|
|
5943
6071
|
if (node.footageUsedBytes !== null) values.push({
|
|
5944
6072
|
topic: stateTopic(srv, `${slug}_footage_used_bytes`),
|
|
@@ -6004,6 +6132,8 @@ var PUSH_PATH = "/api/camstack/push";
|
|
|
6004
6132
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
6005
6133
|
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
6006
6134
|
var DOORBELL_PULSE_MS = 5e3;
|
|
6135
|
+
/** How soon a pass with a held-back read is retried. */
|
|
6136
|
+
var READ_RETRY_MS = 3e4;
|
|
6007
6137
|
/**
|
|
6008
6138
|
* The earliest timestamp a real change could plausibly carry. Below this,
|
|
6009
6139
|
* a slice field is a placeholder or a seeded "known value, unknown change
|
|
@@ -6050,6 +6180,24 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6050
6180
|
* still holds through the periodic pass.
|
|
6051
6181
|
*/
|
|
6052
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;
|
|
6053
6201
|
entityCount = 0;
|
|
6054
6202
|
/** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
|
|
6055
6203
|
lastDoorbellPressAt = /* @__PURE__ */ new Map();
|
|
@@ -6126,6 +6274,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6126
6274
|
for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
|
|
6127
6275
|
this.doorbellReleaseTimers.clear();
|
|
6128
6276
|
this.reachability.dispose();
|
|
6277
|
+
if (this.readTimer !== null) clearTimeout(this.readTimer);
|
|
6278
|
+
this.readTimer = null;
|
|
6129
6279
|
for (const link of this.links.values()) await link.client.dispose();
|
|
6130
6280
|
this.links.clear();
|
|
6131
6281
|
});
|
|
@@ -6598,6 +6748,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6598
6748
|
const startedAt = Date.now();
|
|
6599
6749
|
try {
|
|
6600
6750
|
await this.refreshBrokers();
|
|
6751
|
+
const fullResync = this.resync.take(reason);
|
|
6752
|
+
let readsHeld = false;
|
|
6601
6753
|
await this.refreshMediaBaseUrl();
|
|
6602
6754
|
await this.refreshComponentSupport();
|
|
6603
6755
|
const enabled = this.enabledBrokerIds();
|
|
@@ -6660,16 +6812,22 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6660
6812
|
* used to call the deprecated `getCameraSwitches` separately for the
|
|
6661
6813
|
* same answer.
|
|
6662
6814
|
*/
|
|
6663
|
-
const
|
|
6815
|
+
const sourceNodeId = this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null;
|
|
6816
|
+
const functionReads = device.type === "camera" ? await this.readCameraFunctions({
|
|
6664
6817
|
deviceId: numericId,
|
|
6665
6818
|
deviceType: device.type,
|
|
6666
6819
|
deviceDisabled: device.disabled,
|
|
6667
6820
|
privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
|
|
6668
|
-
sourceNodeId
|
|
6821
|
+
sourceNodeId,
|
|
6669
6822
|
bindableCapsByType,
|
|
6670
6823
|
mutedDeviceIds
|
|
6671
|
-
})
|
|
6672
|
-
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;
|
|
6673
6831
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
6674
6832
|
const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
|
|
6675
6833
|
/**
|
|
@@ -6740,7 +6898,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6740
6898
|
this.entityCount = entityCount;
|
|
6741
6899
|
this.unclassified = unclassifiedCaps([...allCaps]);
|
|
6742
6900
|
const fingerprint = structureFingerprint(exported);
|
|
6743
|
-
if (
|
|
6901
|
+
if (readsHeld) this.scheduleReadRetry();
|
|
6902
|
+
if (!fullResync && shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
|
|
6744
6903
|
this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
|
|
6745
6904
|
devices: exported.size,
|
|
6746
6905
|
entities: entityCount
|
|
@@ -6758,11 +6917,17 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6758
6917
|
entities: line.entities
|
|
6759
6918
|
}
|
|
6760
6919
|
});
|
|
6761
|
-
await this.announce(exported);
|
|
6920
|
+
const delivered = await this.announce(exported, fullResync);
|
|
6762
6921
|
await this.pushFullState(exported, snapshots);
|
|
6763
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
|
+
}
|
|
6764
6928
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6765
6929
|
reason,
|
|
6930
|
+
fullResync,
|
|
6766
6931
|
brokers: enabled.length,
|
|
6767
6932
|
devices: exported.size,
|
|
6768
6933
|
entities: entityCount,
|
|
@@ -6773,6 +6938,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6773
6938
|
this.lastError = void 0;
|
|
6774
6939
|
} catch (err) {
|
|
6775
6940
|
this.lastError = errMsg(err);
|
|
6941
|
+
this.resync.markGap("reconcile-failed");
|
|
6776
6942
|
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
6777
6943
|
}
|
|
6778
6944
|
}
|
|
@@ -6971,7 +7137,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6971
7137
|
online: node.isOnline,
|
|
6972
7138
|
cpuPercent: node.cpuPercent,
|
|
6973
7139
|
memoryPercent: node.memoryPercent,
|
|
6974
|
-
|
|
7140
|
+
uptimeMs: node.uptime,
|
|
6975
7141
|
isHub: node.isHub,
|
|
6976
7142
|
footageUsedBytes: footage.footageUsedBytes,
|
|
6977
7143
|
footageFreePercent: footage.footageFreePercent,
|
|
@@ -6990,13 +7156,16 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6990
7156
|
for (const value of values) link.client.publishState(value.topic, value.value);
|
|
6991
7157
|
}
|
|
6992
7158
|
}
|
|
6993
|
-
|
|
7159
|
+
/** `false` when any announce was not delivered. */
|
|
7160
|
+
async announce(exported, full) {
|
|
7161
|
+
let allDelivered = true;
|
|
6994
7162
|
for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
|
|
6995
7163
|
const link = this.links.get(brokerId);
|
|
6996
7164
|
if (link === void 0) continue;
|
|
6997
7165
|
try {
|
|
6998
|
-
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;
|
|
6999
7167
|
} catch (err) {
|
|
7168
|
+
allDelivered = false;
|
|
7000
7169
|
this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
|
|
7001
7170
|
tags: { deviceId: entry.deviceId },
|
|
7002
7171
|
meta: {
|
|
@@ -7006,6 +7175,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7006
7175
|
});
|
|
7007
7176
|
}
|
|
7008
7177
|
}
|
|
7178
|
+
return allDelivered;
|
|
7009
7179
|
}
|
|
7010
7180
|
async pushFullState(exported, snapshots) {
|
|
7011
7181
|
for (const entry of exported.values()) try {
|
|
@@ -7123,46 +7293,105 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7123
7293
|
}
|
|
7124
7294
|
return values;
|
|
7125
7295
|
}
|
|
7126
|
-
|
|
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) {
|
|
7127
7303
|
const snapshot = toSnapshot(snapshotRaw);
|
|
7128
|
-
|
|
7129
|
-
|
|
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)) : [];
|
|
7130
7315
|
/**
|
|
7131
7316
|
* Every camera function, available or not — `entity-catalog.ts` is the
|
|
7132
7317
|
* one place that filters to `available`, exactly as it did for
|
|
7133
|
-
* `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.
|
|
7134
7321
|
*/
|
|
7135
|
-
const
|
|
7322
|
+
const mappedSwitches = (cameraFunctions ?? []).map((fn) => ({
|
|
7136
7323
|
id: fn.id,
|
|
7137
7324
|
label: fn.label,
|
|
7138
7325
|
available: fn.available
|
|
7139
7326
|
}));
|
|
7140
|
-
const
|
|
7141
|
-
|
|
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
|
+
};
|
|
7142
7346
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
7143
7347
|
const model = readString(device.metadata ?? {}, "model");
|
|
7144
7348
|
return {
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
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
|
|
7164
7371
|
};
|
|
7165
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
|
+
}
|
|
7166
7395
|
/**
|
|
7167
7396
|
* Every stream of the camera, as the hub's own picker lists them.
|
|
7168
7397
|
*
|
|
@@ -7180,45 +7409,54 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7180
7409
|
*/
|
|
7181
7410
|
async loadStreamChoices(deviceId) {
|
|
7182
7411
|
try {
|
|
7183
|
-
return
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
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
|
+
};
|
|
7189
7421
|
} catch (err) {
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
}
|
|
7194
|
-
return [];
|
|
7422
|
+
return {
|
|
7423
|
+
kind: "failed",
|
|
7424
|
+
reason: errMsg(err)
|
|
7425
|
+
};
|
|
7195
7426
|
}
|
|
7196
7427
|
}
|
|
7197
7428
|
async loadBoundCaps(deviceId) {
|
|
7198
7429
|
try {
|
|
7199
|
-
return
|
|
7430
|
+
return {
|
|
7431
|
+
kind: "answered",
|
|
7432
|
+
items: (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName)
|
|
7433
|
+
};
|
|
7200
7434
|
} catch (err) {
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
}
|
|
7205
|
-
return [];
|
|
7435
|
+
return {
|
|
7436
|
+
kind: "failed",
|
|
7437
|
+
reason: errMsg(err)
|
|
7438
|
+
};
|
|
7206
7439
|
}
|
|
7207
7440
|
}
|
|
7208
7441
|
async loadZones(deviceId) {
|
|
7209
7442
|
try {
|
|
7210
7443
|
const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
|
|
7211
|
-
if (snapshot === null) return
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
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
|
+
};
|
|
7216
7455
|
} catch (err) {
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
}
|
|
7221
|
-
return [];
|
|
7456
|
+
return {
|
|
7457
|
+
kind: "failed",
|
|
7458
|
+
reason: errMsg(err)
|
|
7459
|
+
};
|
|
7222
7460
|
}
|
|
7223
7461
|
}
|
|
7224
7462
|
/**
|
|
@@ -7337,14 +7575,19 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7337
7575
|
async loadPresets(deviceId) {
|
|
7338
7576
|
try {
|
|
7339
7577
|
const device = await this.ctx.fetchDevice(deviceId);
|
|
7340
|
-
if (device.ptz === void 0) return
|
|
7341
|
-
|
|
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
|
+
};
|
|
7342
7586
|
} catch (err) {
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
}
|
|
7347
|
-
return [];
|
|
7587
|
+
return {
|
|
7588
|
+
kind: "failed",
|
|
7589
|
+
reason: errMsg(err)
|
|
7590
|
+
};
|
|
7348
7591
|
}
|
|
7349
7592
|
}
|
|
7350
7593
|
/**
|
|
@@ -7423,6 +7666,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7423
7666
|
onLinkRestored: (brokerId) => this.reachability.request(brokerId)
|
|
7424
7667
|
});
|
|
7425
7668
|
client.start();
|
|
7669
|
+
this.resync.markGap("broker-connected");
|
|
7426
7670
|
this.links.set(broker.id, {
|
|
7427
7671
|
brokerId: broker.id,
|
|
7428
7672
|
name: broker.name,
|