@camstack/addon-provider-homeassistant 1.2.617 → 1.2.619

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 { $ as oauthIntegrationCapability, C as buildAddonRouteProvider, Et as nodePin, Ft as string, I as deviceExportCapability, a as COCO_TO_MACRO, bt as BaseAddon, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema, zt as EventCategory } from "../dist-D6huwmAr.mjs";
1
+ import { Bt as string, E as buildAddonRouteProvider, F as consumableKindOf, Ht as unknown, Nt as array, P as consumableFormOf, Rt as object, Tt as BaseAddon, V as deviceExportCapability, Wt as EventCategory, a as COCO_TO_MACRO, c as ConsumableItemSchema, d as FanDirectionSchema, f as HvacModeSchema, g as addonRoutesCapability, i as CAMERA_SWITCH_ORDER, it as oauthIntegrationCapability, jt as nodePin, l as ConsumableStatusSchema, o as CameraSwitchIdSchema, p as MediaPlayerRepeatSchema, s as ConsumableFormSchema, t as AlarmArmModeSchema } from "../dist-fqJMr4fu.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -671,6 +671,227 @@ var ComponentSupportTracker = class {
671
671
  }
672
672
  };
673
673
  //#endregion
674
+ //#region src/ha-export/item-entities.ts
675
+ /**
676
+ * Per-item entities — the reconcile-time mint `CAP_ENTITY_MAP` cannot express.
677
+ *
678
+ * `CAP_ENTITY_MAP` is static per capability: one row, a fixed set of
679
+ * entities, each reading one flat field. A capability whose value is an
680
+ * ARRAY of provider-named items (`consumables.items`: a brush, a filter, a
681
+ * clean-water tank, a dishwasher's salt) has a different entity set on every
682
+ * device, and the set changes when the provider reports a new item. This
683
+ * module mints that set from the device's OWN slice, pure:
684
+ *
685
+ * slice → `ItemEntity[]` (structure, announced by the catalog)
686
+ * slice → `StateValue[]` (values, pushed by the addon)
687
+ *
688
+ * Both halves come from the same function per capability, so an entity can
689
+ * never be announced under one id and fed under another.
690
+ *
691
+ * The STRUCTURE goes through a `ReadMirror` in the addon before it reaches
692
+ * the plan: an item that appears is announced at once, an item that leaves
693
+ * is removed only when two consecutive reads agree (D49) — a removal deletes
694
+ * the entity and its recorder history in Home Assistant.
695
+ *
696
+ * A capability listed here is CLASSIFIED for the export-coverage guard
697
+ * (`scripts/check-export-coverage.ts`), which checks `itemsField` against the
698
+ * capability's status schema exactly as it checks a `CAP_ENTITY_MAP` field.
699
+ */
700
+ var CONSUMABLE_PREFIX = "consumable";
701
+ /** Outside `ConsumableStatusSchema` on purpose: the component reads it as unknown. */
702
+ var STATUS_UNKNOWN = "unknown";
703
+ function consumableEntity(key, facet) {
704
+ return `${CONSUMABLE_PREFIX}_${toSlug(key)}_${facet}`;
705
+ }
706
+ /** Home Assistant's own duration units; a cycle count is a plain count. */
707
+ var REMAINING_UNIT = {
708
+ days: "d",
709
+ hours: "h",
710
+ cycles: "cycles"
711
+ };
712
+ /**
713
+ * The item's form (the capability's own derivation, `consumableFormOf`): the one
714
+ * the provider declared when it is a form the capability knows, else the one its
715
+ * kind implies. An unknown spelling is not trusted — it falls back to the kind
716
+ * rather than to an icon for nothing.
717
+ */
718
+ function consumableForm(item) {
719
+ const declared = ConsumableFormSchema.safeParse(item.form);
720
+ return consumableFormOf({
721
+ ...item.kind !== void 0 ? { kind: item.kind } : {},
722
+ ...declared.success ? { form: declared.data } : {}
723
+ });
724
+ }
725
+ /** A part is drawn by what it is when its key or label says so. */
726
+ function partIcon(key, label) {
727
+ if (`${key} ${label}`.toLowerCase().includes("brush")) return "mdi:brush";
728
+ return "mdi:air-filter";
729
+ }
730
+ function formIcon(item, form) {
731
+ switch (form) {
732
+ case "part": return partIcon(item.key, item.label);
733
+ case "tank": return consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
734
+ case "product": return "mdi:shaker-outline";
735
+ case "bin": return "mdi:delete";
736
+ }
737
+ }
738
+ var ExportConsumablesSchema = object({ items: array(ConsumableItemSchema.extend({ form: unknown().optional() })) });
739
+ /**
740
+ * A worn part's life is a diagnostic; a tank's fill is what the device page
741
+ * is for (a vacuum's clean water, a dishwasher's salt), so it is not hidden.
742
+ */
743
+ function levelCategory(kind) {
744
+ return kind === "wear" ? "diagnostic" : void 0;
745
+ }
746
+ function consumableEntities(item) {
747
+ const base = {
748
+ capName: "consumables",
749
+ itemKey: item.key
750
+ };
751
+ const kind = consumableKindOf(item);
752
+ const form = consumableForm(item);
753
+ const icon = formIcon(item, form);
754
+ /** What the item IS, on the sensors that describe it (not on a counter or a button). */
755
+ const attributes = {
756
+ form,
757
+ kind,
758
+ label: item.label
759
+ };
760
+ const out = [];
761
+ const unique = (facet) => `${CONSUMABLE_PREFIX}_${item.key}_${facet}`;
762
+ if (item.level !== null) {
763
+ const category = levelCategory(kind);
764
+ out.push({
765
+ ...base,
766
+ facet: "level",
767
+ entity: consumableEntity(item.key, "level"),
768
+ uniqueSuffix: unique("level"),
769
+ platform: "sensor",
770
+ label: item.label,
771
+ unit: "%",
772
+ stateClass: "measurement",
773
+ icon,
774
+ attributes,
775
+ ...category !== void 0 ? { entityCategory: category } : {}
776
+ });
777
+ }
778
+ if (item.status !== null)
779
+ /**
780
+ * An `enum` sensor over the cap's own vocabulary — the options come from
781
+ * `ConsumableStatusSchema`, never a second list here.
782
+ */
783
+ out.push({
784
+ ...base,
785
+ facet: "status",
786
+ entity: consumableEntity(item.key, "status"),
787
+ uniqueSuffix: unique("status"),
788
+ platform: "sensor",
789
+ label: `${item.label} status`,
790
+ deviceClass: "enum",
791
+ options: ConsumableStatusSchema.options,
792
+ icon,
793
+ attributes,
794
+ entityCategory: "diagnostic"
795
+ });
796
+ if (item.remaining !== void 0) {
797
+ const isDuration = item.remaining.unit !== "cycles";
798
+ out.push({
799
+ ...base,
800
+ facet: "remaining",
801
+ entity: consumableEntity(item.key, "remaining"),
802
+ uniqueSuffix: unique("remaining"),
803
+ platform: "sensor",
804
+ label: `${item.label} remaining`,
805
+ unit: REMAINING_UNIT[item.remaining.unit],
806
+ stateClass: "measurement",
807
+ ...isDuration ? { deviceClass: "duration" } : {},
808
+ icon: isDuration ? "mdi:timer-sand" : "mdi:counter",
809
+ entityCategory: "diagnostic"
810
+ });
811
+ }
812
+ if (item.resettable) out.push({
813
+ ...base,
814
+ facet: "reset",
815
+ entity: consumableEntity(item.key, "reset"),
816
+ uniqueSuffix: unique("reset"),
817
+ platform: "button",
818
+ label: `Reset ${item.label}`,
819
+ icon: "mdi:restore",
820
+ entityCategory: "config"
821
+ });
822
+ return out;
823
+ }
824
+ function consumableValues(item) {
825
+ const out = [];
826
+ if (item.level !== null) out.push({
827
+ entity: consumableEntity(item.key, "level"),
828
+ value: String(item.level)
829
+ });
830
+ /**
831
+ * A null verdict is published OUTSIDE the enum, which the component reads
832
+ * as `unknown` — publishing nothing would leave the last verdict standing
833
+ * (D393: unknown is never the previous value).
834
+ */
835
+ out.push({
836
+ entity: consumableEntity(item.key, "status"),
837
+ value: item.status ?? STATUS_UNKNOWN
838
+ });
839
+ if (item.remaining !== void 0 && item.remaining.value !== null) out.push({
840
+ entity: consumableEntity(item.key, "remaining"),
841
+ value: String(item.remaining.value)
842
+ });
843
+ return out;
844
+ }
845
+ function parseConsumables(slice) {
846
+ const parsed = ExportConsumablesSchema.safeParse(slice);
847
+ return parsed.success ? parsed.data.items : null;
848
+ }
849
+ /**
850
+ * Two items whose keys slug alike would claim one entity id. The first
851
+ * keeps it; the second is left out rather than announced twice under one
852
+ * component key, where Home Assistant would show either value at random.
853
+ */
854
+ function firstPerEntity(rows) {
855
+ const seen = /* @__PURE__ */ new Set();
856
+ return rows.filter((row) => {
857
+ if (seen.has(row.entity)) return false;
858
+ seen.add(row.entity);
859
+ return true;
860
+ });
861
+ }
862
+ /** capability → its per-item mint. */
863
+ var ITEM_ENTITY_MAP = { consumables: {
864
+ itemsField: "items",
865
+ entities: (slice) => {
866
+ const items = parseConsumables(slice);
867
+ return items === null ? null : firstPerEntity(items.flatMap(consumableEntities));
868
+ },
869
+ values: (slice) => {
870
+ const items = parseConsumables(slice);
871
+ return items === null ? null : firstPerEntity(items.flatMap(consumableValues));
872
+ }
873
+ } };
874
+ /** The entities one capability's slice mints, or `null` for a malformed slice. */
875
+ function itemEntitiesFor(capName, slice) {
876
+ const mapping = ITEM_ENTITY_MAP[capName];
877
+ return mapping === void 0 ? null : mapping.entities(slice);
878
+ }
879
+ /** The values one capability's slice carries, on their state topics. */
880
+ function projectItemSlice(deviceKey, capName, slice) {
881
+ const mapping = ITEM_ENTITY_MAP[capName];
882
+ if (mapping === void 0) return null;
883
+ const values = mapping.values(slice);
884
+ if (values === null) return null;
885
+ return values.map((v) => ({
886
+ topic: stateTopic(deviceKey, v.entity),
887
+ value: v.value
888
+ }));
889
+ }
890
+ /** True for a capability whose entities are minted per item. */
891
+ function isItemEntityCap(capName) {
892
+ return Object.prototype.hasOwnProperty.call(ITEM_ENTITY_MAP, capName);
893
+ }
894
+ //#endregion
674
895
  //#region src/ha-export/native-platforms.ts
675
896
  /**
676
897
  * The NATIVE half of the Home Assistant export.
@@ -1368,6 +1589,8 @@ function buildComponent(device, spec) {
1368
1589
  ...spec.writable === true && COMMANDABLE_PLATFORMS.has(spec.platform) ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
1369
1590
  ...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
1370
1591
  ...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
1592
+ ...spec.stateClass !== void 0 ? { state_class: spec.stateClass } : {},
1593
+ ...spec.attributes !== void 0 ? { attributes: spec.attributes } : {},
1371
1594
  ...spec.icon !== void 0 ? { icon: spec.icon } : {},
1372
1595
  ...spec.options !== void 0 ? { options: spec.options } : {},
1373
1596
  ...spec.range !== void 0 ? {
@@ -1797,9 +2020,36 @@ function assemble(device, specs) {
1797
2020
  cmps
1798
2021
  };
1799
2022
  }
1800
- /** The hand-written half: a camera's entities come from analytics. */
2023
+ /**
2024
+ * The per-item entities of every per-item capability the device binds
2025
+ * (`item-entities.ts`), as the reconcile settled them. A button is a
2026
+ * command, so it is the one writable kind.
2027
+ */
2028
+ function itemEntitySpecs(device, capName) {
2029
+ return (device.itemEntities?.[capName] ?? []).map((item) => ({
2030
+ entity: item.entity,
2031
+ platform: item.platform,
2032
+ label: item.label,
2033
+ uniqueSuffix: item.uniqueSuffix,
2034
+ enabledByDefault: true,
2035
+ ...item.deviceClass !== void 0 ? { deviceClass: item.deviceClass } : {},
2036
+ ...item.unit !== void 0 ? { unit: item.unit } : {},
2037
+ ...item.stateClass !== void 0 ? { stateClass: item.stateClass } : {},
2038
+ ...item.attributes !== void 0 ? { attributes: item.attributes } : {},
2039
+ ...item.options !== void 0 ? { options: item.options } : {},
2040
+ ...item.icon !== void 0 ? { icon: item.icon } : {},
2041
+ ...item.entityCategory !== void 0 ? { entityCategory: item.entityCategory } : {},
2042
+ ...item.platform === "button" ? { writable: true } : {}
2043
+ }));
2044
+ }
2045
+ /**
2046
+ * The hand-written half: a camera's entities come from analytics — plus
2047
+ * the per-item capabilities, which bind on every device type (a camera can
2048
+ * report consumables like anything else) and mint the same ids either way.
2049
+ */
1801
2050
  function buildCameraPlan(device) {
1802
- return assemble(device, cameraSpecs(device));
2051
+ const items = device.boundCaps.filter((cap) => isItemEntityCap(cap)).flatMap((cap) => itemEntitySpecs(device, cap));
2052
+ return assemble(device, [...cameraSpecs(device), ...items]);
1803
2053
  }
1804
2054
  /**
1805
2055
  * capability → the entities it produces.
@@ -1842,13 +2092,14 @@ function buildCameraPlan(device) {
1842
2092
  *
1843
2093
  * ── Why a nested object is never a field ────────────────────────────────
1844
2094
  *
1845
- * `renderCapValue` carries strings, numbers and booleans. `color.rgb`,
1846
- * `vacuum-control.dustBin`, `event-emitter.lastEvent` and
1847
- * `consumables.items` are objects and arrays: mapping one would publish
2095
+ * `renderCapValue` carries strings, numbers and booleans. `color.rgb` and
2096
+ * `event-emitter.lastEvent` are objects: mapping one would publish
1848
2097
  * an entity that reports `unrenderable` on every single delivery. Where a
1849
2098
  * capability's headline value is only expressible as an object, the flat
1850
2099
  * neighbours are exported and the object is named in the doc comment
1851
- * rather than mapped.
2100
+ * rather than mapped. An ARRAY of named items (`consumables.items`) is not
2101
+ * a row here either: `ITEM_ENTITY_MAP` (`item-entities.ts`) mints one
2102
+ * entity set per item from the device's own slice.
1852
2103
  */
1853
2104
  var CAP_ENTITY_MAP = {
1854
2105
  switch: {
@@ -2585,18 +2836,6 @@ var CAP_ENTITY_MAP = {
2585
2836
  entityCategory: "config",
2586
2837
  enabledByDefault: false
2587
2838
  },
2588
- salt: {
2589
- platform: "sensor",
2590
- field: "saltLevel",
2591
- label: "Salt level",
2592
- enabledByDefault: false
2593
- },
2594
- "rinse-aid": {
2595
- platform: "sensor",
2596
- field: "rinseAidLevel",
2597
- label: "Rinse aid level",
2598
- enabledByDefault: false
2599
- },
2600
2839
  error: {
2601
2840
  platform: "sensor",
2602
2841
  field: "errorLabel",
@@ -3349,13 +3588,9 @@ var CAPS_NOT_EXPORTED = [
3349
3588
  cap: "events",
3350
3589
  reason: "A paginated event LOG, not a state. Its natural target is HA's `event` platform, which the component does not build (Tier B)."
3351
3590
  },
3352
- {
3353
- cap: "consumables",
3354
- reason: "The value is `items`, an ARRAY of per-device consumables named by the provider (a filter, a brush, a cartridge). This table is static per capability, so it cannot mint one sensor per item; doing it needs a reconcile-time entity mint keyed on the device, which is a design, not a row."
3355
- },
3356
3591
  {
3357
3592
  cap: "scene-monitor",
3358
- reason: "Per-ROI reference-region state, `monitors` being an array of operator-named regions. Same shape as consumables: the entity set is per device and changes when the operator adds a region, which a static descriptor cannot express."
3593
+ reason: "Per-ROI reference-region state, `monitors` being an array of operator-named regions. The entity set is per device and changes when the operator adds a region, which a static descriptor cannot express. `ITEM_ENTITY_MAP` (`item-entities.ts`) is the per-item mint that could carry it; what each region should become in Home Assistant is not decided."
3359
3594
  },
3360
3595
  {
3361
3596
  cap: "audio-metrics",
@@ -3404,7 +3639,11 @@ function degradeUnsupported(spec, options) {
3404
3639
  }
3405
3640
  /** A capability in neither list — the thing the guard exists to surface. */
3406
3641
  function unclassifiedCaps(caps) {
3407
- const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
3642
+ const known = new Set([
3643
+ ...Object.keys(CAP_ENTITY_MAP),
3644
+ ...Object.keys(ITEM_ENTITY_MAP),
3645
+ ...CAPS_NOT_EXPORTED.map((e) => e.cap)
3646
+ ]);
3408
3647
  return [...new Set(caps)].filter((cap) => !known.has(cap)).sort();
3409
3648
  }
3410
3649
  /**
@@ -3449,6 +3688,11 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3449
3688
  * two halves of this file would build the same entity id twice.
3450
3689
  */
3451
3690
  if (CAMERA_DERIVED_CAPS.includes(cap)) continue;
3691
+ /** Per-item: the entity set comes from the slice, not from a row. */
3692
+ if (isItemEntityCap(cap)) {
3693
+ specs.push(...itemEntitySpecs(device, cap).map((spec) => degradeUnsupported(spec, options)));
3694
+ continue;
3695
+ }
3452
3696
  /**
3453
3697
  * `control` is slice-shaped: select options and number bounds are not
3454
3698
  * on the static row. Mint the announced platform here; the CAP_ENTITY_MAP
@@ -4148,7 +4392,33 @@ var TEMPERATURE_MIN = -20;
4148
4392
  var TEMPERATURE_MAX = 90;
4149
4393
  var MIRED_MIN = 50;
4150
4394
  var MIRED_MAX = 1e3;
4395
+ /**
4396
+ * capability → what pressing one of its per-item buttons does. The builder
4397
+ * receives the item's KEY in place of a payload (a button's payload is
4398
+ * always `PRESS`). Keyed like `ITEM_ENTITY_MAP`; the spec asserts every cap
4399
+ * there that mints a button has a route here.
4400
+ */
4401
+ var ITEM_COMMAND_ROUTES = { consumables: (deviceId, key) => key.length === 0 ? null : {
4402
+ kind: "consumable-reset",
4403
+ deviceId,
4404
+ key
4405
+ } };
4151
4406
  function resolveCommand(target, entity, value) {
4407
+ /**
4408
+ * The per-item half — first, because a camera binds per-item caps too.
4409
+ * Only a `button` is a command; the item it addresses is the one the plan
4410
+ * announced, so a press for an item that left the
4411
+ * slice (or was never resettable) is `unknown-entity`, never a guess.
4412
+ */
4413
+ const item = target.itemEntities?.find((candidate) => candidate.entity === entity && candidate.platform === "button");
4414
+ if (item !== void 0) {
4415
+ if (!target.boundCaps.includes(item.capName)) return {
4416
+ ok: false,
4417
+ reason: "cap-not-bound",
4418
+ capName: item.capName
4419
+ };
4420
+ return build(ITEM_COMMAND_ROUTES[item.capName], target.deviceId, item.itemKey, item.capName);
4421
+ }
4152
4422
  if (target.type === "camera") {
4153
4423
  const switchId = CAMERA_SWITCH_BY_SLUG[entity];
4154
4424
  if (switchId !== void 0) {
@@ -4551,6 +4821,7 @@ async function applyCapCommand(device, command) {
4551
4821
  case "vario-speed-plus": return dishwasher.setVarioSpeedPlus({ on: command.on });
4552
4822
  }
4553
4823
  });
4824
+ case "consumable-reset": return call(device.consumables, "consumables", (consumables) => consumables.reset({ key: command.key }));
4554
4825
  }
4555
4826
  }
4556
4827
  /**
@@ -6441,6 +6712,12 @@ var HaExportAddon = class extends BaseAddon {
6441
6712
  streamsMirror = new ReadMirror((stream) => stream.id);
6442
6713
  presetsMirror = new ReadMirror((preset) => preset);
6443
6714
  switchesMirror = new ReadMirror((fn) => fn.id);
6715
+ /**
6716
+ * Per-item capability → its mirror, keyed by entity id. An item that
6717
+ * leaves the slice deletes its entities (and their history) in Home
6718
+ * Assistant, so the removal waits for a second read to agree (D49).
6719
+ */
6720
+ itemEntityMirrors = new Map(Object.keys(ITEM_ENTITY_MAP).map((cap) => [cap, new ReadMirror((item) => item.entity)]));
6444
6721
  /** A held-back read is retried soon, not at the next 300 s pass. */
6445
6722
  readTimer = null;
6446
6723
  entityCount = 0;
@@ -6626,6 +6903,11 @@ var HaExportAddon = class extends BaseAddon {
6626
6903
  if (deviceId === null || capName === null || slice === null) return;
6627
6904
  const deviceKey = this.keyByDeviceId.get(deviceId);
6628
6905
  if (deviceKey === void 0) return;
6906
+ /** Per-item: on a camera and a base device alike, minted from the slice. */
6907
+ if (isItemEntityCap(capName)) {
6908
+ this.pushItemCap(deviceId, deviceKey, capName, slice);
6909
+ return;
6910
+ }
6629
6911
  if (capName !== "device-status" && capName !== "battery" && !this.isCamera(deviceKey)) {
6630
6912
  this.pushDerivedCap(deviceId, deviceKey, capName, slice);
6631
6913
  return;
@@ -6765,6 +7047,51 @@ var HaExportAddon = class extends BaseAddon {
6765
7047
  return;
6766
7048
  }
6767
7049
  }
7050
+ /** The per-item values of one slice that land on an entity the plan announced. */
7051
+ announcedItemValues(entry, capName, slice) {
7052
+ const key = entry.plan.deviceKey;
7053
+ const topics = new Set((entry.target.itemEntities ?? []).filter((item) => item.capName === capName).map((item) => stateTopic(key, item.entity)));
7054
+ return (projectItemSlice(key, capName, slice) ?? []).filter((value) => topics.has(value.topic));
7055
+ }
7056
+ /**
7057
+ * The per-item push (`item-entities.ts`).
7058
+ *
7059
+ * Values land only on entities the plan announced. When the slice mints a
7060
+ * different entity SET than the plan holds — an item appeared, left, or
7061
+ * started reporting a level — the structure is re-derived by a debounced
7062
+ * reconcile; the item mirror there decides whether a removal is believed.
7063
+ */
7064
+ pushItemCap(deviceId, deviceKey, capName, slice) {
7065
+ const minted = itemEntitiesFor(capName, slice);
7066
+ if (minted === null) {
7067
+ this.warnOnce(deviceId, capName, "items", {
7068
+ message: "ha-export: a per-item slice is not the capability shape, nothing published",
7069
+ meta: {
7070
+ capName,
7071
+ sliceFields: Object.keys(slice)
7072
+ }
7073
+ });
7074
+ return;
7075
+ }
7076
+ const entry = this.exported.get(deviceKey);
7077
+ if (entry === void 0) return;
7078
+ const announced = (entry.target.itemEntities ?? []).filter((item) => item.capName === capName);
7079
+ const landing = this.announcedItemValues(entry, capName, slice);
7080
+ if (landing.length > 0) this.push(deviceId, landing);
7081
+ const mintedIds = minted.map((item) => item.entity).toSorted();
7082
+ const announcedIds = announced.map((item) => item.entity).toSorted();
7083
+ if (mintedIds.join("\0") !== announcedIds.join("\0")) {
7084
+ this.ctx.logger.info("ha-export: a per-item entity set changed, re-announcing the device", {
7085
+ tags: { deviceId },
7086
+ meta: {
7087
+ capName,
7088
+ added: mintedIds.filter((id) => !announcedIds.includes(id)),
7089
+ removed: announcedIds.filter((id) => !mintedIds.includes(id))
7090
+ }
7091
+ });
7092
+ this.reconcileSoon();
7093
+ }
7094
+ }
6768
7095
  /** deviceId:cap:field already reported. Bounded by devices × mapped caps. */
6769
7096
  warnedCapFields = /* @__PURE__ */ new Set();
6770
7097
  warnOnce(deviceId, capName, field, line) {
@@ -7089,7 +7416,8 @@ var HaExportAddon = class extends BaseAddon {
7089
7416
  target: {
7090
7417
  deviceId: numericId,
7091
7418
  type: device.type,
7092
- boundCaps: catalogDevice.boundCaps
7419
+ boundCaps: catalogDevice.boundCaps,
7420
+ itemEntities: Object.values(catalogDevice.itemEntities ?? {}).flat()
7093
7421
  },
7094
7422
  brokerIds,
7095
7423
  streams: catalogDevice.streams ?? [],
@@ -7484,6 +7812,10 @@ var HaExportAddon = class extends BaseAddon {
7484
7812
  const isCamera = entry.target.type === "camera";
7485
7813
  for (const [capName, capSlice] of Object.entries(snapshot)) {
7486
7814
  if (capName === "device-status" || capName === "battery") continue;
7815
+ if (isItemEntityCap(capName)) {
7816
+ values.push(...this.announcedItemValues(entry, capName, capSlice));
7817
+ continue;
7818
+ }
7487
7819
  if (isCamera && !CAMERA_DERIVED_CAPS.includes(capName)) continue;
7488
7820
  const projection = projectCapSlice(key, capName, capSlice);
7489
7821
  if (projection.kind === "values") values.push(...projection.values);
@@ -7588,6 +7920,8 @@ var HaExportAddon = class extends BaseAddon {
7588
7920
  device: null,
7589
7921
  heldBack
7590
7922
  };
7923
+ const items = this.settleItemEntities(device.id, boundCaps, snapshot);
7924
+ if (items.held) heldBack = true;
7591
7925
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
7592
7926
  const model = readString(device.metadata ?? {}, "model");
7593
7927
  return {
@@ -7610,11 +7944,60 @@ var HaExportAddon = class extends BaseAddon {
7610
7944
  * `availableModes` is on the slice and nowhere else. See
7611
7945
  * `CatalogDevice.capSlices`.
7612
7946
  */
7613
- capSlices: snapshot
7947
+ capSlices: snapshot,
7948
+ itemEntities: items.entities
7614
7949
  },
7615
7950
  heldBack
7616
7951
  };
7617
7952
  }
7953
+ /**
7954
+ * The per-item entities of every per-item capability the device binds,
7955
+ * through each capability's mirror.
7956
+ *
7957
+ * An ABSENT slice is an answer — the capability is inert until a provider
7958
+ * sets items — so it reads as "no items", and a set that was announced
7959
+ * shrinks only when the next read agrees. A slice that is not the
7960
+ * capability's shape is a failed read: the held set is announced again,
7961
+ * and with nothing held the device is announced WITHOUT those entities
7962
+ * rather than held back whole, because a malformed consumables slice must
7963
+ * not take a vacuum's every other entity out of Home Assistant.
7964
+ */
7965
+ settleItemEntities(deviceId, boundCaps, snapshot) {
7966
+ const entities = {};
7967
+ let held = false;
7968
+ for (const cap of boundCaps) {
7969
+ const mirror = this.itemEntityMirrors.get(cap);
7970
+ if (mirror === void 0) continue;
7971
+ const slice = snapshot[cap];
7972
+ const minted = slice === void 0 ? [] : itemEntitiesFor(cap, slice);
7973
+ const outcome = minted === null ? {
7974
+ kind: "failed",
7975
+ reason: `the ${cap} slice is not the capability shape`
7976
+ } : {
7977
+ kind: "answered",
7978
+ items: minted
7979
+ };
7980
+ const resolution = mirror.resolve(deviceId, outcome);
7981
+ if (resolution.status === "unknown") {
7982
+ this.ctx.logger.warn("ha-export: a per-item slice is not the capability shape and nothing was exported yet, announcing the device without its items", {
7983
+ tags: { deviceId },
7984
+ meta: {
7985
+ capName: cap,
7986
+ reason: resolution.reason
7987
+ }
7988
+ });
7989
+ entities[cap] = [];
7990
+ continue;
7991
+ }
7992
+ if (resolution.status !== "fresh") held = true;
7993
+ this.logHeldRead(deviceId, cap, resolution);
7994
+ entities[cap] = resolution.items;
7995
+ }
7996
+ return {
7997
+ entities,
7998
+ held
7999
+ };
8000
+ }
7618
8001
  /** One WARN per held part, with the device tag and the reason. */
7619
8002
  logHeldRead(deviceId, part, resolution) {
7620
8003
  if (resolution.status === "fresh") return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.617",
3
+ "version": "1.2.619",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",