@camstack/addon-provider-enpal 0.1.1 → 0.1.2

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.mjs CHANGED
@@ -10469,20 +10469,6 @@ object({
10469
10469
  code: string().regex(/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/),
10470
10470
  params: record(string(), union([string(), number()])).optional()
10471
10471
  });
10472
- var CamstackError = class extends Error {
10473
- i18nCode;
10474
- i18nParams;
10475
- constructor(code, params, englishMessage) {
10476
- super(englishMessage);
10477
- this.name = "CamstackError";
10478
- this.i18nCode = code;
10479
- this.i18nParams = params;
10480
- }
10481
- };
10482
- /** `throw userError('device.not-found', { id }, 'Device 12 not found')` */
10483
- function userError(code, params, englishFallback) {
10484
- return new CamstackError(code, params, englishFallback);
10485
- }
10486
10472
  //#endregion
10487
10473
  //#region ../types/dist/err-msg-DIorGYeC.mjs
10488
10474
  var DeviceType = /* @__PURE__ */ function(DeviceType) {
@@ -21032,19 +21018,36 @@ var ResyncResultSchema = object({
21032
21018
  * provider re-derived the device. 0/absent for a normal incremental re-sync. */
21033
21019
  removedChildren: number().int().nonnegative().optional()
21034
21020
  });
21035
- method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }), method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }), method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }), method(object({ integrationId: string() }), AdoptionStatusSchema, {
21036
- kind: "mutation",
21037
- auth: "admin"
21038
- }), method(AdoptInputSchema, AdoptResultSchema, {
21039
- kind: "mutation",
21040
- auth: "admin"
21041
- }), method(ReleaseInputSchema, _void(), {
21042
- kind: "mutation",
21043
- auth: "admin"
21044
- }), method(ResyncInputSchema, ResyncResultSchema, {
21045
- kind: "mutation",
21046
- auth: "admin"
21047
- });
21021
+ var deviceAdoptionCapability = {
21022
+ name: "device-adoption",
21023
+ scope: "system",
21024
+ mode: "collection",
21025
+ status: {
21026
+ schema: AdoptionStatusSchema,
21027
+ kind: "poll"
21028
+ },
21029
+ methods: {
21030
+ listCandidateFilters: method(object({ integrationId: string() }), object({ filters: array(AdoptionFilterSchema) }), { auth: "admin" }),
21031
+ listCandidates: method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }),
21032
+ getCandidate: method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }),
21033
+ refresh: method(object({ integrationId: string() }), AdoptionStatusSchema, {
21034
+ kind: "mutation",
21035
+ auth: "admin"
21036
+ }),
21037
+ adopt: method(AdoptInputSchema, AdoptResultSchema, {
21038
+ kind: "mutation",
21039
+ auth: "admin"
21040
+ }),
21041
+ release: method(ReleaseInputSchema, _void(), {
21042
+ kind: "mutation",
21043
+ auth: "admin"
21044
+ }),
21045
+ resync: method(ResyncInputSchema, ResyncResultSchema, {
21046
+ kind: "mutation",
21047
+ auth: "admin"
21048
+ })
21049
+ }
21050
+ };
21048
21051
  /**
21049
21052
  * `device-export` — collection cap for addons that export camstack
21050
21053
  * devices to external ecosystems (HomeAssistant via MQTT discovery,
@@ -64360,9 +64363,9 @@ var PowerMeterStatusSchema = object({
64360
64363
  /** Cumulative energy in kilowatt-hours since the meter was reset. */
64361
64364
  kwhTotal: number().optional().meta(readingMeta("energy", { label: "Energy" })),
64362
64365
  /** Voltage in volts. */
64363
- volts: number().optional().meta(readingMeta("voltage")),
64366
+ volts: number().optional().meta(readingMeta("voltage", { label: "Voltage" })),
64364
64367
  /** Current in amperes. */
64365
- amps: number().optional().meta(readingMeta("current")),
64368
+ amps: number().optional().meta(readingMeta("current", { label: "Current" })),
64366
64369
  /** Apparent power in volt-amperes. */
64367
64370
  va: number().optional().meta(readingMeta("apparent-power")),
64368
64371
  /** Power factor (0..1) if reported. */
@@ -64370,17 +64373,74 @@ var PowerMeterStatusSchema = object({
64370
64373
  /** Ms epoch when the slice was last updated. */
64371
64374
  lastFetchedAt: number()
64372
64375
  });
64376
+ /**
64377
+ * Loads that draw metered mains power (D951). The reading is a feature of the
64378
+ * device itself — a plug, a lamp, a fan, an air conditioner, a boiler, a
64379
+ * vacuum dock — not a separate sensor beside it. A `Sensor` stays: a clamp
64380
+ * meter or a standalone energy monitor IS the device. Cover, lock, valve,
64381
+ * camera and the like are left out: their draw is not what anyone meters.
64382
+ */
64383
+ var POWER_METER_DEVICE_TYPES = [
64384
+ DeviceType.Sensor,
64385
+ DeviceType.Switch,
64386
+ DeviceType.Light,
64387
+ DeviceType.Fan,
64388
+ DeviceType.Climate,
64389
+ DeviceType.Thermostat,
64390
+ DeviceType.Humidifier,
64391
+ DeviceType.WaterHeater,
64392
+ DeviceType.MediaPlayer,
64393
+ DeviceType.Washer,
64394
+ DeviceType.Dryer,
64395
+ DeviceType.Dishwasher,
64396
+ DeviceType.Vacuum,
64397
+ DeviceType.LawnMower,
64398
+ DeviceType.PetFeeder
64399
+ ];
64400
+ /** Everything but a bare `Sensor`: a sensor that takes its reading from another sensor adds nothing. */
64401
+ var POWER_METER_CUSTOMIZE_DEVICE_TYPES = POWER_METER_DEVICE_TYPES.filter((type) => type !== DeviceType.Sensor);
64402
+ /** One scalar target per reading; each is sourced from a device of its own (D951). */
64403
+ function powerTarget(id, unit) {
64404
+ return {
64405
+ id,
64406
+ cardinality: "scalar",
64407
+ vocabulary: {
64408
+ cap: "power-meter",
64409
+ stateField: id
64410
+ },
64411
+ deviceTypes: POWER_METER_CUSTOMIZE_DEVICE_TYPES,
64412
+ apply: "add",
64413
+ path: id,
64414
+ sources: [{
64415
+ kind: "numeric",
64416
+ requiredUnit: unit,
64417
+ transforms: [{ kind: "identity" }]
64418
+ }],
64419
+ companions: [{
64420
+ kind: "clock",
64421
+ path: "lastFetchedAt",
64422
+ when: "always"
64423
+ }],
64424
+ sourceReport: { status: "assignment-status" },
64425
+ advancedExpression: true
64426
+ };
64427
+ }
64428
+ /** "Power and energy" extension: watts, energy, voltage and current from other devices (D951). */
64429
+ var powerMeterCustomize = {
64430
+ version: 1,
64431
+ targets: [
64432
+ powerTarget("watts", "W"),
64433
+ powerTarget("kwhTotal", "kWh"),
64434
+ powerTarget("volts", "V"),
64435
+ powerTarget("amps", "A")
64436
+ ]
64437
+ };
64373
64438
  var powerMeterCapability = {
64374
64439
  name: "power-meter",
64375
64440
  scope: "device",
64376
64441
  deviceNative: true,
64377
64442
  mode: "singleton",
64378
- deviceTypes: [
64379
- DeviceType.Sensor,
64380
- DeviceType.Washer,
64381
- DeviceType.Dryer,
64382
- DeviceType.Dishwasher
64383
- ],
64443
+ deviceTypes: POWER_METER_DEVICE_TYPES,
64384
64444
  methods: {},
64385
64445
  status: {
64386
64446
  schema: PowerMeterStatusSchema,
@@ -64437,7 +64497,8 @@ var powerMeterCapability = {
64437
64497
  } },
64438
64498
  /** Clock fields: written, but excluded from the compare that decides
64439
64499
  * whether persisting is worth a SQLite commit. */
64440
- volatileStateFields: ["lastFetchedAt"]
64500
+ volatileStateFields: ["lastFetchedAt"],
64501
+ customize: powerMeterCustomize
64441
64502
  };
64442
64503
  /**
64443
64504
  * Presence cap. Models HA `person.*` and `device_tracker.*` entities
@@ -70208,7 +70269,7 @@ function tryConvertUnit(value, from, to) {
70208
70269
  return fromCanonical(toCanonical(value, fromSpec), toSpec);
70209
70270
  }
70210
70271
  SOURCE_CAPS.filter((cap) => cap !== "switch" && cap !== "motion");
70211
- DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Camera, DeviceType.Camera, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder;
70272
+ DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Dryer, DeviceType.Camera, DeviceType.Camera, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Switch, DeviceType.Light, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.PetFeeder, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder;
70212
70273
  String.raw`rose\(active(\d+), changed(\d+), (\d+(?:\.\d+)?)\)`;
70213
70274
  DeviceRole.BinarySensor, DeviceRole.MotionSensor;
70214
70275
  new Set(CAP_NAMES);
@@ -89642,9 +89703,9 @@ var MB = 1024 * 1024;
89642
89703
  //#endregion
89643
89704
  //#region src/config.ts
89644
89705
  /**
89645
- * Operator-supplied connection for ONE Enpal box. The addon runs in `standalone`
89646
- * mode (the VeSync / Ecowitt / Velux pattern): the CONNECTION lives on the box
89647
- * DEVICE config, there is no broker registry.
89706
+ * Operator-supplied connection of ONE Enpal integration (`mode: account`, broker-less): the
89707
+ * settings of the integration itself, not of a device. Every equipment the box reports is
89708
+ * adopted as a Container from it.
89648
89709
  *
89649
89710
  * Two sources behind `source`:
89650
89711
  * - `influx`: the box's InfluxDB 2.x (port 8086, org `enpal`, bucket `solar`).
@@ -89665,6 +89726,10 @@ function markerToEmpty(value) {
89665
89726
  return value === null || value === void 0 ? "" : value;
89666
89727
  }
89667
89728
  var blankToUndefined = (v) => v === "" || v === null ? void 0 : v;
89729
+ /** Names that are interpolated into a Flux string: no quote, no space, no operator. */
89730
+ var FLUX_NAME = /^[A-Za-z0-9_.-]+$/;
89731
+ /** The device-registry `system` tag every Enpal device carries. */
89732
+ var ENPAL_SYSTEM = "enpal";
89668
89733
  var enpalConnectionSchema = object({
89669
89734
  host: preprocess(cleanHost, string().min(1).regex(/^[A-Za-z0-9._-]+$/, "host name or IP address only").describe("Enpal box host name or IP address")),
89670
89735
  source: _enum([
@@ -89674,7 +89739,7 @@ var enpalConnectionSchema = object({
89674
89739
  ]).default("auto").describe("Where the readings come from"),
89675
89740
  influxPort: preprocess(blankToUndefined, number().int().min(1).max(65535).default(8086)).describe("InfluxDB port"),
89676
89741
  org: string().trim().min(1).default("enpal").describe("InfluxDB organisation"),
89677
- bucket: string().trim().regex(/^[A-Za-z0-9_.-]+$/, "letters, digits, dot, dash and underscore only").default("solar").describe("InfluxDB bucket"),
89742
+ bucket: string().trim().regex(FLUX_NAME, "letters, digits, dot, dash and underscore only").default("solar").describe("InfluxDB bucket"),
89678
89743
  token: preprocess(markerToEmpty, string().default("")).describe("InfluxDB access token (issued by Enpal support)").meta({ secret: true }),
89679
89744
  pagePort: preprocess(blankToUndefined, number().int().min(1).max(65535).default(80)).describe("Device page HTTP port"),
89680
89745
  pollIntervalMs: preprocess(blankToUndefined, number().int().min(1e4).max(36e5).default(3e4)).describe("Poll interval (ms)"),
@@ -89697,30 +89762,21 @@ function readConnection(settings) {
89697
89762
  tokenLocked: isSecretMarkerLike(raw["token"])
89698
89763
  };
89699
89764
  }
89700
- /** Persisted config for the Enpal box {@link import('@camstack/types').DeviceType.Hub} device. */
89701
- var enpalBoxSchema = object({
89702
- connection: enpalConnectionSchema,
89703
- /** Provenance marker used to scope offline / cascade helpers. */
89704
- system: literal("enpal-box").optional()
89705
- });
89706
- /**
89707
- * Persisted config every adopted Enpal device carries. `specId` selects the entry of the
89708
- * device catalog (PV, battery, grid import, ...); `boxId` selects the parent box (its Hub
89709
- * `stableId`) in the in-process resolver.
89710
- */
89711
- var enpalSensorSchema = object({
89712
- specId: string().min(1).describe("Device catalog entry id"),
89713
- boxId: string().min(1).describe("Parent box stableId"),
89714
- system: literal("enpal").optional()
89715
- });
89716
- /** Slugify a host into a readable, stable row-key fragment. */
89717
- function slugifyHost(value) {
89718
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
89765
+ /** Parse an integration's settings into a connection, or null while its host is missing. */
89766
+ function connectionFromSettings(settings) {
89767
+ const raw = toRecord(settings);
89768
+ const parsed = enpalConnectionSchema.safeParse(raw);
89769
+ if (!parsed.success) return null;
89770
+ return {
89771
+ connection: parsed.data,
89772
+ tokenLocked: isSecretMarkerLike(raw["token"])
89773
+ };
89719
89774
  }
89720
- /**
89721
- * Hand-written connection form for the box device-creation UI. The connection lives on the box
89722
- * DEVICE config; a `name` field lets the operator name it at creation time.
89723
- */
89775
+ /** True when two connections would poll the box the same way. */
89776
+ function sameConnection(a, b) {
89777
+ return JSON.stringify(a) === JSON.stringify(b);
89778
+ }
89779
+ /** The integration's settings form (`globalSettingsSchema`); the integration carries the name. */
89724
89780
  function buildConnectionFormSchema() {
89725
89781
  return { sections: [
89726
89782
  {
@@ -89729,13 +89785,6 @@ function buildConnectionFormSchema() {
89729
89785
  titleKey: "provider-enpal:connection.s_identity.title",
89730
89786
  columns: 1,
89731
89787
  fields: [{
89732
- type: "text",
89733
- key: "name",
89734
- label: "Name",
89735
- labelKey: "provider-enpal:connection.s_identity.name.label",
89736
- required: true,
89737
- placeholder: "Enpal"
89738
- }, {
89739
89788
  type: "text",
89740
89789
  key: "host",
89741
89790
  label: "Box host / IP",
@@ -89890,7 +89939,7 @@ var ENPAL_SPECS = [
89890
89939
  {
89891
89940
  kind: "power-meter",
89892
89941
  id: "pv",
89893
- name: "PV production",
89942
+ name: "Production",
89894
89943
  role: DeviceRole.PowerSensor,
89895
89944
  watts: point("power", "Power.DC.Total", "Power.DC.Total.Calculated", "Power.DC.Total.Huawei", "Power.Production.Total"),
89896
89945
  kwh: point("energy", "Energy.Production.Total.Lifetime")
@@ -89909,7 +89958,7 @@ var ENPAL_SPECS = [
89909
89958
  {
89910
89959
  kind: "meter",
89911
89960
  id: "battery-charged",
89912
- name: "Battery energy charged",
89961
+ name: "Energy charged",
89913
89962
  role: DeviceRole.EnergySensor,
89914
89963
  quantity: "energy",
89915
89964
  value: point("energy", "Energy.Battery.Charge.Lifetime")
@@ -89917,7 +89966,7 @@ var ENPAL_SPECS = [
89917
89966
  {
89918
89967
  kind: "meter",
89919
89968
  id: "battery-discharged",
89920
- name: "Battery energy discharged",
89969
+ name: "Energy discharged",
89921
89970
  role: DeviceRole.EnergySensor,
89922
89971
  quantity: "energy",
89923
89972
  value: point("energy", "Energy.Battery.Discharge.Lifetime")
@@ -89925,7 +89974,7 @@ var ENPAL_SPECS = [
89925
89974
  {
89926
89975
  kind: "power-meter",
89927
89976
  id: "grid-import",
89928
- name: "Grid import",
89977
+ name: "Import",
89929
89978
  role: DeviceRole.PowerSensor,
89930
89979
  watts: point("power", "Grid.Import.Power.Total.Calculated", {
89931
89980
  key: "Power.Grid.Export",
@@ -89942,7 +89991,7 @@ var ENPAL_SPECS = [
89942
89991
  {
89943
89992
  kind: "power-meter",
89944
89993
  id: "grid-export",
89945
- name: "Grid export",
89994
+ name: "Export",
89946
89995
  role: DeviceRole.PowerSensor,
89947
89996
  watts: point("power", {
89948
89997
  key: "Power.Grid.Export",
@@ -89959,7 +90008,7 @@ var ENPAL_SPECS = [
89959
90008
  {
89960
90009
  kind: "power-meter",
89961
90010
  id: "house",
89962
- name: "House consumption",
90011
+ name: "Consumption",
89963
90012
  role: DeviceRole.PowerSensor,
89964
90013
  watts: point("power", "Power.Consumption.Total", "Power.Consumption.AC"),
89965
90014
  kwh: point("energy", "Energy.Consumption.Total.Lifetime")
@@ -89967,7 +90016,7 @@ var ENPAL_SPECS = [
89967
90016
  {
89968
90017
  kind: "power-meter",
89969
90018
  id: "heatpump",
89970
- name: "Heat pump",
90019
+ name: "Consumption",
89971
90020
  role: DeviceRole.PowerSensor,
89972
90021
  watts: point("power", "Heatpump.Power.Consumption.Total"),
89973
90022
  kwh: point("energy", "Heatpump.Energy.Consumption.Total.Lifetime")
@@ -89975,7 +90024,7 @@ var ENPAL_SPECS = [
89975
90024
  {
89976
90025
  kind: "numeric",
89977
90026
  id: "heatpump-hot-water",
89978
- name: "Heat pump hot water",
90027
+ name: "Hot water temperature",
89979
90028
  role: DeviceRole.TemperatureSensor,
89980
90029
  quantity: "temperature",
89981
90030
  value: point("temperature", "Heatpump.DomesticHotWater.Temperature")
@@ -89983,7 +90032,7 @@ var ENPAL_SPECS = [
89983
90032
  {
89984
90033
  kind: "numeric",
89985
90034
  id: "heatpump-outside",
89986
- name: "Heat pump outside temperature",
90035
+ name: "Outside temperature",
89987
90036
  role: DeviceRole.TemperatureSensor,
89988
90037
  quantity: "temperature",
89989
90038
  value: point("temperature", "Heatpump.Outside.Temperature")
@@ -89999,7 +90048,7 @@ var ENPAL_SPECS = [
89999
90048
  {
90000
90049
  kind: "numeric",
90001
90050
  id: "battery-temperature",
90002
- name: "Battery temperature",
90051
+ name: "Temperature",
90003
90052
  role: DeviceRole.TemperatureSensor,
90004
90053
  quantity: "temperature",
90005
90054
  value: point("temperature", "Temperature.Battery")
@@ -90007,7 +90056,7 @@ var ENPAL_SPECS = [
90007
90056
  {
90008
90057
  kind: "numeric",
90009
90058
  id: "battery-health",
90010
- name: "Battery health",
90059
+ name: "Health",
90011
90060
  role: DeviceRole.NumericSensor,
90012
90061
  quantity: "percentage",
90013
90062
  value: point("percentage", "Battery.SOH")
@@ -90164,36 +90213,528 @@ function computeSlices(spec, snapshot, options, now) {
90164
90213
  }
90165
90214
  }
90166
90215
  //#endregion
90167
- //#region src/enpal-discovery.ts
90168
- /** The `childNativeId` of a catalog entry on a box: namespaced so two boxes never collide. */
90169
- function specNativeId(boxId, specId) {
90170
- return `${boxId}:${specId}`;
90216
+ //#region src/enpal-counter-guard.ts
90217
+ /**
90218
+ * Smaller than the history deadband of the lifetime counters (0.01 kWh): float noise from
90219
+ * rounding or a unit conversion, never a real drop.
90220
+ */
90221
+ var NOISE = .01;
90222
+ /**
90223
+ * Keeps a lifetime counter monotonic before it reaches a `counter` history series, where a
90224
+ * drop means "the meter was reset" and re-bases every later total.
90225
+ *
90226
+ * The box occasionally reports 0 or a stale lower figure for one poll (a restart, a partly
90227
+ * written window). One such reading is held back: the previous value is published instead.
90228
+ * A lower reading that is then followed by a value at or above it is a genuine reset or a
90229
+ * replaced meter, and is accepted.
90230
+ */
90231
+ var LifetimeCounterGuard = class {
90232
+ #published = null;
90233
+ #suspect = null;
90234
+ /** The value to publish for the reading `next`. */
90235
+ accept(next) {
90236
+ const published = this.#published;
90237
+ if (published === null || next >= published - NOISE) {
90238
+ this.#published = next;
90239
+ this.#suspect = null;
90240
+ return next;
90241
+ }
90242
+ const suspect = this.#suspect;
90243
+ if (suspect !== null && next >= suspect - NOISE) {
90244
+ this.#published = next;
90245
+ this.#suspect = null;
90246
+ return next;
90247
+ }
90248
+ this.#suspect = next;
90249
+ return published;
90250
+ }
90251
+ };
90252
+ //#endregion
90253
+ //#region src/enpal-resolver.ts
90254
+ /**
90255
+ * Per-integration registry that the adopted devices use to reach the live
90256
+ * {@link EnpalIntegrationManager}. Keyed on the integration id; the client registry publishes
90257
+ * a new manager here whenever the connection changes, and the sensors read through the
90258
+ * resolver so they follow it.
90259
+ */
90260
+ var EnpalConnectionResolver = class {
90261
+ #managers = /* @__PURE__ */ new Map();
90262
+ #subs = /* @__PURE__ */ new Map();
90263
+ set(integrationId, manager) {
90264
+ if (manager === null) {
90265
+ this.#managers.delete(integrationId);
90266
+ return;
90267
+ }
90268
+ this.#managers.set(integrationId, manager);
90269
+ }
90270
+ getManager(integrationId) {
90271
+ return this.#managers.get(integrationId) ?? null;
90272
+ }
90273
+ /** The latest snapshot, or null when the box is unreachable (never stale readings). */
90274
+ getSnapshot(integrationId) {
90275
+ return this.#managers.get(integrationId)?.getSnapshot() ?? null;
90276
+ }
90277
+ /** Subscribe to poll ticks (successful or not). Returns an unsubscribe function. */
90278
+ onSnapshot(integrationId, cb) {
90279
+ let set = this.#subs.get(integrationId);
90280
+ if (set === void 0) {
90281
+ set = /* @__PURE__ */ new Set();
90282
+ this.#subs.set(integrationId, set);
90283
+ }
90284
+ set.add(cb);
90285
+ return () => {
90286
+ const current = this.#subs.get(integrationId);
90287
+ current?.delete(cb);
90288
+ if (current !== void 0 && current.size === 0) this.#subs.delete(integrationId);
90289
+ };
90290
+ }
90291
+ emitSnapshot(integrationId) {
90292
+ const set = this.#subs.get(integrationId);
90293
+ if (set === void 0) return;
90294
+ for (const cb of set) try {
90295
+ cb();
90296
+ } catch {}
90297
+ }
90298
+ clear() {
90299
+ this.#managers.clear();
90300
+ this.#subs.clear();
90301
+ }
90302
+ };
90303
+ var enpalConnections = new EnpalConnectionResolver();
90304
+ //#endregion
90305
+ //#region src/devices/enpal-sensor-device.ts
90306
+ var POWER_CAP = powerMeterCapability.name;
90307
+ var BATTERY_CAP = batteryCapability.name;
90308
+ var METER_CAP = meterCapability.name;
90309
+ var NUMERIC_CAP = numericSensorCapability.name;
90310
+ var POWER_COLD = { lastFetchedAt: 0 };
90311
+ var BATTERY_COLD = {
90312
+ percentage: null,
90313
+ charging: "unknown",
90314
+ sleeping: false,
90315
+ lastUpdated: 0
90316
+ };
90317
+ var VALUE_COLD = {
90318
+ value: null,
90319
+ lastFetchedAt: 0
90320
+ };
90321
+ /**
90322
+ * Persisted config of an Enpal Sensor. `specId` selects the entry of the catalog;
90323
+ * `integrationId` selects the integration whose box feeds it.
90324
+ */
90325
+ var enpalSensorSchema = object({
90326
+ specId: string().min(1).describe("Device catalog entry id"),
90327
+ integrationId: string().min(1).describe("Owning integration"),
90328
+ system: literal(ENPAL_SYSTEM).optional()
90329
+ });
90330
+ /**
90331
+ * One Enpal data point group (production, state of charge, grid import, ...) as a
90332
+ * {@link DeviceType.Sensor} child of an equipment Container. The catalog entry in `specId`
90333
+ * says which caps it registers and which box keys feed them; the slices are recomputed from
90334
+ * the integration's snapshot on every poll tick.
90335
+ *
90336
+ * An unreachable box marks the device offline and leaves the slices alone: a failed read is
90337
+ * a gap, never a zero (D393). A lifetime counter passes through {@link LifetimeCounterGuard}
90338
+ * so one stale low reading cannot become a history reset.
90339
+ */
90340
+ var EnpalSensorDevice = class extends BaseDevice {
90341
+ features = [];
90342
+ spec;
90343
+ integrationId;
90344
+ energyGuard = new LifetimeCounterGuard();
90345
+ meterGuard = new LifetimeCounterGuard();
90346
+ snapshotUnsub = null;
90347
+ constructor(ctx) {
90348
+ const cfg = enpalSensorSchema.parse(ctx.persistedConfig ?? {});
90349
+ super(ctx, enpalSensorSchema, { type: ctx.deviceMeta.type });
90350
+ const spec = findSpec(cfg.specId);
90351
+ if (spec === void 0) throw new Error(`Enpal: unknown device kind "${cfg.specId}"`);
90352
+ this.spec = spec;
90353
+ this.integrationId = cfg.integrationId;
90354
+ this.online = enpalConnections.getSnapshot(this.integrationId) !== null;
90355
+ this.updateSourceInfo({
90356
+ id: `${this.integrationId}:${spec.id}`,
90357
+ system: ENPAL_SYSTEM,
90358
+ ...spec.kind === "meter" || spec.kind === "numeric" ? { quantity: spec.quantity } : {}
90359
+ });
90360
+ }
90361
+ async onActivate() {
90362
+ await super.onActivate();
90363
+ this.registerCaps();
90364
+ this.snapshotUnsub = enpalConnections.onSnapshot(this.integrationId, () => {
90365
+ try {
90366
+ this.recompute();
90367
+ } catch (err) {
90368
+ this.ctx.logger.warn("enpal sensor: snapshot handler failed", {
90369
+ tags: { deviceId: this.id },
90370
+ meta: {
90371
+ spec: this.spec.id,
90372
+ error: errMsg(err)
90373
+ }
90374
+ });
90375
+ }
90376
+ });
90377
+ this.recompute();
90378
+ }
90379
+ async removeDevice() {
90380
+ this.snapshotUnsub?.();
90381
+ this.snapshotUnsub = null;
90382
+ await super.removeDevice();
90383
+ }
90384
+ recompute() {
90385
+ const snapshot = enpalConnections.getSnapshot(this.integrationId);
90386
+ if (snapshot === null) {
90387
+ if (this.online) this.markOnline(false);
90388
+ return;
90389
+ }
90390
+ if (!this.online) this.markOnline(true);
90391
+ const invert = enpalConnections.getManager(this.integrationId)?.connection.invertBatteryPower ?? false;
90392
+ const slices = computeSlices(this.spec, snapshot, { invertBatteryPower: invert }, snapshot.fetchedAt);
90393
+ this.publish(slices);
90394
+ for (const line of slices.refusals) this.ctx.logger.warn(`enpal sensor: reading not used, ${line}`, {
90395
+ tags: { deviceId: this.id },
90396
+ meta: { spec: this.spec.id }
90397
+ });
90398
+ }
90399
+ publish(slices) {
90400
+ if (slices.powerMeter !== void 0) {
90401
+ const kwh = slices.powerMeter.kwhTotal;
90402
+ this.runtimeState.setCapState(POWER_CAP, kwh === void 0 ? slices.powerMeter : {
90403
+ ...slices.powerMeter,
90404
+ kwhTotal: this.energyGuard.accept(kwh)
90405
+ });
90406
+ }
90407
+ if (slices.battery !== void 0) this.runtimeState.setCapState(BATTERY_CAP, slices.battery);
90408
+ if (slices.meter !== void 0) {
90409
+ const raw = slices.meter.value;
90410
+ this.runtimeState.setCapState(METER_CAP, {
90411
+ ...slices.meter,
90412
+ value: raw === null ? null : this.meterGuard.accept(raw)
90413
+ });
90414
+ }
90415
+ if (slices.numeric !== void 0) this.runtimeState.setCapState(NUMERIC_CAP, slices.numeric);
90416
+ }
90417
+ registerCaps() {
90418
+ switch (this.spec.kind) {
90419
+ case "power-meter":
90420
+ this.registerPowerMeter();
90421
+ return;
90422
+ case "battery":
90423
+ this.registerBattery();
90424
+ this.registerPowerMeter();
90425
+ return;
90426
+ case "meter":
90427
+ this.ctx.registerNativeCap(meterCapability, { getStatus: async () => this.runtimeState.getCapState(METER_CAP) ?? VALUE_COLD });
90428
+ this.runtimeState.setCapState(METER_CAP, VALUE_COLD);
90429
+ return;
90430
+ case "numeric":
90431
+ this.ctx.registerNativeCap(numericSensorCapability, { getStatus: async () => this.runtimeState.getCapState(NUMERIC_CAP) ?? VALUE_COLD });
90432
+ this.runtimeState.setCapState(NUMERIC_CAP, VALUE_COLD);
90433
+ return;
90434
+ }
90435
+ }
90436
+ registerPowerMeter() {
90437
+ this.ctx.registerNativeCap(powerMeterCapability, { getStatus: async () => this.runtimeState.getCapState(POWER_CAP) ?? POWER_COLD });
90438
+ this.runtimeState.setCapState(POWER_CAP, POWER_COLD);
90439
+ }
90440
+ registerBattery() {
90441
+ this.ctx.registerNativeCap(batteryCapability, {
90442
+ getStatus: async () => this.runtimeState.getCapState(BATTERY_CAP) ?? BATTERY_COLD,
90443
+ wakeForStream: async () => ({
90444
+ awoke: false,
90445
+ durationMs: 0
90446
+ })
90447
+ });
90448
+ this.runtimeState.setCapState(BATTERY_CAP, BATTERY_COLD);
90449
+ }
90450
+ };
90451
+ //#endregion
90452
+ //#region src/devices/enpal-sensor-factory.ts
90453
+ /** Constructor closure the kernel calls for every Sensor child of an equipment Container. */
90454
+ function enpalSensorFactory(ctx) {
90455
+ return new EnpalSensorDevice(ctx);
90171
90456
  }
90172
- /** The catalog entry id of a {@link specNativeId} value, or null. */
90173
- function parseSpecNativeId(boxId, childNativeId) {
90174
- const prefix = `${boxId}:`;
90175
- if (!childNativeId.startsWith(prefix)) return null;
90176
- const id = childNativeId.slice(prefix.length);
90177
- return id.length > 0 ? id : null;
90457
+ //#endregion
90458
+ //#region src/enpal-equipment.ts
90459
+ /** Order is the order the candidates are listed in. Every catalog entry belongs to one. */
90460
+ var ENPAL_EQUIPMENT = [
90461
+ {
90462
+ id: "pv",
90463
+ name: "Photovoltaic",
90464
+ specIds: ["pv", "inverter-temperature"]
90465
+ },
90466
+ {
90467
+ id: "battery",
90468
+ name: "Battery",
90469
+ specIds: [
90470
+ "battery",
90471
+ "battery-charged",
90472
+ "battery-discharged",
90473
+ "battery-temperature",
90474
+ "battery-health"
90475
+ ]
90476
+ },
90477
+ {
90478
+ id: "grid",
90479
+ name: "Grid",
90480
+ specIds: ["grid-import", "grid-export"]
90481
+ },
90482
+ {
90483
+ id: "house",
90484
+ name: "House",
90485
+ specIds: ["house"]
90486
+ },
90487
+ {
90488
+ id: "heatpump",
90489
+ name: "Heat pump",
90490
+ specIds: [
90491
+ "heatpump",
90492
+ "heatpump-hot-water",
90493
+ "heatpump-outside"
90494
+ ]
90495
+ }
90496
+ ];
90497
+ function findEquipment(id) {
90498
+ return ENPAL_EQUIPMENT.find((e) => e.id === id);
90178
90499
  }
90179
- /** One Sensor candidate per data group the box reported. Pure. */
90180
- function buildCandidates(input) {
90181
- return input.specs.map((spec) => {
90182
- const adoptedDeviceId = input.adoptedDeviceIdBySpec[spec.id] ?? null;
90183
- return {
90184
- childNativeId: specNativeId(input.boxId, spec.id),
90185
- name: `${input.boxName} ${spec.name}`,
90186
- type: DeviceType.Sensor,
90187
- status: input.online ? "online" : "offline",
90188
- metadata: {
90189
- manufacturer: "Enpal",
90190
- integration: "enpal"
90500
+ /** The `childNativeId` of a child candidate: namespaced by its container. */
90501
+ function childNativeId(equipmentId, specId) {
90502
+ return `${equipmentId}#${specId}`;
90503
+ }
90504
+ /**
90505
+ * Persisted config of an equipment Container. `specIds` are the children it was adopted with:
90506
+ * they stay, whatever the box reports later, so a restart with the box off keeps them.
90507
+ */
90508
+ var enpalEquipmentSchema = object({
90509
+ equipment: string().min(1).describe("Equipment id (pv, battery, grid, house, heatpump)"),
90510
+ specIds: array(string().min(1)).describe("Catalog entries adopted as children"),
90511
+ integrationId: string().min(1).describe("Owning integration"),
90512
+ system: literal(ENPAL_SYSTEM).optional()
90513
+ });
90514
+ /** The Sensor children the kernel spawns under an equipment Container. */
90515
+ function accessoryChildSpecs(config) {
90516
+ const out = [];
90517
+ for (const specId of config.specIds) {
90518
+ const spec = findSpec(specId);
90519
+ if (spec === void 0) continue;
90520
+ out.push({
90521
+ stableIdSuffix: spec.id,
90522
+ meta: {
90523
+ type: DeviceType.Sensor,
90524
+ name: spec.name,
90525
+ role: spec.role,
90526
+ integrationId: config.integrationId
90527
+ },
90528
+ config: {
90529
+ specId: spec.id,
90530
+ integrationId: config.integrationId,
90531
+ system: ENPAL_SYSTEM
90191
90532
  },
90192
- alreadyAdopted: adoptedDeviceId !== null,
90193
- adoptedDeviceId,
90194
- capabilities: capsOfSpec(spec)
90533
+ factory: enpalSensorFactory
90534
+ });
90535
+ }
90536
+ return out;
90537
+ }
90538
+ //#endregion
90539
+ //#region src/devices/enpal-equipment-device.ts
90540
+ /**
90541
+ * One physical piece of the installation (photovoltaic, battery, grid, house, heat pump) as a
90542
+ * {@link DeviceType.Container}. It owns no caps: it declares the Sensor children it was
90543
+ * adopted with, and the kernel spawns and restores them. Online follows the integration's
90544
+ * box; the integration marks it offline when a poll fails.
90545
+ */
90546
+ var EnpalEquipmentDevice = class extends BaseDevice {
90547
+ features = [];
90548
+ equipment;
90549
+ specIds;
90550
+ integrationId;
90551
+ constructor(ctx) {
90552
+ const cfg = enpalEquipmentSchema.parse(ctx.persistedConfig ?? {});
90553
+ super(ctx, enpalEquipmentSchema, { type: DeviceType.Container });
90554
+ this.equipment = cfg.equipment;
90555
+ this.specIds = cfg.specIds;
90556
+ this.integrationId = cfg.integrationId;
90557
+ this.online = enpalConnections.getSnapshot(this.integrationId) !== null;
90558
+ this.updateSourceInfo({
90559
+ id: `${this.integrationId}:${this.equipment}`,
90560
+ system: ENPAL_SYSTEM
90561
+ });
90562
+ }
90563
+ getAccessoryChildren() {
90564
+ return accessoryChildSpecs({
90565
+ equipment: this.equipment,
90566
+ specIds: this.specIds,
90567
+ integrationId: this.integrationId
90568
+ });
90569
+ }
90570
+ };
90571
+ //#endregion
90572
+ //#region src/enpal-discovery.ts
90573
+ var METADATA = {
90574
+ manufacturer: "Enpal",
90575
+ integration: "enpal"
90576
+ };
90577
+ /**
90578
+ * One Container candidate per equipment the box reported at least one value for, with the
90579
+ * Sensor children it reported. Pure.
90580
+ */
90581
+ function buildEnpalCandidates(input) {
90582
+ const { snapshot } = input;
90583
+ if (snapshot === null) return [];
90584
+ const reported = new Map(availableSpecs(snapshot).map((spec) => [spec.id, spec]));
90585
+ const out = [];
90586
+ for (const equipment of ENPAL_EQUIPMENT) {
90587
+ const specs = equipment.specIds.flatMap((id) => reported.get(id) ?? []);
90588
+ if (specs.length === 0) continue;
90589
+ const adoptedId = input.adopted.get(equipment.id) ?? null;
90590
+ out.push({
90591
+ childNativeId: equipment.id,
90592
+ name: equipment.name,
90593
+ type: DeviceType.Container,
90594
+ status: "online",
90595
+ metadata: METADATA,
90596
+ alreadyAdopted: adoptedId !== null,
90597
+ adoptedDeviceId: adoptedId,
90598
+ capabilities: [],
90599
+ children: specs.map((spec) => ({
90600
+ childNativeId: childNativeId(equipment.id, spec.id),
90601
+ name: spec.name,
90602
+ type: DeviceType.Sensor,
90603
+ status: "online",
90604
+ metadata: METADATA,
90605
+ alreadyAdopted: false,
90606
+ adoptedDeviceId: null,
90607
+ capabilities: capsOfSpec(spec)
90608
+ }))
90609
+ });
90610
+ }
90611
+ return out;
90612
+ }
90613
+ //#endregion
90614
+ //#region src/enpal-adoption-provider.ts
90615
+ var DEVICES_FILTER = {
90616
+ id: "devices",
90617
+ label: "Devices",
90618
+ isDefault: true
90619
+ };
90620
+ function buildEnpalAdoptionProvider(deps) {
90621
+ const { getSnapshot, hasIntegration, listIntegrations, listAdopted, logger } = deps;
90622
+ async function adoptedMap(integrationId) {
90623
+ const map = /* @__PURE__ */ new Map();
90624
+ for (const device of await listAdopted()) {
90625
+ const cfg = device.config;
90626
+ if (cfg["system"] !== "enpal" || cfg["integrationId"] !== integrationId) continue;
90627
+ const equipment = cfg["equipment"];
90628
+ if (typeof equipment === "string") map.set(equipment, device.id);
90629
+ }
90630
+ return map;
90631
+ }
90632
+ async function candidates(integrationId) {
90633
+ return buildEnpalCandidates({
90634
+ snapshot: getSnapshot(integrationId),
90635
+ adopted: await adoptedMap(integrationId)
90636
+ });
90637
+ }
90638
+ async function status(integrationId) {
90639
+ return {
90640
+ lastDiscoveryAt: Date.now(),
90641
+ candidateCount: (await candidates(integrationId)).length,
90642
+ adoptedCount: (await adoptedMap(integrationId)).size,
90643
+ lastError: null
90195
90644
  };
90196
- });
90645
+ }
90646
+ return {
90647
+ listCandidateFilters: async () => ({ filters: [DEVICES_FILTER] }),
90648
+ listCandidates: async ({ integrationId, page, pageSize }) => {
90649
+ const all = await candidates(integrationId);
90650
+ const start = (page - 1) * pageSize;
90651
+ return {
90652
+ candidates: all.slice(start, start + pageSize),
90653
+ totalCount: all.length,
90654
+ page,
90655
+ pageSize
90656
+ };
90657
+ },
90658
+ getCandidate: async ({ integrationId, childNativeId }) => {
90659
+ return (await candidates(integrationId)).find((c) => c.childNativeId === childNativeId) ?? null;
90660
+ },
90661
+ getStatus: async () => {
90662
+ try {
90663
+ let candidateCount = 0;
90664
+ let adoptedCount = 0;
90665
+ for (const integrationId of listIntegrations()) {
90666
+ const s = await status(integrationId);
90667
+ candidateCount += s.candidateCount;
90668
+ adoptedCount += s.adoptedCount;
90669
+ }
90670
+ return {
90671
+ lastDiscoveryAt: Date.now(),
90672
+ candidateCount,
90673
+ adoptedCount,
90674
+ lastError: null
90675
+ };
90676
+ } catch (err) {
90677
+ return {
90678
+ lastDiscoveryAt: null,
90679
+ candidateCount: 0,
90680
+ adoptedCount: 0,
90681
+ lastError: errMsg(err)
90682
+ };
90683
+ }
90684
+ },
90685
+ refresh: async ({ integrationId }) => status(integrationId),
90686
+ adopt: async ({ integrationId, childNativeIds, perCandidate }) => {
90687
+ if (!hasIntegration(integrationId)) throw new Error(`enpal adopt: integration ${integrationId} not found`);
90688
+ const offered = await candidates(integrationId);
90689
+ const adopted = [];
90690
+ let failures = 0;
90691
+ for (const equipmentId of childNativeIds) try {
90692
+ const candidate = offered.find((c) => c.childNativeId === equipmentId);
90693
+ const equipment = findEquipment(equipmentId);
90694
+ if (candidate === void 0 || equipment === void 0) {
90695
+ logger.warn("enpal adopt: the box reports nothing for this equipment — skipping", { meta: {
90696
+ equipment: equipmentId,
90697
+ integrationId
90698
+ } });
90699
+ failures++;
90700
+ continue;
90701
+ }
90702
+ const reported = new Set((candidate.children ?? []).map((c) => c.childNativeId));
90703
+ const specIds = equipment.specIds.filter((id) => reported.has(`${equipment.id}#${id}`));
90704
+ const { deviceId, accessoryDeviceIds } = await deps.adoptDevice({
90705
+ equipment: equipment.id,
90706
+ specIds,
90707
+ integrationId,
90708
+ name: perCandidate?.[equipmentId]?.name ?? equipment.name
90709
+ });
90710
+ adopted.push({
90711
+ childNativeId: equipmentId,
90712
+ parentDeviceId: deviceId,
90713
+ accessoryDeviceIds
90714
+ });
90715
+ } catch (err) {
90716
+ logger.warn("enpal adopt: failed to adopt equipment", { meta: {
90717
+ equipment: equipmentId,
90718
+ integrationId,
90719
+ error: errMsg(err)
90720
+ } });
90721
+ failures++;
90722
+ }
90723
+ if (adopted.length === 0 && failures > 0) throw new Error(`enpal adopt: all ${failures} adopt(s) failed`);
90724
+ return { adopted };
90725
+ },
90726
+ release: async ({ camDeviceId }) => {
90727
+ await deps.removeDevice(camDeviceId);
90728
+ },
90729
+ resync: async ({ camDeviceId }) => {
90730
+ const cfg = await deps.findDeviceConfig(camDeviceId);
90731
+ if (cfg === null || cfg["system"] !== "enpal") throw new Error(`enpal resync: device ${camDeviceId} is not an Enpal device`);
90732
+ return {
90733
+ changed: false,
90734
+ rebuiltChildren: 0
90735
+ };
90736
+ }
90737
+ };
90197
90738
  }
90198
90739
  //#endregion
90199
90740
  //#region src/enpal-device-page.ts
@@ -90549,482 +91090,309 @@ var EnpalIntegrationManager = class {
90549
91090
  }
90550
91091
  };
90551
91092
  //#endregion
90552
- //#region src/enpal-resolver.ts
91093
+ //#region src/enpal-client-registry.ts
90553
91094
  /**
90554
- * Per-box registry that the adopted devices use to reach the live
90555
- * {@link EnpalIntegrationManager}. Keyed on the box Hub device's `stableId`; the box
90556
- * publishes a new manager here whenever its connection changes, and children read through
90557
- * the resolver so they follow it.
91095
+ * Owns one poller per Enpal integration (`Map<integrationId, manager>`) and publishes each on
91096
+ * {@link enpalConnections} so the devices follow it. The caller reconciles it against the
91097
+ * live integration list at boot and on every integration lifecycle event.
90558
91098
  */
90559
- var EnpalBoxResolver = class {
90560
- #managers = /* @__PURE__ */ new Map();
90561
- #subs = /* @__PURE__ */ new Map();
90562
- set(boxId, manager) {
90563
- if (manager === null) {
90564
- this.#managers.delete(boxId);
90565
- return;
91099
+ var EnpalClientRegistry = class {
91100
+ #logger;
91101
+ #deps;
91102
+ #entries = /* @__PURE__ */ new Map();
91103
+ constructor(logger, deps) {
91104
+ this.#logger = logger;
91105
+ this.#deps = deps;
91106
+ }
91107
+ /**
91108
+ * Ensure a poller runs for `integrationId` with this connection. An unchanged connection
91109
+ * keeps the running poller; a changed one replaces it. Never throws: a box that is off is
91110
+ * an `error` status, polled again later.
91111
+ */
91112
+ async upsert(integrationId, name, connection, tokenLocked) {
91113
+ const existing = this.#entries.get(integrationId);
91114
+ if (existing !== void 0) {
91115
+ if (existing.tokenLocked === tokenLocked && sameConnection(existing.connection, connection)) return;
91116
+ await this.remove(integrationId);
91117
+ }
91118
+ const manager = new EnpalIntegrationManager({
91119
+ id: integrationId,
91120
+ name,
91121
+ connection,
91122
+ tokenLocked,
91123
+ logger: this.#logger.withTags({ integrationId }),
91124
+ fetch: this.#deps.fetch,
91125
+ now: this.#deps.now,
91126
+ onConnected: () => {
91127
+ this.#deps.onConnected(integrationId);
91128
+ },
91129
+ onDisconnected: () => {
91130
+ this.#deps.onDisconnected(integrationId);
91131
+ enpalConnections.emitSnapshot(integrationId);
91132
+ },
91133
+ onPolled: () => {
91134
+ enpalConnections.emitSnapshot(integrationId);
91135
+ }
91136
+ });
91137
+ this.#entries.set(integrationId, {
91138
+ manager,
91139
+ connection,
91140
+ tokenLocked
91141
+ });
91142
+ enpalConnections.set(integrationId, manager);
91143
+ await manager.start();
91144
+ if (manager.status === "error") {
91145
+ this.#deps.onDisconnected(integrationId);
91146
+ enpalConnections.emitSnapshot(integrationId);
90566
91147
  }
90567
- this.#managers.set(boxId, manager);
90568
91148
  }
90569
- getManager(boxId) {
90570
- return this.#managers.get(boxId) ?? null;
91149
+ async remove(integrationId) {
91150
+ const entry = this.#entries.get(integrationId);
91151
+ if (entry === void 0) return;
91152
+ this.#entries.delete(integrationId);
91153
+ enpalConnections.set(integrationId, null);
91154
+ await entry.manager.stop();
90571
91155
  }
90572
- /** The latest snapshot, or null when the box is unreachable (never stale readings). */
90573
- getSnapshot(boxId) {
90574
- return this.#managers.get(boxId)?.getSnapshot() ?? null;
91156
+ /** Stop every poller whose integration is not in `keep`. */
91157
+ async retain(keep) {
91158
+ for (const id of [...this.#entries.keys()]) if (!keep.has(id)) await this.remove(id);
90575
91159
  }
90576
- /** Subscribe to poll ticks (successful or not). Returns an unsubscribe function. */
90577
- onSnapshot(boxId, cb) {
90578
- let set = this.#subs.get(boxId);
90579
- if (set === void 0) {
90580
- set = /* @__PURE__ */ new Set();
90581
- this.#subs.set(boxId, set);
90582
- }
90583
- set.add(cb);
90584
- return () => {
90585
- const current = this.#subs.get(boxId);
90586
- current?.delete(cb);
90587
- if (current !== void 0 && current.size === 0) this.#subs.delete(boxId);
90588
- };
91160
+ async shutdown() {
91161
+ for (const id of [...this.#entries.keys()]) await this.remove(id);
90589
91162
  }
90590
- emitSnapshot(boxId) {
90591
- const set = this.#subs.get(boxId);
90592
- if (set === void 0) return;
90593
- for (const cb of set) try {
90594
- cb();
90595
- } catch {}
91163
+ has(integrationId) {
91164
+ return this.#entries.has(integrationId);
90596
91165
  }
90597
- clear() {
90598
- this.#managers.clear();
90599
- this.#subs.clear();
91166
+ list() {
91167
+ return [...this.#entries.keys()];
90600
91168
  }
90601
91169
  };
90602
- var enpalBoxes = new EnpalBoxResolver();
90603
91170
  //#endregion
90604
- //#region src/enpal-counter-guard.ts
90605
- /**
90606
- * Smaller than the history deadband of the lifetime counters (0.01 kWh): float noise from
90607
- * rounding or a unit conversion, never a real drop.
90608
- */
90609
- var NOISE = .01;
90610
- /**
90611
- * Keeps a lifetime counter monotonic before it reaches a `counter` history series, where a
90612
- * drop means "the meter was reset" and re-bases every later total.
90613
- *
90614
- * The box occasionally reports 0 or a stale lower figure for one poll (a restart, a partly
90615
- * written window). One such reading is held back: the previous value is published instead.
90616
- * A lower reading that is then followed by a value at or above it is a genuine reset or a
90617
- * replaced meter, and is accepted.
90618
- */
90619
- var LifetimeCounterGuard = class {
90620
- #published = null;
90621
- #suspect = null;
90622
- /** The value to publish for the reading `next`. */
90623
- accept(next) {
90624
- const published = this.#published;
90625
- if (published === null || next >= published - NOISE) {
90626
- this.#published = next;
90627
- this.#suspect = null;
90628
- return next;
90629
- }
90630
- const suspect = this.#suspect;
90631
- if (suspect !== null && next >= suspect - NOISE) {
90632
- this.#published = next;
90633
- this.#suspect = null;
90634
- return next;
90635
- }
90636
- this.#suspect = next;
90637
- return published;
90638
- }
90639
- };
90640
- //#endregion
90641
- //#region src/devices/enpal-sensor-device.ts
90642
- var POWER_CAP = powerMeterCapability.name;
90643
- var BATTERY_CAP = batteryCapability.name;
90644
- var METER_CAP = meterCapability.name;
90645
- var NUMERIC_CAP = numericSensorCapability.name;
90646
- var POWER_COLD = { lastFetchedAt: 0 };
90647
- var BATTERY_COLD = {
90648
- percentage: null,
90649
- charging: "unknown",
90650
- sleeping: false,
90651
- lastUpdated: 0
90652
- };
90653
- var VALUE_COLD = {
90654
- value: null,
90655
- lastFetchedAt: 0
90656
- };
90657
- /**
90658
- * One Enpal data group (PV, battery, grid import, ...) as a {@link DeviceType.Sensor}
90659
- * child of the box. The catalog entry in `specId` says which caps it registers and which
90660
- * box keys feed them; the slices are recomputed from the box snapshot on every poll tick.
90661
- *
90662
- * An unreachable box marks the device offline and leaves the slices alone: a failed read is
90663
- * a gap, never a zero (D393). A lifetime counter passes through {@link LifetimeCounterGuard}
90664
- * so one stale low reading cannot become a history reset.
90665
- */
90666
- var EnpalSensorDevice = class extends BaseDevice {
90667
- features = [];
90668
- spec;
90669
- boxId;
90670
- energyGuard = new LifetimeCounterGuard();
90671
- meterGuard = new LifetimeCounterGuard();
90672
- snapshotUnsub = null;
90673
- constructor(ctx) {
90674
- const cfg = enpalSensorSchema.parse(ctx.persistedConfig ?? {});
90675
- super(ctx, enpalSensorSchema, { type: ctx.deviceMeta.type });
90676
- const spec = findSpec(cfg.specId);
90677
- if (spec === void 0) throw new Error(`Enpal: unknown device kind "${cfg.specId}"`);
90678
- this.spec = spec;
90679
- this.boxId = cfg.boxId;
90680
- this.online = enpalBoxes.getSnapshot(this.boxId) !== null;
90681
- this.updateSourceInfo({
90682
- id: `${this.boxId}:${spec.id}`,
90683
- system: "enpal",
90684
- ...spec.kind === "meter" || spec.kind === "numeric" ? { quantity: spec.quantity } : {}
90685
- });
90686
- }
90687
- async onActivate() {
90688
- await super.onActivate();
90689
- this.registerCaps();
90690
- this.snapshotUnsub = enpalBoxes.onSnapshot(this.boxId, () => {
90691
- try {
90692
- this.recompute();
90693
- } catch (err) {
90694
- this.ctx.logger.warn("enpal sensor: snapshot handler failed", {
90695
- tags: { deviceId: this.id },
90696
- meta: {
90697
- spec: this.spec.id,
90698
- error: errMsg(err)
90699
- }
90700
- });
90701
- }
90702
- });
90703
- this.recompute();
90704
- }
90705
- async removeDevice() {
90706
- this.snapshotUnsub?.();
90707
- this.snapshotUnsub = null;
90708
- await super.removeDevice();
90709
- }
90710
- recompute() {
90711
- const snapshot = enpalBoxes.getSnapshot(this.boxId);
90712
- if (snapshot === null) {
90713
- if (this.online) this.markOnline(false);
90714
- return;
90715
- }
90716
- if (!this.online) this.markOnline(true);
90717
- const invert = enpalBoxes.getManager(this.boxId)?.connection.invertBatteryPower ?? false;
90718
- const slices = computeSlices(this.spec, snapshot, { invertBatteryPower: invert }, snapshot.fetchedAt);
90719
- this.publish(slices);
90720
- for (const line of slices.refusals) this.ctx.logger.warn(`enpal sensor: reading not used, ${line}`, {
90721
- tags: { deviceId: this.id },
90722
- meta: { spec: this.spec.id }
90723
- });
90724
- }
90725
- publish(slices) {
90726
- if (slices.powerMeter !== void 0) {
90727
- const kwh = slices.powerMeter.kwhTotal;
90728
- this.runtimeState.setCapState(POWER_CAP, kwh === void 0 ? slices.powerMeter : {
90729
- ...slices.powerMeter,
90730
- kwhTotal: this.energyGuard.accept(kwh)
90731
- });
90732
- }
90733
- if (slices.battery !== void 0) this.runtimeState.setCapState(BATTERY_CAP, slices.battery);
90734
- if (slices.meter !== void 0) {
90735
- const raw = slices.meter.value;
90736
- this.runtimeState.setCapState(METER_CAP, {
90737
- ...slices.meter,
90738
- value: raw === null ? null : this.meterGuard.accept(raw)
91171
+ //#region src/enpal-integration-device-cascade.ts
91172
+ /**
91173
+ * Remove every equipment Container of an integration that was deleted or disabled; the
91174
+ * kernel takes the Sensor children with it. Best-effort per device; returns the count removed.
91175
+ */
91176
+ async function cascadeRemoveDevicesForIntegration(input) {
91177
+ const { reg, devices, addonId, integrationId, logger } = input;
91178
+ let removed = 0;
91179
+ for (const d of reg.getAllForAddon(addonId)) {
91180
+ if (d.parentDeviceId !== null) continue;
91181
+ const cfg = await devices.loadConfig(d.id).catch(() => ({}));
91182
+ if (cfg["system"] !== "enpal" || cfg["integrationId"] !== integrationId) continue;
91183
+ try {
91184
+ await devices.remove(d.id);
91185
+ removed += 1;
91186
+ } catch (err) {
91187
+ logger.warn("enpal: integration cascade-remove failed", {
91188
+ tags: { deviceId: d.id },
91189
+ meta: {
91190
+ integrationId,
91191
+ error: errMsg(err)
91192
+ }
90739
91193
  });
90740
91194
  }
90741
- if (slices.numeric !== void 0) this.runtimeState.setCapState(NUMERIC_CAP, slices.numeric);
90742
- }
90743
- registerCaps() {
90744
- switch (this.spec.kind) {
90745
- case "power-meter":
90746
- this.registerPowerMeter();
90747
- return;
90748
- case "battery":
90749
- this.registerBattery();
90750
- this.registerPowerMeter();
90751
- return;
90752
- case "meter":
90753
- this.ctx.registerNativeCap(meterCapability, { getStatus: async () => this.runtimeState.getCapState(METER_CAP) ?? VALUE_COLD });
90754
- this.runtimeState.setCapState(METER_CAP, VALUE_COLD);
90755
- return;
90756
- case "numeric":
90757
- this.ctx.registerNativeCap(numericSensorCapability, { getStatus: async () => this.runtimeState.getCapState(NUMERIC_CAP) ?? VALUE_COLD });
90758
- this.runtimeState.setCapState(NUMERIC_CAP, VALUE_COLD);
90759
- return;
90760
- }
90761
91195
  }
90762
- registerPowerMeter() {
90763
- this.ctx.registerNativeCap(powerMeterCapability, { getStatus: async () => this.runtimeState.getCapState(POWER_CAP) ?? POWER_COLD });
90764
- this.runtimeState.setCapState(POWER_CAP, POWER_COLD);
90765
- }
90766
- registerBattery() {
90767
- this.ctx.registerNativeCap(batteryCapability, {
90768
- getStatus: async () => this.runtimeState.getCapState(BATTERY_CAP) ?? BATTERY_COLD,
90769
- wakeForStream: async () => ({
90770
- awoke: false,
90771
- durationMs: 0
90772
- })
90773
- });
90774
- this.runtimeState.setCapState(BATTERY_CAP, BATTERY_COLD);
91196
+ return removed;
91197
+ }
91198
+ //#endregion
91199
+ //#region src/enpal-integration-offline.ts
91200
+ /**
91201
+ * Set every Enpal device of `integrationId` (containers and their sensors) to `online`.
91202
+ * Churn-free: a device already in that state is left alone. Returns how many changed.
91203
+ */
91204
+ function setIntegrationDevicesOnline(devices, integrationId, online) {
91205
+ let count = 0;
91206
+ for (const dev of devices) {
91207
+ if (dev.config.get("system") !== "enpal") continue;
91208
+ if (dev.config.get("integrationId") !== integrationId) continue;
91209
+ if (dev.online === online) continue;
91210
+ dev.markOnline(online);
91211
+ count += 1;
90775
91212
  }
90776
- };
91213
+ return count;
91214
+ }
90777
91215
  //#endregion
90778
- //#region src/devices/enpal-box-device.ts
91216
+ //#region src/addon.ts
90779
91217
  /**
90780
- * One Enpal box, a {@link DeviceType.Hub} device. The connection (host, InfluxDB access,
90781
- * token) lives on this device's config; it owns the poller, publishes it on
90782
- * {@link enpalBoxes} and registers `device-discovery` so the operator adopts the data groups
90783
- * the box actually reports (PV, battery, grid, house, heat pump) as Sensor children.
91218
+ * Enpal solar box provider, `mode: account` and broker-less. An integration is one box: its
91219
+ * host, InfluxDB access and token live on the integration's settings, and the addon polls it
91220
+ * with one manager per `integrationId`. The box is not a device: discovery offers one
91221
+ * {@link DeviceType.Container} per piece of equipment it reports (photovoltaic, battery,
91222
+ * grid, house, heat pump), and adopting a container adopts its Sensor children.
91223
+ *
91224
+ * The box does not advertise itself on the network, so the integration is added by hand.
90784
91225
  */
90785
- var EnpalBoxDevice = class extends BaseDevice {
90786
- features = [];
90787
- manager = null;
90788
- eventUnsubscribers = [];
90789
- constructor(ctx) {
90790
- enpalBoxSchema.parse(ctx.persistedConfig ?? {});
90791
- super(ctx, enpalBoxSchema, { type: DeviceType.Hub });
90792
- this.online = false;
90793
- this.registerDeviceDiscovery();
90794
- this.updateSourceInfo({
90795
- id: this.stableId,
90796
- system: "enpal"
90797
- });
91226
+ var EnpalProviderAddon = class extends BaseDeviceProvider {
91227
+ addonId = "provider-enpal";
91228
+ providerName = "Enpal";
91229
+ deviceClasses = { [DeviceType.Container]: EnpalEquipmentDevice };
91230
+ clients = null;
91231
+ constructor() {
91232
+ super({});
90798
91233
  }
90799
- async onActivate() {
90800
- await super.onActivate();
90801
- this.ensureManager().catch((err) => {
90802
- this.ctx.logger.warn("Enpal box: start failed", {
90803
- tags: { deviceId: this.id },
90804
- meta: { error: errMsg(err) }
90805
- });
91234
+ async onInitialize() {
91235
+ const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
91236
+ this.clients = new EnpalClientRegistry(this.ctx.logger, {
91237
+ onConnected: (integrationId) => this.setIntegrationDevicesOnline(integrationId, true),
91238
+ onDisconnected: (integrationId) => this.setIntegrationDevicesOnline(integrationId, false)
90806
91239
  });
90807
- this.eventUnsubscribers.push(this.ctx.eventBus.subscribe({ category: EventCategory.DeviceUnregistered }, (event) => {
90808
- if (event.data.parentDeviceId !== this.id) return;
90809
- this.refreshDiscovery().catch(() => void 0);
90810
- }));
91240
+ await this.reconcileIntegrations();
91241
+ this.subscribeIntegrationLifecycle();
91242
+ return [...regs, {
91243
+ capability: deviceAdoptionCapability,
91244
+ provider: this.buildAdoptionProvider()
91245
+ }];
90811
91246
  }
90812
- async removeDevice() {
90813
- for (const unsub of this.eventUnsubscribers) unsub();
90814
- this.eventUnsubscribers.length = 0;
90815
- await this.stopManager();
90816
- await super.removeDevice();
91247
+ async onShutdown() {
91248
+ try {
91249
+ await this.clients?.shutdown();
91250
+ } catch (err) {
91251
+ this.ctx.logger.warn("Enpal shutdown error", { meta: { error: errMsg(err) } });
91252
+ }
91253
+ this.clients = null;
91254
+ enpalConnections.clear();
91255
+ await super.onShutdown();
90817
91256
  }
90818
- async applySettingsPatch(patch) {
90819
- if (!("connection" in patch)) return;
90820
- const parsed = enpalBoxSchema.parse({
90821
- connection: patch["connection"],
90822
- system: "enpal-box"
90823
- });
90824
- await this.config.setAll({ connection: parsed.connection });
90825
- await this.stopManager();
90826
- await this.ensureManager();
91257
+ requireClients() {
91258
+ if (!this.clients) throw new Error("Enpal provider not initialised");
91259
+ return this.clients;
90827
91260
  }
90828
- async stopManager() {
90829
- const mgr = this.manager;
90830
- this.manager = null;
90831
- enpalBoxes.set(this.stableId, null);
90832
- if (mgr !== null) await mgr.stop();
91261
+ /** One container per equipment per integration. */
91262
+ generateStableId(_type, config) {
91263
+ const integrationId = config?.["integrationId"];
91264
+ const equipment = config?.["equipment"];
91265
+ if (typeof integrationId !== "string" || integrationId.length === 0) throw new Error("enpal generateStableId: integrationId required");
91266
+ if (typeof equipment !== "string" || equipment.length === 0) throw new Error("enpal generateStableId: equipment required");
91267
+ return `enpal:${integrationId}:${equipment}`;
90833
91268
  }
90834
- async ensureManager() {
90835
- if (this.manager !== null) return;
90836
- const { connection, tokenLocked } = readConnection(this.config.values.connection);
90837
- const mgr = new EnpalIntegrationManager({
90838
- id: this.stableId,
90839
- name: this.name,
90840
- connection,
90841
- tokenLocked,
90842
- logger: this.ctx.logger.withTags({ deviceId: this.id }),
90843
- onConnected: () => this.setOnline(true),
90844
- onDisconnected: () => {
90845
- this.setOnline(false);
90846
- enpalBoxes.emitSnapshot(this.stableId);
90847
- },
90848
- onPolled: () => {
90849
- this.refreshDiscovery().catch(() => void 0);
90850
- enpalBoxes.emitSnapshot(this.stableId);
91269
+ /**
91270
+ * Rebuild the pollers from the live integration list: upsert one per enabled integration
91271
+ * with a host, drop the ones that vanished and cascade-remove their devices. Idempotent;
91272
+ * runs at boot and on every integration lifecycle event.
91273
+ */
91274
+ async reconcileIntegrations() {
91275
+ const reg = this.requireClients();
91276
+ try {
91277
+ const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
91278
+ const surviving = /* @__PURE__ */ new Set();
91279
+ for (const integration of mine) try {
91280
+ const read = connectionFromSettings(await this.ctx.api.integrations.getSettings.query({ id: integration.id }));
91281
+ if (read === null) {
91282
+ this.ctx.logger.warn("Enpal integration has no box host — skipping", { meta: { integrationId: integration.id } });
91283
+ continue;
91284
+ }
91285
+ await reg.upsert(integration.id, integration.name, read.connection, read.tokenLocked);
91286
+ surviving.add(integration.id);
91287
+ } catch (err) {
91288
+ this.ctx.logger.warn("Enpal reconcile failed for integration", { meta: {
91289
+ integrationId: integration.id,
91290
+ error: errMsg(err)
91291
+ } });
90851
91292
  }
90852
- });
90853
- this.manager = mgr;
90854
- enpalBoxes.set(this.stableId, mgr);
90855
- await mgr.start();
90856
- if (mgr.status === "error") {
90857
- this.setOnline(false);
90858
- enpalBoxes.emitSnapshot(this.stableId);
91293
+ const vanished = reg.list().filter((id) => !surviving.has(id));
91294
+ await reg.retain(surviving);
91295
+ for (const integrationId of vanished) await this.cascadeRemoveDevicesForIntegration(integrationId);
91296
+ } catch (err) {
91297
+ this.ctx.logger.warn("Enpal integration reconcile failed", { meta: { error: errMsg(err) } });
90859
91298
  }
90860
91299
  }
90861
- setOnline(online) {
90862
- if (this.online === online) return;
90863
- this.markOnline(online);
90864
- this.ctx.logger.info("Enpal box " + (online ? "online" : "offline"), { tags: { deviceId: this.id } });
91300
+ subscribeIntegrationLifecycle() {
91301
+ const handler = (event) => {
91302
+ const addonId = event.data["addonId"];
91303
+ if (typeof addonId === "string" && addonId !== this.ctx.id) return;
91304
+ this.reconcileIntegrations();
91305
+ };
91306
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
91307
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
91308
+ this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
90865
91309
  }
90866
- registerDeviceDiscovery() {
90867
- this.ctx.registerNativeCap(deviceDiscoveryCapability, {
90868
- getStatus: async ({ deviceId }) => {
90869
- if (deviceId !== this.id) return null;
90870
- return this.runtimeState.getCapState("device-discovery") ?? null;
91310
+ async cascadeRemoveDevicesForIntegration(integrationId) {
91311
+ const reg = this.ctx.kernel.deviceRegistry;
91312
+ const devices = this.ctx.kernel.devices;
91313
+ if (!reg || !devices) return;
91314
+ await cascadeRemoveDevicesForIntegration({
91315
+ reg,
91316
+ devices,
91317
+ addonId: this.addonId,
91318
+ integrationId,
91319
+ logger: this.ctx.logger
91320
+ });
91321
+ }
91322
+ buildAdoptionProvider() {
91323
+ return buildEnpalAdoptionProvider({
91324
+ getSnapshot: (integrationId) => enpalConnections.getSnapshot(integrationId),
91325
+ hasIntegration: (integrationId) => this.requireClients().has(integrationId),
91326
+ listIntegrations: () => this.requireClients().list(),
91327
+ listAdopted: async () => {
91328
+ const reg = this.ctx.kernel.deviceRegistry;
91329
+ const devices = this.ctx.kernel.devices;
91330
+ if (!reg || !devices) return [];
91331
+ const out = [];
91332
+ for (const d of reg.getAllForAddon(this.addonId)) {
91333
+ if (d.parentDeviceId !== null) continue;
91334
+ const config = await devices.loadConfig(d.id).catch(() => ({}));
91335
+ out.push({
91336
+ id: d.id,
91337
+ config
91338
+ });
91339
+ }
91340
+ return out;
90871
91341
  },
90872
- listDiscovered: async ({ deviceId }) => {
90873
- if (deviceId !== this.id) return [];
90874
- return this.runtimeState.getCapState("device-discovery")?.discovered ?? [];
91342
+ adoptDevice: async ({ equipment, specIds, integrationId, name }) => {
91343
+ const devices = this.ctx.kernel.devices;
91344
+ if (!devices) throw new Error("enpal adopt: kernel.devices unavailable");
91345
+ const config = {
91346
+ equipment,
91347
+ specIds,
91348
+ integrationId,
91349
+ system: ENPAL_SYSTEM
91350
+ };
91351
+ const stableId = this.generateStableId(DeviceType.Container, config);
91352
+ const device = await devices.create(stableId, EnpalEquipmentDevice, config, null, {
91353
+ type: DeviceType.Container,
91354
+ name,
91355
+ integrationId
91356
+ });
91357
+ const children = await devices.getChildren(device.id);
91358
+ return {
91359
+ deviceId: device.id,
91360
+ accessoryDeviceIds: children.map((c) => c.id)
91361
+ };
90875
91362
  },
90876
- refreshDiscovery: async ({ deviceId }) => {
90877
- if (deviceId !== this.id) return [];
90878
- await this.refreshDiscovery();
90879
- return this.runtimeState.getCapState("device-discovery")?.discovered ?? [];
91363
+ removeDevice: async (id) => {
91364
+ await this.ctx.kernel.devices?.remove(id);
90880
91365
  },
90881
- adoptDevice: async ({ deviceId, childNativeId, name }) => {
90882
- if (deviceId !== this.id) throw new Error(`adoptDevice: deviceId mismatch — got ${deviceId}, expected ${this.id}`);
90883
- return this.adopt(childNativeId, name);
91366
+ findDeviceConfig: async (id) => {
91367
+ const devices = this.ctx.kernel.devices;
91368
+ if (!devices) return null;
91369
+ return devices.loadConfig(id).catch(() => null);
90884
91370
  },
90885
- releaseDevice: async ({ deviceId, childDeviceId }) => {
90886
- if (deviceId !== this.id) return;
90887
- await this.release(childDeviceId);
90888
- }
90889
- });
90890
- this.setCapSlice(deviceDiscoveryCapability, {
90891
- discovered: [],
90892
- lastDiscoveryAt: null,
90893
- lastError: null,
90894
- lastFetchedAt: 0
91371
+ logger: this.ctx.logger
90895
91372
  });
90896
91373
  }
90897
- async refreshDiscovery() {
90898
- const snapshot = enpalBoxes.getSnapshot(this.stableId);
90899
- let lastError = null;
90900
- let discovered = [];
90901
- if (snapshot === null) lastError = enpalBoxes.getManager(this.stableId)?.lastError?.message ?? null;
90902
- else try {
90903
- discovered = buildCandidates({
90904
- boxId: this.stableId,
90905
- boxName: this.name,
90906
- specs: availableSpecs(snapshot),
90907
- adoptedDeviceIdBySpec: await this.adoptedBySpec(),
90908
- online: true
90909
- });
90910
- } catch (err) {
90911
- lastError = errMsg(err);
90912
- this.ctx.logger.warn("Enpal box: discovery refresh failed", {
90913
- tags: { deviceId: this.id },
90914
- meta: { error: lastError }
90915
- });
90916
- }
90917
- this.runtimeState.setCapState(deviceDiscoveryCapability.name, {
90918
- discovered,
90919
- lastDiscoveryAt: Date.now(),
90920
- lastError,
90921
- lastFetchedAt: Date.now()
90922
- });
90923
- }
90924
- async adoptedBySpec() {
90925
- const out = {};
90926
- const children = await this.ctx.devices.getChildren(this.id).catch(() => []);
90927
- for (const child of children) {
90928
- const specId = (await this.ctx.devices.loadConfig(child.id).catch(() => ({})))["specId"];
90929
- if (typeof specId === "string") out[specId] = child.id;
90930
- }
90931
- return out;
90932
- }
90933
- async adopt(childNativeId, nameOverride) {
90934
- const specId = parseSpecNativeId(this.stableId, childNativeId);
90935
- const spec = specId === null ? void 0 : findSpec(specId);
90936
- if (spec === void 0) throw new Error(`adoptDevice: ${childNativeId} is not an Enpal device of this box`);
90937
- const existingId = (await this.adoptedBySpec())[spec.id];
90938
- if (typeof existingId === "number") {
90939
- const existing = (await this.ctx.devices.getAll()).find((d) => d.id === existingId);
90940
- if (existing) return {
90941
- deviceId: existing.id,
90942
- stableId: existing.stableId
90943
- };
90944
- }
90945
- const name = nameOverride ?? `${this.name} ${spec.name}`;
90946
- const stableId = specNativeId(this.stableId, spec.id);
90947
- const created = await this.ctx.devices.create(stableId, EnpalSensorDevice, {
90948
- specId: spec.id,
90949
- boxId: this.stableId,
90950
- system: "enpal",
90951
- name
90952
- }, this.id, {
90953
- type: DeviceType.Sensor,
90954
- name,
90955
- role: spec.role
90956
- });
90957
- this.ctx.logger.info("Enpal box: adopted device", {
90958
- tags: { deviceId: created.id },
90959
- meta: {
90960
- spec: spec.id,
90961
- stableId
90962
- }
90963
- });
90964
- await this.refreshDiscovery().catch(() => void 0);
90965
- return {
90966
- deviceId: created.id,
90967
- stableId
90968
- };
90969
- }
90970
- async release(childDeviceId) {
90971
- const child = (await this.ctx.devices.getAll()).find((d) => d.id === childDeviceId);
90972
- if (!child || child.parentDeviceId !== this.id) throw new Error(`releaseDevice: ${childDeviceId} is not a child of box ${this.id}`);
90973
- await this.ctx.devices.remove(childDeviceId);
90974
- await this.refreshDiscovery().catch(() => void 0);
90975
- }
90976
- };
90977
- //#endregion
90978
- //#region src/addon.ts
90979
- /**
90980
- * Enpal solar box provider, `mode: standalone` (the VeSync / Ecowitt pattern, no broker).
90981
- * A box is added as a {@link DeviceType.Hub} through the manual-creation form: its host,
90982
- * InfluxDB access and token live on the device. The box registers `device-discovery` and
90983
- * adopts the data groups it reports as Sensor devices. The box does not advertise itself on
90984
- * the network, so there is no auto-discovery of the box itself.
90985
- */
90986
- var EnpalProviderAddon = class extends BaseDeviceProvider {
90987
- addonId = "provider-enpal";
90988
- providerName = "Enpal";
90989
- deviceClasses = {
90990
- [DeviceType.Hub]: EnpalBoxDevice,
90991
- [DeviceType.Sensor]: EnpalSensorDevice
90992
- };
90993
- constructor() {
90994
- super({});
90995
- }
90996
- async supportsDiscovery() {
90997
- return false;
91374
+ /** The integration's settings form; the submitted values are stored per integration. */
91375
+ globalSettingsSchema() {
91376
+ return buildConnectionFormSchema();
90998
91377
  }
90999
91378
  async supportsManualCreation() {
91000
- return true;
91379
+ return false;
91001
91380
  }
91002
- async onGetCreationSchema(type) {
91003
- return type === DeviceType.Hub ? buildConnectionFormSchema() : null;
91381
+ async onGetCreationSchema(_type) {
91382
+ return null;
91004
91383
  }
91005
- async onCreateDevice(type, config) {
91006
- if (type !== DeviceType.Hub) throw new Error(`Enpal provider does not support device type: ${type}`);
91007
- const name = typeof config["name"] === "string" ? config["name"].trim() : "";
91008
- if (name.length === 0) throw userError("enpal.name-required", void 0, "Box name is required");
91009
- const parsed = enpalConnectionSchema.safeParse(config["connection"] ?? config);
91010
- if (!parsed.success || parsed.data.host.length === 0) throw userError("enpal.host-required", void 0, "The box host or IP is required");
91011
- return {
91012
- meta: {
91013
- type: DeviceType.Hub,
91014
- name
91015
- },
91016
- config: {
91017
- connection: parsed.data,
91018
- system: "enpal-box"
91019
- }
91020
- };
91384
+ async onCreateDevice(_type, _config) {
91385
+ throw new Error("Enpal devices are adopted from the integration");
91021
91386
  }
91022
- /** One row per box host: re-adding the same box reuses its persisted row. */
91023
- generateStableId(_type, config) {
91024
- const host = readConnection(config?.["connection"] ?? config).connection.host;
91025
- if (host.length > 0) return `enpal:${slugifyHost(host)}`;
91026
- throw new Error("Enpal: box resolved no host — cannot persist a stable row key.");
91387
+ setIntegrationDevicesOnline(integrationId, online) {
91388
+ const reg = this.ctx.kernel.deviceRegistry;
91389
+ if (!reg) return;
91390
+ const n = setIntegrationDevicesOnline(reg.getAllForAddon(this.addonId), integrationId, online);
91391
+ if (n > 0) this.ctx.logger.info("Enpal integration devices " + (online ? "online" : "offline"), { meta: {
91392
+ integrationId,
91393
+ count: n
91394
+ } });
91027
91395
  }
91028
91396
  };
91029
91397
  //#endregion
91030
- export { slugifyHost as C, EnpalProviderAddon, settingsToConnection as S, buildConnectionFormSchema as _, enpalBoxes as a, enpalSensorSchema as b, readEnpalSnapshot as c, specNativeId as d, ENPAL_SPECS as f, findSpec as g, computeSlices as h, EnpalBoxResolver as i, buildCandidates as l, capsOfSpec as m, EnpalSensorDevice as n, EnpalIntegrationManager as o, availableSpecs as p, LifetimeCounterGuard as r, EnpalError as s, EnpalBoxDevice as t, parseSpecNativeId as u, enpalBoxSchema as v, readConnection as x, enpalConnectionSchema as y };
91398
+ export { buildConnectionFormSchema as C, sameConnection as D, readConnection as E, EnpalProviderAddon, settingsToConnection as O, ENPAL_SYSTEM as S, enpalConnectionSchema as T, ENPAL_SPECS as _, buildEnpalAdoptionProvider as a, computeSlices as b, ENPAL_EQUIPMENT as c, findEquipment as d, EnpalSensorDevice as f, LifetimeCounterGuard as g, enpalConnections as h, readEnpalSnapshot as i, accessoryChildSpecs as l, EnpalConnectionResolver as m, EnpalIntegrationManager as n, buildEnpalCandidates as o, enpalSensorSchema as p, EnpalError as r, EnpalEquipmentDevice as s, EnpalClientRegistry as t, enpalEquipmentSchema as u, availableSpecs as v, connectionFromSettings as w, findSpec as x, capsOfSpec as y };