@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.
- package/dist/addon.js +1 -9
- package/dist/addon.mjs +1 -9
- package/dist/{dist-CfZUXrNa.js → dist-DwxJnRNc.js} +237 -123
- package/dist/{dist-D6huwmAr.mjs → dist-fqJMr4fu.mjs} +208 -124
- 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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
* `
|
|
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.
|
|
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([
|
|
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;
|