@camstack/addon-provider-homeassistant 1.2.635 → 1.2.637

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.
@@ -1,4 +1,4 @@
1
- import { Ft as BaseAddon, Gt as array, Ht as isReadingQuantity, I as consumableFormOf, L as consumableKindOf, Lt as baseUnitOf, O as buildAddonRouteProvider, Qt as string, U as deviceExportCapability, Ut as nodePin, Xt as object, a as COCO_TO_MACRO, c as ConsumableItemSchema, d as FanDirectionSchema, en as unknown, f as HvacModeSchema, i as CAMERA_SWITCH_ORDER, l as ConsumableStatusSchema, m as PLUG_APPLIANCE_TYPES, nn as EventCategory, o as CameraSwitchIdSchema, p as MediaPlayerRepeatSchema, s as ConsumableFormSchema, st as oauthIntegrationCapability, t as AlarmArmModeSchema, v as addonRoutesCapability } from "../dist-CYl0u8cF.mjs";
1
+ import { $t as object, Bt as baseUnitOf, Gt as isReadingQuantity, I as consumableFormOf, Jt as array, Kt as nodePin, L as consumableKindOf, O as buildAddonRouteProvider, Rt as BaseAddon, U as deviceExportCapability, a as COCO_TO_MACRO, an as EventCategory, c as ConsumableItemSchema, d as FanDirectionSchema, f as HvacModeSchema, ht as runExportedAction, i as CAMERA_SWITCH_ORDER, l as ConsumableStatusSchema, m as PLUG_APPLIANCE_TYPES, o as CameraSwitchIdSchema, p as MediaPlayerRepeatSchema, pt as pruneDeletedEntities, rn as unknown, s as ConsumableFormSchema, st as oauthIntegrationCapability, t as AlarmArmModeSchema, tn as string, v as addonRoutesCapability, xt as sendToExportedTarget } from "../dist-Co6d8Y_P.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -557,6 +557,7 @@ var KNOWN_PLATFORMS = new Set([
557
557
  "camera",
558
558
  "alarm_control_panel",
559
559
  "number",
560
+ "notify",
560
561
  "cover",
561
562
  "climate",
562
563
  "lock",
@@ -3453,10 +3454,18 @@ function controlEntitySpecs(slice, quantity) {
3453
3454
  }, kindExtra];
3454
3455
  }
3455
3456
  var CAPS_NOT_EXPORTED = [
3457
+ {
3458
+ cap: "pet-fountain",
3459
+ reason: "A fountain is BUILT here from the loose entities Home Assistant already holds (a pump switch, two number boxes, a button), so exporting it back would publish a second copy of entities that already exist there. A dedicated mapping can follow when an ecosystem asks for the built device."
3460
+ },
3456
3461
  {
3457
3462
  cap: "ir-transmitter",
3458
3463
  reason: "A pulse-train emitter with no state: it is driven by the remote that owns a command table, and a raw send has no meaningful single entity."
3459
3464
  },
3465
+ {
3466
+ cap: "remote",
3467
+ reason: "A named command map sent through a transmitter. Its useful surface for an ecosystem is the `media-player` the same device provides (power, volume, source); the free-form command name has no typed entity. Its state (last command, last send time) is session-only bookkeeping."
3468
+ },
3460
3469
  {
3461
3470
  cap: "group-status",
3462
3471
  reason: "Bookkeeping for a helper group (D944): how many members were included, excluded, or named. The value an operator automates on is the output capability (the temperature, the motion, the watts). These counts are session-only, have no history, and would publish a second entity that never means the reading."
@@ -5648,7 +5657,8 @@ function renderCapValue(mapping, raw) {
5648
5657
  case "alarm_control_panel":
5649
5658
  case "image":
5650
5659
  case "camera": return typeof raw === "string" ? raw : null;
5651
- case "button": return null;
5660
+ case "button":
5661
+ case "notify": return null;
5652
5662
  case "cover":
5653
5663
  case "climate":
5654
5664
  case "lock":
@@ -6605,7 +6615,8 @@ function projectSynthetic(input, nowMs) {
6605
6615
  }, {
6606
6616
  topic: stateTopic(srv, `${slug}_cpu`),
6607
6617
  value: String(Math.round(node.cpuPercent))
6608
- }, {
6618
+ });
6619
+ if (node.memoryPercent !== null) values.push({
6609
6620
  topic: stateTopic(srv, `${slug}_memory`),
6610
6621
  value: String(Math.round(node.memoryPercent))
6611
6622
  });
@@ -6635,6 +6646,225 @@ function projectSynthetic(input, nowMs) {
6635
6646
  return values;
6636
6647
  }
6637
6648
  //#endregion
6649
+ //#region src/ha-export/exported-entities.ts
6650
+ /**
6651
+ * Actions and notification targets exported to Home Assistant (D968).
6652
+ *
6653
+ * An action is a `button`: pressing it runs the action through `actions.run`.
6654
+ * A notification target is a `notify` entity: a message sent to it goes out
6655
+ * through `notification-output.send` to that target. Both live on ONE
6656
+ * synthetic device — they are list entries, never camstack devices (D954), so
6657
+ * nothing here touches the device registry or the membership store.
6658
+ *
6659
+ * The selection is this addon's own setting. The class holds it, validates an
6660
+ * entry against the live lists, prunes what the operator deleted (a failed
6661
+ * read changes nothing, D49), and answers the commands Home Assistant sends
6662
+ * back. The addon supplies `persist` and `reconcile`, so the lifecycle is
6663
+ * testable without an Home Assistant.
6664
+ *
6665
+ * `notify` is a platform the custom component must BUILD (see
6666
+ * `component-support.ts`): against a component that does not, the targets are
6667
+ * withheld from the plan and the caller logs which ones, by name — an entity
6668
+ * announced to a component that drops it would read as "exported nothing".
6669
+ */
6670
+ /** The stable id of the synthetic device that carries them. A WIRE FORMAT. */
6671
+ var EXPORTED_ENTITIES_STABLE_ID = "synthetic-exported-entities";
6672
+ var EXPORTER = "homeassistant-export";
6673
+ /** `action_<slug>` — derived from the action ID, so a rename keeps its entity. */
6674
+ function actionEntity(actionId) {
6675
+ return `action_${toSlug(actionId)}`;
6676
+ }
6677
+ /** `notify_<slug>` — derived from the target ID. */
6678
+ function targetEntity(targetId) {
6679
+ return `notify_${toSlug(targetId)}`;
6680
+ }
6681
+ function errMsg$1(err) {
6682
+ return err instanceof Error ? err.message : String(err);
6683
+ }
6684
+ var ExportedEntities = class {
6685
+ ports;
6686
+ state;
6687
+ constructor(ports, initial) {
6688
+ this.ports = ports;
6689
+ this.state = initial;
6690
+ }
6691
+ /** The cap's `listExposedEntities` rows for actions and notifiers. */
6692
+ list() {
6693
+ return [...this.state.actions.map((e) => ({
6694
+ entity: {
6695
+ kind: "action",
6696
+ actionId: e.actionId
6697
+ },
6698
+ exposedAs: e.displayName
6699
+ })), ...this.state.targets.map((e) => ({
6700
+ entity: {
6701
+ kind: "notifier",
6702
+ targetId: e.targetId
6703
+ },
6704
+ exposedAs: e.displayName
6705
+ }))];
6706
+ }
6707
+ async expose(entity) {
6708
+ if (entity.kind === "device") throw new Error("homeassistant-export: a device is not an action or a notification target");
6709
+ if (entity.kind === "action") {
6710
+ const action = (await this.ports.api.actions.list.query({})).find((a) => a.id === entity.actionId);
6711
+ if (action === void 0) throw new Error(`homeassistant-export: action '${entity.actionId}' does not exist`);
6712
+ const previous = this.state.actions.find((e) => e.actionId === entity.actionId);
6713
+ const entry = {
6714
+ actionId: entity.actionId,
6715
+ displayName: action.name,
6716
+ addedAt: previous?.addedAt ?? Date.now()
6717
+ };
6718
+ await this.save({
6719
+ ...this.state,
6720
+ actions: [...this.state.actions.filter((e) => e.actionId !== entity.actionId), entry]
6721
+ });
6722
+ this.ports.logger.info("homeassistant-export: exposed action", {
6723
+ tags: { actionId: entity.actionId },
6724
+ meta: { displayName: action.name }
6725
+ });
6726
+ } else {
6727
+ const target = (await this.ports.api.notificationOutput.listTargets.query({})).find((t) => t.id === entity.targetId);
6728
+ if (target === void 0) throw new Error(`homeassistant-export: notification target '${entity.targetId}' does not exist`);
6729
+ const previous = this.state.targets.find((e) => e.targetId === entity.targetId);
6730
+ const entry = {
6731
+ targetId: entity.targetId,
6732
+ displayName: target.name,
6733
+ addedAt: previous?.addedAt ?? Date.now()
6734
+ };
6735
+ await this.save({
6736
+ ...this.state,
6737
+ targets: [...this.state.targets.filter((e) => e.targetId !== entity.targetId), entry]
6738
+ });
6739
+ this.ports.logger.info("homeassistant-export: exposed notification target", {
6740
+ tags: { targetId: entity.targetId },
6741
+ meta: { displayName: target.name }
6742
+ });
6743
+ }
6744
+ await this.ports.reconcile();
6745
+ }
6746
+ async unexpose(entity) {
6747
+ if (entity.kind === "device") throw new Error("homeassistant-export: a device is not an action or a notification target");
6748
+ const next = entity.kind === "action" ? {
6749
+ ...this.state,
6750
+ actions: this.state.actions.filter((e) => e.actionId !== entity.actionId)
6751
+ } : {
6752
+ ...this.state,
6753
+ targets: this.state.targets.filter((e) => e.targetId !== entity.targetId)
6754
+ };
6755
+ await this.save(next);
6756
+ await this.ports.reconcile();
6757
+ }
6758
+ /**
6759
+ * Drop what the operator deleted, logging each drop. A list that could not
6760
+ * be read changes nothing: a failed read must never destroy the selection.
6761
+ */
6762
+ async pruneDeleted() {
6763
+ if (this.state.actions.length === 0 && this.state.targets.length === 0) return;
6764
+ let actionIds;
6765
+ let targetIds;
6766
+ try {
6767
+ const [actions, targets] = await Promise.all([this.ports.api.actions.list.query({}), this.ports.api.notificationOutput.listTargets.query({})]);
6768
+ actionIds = new Set(actions.map((a) => a.id));
6769
+ targetIds = new Set(targets.map((t) => t.id));
6770
+ } catch (err) {
6771
+ this.ports.logger.warn("homeassistant-export: exported entities not reconciled — a list was unanswered", { meta: { error: errMsg$1(err) } });
6772
+ return;
6773
+ }
6774
+ const { dropped } = pruneDeletedEntities([...this.state.actions.map((e) => ({
6775
+ kind: "action",
6776
+ actionId: e.actionId
6777
+ })), ...this.state.targets.map((e) => ({
6778
+ kind: "notifier",
6779
+ targetId: e.targetId
6780
+ }))], {
6781
+ actionIds,
6782
+ targetIds
6783
+ }, this.ports.logger, EXPORTER);
6784
+ if (dropped.length === 0) return;
6785
+ const goneActions = new Set(dropped.flatMap((r) => r.kind === "action" ? [r.actionId] : []));
6786
+ const goneTargets = new Set(dropped.flatMap((r) => r.kind === "notifier" ? [r.targetId] : []));
6787
+ await this.save({
6788
+ actions: this.state.actions.filter((e) => !goneActions.has(e.actionId)),
6789
+ targets: this.state.targets.filter((e) => !goneTargets.has(e.targetId))
6790
+ });
6791
+ }
6792
+ /** The synthetic device the selection announces. Null when nothing is exported. */
6793
+ plan(support) {
6794
+ const deviceKey = syntheticDeviceKeyFor(EXPORTED_ENTITIES_STABLE_ID);
6795
+ const cmps = {};
6796
+ for (const e of this.state.actions) {
6797
+ const entity = actionEntity(e.actionId);
6798
+ cmps[toComponentKey("button", entity)] = {
6799
+ platform: "button",
6800
+ unique_id: `${EXPORTED_ENTITIES_STABLE_ID}_${entity}`,
6801
+ name: e.displayName,
6802
+ command_topic: commandTopic(deviceKey, entity),
6803
+ payload_press: "PRESS",
6804
+ icon: "mdi:play-circle-outline"
6805
+ };
6806
+ }
6807
+ const withheld = [];
6808
+ for (const e of this.state.targets) {
6809
+ if (!support.has("notify")) {
6810
+ withheld.push(e.targetId);
6811
+ continue;
6812
+ }
6813
+ const entity = targetEntity(e.targetId);
6814
+ cmps[toComponentKey("notify", entity)] = {
6815
+ platform: "notify",
6816
+ unique_id: `${EXPORTED_ENTITIES_STABLE_ID}_${entity}`,
6817
+ name: e.displayName,
6818
+ command_topic: commandTopic(deviceKey, entity),
6819
+ icon: "mdi:message-text-outline"
6820
+ };
6821
+ }
6822
+ if (Object.keys(cmps).length === 0) return {
6823
+ plan: null,
6824
+ withheld
6825
+ };
6826
+ return {
6827
+ plan: {
6828
+ deviceKey,
6829
+ deviceId: null,
6830
+ dev: {
6831
+ ids: [deviceKey],
6832
+ name: "CamStack Actions and Notifications",
6833
+ mf: "CamStack",
6834
+ mdl: "Synthetic"
6835
+ },
6836
+ cmps
6837
+ },
6838
+ withheld
6839
+ };
6840
+ }
6841
+ /** Answer a command Home Assistant sent for this device. */
6842
+ async handleCommand(entity, value) {
6843
+ const action = this.state.actions.find((e) => actionEntity(e.actionId) === entity);
6844
+ if (action !== void 0) return {
6845
+ handled: true,
6846
+ applied: (await runExportedAction(this.ports.api, this.ports.logger, {
6847
+ exporter: EXPORTER,
6848
+ actionId: action.actionId
6849
+ })).status === "started"
6850
+ };
6851
+ const target = this.state.targets.find((e) => targetEntity(e.targetId) === entity);
6852
+ if (target !== void 0) return {
6853
+ handled: true,
6854
+ applied: (await sendToExportedTarget(this.ports.api, this.ports.logger, {
6855
+ exporter: EXPORTER,
6856
+ targetId: target.targetId,
6857
+ notification: { body: value }
6858
+ })).status === "sent"
6859
+ };
6860
+ return { handled: false };
6861
+ }
6862
+ async save(next) {
6863
+ this.state = next;
6864
+ await this.ports.persist(next);
6865
+ }
6866
+ };
6867
+ //#endregion
6638
6868
  //#region src/ha-export/supported-device-kinds.ts
6639
6869
  /**
6640
6870
  * Device types the Home Assistant export picker offers.
@@ -6739,6 +6969,10 @@ var DEFAULT_CONFIG = {
6739
6969
  reconcileIntervalSec: 300,
6740
6970
  membership: {},
6741
6971
  knownBrokers: [],
6972
+ exportedEntities: {
6973
+ actions: [],
6974
+ targets: []
6975
+ },
6742
6976
  publicBaseUrl: ""
6743
6977
  };
6744
6978
  var HaExportAddon = class extends BaseAddon {
@@ -6810,6 +7044,8 @@ var HaExportAddon = class extends BaseAddon {
6810
7044
  * whenever the synthetic devices are, never written to.
6811
7045
  */
6812
7046
  syntheticRules = [];
7047
+ /** D968: the actions and notification targets exported beside the devices. */
7048
+ exportedEntities = null;
6813
7049
  /**
6814
7050
  * `pipelineOrchestrator.getCameraStatuses`, keyed by deviceId — ONE call
6815
7051
  * per reconcile pass for every exported camera (Task C3), never per
@@ -6870,6 +7106,13 @@ var HaExportAddon = class extends BaseAddon {
6870
7106
  for (const link of this.links.values()) await link.client.dispose();
6871
7107
  this.links.clear();
6872
7108
  });
7109
+ const exportedEntities = new ExportedEntities({
7110
+ api: this.ctx.api,
7111
+ logger: this.ctx.logger,
7112
+ persist: (next) => this.updateGlobalSettings({ exportedEntities: next }),
7113
+ reconcile: () => this.reconcile("exported-entities-changed")
7114
+ }, this.config.exportedEntities ?? DEFAULT_CONFIG.exportedEntities);
7115
+ this.exportedEntities = exportedEntities;
6873
7116
  return [
6874
7117
  {
6875
7118
  capability: deviceExportCapability,
@@ -6882,6 +7125,23 @@ var HaExportAddon = class extends BaseAddon {
6882
7125
  })),
6883
7126
  exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
6884
7127
  unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
7128
+ listSupportedEntityKinds: async () => [
7129
+ "device",
7130
+ "action",
7131
+ "notifier"
7132
+ ],
7133
+ listExposedEntities: async () => {
7134
+ await exportedEntities.pruneDeleted();
7135
+ return [...exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
7136
+ entity: {
7137
+ kind: "device",
7138
+ deviceId
7139
+ },
7140
+ exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
7141
+ })), ...exportedEntities.list()];
7142
+ },
7143
+ exposeEntity: async ({ entity }) => entity.kind === "device" ? this.setMembershipEverywhere(entity.deviceId, true) : exportedEntities.expose(entity),
7144
+ unexposeEntity: async ({ entity }) => entity.kind === "device" ? this.setMembershipEverywhere(entity.deviceId, false) : exportedEntities.unexpose(entity),
6885
7145
  getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
6886
7146
  getDeviceLiveContribution: async () => null,
6887
7147
  applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
@@ -7519,6 +7779,32 @@ var HaExportAddon = class extends BaseAddon {
7519
7779
  * `brokerIds` is every enabled broker, because they belong to the server
7520
7780
  * and not to a membership the operator picks per camera.
7521
7781
  */
7782
+ /**
7783
+ * The exported actions and notification targets (D968): one more
7784
+ * synthetic device. What the operator deleted is dropped first (a failed
7785
+ * read changes nothing); a target the component cannot build as `notify`
7786
+ * is named, not silently lost.
7787
+ */
7788
+ if (this.exportedEntities !== null) {
7789
+ await this.exportedEntities.pruneDeleted();
7790
+ const { plan: entitiesPlan, withheld } = this.exportedEntities.plan(this.componentSupport.supportForAll([...enabled]));
7791
+ if (withheld.length > 0) this.ctx.logger.warn("ha-export: notification targets withheld — the Home Assistant component does not build notify entities yet", { meta: { targetIds: withheld } });
7792
+ if (entitiesPlan !== null) {
7793
+ exported.set(entitiesPlan.deviceKey, {
7794
+ plan: entitiesPlan,
7795
+ deviceId: -1,
7796
+ target: {
7797
+ deviceId: -1,
7798
+ type: "synthetic",
7799
+ boundCaps: []
7800
+ },
7801
+ brokerIds: [...enabled],
7802
+ streams: [],
7803
+ cameraFunctions: null
7804
+ });
7805
+ entityCount += Object.keys(entitiesPlan.cmps).length;
7806
+ }
7807
+ }
7522
7808
  const synthetic = await this.buildSynthetic(byId);
7523
7809
  if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
7524
7810
  exported.set(plan.deviceKey, {
@@ -8541,6 +8827,21 @@ var HaExportAddon = class extends BaseAddon {
8541
8827
  * `setRuleEnabled`, never a store of its own. A second knob that can
8542
8828
  * disagree with the admin UI is worse than no knob (D62).
8543
8829
  */
8830
+ if (this.exportedEntities !== null && parsed.deviceKey === syntheticDeviceKeyFor("synthetic-exported-entities")) {
8831
+ const outcome = await this.exportedEntities.handleCommand(parsed.entity, value);
8832
+ if (!outcome.handled) {
8833
+ this.ctx.logger.warn("ha-export: dropping a command for an entity that is not exported", { meta: {
8834
+ topic,
8835
+ entity: parsed.entity
8836
+ } });
8837
+ reply.status(422);
8838
+ reply.send({ error: "entity not exported" });
8839
+ return;
8840
+ }
8841
+ reply.status(outcome.applied ? 200 : 422);
8842
+ reply.send(outcome.applied ? {} : { error: "command not applied" });
8843
+ return;
8844
+ }
8544
8845
  if (isSyntheticDeviceKey(parsed.deviceKey)) {
8545
8846
  const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
8546
8847
  if (ruleId === null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.635",
3
+ "version": "1.2.637",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",