@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.
@@ -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_dist = require("../dist-C_PMvyYa.js");
27
+ const require_dist = require("../dist-DYCWN6ge.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -585,6 +585,7 @@ var KNOWN_PLATFORMS = new Set([
585
585
  "camera",
586
586
  "alarm_control_panel",
587
587
  "number",
588
+ "notify",
588
589
  "cover",
589
590
  "climate",
590
591
  "lock",
@@ -3488,10 +3489,18 @@ function controlEntitySpecs(slice, quantity) {
3488
3489
  }, kindExtra];
3489
3490
  }
3490
3491
  var CAPS_NOT_EXPORTED = [
3492
+ {
3493
+ cap: "pet-fountain",
3494
+ 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."
3495
+ },
3491
3496
  {
3492
3497
  cap: "ir-transmitter",
3493
3498
  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."
3494
3499
  },
3500
+ {
3501
+ cap: "remote",
3502
+ 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."
3503
+ },
3495
3504
  {
3496
3505
  cap: "group-status",
3497
3506
  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."
@@ -5683,7 +5692,8 @@ function renderCapValue(mapping, raw) {
5683
5692
  case "alarm_control_panel":
5684
5693
  case "image":
5685
5694
  case "camera": return typeof raw === "string" ? raw : null;
5686
- case "button": return null;
5695
+ case "button":
5696
+ case "notify": return null;
5687
5697
  case "cover":
5688
5698
  case "climate":
5689
5699
  case "lock":
@@ -6640,7 +6650,8 @@ function projectSynthetic(input, nowMs) {
6640
6650
  }, {
6641
6651
  topic: stateTopic(srv, `${slug}_cpu`),
6642
6652
  value: String(Math.round(node.cpuPercent))
6643
- }, {
6653
+ });
6654
+ if (node.memoryPercent !== null) values.push({
6644
6655
  topic: stateTopic(srv, `${slug}_memory`),
6645
6656
  value: String(Math.round(node.memoryPercent))
6646
6657
  });
@@ -6670,6 +6681,225 @@ function projectSynthetic(input, nowMs) {
6670
6681
  return values;
6671
6682
  }
6672
6683
  //#endregion
6684
+ //#region src/ha-export/exported-entities.ts
6685
+ /**
6686
+ * Actions and notification targets exported to Home Assistant (D968).
6687
+ *
6688
+ * An action is a `button`: pressing it runs the action through `actions.run`.
6689
+ * A notification target is a `notify` entity: a message sent to it goes out
6690
+ * through `notification-output.send` to that target. Both live on ONE
6691
+ * synthetic device — they are list entries, never camstack devices (D954), so
6692
+ * nothing here touches the device registry or the membership store.
6693
+ *
6694
+ * The selection is this addon's own setting. The class holds it, validates an
6695
+ * entry against the live lists, prunes what the operator deleted (a failed
6696
+ * read changes nothing, D49), and answers the commands Home Assistant sends
6697
+ * back. The addon supplies `persist` and `reconcile`, so the lifecycle is
6698
+ * testable without an Home Assistant.
6699
+ *
6700
+ * `notify` is a platform the custom component must BUILD (see
6701
+ * `component-support.ts`): against a component that does not, the targets are
6702
+ * withheld from the plan and the caller logs which ones, by name — an entity
6703
+ * announced to a component that drops it would read as "exported nothing".
6704
+ */
6705
+ /** The stable id of the synthetic device that carries them. A WIRE FORMAT. */
6706
+ var EXPORTED_ENTITIES_STABLE_ID = "synthetic-exported-entities";
6707
+ var EXPORTER = "homeassistant-export";
6708
+ /** `action_<slug>` — derived from the action ID, so a rename keeps its entity. */
6709
+ function actionEntity(actionId) {
6710
+ return `action_${toSlug(actionId)}`;
6711
+ }
6712
+ /** `notify_<slug>` — derived from the target ID. */
6713
+ function targetEntity(targetId) {
6714
+ return `notify_${toSlug(targetId)}`;
6715
+ }
6716
+ function errMsg$1(err) {
6717
+ return err instanceof Error ? err.message : String(err);
6718
+ }
6719
+ var ExportedEntities = class {
6720
+ ports;
6721
+ state;
6722
+ constructor(ports, initial) {
6723
+ this.ports = ports;
6724
+ this.state = initial;
6725
+ }
6726
+ /** The cap's `listExposedEntities` rows for actions and notifiers. */
6727
+ list() {
6728
+ return [...this.state.actions.map((e) => ({
6729
+ entity: {
6730
+ kind: "action",
6731
+ actionId: e.actionId
6732
+ },
6733
+ exposedAs: e.displayName
6734
+ })), ...this.state.targets.map((e) => ({
6735
+ entity: {
6736
+ kind: "notifier",
6737
+ targetId: e.targetId
6738
+ },
6739
+ exposedAs: e.displayName
6740
+ }))];
6741
+ }
6742
+ async expose(entity) {
6743
+ if (entity.kind === "device") throw new Error("homeassistant-export: a device is not an action or a notification target");
6744
+ if (entity.kind === "action") {
6745
+ const action = (await this.ports.api.actions.list.query({})).find((a) => a.id === entity.actionId);
6746
+ if (action === void 0) throw new Error(`homeassistant-export: action '${entity.actionId}' does not exist`);
6747
+ const previous = this.state.actions.find((e) => e.actionId === entity.actionId);
6748
+ const entry = {
6749
+ actionId: entity.actionId,
6750
+ displayName: action.name,
6751
+ addedAt: previous?.addedAt ?? Date.now()
6752
+ };
6753
+ await this.save({
6754
+ ...this.state,
6755
+ actions: [...this.state.actions.filter((e) => e.actionId !== entity.actionId), entry]
6756
+ });
6757
+ this.ports.logger.info("homeassistant-export: exposed action", {
6758
+ tags: { actionId: entity.actionId },
6759
+ meta: { displayName: action.name }
6760
+ });
6761
+ } else {
6762
+ const target = (await this.ports.api.notificationOutput.listTargets.query({})).find((t) => t.id === entity.targetId);
6763
+ if (target === void 0) throw new Error(`homeassistant-export: notification target '${entity.targetId}' does not exist`);
6764
+ const previous = this.state.targets.find((e) => e.targetId === entity.targetId);
6765
+ const entry = {
6766
+ targetId: entity.targetId,
6767
+ displayName: target.name,
6768
+ addedAt: previous?.addedAt ?? Date.now()
6769
+ };
6770
+ await this.save({
6771
+ ...this.state,
6772
+ targets: [...this.state.targets.filter((e) => e.targetId !== entity.targetId), entry]
6773
+ });
6774
+ this.ports.logger.info("homeassistant-export: exposed notification target", {
6775
+ tags: { targetId: entity.targetId },
6776
+ meta: { displayName: target.name }
6777
+ });
6778
+ }
6779
+ await this.ports.reconcile();
6780
+ }
6781
+ async unexpose(entity) {
6782
+ if (entity.kind === "device") throw new Error("homeassistant-export: a device is not an action or a notification target");
6783
+ const next = entity.kind === "action" ? {
6784
+ ...this.state,
6785
+ actions: this.state.actions.filter((e) => e.actionId !== entity.actionId)
6786
+ } : {
6787
+ ...this.state,
6788
+ targets: this.state.targets.filter((e) => e.targetId !== entity.targetId)
6789
+ };
6790
+ await this.save(next);
6791
+ await this.ports.reconcile();
6792
+ }
6793
+ /**
6794
+ * Drop what the operator deleted, logging each drop. A list that could not
6795
+ * be read changes nothing: a failed read must never destroy the selection.
6796
+ */
6797
+ async pruneDeleted() {
6798
+ if (this.state.actions.length === 0 && this.state.targets.length === 0) return;
6799
+ let actionIds;
6800
+ let targetIds;
6801
+ try {
6802
+ const [actions, targets] = await Promise.all([this.ports.api.actions.list.query({}), this.ports.api.notificationOutput.listTargets.query({})]);
6803
+ actionIds = new Set(actions.map((a) => a.id));
6804
+ targetIds = new Set(targets.map((t) => t.id));
6805
+ } catch (err) {
6806
+ this.ports.logger.warn("homeassistant-export: exported entities not reconciled — a list was unanswered", { meta: { error: errMsg$1(err) } });
6807
+ return;
6808
+ }
6809
+ const { dropped } = require_dist.pruneDeletedEntities([...this.state.actions.map((e) => ({
6810
+ kind: "action",
6811
+ actionId: e.actionId
6812
+ })), ...this.state.targets.map((e) => ({
6813
+ kind: "notifier",
6814
+ targetId: e.targetId
6815
+ }))], {
6816
+ actionIds,
6817
+ targetIds
6818
+ }, this.ports.logger, EXPORTER);
6819
+ if (dropped.length === 0) return;
6820
+ const goneActions = new Set(dropped.flatMap((r) => r.kind === "action" ? [r.actionId] : []));
6821
+ const goneTargets = new Set(dropped.flatMap((r) => r.kind === "notifier" ? [r.targetId] : []));
6822
+ await this.save({
6823
+ actions: this.state.actions.filter((e) => !goneActions.has(e.actionId)),
6824
+ targets: this.state.targets.filter((e) => !goneTargets.has(e.targetId))
6825
+ });
6826
+ }
6827
+ /** The synthetic device the selection announces. Null when nothing is exported. */
6828
+ plan(support) {
6829
+ const deviceKey = syntheticDeviceKeyFor(EXPORTED_ENTITIES_STABLE_ID);
6830
+ const cmps = {};
6831
+ for (const e of this.state.actions) {
6832
+ const entity = actionEntity(e.actionId);
6833
+ cmps[toComponentKey("button", entity)] = {
6834
+ platform: "button",
6835
+ unique_id: `${EXPORTED_ENTITIES_STABLE_ID}_${entity}`,
6836
+ name: e.displayName,
6837
+ command_topic: commandTopic(deviceKey, entity),
6838
+ payload_press: "PRESS",
6839
+ icon: "mdi:play-circle-outline"
6840
+ };
6841
+ }
6842
+ const withheld = [];
6843
+ for (const e of this.state.targets) {
6844
+ if (!support.has("notify")) {
6845
+ withheld.push(e.targetId);
6846
+ continue;
6847
+ }
6848
+ const entity = targetEntity(e.targetId);
6849
+ cmps[toComponentKey("notify", entity)] = {
6850
+ platform: "notify",
6851
+ unique_id: `${EXPORTED_ENTITIES_STABLE_ID}_${entity}`,
6852
+ name: e.displayName,
6853
+ command_topic: commandTopic(deviceKey, entity),
6854
+ icon: "mdi:message-text-outline"
6855
+ };
6856
+ }
6857
+ if (Object.keys(cmps).length === 0) return {
6858
+ plan: null,
6859
+ withheld
6860
+ };
6861
+ return {
6862
+ plan: {
6863
+ deviceKey,
6864
+ deviceId: null,
6865
+ dev: {
6866
+ ids: [deviceKey],
6867
+ name: "CamStack Actions and Notifications",
6868
+ mf: "CamStack",
6869
+ mdl: "Synthetic"
6870
+ },
6871
+ cmps
6872
+ },
6873
+ withheld
6874
+ };
6875
+ }
6876
+ /** Answer a command Home Assistant sent for this device. */
6877
+ async handleCommand(entity, value) {
6878
+ const action = this.state.actions.find((e) => actionEntity(e.actionId) === entity);
6879
+ if (action !== void 0) return {
6880
+ handled: true,
6881
+ applied: (await require_dist.runExportedAction(this.ports.api, this.ports.logger, {
6882
+ exporter: EXPORTER,
6883
+ actionId: action.actionId
6884
+ })).status === "started"
6885
+ };
6886
+ const target = this.state.targets.find((e) => targetEntity(e.targetId) === entity);
6887
+ if (target !== void 0) return {
6888
+ handled: true,
6889
+ applied: (await require_dist.sendToExportedTarget(this.ports.api, this.ports.logger, {
6890
+ exporter: EXPORTER,
6891
+ targetId: target.targetId,
6892
+ notification: { body: value }
6893
+ })).status === "sent"
6894
+ };
6895
+ return { handled: false };
6896
+ }
6897
+ async save(next) {
6898
+ this.state = next;
6899
+ await this.ports.persist(next);
6900
+ }
6901
+ };
6902
+ //#endregion
6673
6903
  //#region src/ha-export/supported-device-kinds.ts
6674
6904
  /**
6675
6905
  * Device types the Home Assistant export picker offers.
@@ -6774,6 +7004,10 @@ var DEFAULT_CONFIG = {
6774
7004
  reconcileIntervalSec: 300,
6775
7005
  membership: {},
6776
7006
  knownBrokers: [],
7007
+ exportedEntities: {
7008
+ actions: [],
7009
+ targets: []
7010
+ },
6777
7011
  publicBaseUrl: ""
6778
7012
  };
6779
7013
  var HaExportAddon = class extends require_dist.BaseAddon {
@@ -6845,6 +7079,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6845
7079
  * whenever the synthetic devices are, never written to.
6846
7080
  */
6847
7081
  syntheticRules = [];
7082
+ /** D968: the actions and notification targets exported beside the devices. */
7083
+ exportedEntities = null;
6848
7084
  /**
6849
7085
  * `pipelineOrchestrator.getCameraStatuses`, keyed by deviceId — ONE call
6850
7086
  * per reconcile pass for every exported camera (Task C3), never per
@@ -6905,6 +7141,13 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6905
7141
  for (const link of this.links.values()) await link.client.dispose();
6906
7142
  this.links.clear();
6907
7143
  });
7144
+ const exportedEntities = new ExportedEntities({
7145
+ api: this.ctx.api,
7146
+ logger: this.ctx.logger,
7147
+ persist: (next) => this.updateGlobalSettings({ exportedEntities: next }),
7148
+ reconcile: () => this.reconcile("exported-entities-changed")
7149
+ }, this.config.exportedEntities ?? DEFAULT_CONFIG.exportedEntities);
7150
+ this.exportedEntities = exportedEntities;
6908
7151
  return [
6909
7152
  {
6910
7153
  capability: require_dist.deviceExportCapability,
@@ -6917,6 +7160,23 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6917
7160
  })),
6918
7161
  exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
6919
7162
  unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
7163
+ listSupportedEntityKinds: async () => [
7164
+ "device",
7165
+ "action",
7166
+ "notifier"
7167
+ ],
7168
+ listExposedEntities: async () => {
7169
+ await exportedEntities.pruneDeleted();
7170
+ return [...exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
7171
+ entity: {
7172
+ kind: "device",
7173
+ deviceId
7174
+ },
7175
+ exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
7176
+ })), ...exportedEntities.list()];
7177
+ },
7178
+ exposeEntity: async ({ entity }) => entity.kind === "device" ? this.setMembershipEverywhere(entity.deviceId, true) : exportedEntities.expose(entity),
7179
+ unexposeEntity: async ({ entity }) => entity.kind === "device" ? this.setMembershipEverywhere(entity.deviceId, false) : exportedEntities.unexpose(entity),
6920
7180
  getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
6921
7181
  getDeviceLiveContribution: async () => null,
6922
7182
  applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
@@ -7554,6 +7814,32 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7554
7814
  * `brokerIds` is every enabled broker, because they belong to the server
7555
7815
  * and not to a membership the operator picks per camera.
7556
7816
  */
7817
+ /**
7818
+ * The exported actions and notification targets (D968): one more
7819
+ * synthetic device. What the operator deleted is dropped first (a failed
7820
+ * read changes nothing); a target the component cannot build as `notify`
7821
+ * is named, not silently lost.
7822
+ */
7823
+ if (this.exportedEntities !== null) {
7824
+ await this.exportedEntities.pruneDeleted();
7825
+ const { plan: entitiesPlan, withheld } = this.exportedEntities.plan(this.componentSupport.supportForAll([...enabled]));
7826
+ 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 } });
7827
+ if (entitiesPlan !== null) {
7828
+ exported.set(entitiesPlan.deviceKey, {
7829
+ plan: entitiesPlan,
7830
+ deviceId: -1,
7831
+ target: {
7832
+ deviceId: -1,
7833
+ type: "synthetic",
7834
+ boundCaps: []
7835
+ },
7836
+ brokerIds: [...enabled],
7837
+ streams: [],
7838
+ cameraFunctions: null
7839
+ });
7840
+ entityCount += Object.keys(entitiesPlan.cmps).length;
7841
+ }
7842
+ }
7557
7843
  const synthetic = await this.buildSynthetic(byId);
7558
7844
  if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
7559
7845
  exported.set(plan.deviceKey, {
@@ -8576,6 +8862,21 @@ var HaExportAddon = class extends require_dist.BaseAddon {
8576
8862
  * `setRuleEnabled`, never a store of its own. A second knob that can
8577
8863
  * disagree with the admin UI is worse than no knob (D62).
8578
8864
  */
8865
+ if (this.exportedEntities !== null && parsed.deviceKey === syntheticDeviceKeyFor("synthetic-exported-entities")) {
8866
+ const outcome = await this.exportedEntities.handleCommand(parsed.entity, value);
8867
+ if (!outcome.handled) {
8868
+ this.ctx.logger.warn("ha-export: dropping a command for an entity that is not exported", { meta: {
8869
+ topic,
8870
+ entity: parsed.entity
8871
+ } });
8872
+ reply.status(422);
8873
+ reply.send({ error: "entity not exported" });
8874
+ return;
8875
+ }
8876
+ reply.status(outcome.applied ? 200 : 422);
8877
+ reply.send(outcome.applied ? {} : { error: "command not applied" });
8878
+ return;
8879
+ }
8579
8880
  if (isSyntheticDeviceKey(parsed.deviceKey)) {
8580
8881
  const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
8581
8882
  if (ruleId === null) {