@camstack/addon-provider-enpal 0.1.1 → 0.1.3

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