@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
|
@@ -1,23 +1,9 @@
|
|
|
1
|
-
import { $t as
|
|
1
|
+
import { $t as array, B as consumableKindOf, Et as sendToExportedTarget, Gt as baseUnitOf, K as deviceExportCapability, Ut as BaseAddon, Xt as isReadingQuantity, Zt as nodePin, _ as PLUG_APPLIANCE_TYPES, bt as runExportedAction, c as COCO_TO_MACRO, cn as unknown, d as ConsumableItemSchema, f as ConsumableStatusSchema, g as MediaPlayerRepeatSchema, gt as pruneDeletedEntities, h as HvacModeSchema, i as AlarmArmModeSchema, in as object, j as buildAddonRouteProvider, l as CameraSwitchIdSchema, m as FanDirectionSchema, on as string, r as listHomeAssistantBrokers, s as CAMERA_SWITCH_ORDER, u as ConsumableFormSchema, un as EventCategory, ut as oauthIntegrationCapability, x as addonRoutesCapability, z as consumableFormOf } from "../ha-broker-source-vytM8SXk.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import "node:zlib";
|
|
4
4
|
import "node:fs";
|
|
5
5
|
import "node:path";
|
|
6
6
|
import "node:child_process";
|
|
7
|
-
/** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
|
|
8
|
-
var HA_BROKER_KIND = "home-assistant";
|
|
9
|
-
/**
|
|
10
|
-
* Every Home Assistant broker currently registered, cluster-wide.
|
|
11
|
-
*
|
|
12
|
-
* Unpinned by design (see the module docblock) and filtered by `kind`, which is
|
|
13
|
-
* the contract check that matters: whatever mix of providers answered the
|
|
14
|
-
* union, only `home-assistant` brokers are ever exported to over Home
|
|
15
|
-
* Assistant's transport.
|
|
16
|
-
*/
|
|
17
|
-
async function listHomeAssistantBrokers(query) {
|
|
18
|
-
return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
|
|
19
|
-
}
|
|
20
|
-
//#endregion
|
|
21
7
|
//#region src/ha-export/camera-function-writes.ts
|
|
22
8
|
/**
|
|
23
9
|
* Performs the WRITE of a camera function on the authority that owns it.
|
|
@@ -5213,12 +5199,125 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
|
|
|
5213
5199
|
refreshTokenTtlSec: 365 * 24 * 60 * 60
|
|
5214
5200
|
};
|
|
5215
5201
|
//#endregion
|
|
5202
|
+
//#region src/ha-export/push-failure.ts
|
|
5203
|
+
/**
|
|
5204
|
+
* Why a push to Home Assistant failed, in words an operator can act on.
|
|
5205
|
+
*
|
|
5206
|
+
* The reason used to live only in the log line's `meta.error`, which a log
|
|
5207
|
+
* viewer that shows the message alone never displays: an operator saw
|
|
5208
|
+
* "push failed, messages dropped" every 30 s for hours with nothing to act on.
|
|
5209
|
+
*/
|
|
5210
|
+
/** Where the custom component registers its push view inside HA. */
|
|
5211
|
+
var PUSH_PATH = "/api/camstack/push";
|
|
5212
|
+
var MAX_BODY_CHARS = 200;
|
|
5213
|
+
/** A non-2xx answer from Home Assistant's push view. */
|
|
5214
|
+
var PushHttpError = class extends Error {
|
|
5215
|
+
status;
|
|
5216
|
+
body;
|
|
5217
|
+
constructor(status, body) {
|
|
5218
|
+
super(`Home Assistant answered ${String(status)}`);
|
|
5219
|
+
this.status = status;
|
|
5220
|
+
this.body = body;
|
|
5221
|
+
this.name = "PushHttpError";
|
|
5222
|
+
}
|
|
5223
|
+
};
|
|
5224
|
+
/** First 200 chars of a response body, with any bearer token removed. */
|
|
5225
|
+
function scrubBody(body) {
|
|
5226
|
+
return body.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").slice(0, MAX_BODY_CHARS);
|
|
5227
|
+
}
|
|
5228
|
+
var NETWORK_CODES = new Set([
|
|
5229
|
+
"ECONNREFUSED",
|
|
5230
|
+
"ENOTFOUND",
|
|
5231
|
+
"ETIMEDOUT",
|
|
5232
|
+
"ECONNRESET",
|
|
5233
|
+
"EHOSTUNREACH",
|
|
5234
|
+
"ENETUNREACH",
|
|
5235
|
+
"EAI_AGAIN",
|
|
5236
|
+
"UND_ERR_CONNECT_TIMEOUT"
|
|
5237
|
+
]);
|
|
5238
|
+
function networkCode(err) {
|
|
5239
|
+
for (let current = err, depth = 0; current instanceof Error && depth < 4; depth += 1) {
|
|
5240
|
+
const code = Reflect.get(current, "code");
|
|
5241
|
+
if (typeof code === "string" && NETWORK_CODES.has(code)) return code;
|
|
5242
|
+
current = current.cause;
|
|
5243
|
+
}
|
|
5244
|
+
}
|
|
5245
|
+
function describePushFailure(err) {
|
|
5246
|
+
if (err instanceof PushHttpError) {
|
|
5247
|
+
const http = `HTTP ${String(err.status)}`;
|
|
5248
|
+
if (err.status === 404) return {
|
|
5249
|
+
kind: "component-missing",
|
|
5250
|
+
permanent: true,
|
|
5251
|
+
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`
|
|
5252
|
+
};
|
|
5253
|
+
if (err.status === 401 || err.status === 403) return {
|
|
5254
|
+
kind: "token-rejected",
|
|
5255
|
+
permanent: false,
|
|
5256
|
+
text: `${http} — Home Assistant rejected the access token (revoked, or the user lacks rights); create a new long-lived token`
|
|
5257
|
+
};
|
|
5258
|
+
if (err.status === 413) return {
|
|
5259
|
+
kind: "too-large",
|
|
5260
|
+
permanent: false,
|
|
5261
|
+
text: `${http} — the batch is too large for Home Assistant or a proxy in front of it`
|
|
5262
|
+
};
|
|
5263
|
+
const body = scrubBody(err.body);
|
|
5264
|
+
return {
|
|
5265
|
+
kind: "other",
|
|
5266
|
+
permanent: false,
|
|
5267
|
+
text: body.length > 0 ? `${http} — ${body}` : http
|
|
5268
|
+
};
|
|
5269
|
+
}
|
|
5270
|
+
const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
|
|
5271
|
+
const code = networkCode(err);
|
|
5272
|
+
const fetchFailed = err instanceof TypeError && err.message === "fetch failed";
|
|
5273
|
+
if (timedOut || code !== void 0 || fetchFailed) return {
|
|
5274
|
+
kind: "unreachable",
|
|
5275
|
+
permanent: false,
|
|
5276
|
+
text: `Home Assistant is unreachable (${timedOut ? "timed out" : code ?? "network error"}) — check the address and that it is running`
|
|
5277
|
+
};
|
|
5278
|
+
return {
|
|
5279
|
+
kind: "other",
|
|
5280
|
+
permanent: false,
|
|
5281
|
+
text: err instanceof Error ? err.message : String(err)
|
|
5282
|
+
};
|
|
5283
|
+
}
|
|
5284
|
+
/**
|
|
5285
|
+
* POST one message to Home Assistant's push view.
|
|
5286
|
+
*
|
|
5287
|
+
* Authenticated with the broker's OWN long-lived token: the component
|
|
5288
|
+
* registers its view behind HA auth, so there is no second shared secret
|
|
5289
|
+
* to configure or rotate. A non-2xx throws `PushHttpError` carrying the first
|
|
5290
|
+
* 200 chars of the body (never the request headers), which is how the push
|
|
5291
|
+
* client learns why the instance is not taking the push.
|
|
5292
|
+
*/
|
|
5293
|
+
async function postToHomeAssistant(baseUrl, token, message) {
|
|
5294
|
+
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
|
|
5295
|
+
method: "POST",
|
|
5296
|
+
headers: {
|
|
5297
|
+
authorization: `Bearer ${token}`,
|
|
5298
|
+
"content-type": "application/json"
|
|
5299
|
+
},
|
|
5300
|
+
body: JSON.stringify(message),
|
|
5301
|
+
signal: AbortSignal.timeout(1e4)
|
|
5302
|
+
});
|
|
5303
|
+
if (!response.ok) {
|
|
5304
|
+
const body = await response.text().catch(() => "");
|
|
5305
|
+
throw new PushHttpError(response.status, scrubBody(body));
|
|
5306
|
+
}
|
|
5307
|
+
}
|
|
5308
|
+
//#endregion
|
|
5216
5309
|
//#region src/ha-export/push-client.ts
|
|
5217
5310
|
/** How long state updates accumulate before one POST carries them all. */
|
|
5218
5311
|
var BATCH_FLUSH_MS = 1e3;
|
|
5219
5312
|
/** Availability cadence. The component's timeout is a multiple of this. */
|
|
5220
5313
|
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
5221
5314
|
var IMAGE_SIGNAL_PREFIX = "__image_updated__:";
|
|
5315
|
+
/**
|
|
5316
|
+
* How long a permanent failure (a missing component) is left alone before the
|
|
5317
|
+
* next probe. A 404 does not heal by retrying; it heals when the operator
|
|
5318
|
+
* installs the component, which the settings-change reset picks up at once.
|
|
5319
|
+
*/
|
|
5320
|
+
var COMPONENT_RECHECK_MS = 10 * 6e4;
|
|
5222
5321
|
/** Rate limit for the "HA is not answering" warning. */
|
|
5223
5322
|
var DROP_LOG_INTERVAL_MS = 3e4;
|
|
5224
5323
|
var PushClient = class {
|
|
@@ -5235,6 +5334,8 @@ var PushClient = class {
|
|
|
5235
5334
|
dropped = 0;
|
|
5236
5335
|
droppedLoggedAt = 0;
|
|
5237
5336
|
disposed = false;
|
|
5337
|
+
lastFailure = null;
|
|
5338
|
+
suspendedUntil = 0;
|
|
5238
5339
|
constructor(options) {
|
|
5239
5340
|
this.transport = options.transport;
|
|
5240
5341
|
this.logger = options.logger;
|
|
@@ -5245,6 +5346,18 @@ var PushClient = class {
|
|
|
5245
5346
|
get healthy() {
|
|
5246
5347
|
return this.available;
|
|
5247
5348
|
}
|
|
5349
|
+
/** Why the last push failed; `null` while the link is healthy. */
|
|
5350
|
+
get failure() {
|
|
5351
|
+
return this.lastFailure;
|
|
5352
|
+
}
|
|
5353
|
+
/** A permanent failure is being left alone until the next probe. */
|
|
5354
|
+
get suspended() {
|
|
5355
|
+
return this.suspendedUntil > Date.now();
|
|
5356
|
+
}
|
|
5357
|
+
/** Probe again at once — the operator changed something. */
|
|
5358
|
+
resetBackoff() {
|
|
5359
|
+
this.suspendedUntil = 0;
|
|
5360
|
+
}
|
|
5248
5361
|
/** Messages discarded because HA did not answer. Never silent. */
|
|
5249
5362
|
get droppedCount() {
|
|
5250
5363
|
return this.dropped;
|
|
@@ -5320,11 +5433,29 @@ var PushClient = class {
|
|
|
5320
5433
|
}
|
|
5321
5434
|
/** `true` when Home Assistant took the message. */
|
|
5322
5435
|
async send(message) {
|
|
5436
|
+
if (this.suspended) {
|
|
5437
|
+
this.dropped += message.type === "batch" ? message.items.length : 1;
|
|
5438
|
+
return false;
|
|
5439
|
+
}
|
|
5323
5440
|
try {
|
|
5324
5441
|
await this.transport(message);
|
|
5325
5442
|
this.onReachable();
|
|
5326
5443
|
return true;
|
|
5327
5444
|
} catch (err) {
|
|
5445
|
+
if (err instanceof PushHttpError && err.status === 413 && message.type === "batch") {
|
|
5446
|
+
const half = Math.ceil(message.items.length / 2);
|
|
5447
|
+
if (message.items.length > 1) {
|
|
5448
|
+
const first = await this.send({
|
|
5449
|
+
type: "batch",
|
|
5450
|
+
items: message.items.slice(0, half)
|
|
5451
|
+
});
|
|
5452
|
+
const second = await this.send({
|
|
5453
|
+
type: "batch",
|
|
5454
|
+
items: message.items.slice(half)
|
|
5455
|
+
});
|
|
5456
|
+
return first && second;
|
|
5457
|
+
}
|
|
5458
|
+
}
|
|
5328
5459
|
this.onUnreachable(message, err);
|
|
5329
5460
|
return false;
|
|
5330
5461
|
}
|
|
@@ -5332,6 +5463,8 @@ var PushClient = class {
|
|
|
5332
5463
|
onReachable() {
|
|
5333
5464
|
if (this.available) return;
|
|
5334
5465
|
this.available = true;
|
|
5466
|
+
this.lastFailure = null;
|
|
5467
|
+
this.suspendedUntil = 0;
|
|
5335
5468
|
/**
|
|
5336
5469
|
* The link came back. Drop the dedup cache so the reconcile that
|
|
5337
5470
|
* follows re-pushes every value: HA may have restarted and lost its
|
|
@@ -5369,14 +5502,18 @@ var PushClient = class {
|
|
|
5369
5502
|
*/
|
|
5370
5503
|
onUnreachable(message, err) {
|
|
5371
5504
|
this.available = false;
|
|
5505
|
+
const failure = describePushFailure(err);
|
|
5506
|
+
this.lastFailure = failure;
|
|
5507
|
+
if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
|
|
5372
5508
|
this.dropped += message.type === "batch" ? message.items.length : 1;
|
|
5373
5509
|
const now = Date.now();
|
|
5374
5510
|
if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
|
|
5375
5511
|
this.droppedLoggedAt = now;
|
|
5376
|
-
this.logger.warn(
|
|
5512
|
+
this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
|
|
5377
5513
|
brokerId: this.brokerId,
|
|
5378
5514
|
dropped: this.dropped,
|
|
5379
5515
|
messageType: message.type,
|
|
5516
|
+
failureKind: failure.kind,
|
|
5380
5517
|
error: err instanceof Error ? err.message : String(err)
|
|
5381
5518
|
} });
|
|
5382
5519
|
}
|
|
@@ -6942,8 +7079,6 @@ var SUPPORTED_DEVICE_KINDS = [
|
|
|
6942
7079
|
* one switch per broker over the single membership store.
|
|
6943
7080
|
*/
|
|
6944
7081
|
var ADDON_ID = "homeassistant-export";
|
|
6945
|
-
/** Where the custom component registers its push view inside HA. */
|
|
6946
|
-
var PUSH_PATH = "/api/camstack/push";
|
|
6947
7082
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
6948
7083
|
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
6949
7084
|
var DOORBELL_PULSE_MS = 5e3;
|
|
@@ -7166,6 +7301,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7166
7301
|
];
|
|
7167
7302
|
}
|
|
7168
7303
|
async onConfigChanged() {
|
|
7304
|
+
for (const link of this.links.values()) link.client.resetBackoff();
|
|
7169
7305
|
await this.reconcile("config-changed");
|
|
7170
7306
|
this.startTimer();
|
|
7171
7307
|
}
|
|
@@ -7848,7 +7984,9 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7848
7984
|
const delivered = await this.announce(exported, fullResync);
|
|
7849
7985
|
await this.pushFullState(exported, snapshots);
|
|
7850
7986
|
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
7851
|
-
|
|
7987
|
+
const failing = [...this.links.values()].filter((link) => !link.client.healthy && !link.client.suspended);
|
|
7988
|
+
const anyLive = [...this.links.values()].some((link) => !link.client.suspended);
|
|
7989
|
+
if (failing.length > 0 || !delivered && anyLive) {
|
|
7852
7990
|
this.resync.markGap("push-not-delivered");
|
|
7853
7991
|
this.lastStructureFingerprint = void 0;
|
|
7854
7992
|
this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
|
|
@@ -9190,7 +9328,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9190
9328
|
id: "ha-export-brokers",
|
|
9191
9329
|
title: "Home Assistant instances",
|
|
9192
9330
|
titleKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.title",
|
|
9193
|
-
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.",
|
|
9331
|
+
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.",
|
|
9194
9332
|
columns: 1,
|
|
9195
9333
|
fields: brokerFields.length > 0 ? brokerFields : [{
|
|
9196
9334
|
type: "info",
|
|
@@ -9324,16 +9462,21 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9324
9462
|
buildStatus() {
|
|
9325
9463
|
const enabled = this.enabledBrokerIds();
|
|
9326
9464
|
const healthy = [...this.links.values()].filter((link) => link.client.healthy).length;
|
|
9465
|
+
const linkFailure = [...this.links.values()].map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
|
|
9327
9466
|
return {
|
|
9328
9467
|
linkState: enabled.length === 0 ? "unlinked" : healthy === this.links.size ? "linked" : "error",
|
|
9329
9468
|
exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
|
|
9330
|
-
...this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
9469
|
+
...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
9331
9470
|
setup: {
|
|
9332
9471
|
fields: [
|
|
9333
9472
|
{
|
|
9334
9473
|
label: "Home Assistant instances",
|
|
9335
9474
|
value: `${healthy} / ${this.links.size} reachable`
|
|
9336
9475
|
},
|
|
9476
|
+
{
|
|
9477
|
+
label: "Last push failure",
|
|
9478
|
+
value: linkFailure ?? "none"
|
|
9479
|
+
},
|
|
9337
9480
|
{
|
|
9338
9481
|
label: "Entities pushed",
|
|
9339
9482
|
value: String(this.entityCount)
|
|
@@ -9368,26 +9511,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9368
9511
|
* CAPABILITIES, not its type, and restricting the picker by type is what
|
|
9369
9512
|
* confined the previous exporter to cameras.
|
|
9370
9513
|
*/
|
|
9371
|
-
/**
|
|
9372
|
-
* POST one message to Home Assistant's push view.
|
|
9373
|
-
*
|
|
9374
|
-
* Authenticated with the broker's OWN long-lived token: the component
|
|
9375
|
-
* registers its view behind HA auth, so there is no second shared secret
|
|
9376
|
-
* to configure or rotate. A non-2xx throws, which is how the push client
|
|
9377
|
-
* learns the instance is unreachable.
|
|
9378
|
-
*/
|
|
9379
|
-
async function postToHomeAssistant(baseUrl, token, message) {
|
|
9380
|
-
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
|
|
9381
|
-
method: "POST",
|
|
9382
|
-
headers: {
|
|
9383
|
-
authorization: `Bearer ${token}`,
|
|
9384
|
-
"content-type": "application/json"
|
|
9385
|
-
},
|
|
9386
|
-
body: JSON.stringify(message),
|
|
9387
|
-
signal: AbortSignal.timeout(1e4)
|
|
9388
|
-
});
|
|
9389
|
-
if (!response.ok) throw new Error(`Home Assistant answered ${response.status}`);
|
|
9390
|
-
}
|
|
9391
9514
|
function randomSecret() {
|
|
9392
9515
|
const bytes = new Uint8Array(32);
|
|
9393
9516
|
crypto.getRandomValues(bytes);
|
package/i18n/en.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"contribution.s_ha-export.title": "Home Assistant",
|
|
23
23
|
"haConfig.s_connection.title": "Home Assistant notify",
|
|
24
24
|
"haConfig.s_connection.service.label": "Notify service",
|
|
25
|
-
"haConfig.s_connection.service.description": "
|
|
25
|
+
"haConfig.s_connection.service.description": "Loaded from the broker once it is picked. If the list does not load, type the part after `notify.`.",
|
|
26
26
|
"haConfig.s_connection.brokerId.label": "Broker",
|
|
27
|
-
"haConfig.s_connection.brokerId.description": "
|
|
27
|
+
"haConfig.s_connection.brokerId.description": "The Home Assistant connection the notification goes through."
|
|
28
28
|
}
|
package/i18n/it.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"contribution.s_ha-export.title": "Home Assistant",
|
|
23
23
|
"haConfig.s_connection.title": "Notifica Home Assistant",
|
|
24
24
|
"haConfig.s_connection.service.label": "Servizio di notifica",
|
|
25
|
-
"haConfig.s_connection.service.description": "
|
|
25
|
+
"haConfig.s_connection.service.description": "Caricato dal broker una volta scelto. Se l'elenco non si carica, scrivi la parte dopo `notify.`.",
|
|
26
26
|
"haConfig.s_connection.brokerId.label": "Broker",
|
|
27
|
-
"haConfig.s_connection.brokerId.description": "
|
|
27
|
+
"haConfig.s_connection.brokerId.description": "La connessione Home Assistant attraverso cui passa la notifica."
|
|
28
28
|
}
|