@camstack/addon-provider-homeassistant 1.2.639 → 1.2.641
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/CHANGELOG.md +13 -0
- package/dist/addon.js +536 -461
- package/dist/addon.mjs +92 -17
- package/dist/{dist-C_XoeULr.js → ha-broker-source-D3_ZXq6l.js} +67695 -65422
- package/dist/{dist-Cvz76AD1.mjs → ha-broker-source-vytM8SXk.mjs} +67609 -65366
- package/dist/ha-export/ha-export.addon.js +209 -86
- package/dist/ha-export/ha-export.addon.mjs +164 -41
- package/i18n/en.json +2 -2
- package/i18n/it.json +2 -2
- 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
|
|
27
|
+
const require_ha_broker_source = require("../ha-broker-source-D3_ZXq6l.js");
|
|
28
28
|
let node_crypto = require("node:crypto");
|
|
29
29
|
require("node:zlib");
|
|
30
30
|
let node_fs = require("node:fs");
|
|
@@ -32,20 +32,6 @@ node_fs = __toESM(node_fs, 1);
|
|
|
32
32
|
let node_path = require("node:path");
|
|
33
33
|
node_path = __toESM(node_path, 1);
|
|
34
34
|
require("node:child_process");
|
|
35
|
-
/** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
|
|
36
|
-
var HA_BROKER_KIND = "home-assistant";
|
|
37
|
-
/**
|
|
38
|
-
* Every Home Assistant broker currently registered, cluster-wide.
|
|
39
|
-
*
|
|
40
|
-
* Unpinned by design (see the module docblock) and filtered by `kind`, which is
|
|
41
|
-
* the contract check that matters: whatever mix of providers answered the
|
|
42
|
-
* union, only `home-assistant` brokers are ever exported to over Home
|
|
43
|
-
* Assistant's transport.
|
|
44
|
-
*/
|
|
45
|
-
async function listHomeAssistantBrokers(query) {
|
|
46
|
-
return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
|
|
47
|
-
}
|
|
48
|
-
//#endregion
|
|
49
35
|
//#region src/ha-export/camera-function-writes.ts
|
|
50
36
|
/**
|
|
51
37
|
* Performs the WRITE of a camera function on the authority that owns it.
|
|
@@ -105,7 +91,7 @@ async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
|
|
|
105
91
|
await api.streamBroker.setDeviceAudioMute.mutate({
|
|
106
92
|
deviceId,
|
|
107
93
|
muted: write.muted
|
|
108
|
-
},
|
|
94
|
+
}, require_ha_broker_source.nodePin(sourceNodeId));
|
|
109
95
|
return;
|
|
110
96
|
}
|
|
111
97
|
}
|
|
@@ -739,8 +725,8 @@ function consumableEntity(key, facet) {
|
|
|
739
725
|
* rather than to an icon for nothing.
|
|
740
726
|
*/
|
|
741
727
|
function consumableForm(item) {
|
|
742
|
-
const declared =
|
|
743
|
-
return
|
|
728
|
+
const declared = require_ha_broker_source.ConsumableFormSchema.safeParse(item.form);
|
|
729
|
+
return require_ha_broker_source.consumableFormOf({
|
|
744
730
|
...item.kind !== void 0 ? { kind: item.kind } : {},
|
|
745
731
|
...declared.success ? { form: declared.data } : {}
|
|
746
732
|
});
|
|
@@ -753,7 +739,7 @@ function partIcon(key, label) {
|
|
|
753
739
|
function formIcon(item, form) {
|
|
754
740
|
switch (form) {
|
|
755
741
|
case "part": return partIcon(item.key, item.label);
|
|
756
|
-
case "tank": return
|
|
742
|
+
case "tank": return require_ha_broker_source.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
|
|
757
743
|
case "product": return "mdi:shaker-outline";
|
|
758
744
|
case "bin": return "mdi:delete";
|
|
759
745
|
}
|
|
@@ -764,8 +750,8 @@ function formIcon(item, form) {
|
|
|
764
750
|
* form this export does not know degrades one icon instead of refusing the
|
|
765
751
|
* whole slice.
|
|
766
752
|
*/
|
|
767
|
-
var ExportConsumableItemSchema =
|
|
768
|
-
var ExportConsumablesSchema =
|
|
753
|
+
var ExportConsumableItemSchema = require_ha_broker_source.ConsumableItemSchema.extend({ form: require_ha_broker_source.unknown().optional() });
|
|
754
|
+
var ExportConsumablesSchema = require_ha_broker_source.object({ items: require_ha_broker_source.array(ExportConsumableItemSchema) });
|
|
769
755
|
/**
|
|
770
756
|
* A worn part's life is a diagnostic; a tank's fill is what the device page
|
|
771
757
|
* is for (a vacuum's clean water, a dishwasher's salt), so it is not hidden.
|
|
@@ -778,7 +764,7 @@ function consumableEntities(item) {
|
|
|
778
764
|
capName: "consumables",
|
|
779
765
|
itemKey: item.key
|
|
780
766
|
};
|
|
781
|
-
const kind =
|
|
767
|
+
const kind = require_ha_broker_source.consumableKindOf(item);
|
|
782
768
|
const form = consumableForm(item);
|
|
783
769
|
const icon = formIcon(item, form);
|
|
784
770
|
/** What the item IS, on the sensors that describe it (not on a counter or a button). */
|
|
@@ -818,7 +804,7 @@ function consumableEntities(item) {
|
|
|
818
804
|
platform: "sensor",
|
|
819
805
|
label: `${item.label} status`,
|
|
820
806
|
deviceClass: "enum",
|
|
821
|
-
options:
|
|
807
|
+
options: require_ha_broker_source.ConsumableStatusSchema.options,
|
|
822
808
|
icon,
|
|
823
809
|
attributes,
|
|
824
810
|
entityCategory: "diagnostic"
|
|
@@ -2395,7 +2381,7 @@ var CAP_ENTITY_MAP = {
|
|
|
2395
2381
|
* failure. `fanMode` and `preset` get no such list at all: they are
|
|
2396
2382
|
* free-form strings, so they stay sensors.
|
|
2397
2383
|
*/
|
|
2398
|
-
options:
|
|
2384
|
+
options: require_ha_broker_source.HvacModeSchema.options,
|
|
2399
2385
|
extras: {
|
|
2400
2386
|
"current-temp": {
|
|
2401
2387
|
platform: "sensor",
|
|
@@ -2545,7 +2531,7 @@ var CAP_ENTITY_MAP = {
|
|
|
2545
2531
|
field: "direction",
|
|
2546
2532
|
label: "Direction",
|
|
2547
2533
|
writable: true,
|
|
2548
|
-
options:
|
|
2534
|
+
options: require_ha_broker_source.FanDirectionSchema.options,
|
|
2549
2535
|
enabledByDefault: false
|
|
2550
2536
|
}
|
|
2551
2537
|
}
|
|
@@ -2975,7 +2961,7 @@ var CAP_ENTITY_MAP = {
|
|
|
2975
2961
|
field: "repeat",
|
|
2976
2962
|
label: "Repeat",
|
|
2977
2963
|
writable: true,
|
|
2978
|
-
options:
|
|
2964
|
+
options: require_ha_broker_source.MediaPlayerRepeatSchema.options,
|
|
2979
2965
|
enabledByDefault: false
|
|
2980
2966
|
},
|
|
2981
2967
|
shuffle: {
|
|
@@ -3466,7 +3452,7 @@ function controlEntitySpecs(slice, quantity) {
|
|
|
3466
3452
|
const min = readFiniteNumber(slice, "min") ?? PERCENT_RANGE.min;
|
|
3467
3453
|
const max = readFiniteNumber(slice, "max") ?? PERCENT_RANGE.max;
|
|
3468
3454
|
const step = readFiniteNumber(slice, "step") ?? PERCENT_RANGE.step;
|
|
3469
|
-
const unit = quantity === void 0 ? void 0 :
|
|
3455
|
+
const unit = quantity === void 0 ? void 0 : require_ha_broker_source.baseUnitOf(quantity);
|
|
3470
3456
|
if (min < max && step > 0) return [{
|
|
3471
3457
|
entity: primary,
|
|
3472
3458
|
platform: "number",
|
|
@@ -3768,7 +3754,7 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
|
3768
3754
|
continue;
|
|
3769
3755
|
}
|
|
3770
3756
|
if (cap === "numeric-sensor") {
|
|
3771
|
-
const unit = device.readingQuantity === void 0 ? void 0 :
|
|
3757
|
+
const unit = device.readingQuantity === void 0 ? void 0 : require_ha_broker_source.baseUnitOf(device.readingQuantity);
|
|
3772
3758
|
specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(unit === void 0 ? spec : {
|
|
3773
3759
|
...spec,
|
|
3774
3760
|
unit
|
|
@@ -3842,7 +3828,7 @@ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
|
3842
3828
|
* was.
|
|
3843
3829
|
*/
|
|
3844
3830
|
/** The camera function ids, keyed by the entity slug the catalog emits. */
|
|
3845
|
-
var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(
|
|
3831
|
+
var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_ha_broker_source.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
|
|
3846
3832
|
var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
|
|
3847
3833
|
function parseBool(value) {
|
|
3848
3834
|
const lower = value.trim().toLowerCase();
|
|
@@ -3905,7 +3891,7 @@ function parseAlarmAction(value) {
|
|
|
3905
3891
|
if (lower === "disarm" || lower === "disarmed") return { kind: "disarm" };
|
|
3906
3892
|
if (lower === "trigger" || lower === "triggered") return { kind: "trigger" };
|
|
3907
3893
|
const withoutPrefix = lower.startsWith("armed_") ? lower.slice(6) : lower.startsWith("arm_") ? lower.slice(4) : lower;
|
|
3908
|
-
const mode =
|
|
3894
|
+
const mode = require_ha_broker_source.AlarmArmModeSchema.safeParse(withoutPrefix);
|
|
3909
3895
|
return mode.success ? {
|
|
3910
3896
|
kind: "arm",
|
|
3911
3897
|
mode: mode.data
|
|
@@ -4054,7 +4040,7 @@ var CAP_COMMAND_ROUTES = {
|
|
|
4054
4040
|
},
|
|
4055
4041
|
"climate-control": {
|
|
4056
4042
|
primary: (deviceId, value) => {
|
|
4057
|
-
const mode = parseEnum(
|
|
4043
|
+
const mode = parseEnum(require_ha_broker_source.HvacModeSchema, value);
|
|
4058
4044
|
return mode === null ? null : {
|
|
4059
4045
|
kind: "climate-mode",
|
|
4060
4046
|
deviceId,
|
|
@@ -4169,7 +4155,7 @@ var CAP_COMMAND_ROUTES = {
|
|
|
4169
4155
|
};
|
|
4170
4156
|
},
|
|
4171
4157
|
direction: (deviceId, value) => {
|
|
4172
|
-
const direction = parseEnum(
|
|
4158
|
+
const direction = parseEnum(require_ha_broker_source.FanDirectionSchema, value);
|
|
4173
4159
|
return direction === null ? null : {
|
|
4174
4160
|
kind: "fan-direction",
|
|
4175
4161
|
deviceId,
|
|
@@ -4373,7 +4359,7 @@ var CAP_COMMAND_ROUTES = {
|
|
|
4373
4359
|
};
|
|
4374
4360
|
},
|
|
4375
4361
|
repeat: (deviceId, value) => {
|
|
4376
|
-
const repeat = parseEnum(
|
|
4362
|
+
const repeat = parseEnum(require_ha_broker_source.MediaPlayerRepeatSchema, value);
|
|
4377
4363
|
return repeat === null ? null : {
|
|
4378
4364
|
kind: "media-repeat",
|
|
4379
4365
|
deviceId,
|
|
@@ -5248,12 +5234,125 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
|
|
|
5248
5234
|
refreshTokenTtlSec: 365 * 24 * 60 * 60
|
|
5249
5235
|
};
|
|
5250
5236
|
//#endregion
|
|
5237
|
+
//#region src/ha-export/push-failure.ts
|
|
5238
|
+
/**
|
|
5239
|
+
* Why a push to Home Assistant failed, in words an operator can act on.
|
|
5240
|
+
*
|
|
5241
|
+
* The reason used to live only in the log line's `meta.error`, which a log
|
|
5242
|
+
* viewer that shows the message alone never displays: an operator saw
|
|
5243
|
+
* "push failed, messages dropped" every 30 s for hours with nothing to act on.
|
|
5244
|
+
*/
|
|
5245
|
+
/** Where the custom component registers its push view inside HA. */
|
|
5246
|
+
var PUSH_PATH = "/api/camstack/push";
|
|
5247
|
+
var MAX_BODY_CHARS = 200;
|
|
5248
|
+
/** A non-2xx answer from Home Assistant's push view. */
|
|
5249
|
+
var PushHttpError = class extends Error {
|
|
5250
|
+
status;
|
|
5251
|
+
body;
|
|
5252
|
+
constructor(status, body) {
|
|
5253
|
+
super(`Home Assistant answered ${String(status)}`);
|
|
5254
|
+
this.status = status;
|
|
5255
|
+
this.body = body;
|
|
5256
|
+
this.name = "PushHttpError";
|
|
5257
|
+
}
|
|
5258
|
+
};
|
|
5259
|
+
/** First 200 chars of a response body, with any bearer token removed. */
|
|
5260
|
+
function scrubBody(body) {
|
|
5261
|
+
return body.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").slice(0, MAX_BODY_CHARS);
|
|
5262
|
+
}
|
|
5263
|
+
var NETWORK_CODES = new Set([
|
|
5264
|
+
"ECONNREFUSED",
|
|
5265
|
+
"ENOTFOUND",
|
|
5266
|
+
"ETIMEDOUT",
|
|
5267
|
+
"ECONNRESET",
|
|
5268
|
+
"EHOSTUNREACH",
|
|
5269
|
+
"ENETUNREACH",
|
|
5270
|
+
"EAI_AGAIN",
|
|
5271
|
+
"UND_ERR_CONNECT_TIMEOUT"
|
|
5272
|
+
]);
|
|
5273
|
+
function networkCode(err) {
|
|
5274
|
+
for (let current = err, depth = 0; current instanceof Error && depth < 4; depth += 1) {
|
|
5275
|
+
const code = Reflect.get(current, "code");
|
|
5276
|
+
if (typeof code === "string" && NETWORK_CODES.has(code)) return code;
|
|
5277
|
+
current = current.cause;
|
|
5278
|
+
}
|
|
5279
|
+
}
|
|
5280
|
+
function describePushFailure(err) {
|
|
5281
|
+
if (err instanceof PushHttpError) {
|
|
5282
|
+
const http = `HTTP ${String(err.status)}`;
|
|
5283
|
+
if (err.status === 404) return {
|
|
5284
|
+
kind: "component-missing",
|
|
5285
|
+
permanent: true,
|
|
5286
|
+
text: `${http} — Home Assistant has no CamStack integration installed (the ${PUSH_PATH} endpoint is missing). Install it from HACS, or turn off Home Assistant export`
|
|
5287
|
+
};
|
|
5288
|
+
if (err.status === 401 || err.status === 403) return {
|
|
5289
|
+
kind: "token-rejected",
|
|
5290
|
+
permanent: false,
|
|
5291
|
+
text: `${http} — Home Assistant rejected the access token (revoked, or the user lacks rights); create a new long-lived token`
|
|
5292
|
+
};
|
|
5293
|
+
if (err.status === 413) return {
|
|
5294
|
+
kind: "too-large",
|
|
5295
|
+
permanent: false,
|
|
5296
|
+
text: `${http} — the batch is too large for Home Assistant or a proxy in front of it`
|
|
5297
|
+
};
|
|
5298
|
+
const body = scrubBody(err.body);
|
|
5299
|
+
return {
|
|
5300
|
+
kind: "other",
|
|
5301
|
+
permanent: false,
|
|
5302
|
+
text: body.length > 0 ? `${http} — ${body}` : http
|
|
5303
|
+
};
|
|
5304
|
+
}
|
|
5305
|
+
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
5306
|
+
const code = networkCode(err);
|
|
5307
|
+
const fetchFailed = err instanceof TypeError && err.message === "fetch failed";
|
|
5308
|
+
if (timedOut || code !== void 0 || fetchFailed) return {
|
|
5309
|
+
kind: "unreachable",
|
|
5310
|
+
permanent: false,
|
|
5311
|
+
text: `Home Assistant is unreachable (${timedOut ? "timed out" : code ?? "network error"}) — check the address and that it is running`
|
|
5312
|
+
};
|
|
5313
|
+
return {
|
|
5314
|
+
kind: "other",
|
|
5315
|
+
permanent: false,
|
|
5316
|
+
text: err instanceof Error ? err.message : String(err)
|
|
5317
|
+
};
|
|
5318
|
+
}
|
|
5319
|
+
/**
|
|
5320
|
+
* POST one message to Home Assistant's push view.
|
|
5321
|
+
*
|
|
5322
|
+
* Authenticated with the broker's OWN long-lived token: the component
|
|
5323
|
+
* registers its view behind HA auth, so there is no second shared secret
|
|
5324
|
+
* to configure or rotate. A non-2xx throws `PushHttpError` carrying the first
|
|
5325
|
+
* 200 chars of the body (never the request headers), which is how the push
|
|
5326
|
+
* client learns why the instance is not taking the push.
|
|
5327
|
+
*/
|
|
5328
|
+
async function postToHomeAssistant(baseUrl, token, message) {
|
|
5329
|
+
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
|
|
5330
|
+
method: "POST",
|
|
5331
|
+
headers: {
|
|
5332
|
+
authorization: `Bearer ${token}`,
|
|
5333
|
+
"content-type": "application/json"
|
|
5334
|
+
},
|
|
5335
|
+
body: JSON.stringify(message),
|
|
5336
|
+
signal: AbortSignal.timeout(1e4)
|
|
5337
|
+
});
|
|
5338
|
+
if (!response.ok) {
|
|
5339
|
+
const body = await response.text().catch(() => "");
|
|
5340
|
+
throw new PushHttpError(response.status, scrubBody(body));
|
|
5341
|
+
}
|
|
5342
|
+
}
|
|
5343
|
+
//#endregion
|
|
5251
5344
|
//#region src/ha-export/push-client.ts
|
|
5252
5345
|
/** How long state updates accumulate before one POST carries them all. */
|
|
5253
5346
|
var BATCH_FLUSH_MS = 1e3;
|
|
5254
5347
|
/** Availability cadence. The component's timeout is a multiple of this. */
|
|
5255
5348
|
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
5256
5349
|
var IMAGE_SIGNAL_PREFIX = "__image_updated__:";
|
|
5350
|
+
/**
|
|
5351
|
+
* How long a permanent failure (a missing component) is left alone before the
|
|
5352
|
+
* next probe. A 404 does not heal by retrying; it heals when the operator
|
|
5353
|
+
* installs the component, which the settings-change reset picks up at once.
|
|
5354
|
+
*/
|
|
5355
|
+
var COMPONENT_RECHECK_MS = 10 * 6e4;
|
|
5257
5356
|
/** Rate limit for the "HA is not answering" warning. */
|
|
5258
5357
|
var DROP_LOG_INTERVAL_MS = 3e4;
|
|
5259
5358
|
var PushClient = class {
|
|
@@ -5270,6 +5369,8 @@ var PushClient = class {
|
|
|
5270
5369
|
dropped = 0;
|
|
5271
5370
|
droppedLoggedAt = 0;
|
|
5272
5371
|
disposed = false;
|
|
5372
|
+
lastFailure = null;
|
|
5373
|
+
suspendedUntil = 0;
|
|
5273
5374
|
constructor(options) {
|
|
5274
5375
|
this.transport = options.transport;
|
|
5275
5376
|
this.logger = options.logger;
|
|
@@ -5280,6 +5381,18 @@ var PushClient = class {
|
|
|
5280
5381
|
get healthy() {
|
|
5281
5382
|
return this.available;
|
|
5282
5383
|
}
|
|
5384
|
+
/** Why the last push failed; `null` while the link is healthy. */
|
|
5385
|
+
get failure() {
|
|
5386
|
+
return this.lastFailure;
|
|
5387
|
+
}
|
|
5388
|
+
/** A permanent failure is being left alone until the next probe. */
|
|
5389
|
+
get suspended() {
|
|
5390
|
+
return this.suspendedUntil > Date.now();
|
|
5391
|
+
}
|
|
5392
|
+
/** Probe again at once — the operator changed something. */
|
|
5393
|
+
resetBackoff() {
|
|
5394
|
+
this.suspendedUntil = 0;
|
|
5395
|
+
}
|
|
5283
5396
|
/** Messages discarded because HA did not answer. Never silent. */
|
|
5284
5397
|
get droppedCount() {
|
|
5285
5398
|
return this.dropped;
|
|
@@ -5355,11 +5468,29 @@ var PushClient = class {
|
|
|
5355
5468
|
}
|
|
5356
5469
|
/** `true` when Home Assistant took the message. */
|
|
5357
5470
|
async send(message) {
|
|
5471
|
+
if (this.suspended) {
|
|
5472
|
+
this.dropped += message.type === "batch" ? message.items.length : 1;
|
|
5473
|
+
return false;
|
|
5474
|
+
}
|
|
5358
5475
|
try {
|
|
5359
5476
|
await this.transport(message);
|
|
5360
5477
|
this.onReachable();
|
|
5361
5478
|
return true;
|
|
5362
5479
|
} catch (err) {
|
|
5480
|
+
if (err instanceof PushHttpError && err.status === 413 && message.type === "batch") {
|
|
5481
|
+
const half = Math.ceil(message.items.length / 2);
|
|
5482
|
+
if (message.items.length > 1) {
|
|
5483
|
+
const first = await this.send({
|
|
5484
|
+
type: "batch",
|
|
5485
|
+
items: message.items.slice(0, half)
|
|
5486
|
+
});
|
|
5487
|
+
const second = await this.send({
|
|
5488
|
+
type: "batch",
|
|
5489
|
+
items: message.items.slice(half)
|
|
5490
|
+
});
|
|
5491
|
+
return first && second;
|
|
5492
|
+
}
|
|
5493
|
+
}
|
|
5363
5494
|
this.onUnreachable(message, err);
|
|
5364
5495
|
return false;
|
|
5365
5496
|
}
|
|
@@ -5367,6 +5498,8 @@ var PushClient = class {
|
|
|
5367
5498
|
onReachable() {
|
|
5368
5499
|
if (this.available) return;
|
|
5369
5500
|
this.available = true;
|
|
5501
|
+
this.lastFailure = null;
|
|
5502
|
+
this.suspendedUntil = 0;
|
|
5370
5503
|
/**
|
|
5371
5504
|
* The link came back. Drop the dedup cache so the reconcile that
|
|
5372
5505
|
* follows re-pushes every value: HA may have restarted and lost its
|
|
@@ -5404,14 +5537,18 @@ var PushClient = class {
|
|
|
5404
5537
|
*/
|
|
5405
5538
|
onUnreachable(message, err) {
|
|
5406
5539
|
this.available = false;
|
|
5540
|
+
const failure = describePushFailure(err);
|
|
5541
|
+
this.lastFailure = failure;
|
|
5542
|
+
if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
|
|
5407
5543
|
this.dropped += message.type === "batch" ? message.items.length : 1;
|
|
5408
5544
|
const now = Date.now();
|
|
5409
5545
|
if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
|
|
5410
5546
|
this.droppedLoggedAt = now;
|
|
5411
|
-
this.logger.warn(
|
|
5547
|
+
this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
|
|
5412
5548
|
brokerId: this.brokerId,
|
|
5413
5549
|
dropped: this.dropped,
|
|
5414
5550
|
messageType: message.type,
|
|
5551
|
+
failureKind: failure.kind,
|
|
5415
5552
|
error: err instanceof Error ? err.message : String(err)
|
|
5416
5553
|
} });
|
|
5417
5554
|
}
|
|
@@ -5657,7 +5794,7 @@ function reduceLastTriggers(rows) {
|
|
|
5657
5794
|
function macroOf(className) {
|
|
5658
5795
|
const lower = className.toLowerCase();
|
|
5659
5796
|
if (EXPORTED_MACROS.includes(lower)) return lower;
|
|
5660
|
-
const mapped =
|
|
5797
|
+
const mapped = require_ha_broker_source.COCO_TO_MACRO.mapping[lower];
|
|
5661
5798
|
return typeof mapped === "string" ? mapped : null;
|
|
5662
5799
|
}
|
|
5663
5800
|
function iso(timestamp) {
|
|
@@ -6806,7 +6943,7 @@ var ExportedEntities = class {
|
|
|
6806
6943
|
this.ports.logger.warn("homeassistant-export: exported entities not reconciled — a list was unanswered", { meta: { error: errMsg$1(err) } });
|
|
6807
6944
|
return;
|
|
6808
6945
|
}
|
|
6809
|
-
const { dropped } =
|
|
6946
|
+
const { dropped } = require_ha_broker_source.pruneDeletedEntities([...this.state.actions.map((e) => ({
|
|
6810
6947
|
kind: "action",
|
|
6811
6948
|
actionId: e.actionId
|
|
6812
6949
|
})), ...this.state.targets.map((e) => ({
|
|
@@ -6878,7 +7015,7 @@ var ExportedEntities = class {
|
|
|
6878
7015
|
const action = this.state.actions.find((e) => actionEntity(e.actionId) === entity);
|
|
6879
7016
|
if (action !== void 0) return {
|
|
6880
7017
|
handled: true,
|
|
6881
|
-
applied: (await
|
|
7018
|
+
applied: (await require_ha_broker_source.runExportedAction(this.ports.api, this.ports.logger, {
|
|
6882
7019
|
exporter: EXPORTER,
|
|
6883
7020
|
actionId: action.actionId
|
|
6884
7021
|
})).status === "started"
|
|
@@ -6886,7 +7023,7 @@ var ExportedEntities = class {
|
|
|
6886
7023
|
const target = this.state.targets.find((e) => targetEntity(e.targetId) === entity);
|
|
6887
7024
|
if (target !== void 0) return {
|
|
6888
7025
|
handled: true,
|
|
6889
|
-
applied: (await
|
|
7026
|
+
applied: (await require_ha_broker_source.sendToExportedTarget(this.ports.api, this.ports.logger, {
|
|
6890
7027
|
exporter: EXPORTER,
|
|
6891
7028
|
targetId: target.targetId,
|
|
6892
7029
|
notification: { body: value }
|
|
@@ -6937,7 +7074,7 @@ var SUPPORTED_DEVICE_KINDS = [
|
|
|
6937
7074
|
"image",
|
|
6938
7075
|
"pet-feeder",
|
|
6939
7076
|
"hub",
|
|
6940
|
-
...
|
|
7077
|
+
...require_ha_broker_source.PLUG_APPLIANCE_TYPES
|
|
6941
7078
|
];
|
|
6942
7079
|
//#endregion
|
|
6943
7080
|
//#region src/ha-export/ha-export.addon.ts
|
|
@@ -6977,8 +7114,6 @@ var SUPPORTED_DEVICE_KINDS = [
|
|
|
6977
7114
|
* one switch per broker over the single membership store.
|
|
6978
7115
|
*/
|
|
6979
7116
|
var ADDON_ID = "homeassistant-export";
|
|
6980
|
-
/** Where the custom component registers its push view inside HA. */
|
|
6981
|
-
var PUSH_PATH = "/api/camstack/push";
|
|
6982
7117
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
6983
7118
|
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
6984
7119
|
var DOORBELL_PULSE_MS = 5e3;
|
|
@@ -7010,7 +7145,7 @@ var DEFAULT_CONFIG = {
|
|
|
7010
7145
|
},
|
|
7011
7146
|
publicBaseUrl: ""
|
|
7012
7147
|
};
|
|
7013
|
-
var HaExportAddon = class extends
|
|
7148
|
+
var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
7014
7149
|
links = /* @__PURE__ */ new Map();
|
|
7015
7150
|
/** deviceKey → the device it addresses. Rebuilt on every reconcile. */
|
|
7016
7151
|
exported = /* @__PURE__ */ new Map();
|
|
@@ -7117,7 +7252,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7117
7252
|
super({ ...DEFAULT_CONFIG });
|
|
7118
7253
|
}
|
|
7119
7254
|
async onInitialize() {
|
|
7120
|
-
const secret = this.state("haExportMediaSecret",
|
|
7255
|
+
const secret = this.state("haExportMediaSecret", require_ha_broker_source.string(), "");
|
|
7121
7256
|
const existing = await secret.get();
|
|
7122
7257
|
if (existing.length === 0) {
|
|
7123
7258
|
const minted = randomSecret();
|
|
@@ -7150,7 +7285,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7150
7285
|
this.exportedEntities = exportedEntities;
|
|
7151
7286
|
return [
|
|
7152
7287
|
{
|
|
7153
|
-
capability:
|
|
7288
|
+
capability: require_ha_broker_source.deviceExportCapability,
|
|
7154
7289
|
provider: {
|
|
7155
7290
|
getStatus: async () => this.buildStatus(),
|
|
7156
7291
|
listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
|
|
@@ -7183,8 +7318,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7183
7318
|
}
|
|
7184
7319
|
},
|
|
7185
7320
|
{
|
|
7186
|
-
capability:
|
|
7187
|
-
provider:
|
|
7321
|
+
capability: require_ha_broker_source.addonRoutesCapability,
|
|
7322
|
+
provider: require_ha_broker_source.buildAddonRouteProvider(ADDON_ID, [{
|
|
7188
7323
|
method: "POST",
|
|
7189
7324
|
path: "/command",
|
|
7190
7325
|
access: "authenticated",
|
|
@@ -7195,26 +7330,27 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7195
7330
|
}])
|
|
7196
7331
|
},
|
|
7197
7332
|
{
|
|
7198
|
-
capability:
|
|
7333
|
+
capability: require_ha_broker_source.oauthIntegrationCapability,
|
|
7199
7334
|
provider: { getDescriptor: async () => HOME_ASSISTANT_OAUTH_INTEGRATION }
|
|
7200
7335
|
}
|
|
7201
7336
|
];
|
|
7202
7337
|
}
|
|
7203
7338
|
async onConfigChanged() {
|
|
7339
|
+
for (const link of this.links.values()) link.client.resetBackoff();
|
|
7204
7340
|
await this.reconcile("config-changed");
|
|
7205
7341
|
this.startTimer();
|
|
7206
7342
|
}
|
|
7207
7343
|
wireListeners() {
|
|
7208
|
-
this.subscribe({ category:
|
|
7344
|
+
this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceStateChanged }, (event) => {
|
|
7209
7345
|
this.onSliceChanged(event.data);
|
|
7210
7346
|
});
|
|
7211
|
-
this.subscribe({ category:
|
|
7347
|
+
this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceOnline }, (event) => {
|
|
7212
7348
|
this.onOnlineChanged(event.data, true);
|
|
7213
7349
|
});
|
|
7214
|
-
this.subscribe({ category:
|
|
7350
|
+
this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceOffline }, (event) => {
|
|
7215
7351
|
this.onOnlineChanged(event.data, false);
|
|
7216
7352
|
});
|
|
7217
|
-
this.subscribe({ category:
|
|
7353
|
+
this.subscribe({ category: require_ha_broker_source.EventCategory.MotionOnMotionChanged }, (event) => {
|
|
7218
7354
|
this.onMotion(event.data);
|
|
7219
7355
|
});
|
|
7220
7356
|
/**
|
|
@@ -7223,13 +7359,13 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7223
7359
|
* the entity existed and effectively never moved. It also carries the
|
|
7224
7360
|
* window LEVEL, which nothing else on the export path does.
|
|
7225
7361
|
*/
|
|
7226
|
-
this.subscribe({ category:
|
|
7362
|
+
this.subscribe({ category: require_ha_broker_source.EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
7227
7363
|
this.onAudioWindow(event.data);
|
|
7228
7364
|
});
|
|
7229
|
-
this.subscribe({ category:
|
|
7365
|
+
this.subscribe({ category: require_ha_broker_source.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
7230
7366
|
this.onTrackLifecycle(event.data);
|
|
7231
7367
|
});
|
|
7232
|
-
this.subscribe({ category:
|
|
7368
|
+
this.subscribe({ category: require_ha_broker_source.EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
|
|
7233
7369
|
this.onZoneOccupancy(event.data);
|
|
7234
7370
|
});
|
|
7235
7371
|
/**
|
|
@@ -7238,11 +7374,11 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7238
7374
|
* rather than a state push.
|
|
7239
7375
|
*/
|
|
7240
7376
|
for (const category of [
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
|
|
7377
|
+
require_ha_broker_source.EventCategory.DeviceRegistered,
|
|
7378
|
+
require_ha_broker_source.EventCategory.DeviceUnregistered,
|
|
7379
|
+
require_ha_broker_source.EventCategory.DeviceBindingsChanged,
|
|
7380
|
+
require_ha_broker_source.EventCategory.DeviceMetaChanged,
|
|
7381
|
+
require_ha_broker_source.EventCategory.BrokerStatusChanged
|
|
7246
7382
|
]) this.subscribe({ category }, () => {
|
|
7247
7383
|
this.reconcileSoon();
|
|
7248
7384
|
});
|
|
@@ -7883,7 +8019,9 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7883
8019
|
const delivered = await this.announce(exported, fullResync);
|
|
7884
8020
|
await this.pushFullState(exported, snapshots);
|
|
7885
8021
|
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
7886
|
-
|
|
8022
|
+
const failing = [...this.links.values()].filter((link) => !link.client.healthy && !link.client.suspended);
|
|
8023
|
+
const anyLive = [...this.links.values()].some((link) => !link.client.suspended);
|
|
8024
|
+
if (failing.length > 0 || !delivered && anyLive) {
|
|
7887
8025
|
this.resync.markGap("push-not-delivered");
|
|
7888
8026
|
this.lastStructureFingerprint = void 0;
|
|
7889
8027
|
this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
|
|
@@ -8337,7 +8475,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
8337
8475
|
*/
|
|
8338
8476
|
capSlices: snapshot,
|
|
8339
8477
|
itemEntities: items.entities,
|
|
8340
|
-
...
|
|
8478
|
+
...require_ha_broker_source.isReadingQuantity(readingQuantity) ? { readingQuantity } : {}
|
|
8341
8479
|
},
|
|
8342
8480
|
heldBack
|
|
8343
8481
|
};
|
|
@@ -8571,7 +8709,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
8571
8709
|
maskEnabled: privacySlice["enabled"] === true,
|
|
8572
8710
|
audioEnabled: typeof privacySlice["audioEnabled"] === "boolean" ? privacySlice["audioEnabled"] : null
|
|
8573
8711
|
};
|
|
8574
|
-
const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId },
|
|
8712
|
+
const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_ha_broker_source.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
|
|
8575
8713
|
this.ctx.logger.warn("ha-export: could not read broker audio mute, the broker-audio function degrades", {
|
|
8576
8714
|
tags: { deviceId },
|
|
8577
8715
|
meta: {
|
|
@@ -8622,7 +8760,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
8622
8760
|
* reasoning.
|
|
8623
8761
|
*/
|
|
8624
8762
|
async refreshBrokers() {
|
|
8625
|
-
const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
|
|
8763
|
+
const haBrokers = await require_ha_broker_source.listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
|
|
8626
8764
|
const known = haBrokers.map((broker) => ({
|
|
8627
8765
|
id: broker.id,
|
|
8628
8766
|
name: broker.name
|
|
@@ -8964,7 +9102,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
8964
9102
|
}
|
|
8965
9103
|
switch (command.kind) {
|
|
8966
9104
|
case "camera-function": {
|
|
8967
|
-
const parsed =
|
|
9105
|
+
const parsed = require_ha_broker_source.CameraSwitchIdSchema.safeParse(command.switchId);
|
|
8968
9106
|
if (!parsed.success) {
|
|
8969
9107
|
this.ctx.logger.warn("ha-export: dropping a command for an unknown camera function", {
|
|
8970
9108
|
tags: { deviceId: command.deviceId },
|
|
@@ -9225,7 +9363,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
9225
9363
|
id: "ha-export-brokers",
|
|
9226
9364
|
title: "Home Assistant instances",
|
|
9227
9365
|
titleKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.title",
|
|
9228
|
-
description: brokerFields.length > 0 ? "An instance that is off receives nothing; its exported devices resume when it is turned back on." : "No Home Assistant broker is registered yet.",
|
|
9366
|
+
description: brokerFields.length > 0 ? "An instance that is off receives nothing; its exported devices resume when it is turned back on. Export needs the CamStack custom component installed in Home Assistant (HACS)." : "No Home Assistant broker is registered yet.",
|
|
9229
9367
|
columns: 1,
|
|
9230
9368
|
fields: brokerFields.length > 0 ? brokerFields : [{
|
|
9231
9369
|
type: "info",
|
|
@@ -9359,16 +9497,21 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
9359
9497
|
buildStatus() {
|
|
9360
9498
|
const enabled = this.enabledBrokerIds();
|
|
9361
9499
|
const healthy = [...this.links.values()].filter((link) => link.client.healthy).length;
|
|
9500
|
+
const linkFailure = [...this.links.values()].map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
|
|
9362
9501
|
return {
|
|
9363
9502
|
linkState: enabled.length === 0 ? "unlinked" : healthy === this.links.size ? "linked" : "error",
|
|
9364
9503
|
exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
|
|
9365
|
-
...this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
9504
|
+
...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
9366
9505
|
setup: {
|
|
9367
9506
|
fields: [
|
|
9368
9507
|
{
|
|
9369
9508
|
label: "Home Assistant instances",
|
|
9370
9509
|
value: `${healthy} / ${this.links.size} reachable`
|
|
9371
9510
|
},
|
|
9511
|
+
{
|
|
9512
|
+
label: "Last push failure",
|
|
9513
|
+
value: linkFailure ?? "none"
|
|
9514
|
+
},
|
|
9372
9515
|
{
|
|
9373
9516
|
label: "Entities pushed",
|
|
9374
9517
|
value: String(this.entityCount)
|
|
@@ -9403,26 +9546,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
9403
9546
|
* CAPABILITIES, not its type, and restricting the picker by type is what
|
|
9404
9547
|
* confined the previous exporter to cameras.
|
|
9405
9548
|
*/
|
|
9406
|
-
/**
|
|
9407
|
-
* POST one message to Home Assistant's push view.
|
|
9408
|
-
*
|
|
9409
|
-
* Authenticated with the broker's OWN long-lived token: the component
|
|
9410
|
-
* registers its view behind HA auth, so there is no second shared secret
|
|
9411
|
-
* to configure or rotate. A non-2xx throws, which is how the push client
|
|
9412
|
-
* learns the instance is unreachable.
|
|
9413
|
-
*/
|
|
9414
|
-
async function postToHomeAssistant(baseUrl, token, message) {
|
|
9415
|
-
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
|
|
9416
|
-
method: "POST",
|
|
9417
|
-
headers: {
|
|
9418
|
-
authorization: `Bearer ${token}`,
|
|
9419
|
-
"content-type": "application/json"
|
|
9420
|
-
},
|
|
9421
|
-
body: JSON.stringify(message),
|
|
9422
|
-
signal: AbortSignal.timeout(1e4)
|
|
9423
|
-
});
|
|
9424
|
-
if (!response.ok) throw new Error(`Home Assistant answered ${response.status}`);
|
|
9425
|
-
}
|
|
9426
9549
|
function randomSecret() {
|
|
9427
9550
|
const bytes = new Uint8Array(32);
|
|
9428
9551
|
crypto.getRandomValues(bytes);
|