@camstack/addon-provider-homeassistant 1.2.649 → 1.2.651

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.
@@ -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_ha_broker_source = require("../ha-broker-source-CE3nHkyW.js");
27
+ const require_ha_broker_source = require("../ha-broker-source-D4_TtF2V.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -975,19 +975,22 @@ var NATIVE_CAP_PLATFORMS = {
975
975
  */
976
976
  cover: {
977
977
  platform: "cover",
978
+ availableWhenOnline: true,
978
979
  primary: { from: null },
979
980
  controls: {
980
981
  position: {
981
982
  from: "position",
982
983
  min: 0,
983
984
  max: 100,
984
- step: 1
985
+ step: 1,
986
+ requiresFeature: "cover-positionable"
985
987
  },
986
988
  tilt: {
987
989
  from: "tilt",
988
990
  min: 0,
989
991
  max: 100,
990
- step: 1
992
+ step: 1,
993
+ requiresFeature: "cover-tilt"
991
994
  },
992
995
  open: {
993
996
  from: "open",
@@ -999,7 +1002,8 @@ var NATIVE_CAP_PLATFORMS = {
999
1002
  },
1000
1003
  stop: {
1001
1004
  from: "stop",
1002
- commandOnly: true
1005
+ commandOnly: true,
1006
+ requiresFeature: "cover-stoppable"
1003
1007
  }
1004
1008
  }
1005
1009
  },
@@ -1388,9 +1392,11 @@ function buildNativeComponent(input) {
1388
1392
  if (native === void 0) return null;
1389
1393
  const primary = resolveBinding(input, native.primary);
1390
1394
  if (primary === null) return null;
1395
+ const features = input.features ?? [];
1391
1396
  const absorbed = new Set([derivedEntityId(input.capName), ...primary.entities]);
1392
1397
  const controls = {};
1393
1398
  for (const [name, binding] of Object.entries(native.controls ?? {})) {
1399
+ if (binding.requiresFeature !== void 0 && !features.includes(binding.requiresFeature)) continue;
1394
1400
  const resolved = resolveBinding(input, binding);
1395
1401
  if (resolved === null) continue;
1396
1402
  controls[name] = resolved.control;
@@ -1409,6 +1415,11 @@ function buildNativeComponent(input) {
1409
1415
  ...primary.control,
1410
1416
  ...native.deviceClass !== void 0 ? { device_class: native.deviceClass } : {},
1411
1417
  ...native.icon !== void 0 ? { icon: native.icon } : {},
1418
+ ...native.availableWhenOnline === true ? {
1419
+ availability_topic: stateTopic(input.deviceKey, "online"),
1420
+ payload_available: "true",
1421
+ payload_not_available: "false"
1422
+ } : {},
1412
1423
  ...Object.keys(controls).length > 0 ? { controls } : {},
1413
1424
  ...native.constants,
1414
1425
  ...flags
@@ -2343,7 +2354,8 @@ var CAP_ENTITY_MAP = {
2343
2354
  unit: "%",
2344
2355
  icon: "mdi:arrow-up-down",
2345
2356
  writable: true,
2346
- range: PERCENT_RANGE
2357
+ range: PERCENT_RANGE,
2358
+ requiresFeature: "cover-positionable"
2347
2359
  },
2348
2360
  tilt: {
2349
2361
  platform: "number",
@@ -2353,7 +2365,8 @@ var CAP_ENTITY_MAP = {
2353
2365
  icon: "mdi:angle-acute",
2354
2366
  writable: true,
2355
2367
  range: PERCENT_RANGE,
2356
- enabledByDefault: false
2368
+ enabledByDefault: false,
2369
+ requiresFeature: "cover-tilt"
2357
2370
  }
2358
2371
  },
2359
2372
  commands: {
@@ -2368,7 +2381,8 @@ var CAP_ENTITY_MAP = {
2368
2381
  stop: {
2369
2382
  label: "Stop",
2370
2383
  icon: "mdi:stop",
2371
- enabledByDefault: false
2384
+ enabledByDefault: false,
2385
+ requiresFeature: "cover-stoppable"
2372
2386
  }
2373
2387
  }
2374
2388
  },
@@ -3375,7 +3389,8 @@ function capEntities(capName) {
3375
3389
  for (const [suffix, extra] of Object.entries(mapping.extras ?? {})) refs.push({
3376
3390
  entity: derivedExtraEntityId(capName, suffix),
3377
3391
  label: extra.label,
3378
- descriptor: extra
3392
+ descriptor: extra,
3393
+ ...extra.requiresFeature !== void 0 ? { requiresFeature: extra.requiresFeature } : {}
3379
3394
  });
3380
3395
  return refs;
3381
3396
  }
@@ -3394,8 +3409,9 @@ function deriveCapValue(derivation, raw) {
3394
3409
  return derivation.kind === "equals" ? raw === derivation.value : raw !== derivation.value;
3395
3410
  }
3396
3411
  /** A capability's entities as the catalog builders consume them. */
3397
- function capEntitySpecs(capName) {
3398
- const specs = capEntities(capName).map((ref) => ({
3412
+ function capEntitySpecs(capName, features = []) {
3413
+ const declared = (required) => required === void 0 || features.includes(required);
3414
+ const specs = capEntities(capName).filter((ref) => declared(ref.requiresFeature)).map((ref) => ({
3399
3415
  entity: ref.entity,
3400
3416
  platform: ref.descriptor.platform,
3401
3417
  label: ref.label,
@@ -3408,15 +3424,18 @@ function capEntitySpecs(capName) {
3408
3424
  ...ref.descriptor.entityCategory !== void 0 ? { entityCategory: ref.descriptor.entityCategory } : {},
3409
3425
  ...ref.descriptor.writable === true ? { writable: true } : {}
3410
3426
  }));
3411
- for (const ref of capCommandEntities(capName)) specs.push({
3412
- entity: ref.entity,
3413
- platform: "button",
3414
- label: ref.label,
3415
- enabledByDefault: ref.command.enabledByDefault ?? true,
3416
- writable: true,
3417
- ...ref.command.icon !== void 0 ? { icon: ref.command.icon } : {},
3418
- ...ref.command.entityCategory !== void 0 ? { entityCategory: ref.command.entityCategory } : {}
3419
- });
3427
+ for (const ref of capCommandEntities(capName)) {
3428
+ if (!declared(ref.command.requiresFeature)) continue;
3429
+ specs.push({
3430
+ entity: ref.entity,
3431
+ platform: "button",
3432
+ label: ref.label,
3433
+ enabledByDefault: ref.command.enabledByDefault ?? true,
3434
+ writable: true,
3435
+ ...ref.command.icon !== void 0 ? { icon: ref.command.icon } : {},
3436
+ ...ref.command.entityCategory !== void 0 ? { entityCategory: ref.command.entityCategory } : {}
3437
+ });
3438
+ }
3420
3439
  return specs;
3421
3440
  }
3422
3441
  function readControlOptions(slice) {
@@ -3794,11 +3813,12 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3794
3813
  deviceId: device.id,
3795
3814
  capName: cap,
3796
3815
  label: humanise(cap),
3816
+ features: device.features,
3797
3817
  slice: device.capSlices?.[cap],
3798
3818
  mapping
3799
3819
  });
3800
3820
  if (plan === null) {
3801
- specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(spec, options)));
3821
+ specs.push(...capEntitySpecs(cap, device.features).map((spec) => degradeUnsupported(spec, options)));
3802
3822
  continue;
3803
3823
  }
3804
3824
  native[toComponentKey(plan.component.platform, derivedEntityId(cap))] = plan.component;
@@ -3809,7 +3829,7 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3809
3829
  * to make the device look tidy would be a silent loss of a value an
3810
3830
  * operator already automates on.
3811
3831
  */
3812
- specs.push(...capEntitySpecs(cap).filter((spec) => !plan.absorbed.has(spec.entity)));
3832
+ specs.push(...capEntitySpecs(cap, device.features).filter((spec) => !plan.absorbed.has(spec.entity)));
3813
3833
  }
3814
3834
  const assembled = assemble(device, specs);
3815
3835
  return {
@@ -4671,6 +4691,15 @@ function isCameraCommand(command) {
4671
4691
  function isCapCommand(command) {
4672
4692
  return !isCameraCommand(command);
4673
4693
  }
4694
+ var APPLIED = { ok: true };
4695
+ /** The 422 body of a command that was not applied: the reason travels with it. */
4696
+ function commandNotAppliedBody(refusal) {
4697
+ return {
4698
+ error: "command not applied",
4699
+ reason: refusal.reason,
4700
+ ...refusal.message !== void 0 ? { message: refusal.message } : {}
4701
+ };
4702
+ }
4674
4703
  /**
4675
4704
  * Make the call the command names.
4676
4705
  *
@@ -5462,6 +5491,10 @@ var PushClient = class {
5462
5491
  disposed = false;
5463
5492
  lastFailure = null;
5464
5493
  suspendedUntil = 0;
5494
+ /** When the current unbroken streak of 404s began; 0 when there is none. */
5495
+ first404At = 0;
5496
+ /** A suspension was entered and not yet logged as left. */
5497
+ suspensionLogged = false;
5465
5498
  constructor(options) {
5466
5499
  this.transport = options.transport;
5467
5500
  this.logger = options.logger;
@@ -5480,9 +5513,20 @@ var PushClient = class {
5480
5513
  get suspended() {
5481
5514
  return this.suspendedUntil > Date.now();
5482
5515
  }
5516
+ /** Epoch ms of the next probe while suspended; 0 otherwise. */
5517
+ get recheckAt() {
5518
+ return this.suspended ? this.suspendedUntil : 0;
5519
+ }
5483
5520
  /** Probe again at once — the operator changed something. */
5484
5521
  resetBackoff() {
5485
5522
  this.suspendedUntil = 0;
5523
+ this.first404At = 0;
5524
+ this.logSuspensionEnded("reset by a settings change");
5525
+ }
5526
+ logSuspensionEnded(why) {
5527
+ if (!this.suspensionLogged) return;
5528
+ this.suspensionLogged = false;
5529
+ this.logger.info(`ha-export: push link no longer suspended (${why}), probing again`, { meta: { brokerId: this.brokerId } });
5486
5530
  }
5487
5531
  /** Messages discarded because HA did not answer. Never silent. */
5488
5532
  get droppedCount() {
@@ -5563,8 +5607,13 @@ var PushClient = class {
5563
5607
  this.dropped += message.type === "batch" ? message.items.length : 1;
5564
5608
  return false;
5565
5609
  }
5610
+ if (this.suspendedUntil !== 0 && !this.suspended) {
5611
+ this.suspendedUntil = 0;
5612
+ this.logSuspensionEnded("recheck time reached");
5613
+ }
5566
5614
  try {
5567
5615
  await this.transport(message);
5616
+ this.first404At = 0;
5568
5617
  this.onReachable();
5569
5618
  return true;
5570
5619
  } catch (err) {
@@ -5591,6 +5640,7 @@ var PushClient = class {
5591
5640
  this.available = true;
5592
5641
  this.lastFailure = null;
5593
5642
  this.suspendedUntil = 0;
5643
+ this.logSuspensionEnded("Home Assistant answered");
5594
5644
  /**
5595
5645
  * The link came back. Drop the dedup cache so the reconcile that
5596
5646
  * follows re-pushes every value: HA may have restarted and lost its
@@ -5628,11 +5678,29 @@ var PushClient = class {
5628
5678
  */
5629
5679
  onUnreachable(message, err) {
5630
5680
  this.available = false;
5631
- const failure = describePushFailure(err);
5681
+ const now = Date.now();
5682
+ const described = describePushFailure(err);
5683
+ let failure = described;
5684
+ if (described.kind === "component-missing") {
5685
+ if (this.first404At === 0) this.first404At = now;
5686
+ if (now - this.first404At < 6e4) failure = {
5687
+ kind: "other",
5688
+ permanent: false,
5689
+ text: "HTTP 404 — Home Assistant has not registered the CamStack endpoint (yet); retrying"
5690
+ };
5691
+ } else this.first404At = 0;
5632
5692
  this.lastFailure = failure;
5633
- if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
5634
5693
  this.dropped += message.type === "batch" ? message.items.length : 1;
5635
- const now = Date.now();
5694
+ if (failure.permanent) {
5695
+ this.suspendedUntil = now + COMPONENT_RECHECK_MS;
5696
+ this.suspensionLogged = true;
5697
+ this.logger.warn(`ha-export: push link SUSPENDED (${failure.text}); next probe at ${new Date(this.suspendedUntil).toISOString()}`, { meta: {
5698
+ brokerId: this.brokerId,
5699
+ failureKind: failure.kind,
5700
+ recheckAt: this.suspendedUntil,
5701
+ recheckInMs: COMPONENT_RECHECK_MS
5702
+ } });
5703
+ }
5636
5704
  if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
5637
5705
  this.droppedLoggedAt = now;
5638
5706
  this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
@@ -5644,6 +5712,19 @@ var PushClient = class {
5644
5712
  } });
5645
5713
  }
5646
5714
  };
5715
+ //#endregion
5716
+ //#region src/ha-export/link-gaps.ts
5717
+ /** A parked link is reported, never silently excluded. */
5718
+ function summariseLinkGaps(links) {
5719
+ return {
5720
+ failing: links.filter((l) => !l.healthy && !l.suspended).map((l) => l.brokerId),
5721
+ suspended: links.filter((l) => l.suspended).map((l) => ({
5722
+ brokerId: l.brokerId,
5723
+ recheckAt: l.recheckAt
5724
+ })),
5725
+ anyLive: links.some((l) => !l.suspended)
5726
+ };
5727
+ }
5647
5728
  var ReachabilityReconciler = class {
5648
5729
  options;
5649
5730
  minIntervalMs;
@@ -8115,13 +8196,21 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8115
8196
  const delivered = await this.announce(exported, fullResync);
8116
8197
  await this.pushFullState(exported, snapshots);
8117
8198
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
8118
- const failing = [...this.links.values()].filter((link) => !link.client.healthy && !link.client.suspended);
8119
- const anyLive = [...this.links.values()].some((link) => !link.client.suspended);
8120
- if (failing.length > 0 || !delivered && anyLive) {
8199
+ const gaps = summariseLinkGaps([...this.links.entries()].map(([brokerId, link]) => ({
8200
+ brokerId,
8201
+ healthy: link.client.healthy,
8202
+ suspended: link.client.suspended,
8203
+ recheckAt: link.client.recheckAt
8204
+ })));
8205
+ if (gaps.failing.length > 0 || !delivered && gaps.anyLive) {
8121
8206
  this.resync.markGap("push-not-delivered");
8122
8207
  this.lastStructureFingerprint = void 0;
8123
8208
  this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
8124
8209
  }
8210
+ if (gaps.suspended.length > 0) this.ctx.logger.warn("ha-export: push links suspended, Home Assistant receives nothing", { meta: { suspended: gaps.suspended.map((s) => ({
8211
+ brokerId: s.brokerId,
8212
+ recheckAt: new Date(s.recheckAt).toISOString()
8213
+ })) } });
8125
8214
  this.ctx.logger.info("ha-export: reconciled", { meta: {
8126
8215
  reason,
8127
8216
  fullResync,
@@ -8129,6 +8218,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8129
8218
  devices: exported.size,
8130
8219
  entities: entityCount,
8131
8220
  unclassifiedCaps: this.unclassified.length,
8221
+ suspendedLinks: gaps.suspended.length,
8132
8222
  durationMs: Date.now() - startedAt
8133
8223
  } });
8134
8224
  if (this.unclassified.length > 0) this.ctx.logger.warn("ha-export: capabilities in neither the map nor the exclusion list", { meta: { caps: this.unclassified } });
@@ -9168,9 +9258,23 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9168
9258
  reply.send({ error: `unroutable command (${resolution.reason})` });
9169
9259
  return;
9170
9260
  }
9171
- const applied = await this.dispatch(resolution.command);
9172
- reply.status(applied ? 200 : 422);
9173
- reply.send(applied ? {} : { error: "command not applied" });
9261
+ const outcome = await this.dispatch(resolution.command);
9262
+ if (outcome.ok) {
9263
+ reply.status(200);
9264
+ reply.send({});
9265
+ return;
9266
+ }
9267
+ this.ctx.logger.warn("ha-export: command answered 422", {
9268
+ tags: { deviceId: exported.deviceId },
9269
+ meta: {
9270
+ topic,
9271
+ entity: parsed.entity,
9272
+ reason: outcome.reason,
9273
+ message: outcome.message
9274
+ }
9275
+ });
9276
+ reply.status(422);
9277
+ reply.send(commandNotAppliedBody(outcome));
9174
9278
  }
9175
9279
  async dispatch(command) {
9176
9280
  try {
@@ -9184,7 +9288,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9184
9288
  */
9185
9289
  if (isCapCommand(command)) {
9186
9290
  const outcome = await applyCapCommand(await this.ctx.fetchDevice(command.deviceId), command);
9187
- if (outcome.ok) return true;
9291
+ if (outcome.ok) return APPLIED;
9188
9292
  if (outcome.reason === "no-provider") return this.noProvider(command.deviceId, outcome.capName);
9189
9293
  this.ctx.logger.warn("ha-export: the device refused the command", {
9190
9294
  tags: { deviceId: command.deviceId },
@@ -9194,7 +9298,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9194
9298
  ...outcome.error !== void 0 ? { error: outcome.error } : {}
9195
9299
  }
9196
9300
  });
9197
- return false;
9301
+ return {
9302
+ ok: false,
9303
+ reason: outcome.reason,
9304
+ ...outcome.error !== void 0 ? { message: outcome.error } : {}
9305
+ };
9198
9306
  }
9199
9307
  switch (command.kind) {
9200
9308
  case "camera-function": {
@@ -9204,7 +9312,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9204
9312
  tags: { deviceId: command.deviceId },
9205
9313
  meta: { switchId: command.switchId }
9206
9314
  });
9207
- return false;
9315
+ return {
9316
+ ok: false,
9317
+ reason: "unknown-function",
9318
+ message: "the switch id is not a camera function"
9319
+ };
9208
9320
  }
9209
9321
  /**
9210
9322
  * A FRESH read for this one device — the pass-level caches are up to
@@ -9214,7 +9326,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9214
9326
  const device = await this.ctx.api.deviceManager.getDevice.query({ deviceId: command.deviceId });
9215
9327
  if (device === null) {
9216
9328
  this.ctx.logger.warn("ha-export: dropping a camera-function command, the device is not in the registry", { tags: { deviceId: command.deviceId } });
9217
- return false;
9329
+ return {
9330
+ ok: false,
9331
+ reason: "device-not-found",
9332
+ message: "the device is not in the registry"
9333
+ };
9218
9334
  }
9219
9335
  const privacySlice = await this.ctx.api.deviceState.getCapSlice.query({
9220
9336
  deviceId: command.deviceId,
@@ -9250,22 +9366,26 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9250
9366
  tags: { deviceId: command.deviceId },
9251
9367
  meta: { switchId: parsed.data }
9252
9368
  });
9253
- return false;
9369
+ return {
9370
+ ok: false,
9371
+ reason: "function-unavailable",
9372
+ message: "the camera function is not available"
9373
+ };
9254
9374
  }
9255
9375
  await applyCameraFunctionWrite(this.ctx.api, command.deviceId, write, sourceNodeId);
9256
- return true;
9376
+ return APPLIED;
9257
9377
  }
9258
9378
  case "reboot": {
9259
9379
  const device = await this.ctx.fetchDevice(command.deviceId);
9260
9380
  if (device.reboot === void 0) return this.noProvider(command.deviceId, "reboot");
9261
9381
  await device.reboot.reboot({});
9262
- return true;
9382
+ return APPLIED;
9263
9383
  }
9264
9384
  case "ptz-move": {
9265
9385
  const device = await this.ctx.fetchDevice(command.deviceId);
9266
9386
  if (device.ptz === void 0) return this.noProvider(command.deviceId, "ptz");
9267
9387
  await device.ptz.move(PTZ_MOVE_BY_DIRECTION[command.direction] ?? {});
9268
- return true;
9388
+ return APPLIED;
9269
9389
  }
9270
9390
  case "ptz-preset": {
9271
9391
  const device = await this.ctx.fetchDevice(command.deviceId);
@@ -9276,10 +9396,14 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9276
9396
  tags: { deviceId: command.deviceId },
9277
9397
  meta: { preset: command.preset }
9278
9398
  });
9279
- return false;
9399
+ return {
9400
+ ok: false,
9401
+ reason: "no-such-preset",
9402
+ message: "no preset by that name"
9403
+ };
9280
9404
  }
9281
9405
  await device.ptz.goToPreset({ presetId: preset.id });
9282
- return true;
9406
+ return APPLIED;
9283
9407
  }
9284
9408
  }
9285
9409
  } catch (err) {
@@ -9295,7 +9419,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9295
9419
  error: errMsg(err)
9296
9420
  }
9297
9421
  });
9298
- return false;
9422
+ return {
9423
+ ok: false,
9424
+ reason: "error",
9425
+ message: errMsg(err)
9426
+ };
9299
9427
  }
9300
9428
  }
9301
9429
  /** A cap the device does not provide. Refused, and never silently. */
@@ -9304,7 +9432,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9304
9432
  tags: { deviceId },
9305
9433
  meta: { capName }
9306
9434
  });
9307
- return false;
9435
+ return {
9436
+ ok: false,
9437
+ reason: "no-provider",
9438
+ message: `the device has no provider for ${capName}`
9439
+ };
9308
9440
  }
9309
9441
  /**
9310
9442
  * A public, signed, expiring route Home Assistant can fetch bytes from.