@camstack/addon-provider-homeassistant 1.2.618 → 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.
- package/dist/addon.js +1 -9
- package/dist/addon.mjs +1 -9
- package/dist/{dist-BNSbY2dZ.js → dist-DwxJnRNc.js} +237 -117
- package/dist/{dist-BRSVYxR3.mjs → dist-fqJMr4fu.mjs} +208 -118
- package/dist/ha-export/ha-export.addon.js +417 -27
- package/dist/ha-export/ha-export.addon.mjs +410 -27
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
* `
|
|
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.
|
|
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([
|
|
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;
|