@camstack/addon-provider-homeassistant 1.2.630 → 1.2.632

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dist = require("./dist-Dcmr-Isu.js");
2
+ const require_dist = require("./dist-D0ZiJUPy.js");
3
3
  let node_crypto = require("node:crypto");
4
4
  let node_zlib = require("node:zlib");
5
5
  //#region src/ha-admin-link.ts
@@ -126,7 +126,14 @@ var haSourceSchemaFields = {
126
126
  deviceClass: require_dist.string().optional(),
127
127
  integrationId: require_dist.string().optional(),
128
128
  options: require_dist.array(require_dist.string()).optional(),
129
- features: require_dist.array(require_dist.string()).optional()
129
+ features: require_dist.array(require_dist.string()).optional(),
130
+ /** Sibling meter sensors whose readings this entity carries as `power-meter` (D951). */
131
+ meterEntities: require_dist.object({
132
+ watts: require_dist.string().optional(),
133
+ kwhTotal: require_dist.string().optional(),
134
+ volts: require_dist.string().optional(),
135
+ amps: require_dist.string().optional()
136
+ }).optional()
130
137
  };
131
138
  /** Project a persisted config blob into a typed source, or null when the
132
139
  * blob is not an HA-adopted device (no brokerId). */
@@ -292,6 +299,168 @@ var HaDomain = /* @__PURE__ */ function(HaDomain) {
292
299
  return HaDomain;
293
300
  }({});
294
301
  //#endregion
302
+ //#region src/ha-reading.ts
303
+ /**
304
+ * Convert one sample into the quantity's base unit. A refusal publishes
305
+ * nothing (the caller stores null) and is logged with the device id —
306
+ * an unknown unit is never guessed (D393, D944).
307
+ */
308
+ function takeReading(logger, deviceId, value, rawUnit, quantity) {
309
+ const result = require_dist.ingestReading({
310
+ value,
311
+ rawUnit,
312
+ quantity
313
+ });
314
+ if (result.ok) return result.value;
315
+ logger.warn("numeric reading refused", {
316
+ tags: { deviceId },
317
+ meta: {
318
+ quantity,
319
+ unit: rawUnit ?? "",
320
+ reason: result.reason
321
+ }
322
+ });
323
+ return null;
324
+ }
325
+ //#endregion
326
+ //#region src/ha-meter-fold.ts
327
+ /**
328
+ * Power and energy are a feature of the electrical device itself (D951). A
329
+ * metered plug is exposed by Home Assistant as a switch plus separate
330
+ * power / energy / voltage / current sensors; the switch carries the readings
331
+ * and the sensors stay as children (D865), flagged `foldedInto`.
332
+ */
333
+ /** The `power-meter` status field a reading lands in. */
334
+ var METER_FIELDS = [
335
+ "watts",
336
+ "kwhTotal",
337
+ "volts",
338
+ "amps"
339
+ ];
340
+ var METER_BY_DEVICE_CLASS = {
341
+ power: {
342
+ field: "watts",
343
+ quantity: "power"
344
+ },
345
+ energy: {
346
+ field: "kwhTotal",
347
+ quantity: "energy"
348
+ },
349
+ voltage: {
350
+ field: "volts",
351
+ quantity: "voltage"
352
+ },
353
+ current: {
354
+ field: "amps",
355
+ quantity: "current"
356
+ }
357
+ };
358
+ /** The meter field and quantity an HA `device_class` feeds; undefined for any other class. */
359
+ function meterKindForDeviceClass(deviceClass) {
360
+ return deviceClass === null || deviceClass === void 0 ? void 0 : METER_BY_DEVICE_CLASS[deviceClass];
361
+ }
362
+ /** Status with one reading applied; the clock moves with it. */
363
+ function applyMeterReading(prev, field, value, lastFetchedAt) {
364
+ return {
365
+ ...prev,
366
+ [field]: value,
367
+ lastFetchedAt
368
+ };
369
+ }
370
+ var LOAD_TYPES = new Set(require_dist.powerMeterCapability.deviceTypes.filter((type) => type !== require_dist.DeviceType.Sensor));
371
+ function isLoad(entity) {
372
+ return LOAD_TYPES.has(entity.deviceType) && entity.entityCategory === void 0 && !entity.capabilities.includes("power-meter");
373
+ }
374
+ function meterFieldOf(entity) {
375
+ if (!entity.capabilities.includes("power-meter")) return void 0;
376
+ return meterKindForDeviceClass(entity.deviceClass)?.field;
377
+ }
378
+ /**
379
+ * Fold the device's meter sensors into its electrical load: the first normal
380
+ * control of a load type gains `power-meter` and the entity ids it reads; each
381
+ * sensor it reads is flagged `foldedInto`. A device with no load, or no meter,
382
+ * comes back unchanged.
383
+ */
384
+ function foldMeterEntities(entities) {
385
+ const primary = entities.find(isLoad);
386
+ if (primary === void 0) return [...entities];
387
+ const meters = {};
388
+ for (const entity of entities) {
389
+ const field = meterFieldOf(entity);
390
+ if (field !== void 0 && meters[field] === void 0) meters[field] = entity.entityId;
391
+ }
392
+ const folded = new Set(Object.values(meters));
393
+ if (folded.size === 0) return [...entities];
394
+ return entities.map((entity) => {
395
+ if (entity.entityId === primary.entityId) return {
396
+ ...entity,
397
+ capabilities: [...entity.capabilities, "power-meter"],
398
+ meterEntities: meters
399
+ };
400
+ return folded.has(entity.entityId) ? {
401
+ ...entity,
402
+ foldedInto: primary.entityId
403
+ } : entity;
404
+ });
405
+ }
406
+ //#endregion
407
+ //#region src/ha-meter-carrier.ts
408
+ var COLD_START$20 = { lastFetchedAt: 0 };
409
+ /**
410
+ * The `power-meter` of a device whose power / energy readings come from sibling
411
+ * HA sensors (D951). It registers the cap, names the entities the owner must
412
+ * subscribe to, and turns each of their pushes into one field of the slice.
413
+ * The owner is any HA child that is an electrical load (switch, light, fan, ...);
414
+ * none of them knows how a reading is converted.
415
+ */
416
+ var HaMeterCarrier = class {
417
+ ctx;
418
+ runtimeState;
419
+ logger;
420
+ deviceId;
421
+ fieldByEntity;
422
+ constructor(ctx, runtimeState, logger, deviceId, meters) {
423
+ this.ctx = ctx;
424
+ this.runtimeState = runtimeState;
425
+ this.logger = logger;
426
+ this.deviceId = deviceId;
427
+ this.fieldByEntity = new Map(METER_FIELDS.flatMap((field) => {
428
+ const entityId = meters[field];
429
+ return entityId === void 0 ? [] : [[entityId, field]];
430
+ }));
431
+ this.ctx.registerNativeCap(require_dist.powerMeterCapability, { getStatus: async () => this.runtimeState.getCapState("power-meter") ?? COLD_START$20 });
432
+ this.runtimeState.setCapState("power-meter", {
433
+ ...COLD_START$20,
434
+ ...this.runtimeState.getCapState("power-meter")
435
+ });
436
+ }
437
+ /** The meter entities the owner must subscribe to, beside its own. */
438
+ entityIds() {
439
+ return [...this.fieldByEntity.keys()];
440
+ }
441
+ /** Whether a push belongs to one of the meter entities. */
442
+ carries(entityId) {
443
+ return this.fieldByEntity.has(entityId);
444
+ }
445
+ /** Apply one meter push; an unavailable or unreadable sample changes nothing. */
446
+ push(entityId, state) {
447
+ const field = this.fieldByEntity.get(entityId);
448
+ if (field === void 0) return;
449
+ if (state.state === "unavailable" || state.state === "unknown") return;
450
+ const value = Number(state.state);
451
+ if (!Number.isFinite(value)) return;
452
+ const deviceClass = state.attributes["device_class"];
453
+ const kind = meterKindForDeviceClass(typeof deviceClass === "string" ? deviceClass : null);
454
+ if (kind === void 0 || kind.field !== field) return;
455
+ const unit = state.attributes["unit_of_measurement"];
456
+ const stored = takeReading(this.logger, this.deviceId, value, typeof unit === "string" ? unit : void 0, kind.quantity);
457
+ if (stored === null) return;
458
+ const prev = this.runtimeState.getCapState("power-meter") ?? COLD_START$20;
459
+ const lastFetchedAt = Date.parse(state.last_updated) || Date.now();
460
+ this.runtimeState.setCapState("power-meter", applyMeterReading(prev, field, stored, lastFetchedAt));
461
+ }
462
+ };
463
+ //#endregion
295
464
  //#region src/ha-base-child-device.ts
296
465
  /**
297
466
  * Persisted config shared by every per-domain HA child device class.
@@ -357,6 +526,9 @@ var HaBaseChildDevice = class HaBaseChildDevice extends require_dist.BaseDevice
357
526
  /** Handle to the accessories cap when this device acts as a parent
358
527
  * that hosts entity-children. Null for plain child devices. */
359
528
  accessories = null;
529
+ /** The power-meter this device carries for its sibling meter sensors (D951);
530
+ * null for a device that is not metered through other entities. */
531
+ meterCarrier;
360
532
  constructor(ctx, options = {}) {
361
533
  const persisted = ctx.persistedConfig ?? {};
362
534
  const brokerId = persisted.brokerId;
@@ -372,6 +544,7 @@ var HaBaseChildDevice = class HaBaseChildDevice extends require_dist.BaseDevice
372
544
  this.features = parseDeviceFeatures(persisted.features);
373
545
  this.deviceLogger = ctx.deviceMeta.integrationId !== void 0 ? ctx.logger.withTags({ integrationId: ctx.deviceMeta.integrationId }) ?? ctx.logger : ctx.logger;
374
546
  this.online = true;
547
+ this.meterCarrier = persisted.meterEntities !== void 0 ? new HaMeterCarrier(this.ctx, this.runtimeState, this.deviceLogger, this.id, persisted.meterEntities) : null;
375
548
  this.attachEventBusListener();
376
549
  const childIds = Array.isArray(persisted.childDeviceIds) ? persisted.childDeviceIds : [];
377
550
  if (childIds.length > 0) {
@@ -535,7 +708,7 @@ var HaBaseChildDevice = class HaBaseChildDevice extends require_dist.BaseDevice
535
708
  try {
536
709
  const result = await this.ctx.api.broker.subscribe.mutate({
537
710
  brokerId: this.brokerId,
538
- filter: { entityIds: [this.entityId] }
711
+ filter: { entityIds: [this.entityId, ...this.meterCarrier?.entityIds() ?? []] }
539
712
  });
540
713
  this.brokerSubscriptionId = result.subscriptionId;
541
714
  return true;
@@ -554,8 +727,12 @@ var HaBaseChildDevice = class HaBaseChildDevice extends require_dist.BaseDevice
554
727
  const data = event.data;
555
728
  if (!data) return;
556
729
  if (data.brokerId !== this.brokerId) return;
557
- if (data.key !== this.entityId) return;
558
730
  const state = data.payload;
731
+ if (data.key !== void 0 && this.meterCarrier?.carries(data.key) === true) {
732
+ if (state) this.meterCarrier.push(data.key, state);
733
+ return;
734
+ }
735
+ if (data.key !== this.entityId) return;
559
736
  if (!state) return;
560
737
  if (state.state !== this.lastLoggedState) {
561
738
  this.deviceLogger.debug("HA→device value change", { meta: {
@@ -766,30 +943,6 @@ var HaSwitchDevice = class extends HaBaseChildDevice {
766
943
  }
767
944
  };
768
945
  //#endregion
769
- //#region src/ha-reading.ts
770
- /**
771
- * Convert one sample into the quantity's base unit. A refusal publishes
772
- * nothing (the caller stores null) and is logged with the device id —
773
- * an unknown unit is never guessed (D393, D944).
774
- */
775
- function takeReading(logger, deviceId, value, rawUnit, quantity) {
776
- const result = require_dist.ingestReading({
777
- value,
778
- rawUnit,
779
- quantity
780
- });
781
- if (result.ok) return result.value;
782
- logger.warn("numeric reading refused", {
783
- tags: { deviceId },
784
- meta: {
785
- quantity,
786
- unit: rawUnit ?? "",
787
- reason: result.reason
788
- }
789
- });
790
- return null;
791
- }
792
- //#endregion
793
946
  //#region src/devices/ha-numeric-sensor-device.ts
794
947
  var SUPPORTED_CAP_NAMES$1 = [
795
948
  "temperature-sensor",
@@ -3699,7 +3852,14 @@ var containerEntitySchema = require_dist.object({
3699
3852
  features: require_dist.array(require_dist.string()).optional(),
3700
3853
  name: require_dist.string().optional(),
3701
3854
  dateTimeFormat: require_dist.EnumSensorDateTimeFormatSchema.optional(),
3702
- entityCategory: require_dist._enum(["config", "diagnostic"]).optional()
3855
+ entityCategory: require_dist._enum(["config", "diagnostic"]).optional(),
3856
+ meterEntities: require_dist.object({
3857
+ watts: require_dist.string().optional(),
3858
+ kwhTotal: require_dist.string().optional(),
3859
+ volts: require_dist.string().optional(),
3860
+ amps: require_dist.string().optional()
3861
+ }).optional(),
3862
+ foldedInto: require_dist.string().optional()
3703
3863
  });
3704
3864
  function parseContainerEntities(raw) {
3705
3865
  const result = require_dist.array(containerEntitySchema).safeParse(raw);
@@ -3712,6 +3872,9 @@ function parseContainerEntities(raw) {
3712
3872
  * skipped (the adoption path skips them too).
3713
3873
  */
3714
3874
  function buildContainerEntities(haDevice) {
3875
+ return foldMeterEntities(mapContainerEntities(haDevice));
3876
+ }
3877
+ function mapContainerEntities(haDevice) {
3715
3878
  const out = [];
3716
3879
  for (const ent of haDevice.entities) {
3717
3880
  if (ent.mappedTo === null) continue;
@@ -3742,7 +3905,8 @@ function derivedKey(e) {
3742
3905
  deviceClass: e.deviceClass ?? null,
3743
3906
  options: e.options ?? null,
3744
3907
  features: e.features ?? null,
3745
- dateTimeFormat: e.dateTimeFormat ?? null
3908
+ dateTimeFormat: e.dateTimeFormat ?? null,
3909
+ meterEntities: e.meterEntities ?? null
3746
3910
  });
3747
3911
  }
3748
3912
  /** Stable key over the FULL entry incl. name + entityId + the layout-affecting
@@ -3755,6 +3919,7 @@ function fullKey(e) {
3755
3919
  entityId: e.entityId,
3756
3920
  name: e.name ?? null,
3757
3921
  entityCategory: e.entityCategory ?? null,
3922
+ foldedInto: e.foldedInto ?? null,
3758
3923
  derived: derivedKey(e)
3759
3924
  });
3760
3925
  }
@@ -3766,6 +3931,7 @@ function childConfigPatch(e) {
3766
3931
  ...e.options !== void 0 ? { options: e.options } : {},
3767
3932
  ...e.features !== void 0 ? { features: e.features } : {},
3768
3933
  ...e.dateTimeFormat !== void 0 ? { dateTimeFormat: e.dateTimeFormat } : {},
3934
+ ...e.meterEntities !== void 0 ? { meterEntities: e.meterEntities } : {},
3769
3935
  ...e.role !== void 0 ? { role: e.role } : {}
3770
3936
  };
3771
3937
  }
@@ -3819,7 +3985,7 @@ var SECTION_RANK = {
3819
3985
  * normal control). */
3820
3986
  function sectionForEntity(e) {
3821
3987
  if (e.entityCategory === "config") return SECTION_CONFIGURATION;
3822
- if (e.entityCategory === "diagnostic") return SECTION_DIAGNOSTIC;
3988
+ if (e.entityCategory === "diagnostic" || e.foldedInto !== void 0) return SECTION_DIAGNOSTIC;
3823
3989
  return SECTION_CONTROLS;
3824
3990
  }
3825
3991
  /**
@@ -3872,7 +4038,16 @@ var haEntitySchema = require_dist.object({
3872
4038
  dateTimeFormat: require_dist.EnumSensorDateTimeFormatSchema.optional(),
3873
4039
  /** HA `entity_category` — `config` / `diagnostic` / absent (a normal
3874
4040
  * control). Drives the accordion section the child renders in. */
3875
- entityCategory: require_dist._enum(["config", "diagnostic"]).optional()
4041
+ entityCategory: require_dist._enum(["config", "diagnostic"]).optional(),
4042
+ /** Sibling meter sensors this entity reads as its own power-meter (D951). */
4043
+ meterEntities: require_dist.object({
4044
+ watts: require_dist.string().optional(),
4045
+ kwhTotal: require_dist.string().optional(),
4046
+ volts: require_dist.string().optional(),
4047
+ amps: require_dist.string().optional()
4048
+ }).optional(),
4049
+ /** Set on a meter sensor folded into another entity: sectioned as Diagnostic. */
4050
+ foldedInto: require_dist.string().optional()
3876
4051
  });
3877
4052
  var haContainerSchema = require_dist.object({
3878
4053
  system: require_dist.literal("homeassistant"),
@@ -4187,6 +4362,7 @@ var HaContainerDevice = class HaContainerDevice extends require_dist.BaseDevice
4187
4362
  ...e.options !== void 0 ? { options: e.options } : {},
4188
4363
  ...e.features !== void 0 ? { features: e.features } : {},
4189
4364
  ...e.dateTimeFormat !== void 0 ? { dateTimeFormat: e.dateTimeFormat } : {},
4365
+ ...e.meterEntities !== void 0 ? { meterEntities: e.meterEntities } : {},
4190
4366
  ...e.role !== void 0 ? { role: e.role } : {}
4191
4367
  };
4192
4368
  const factory = (childCtx) => {
package/dist/addon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as lawnMowerControlCapability, $t as union, A as buttonCapability, At as weatherCapability, B as coverCapability, Bt as defineRecordKinds, C as batteryCapability, Ct as tryConvertDelta, D as brokerCapability, Dt as valveCapability, E as brightnessCapability, Et as vacuumControlCapability, F as connectivityCapability, G as eventEmitterCapability, Gt as array, H as deviceAdoptionCapability, It as RecordSecretUnavailableError, J as gasCapability, Jt as literal, K as fanControlCapability, Kt as boolean, Lt as baseUnitOf, M as carbonMonoxideCapability, Mt as DeviceRole, N as climateControlCapability, Nt as DeviceType, O as buildAddonRouteProvider, Ot as vibrationCapability, P as colorCapability, Pt as errMsg, Q as ingestReading, Qt as string, R as contactCapability, Rt as createEvent, S as automationControlCapability, St as temperatureSensorCapability, T as binaryCapability, Tt as updateCapability, V as deviceAdminLinkCapability, Vt as entryBody, W as enumSensorCapability, Wt as _enum, X as humiditySensorCapability, Xt as object, Y as humidifierCapability, Yt as number, Z as imageCapability, Zt as record, _ as accessoriesCapability, _t as runningStateCapability, at as notifierCapability, b as alarmPanelCapability, bt as switchCapability, ct as parseAdminBase, dt as presenceCapability, en as unknown, et as lockControlCapability, ft as pressureSensorCapability, g as TargetSchema, gt as runningFromVacuumState, h as RUNNING_STATE_COLD, ht as runningFromMediaPlayer, it as notificationOutputCapability, j as calendarSourceCapability, jt as DeviceFeature, k as buildDeviceAdminUrl, kt as waterHeaterCapability, lt as powerMeterCapability, mt as runningFromLawnMower, n as BaseDevice, nn as EventCategory, nt as motionCapability, ot as numericSensorCapability, pt as quantityFromUnit, q as floodCapability, qt as discriminatedUnion, r as BaseDeviceProvider, rt as normalizeUnit, tn as url, tt as mediaPlayerCapability, u as EnumSensorDateTimeFormatSchema, ut as prepareNotification, v as addonRoutesCapability, vt as scriptRunnerCapability, w as bestLocationMatch, wt as tryConvertUnit, x as ambientLightSensorCapability, xt as tamperCapability, y as airQualitySensorCapability, yt as smokeCapability, z as controlCapability, zt as defineEntryKind } from "./dist-CT3sP-p0.mjs";
1
+ import { $ as lawnMowerControlCapability, $t as union, A as buttonCapability, At as weatherCapability, B as coverCapability, Bt as defineRecordKinds, C as batteryCapability, Ct as tryConvertDelta, D as brokerCapability, Dt as valveCapability, E as brightnessCapability, Et as vacuumControlCapability, F as connectivityCapability, G as eventEmitterCapability, Gt as array, H as deviceAdoptionCapability, It as RecordSecretUnavailableError, J as gasCapability, Jt as literal, K as fanControlCapability, Kt as boolean, Lt as baseUnitOf, M as carbonMonoxideCapability, Mt as DeviceRole, N as climateControlCapability, Nt as DeviceType, O as buildAddonRouteProvider, Ot as vibrationCapability, P as colorCapability, Pt as errMsg, Q as ingestReading, Qt as string, R as contactCapability, Rt as createEvent, S as automationControlCapability, St as temperatureSensorCapability, T as binaryCapability, Tt as updateCapability, V as deviceAdminLinkCapability, Vt as entryBody, W as enumSensorCapability, Wt as _enum, X as humiditySensorCapability, Xt as object, Y as humidifierCapability, Yt as number, Z as imageCapability, Zt as record, _ as accessoriesCapability, _t as runningStateCapability, at as notifierCapability, b as alarmPanelCapability, bt as switchCapability, ct as parseAdminBase, dt as presenceCapability, en as unknown, et as lockControlCapability, ft as pressureSensorCapability, g as TargetSchema, gt as runningFromVacuumState, h as RUNNING_STATE_COLD, ht as runningFromMediaPlayer, it as notificationOutputCapability, j as calendarSourceCapability, jt as DeviceFeature, k as buildDeviceAdminUrl, kt as waterHeaterCapability, lt as powerMeterCapability, mt as runningFromLawnMower, n as BaseDevice, nn as EventCategory, nt as motionCapability, ot as numericSensorCapability, pt as quantityFromUnit, q as floodCapability, qt as discriminatedUnion, r as BaseDeviceProvider, rt as normalizeUnit, tn as url, tt as mediaPlayerCapability, u as EnumSensorDateTimeFormatSchema, ut as prepareNotification, v as addonRoutesCapability, vt as scriptRunnerCapability, w as bestLocationMatch, wt as tryConvertUnit, x as ambientLightSensorCapability, xt as tamperCapability, y as airQualitySensorCapability, yt as smokeCapability, z as controlCapability, zt as defineEntryKind } from "./dist-CPkvghX8.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
4
4
  //#region src/ha-admin-link.ts
@@ -125,7 +125,14 @@ var haSourceSchemaFields = {
125
125
  deviceClass: string().optional(),
126
126
  integrationId: string().optional(),
127
127
  options: array(string()).optional(),
128
- features: array(string()).optional()
128
+ features: array(string()).optional(),
129
+ /** Sibling meter sensors whose readings this entity carries as `power-meter` (D951). */
130
+ meterEntities: object({
131
+ watts: string().optional(),
132
+ kwhTotal: string().optional(),
133
+ volts: string().optional(),
134
+ amps: string().optional()
135
+ }).optional()
129
136
  };
130
137
  /** Project a persisted config blob into a typed source, or null when the
131
138
  * blob is not an HA-adopted device (no brokerId). */
@@ -291,6 +298,168 @@ var HaDomain = /* @__PURE__ */ function(HaDomain) {
291
298
  return HaDomain;
292
299
  }({});
293
300
  //#endregion
301
+ //#region src/ha-reading.ts
302
+ /**
303
+ * Convert one sample into the quantity's base unit. A refusal publishes
304
+ * nothing (the caller stores null) and is logged with the device id —
305
+ * an unknown unit is never guessed (D393, D944).
306
+ */
307
+ function takeReading(logger, deviceId, value, rawUnit, quantity) {
308
+ const result = ingestReading({
309
+ value,
310
+ rawUnit,
311
+ quantity
312
+ });
313
+ if (result.ok) return result.value;
314
+ logger.warn("numeric reading refused", {
315
+ tags: { deviceId },
316
+ meta: {
317
+ quantity,
318
+ unit: rawUnit ?? "",
319
+ reason: result.reason
320
+ }
321
+ });
322
+ return null;
323
+ }
324
+ //#endregion
325
+ //#region src/ha-meter-fold.ts
326
+ /**
327
+ * Power and energy are a feature of the electrical device itself (D951). A
328
+ * metered plug is exposed by Home Assistant as a switch plus separate
329
+ * power / energy / voltage / current sensors; the switch carries the readings
330
+ * and the sensors stay as children (D865), flagged `foldedInto`.
331
+ */
332
+ /** The `power-meter` status field a reading lands in. */
333
+ var METER_FIELDS = [
334
+ "watts",
335
+ "kwhTotal",
336
+ "volts",
337
+ "amps"
338
+ ];
339
+ var METER_BY_DEVICE_CLASS = {
340
+ power: {
341
+ field: "watts",
342
+ quantity: "power"
343
+ },
344
+ energy: {
345
+ field: "kwhTotal",
346
+ quantity: "energy"
347
+ },
348
+ voltage: {
349
+ field: "volts",
350
+ quantity: "voltage"
351
+ },
352
+ current: {
353
+ field: "amps",
354
+ quantity: "current"
355
+ }
356
+ };
357
+ /** The meter field and quantity an HA `device_class` feeds; undefined for any other class. */
358
+ function meterKindForDeviceClass(deviceClass) {
359
+ return deviceClass === null || deviceClass === void 0 ? void 0 : METER_BY_DEVICE_CLASS[deviceClass];
360
+ }
361
+ /** Status with one reading applied; the clock moves with it. */
362
+ function applyMeterReading(prev, field, value, lastFetchedAt) {
363
+ return {
364
+ ...prev,
365
+ [field]: value,
366
+ lastFetchedAt
367
+ };
368
+ }
369
+ var LOAD_TYPES = new Set(powerMeterCapability.deviceTypes.filter((type) => type !== DeviceType.Sensor));
370
+ function isLoad(entity) {
371
+ return LOAD_TYPES.has(entity.deviceType) && entity.entityCategory === void 0 && !entity.capabilities.includes("power-meter");
372
+ }
373
+ function meterFieldOf(entity) {
374
+ if (!entity.capabilities.includes("power-meter")) return void 0;
375
+ return meterKindForDeviceClass(entity.deviceClass)?.field;
376
+ }
377
+ /**
378
+ * Fold the device's meter sensors into its electrical load: the first normal
379
+ * control of a load type gains `power-meter` and the entity ids it reads; each
380
+ * sensor it reads is flagged `foldedInto`. A device with no load, or no meter,
381
+ * comes back unchanged.
382
+ */
383
+ function foldMeterEntities(entities) {
384
+ const primary = entities.find(isLoad);
385
+ if (primary === void 0) return [...entities];
386
+ const meters = {};
387
+ for (const entity of entities) {
388
+ const field = meterFieldOf(entity);
389
+ if (field !== void 0 && meters[field] === void 0) meters[field] = entity.entityId;
390
+ }
391
+ const folded = new Set(Object.values(meters));
392
+ if (folded.size === 0) return [...entities];
393
+ return entities.map((entity) => {
394
+ if (entity.entityId === primary.entityId) return {
395
+ ...entity,
396
+ capabilities: [...entity.capabilities, "power-meter"],
397
+ meterEntities: meters
398
+ };
399
+ return folded.has(entity.entityId) ? {
400
+ ...entity,
401
+ foldedInto: primary.entityId
402
+ } : entity;
403
+ });
404
+ }
405
+ //#endregion
406
+ //#region src/ha-meter-carrier.ts
407
+ var COLD_START$20 = { lastFetchedAt: 0 };
408
+ /**
409
+ * The `power-meter` of a device whose power / energy readings come from sibling
410
+ * HA sensors (D951). It registers the cap, names the entities the owner must
411
+ * subscribe to, and turns each of their pushes into one field of the slice.
412
+ * The owner is any HA child that is an electrical load (switch, light, fan, ...);
413
+ * none of them knows how a reading is converted.
414
+ */
415
+ var HaMeterCarrier = class {
416
+ ctx;
417
+ runtimeState;
418
+ logger;
419
+ deviceId;
420
+ fieldByEntity;
421
+ constructor(ctx, runtimeState, logger, deviceId, meters) {
422
+ this.ctx = ctx;
423
+ this.runtimeState = runtimeState;
424
+ this.logger = logger;
425
+ this.deviceId = deviceId;
426
+ this.fieldByEntity = new Map(METER_FIELDS.flatMap((field) => {
427
+ const entityId = meters[field];
428
+ return entityId === void 0 ? [] : [[entityId, field]];
429
+ }));
430
+ this.ctx.registerNativeCap(powerMeterCapability, { getStatus: async () => this.runtimeState.getCapState("power-meter") ?? COLD_START$20 });
431
+ this.runtimeState.setCapState("power-meter", {
432
+ ...COLD_START$20,
433
+ ...this.runtimeState.getCapState("power-meter")
434
+ });
435
+ }
436
+ /** The meter entities the owner must subscribe to, beside its own. */
437
+ entityIds() {
438
+ return [...this.fieldByEntity.keys()];
439
+ }
440
+ /** Whether a push belongs to one of the meter entities. */
441
+ carries(entityId) {
442
+ return this.fieldByEntity.has(entityId);
443
+ }
444
+ /** Apply one meter push; an unavailable or unreadable sample changes nothing. */
445
+ push(entityId, state) {
446
+ const field = this.fieldByEntity.get(entityId);
447
+ if (field === void 0) return;
448
+ if (state.state === "unavailable" || state.state === "unknown") return;
449
+ const value = Number(state.state);
450
+ if (!Number.isFinite(value)) return;
451
+ const deviceClass = state.attributes["device_class"];
452
+ const kind = meterKindForDeviceClass(typeof deviceClass === "string" ? deviceClass : null);
453
+ if (kind === void 0 || kind.field !== field) return;
454
+ const unit = state.attributes["unit_of_measurement"];
455
+ const stored = takeReading(this.logger, this.deviceId, value, typeof unit === "string" ? unit : void 0, kind.quantity);
456
+ if (stored === null) return;
457
+ const prev = this.runtimeState.getCapState("power-meter") ?? COLD_START$20;
458
+ const lastFetchedAt = Date.parse(state.last_updated) || Date.now();
459
+ this.runtimeState.setCapState("power-meter", applyMeterReading(prev, field, stored, lastFetchedAt));
460
+ }
461
+ };
462
+ //#endregion
294
463
  //#region src/ha-base-child-device.ts
295
464
  /**
296
465
  * Persisted config shared by every per-domain HA child device class.
@@ -356,6 +525,9 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
356
525
  /** Handle to the accessories cap when this device acts as a parent
357
526
  * that hosts entity-children. Null for plain child devices. */
358
527
  accessories = null;
528
+ /** The power-meter this device carries for its sibling meter sensors (D951);
529
+ * null for a device that is not metered through other entities. */
530
+ meterCarrier;
359
531
  constructor(ctx, options = {}) {
360
532
  const persisted = ctx.persistedConfig ?? {};
361
533
  const brokerId = persisted.brokerId;
@@ -371,6 +543,7 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
371
543
  this.features = parseDeviceFeatures(persisted.features);
372
544
  this.deviceLogger = ctx.deviceMeta.integrationId !== void 0 ? ctx.logger.withTags({ integrationId: ctx.deviceMeta.integrationId }) ?? ctx.logger : ctx.logger;
373
545
  this.online = true;
546
+ this.meterCarrier = persisted.meterEntities !== void 0 ? new HaMeterCarrier(this.ctx, this.runtimeState, this.deviceLogger, this.id, persisted.meterEntities) : null;
374
547
  this.attachEventBusListener();
375
548
  const childIds = Array.isArray(persisted.childDeviceIds) ? persisted.childDeviceIds : [];
376
549
  if (childIds.length > 0) {
@@ -534,7 +707,7 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
534
707
  try {
535
708
  const result = await this.ctx.api.broker.subscribe.mutate({
536
709
  brokerId: this.brokerId,
537
- filter: { entityIds: [this.entityId] }
710
+ filter: { entityIds: [this.entityId, ...this.meterCarrier?.entityIds() ?? []] }
538
711
  });
539
712
  this.brokerSubscriptionId = result.subscriptionId;
540
713
  return true;
@@ -553,8 +726,12 @@ var HaBaseChildDevice = class HaBaseChildDevice extends BaseDevice {
553
726
  const data = event.data;
554
727
  if (!data) return;
555
728
  if (data.brokerId !== this.brokerId) return;
556
- if (data.key !== this.entityId) return;
557
729
  const state = data.payload;
730
+ if (data.key !== void 0 && this.meterCarrier?.carries(data.key) === true) {
731
+ if (state) this.meterCarrier.push(data.key, state);
732
+ return;
733
+ }
734
+ if (data.key !== this.entityId) return;
558
735
  if (!state) return;
559
736
  if (state.state !== this.lastLoggedState) {
560
737
  this.deviceLogger.debug("HA→device value change", { meta: {
@@ -765,30 +942,6 @@ var HaSwitchDevice = class extends HaBaseChildDevice {
765
942
  }
766
943
  };
767
944
  //#endregion
768
- //#region src/ha-reading.ts
769
- /**
770
- * Convert one sample into the quantity's base unit. A refusal publishes
771
- * nothing (the caller stores null) and is logged with the device id —
772
- * an unknown unit is never guessed (D393, D944).
773
- */
774
- function takeReading(logger, deviceId, value, rawUnit, quantity) {
775
- const result = ingestReading({
776
- value,
777
- rawUnit,
778
- quantity
779
- });
780
- if (result.ok) return result.value;
781
- logger.warn("numeric reading refused", {
782
- tags: { deviceId },
783
- meta: {
784
- quantity,
785
- unit: rawUnit ?? "",
786
- reason: result.reason
787
- }
788
- });
789
- return null;
790
- }
791
- //#endregion
792
945
  //#region src/devices/ha-numeric-sensor-device.ts
793
946
  var SUPPORTED_CAP_NAMES$1 = [
794
947
  "temperature-sensor",
@@ -3698,7 +3851,14 @@ var containerEntitySchema = object({
3698
3851
  features: array(string()).optional(),
3699
3852
  name: string().optional(),
3700
3853
  dateTimeFormat: EnumSensorDateTimeFormatSchema.optional(),
3701
- entityCategory: _enum(["config", "diagnostic"]).optional()
3854
+ entityCategory: _enum(["config", "diagnostic"]).optional(),
3855
+ meterEntities: object({
3856
+ watts: string().optional(),
3857
+ kwhTotal: string().optional(),
3858
+ volts: string().optional(),
3859
+ amps: string().optional()
3860
+ }).optional(),
3861
+ foldedInto: string().optional()
3702
3862
  });
3703
3863
  function parseContainerEntities(raw) {
3704
3864
  const result = array(containerEntitySchema).safeParse(raw);
@@ -3711,6 +3871,9 @@ function parseContainerEntities(raw) {
3711
3871
  * skipped (the adoption path skips them too).
3712
3872
  */
3713
3873
  function buildContainerEntities(haDevice) {
3874
+ return foldMeterEntities(mapContainerEntities(haDevice));
3875
+ }
3876
+ function mapContainerEntities(haDevice) {
3714
3877
  const out = [];
3715
3878
  for (const ent of haDevice.entities) {
3716
3879
  if (ent.mappedTo === null) continue;
@@ -3741,7 +3904,8 @@ function derivedKey(e) {
3741
3904
  deviceClass: e.deviceClass ?? null,
3742
3905
  options: e.options ?? null,
3743
3906
  features: e.features ?? null,
3744
- dateTimeFormat: e.dateTimeFormat ?? null
3907
+ dateTimeFormat: e.dateTimeFormat ?? null,
3908
+ meterEntities: e.meterEntities ?? null
3745
3909
  });
3746
3910
  }
3747
3911
  /** Stable key over the FULL entry incl. name + entityId + the layout-affecting
@@ -3754,6 +3918,7 @@ function fullKey(e) {
3754
3918
  entityId: e.entityId,
3755
3919
  name: e.name ?? null,
3756
3920
  entityCategory: e.entityCategory ?? null,
3921
+ foldedInto: e.foldedInto ?? null,
3757
3922
  derived: derivedKey(e)
3758
3923
  });
3759
3924
  }
@@ -3765,6 +3930,7 @@ function childConfigPatch(e) {
3765
3930
  ...e.options !== void 0 ? { options: e.options } : {},
3766
3931
  ...e.features !== void 0 ? { features: e.features } : {},
3767
3932
  ...e.dateTimeFormat !== void 0 ? { dateTimeFormat: e.dateTimeFormat } : {},
3933
+ ...e.meterEntities !== void 0 ? { meterEntities: e.meterEntities } : {},
3768
3934
  ...e.role !== void 0 ? { role: e.role } : {}
3769
3935
  };
3770
3936
  }
@@ -3818,7 +3984,7 @@ var SECTION_RANK = {
3818
3984
  * normal control). */
3819
3985
  function sectionForEntity(e) {
3820
3986
  if (e.entityCategory === "config") return SECTION_CONFIGURATION;
3821
- if (e.entityCategory === "diagnostic") return SECTION_DIAGNOSTIC;
3987
+ if (e.entityCategory === "diagnostic" || e.foldedInto !== void 0) return SECTION_DIAGNOSTIC;
3822
3988
  return SECTION_CONTROLS;
3823
3989
  }
3824
3990
  /**
@@ -3871,7 +4037,16 @@ var haEntitySchema = object({
3871
4037
  dateTimeFormat: EnumSensorDateTimeFormatSchema.optional(),
3872
4038
  /** HA `entity_category` — `config` / `diagnostic` / absent (a normal
3873
4039
  * control). Drives the accordion section the child renders in. */
3874
- entityCategory: _enum(["config", "diagnostic"]).optional()
4040
+ entityCategory: _enum(["config", "diagnostic"]).optional(),
4041
+ /** Sibling meter sensors this entity reads as its own power-meter (D951). */
4042
+ meterEntities: object({
4043
+ watts: string().optional(),
4044
+ kwhTotal: string().optional(),
4045
+ volts: string().optional(),
4046
+ amps: string().optional()
4047
+ }).optional(),
4048
+ /** Set on a meter sensor folded into another entity: sectioned as Diagnostic. */
4049
+ foldedInto: string().optional()
3875
4050
  });
3876
4051
  var haContainerSchema = object({
3877
4052
  system: literal("homeassistant"),
@@ -4186,6 +4361,7 @@ var HaContainerDevice = class HaContainerDevice extends BaseDevice {
4186
4361
  ...e.options !== void 0 ? { options: e.options } : {},
4187
4362
  ...e.features !== void 0 ? { features: e.features } : {},
4188
4363
  ...e.dateTimeFormat !== void 0 ? { dateTimeFormat: e.dateTimeFormat } : {},
4364
+ ...e.meterEntities !== void 0 ? { meterEntities: e.meterEntities } : {},
4189
4365
  ...e.role !== void 0 ? { role: e.role } : {}
4190
4366
  };
4191
4367
  const factory = (childCtx) => {