@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.
@@ -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-CfZUXrNa.js");
27
+ const require_dist = require("../dist-DwxJnRNc.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -699,6 +699,234 @@ var ComponentSupportTracker = class {
699
699
  }
700
700
  };
701
701
  //#endregion
702
+ //#region src/ha-export/item-entities.ts
703
+ /**
704
+ * Per-item entities — the reconcile-time mint `CAP_ENTITY_MAP` cannot express.
705
+ *
706
+ * `CAP_ENTITY_MAP` is static per capability: one row, a fixed set of
707
+ * entities, each reading one flat field. A capability whose value is an
708
+ * ARRAY of provider-named items (`consumables.items`: a brush, a filter, a
709
+ * clean-water tank, a dishwasher's salt) has a different entity set on every
710
+ * device, and the set changes when the provider reports a new item. This
711
+ * module mints that set from the device's OWN slice, pure:
712
+ *
713
+ * slice → `ItemEntity[]` (structure, announced by the catalog)
714
+ * slice → `StateValue[]` (values, pushed by the addon)
715
+ *
716
+ * Both halves come from the same function per capability, so an entity can
717
+ * never be announced under one id and fed under another.
718
+ *
719
+ * The STRUCTURE goes through a `ReadMirror` in the addon before it reaches
720
+ * the plan: an item that appears is announced at once, an item that leaves
721
+ * is removed only when two consecutive reads agree (D49) — a removal deletes
722
+ * the entity and its recorder history in Home Assistant.
723
+ *
724
+ * A capability listed here is CLASSIFIED for the export-coverage guard
725
+ * (`scripts/check-export-coverage.ts`), which checks `itemsField` against the
726
+ * capability's status schema exactly as it checks a `CAP_ENTITY_MAP` field.
727
+ */
728
+ var CONSUMABLE_PREFIX = "consumable";
729
+ /** Outside `ConsumableStatusSchema` on purpose: the component reads it as unknown. */
730
+ var STATUS_UNKNOWN = "unknown";
731
+ function consumableEntity(key, facet) {
732
+ return `${CONSUMABLE_PREFIX}_${toSlug(key)}_${facet}`;
733
+ }
734
+ /** Home Assistant's own duration units; a cycle count is a plain count. */
735
+ var REMAINING_UNIT = {
736
+ days: "d",
737
+ hours: "h",
738
+ cycles: "cycles"
739
+ };
740
+ /**
741
+ * The item's form (the capability's own derivation, `consumableFormOf`): the one
742
+ * the provider declared when it is a form the capability knows, else the one its
743
+ * kind implies. An unknown spelling is not trusted — it falls back to the kind
744
+ * rather than to an icon for nothing.
745
+ */
746
+ function consumableForm(item) {
747
+ const declared = require_dist.ConsumableFormSchema.safeParse(item.form);
748
+ return require_dist.consumableFormOf({
749
+ ...item.kind !== void 0 ? { kind: item.kind } : {},
750
+ ...declared.success ? { form: declared.data } : {}
751
+ });
752
+ }
753
+ /** A part is drawn by what it is when its key or label says so. */
754
+ function partIcon(key, label) {
755
+ if (`${key} ${label}`.toLowerCase().includes("brush")) return "mdi:brush";
756
+ return "mdi:air-filter";
757
+ }
758
+ function formIcon(item, form) {
759
+ switch (form) {
760
+ case "part": return partIcon(item.key, item.label);
761
+ case "tank": return require_dist.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
762
+ case "product": return "mdi:shaker-outline";
763
+ case "bin": return "mdi:delete";
764
+ }
765
+ }
766
+ /**
767
+ * The item as this export reads it: the capability's own item, plus a
768
+ * `form` read as `unknown` and narrowed by {@link consumableForm} — so a
769
+ * form this export does not know degrades one icon instead of refusing the
770
+ * whole slice.
771
+ */
772
+ var ExportConsumableItemSchema = require_dist.ConsumableItemSchema.extend({ form: require_dist.unknown().optional() });
773
+ var ExportConsumablesSchema = require_dist.object({ items: require_dist.array(ExportConsumableItemSchema) });
774
+ /**
775
+ * A worn part's life is a diagnostic; a tank's fill is what the device page
776
+ * is for (a vacuum's clean water, a dishwasher's salt), so it is not hidden.
777
+ */
778
+ function levelCategory(kind) {
779
+ return kind === "wear" ? "diagnostic" : void 0;
780
+ }
781
+ function consumableEntities(item) {
782
+ const base = {
783
+ capName: "consumables",
784
+ itemKey: item.key
785
+ };
786
+ const kind = require_dist.consumableKindOf(item);
787
+ const form = consumableForm(item);
788
+ const icon = formIcon(item, form);
789
+ /** What the item IS, on the sensors that describe it (not on a counter or a button). */
790
+ const attributes = {
791
+ form,
792
+ kind,
793
+ label: item.label
794
+ };
795
+ const out = [];
796
+ const unique = (facet) => `${CONSUMABLE_PREFIX}_${item.key}_${facet}`;
797
+ if (item.level !== null) {
798
+ const category = levelCategory(kind);
799
+ out.push({
800
+ ...base,
801
+ facet: "level",
802
+ entity: consumableEntity(item.key, "level"),
803
+ uniqueSuffix: unique("level"),
804
+ platform: "sensor",
805
+ label: item.label,
806
+ unit: "%",
807
+ stateClass: "measurement",
808
+ icon,
809
+ attributes,
810
+ ...category !== void 0 ? { entityCategory: category } : {}
811
+ });
812
+ }
813
+ if (item.status !== null)
814
+ /**
815
+ * An `enum` sensor over the cap's own vocabulary — the options come from
816
+ * `ConsumableStatusSchema`, never a second list here.
817
+ */
818
+ out.push({
819
+ ...base,
820
+ facet: "status",
821
+ entity: consumableEntity(item.key, "status"),
822
+ uniqueSuffix: unique("status"),
823
+ platform: "sensor",
824
+ label: `${item.label} status`,
825
+ deviceClass: "enum",
826
+ options: require_dist.ConsumableStatusSchema.options,
827
+ icon,
828
+ attributes,
829
+ entityCategory: "diagnostic"
830
+ });
831
+ if (item.remaining !== void 0) {
832
+ const isDuration = item.remaining.unit !== "cycles";
833
+ out.push({
834
+ ...base,
835
+ facet: "remaining",
836
+ entity: consumableEntity(item.key, "remaining"),
837
+ uniqueSuffix: unique("remaining"),
838
+ platform: "sensor",
839
+ label: `${item.label} remaining`,
840
+ unit: REMAINING_UNIT[item.remaining.unit],
841
+ stateClass: "measurement",
842
+ ...isDuration ? { deviceClass: "duration" } : {},
843
+ icon: isDuration ? "mdi:timer-sand" : "mdi:counter",
844
+ entityCategory: "diagnostic"
845
+ });
846
+ }
847
+ if (item.resettable) out.push({
848
+ ...base,
849
+ facet: "reset",
850
+ entity: consumableEntity(item.key, "reset"),
851
+ uniqueSuffix: unique("reset"),
852
+ platform: "button",
853
+ label: `Reset ${item.label}`,
854
+ icon: "mdi:restore",
855
+ entityCategory: "config"
856
+ });
857
+ return out;
858
+ }
859
+ function consumableValues(item) {
860
+ const out = [];
861
+ if (item.level !== null) out.push({
862
+ entity: consumableEntity(item.key, "level"),
863
+ value: String(item.level)
864
+ });
865
+ /**
866
+ * A null verdict is published OUTSIDE the enum, which the component reads
867
+ * as `unknown` — publishing nothing would leave the last verdict standing
868
+ * (D393: unknown is never the previous value).
869
+ */
870
+ out.push({
871
+ entity: consumableEntity(item.key, "status"),
872
+ value: item.status ?? STATUS_UNKNOWN
873
+ });
874
+ if (item.remaining !== void 0 && item.remaining.value !== null) out.push({
875
+ entity: consumableEntity(item.key, "remaining"),
876
+ value: String(item.remaining.value)
877
+ });
878
+ return out;
879
+ }
880
+ function parseConsumables(slice) {
881
+ const parsed = ExportConsumablesSchema.safeParse(slice);
882
+ return parsed.success ? parsed.data.items : null;
883
+ }
884
+ /**
885
+ * Two items whose keys slug alike would claim one entity id. The first
886
+ * keeps it; the second is left out rather than announced twice under one
887
+ * component key, where Home Assistant would show either value at random.
888
+ */
889
+ function firstPerEntity(rows) {
890
+ const seen = /* @__PURE__ */ new Set();
891
+ return rows.filter((row) => {
892
+ if (seen.has(row.entity)) return false;
893
+ seen.add(row.entity);
894
+ return true;
895
+ });
896
+ }
897
+ /** capability → its per-item mint. */
898
+ var ITEM_ENTITY_MAP = { consumables: {
899
+ itemsField: "items",
900
+ entities: (slice) => {
901
+ const items = parseConsumables(slice);
902
+ return items === null ? null : firstPerEntity(items.flatMap(consumableEntities));
903
+ },
904
+ values: (slice) => {
905
+ const items = parseConsumables(slice);
906
+ return items === null ? null : firstPerEntity(items.flatMap(consumableValues));
907
+ }
908
+ } };
909
+ /** The entities one capability's slice mints, or `null` for a malformed slice. */
910
+ function itemEntitiesFor(capName, slice) {
911
+ const mapping = ITEM_ENTITY_MAP[capName];
912
+ return mapping === void 0 ? null : mapping.entities(slice);
913
+ }
914
+ /** The values one capability's slice carries, on their state topics. */
915
+ function projectItemSlice(deviceKey, capName, slice) {
916
+ const mapping = ITEM_ENTITY_MAP[capName];
917
+ if (mapping === void 0) return null;
918
+ const values = mapping.values(slice);
919
+ if (values === null) return null;
920
+ return values.map((v) => ({
921
+ topic: stateTopic(deviceKey, v.entity),
922
+ value: v.value
923
+ }));
924
+ }
925
+ /** True for a capability whose entities are minted per item. */
926
+ function isItemEntityCap(capName) {
927
+ return Object.prototype.hasOwnProperty.call(ITEM_ENTITY_MAP, capName);
928
+ }
929
+ //#endregion
702
930
  //#region src/ha-export/native-platforms.ts
703
931
  /**
704
932
  * The NATIVE half of the Home Assistant export.
@@ -1396,6 +1624,8 @@ function buildComponent(device, spec) {
1396
1624
  ...spec.writable === true && COMMANDABLE_PLATFORMS.has(spec.platform) ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
1397
1625
  ...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
1398
1626
  ...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
1627
+ ...spec.stateClass !== void 0 ? { state_class: spec.stateClass } : {},
1628
+ ...spec.attributes !== void 0 ? { attributes: spec.attributes } : {},
1399
1629
  ...spec.icon !== void 0 ? { icon: spec.icon } : {},
1400
1630
  ...spec.options !== void 0 ? { options: spec.options } : {},
1401
1631
  ...spec.range !== void 0 ? {
@@ -1825,9 +2055,36 @@ function assemble(device, specs) {
1825
2055
  cmps
1826
2056
  };
1827
2057
  }
1828
- /** The hand-written half: a camera's entities come from analytics. */
2058
+ /**
2059
+ * The per-item entities of every per-item capability the device binds
2060
+ * (`item-entities.ts`), as the reconcile settled them. A button is a
2061
+ * command, so it is the one writable kind.
2062
+ */
2063
+ function itemEntitySpecs(device, capName) {
2064
+ return (device.itemEntities?.[capName] ?? []).map((item) => ({
2065
+ entity: item.entity,
2066
+ platform: item.platform,
2067
+ label: item.label,
2068
+ uniqueSuffix: item.uniqueSuffix,
2069
+ enabledByDefault: true,
2070
+ ...item.deviceClass !== void 0 ? { deviceClass: item.deviceClass } : {},
2071
+ ...item.unit !== void 0 ? { unit: item.unit } : {},
2072
+ ...item.stateClass !== void 0 ? { stateClass: item.stateClass } : {},
2073
+ ...item.attributes !== void 0 ? { attributes: item.attributes } : {},
2074
+ ...item.options !== void 0 ? { options: item.options } : {},
2075
+ ...item.icon !== void 0 ? { icon: item.icon } : {},
2076
+ ...item.entityCategory !== void 0 ? { entityCategory: item.entityCategory } : {},
2077
+ ...item.platform === "button" ? { writable: true } : {}
2078
+ }));
2079
+ }
2080
+ /**
2081
+ * The hand-written half: a camera's entities come from analytics — plus
2082
+ * the per-item capabilities, which bind on every device type (a camera can
2083
+ * report consumables like anything else) and mint the same ids either way.
2084
+ */
1829
2085
  function buildCameraPlan(device) {
1830
- return assemble(device, cameraSpecs(device));
2086
+ const items = device.boundCaps.filter((cap) => isItemEntityCap(cap)).flatMap((cap) => itemEntitySpecs(device, cap));
2087
+ return assemble(device, [...cameraSpecs(device), ...items]);
1831
2088
  }
1832
2089
  /**
1833
2090
  * capability → the entities it produces.
@@ -1870,13 +2127,14 @@ function buildCameraPlan(device) {
1870
2127
  *
1871
2128
  * ── Why a nested object is never a field ────────────────────────────────
1872
2129
  *
1873
- * `renderCapValue` carries strings, numbers and booleans. `color.rgb`,
1874
- * `vacuum-control.dustBin`, `event-emitter.lastEvent` and
1875
- * `consumables.items` are objects and arrays: mapping one would publish
2130
+ * `renderCapValue` carries strings, numbers and booleans. `color.rgb` and
2131
+ * `event-emitter.lastEvent` are objects: mapping one would publish
1876
2132
  * an entity that reports `unrenderable` on every single delivery. Where a
1877
2133
  * capability's headline value is only expressible as an object, the flat
1878
2134
  * neighbours are exported and the object is named in the doc comment
1879
- * rather than mapped.
2135
+ * rather than mapped. An ARRAY of named items (`consumables.items`) is not
2136
+ * a row here either: `ITEM_ENTITY_MAP` (`item-entities.ts`) mints one
2137
+ * entity set per item from the device's own slice.
1880
2138
  */
1881
2139
  var CAP_ENTITY_MAP = {
1882
2140
  switch: {
@@ -2613,18 +2871,6 @@ var CAP_ENTITY_MAP = {
2613
2871
  entityCategory: "config",
2614
2872
  enabledByDefault: false
2615
2873
  },
2616
- salt: {
2617
- platform: "sensor",
2618
- field: "saltLevel",
2619
- label: "Salt level",
2620
- enabledByDefault: false
2621
- },
2622
- "rinse-aid": {
2623
- platform: "sensor",
2624
- field: "rinseAidLevel",
2625
- label: "Rinse aid level",
2626
- enabledByDefault: false
2627
- },
2628
2874
  error: {
2629
2875
  platform: "sensor",
2630
2876
  field: "errorLabel",
@@ -3377,13 +3623,9 @@ var CAPS_NOT_EXPORTED = [
3377
3623
  cap: "events",
3378
3624
  reason: "A paginated event LOG, not a state. Its natural target is HA's `event` platform, which the component does not build (Tier B)."
3379
3625
  },
3380
- {
3381
- cap: "consumables",
3382
- 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."
3383
- },
3384
3626
  {
3385
3627
  cap: "scene-monitor",
3386
- 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."
3628
+ 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."
3387
3629
  },
3388
3630
  {
3389
3631
  cap: "audio-metrics",
@@ -3432,7 +3674,11 @@ function degradeUnsupported(spec, options) {
3432
3674
  }
3433
3675
  /** A capability in neither list — the thing the guard exists to surface. */
3434
3676
  function unclassifiedCaps(caps) {
3435
- const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
3677
+ const known = new Set([
3678
+ ...Object.keys(CAP_ENTITY_MAP),
3679
+ ...Object.keys(ITEM_ENTITY_MAP),
3680
+ ...CAPS_NOT_EXPORTED.map((e) => e.cap)
3681
+ ]);
3436
3682
  return [...new Set(caps)].filter((cap) => !known.has(cap)).sort();
3437
3683
  }
3438
3684
  /**
@@ -3477,6 +3723,11 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3477
3723
  * two halves of this file would build the same entity id twice.
3478
3724
  */
3479
3725
  if (CAMERA_DERIVED_CAPS.includes(cap)) continue;
3726
+ /** Per-item: the entity set comes from the slice, not from a row. */
3727
+ if (isItemEntityCap(cap)) {
3728
+ specs.push(...itemEntitySpecs(device, cap).map((spec) => degradeUnsupported(spec, options)));
3729
+ continue;
3730
+ }
3480
3731
  /**
3481
3732
  * `control` is slice-shaped: select options and number bounds are not
3482
3733
  * on the static row. Mint the announced platform here; the CAP_ENTITY_MAP
@@ -4176,7 +4427,33 @@ var TEMPERATURE_MIN = -20;
4176
4427
  var TEMPERATURE_MAX = 90;
4177
4428
  var MIRED_MIN = 50;
4178
4429
  var MIRED_MAX = 1e3;
4430
+ /**
4431
+ * capability → what pressing one of its per-item buttons does. The builder
4432
+ * receives the item's KEY in place of a payload (a button's payload is
4433
+ * always `PRESS`). Keyed like `ITEM_ENTITY_MAP`; the spec asserts every cap
4434
+ * there that mints a button has a route here.
4435
+ */
4436
+ var ITEM_COMMAND_ROUTES = { consumables: (deviceId, key) => key.length === 0 ? null : {
4437
+ kind: "consumable-reset",
4438
+ deviceId,
4439
+ key
4440
+ } };
4179
4441
  function resolveCommand(target, entity, value) {
4442
+ /**
4443
+ * The per-item half — first, because a camera binds per-item caps too.
4444
+ * Only a `button` is a command; the item it addresses is the one the plan
4445
+ * announced, so a press for an item that left the
4446
+ * slice (or was never resettable) is `unknown-entity`, never a guess.
4447
+ */
4448
+ const item = target.itemEntities?.find((candidate) => candidate.entity === entity && candidate.platform === "button");
4449
+ if (item !== void 0) {
4450
+ if (!target.boundCaps.includes(item.capName)) return {
4451
+ ok: false,
4452
+ reason: "cap-not-bound",
4453
+ capName: item.capName
4454
+ };
4455
+ return build(ITEM_COMMAND_ROUTES[item.capName], target.deviceId, item.itemKey, item.capName);
4456
+ }
4180
4457
  if (target.type === "camera") {
4181
4458
  const switchId = CAMERA_SWITCH_BY_SLUG[entity];
4182
4459
  if (switchId !== void 0) {
@@ -4579,6 +4856,7 @@ async function applyCapCommand(device, command) {
4579
4856
  case "vario-speed-plus": return dishwasher.setVarioSpeedPlus({ on: command.on });
4580
4857
  }
4581
4858
  });
4859
+ case "consumable-reset": return call(device.consumables, "consumables", (consumables) => consumables.reset({ key: command.key }));
4582
4860
  }
4583
4861
  }
4584
4862
  /**
@@ -6469,6 +6747,12 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6469
6747
  streamsMirror = new ReadMirror((stream) => stream.id);
6470
6748
  presetsMirror = new ReadMirror((preset) => preset);
6471
6749
  switchesMirror = new ReadMirror((fn) => fn.id);
6750
+ /**
6751
+ * Per-item capability → its mirror, keyed by entity id. An item that
6752
+ * leaves the slice deletes its entities (and their history) in Home
6753
+ * Assistant, so the removal waits for a second read to agree (D49).
6754
+ */
6755
+ itemEntityMirrors = new Map(Object.keys(ITEM_ENTITY_MAP).map((cap) => [cap, new ReadMirror((item) => item.entity)]));
6472
6756
  /** A held-back read is retried soon, not at the next 300 s pass. */
6473
6757
  readTimer = null;
6474
6758
  entityCount = 0;
@@ -6654,6 +6938,11 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6654
6938
  if (deviceId === null || capName === null || slice === null) return;
6655
6939
  const deviceKey = this.keyByDeviceId.get(deviceId);
6656
6940
  if (deviceKey === void 0) return;
6941
+ /** Per-item: on a camera and a base device alike, minted from the slice. */
6942
+ if (isItemEntityCap(capName)) {
6943
+ this.pushItemCap(deviceId, deviceKey, capName, slice);
6944
+ return;
6945
+ }
6657
6946
  if (capName !== "device-status" && capName !== "battery" && !this.isCamera(deviceKey)) {
6658
6947
  this.pushDerivedCap(deviceId, deviceKey, capName, slice);
6659
6948
  return;
@@ -6793,6 +7082,51 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6793
7082
  return;
6794
7083
  }
6795
7084
  }
7085
+ /** The per-item values of one slice that land on an entity the plan announced. */
7086
+ announcedItemValues(entry, capName, slice) {
7087
+ const key = entry.plan.deviceKey;
7088
+ const topics = new Set((entry.target.itemEntities ?? []).filter((item) => item.capName === capName).map((item) => stateTopic(key, item.entity)));
7089
+ return (projectItemSlice(key, capName, slice) ?? []).filter((value) => topics.has(value.topic));
7090
+ }
7091
+ /**
7092
+ * The per-item push (`item-entities.ts`).
7093
+ *
7094
+ * Values land only on entities the plan announced. When the slice mints a
7095
+ * different entity SET than the plan holds — an item appeared, left, or
7096
+ * started reporting a level — the structure is re-derived by a debounced
7097
+ * reconcile; the item mirror there decides whether a removal is believed.
7098
+ */
7099
+ pushItemCap(deviceId, deviceKey, capName, slice) {
7100
+ const minted = itemEntitiesFor(capName, slice);
7101
+ if (minted === null) {
7102
+ this.warnOnce(deviceId, capName, "items", {
7103
+ message: "ha-export: a per-item slice is not the capability shape, nothing published",
7104
+ meta: {
7105
+ capName,
7106
+ sliceFields: Object.keys(slice)
7107
+ }
7108
+ });
7109
+ return;
7110
+ }
7111
+ const entry = this.exported.get(deviceKey);
7112
+ if (entry === void 0) return;
7113
+ const announced = (entry.target.itemEntities ?? []).filter((item) => item.capName === capName);
7114
+ const landing = this.announcedItemValues(entry, capName, slice);
7115
+ if (landing.length > 0) this.push(deviceId, landing);
7116
+ const mintedIds = minted.map((item) => item.entity).toSorted();
7117
+ const announcedIds = announced.map((item) => item.entity).toSorted();
7118
+ if (mintedIds.join("\0") !== announcedIds.join("\0")) {
7119
+ this.ctx.logger.info("ha-export: a per-item entity set changed, re-announcing the device", {
7120
+ tags: { deviceId },
7121
+ meta: {
7122
+ capName,
7123
+ added: mintedIds.filter((id) => !announcedIds.includes(id)),
7124
+ removed: announcedIds.filter((id) => !mintedIds.includes(id))
7125
+ }
7126
+ });
7127
+ this.reconcileSoon();
7128
+ }
7129
+ }
6796
7130
  /** deviceId:cap:field already reported. Bounded by devices × mapped caps. */
6797
7131
  warnedCapFields = /* @__PURE__ */ new Set();
6798
7132
  warnOnce(deviceId, capName, field, line) {
@@ -7117,7 +7451,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7117
7451
  target: {
7118
7452
  deviceId: numericId,
7119
7453
  type: device.type,
7120
- boundCaps: catalogDevice.boundCaps
7454
+ boundCaps: catalogDevice.boundCaps,
7455
+ itemEntities: Object.values(catalogDevice.itemEntities ?? {}).flat()
7121
7456
  },
7122
7457
  brokerIds,
7123
7458
  streams: catalogDevice.streams ?? [],
@@ -7512,6 +7847,10 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7512
7847
  const isCamera = entry.target.type === "camera";
7513
7848
  for (const [capName, capSlice] of Object.entries(snapshot)) {
7514
7849
  if (capName === "device-status" || capName === "battery") continue;
7850
+ if (isItemEntityCap(capName)) {
7851
+ values.push(...this.announcedItemValues(entry, capName, capSlice));
7852
+ continue;
7853
+ }
7515
7854
  if (isCamera && !CAMERA_DERIVED_CAPS.includes(capName)) continue;
7516
7855
  const projection = projectCapSlice(key, capName, capSlice);
7517
7856
  if (projection.kind === "values") values.push(...projection.values);
@@ -7616,6 +7955,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7616
7955
  device: null,
7617
7956
  heldBack
7618
7957
  };
7958
+ const items = this.settleItemEntities(device.id, boundCaps, snapshot);
7959
+ if (items.held) heldBack = true;
7619
7960
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
7620
7961
  const model = readString(device.metadata ?? {}, "model");
7621
7962
  return {
@@ -7638,11 +7979,60 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7638
7979
  * `availableModes` is on the slice and nowhere else. See
7639
7980
  * `CatalogDevice.capSlices`.
7640
7981
  */
7641
- capSlices: snapshot
7982
+ capSlices: snapshot,
7983
+ itemEntities: items.entities
7642
7984
  },
7643
7985
  heldBack
7644
7986
  };
7645
7987
  }
7988
+ /**
7989
+ * The per-item entities of every per-item capability the device binds,
7990
+ * through each capability's mirror.
7991
+ *
7992
+ * An ABSENT slice is an answer — the capability is inert until a provider
7993
+ * sets items — so it reads as "no items", and a set that was announced
7994
+ * shrinks only when the next read agrees. A slice that is not the
7995
+ * capability's shape is a failed read: the held set is announced again,
7996
+ * and with nothing held the device is announced WITHOUT those entities
7997
+ * rather than held back whole, because a malformed consumables slice must
7998
+ * not take a vacuum's every other entity out of Home Assistant.
7999
+ */
8000
+ settleItemEntities(deviceId, boundCaps, snapshot) {
8001
+ const entities = {};
8002
+ let held = false;
8003
+ for (const cap of boundCaps) {
8004
+ const mirror = this.itemEntityMirrors.get(cap);
8005
+ if (mirror === void 0) continue;
8006
+ const slice = snapshot[cap];
8007
+ const minted = slice === void 0 ? [] : itemEntitiesFor(cap, slice);
8008
+ const outcome = minted === null ? {
8009
+ kind: "failed",
8010
+ reason: `the ${cap} slice is not the capability shape`
8011
+ } : {
8012
+ kind: "answered",
8013
+ items: minted
8014
+ };
8015
+ const resolution = mirror.resolve(deviceId, outcome);
8016
+ if (resolution.status === "unknown") {
8017
+ 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", {
8018
+ tags: { deviceId },
8019
+ meta: {
8020
+ capName: cap,
8021
+ reason: resolution.reason
8022
+ }
8023
+ });
8024
+ entities[cap] = [];
8025
+ continue;
8026
+ }
8027
+ if (resolution.status !== "fresh") held = true;
8028
+ this.logHeldRead(deviceId, cap, resolution);
8029
+ entities[cap] = resolution.items;
8030
+ }
8031
+ return {
8032
+ entities,
8033
+ held
8034
+ };
8035
+ }
7646
8036
  /** One WARN per held part, with the device tag and the reason. */
7647
8037
  logHeldRead(deviceId, part, resolution) {
7648
8038
  if (resolution.status === "fresh") return;