@camstack/addon-provider-enpal 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -1,4 +1,8 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let node_child_process = require("node:child_process");
3
+ let node_fs = require("node:fs");
4
+ let node_path = require("node:path");
5
+ let node_url = require("node:url");
2
6
  //#region ../types/dist/event-category-78tcBemo.mjs
3
7
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4
8
  EventCategory["SystemBoot"] = "system.boot";
@@ -6026,7 +6030,7 @@ var ZodIssueCode = {
6026
6030
  var ZodFirstPartyTypeKind;
6027
6031
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
6028
6032
  //#endregion
6029
- //#region ../types/dist/sleep-Dz1P6--1.mjs
6033
+ //#region ../types/dist/sleep-Cw1uUXvl.mjs
6030
6034
  /**
6031
6035
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6032
6036
  * window to float samples (D455).
@@ -15067,6 +15071,7 @@ var SYSTEM_CAP_VOCABULARY = {
15067
15071
  backup: system("system backups — run and restored from the Backups page"),
15068
15072
  broker: system("message-broker connections — configured, not actuated"),
15069
15073
  "calendar-source": system(INTERNAL),
15074
+ "cluster-update": system(CLUSTER),
15070
15075
  calendars: system("calendars feed schedules and conditions, not device actions"),
15071
15076
  composer: system("device composition — an authoring tool, not an action"),
15072
15077
  "core-blocks": system(INTERNAL),
@@ -15334,6 +15339,7 @@ var CAP_NAMES = Object.freeze([
15334
15339
  "camera-streams",
15335
15340
  "carbon-monoxide",
15336
15341
  "climate-control",
15342
+ "cluster-update",
15337
15343
  "color",
15338
15344
  "composer",
15339
15345
  "connectivity",
@@ -49550,6 +49556,140 @@ var climateControlCapability = {
49550
49556
  } }
49551
49557
  };
49552
49558
  /**
49559
+ * cluster-update — the ONE flow that brings the whole cluster to a release
49560
+ * (planning#493). Hub-provided (`server-provided`), hub-only: the hub runs
49561
+ * the sequence itself, so the admin UI, the MCP and the CLI all start the same
49562
+ * job and read the same report instead of each driving their own loop.
49563
+ *
49564
+ * 1. backup (`backup.trigger`) — skipped only when the caller says so
49565
+ * 2. the hub's server closure (`applyServerUpdate`); the hub restarts and the
49566
+ * job RESUMES on the new boot, verified against the hub's running version
49567
+ * 3. every online agent behind the target, one at a time, verified: runs the
49568
+ * target, settled, online, and its capabilities registered (D848) — an
49569
+ * agent that comes back capless is a FAILED step, never a done one
49570
+ * 4. every stale addon on the hub (`addons.updatePackage`); agents follow by
49571
+ * reconciliation, which is verified, never pushed
49572
+ *
49573
+ * The job stops on the first failed step with a typed reason (D877). Its state
49574
+ * is durable on the hub and read by RPC (`getClusterUpdateJob`) — events are
49575
+ * telemetry and never carry it (D8, D11).
49576
+ */
49577
+ /** The steps, in the order they run. */
49578
+ var ClusterUpdateStepKindSchema = _enum([
49579
+ "backup",
49580
+ "hub-server",
49581
+ "agent-server",
49582
+ "addons"
49583
+ ]);
49584
+ /**
49585
+ * - `pending` — not reached yet.
49586
+ * - `running` — in progress (for `hub-server`: possibly across the restart).
49587
+ * - `ok` — done AND verified.
49588
+ * - `failed` — did not land; `reason` + `detail` say why. Stops the job.
49589
+ * - `skipped` — not run: nothing to do, opted out, the node is offline, or a
49590
+ * step before it failed. `detail` says which.
49591
+ */
49592
+ var ClusterUpdateStepStatusSchema = _enum([
49593
+ "pending",
49594
+ "running",
49595
+ "ok",
49596
+ "failed",
49597
+ "skipped"
49598
+ ]);
49599
+ /**
49600
+ * Why a step (and with it the job) failed. Stable, greppable; `detail` carries
49601
+ * the prose.
49602
+ */
49603
+ var ClusterUpdateFailureReasonSchema = _enum([
49604
+ "backup-unavailable",
49605
+ "backup-failed",
49606
+ "server-refused",
49607
+ "server-not-on-target",
49608
+ "node-capless",
49609
+ "status-unreadable",
49610
+ "addon-update-failed",
49611
+ "addons-not-converged",
49612
+ "interrupted"
49613
+ ]);
49614
+ var ClusterUpdateStepSchema = object({
49615
+ kind: ClusterUpdateStepKindSchema,
49616
+ /** The node the step acts on; absent for `backup` and `addons`. */
49617
+ nodeId: string().optional(),
49618
+ status: ClusterUpdateStepStatusSchema,
49619
+ /** One line for the operator: what happened, or why not. */
49620
+ detail: string(),
49621
+ reason: ClusterUpdateFailureReasonSchema.optional(),
49622
+ /** `hub-server` / `agent-server`: the version the node ran before. */
49623
+ fromVersion: string().nullable().optional(),
49624
+ /** `addons`: the packages this step updates, `name from -> to`. */
49625
+ packages: array(string()).readonly().optional(),
49626
+ startedAt: number$1().optional(),
49627
+ finishedAt: number$1().optional()
49628
+ });
49629
+ var ClusterUpdateJobStateSchema = _enum([
49630
+ "running",
49631
+ "succeeded",
49632
+ "failed"
49633
+ ]);
49634
+ var ClusterUpdateJobSchema = object({
49635
+ id: string(),
49636
+ state: ClusterUpdateJobStateSchema,
49637
+ /** The server version every node is brought to. */
49638
+ targetVersion: string(),
49639
+ /** Who started it (the user name, or `system`). */
49640
+ requestedBy: string(),
49641
+ startedAt: number$1(),
49642
+ finishedAt: number$1().optional(),
49643
+ /** Every step, in run order; later steps stay `pending` while it runs. */
49644
+ steps: array(ClusterUpdateStepSchema).readonly(),
49645
+ /** On `failed`: the step that stopped it (`nodeId` or the step kind). */
49646
+ stoppedAt: string().optional(),
49647
+ reason: ClusterUpdateFailureReasonSchema.optional(),
49648
+ /** Agents offline when the plan was read: never touched. */
49649
+ skippedOffline: array(string()).readonly()
49650
+ });
49651
+ var ClusterUpdateOptionsSchema = object({
49652
+ /** Explicit server target; omitted = the hub's latest registry check. */
49653
+ version: string().optional(),
49654
+ /** Update the addons too (default true). */
49655
+ addons: boolean().optional(),
49656
+ /**
49657
+ * Back up before touching anything (default true). `false` is an explicit
49658
+ * operator opt-out; a missing backup provider is otherwise a failure.
49659
+ */
49660
+ backup: boolean().optional()
49661
+ });
49662
+ /** Why a plan cannot start. */
49663
+ var ClusterUpdateBlockerSchema = _enum([
49664
+ "no-target",
49665
+ "node-busy",
49666
+ "job-running",
49667
+ "nothing-to-do",
49668
+ "status-unreadable"
49669
+ ]);
49670
+ var ClusterUpdatePlanSchema = object({
49671
+ /** `null` when no target could be resolved (`blocker: 'no-target'`). */
49672
+ targetVersion: string().nullable(),
49673
+ /** The steps the job WOULD run, all `pending` (or `skipped` with a reason). */
49674
+ steps: array(ClusterUpdateStepSchema).readonly(),
49675
+ skippedOffline: array(string()).readonly(),
49676
+ /** `null` = the plan can start. */
49677
+ blocker: ClusterUpdateBlockerSchema.nullable(),
49678
+ /** Prose for `blocker`. */
49679
+ blockerDetail: string().nullable()
49680
+ });
49681
+ var StartClusterUpdateResultSchema = object({
49682
+ accepted: boolean(),
49683
+ /** The job to follow with `getClusterUpdateJob`; `null` when refused. */
49684
+ jobId: string().nullable(),
49685
+ blocker: ClusterUpdateBlockerSchema.nullable(),
49686
+ message: string()
49687
+ });
49688
+ method(ClusterUpdateOptionsSchema, ClusterUpdatePlanSchema, { auth: "admin" }), method(ClusterUpdateOptionsSchema, StartClusterUpdateResultSchema, {
49689
+ kind: "mutation",
49690
+ auth: "admin"
49691
+ }), method(object({ jobId: string().optional() }), ClusterUpdateJobSchema.nullable(), { auth: "admin" });
49692
+ /**
49553
49693
  * Color-light cap. Coexists with `switch` (on/off) and `brightness`
49554
49694
  * (level) on the same device — `switch` toggles the bulb, `brightness`
49555
49695
  * sets the dim level, this cap sets the chromaticity.
@@ -50881,7 +51021,7 @@ var mediaPlayerCapability = {
50881
51021
  } }
50882
51022
  };
50883
51023
  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.PetFountain, DeviceType.Dishwasher, DeviceType.Washer, DeviceType.Heater, DeviceType.Dryer, DeviceType.Gateway, DeviceType.HeatPump, DeviceType.Camera, DeviceType.Fan, DeviceType.Lock, 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.Heater, 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.Heater, 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.Heater, 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.Heater, DeviceType.Gateway, DeviceType.Hub, DeviceType.Camera, DeviceType.Sensor, DeviceType.MediaPlayer, DeviceType.Vacuum, DeviceType.Generic, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.PetFountain, DeviceType.Fan, DeviceType.Humidifier, DeviceType.Climate, DeviceType.WaterHeater, DeviceType.MediaPlayer, DeviceType.Washer, DeviceType.Dryer, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder, DeviceType.Heater, DeviceType.MediaPlayer;
50884
- var NUMBER = {
51024
+ var NUMBER$1 = {
50885
51025
  kind: "fixed",
50886
51026
  result: ["number"]
50887
51027
  };
@@ -50890,19 +51030,19 @@ var BOOLEAN = {
50890
51030
  result: ["boolean"]
50891
51031
  };
50892
51032
  Object.freeze({
50893
- min: NUMBER,
50894
- max: NUMBER,
50895
- abs: NUMBER,
50896
- floor: NUMBER,
50897
- ceil: NUMBER,
50898
- sqrt: NUMBER,
50899
- round: NUMBER,
50900
- pow: NUMBER,
50901
- clamp: NUMBER,
50902
- avg: NUMBER,
50903
- sum: NUMBER,
50904
- age: NUMBER,
50905
- convert: NUMBER,
51033
+ min: NUMBER$1,
51034
+ max: NUMBER$1,
51035
+ abs: NUMBER$1,
51036
+ floor: NUMBER$1,
51037
+ ceil: NUMBER$1,
51038
+ sqrt: NUMBER$1,
51039
+ round: NUMBER$1,
51040
+ pow: NUMBER$1,
51041
+ clamp: NUMBER$1,
51042
+ avg: NUMBER$1,
51043
+ sum: NUMBER$1,
51044
+ age: NUMBER$1,
51045
+ convert: NUMBER$1,
50906
51046
  coalesce: { kind: "coalesce" },
50907
51047
  any: BOOLEAN,
50908
51048
  all: BOOLEAN,
@@ -50919,7 +51059,7 @@ Object.freeze({
50919
51059
  kind: "fixed",
50920
51060
  result: ["number", "null"]
50921
51061
  },
50922
- count: NUMBER,
51062
+ count: NUMBER$1,
50923
51063
  dwell: {
50924
51064
  kind: "fixed",
50925
51065
  result: ["boolean", "null"]
@@ -50945,7 +51085,7 @@ var CHANGED = {
50945
51085
  fieldPath: "lastChangedAt",
50946
51086
  expr: "now"
50947
51087
  };
50948
- function numeric(input) {
51088
+ function numeric$1(input) {
50949
51089
  const { alsoFrom, ...base } = input;
50950
51090
  return {
50951
51091
  ...base,
@@ -50980,7 +51120,7 @@ function binary(input) {
50980
51120
  };
50981
51121
  }
50982
51122
  var GROUP_TEMPLATES = [
50983
- numeric({
51123
+ numeric$1({
50984
51124
  id: "temperature",
50985
51125
  summary: "Mean, median, min, max or sum of temperatures. Offline and unknown members are left out, never read as 0 °C.",
50986
51126
  role: DeviceRole.TemperatureSensor,
@@ -50998,7 +51138,7 @@ var GROUP_TEMPLATES = [
50998
51138
  }
50999
51139
  }
51000
51140
  }),
51001
- numeric({
51141
+ numeric$1({
51002
51142
  id: "humidity",
51003
51143
  summary: "Humidity in percent. Offline and unknown members are left out, never read as 0 %.",
51004
51144
  role: DeviceRole.HumiditySensor,
@@ -51016,7 +51156,7 @@ var GROUP_TEMPLATES = [
51016
51156
  }
51017
51157
  }
51018
51158
  }),
51019
- numeric({
51159
+ numeric$1({
51020
51160
  id: "pressure",
51021
51161
  summary: "Pressure in hectopascals. Offline and unknown members are left out, never read as 0 hPa.",
51022
51162
  role: DeviceRole.PressureSensor,
@@ -51027,7 +51167,7 @@ var GROUP_TEMPLATES = [
51027
51167
  empty: "null",
51028
51168
  companions: [NOW$3]
51029
51169
  }),
51030
- numeric({
51170
+ numeric$1({
51031
51171
  id: "illuminance",
51032
51172
  summary: "Illuminance in lux. Offline and unknown members are left out, never read as 0 lx.",
51033
51173
  role: DeviceRole.AmbientLightSensor,
@@ -51038,7 +51178,7 @@ var GROUP_TEMPLATES = [
51038
51178
  empty: "null",
51039
51179
  companions: [NOW$3]
51040
51180
  }),
51041
- numeric({
51181
+ numeric$1({
51042
51182
  id: "power",
51043
51183
  summary: "Power in watts, summed by default. Nobody reporting omits the key rather than writing 0 W.",
51044
51184
  role: DeviceRole.PowerSensor,
@@ -51049,7 +51189,7 @@ var GROUP_TEMPLATES = [
51049
51189
  empty: "omit",
51050
51190
  companions: [NOW$3]
51051
51191
  }),
51052
- numeric({
51192
+ numeric$1({
51053
51193
  id: "energy",
51054
51194
  summary: "Energy in kilowatt-hours, summed by default. Nobody reporting omits the key rather than writing 0 kWh.",
51055
51195
  role: DeviceRole.EnergySensor,
@@ -51060,7 +51200,7 @@ var GROUP_TEMPLATES = [
51060
51200
  empty: "omit",
51061
51201
  companions: [NOW$3]
51062
51202
  }),
51063
- numeric({
51203
+ numeric$1({
51064
51204
  id: "battery",
51065
51205
  summary: "Battery charge in percent. Offline and unknown members are left out, never read as 0 %.",
51066
51206
  role: DeviceRole.BatterySensor,
@@ -51084,7 +51224,7 @@ var GROUP_TEMPLATES = [
51084
51224
  }
51085
51225
  ]
51086
51226
  }),
51087
- numeric({
51227
+ numeric$1({
51088
51228
  id: "numeric",
51089
51229
  summary: "A numeric reading in the members’ shared quantity. Mixed quantities are refused. Nobody reporting publishes null, not 0.",
51090
51230
  role: DeviceRole.NumericSensor,
@@ -53022,6 +53162,90 @@ var SiteZoneSchema = object({
53022
53162
  * the ones that most want grouping. */
53023
53163
  locationKeys: array(string())
53024
53164
  });
53165
+ var DeviceSearchRequiresSchema = object({
53166
+ /** Capability names the field wants a value of. */
53167
+ caps: array(string()).readonly(),
53168
+ /** `any` (default): a device with one of them is compatible. `all`: it must hold every one. */
53169
+ mode: _enum(["any", "all"]).optional()
53170
+ });
53171
+ var DeviceSearchFiltersSchema = object({
53172
+ locations: array(string()).readonly().optional(),
53173
+ integrationIds: array(string()).readonly().optional(),
53174
+ types: array(string()).readonly().optional()
53175
+ });
53176
+ var DeviceSearchInputSchema = object({
53177
+ /** Matches name, location, type, integration (id and label) and id. Case-insensitive. */
53178
+ text: string().optional(),
53179
+ requires: DeviceSearchRequiresSchema.optional(),
53180
+ filters: DeviceSearchFiltersSchema.optional(),
53181
+ /** List the devices that do not provide the required cap too (greyed, not pickable). */
53182
+ includeIncompatible: boolean().optional(),
53183
+ /** The device the field belongs to: its location drives `suggested`. */
53184
+ contextDeviceId: number$1().optional(),
53185
+ /**
53186
+ * Resolve exactly these devices, regardless of every filter, so a picked
53187
+ * value can be rendered. When given, `items` is these devices in the order
53188
+ * asked (no paging, `nextCursor` null) and the ones that do not exist are
53189
+ * listed in `missingIds`.
53190
+ */
53191
+ ids: array(number$1()).readonly().optional(),
53192
+ cursor: string().nullable().optional(),
53193
+ limit: number$1().int().min(1).max(200).optional()
53194
+ });
53195
+ var DeviceValueOptionSchema = object({
53196
+ cap: string(),
53197
+ /** Status-schema field path inside the cap (same paths as `getWireableFields`). */
53198
+ path: string(),
53199
+ label: string(),
53200
+ /** The current value as text; `null` when it is unknown or the read FAILED — never `0`. */
53201
+ reading: string().nullable(),
53202
+ unit: string().nullable().optional(),
53203
+ /** The field's measured quantity, when the cap declares one, so a client can format the reading. */
53204
+ quantity: ReadingQuantitySchema.optional()
53205
+ });
53206
+ var DeviceSearchItemSchema = object({
53207
+ id: number$1(),
53208
+ name: string(),
53209
+ type: _enum(DeviceType),
53210
+ location: string().nullable(),
53211
+ integrationId: string().nullable(),
53212
+ integrationLabel: string().nullable(),
53213
+ online: boolean().nullable(),
53214
+ disabled: boolean(),
53215
+ compatible: boolean(),
53216
+ /** Only the required caps' fields; empty when nothing was required or the device is incompatible. */
53217
+ values: array(DeviceValueOptionSchema).readonly()
53218
+ });
53219
+ var DeviceSearchFacetValueSchema = object({
53220
+ value: string(),
53221
+ count: number$1()
53222
+ });
53223
+ var DeviceSearchFacetIntegrationSchema = object({
53224
+ id: string(),
53225
+ label: string(),
53226
+ count: number$1()
53227
+ });
53228
+ var DeviceSearchFacetsSchema = object({
53229
+ locations: array(DeviceSearchFacetValueSchema).readonly(),
53230
+ integrations: array(DeviceSearchFacetIntegrationSchema).readonly(),
53231
+ types: array(DeviceSearchFacetValueSchema).readonly()
53232
+ });
53233
+ var DeviceSearchResultSchema = object({
53234
+ items: array(DeviceSearchItemSchema).readonly(),
53235
+ nextCursor: string().nullable(),
53236
+ /** Devices listed by this query across all pages (filters + text applied; incompatible counted only when `includeIncompatible`). */
53237
+ total: number$1(),
53238
+ /** Compatible devices after filters + text. */
53239
+ compatibleTotal: number$1(),
53240
+ /** Every device the caller's fleet holds, before anything is applied. */
53241
+ fleetTotal: number$1(),
53242
+ /** Incompatible devices after filters + text — the K of "show the K that don't". */
53243
+ hiddenIncompatible: number$1(),
53244
+ facets: DeviceSearchFacetsSchema,
53245
+ /** Same location as `contextDeviceId`; first page only. */
53246
+ suggested: array(DeviceSearchItemSchema).readonly(),
53247
+ missingIds: array(number$1()).readonly()
53248
+ });
53025
53249
  var ProviderStatusSchema = object({
53026
53250
  connected: boolean(),
53027
53251
  deviceCount: number$1(),
@@ -53507,6 +53731,9 @@ var DeviceInfoSchema = object({
53507
53731
  * named accordion sections (with optional intra-section order). See
53508
53732
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
53509
53733
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
53734
+ /** The children a container stands for, most preferred first, by accessory
53735
+ * `childKey`. Provider-declared. See `DeviceMeta.primaryChildPreference`. */
53736
+ primaryChildPreference: array(string()).readonly().optional(),
53510
53737
  /** Operator-authored per-device display override. See `DeviceMeta.display`. */
53511
53738
  display: DeviceDisplayOverrideSchema.optional()
53512
53739
  });
@@ -53655,6 +53882,9 @@ var DeviceMetaSchema = object({
53655
53882
  * accordion sections (with optional intra-section order). See
53656
53883
  * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
53657
53884
  childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
53885
+ /** The children a container stands for, most preferred first, by accessory
53886
+ * `childKey`. Provider-declared. See `DeviceMeta.primaryChildPreference`. */
53887
+ primaryChildPreference: array(string()).readonly().optional(),
53658
53888
  /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
53659
53889
  * Optional: only present for accessory children that carry a known role. */
53660
53890
  role: string().nullable().optional(),
@@ -53870,6 +54100,12 @@ method(object({
53870
54100
  }), _void(), {
53871
54101
  kind: "mutation",
53872
54102
  auth: "admin"
54103
+ }), method(object({
54104
+ deviceId: number$1(),
54105
+ primaryChildPreference: array(string()).readonly()
54106
+ }), _void(), {
54107
+ kind: "mutation",
54108
+ auth: "admin"
53873
54109
  }), method(object({
53874
54110
  deviceId: number$1(),
53875
54111
  display: DeviceDisplayOverrideSchema.nullable()
@@ -54007,7 +54243,7 @@ method(object({
54007
54243
  * it answers today and the caller filters as it already does.
54008
54244
  */
54009
54245
  deviceIds: array(number$1()).optional()
54010
- }), array(DeviceInfoSchema), { role: "viewer" }), method(object({ deviceId: number$1() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number$1() }), array(DeviceInfoSchema), { role: "viewer" }), method(object({ parentDeviceIds: array(number$1()).max(256) }), record(string(), array(DeviceInfoSchema))), method(object({ deviceId: number$1() }), object({
54246
+ }), array(DeviceInfoSchema), { role: "viewer" }), method(DeviceSearchInputSchema, DeviceSearchResultSchema, { role: "viewer" }), method(object({ deviceId: number$1() }), DeviceInfoSchema.nullable()), method(object({ parentDeviceId: number$1() }), array(DeviceInfoSchema), { role: "viewer" }), method(object({ parentDeviceIds: array(number$1()).max(256) }), record(string(), array(DeviceInfoSchema))), method(object({ deviceId: number$1() }), object({
54011
54247
  mode: LinkedDevicesModeSchema,
54012
54248
  devices: array(LinkedDeviceSchema)
54013
54249
  }), { role: "viewer" }), method(object({ deviceIds: array(number$1()) }), array(LinkedDevicesForDeviceSchema), { role: "viewer" }), method(object({ deviceId: number$1() }), array(StreamSourceEntrySchema$1)), method(object({ deviceId: number$1() }), array(ConfigEntrySchema)), method(object({ deviceId: number$1() }), ConfigUISchemaOutput), method(object({
@@ -57705,6 +57941,8 @@ var HeroPillSchema = object({
57705
57941
  });
57706
57942
  var HeroRowSchema = object({
57707
57943
  id: HeroRowIdSchema,
57944
+ /** `scene`: the row's action and slider controls are drawn on the scene. Absent: an ordinary row. */
57945
+ placement: _enum(["row", "scene"]).optional(),
57708
57946
  /** The row's own name when the kind's label would not tell rows of one kind apart (a circuit's label). */
57709
57947
  label: HeroTextSchema.optional(),
57710
57948
  /** A group header drawn above the row when it differs from the previous row's ("System"). */
@@ -69494,13 +69732,23 @@ var NcRuleTargetSchema = object({
69494
69732
  */
69495
69733
  params: record(string(), unknown()).optional()
69496
69734
  });
69735
+ /** Which picture kind one target receives (#487). */
69736
+ var NcTargetAttachmentSchema = _enum([
69737
+ "none",
69738
+ "image",
69739
+ "clip",
69740
+ "gif"
69741
+ ]);
69497
69742
  var NcTargetOverrideSchema = object({
69498
69743
  template: object({
69499
69744
  title: string().max(500).optional(),
69500
69745
  body: string().max(2e3).optional()
69501
69746
  }).optional(),
69502
69747
  schedule: NcScheduleSchema.optional(),
69503
- priority: number$1().int().min(1).max(5).optional()
69748
+ priority: number$1().int().min(1).max(5).optional(),
69749
+ attachment: NcTargetAttachmentSchema.optional(),
69750
+ /** `false` = no buttons for this target; `true` is the same as absent. */
69751
+ actions: boolean().optional()
69504
69752
  });
69505
69753
  /** Per-target settings keyed by target id (#487). */
69506
69754
  var NcTargetOverridesSchema = record(string(), NcTargetOverrideSchema);
@@ -73538,18 +73786,18 @@ var powerSourceCapability = {
73538
73786
  mode: "singleton",
73539
73787
  deviceTypes: POWER_SOURCE_DEVICE_TYPES,
73540
73788
  methods: {
73541
- getStatus: systemMethod(deviceInput$1, PowerSourceInfoSchema, { auth: "admin" }),
73789
+ getStatus: systemMethod(deviceInput$1, PowerSourceInfoSchema, { auth: "protected" }),
73542
73790
  powerOn: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
73543
73791
  kind: "mutation",
73544
- auth: "admin"
73792
+ auth: "protected"
73545
73793
  }),
73546
73794
  powerOff: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
73547
73795
  kind: "mutation",
73548
- auth: "admin"
73796
+ auth: "protected"
73549
73797
  }),
73550
73798
  forceRestart: systemMethod(deviceInput$1, PowerSourceActionResultSchema, {
73551
73799
  kind: "mutation",
73552
- auth: "admin"
73800
+ auth: "protected"
73553
73801
  })
73554
73802
  },
73555
73803
  runtimeState: PowerSourceStateSchema,
@@ -88299,6 +88547,27 @@ Object.freeze({
88299
88547
  access: "create",
88300
88548
  role: "viewer"
88301
88549
  },
88550
+ "clusterUpdate.getClusterUpdateJob": {
88551
+ capName: "cluster-update",
88552
+ capScope: "system",
88553
+ addonId: null,
88554
+ access: "view",
88555
+ role: "admin"
88556
+ },
88557
+ "clusterUpdate.previewClusterUpdate": {
88558
+ capName: "cluster-update",
88559
+ capScope: "system",
88560
+ addonId: null,
88561
+ access: "view",
88562
+ role: "admin"
88563
+ },
88564
+ "clusterUpdate.startClusterUpdate": {
88565
+ capName: "cluster-update",
88566
+ capScope: "system",
88567
+ addonId: null,
88568
+ access: "create",
88569
+ role: "admin"
88570
+ },
88302
88571
  "color.getStatus": {
88303
88572
  capName: "color",
88304
88573
  capScope: "device",
@@ -89671,6 +89940,13 @@ Object.freeze({
89671
89940
  access: "create",
89672
89941
  role: "admin"
89673
89942
  },
89943
+ "deviceManager.search": {
89944
+ capName: "device-manager",
89945
+ capScope: "system",
89946
+ addonId: null,
89947
+ access: "view",
89948
+ role: "viewer"
89949
+ },
89674
89950
  "deviceManager.setAlias": {
89675
89951
  capName: "device-manager",
89676
89952
  capScope: "system",
@@ -89762,6 +90038,13 @@ Object.freeze({
89762
90038
  access: "create",
89763
90039
  role: "admin"
89764
90040
  },
90041
+ "deviceManager.setPrimaryChildPreference": {
90042
+ capName: "device-manager",
90043
+ capScope: "system",
90044
+ addonId: null,
90045
+ access: "create",
90046
+ role: "admin"
90047
+ },
89765
90048
  "deviceManager.setRole": {
89766
90049
  capName: "device-manager",
89767
90050
  capScope: "system",
@@ -94401,28 +94684,28 @@ Object.freeze({
94401
94684
  capScope: "device",
94402
94685
  addonId: null,
94403
94686
  access: "create",
94404
- role: "admin"
94687
+ role: "viewer"
94405
94688
  },
94406
94689
  "powerSource.getStatus": {
94407
94690
  capName: "power-source",
94408
94691
  capScope: "device",
94409
94692
  addonId: null,
94410
94693
  access: "view",
94411
- role: "admin"
94694
+ role: "viewer"
94412
94695
  },
94413
94696
  "powerSource.powerOff": {
94414
94697
  capName: "power-source",
94415
94698
  capScope: "device",
94416
94699
  addonId: null,
94417
94700
  access: "create",
94418
- role: "admin"
94701
+ role: "viewer"
94419
94702
  },
94420
94703
  "powerSource.powerOn": {
94421
94704
  capName: "power-source",
94422
94705
  capScope: "device",
94423
94706
  addonId: null,
94424
94707
  access: "create",
94425
- role: "admin"
94708
+ role: "viewer"
94426
94709
  },
94427
94710
  "presence.getStatus": {
94428
94711
  capName: "presence",
@@ -98286,6 +98569,11 @@ Object.freeze({
98286
98569
  form: "single",
98287
98570
  optional: false
98288
98571
  }],
98572
+ "deviceManager.setPrimaryChildPreference": [{
98573
+ name: "deviceId",
98574
+ form: "single",
98575
+ optional: false
98576
+ }],
98289
98577
  "deviceManager.setRole": [{
98290
98578
  name: "deviceId",
98291
98579
  form: "single",
@@ -101754,7 +102042,9 @@ var TimelapseRuleInputSchema = object({
101754
102042
  /** Classes the notification counts — see {@link ReportClassesField}. */
101755
102043
  reportClasses: ReportClassesField.optional(),
101756
102044
  /** Canonical notification priority ordinal (1..5); per-target overridable. */
101757
- priority: PriorityField.default(3)
102045
+ priority: PriorityField.default(3),
102046
+ /** Per-target settings keyed by target id (D1032) — absent = every target follows the rule. */
102047
+ targetOverrides: NcTargetOverridesSchema.optional()
101758
102048
  });
101759
102049
  object({
101760
102050
  name: NameField.optional(),
@@ -101770,7 +102060,9 @@ object({
101770
102060
  previewText: PreviewTextField.optional(),
101771
102061
  previewMode: PreviewModeField.optional(),
101772
102062
  reportClasses: ReportClassesField.optional(),
101773
- priority: PriorityField.optional()
102063
+ priority: PriorityField.optional(),
102064
+ /** Per-target settings keyed by target id (D1032) — absent = every target follows the rule. */
102065
+ targetOverrides: NcTargetOverridesSchema.optional()
101774
102066
  });
101775
102067
  TimelapseRuleInputSchema.extend({
101776
102068
  id: string(),
@@ -102136,13 +102428,15 @@ object({
102136
102428
  * settings of the integration itself, not of a device. Every equipment the box reports is
102137
102429
  * adopted as a Container from it.
102138
102430
  *
102139
- * Two sources behind `source`:
102431
+ * Sources behind `source`:
102432
+ * - `blazor`: the box's own web-page channel (Blazor Server, firmware 8.51 and later),
102433
+ * read by an embedded Python bridge. No token, nothing written to the box.
102140
102434
  * - `influx`: the box's InfluxDB 2.x (port 8086, org `enpal`, bucket `solar`).
102141
102435
  * Needs the access token Enpal support issues; it is a keychain secret.
102142
102436
  * - `page`: the box's local device page (`/deviceMessages`). No token; fewer
102143
102437
  * boxes and firmware versions still serve it.
102144
- * - `auto` (default): InfluxDB when a token is set, the page otherwise or when
102145
- * InfluxDB cannot be read.
102438
+ * - `auto` (default): `blazor`, then InfluxDB when a token is set, then the page; the first
102439
+ * one that answers wins and the snapshot records why a preferred one was skipped.
102146
102440
  */
102147
102441
  /** Strip a pasted scheme and path: `http://192.0.2.10/deviceMessages` -> `192.0.2.10`. */
102148
102442
  function cleanHost(value) {
@@ -102160,6 +102454,7 @@ var FLUX_NAME = /^[A-Za-z0-9_.-]+$/;
102160
102454
  var ENPAL_SYSTEM = "enpal";
102161
102455
  var ENPAL_SOURCES = [
102162
102456
  "auto",
102457
+ "blazor",
102163
102458
  "influx",
102164
102459
  "page"
102165
102460
  ];
@@ -102220,7 +102515,7 @@ function buildConnectionFormSchema() {
102220
102515
  id: "source",
102221
102516
  title: "Data source",
102222
102517
  titleKey: "provider-enpal:connection.s_source.title",
102223
- description: "InfluxDB needs the access token Enpal support issues (ask in the Enpal app chat). Without it the box device page is used.",
102518
+ description: "The box's own web-page channel needs no credential and is used first. InfluxDB is used only with the access token Enpal support issues; the device page is the last resort.",
102224
102519
  descriptionKey: "provider-enpal:connection.s_source.description",
102225
102520
  columns: 2,
102226
102521
  fields: [
@@ -102233,9 +102528,14 @@ function buildConnectionFormSchema() {
102233
102528
  options: [
102234
102529
  {
102235
102530
  value: "auto",
102236
- label: "Automatic (InfluxDB, then device page)",
102531
+ label: "Automatic (box web channel, InfluxDB, then device page)",
102237
102532
  labelKey: "provider-enpal:connection.s_source.source.o_auto.label"
102238
102533
  },
102534
+ {
102535
+ value: "blazor",
102536
+ label: "Box web channel only (firmware 8.51 and later)",
102537
+ labelKey: "provider-enpal:connection.s_source.source.o_blazor.label"
102538
+ },
102239
102539
  {
102240
102540
  value: "influx",
102241
102541
  label: "InfluxDB only",
@@ -102351,6 +102651,22 @@ var point = (quantity, ...sources) => ({
102351
102651
  quantity,
102352
102652
  sources: sources.map((s) => typeof s === "string" ? { key: s } : s)
102353
102653
  });
102654
+ var numeric = (id, name, quantity, key, role = DeviceRole.NumericSensor) => ({
102655
+ kind: "numeric",
102656
+ id,
102657
+ name,
102658
+ role,
102659
+ quantity,
102660
+ value: point(quantity, key)
102661
+ });
102662
+ var text = (id, name, key, timestamp = false) => ({
102663
+ kind: "text",
102664
+ id,
102665
+ name,
102666
+ role: timestamp ? DeviceRole.DateTimeSensor : DeviceRole.EnumSensor,
102667
+ key,
102668
+ ...timestamp ? { timestamp: true } : {}
102669
+ });
102354
102670
  /**
102355
102671
  * What an Enpal box can show, by the dotted keys its InfluxDB and device page use (the
102356
102672
  * names follow the Home Assistant component's key table). A device is offered only when the
@@ -102433,7 +102749,7 @@ var ENPAL_SPECS = [
102433
102749
  id: "house",
102434
102750
  name: "Consumption",
102435
102751
  role: DeviceRole.PowerSensor,
102436
- power: point("power", "Power.Consumption.Total", "Power.Consumption.AC"),
102752
+ power: point("power", "Power.Consumption.Total"),
102437
102753
  kwh: point("energy", "Energy.Consumption.Total.Lifetime")
102438
102754
  },
102439
102755
  {
@@ -102483,7 +102799,87 @@ var ENPAL_SPECS = [
102483
102799
  role: DeviceRole.NumericSensor,
102484
102800
  quantity: "percentage",
102485
102801
  value: point("percentage", "Battery.SOH")
102486
- }
102802
+ },
102803
+ numeric("pv-string-1-power", "String 1 power", "power", "Power.DC.String.1", DeviceRole.PowerSensor),
102804
+ numeric("pv-string-2-power", "String 2 power", "power", "Power.DC.String.2", DeviceRole.PowerSensor),
102805
+ numeric("pv-energy-day", "Production today", "energy", "Energy.Production.Total.Day", DeviceRole.EnergySensor),
102806
+ numeric("pv-string-1-voltage", "String 1 voltage", "voltage", "Voltage.String.1", DeviceRole.VoltageSensor),
102807
+ numeric("pv-string-2-voltage", "String 2 voltage", "voltage", "Voltage.String.2", DeviceRole.VoltageSensor),
102808
+ numeric("pv-string-1-current", "String 1 current", "current", "Current.String.1", DeviceRole.CurrentSensor),
102809
+ numeric("pv-string-2-current", "String 2 current", "current", "Current.String.2", DeviceRole.CurrentSensor),
102810
+ numeric("battery-stored-energy", "Stored energy", "energy", "Energy.Battery.Charge.Load", DeviceRole.EnergySensor),
102811
+ numeric("battery-charge-day", "Charged today", "energy", "Energy.Battery.Charge.Day", DeviceRole.EnergySensor),
102812
+ numeric("battery-discharge-day", "Discharged today", "energy", "Energy.Battery.Discharge.Day", DeviceRole.EnergySensor),
102813
+ numeric("battery-capacity", "Capacity", "energy", "Energy.Battery.Capacity", DeviceRole.EnergySensor),
102814
+ numeric("battery-power-rating", "Maximum power rating", "power", "Battery.Power.Rating.Maximum", DeviceRole.PowerSensor),
102815
+ numeric("battery-level-max", "Maximum charge level", "percentage", "Battery.ChargeLevel.Max"),
102816
+ numeric("battery-level-min", "Minimum charge level", "percentage", "Battery.ChargeLevel.Min"),
102817
+ numeric("battery-power-set", "Power setpoint", "power", "Power.Battery.Charge.Discharge.Set", DeviceRole.PowerSensor),
102818
+ numeric("battery-charge-power-max", "Maximum charge power", "power", "Power.Battery.Charge.Max", DeviceRole.PowerSensor),
102819
+ numeric("battery-discharge-power-max", "Maximum discharge power", "power", "Power.Battery.Discharge.Max", DeviceRole.PowerSensor),
102820
+ numeric("battery-charge-current-max", "Maximum charge current", "current", "Current.Battery.Charge.Max", DeviceRole.CurrentSensor),
102821
+ numeric("battery-discharge-current-max", "Maximum discharge current", "current", "Current.Battery.Discharge.Max", DeviceRole.CurrentSensor),
102822
+ numeric("grid-phase-a", "Phase A power", "power", "Power.AC.Phase.A", DeviceRole.PowerSensor),
102823
+ numeric("grid-phase-b", "Phase B power", "power", "Power.AC.Phase.B", DeviceRole.PowerSensor),
102824
+ numeric("grid-phase-c", "Phase C power", "power", "Power.AC.Phase.C", DeviceRole.PowerSensor),
102825
+ numeric("grid-import-day", "Imported today", "energy", "Energy.Grid.Import.Day", DeviceRole.EnergySensor),
102826
+ numeric("grid-export-day", "Exported today", "energy", "Energy.Grid.Export.Day", DeviceRole.EnergySensor),
102827
+ numeric("grid-voltage-a", "Phase A voltage", "voltage", "Voltage.Phase.A", DeviceRole.VoltageSensor),
102828
+ numeric("grid-voltage-b", "Phase B voltage", "voltage", "Voltage.Phase.B", DeviceRole.VoltageSensor),
102829
+ numeric("grid-voltage-c", "Phase C voltage", "voltage", "Voltage.Phase.C", DeviceRole.VoltageSensor),
102830
+ {
102831
+ kind: "numeric",
102832
+ id: "grid-frequency",
102833
+ name: "Frequency",
102834
+ role: DeviceRole.NumericSensor,
102835
+ quantity: "index",
102836
+ value: {
102837
+ ...point("index", "Frequency.Grid"),
102838
+ ignoreUnit: true
102839
+ }
102840
+ },
102841
+ numeric("grid-feed-max", "Maximum feed-in power", "power", "Power.Grid.Maximum.Feed", DeviceRole.PowerSensor),
102842
+ numeric("house-energy-day", "Consumption today", "energy", "Energy.Consumption.Total.Day", DeviceRole.EnergySensor),
102843
+ text("inverter-state", "System state", "Inverter.System.State"),
102844
+ text("inverter-ems-mode", "EMS mode", "Command.EMS.Mode"),
102845
+ numeric("inverter-ac-power", "AC power", "power", "Power.Inverter.AC.Export", DeviceRole.PowerSensor),
102846
+ text("inverter-start-stop", "Start/stop command", "Command.Start.Stop"),
102847
+ text("inverter-forcible-mode", "Forcible charge/discharge mode", "Mode.Forcible.Charge.Discharge"),
102848
+ numeric("inverter-power-rating", "Maximum power rating", "power", "Inverter.Power.Rating.Maximum", DeviceRole.PowerSensor),
102849
+ text("inverter-phase-type", "Phase type", "Type.Phase.Inverter"),
102850
+ text("inverter-modbus-rtu", "Modbus RTU status", "Inverter.Modbus.Health.Rtu.Status"),
102851
+ text("inverter-modbus-tcp", "Modbus TCP status", "Inverter.Modbus.Health.Tcp.Status"),
102852
+ text("heatpump-hot-water-mode", "Hot water mode", "Heatpump.DomesticHotWater.1.Mode"),
102853
+ text("heatpump-heating-mode", "Heating circuit mode", "Heatpump.HeatingCircuit.1.Mode"),
102854
+ text("heatpump-generator-state", "Generator state", "Heatpump.Generator.State.Running"),
102855
+ text("heatpump-heater-state", "Electric heater state", "Heatpump.ElectricHeater.State.Running"),
102856
+ text("heatpump-energy-management", "Energy management state", "Heatpump.EnergyManagement.State"),
102857
+ text("heatpump-power-desired", "Power requested", "Heatpump.Power.Desired.Enabled"),
102858
+ text("heatpump-pv-optimization", "PV optimisation", "Heatpump.Settings.PvOptimizationStatus"),
102859
+ numeric("heatpump-power-min", "Generator minimum power", "power", "Heatpump.Generator.Power.Min", DeviceRole.PowerSensor),
102860
+ numeric("heatpump-power-max", "Generator maximum power", "power", "Heatpump.Generator.Power.Max", DeviceRole.PowerSensor),
102861
+ text("heatpump-connection", "Local connection", "Heatpump.Local.ConnectionState"),
102862
+ text("box-main-state", "Main state", "IoT.MainState"),
102863
+ numeric("box-cpu-load", "CPU load", "percentage", "Cpu.Load"),
102864
+ numeric("box-memory-usage", "Memory usage", "percentage", "Memory.Usage"),
102865
+ text("box-hardware-check", "Hardware check", "HW.Cronny.Result"),
102866
+ text("eebus-connection", "Connection state", "Eebus.ConnectionState"),
102867
+ text("eebus-discovery", "Discovery state", "Eebus.DiscoveryState"),
102868
+ text("eebus-cert-expiry", "Certificate expiry", "Eebus.CertExpiry", true),
102869
+ text("lte-state", "LTE state", "LTE.State"),
102870
+ text("lte-connection-type", "Connection type", "LTE.Connection.Type"),
102871
+ numeric("lte-quality", "LTE quality", "percentage", "LTE.Quality"),
102872
+ numeric("lte-rsrp", "RSRP", "signal-level", "LTE.RSRP"),
102873
+ numeric("lte-rsrq", "RSRQ", "index", "LTE.RSRQ"),
102874
+ numeric("lte-rssi", "RSSI", "signal-level", "LTE.RSSI"),
102875
+ numeric("lte-snr", "SNR", "index", "LTE.SNR"),
102876
+ text("lte-failover", "Failover result", "LTE.Failover.Result"),
102877
+ text("lte-signal-check", "Signal check", "LTE.Cronny.Result"),
102878
+ text("wifi-state", "Wi-Fi state", "WiFi.State"),
102879
+ numeric("data-lan-down", "LAN download this month", "count", "IoT.Data.Consumption.Lan.Down.Month"),
102880
+ numeric("data-lan-up", "LAN upload this month", "count", "IoT.Data.Consumption.Lan.Up.Month"),
102881
+ numeric("data-lte-down", "LTE download this month", "count", "IoT.Data.Consumption.Lte.Down.Month"),
102882
+ numeric("data-lte-up", "LTE upload this month", "count", "IoT.Data.Consumption.Lte.Up.Month")
102487
102883
  ];
102488
102884
  function findSpec(id) {
102489
102885
  return ENPAL_SPECS.find((s) => s.id === id);
@@ -102495,6 +102891,7 @@ function capsOfSpec(spec) {
102495
102891
  case "battery": return ["battery", "power-meter"];
102496
102892
  case "meter": return ["meter"];
102497
102893
  case "numeric": return ["numeric-sensor"];
102894
+ case "text": return ["enum-sensor"];
102498
102895
  }
102499
102896
  }
102500
102897
  function pointsOf(spec) {
@@ -102508,6 +102905,7 @@ function pointsOf(spec) {
102508
102905
  ];
102509
102906
  case "meter":
102510
102907
  case "numeric": return [spec.value];
102908
+ case "text": return [];
102511
102909
  }
102512
102910
  }
102513
102911
  function resolve(target, snapshot) {
@@ -102515,7 +102913,7 @@ function resolve(target, snapshot) {
102515
102913
  for (const source of target.sources) {
102516
102914
  const reading = snapshot.readings.get(source.key);
102517
102915
  if (reading === void 0) continue;
102518
- const converted = toBase(reading.value, reading.unit, target.quantity);
102916
+ const converted = toBase(reading.value, target.ignoreUnit === true ? void 0 : reading.unit, target.quantity);
102519
102917
  if (!converted.ok) {
102520
102918
  refusal = `${source.key}: ${converted.reason} (unit ${reading.unit ?? "none"})`;
102521
102919
  continue;
@@ -102534,7 +102932,7 @@ function resolve(target, snapshot) {
102534
102932
  }
102535
102933
  /** Specs whose data the box reported: a refused point still counts, the device exists. */
102536
102934
  function availableSpecs(snapshot) {
102537
- return ENPAL_SPECS.filter((spec) => pointsOf(spec).some((p) => resolve(p, snapshot).state !== "absent"));
102935
+ return ENPAL_SPECS.filter((spec) => spec.kind === "text" ? snapshot.texts.has(spec.key) : pointsOf(spec).some((p) => resolve(p, snapshot).state !== "absent"));
102538
102936
  }
102539
102937
  function numberOf(r) {
102540
102938
  return r.state === "value" ? r.value : void 0;
@@ -102633,6 +103031,17 @@ function computeSlices(spec, snapshot, options, now) {
102633
103031
  refusals: refusalOf(value)
102634
103032
  };
102635
103033
  }
103034
+ case "text": {
103035
+ const value = snapshot.texts.get(spec.key);
103036
+ return {
103037
+ ...value !== void 0 ? { text: {
103038
+ value,
103039
+ ...spec.timestamp === true ? { timestamp: true } : {},
103040
+ lastFetchedAt: now
103041
+ } } : {},
103042
+ refusals: []
103043
+ };
103044
+ }
102636
103045
  }
102637
103046
  }
102638
103047
  //#endregion
@@ -102730,6 +103139,7 @@ var POWER_CAP = powerMeterCapability.name;
102730
103139
  var BATTERY_CAP = batteryCapability.name;
102731
103140
  var METER_CAP = meterCapability.name;
102732
103141
  var NUMERIC_CAP = numericSensorCapability.name;
103142
+ var ENUM_CAP = enumSensorCapability.name;
102733
103143
  var POWER_COLD = { lastFetchedAt: 0 };
102734
103144
  var BATTERY_COLD = {
102735
103145
  level: null,
@@ -102741,6 +103151,11 @@ var VALUE_COLD = {
102741
103151
  value: null,
102742
103152
  lastFetchedAt: 0
102743
103153
  };
103154
+ /** A text has no null: until the box sends one the slice is empty and never read as a state. */
103155
+ var TEXT_COLD = {
103156
+ value: "",
103157
+ lastFetchedAt: 0
103158
+ };
102744
103159
  /**
102745
103160
  * Persisted config of an Enpal Sensor. `specId` selects the entry of the catalog;
102746
103161
  * `integrationId` selects the integration whose box feeds it.
@@ -102778,7 +103193,8 @@ var EnpalSensorDevice = class extends BaseDevice {
102778
103193
  this.updateSourceInfo({
102779
103194
  id: `${this.integrationId}:${spec.id}`,
102780
103195
  system: ENPAL_SYSTEM,
102781
- ...spec.kind === "meter" || spec.kind === "numeric" ? { quantity: spec.quantity } : {}
103196
+ ...spec.kind === "meter" || spec.kind === "numeric" ? { quantity: spec.quantity } : {},
103197
+ ...spec.kind === "text" && spec.timestamp === true ? { dateTimeShape: "date" } : {}
102782
103198
  });
102783
103199
  }
102784
103200
  async onActivate() {
@@ -102836,6 +103252,7 @@ var EnpalSensorDevice = class extends BaseDevice {
102836
103252
  });
102837
103253
  }
102838
103254
  if (slices.numeric !== void 0) this.runtimeState.setCapState(NUMERIC_CAP, slices.numeric);
103255
+ if (slices.text !== void 0) this.runtimeState.setCapState(ENUM_CAP, slices.text);
102839
103256
  }
102840
103257
  registerCaps() {
102841
103258
  switch (this.spec.kind) {
@@ -102850,6 +103267,10 @@ var EnpalSensorDevice = class extends BaseDevice {
102850
103267
  this.ctx.registerNativeCap(meterCapability, { getStatus: async () => this.runtimeState.getCapState(METER_CAP) ?? VALUE_COLD });
102851
103268
  this.runtimeState.setCapState(METER_CAP, VALUE_COLD);
102852
103269
  return;
103270
+ case "text":
103271
+ this.ctx.registerNativeCap(enumSensorCapability, { getStatus: async () => this.runtimeState.getCapState(ENUM_CAP) ?? TEXT_COLD });
103272
+ this.runtimeState.setCapState(ENUM_CAP, TEXT_COLD);
103273
+ return;
102853
103274
  case "numeric":
102854
103275
  this.ctx.registerNativeCap(numericSensorCapability, { getStatus: async () => this.runtimeState.getCapState(NUMERIC_CAP) ?? VALUE_COLD });
102855
103276
  this.runtimeState.setCapState(NUMERIC_CAP, VALUE_COLD);
@@ -102879,43 +103300,166 @@ function enpalSensorFactory(ctx) {
102879
103300
  }
102880
103301
  //#endregion
102881
103302
  //#region src/enpal-equipment.ts
102882
- /** Order is the order the candidates are listed in. Every catalog entry belongs to one. */
103303
+ var live = (section, ...specIds) => specIds.map((specId) => ({
103304
+ specId,
103305
+ ...section !== void 0 ? { section } : {},
103306
+ diagnostic: false
103307
+ }));
103308
+ var diag = (section, ...specIds) => specIds.map((specId) => ({
103309
+ specId,
103310
+ ...section !== void 0 ? { section } : {},
103311
+ diagnostic: true
103312
+ }));
103313
+ function equipment(input) {
103314
+ return {
103315
+ ...input,
103316
+ specIds: input.children.map((c) => c.specId)
103317
+ };
103318
+ }
103319
+ /**
103320
+ * Three adoptable pieces: the box (the Gateway, with its connection, firmware and
103321
+ * diagnostics), the installation (System: Sungrow inverter, battery, grid, house meter), and
103322
+ * the heat pump as the box reads it. Order is the order the candidates are listed in. Every
103323
+ * catalog entry belongs to one. The ids of the older catalog entries are unchanged.
103324
+ */
102883
103325
  var ENPAL_EQUIPMENT = [
102884
- {
102885
- id: "pv",
102886
- name: "Photovoltaic",
102887
- specIds: ["pv", "inverter-temperature"]
102888
- },
102889
- {
102890
- id: "battery",
102891
- name: "Battery",
102892
- specIds: [
103326
+ equipment({
103327
+ id: "box",
103328
+ kind: "gateway",
103329
+ name: "Enpal Box",
103330
+ children: [
103331
+ ...diag("Box", "box-main-state", "box-cpu-load", "box-memory-usage", "box-hardware-check"),
103332
+ ...diag("EEBUS", "eebus-connection", "eebus-discovery", "eebus-cert-expiry"),
103333
+ ...diag("Connectivity", "lte-state", "lte-connection-type", "lte-quality", "lte-rsrp", "lte-rsrq", "lte-rssi", "lte-snr", "lte-failover", "lte-signal-check", "wifi-state", "data-lan-down", "data-lan-up", "data-lte-down", "data-lte-up")
103334
+ ],
103335
+ metadata: [
103336
+ {
103337
+ attribute: "deviceName",
103338
+ key: "Device.Name"
103339
+ },
103340
+ {
103341
+ attribute: "model",
103342
+ key: "IoTEdgeDevice.Device.Type"
103343
+ },
103344
+ {
103345
+ attribute: "serialNumber",
103346
+ key: "IoTEdgeDevice.SerialNumber"
103347
+ },
103348
+ {
103349
+ attribute: "hardware",
103350
+ key: "Generation.HW"
103351
+ },
103352
+ {
103353
+ attribute: "softwareGeneration",
103354
+ key: "Generation.SW"
103355
+ },
103356
+ {
103357
+ attribute: "eebusSki",
103358
+ key: "Eebus.Ski"
103359
+ },
103360
+ {
103361
+ attribute: "modemModel",
103362
+ key: "LTE.Modem.Type"
103363
+ },
103364
+ {
103365
+ attribute: "modemFirmware",
103366
+ key: "LTE.Modem.Firmware.Version"
103367
+ }
103368
+ ],
103369
+ primaryChildPreference: []
103370
+ }),
103371
+ equipment({
103372
+ id: "impianto",
103373
+ kind: "container",
103374
+ name: "System",
103375
+ children: [
103376
+ ...live("Photovoltaic", "pv", "pv-string-1-power", "pv-string-2-power", "pv-energy-day"),
103377
+ ...diag("Photovoltaic", "pv-string-1-voltage", "pv-string-2-voltage", "pv-string-1-current", "pv-string-2-current"),
103378
+ ...live("Battery", "battery", "battery-stored-energy", "battery-temperature", "battery-charge-day", "battery-discharge-day", "battery-charged", "battery-discharged"),
103379
+ ...diag("Battery", "battery-health", "battery-capacity", "battery-power-rating", "battery-level-max", "battery-level-min", "battery-power-set", "battery-charge-power-max", "battery-discharge-power-max", "battery-charge-current-max", "battery-discharge-current-max"),
103380
+ ...live("Grid", "grid-import", "grid-export", "grid-phase-a", "grid-phase-b", "grid-phase-c", "grid-import-day", "grid-export-day"),
103381
+ ...diag("Grid", "grid-voltage-a", "grid-voltage-b", "grid-voltage-c", "grid-frequency", "grid-feed-max"),
103382
+ ...live("Home", "house", "house-energy-day"),
103383
+ ...live("Inverter", "inverter-state", "inverter-ems-mode", "inverter-ac-power"),
103384
+ ...diag("Inverter", "inverter-start-stop", "inverter-forcible-mode", "inverter-temperature", "inverter-power-rating", "inverter-phase-type", "inverter-modbus-rtu", "inverter-modbus-tcp")
103385
+ ],
103386
+ metadata: [
103387
+ {
103388
+ attribute: "model",
103389
+ key: "Code.Device.Type"
103390
+ },
103391
+ {
103392
+ attribute: "firmware",
103393
+ key: "SoftwareVersion.Service.1"
103394
+ },
103395
+ {
103396
+ attribute: "communicationFirmware",
103397
+ key: "SoftwareVersion.Service.2"
103398
+ },
103399
+ {
103400
+ attribute: "dongleSerialNumber",
103401
+ key: "Dongle.SerialNumber"
103402
+ },
103403
+ {
103404
+ attribute: "batterySerialNumber",
103405
+ key: "SerialNumber.Battery"
103406
+ },
103407
+ {
103408
+ attribute: "batteryFirmware",
103409
+ key: "SoftwareVersion.Service.Battery.Unit.1"
103410
+ }
103411
+ ],
103412
+ primaryChildPreference: [
103413
+ "pv",
102893
103414
  "battery",
102894
- "battery-charged",
102895
- "battery-discharged",
102896
- "battery-temperature",
102897
- "battery-health"
103415
+ "house"
102898
103416
  ]
102899
- },
102900
- {
102901
- id: "grid",
102902
- name: "Grid",
102903
- specIds: ["grid-import", "grid-export"]
102904
- },
102905
- {
102906
- id: "house",
102907
- name: "House",
102908
- specIds: ["house"]
102909
- },
102910
- {
103417
+ }),
103418
+ equipment({
102911
103419
  id: "heatpump",
102912
- name: "Heat pump",
102913
- specIds: [
103420
+ kind: "container",
103421
+ name: "Heat pump (Enpal)",
103422
+ children: [...live(void 0, "heatpump", "heatpump-hot-water", "heatpump-outside", "heatpump-hot-water-mode", "heatpump-heating-mode", "heatpump-generator-state", "heatpump-heater-state"), ...diag(void 0, "heatpump-energy-management", "heatpump-power-desired", "heatpump-pv-optimization", "heatpump-power-min", "heatpump-power-max", "heatpump-connection")],
103423
+ metadata: [
103424
+ {
103425
+ attribute: "model",
103426
+ key: "Heatpump.IndoorUnit.DeviceType"
103427
+ },
103428
+ {
103429
+ attribute: "serialNumber",
103430
+ key: "Heatpump.SerialNumber"
103431
+ },
103432
+ {
103433
+ attribute: "indoorUnitSerialNumber",
103434
+ key: "Heatpump.IndoorUnit.SerialNumber"
103435
+ },
103436
+ {
103437
+ attribute: "firmware",
103438
+ key: "Heatpump.SoftwareVersion"
103439
+ },
103440
+ {
103441
+ attribute: "indoorUnitFirmware",
103442
+ key: "Heatpump.IndoorUnit.SoftwareVersion"
103443
+ },
103444
+ {
103445
+ attribute: "gatewayModel",
103446
+ key: "Heatpump.Gateway.Device.Type"
103447
+ },
103448
+ {
103449
+ attribute: "gatewaySerialNumber",
103450
+ key: "Heatpump.Gateway.SerialNumber"
103451
+ },
103452
+ {
103453
+ attribute: "gatewayFirmware",
103454
+ key: "Heatpump.Gateway.SoftwareVersion"
103455
+ }
103456
+ ],
103457
+ primaryChildPreference: [
102914
103458
  "heatpump",
102915
103459
  "heatpump-hot-water",
102916
103460
  "heatpump-outside"
102917
103461
  ]
102918
- }
103462
+ })
102919
103463
  ];
102920
103464
  function findEquipment(id) {
102921
103465
  return ENPAL_EQUIPMENT.find((e) => e.id === id);
@@ -102929,24 +103473,61 @@ function childNativeId(equipmentId, specId) {
102929
103473
  * they stay, whatever the box reports later, so a restart with the box off keeps them.
102930
103474
  */
102931
103475
  var enpalEquipmentSchema = object({
102932
- equipment: string().min(1).describe("Equipment id (pv, battery, grid, house, heatpump)"),
103476
+ equipment: string().min(1).describe("Equipment id (impianto, heatpump; older adoptions: pv, battery, grid, house)"),
102933
103477
  specIds: array(string().min(1)).describe("Catalog entries adopted as children"),
102934
103478
  integrationId: string().min(1).describe("Owning integration"),
102935
103479
  system: literal(ENPAL_SYSTEM).optional()
102936
103480
  });
102937
- /** The Sensor children the kernel spawns under an equipment Container. */
103481
+ /**
103482
+ * The declared primary children of an adopted piece: its preference, limited to the children
103483
+ * it was adopted with. Empty for the gateway and for an equipment id the catalog dropped.
103484
+ */
103485
+ function primaryChildPreferenceOf(found, specIds) {
103486
+ if (found === void 0) return [];
103487
+ return found.primaryChildPreference.filter((id) => specIds.includes(id));
103488
+ }
103489
+ /**
103490
+ * The accordion layout of an equipment Container, for the children it was adopted with.
103491
+ * Empty for a Container with no sections (the heat pump) or an unknown equipment.
103492
+ */
103493
+ function childLayoutOf(found, specIds) {
103494
+ if (found === void 0) return [];
103495
+ const entries = [];
103496
+ found.children.forEach((child, order) => {
103497
+ if (child.section === void 0 || !specIds.includes(child.specId)) return;
103498
+ entries.push({
103499
+ childKey: child.specId,
103500
+ section: child.section,
103501
+ order
103502
+ });
103503
+ });
103504
+ return entries;
103505
+ }
103506
+ /** The identity facts the box reported for an equipment, by device attribute. */
103507
+ function metadataOf(found, snapshot) {
103508
+ const out = {};
103509
+ for (const field of found?.metadata ?? []) {
103510
+ const value = snapshot.texts.get(field.key);
103511
+ if (value !== void 0) out[field.attribute] = value;
103512
+ }
103513
+ return out;
103514
+ }
103515
+ /** The Sensor children the kernel spawns under an adopted piece (a Container or the Gateway). */
102938
103516
  function accessoryChildSpecs(config) {
103517
+ const found = findEquipment(config.equipment);
102939
103518
  const out = [];
102940
103519
  for (const specId of config.specIds) {
102941
103520
  const spec = findSpec(specId);
102942
103521
  if (spec === void 0) continue;
103522
+ const diagnostic = found?.children.find((c) => c.specId === specId)?.diagnostic === true;
102943
103523
  out.push({
102944
103524
  stableIdSuffix: spec.id,
102945
103525
  meta: {
102946
103526
  type: DeviceType.Sensor,
102947
103527
  name: spec.name,
102948
103528
  role: spec.role,
102949
- integrationId: config.integrationId
103529
+ integrationId: config.integrationId,
103530
+ ...diagnostic ? { display: { hidden: true } } : {}
102950
103531
  },
102951
103532
  config: {
102952
103533
  specId: spec.id,
@@ -102959,18 +103540,87 @@ function accessoryChildSpecs(config) {
102959
103540
  return out;
102960
103541
  }
102961
103542
  //#endregion
103543
+ //#region src/devices/enpal-metadata-sync.ts
103544
+ /**
103545
+ * Writes the identity facts a piece reported (model, serial, firmware) as device attributes.
103546
+ * A poll that changed none writes nothing; a failed write is logged and retried next poll.
103547
+ */
103548
+ var EnpalMetadataSync = class {
103549
+ options;
103550
+ applied = "";
103551
+ constructor(options) {
103552
+ this.options = options;
103553
+ }
103554
+ async apply() {
103555
+ const snapshot = this.options.getSnapshot();
103556
+ if (snapshot === null) return;
103557
+ const attributes = metadataOf(findEquipment(this.options.equipment), snapshot);
103558
+ const fingerprint = JSON.stringify(attributes);
103559
+ if (fingerprint === this.applied || Object.keys(attributes).length === 0) return;
103560
+ try {
103561
+ await this.options.write(attributes);
103562
+ this.applied = fingerprint;
103563
+ } catch (err) {
103564
+ this.options.logger.warn("enpal: could not write the device attributes", {
103565
+ tags: { deviceId: this.options.deviceId },
103566
+ meta: {
103567
+ equipment: this.options.equipment,
103568
+ error: errMsg(err)
103569
+ }
103570
+ });
103571
+ }
103572
+ }
103573
+ };
103574
+ //#endregion
103575
+ //#region src/devices/enpal-preference-backfill.ts
103576
+ /**
103577
+ * Stamp the declared primary-child preference on a container adopted before
103578
+ * the declaration existed. Adoption stamps it through `InitialDeviceMeta`;
103579
+ * a restore passes no initial meta, so an older row would otherwise never get
103580
+ * one and keep being shown by the heuristic's child.
103581
+ *
103582
+ * Only an ABSENT declaration is filled: a stored one is the provider's own and
103583
+ * is left alone. The operator's pick (`primaryChildEntityId`) is a different
103584
+ * column, never written here, and still wins at resolution.
103585
+ *
103586
+ * @returns whether it wrote.
103587
+ */
103588
+ async function backfillPrimaryChildPreference(input) {
103589
+ const { deviceId, stored, declared, write, logger } = input;
103590
+ if (stored !== void 0 && stored.length > 0) return false;
103591
+ if (declared.length === 0) return false;
103592
+ try {
103593
+ await write(declared);
103594
+ } catch (err) {
103595
+ logger.warn("enpal: could not stamp the primary-child preference", {
103596
+ tags: { deviceId },
103597
+ meta: { error: errMsg(err) }
103598
+ });
103599
+ return false;
103600
+ }
103601
+ logger.info("enpal: stamped the primary-child preference on a container adopted without one", {
103602
+ tags: { deviceId },
103603
+ meta: { primaryChildPreference: declared }
103604
+ });
103605
+ return true;
103606
+ }
103607
+ //#endregion
102962
103608
  //#region src/devices/enpal-equipment-device.ts
102963
103609
  /**
102964
- * One physical piece of the installation (photovoltaic, battery, grid, house, heat pump) as a
103610
+ * One piece Enpal measures (the System: PV, battery, grid, house; the heat pump) as a
102965
103611
  * {@link DeviceType.Container}. It owns no caps: it declares the Sensor children it was
102966
- * adopted with, and the kernel spawns and restores them. Online follows the integration's
102967
- * box; the integration marks it offline when a poll fails.
103612
+ * adopted with, and the kernel spawns and restores them; which child stands for it is the
103613
+ * `primaryChildPreference` stamped at adoption, and re-stamped on activation for a
103614
+ * container adopted before it had one. Its model, serials and firmware are device
103615
+ * attributes, written from the box's text rows whenever they change. Online follows the
103616
+ * integration's box; the integration marks it offline when a poll fails.
102968
103617
  */
102969
103618
  var EnpalEquipmentDevice = class extends BaseDevice {
102970
103619
  features = [];
102971
103620
  equipment;
102972
103621
  specIds;
102973
103622
  integrationId;
103623
+ snapshotUnsub = null;
102974
103624
  constructor(ctx) {
102975
103625
  const cfg = enpalEquipmentSchema.parse(ctx.persistedConfig ?? {});
102976
103626
  super(ctx, enpalEquipmentSchema, { type: DeviceType.Container });
@@ -102983,6 +103633,42 @@ var EnpalEquipmentDevice = class extends BaseDevice {
102983
103633
  system: ENPAL_SYSTEM
102984
103634
  });
102985
103635
  }
103636
+ async onActivate() {
103637
+ await super.onActivate();
103638
+ await backfillPrimaryChildPreference({
103639
+ deviceId: this.id,
103640
+ stored: this.ctx.deviceMeta.primaryChildPreference,
103641
+ declared: primaryChildPreferenceOf(findEquipment(this.equipment), this.specIds),
103642
+ write: async (keys) => {
103643
+ await this.ctx.api.deviceManager.setPrimaryChildPreference.mutate({
103644
+ deviceId: this.id,
103645
+ primaryChildPreference: keys
103646
+ });
103647
+ },
103648
+ logger: this.ctx.logger
103649
+ });
103650
+ const metadata = new EnpalMetadataSync({
103651
+ equipment: this.equipment,
103652
+ deviceId: this.id,
103653
+ getSnapshot: () => enpalConnections.getSnapshot(this.integrationId),
103654
+ write: async (patch) => {
103655
+ await this.ctx.api.deviceManager.setMetadata.mutate({
103656
+ deviceId: this.id,
103657
+ patch
103658
+ });
103659
+ },
103660
+ logger: this.ctx.logger
103661
+ });
103662
+ this.snapshotUnsub = enpalConnections.onSnapshot(this.integrationId, () => {
103663
+ metadata.apply();
103664
+ });
103665
+ await metadata.apply();
103666
+ }
103667
+ async removeDevice() {
103668
+ this.snapshotUnsub?.();
103669
+ this.snapshotUnsub = null;
103670
+ await super.removeDevice();
103671
+ }
102986
103672
  getAccessoryChildren() {
102987
103673
  return accessoryChildSpecs({
102988
103674
  equipment: this.equipment,
@@ -102991,18 +103677,41 @@ var EnpalEquipmentDevice = class extends BaseDevice {
102991
103677
  });
102992
103678
  }
102993
103679
  };
103680
+ //#endregion
103681
+ //#region src/enpal-discovery.ts
102994
103682
  var METADATA = {
102995
103683
  manufacturer: "Enpal",
102996
103684
  integration: "enpal"
102997
103685
  };
103686
+ /** The Sensor candidates of one piece: the catalog entries of it the box reported. */
103687
+ function reportedChildren(equipment, snapshot) {
103688
+ if (snapshot === null) return [];
103689
+ const reported = new Map(availableSpecs(snapshot).map((spec) => [spec.id, spec]));
103690
+ return equipment.specIds.flatMap((id) => {
103691
+ const spec = reported.get(id);
103692
+ if (spec === void 0) return [];
103693
+ return [{
103694
+ childNativeId: childNativeId(equipment.id, spec.id),
103695
+ name: spec.name,
103696
+ type: DeviceType.Sensor,
103697
+ status: "online",
103698
+ metadata: METADATA,
103699
+ alreadyAdopted: false,
103700
+ adoptedDeviceId: null,
103701
+ capabilities: capsOfSpec(spec)
103702
+ }];
103703
+ });
103704
+ }
102998
103705
  /**
102999
- * The box ({@link DeviceType.Gateway}, always offered, offline while it is unreachable), then one
103000
- * Container candidate per equipment the box reported at least one value for, with the
103001
- * Sensor children it reported. Pure.
103706
+ * The box ({@link DeviceType.Gateway}, always offered, offline while it is unreachable, with the
103707
+ * diagnostics it reported as children), then one Container candidate per other piece the box
103708
+ * reported at least one value for, with the Sensor children it reported. Pure.
103002
103709
  */
103003
103710
  function buildEnpalCandidates(input) {
103004
103711
  const { snapshot } = input;
103005
103712
  const boxAdopted = input.adopted.get("box") ?? null;
103713
+ const box = findEquipment("box");
103714
+ const boxChildren = box === void 0 ? [] : reportedChildren(box, snapshot);
103006
103715
  const out = [{
103007
103716
  childNativeId: "box",
103008
103717
  name: input.box.name,
@@ -103011,13 +103720,13 @@ function buildEnpalCandidates(input) {
103011
103720
  metadata: METADATA,
103012
103721
  alreadyAdopted: boxAdopted !== null,
103013
103722
  adoptedDeviceId: boxAdopted,
103014
- capabilities: [deviceAdminLinkCapability.name]
103723
+ capabilities: [deviceAdminLinkCapability.name],
103724
+ ...boxChildren.length > 0 ? { children: boxChildren } : {}
103015
103725
  }];
103016
- if (snapshot === null) return out;
103017
- const reported = new Map(availableSpecs(snapshot).map((spec) => [spec.id, spec]));
103018
103726
  for (const equipment of ENPAL_EQUIPMENT) {
103019
- const specs = equipment.specIds.flatMap((id) => reported.get(id) ?? []);
103020
- if (specs.length === 0) continue;
103727
+ if (equipment.kind !== "container") continue;
103728
+ const children = reportedChildren(equipment, snapshot);
103729
+ if (children.length === 0) continue;
103021
103730
  const adoptedId = input.adopted.get(equipment.id) ?? null;
103022
103731
  out.push({
103023
103732
  childNativeId: equipment.id,
@@ -103028,16 +103737,7 @@ function buildEnpalCandidates(input) {
103028
103737
  alreadyAdopted: adoptedId !== null,
103029
103738
  adoptedDeviceId: adoptedId,
103030
103739
  capabilities: [],
103031
- children: specs.map((spec) => ({
103032
- childNativeId: childNativeId(equipment.id, spec.id),
103033
- name: spec.name,
103034
- type: DeviceType.Sensor,
103035
- status: "online",
103036
- metadata: METADATA,
103037
- alreadyAdopted: false,
103038
- adoptedDeviceId: null,
103039
- capabilities: capsOfSpec(spec)
103040
- }))
103740
+ children
103041
103741
  });
103042
103742
  }
103043
103743
  return out;
@@ -103051,7 +103751,12 @@ var DEVICES_FILTER = {
103051
103751
  };
103052
103752
  function buildEnpalAdoptionProvider(deps) {
103053
103753
  const { getSnapshot, hasIntegration, listIntegrations, listAdopted, logger } = deps;
103054
- const boxName = (integrationId) => deps.integrationName(integrationId) ?? "Enpal";
103754
+ const boxName = (integrationId) => deps.integrationName(integrationId) ?? findEquipment("box")?.name ?? "Enpal Box";
103755
+ /** The catalog entries of a piece the offered candidate reports as children. */
103756
+ const reportedSpecIds = (equipment, candidate) => {
103757
+ const reported = new Set((candidate?.children ?? []).map((c) => c.childNativeId));
103758
+ return equipment.specIds.filter((id) => reported.has(`${equipment.id}#${id}`));
103759
+ };
103055
103760
  async function adoptedMap(integrationId) {
103056
103761
  const map = /* @__PURE__ */ new Map();
103057
103762
  for (const device of await listAdopted()) {
@@ -103127,20 +103832,23 @@ function buildEnpalAdoptionProvider(deps) {
103127
103832
  const adopted = [];
103128
103833
  let failures = 0;
103129
103834
  for (const equipmentId of childNativeIds) try {
103130
- if (equipmentId === "box") {
103131
- const { deviceId } = await deps.adoptGateway({
103835
+ const candidate = offered.find((c) => c.childNativeId === equipmentId);
103836
+ const equipment = findEquipment(equipmentId);
103837
+ if (equipmentId === "box" && equipment !== void 0) {
103838
+ const specIds = reportedSpecIds(equipment, candidate);
103839
+ const { deviceId, accessoryDeviceIds } = await deps.adoptGateway({
103132
103840
  integrationId,
103133
- name: perCandidate?.[equipmentId]?.name ?? boxName(integrationId)
103841
+ name: perCandidate?.[equipmentId]?.name ?? boxName(integrationId),
103842
+ specIds,
103843
+ childLayout: childLayoutOf(equipment, specIds)
103134
103844
  });
103135
103845
  adopted.push({
103136
103846
  childNativeId: equipmentId,
103137
103847
  parentDeviceId: deviceId,
103138
- accessoryDeviceIds: []
103848
+ accessoryDeviceIds
103139
103849
  });
103140
103850
  continue;
103141
103851
  }
103142
- const candidate = offered.find((c) => c.childNativeId === equipmentId);
103143
- const equipment = findEquipment(equipmentId);
103144
103852
  if (candidate === void 0 || equipment === void 0) {
103145
103853
  logger.warn("enpal adopt: the box reports nothing for this equipment — skipping", { meta: {
103146
103854
  equipment: equipmentId,
@@ -103149,13 +103857,14 @@ function buildEnpalAdoptionProvider(deps) {
103149
103857
  failures++;
103150
103858
  continue;
103151
103859
  }
103152
- const reported = new Set((candidate.children ?? []).map((c) => c.childNativeId));
103153
- const specIds = equipment.specIds.filter((id) => reported.has(`${equipment.id}#${id}`));
103860
+ const specIds = reportedSpecIds(equipment, candidate);
103154
103861
  const { deviceId, accessoryDeviceIds } = await deps.adoptDevice({
103155
103862
  equipment: equipment.id,
103156
103863
  specIds,
103157
103864
  integrationId,
103158
- name: perCandidate?.[equipmentId]?.name ?? equipment.name
103865
+ name: perCandidate?.[equipmentId]?.name ?? equipment.name,
103866
+ childLayout: childLayoutOf(equipment, specIds),
103867
+ primaryChildPreference: primaryChildPreferenceOf(equipment, specIds)
103159
103868
  });
103160
103869
  adopted.push({
103161
103870
  childNativeId: equipmentId,
@@ -103186,10 +103895,187 @@ function buildEnpalAdoptionProvider(deps) {
103186
103895
  }
103187
103896
  };
103188
103897
  }
103898
+ var BlazorRowsSchema = array(object({
103899
+ key: string(),
103900
+ value: string(),
103901
+ unit: string().nullable(),
103902
+ timestamp: string().nullable()
103903
+ }));
103904
+ var BridgeResultSchema = discriminatedUnion("ok", [object({
103905
+ ok: literal(true),
103906
+ rows: BlazorRowsSchema
103907
+ }), object({
103908
+ ok: literal(false),
103909
+ kind: _enum([
103910
+ "unreachable",
103911
+ "bad-response",
103912
+ "empty",
103913
+ "protocol"
103914
+ ]),
103915
+ error: string()
103916
+ })]);
103917
+ /** A failed bridge run. The message never carries the bridge's stderr beyond a short tail. */
103918
+ var BlazorBridgeError = class extends Error {
103919
+ kind;
103920
+ constructor(kind, message) {
103921
+ super(message);
103922
+ this.name = "BlazorBridgeError";
103923
+ this.kind = kind;
103924
+ }
103925
+ };
103926
+ /** The worker waits up to 12 s for the box's first render; this leaves room for connect and exit. */
103927
+ var DEFAULT_TIMEOUT_MS = 4e4;
103928
+ var WINDOW_MS = 12e3;
103929
+ /** A real answer is ~25 KB; anything near this is a runaway bridge, not a box. */
103930
+ var MAX_STDOUT_BYTES = 2 * 1024 * 1024;
103931
+ /**
103932
+ * How long `auto` leaves the Blazor channel alone after it failed. An older box that never
103933
+ * serves it would otherwise cost the full read window on every poll.
103934
+ */
103935
+ var BLAZOR_BACKOFF_MS = 10 * 6e4;
103936
+ /** The slowest a good read takes: connect, the 12 s render window, exit. */
103937
+ var BLAZOR_WORST_CASE_READ_MS = 2e4;
103938
+ /**
103939
+ * The poll interval actually used. A box-web-channel read can take most of
103940
+ * {@link BLAZOR_WORST_CASE_READ_MS}, so a shorter interval is raised to it when that channel
103941
+ * can be the source: ticks would only pile onto the running read.
103942
+ */
103943
+ function effectivePollIntervalMs(connection) {
103944
+ return connection.source === "auto" || connection.source === "blazor" ? Math.max(connection.pollIntervalMs, BLAZOR_WORST_CASE_READ_MS) : connection.pollIntervalMs;
103945
+ }
103946
+ /** Remembers a failed Blazor read so `auto` skips it for a while. Forced `blazor` ignores it. */
103947
+ var BlazorBackoff = class {
103948
+ #until = 0;
103949
+ #reason = null;
103950
+ /** Why the channel is being skipped now, or null when it may be tried. */
103951
+ skipReason(now) {
103952
+ if (this.#reason === null || now >= this.#until) return null;
103953
+ return {
103954
+ kind: this.#reason.kind,
103955
+ message: `${this.#reason.message}; the box web channel is skipped for ${BLAZOR_BACKOFF_MS / 6e4} minutes after a failure`
103956
+ };
103957
+ }
103958
+ failed(now, reason) {
103959
+ this.#until = now + BLAZOR_BACKOFF_MS;
103960
+ this.#reason = reason;
103961
+ }
103962
+ recovered() {
103963
+ this.#until = 0;
103964
+ this.#reason = null;
103965
+ }
103966
+ };
103967
+ /** One bridge run per poll: spawn, write the request on stdin, parse one JSON answer. */
103968
+ function createBlazorReader(deps) {
103969
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
103970
+ return (target) => new Promise((resolve, reject) => {
103971
+ const child = (0, node_child_process.spawn)(deps.pythonPath, [deps.scriptPath], { stdio: [
103972
+ "pipe",
103973
+ "pipe",
103974
+ "pipe"
103975
+ ] });
103976
+ let stdout = "";
103977
+ let stderr = "";
103978
+ let settled = false;
103979
+ const finish = (action) => {
103980
+ if (settled) return;
103981
+ settled = true;
103982
+ clearTimeout(timer);
103983
+ action();
103984
+ };
103985
+ const timer = setTimeout(() => {
103986
+ child.kill("SIGKILL");
103987
+ finish(() => reject(new BlazorBridgeError("timeout", `Enpal box ${target.host} did not answer within ${timeoutMs / 1e3}s`)));
103988
+ }, timeoutMs);
103989
+ child.stdout.on("data", (chunk) => {
103990
+ stdout += chunk.toString("utf8");
103991
+ if (stdout.length > MAX_STDOUT_BYTES) {
103992
+ child.kill("SIGKILL");
103993
+ finish(() => reject(new BlazorBridgeError("protocol", "the Enpal bridge wrote more output than any box answer")));
103994
+ }
103995
+ });
103996
+ child.stderr.on("data", (chunk) => {
103997
+ stderr = (stderr + chunk.toString("utf8")).slice(-16384);
103998
+ });
103999
+ child.on("error", (err) => {
104000
+ finish(() => reject(new BlazorBridgeError("protocol", `cannot start the Enpal bridge: ${err.message}`)));
104001
+ });
104002
+ child.on("close", (code) => {
104003
+ finish(() => {
104004
+ let json;
104005
+ try {
104006
+ json = JSON.parse(stdout.trim());
104007
+ } catch {
104008
+ const tail = stderr.trim().slice(-300) || "no stderr";
104009
+ reject(new BlazorBridgeError("protocol", `the Enpal bridge produced no JSON (exit ${code ?? "null"}): ${tail}`));
104010
+ return;
104011
+ }
104012
+ const parsed = BridgeResultSchema.safeParse(json);
104013
+ if (!parsed.success) {
104014
+ reject(new BlazorBridgeError("protocol", "the Enpal bridge answered an unknown shape"));
104015
+ return;
104016
+ }
104017
+ if (!parsed.data.ok) {
104018
+ reject(new BlazorBridgeError(parsed.data.kind, parsed.data.error));
104019
+ return;
104020
+ }
104021
+ resolve(parsed.data.rows);
104022
+ });
104023
+ });
104024
+ child.stdin.on("error", () => void 0);
104025
+ child.stdin.end(JSON.stringify({
104026
+ host: target.host,
104027
+ port: target.port,
104028
+ windowMs: WINDOW_MS
104029
+ }));
104030
+ });
104031
+ }
104032
+ var KEY_SHAPE$2 = /^[A-Z][A-Za-z0-9]*(\.[A-Za-z0-9-]+)+$/;
104033
+ /** A bare decimal number: `3.6`, `-40`, `0`. A version, a hash or a timestamp is not one. */
104034
+ var NUMBER = /^-?\d+(?:[.,]\d+)?$/;
104035
+ /**
104036
+ * The numeric sensors of the bridge's rows. A text value (a state name, a serial number, the
104037
+ * inverter's markup bitfield) is not a reading, and neither is a dotted string that only
104038
+ * looks like a key (`V1.1.7`, a version).
104039
+ */
104040
+ function blazorRowsToReadings(rows) {
104041
+ const readings = [];
104042
+ for (const row of rows) {
104043
+ if (!KEY_SHAPE$2.test(row.key)) continue;
104044
+ const text = row.value.trim();
104045
+ if (!NUMBER.test(text)) continue;
104046
+ const value = Number(text.replace(",", "."));
104047
+ if (!Number.isFinite(value)) continue;
104048
+ readings.push({
104049
+ key: row.key,
104050
+ value,
104051
+ unit: normalizeEnpalUnit(row.unit ?? void 0),
104052
+ measurement: void 0
104053
+ });
104054
+ }
104055
+ return readings;
104056
+ }
104057
+ /**
104058
+ * Every row's text as delivered, for state labels, serial numbers and firmware strings. A
104059
+ * row with no value, or whose value is markup (the inverter's `<ul>` bitfield), carries no
104060
+ * text worth showing and is left out.
104061
+ */
104062
+ function blazorRowsToTexts(rows) {
104063
+ const texts = [];
104064
+ for (const row of rows) {
104065
+ if (!KEY_SHAPE$2.test(row.key)) continue;
104066
+ const value = row.value.trim();
104067
+ if (value.length === 0 || value.startsWith("<")) continue;
104068
+ texts.push({
104069
+ key: row.key,
104070
+ value
104071
+ });
104072
+ }
104073
+ return texts;
104074
+ }
103189
104075
  //#endregion
103190
104076
  //#region src/enpal-device-page.ts
103191
- var CARD_HEADER = /<div class="card-header">\s*<h2>([^<]+)<\/h2>/g;
103192
- var ROW = /<tr>\s*<td>([^<]+)<\/td>\s*<td>([^<]*)<\/td>\s*<td>[^<]*<\/td>\s*<\/tr>/g;
104077
+ var CARD_HEADER = /<div class="card-header[^"]*">\s*<h2>([^<]+)<\/h2>/g;
104078
+ var ROW = /<tr(?:\s[^>]*)?>\s*<td>([^<]+)<\/td>\s*<td>([^<]*)<\/td>\s*<td>[^<]*<\/td>\s*<\/tr>/g;
103193
104079
  var KEY_SHAPE$1 = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z0-9-]+)+$/;
103194
104080
  /** `10231W`, `-350W`, `1.85kW`, `51°C`, `62.5%`, `3` */
103195
104081
  var VALUE = /^(-?\d+(?:[.,]\d+)?)\s*(\S*)$/;
@@ -103212,7 +104098,7 @@ function cardOffsets(html) {
103212
104098
  * no zone, so it is not used: the poll time is.
103213
104099
  *
103214
104100
  * Firmware 8.51 and later pre-render only the "Site Data" card; the device rows then never
103215
- * appear and this returns `[]`.
104101
+ * appear here (they come through the Blazor circuit, `enpal-blazor.ts`).
103216
104102
  */
103217
104103
  function parseDeviceMessages(html) {
103218
104104
  const cards = cardOffsets(html);
@@ -103328,7 +104214,7 @@ function canonicalKey(key) {
103328
104214
  * Index readings by canonical key. The box writes some keys into two measurements: the first
103329
104215
  * occurrence wins.
103330
104216
  */
103331
- function createSnapshot(source, readings, fetchedAt, fallbackReason) {
104217
+ function createSnapshot(source, readings, fetchedAt, fallbackReason, texts = []) {
103332
104218
  const map = /* @__PURE__ */ new Map();
103333
104219
  for (const reading of readings) {
103334
104220
  const key = canonicalKey(reading.key);
@@ -103338,9 +104224,15 @@ function createSnapshot(source, readings, fetchedAt, fallbackReason) {
103338
104224
  key
103339
104225
  });
103340
104226
  }
104227
+ const textMap = /* @__PURE__ */ new Map();
104228
+ for (const text of texts) {
104229
+ const key = canonicalKey(text.key);
104230
+ if (!textMap.has(key)) textMap.set(key, text.value);
104231
+ }
103341
104232
  return {
103342
104233
  source,
103343
104234
  readings: map,
104235
+ texts: textMap,
103344
104236
  fetchedAt,
103345
104237
  ...fallbackReason !== void 0 ? { fallbackReason } : {}
103346
104238
  };
@@ -103399,14 +104291,42 @@ async function readInflux(input) {
103399
104291
  }
103400
104292
  }, input.fetch));
103401
104293
  if (readings.length === 0) throw new EnpalError("empty", "influxdb", `Enpal InfluxDB on ${connection.host} returned no series for bucket "${connection.bucket}" in the last two hours`);
103402
- return readings;
104294
+ return {
104295
+ readings,
104296
+ texts: []
104297
+ };
104298
+ }
104299
+ async function readBlazor(input) {
104300
+ const { connection } = input;
104301
+ if (input.blazor === void 0) throw new EnpalError("bridge-unavailable", "blazor", "The embedded Python the Enpal bridge needs is not available on this node");
104302
+ let readings;
104303
+ let texts;
104304
+ try {
104305
+ const rows = await input.blazor({
104306
+ host: connection.host,
104307
+ port: connection.pagePort
104308
+ });
104309
+ readings = blazorRowsToReadings(rows);
104310
+ texts = blazorRowsToTexts(rows);
104311
+ } catch (error) {
104312
+ if (!(error instanceof BlazorBridgeError)) throw error;
104313
+ throw new EnpalError(error.kind === "protocol" ? "bad-response" : error.kind, "blazor", error.message);
104314
+ }
104315
+ if (readings.length === 0) throw new EnpalError("empty", "blazor", `Enpal box ${connection.host} sent no numeric sensor rows`);
104316
+ return {
104317
+ readings,
104318
+ texts
104319
+ };
103403
104320
  }
103404
104321
  async function readPage(input) {
103405
104322
  const { connection } = input;
103406
104323
  const url = `http://${connection.host}:${connection.pagePort}/deviceMessages`;
103407
104324
  const readings = parseDeviceMessages(await request("device-page", connection.host, url, { method: "GET" }, input.fetch));
103408
104325
  if (readings.length === 0) throw new EnpalError("empty", "device-page", `The device page of ${connection.host} lists no device rows (firmware 8.51 and later render them only through the web socket)`);
103409
- return readings;
104326
+ return {
104327
+ readings,
104328
+ texts: []
104329
+ };
103410
104330
  }
103411
104331
  function reasonOf(error) {
103412
104332
  return {
@@ -103414,28 +104334,64 @@ function reasonOf(error) {
103414
104334
  message: error.message
103415
104335
  };
103416
104336
  }
104337
+ var READERS = {
104338
+ blazor: readBlazor,
104339
+ influxdb: readInflux,
104340
+ "device-page": readPage
104341
+ };
104342
+ /** One reason from several: the kind of the first real failure, every message. */
104343
+ function joinReasons(reasons) {
104344
+ if (reasons.length === 0) return void 0;
104345
+ return {
104346
+ kind: reasons.find((r) => r.kind !== "bridge-unavailable")?.kind ?? "bridge-unavailable",
104347
+ message: reasons.map((r) => r.message).join("; ")
104348
+ };
104349
+ }
104350
+ var PYTHON_MISSING = {
104351
+ kind: "bridge-unavailable",
104352
+ message: "Python is not available on this node, so the box web channel (the only complete source on firmware 8.51 and later) is not used"
104353
+ };
103417
104354
  /**
103418
- * Read every data point the box reports, once. `auto` tries InfluxDB first (stable, numeric)
103419
- * and falls back to the device page, recording why on the snapshot; when both fail the
103420
- * InfluxDB failure is the one reported. A failed read throws {@link EnpalError}: it never
103421
- * yields a snapshot of zeros.
104355
+ * Read every data point the box reports, once. `auto` tries the Blazor channel first (the
104356
+ * only one that carries the device tables on firmware 8.51 and later, no credential), then
104357
+ * InfluxDB when a token is set, then the device page. The snapshot records why a preferred
104358
+ * source was not used: Python missing, the channel skipped after a recent failure, or the
104359
+ * failure itself. A Blazor failure in `auto` is remembered for {@link BLAZOR_BACKOFF_MS}.
104360
+ * When every source fails the FIRST real failure is the one reported. A failed read throws
104361
+ * {@link EnpalError}: it never yields a snapshot of zeros.
103422
104362
  */
103423
104363
  async function readEnpalSnapshot(input) {
103424
- const { source } = input.connection;
103425
- if (source === "influx") return createSnapshot("influxdb", await readInflux(input), input.now());
103426
- if (source === "page") return createSnapshot("device-page", await readPage(input), input.now());
103427
- if (!(input.tokenLocked !== true && input.connection.token.trim().length > 0)) return createSnapshot("device-page", await readPage(input), input.now());
103428
- try {
103429
- return createSnapshot("influxdb", await readInflux(input), input.now());
103430
- } catch (primary) {
103431
- if (!(primary instanceof EnpalError)) throw primary;
103432
- try {
103433
- return createSnapshot("device-page", await readPage(input), input.now(), reasonOf(primary));
103434
- } catch (secondary) {
103435
- if (!(secondary instanceof EnpalError)) throw secondary;
103436
- throw primary;
104364
+ const { source: mode } = input.connection;
104365
+ const now = input.now();
104366
+ const hasToken = input.tokenLocked !== true && input.connection.token.trim().length > 0;
104367
+ const reasons = [];
104368
+ let first;
104369
+ const order = [];
104370
+ if (mode === "blazor") order.push("blazor");
104371
+ else if (mode === "influx") order.push("influxdb");
104372
+ else if (mode === "page") order.push("device-page");
104373
+ else {
104374
+ if (input.blazor === void 0) reasons.push(PYTHON_MISSING);
104375
+ else {
104376
+ const skip = input.blazorBackoff?.skipReason(now) ?? null;
104377
+ if (skip !== null) reasons.push(skip);
104378
+ else order.push("blazor");
103437
104379
  }
104380
+ if (hasToken) order.push("influxdb");
104381
+ order.push("device-page");
103438
104382
  }
104383
+ for (const source of order) try {
104384
+ const read = await READERS[source](input);
104385
+ if (source === "blazor") input.blazorBackoff?.recovered();
104386
+ return createSnapshot(source, read.readings, now, joinReasons(reasons), read.texts);
104387
+ } catch (error) {
104388
+ if (!(error instanceof EnpalError)) throw error;
104389
+ if (source === "blazor" && mode === "auto") input.blazorBackoff?.failed(now, reasonOf(error));
104390
+ first ??= error;
104391
+ reasons.push(reasonOf(error));
104392
+ }
104393
+ if (first === void 0) throw new Error("readEnpalSnapshot: no source to read");
104394
+ throw first;
103439
104395
  }
103440
104396
  //#endregion
103441
104397
  //#region src/enpal-integration-manager.ts
@@ -103450,6 +104406,9 @@ var EnpalIntegrationManager = class {
103450
104406
  #snapshot = null;
103451
104407
  #lastError = null;
103452
104408
  #timer = null;
104409
+ #blazorBackoff = new BlazorBackoff();
104410
+ #lastSource = null;
104411
+ #lastFallbackKey = null;
103453
104412
  #inFlight = null;
103454
104413
  #stopped = true;
103455
104414
  #failureLogged = false;
@@ -103481,7 +104440,7 @@ var EnpalIntegrationManager = class {
103481
104440
  if (this.#stopped || this.#timer !== null) return;
103482
104441
  this.#timer = setInterval(() => {
103483
104442
  this.poll();
103484
- }, this.#options.connection.pollIntervalMs);
104443
+ }, effectivePollIntervalMs(this.#options.connection));
103485
104444
  this.#timer.unref();
103486
104445
  }
103487
104446
  async stop() {
@@ -103501,6 +104460,19 @@ var EnpalIntegrationManager = class {
103501
104460
  this.#inFlight = run;
103502
104461
  return run;
103503
104462
  }
104463
+ /**
104464
+ * A preferred source that is not in use is said once per change of source or of reason kind (the back-off text changes every poll, the cause does not), not
104465
+ * once per boot: a box that drops from the web channel to the page mid-run is visible.
104466
+ */
104467
+ #logSourceChange(source, fallback) {
104468
+ const fallbackMessage = fallback?.message;
104469
+ const fallbackKey = fallback?.kind ?? null;
104470
+ const changed = source !== this.#lastSource || fallbackKey !== this.#lastFallbackKey;
104471
+ if (changed && fallbackMessage !== void 0) this.#options.logger.warn(`Enpal box ${this.name}: preferred source not used (${fallbackMessage}), reading ${source}`);
104472
+ else if (changed && this.#lastSource !== null && source !== this.#lastSource) this.#options.logger.info(`Enpal box ${this.name}: now reading ${source}`);
104473
+ this.#lastSource = source;
104474
+ this.#lastFallbackKey = fallbackKey;
104475
+ }
103504
104476
  async #pollOnce() {
103505
104477
  const wasConnected = this.#status === "connected";
103506
104478
  try {
@@ -103508,6 +104480,8 @@ var EnpalIntegrationManager = class {
103508
104480
  connection: this.#options.connection,
103509
104481
  tokenLocked: this.#options.tokenLocked,
103510
104482
  fetch: this.#fetch,
104483
+ blazor: this.#options.blazor?.(),
104484
+ blazorBackoff: this.#blazorBackoff,
103511
104485
  now: this.#options.now ?? Date.now
103512
104486
  });
103513
104487
  if (this.#stopped) return;
@@ -103515,7 +104489,7 @@ var EnpalIntegrationManager = class {
103515
104489
  this.#lastError = null;
103516
104490
  this.#status = "connected";
103517
104491
  if (this.#failureLogged || !wasConnected) this.#options.logger.info(`Enpal box ${this.name} reachable through ${snapshot.source} (${snapshot.readings.size} data points)`);
103518
- if (snapshot.fallbackReason !== void 0 && !wasConnected) this.#options.logger.warn(`Enpal box ${this.name}: InfluxDB not used (${snapshot.fallbackReason.kind}), reading the device page`);
104492
+ this.#logSourceChange(snapshot.source, snapshot.fallbackReason);
103519
104493
  this.#failureLogged = false;
103520
104494
  if (!wasConnected) this.#options.onConnected();
103521
104495
  this.#options.onPolled();
@@ -103572,6 +104546,7 @@ var EnpalClientRegistry = class {
103572
104546
  tokenLocked,
103573
104547
  logger: this.#logger.withTags({ integrationId }),
103574
104548
  fetch: this.#deps.fetch,
104549
+ blazor: this.#deps.blazor,
103575
104550
  now: this.#deps.now,
103576
104551
  onConnected: () => {
103577
104552
  this.#deps.onConnected(integrationId);
@@ -103618,6 +104593,26 @@ var EnpalClientRegistry = class {
103618
104593
  }
103619
104594
  };
103620
104595
  //#endregion
104596
+ //#region src/enpal-python-paths.ts
104597
+ var WORKER = "enpal_worker.py";
104598
+ /**
104599
+ * Absolute path of the bundled `python/enpal_worker.py`: next to `dist/` in a deployed
104600
+ * package, two levels up from `src/` in the workspace.
104601
+ */
104602
+ function resolveEnpalWorker(fromModuleUrl = require("url").pathToFileURL(__filename).href) {
104603
+ const here = (0, node_path.dirname)((0, node_url.fileURLToPath)(fromModuleUrl));
104604
+ const candidates = [
104605
+ (0, node_path.join)(here, "../python"),
104606
+ (0, node_path.join)(here, "../../python"),
104607
+ (0, node_path.join)(here, "python")
104608
+ ];
104609
+ for (const dir of candidates) {
104610
+ const file = (0, node_path.join)(dir, WORKER);
104611
+ if ((0, node_fs.existsSync)(file)) return file;
104612
+ }
104613
+ throw new Error(`provider-enpal: ${WORKER} not found. Searched:\n${candidates.join("\n")}`);
104614
+ }
104615
+ //#endregion
103621
104616
  //#region src/enpal-instances.ts
103622
104617
  /**
103623
104618
  * Enpal integrations as `integration-instances` (planning#416, D1007): one `connections` record
@@ -103799,23 +104794,29 @@ async function cascadeRemoveDevicesForIntegration(input) {
103799
104794
  var enpalGatewaySchema = object({
103800
104795
  equipment: literal("box"),
103801
104796
  integrationId: string().min(1).describe("Owning integration"),
103802
- system: literal(ENPAL_SYSTEM).optional()
104797
+ system: literal(ENPAL_SYSTEM).optional(),
104798
+ /** The box diagnostics it was adopted with; a box adopted before they existed has none. */
104799
+ specIds: array(string().min(1)).default([])
103803
104800
  });
103804
104801
  /**
103805
- * The Enpal box itself, as a {@link DeviceType.Gateway}. Its connection lives on the
103806
- * INTEGRATION; this device is the box's presence in the device list, and carries the one
103807
- * action the box really supports: `device-admin-link`, its local device page. The box has no
103808
- * reboot and no update. Equipment is NOT a child of it; each piece is adopted as its own
103809
- * top-level Container. Online follows the integration's box (the integration marks it
103810
- * offline when a poll fails).
104802
+ * The Enpal box itself ("Enpal Box"), as a {@link DeviceType.Gateway}. Its connection lives on
104803
+ * the INTEGRATION; this device is the box's presence in the device list: its identity and
104804
+ * firmware as device attributes, its own diagnostics (CPU, memory, LTE) as hidden Sensor
104805
+ * children, and the one action the box really supports, `device-admin-link`, its local device
104806
+ * page. The box has no reboot and no update. The System and the heat pump are NOT children of
104807
+ * it; each is adopted as its own top-level Container. Online follows the integration's box
104808
+ * (the integration marks it offline when a poll fails).
103811
104809
  */
103812
104810
  var EnpalGatewayDevice = class extends BaseDevice {
103813
104811
  features = [];
103814
104812
  integrationId;
104813
+ specIds;
104814
+ snapshotUnsub = null;
103815
104815
  constructor(ctx) {
103816
104816
  const cfg = enpalGatewaySchema.parse(ctx.persistedConfig ?? {});
103817
104817
  super(ctx, enpalGatewaySchema, { type: DeviceType.Gateway });
103818
104818
  this.integrationId = cfg.integrationId;
104819
+ this.specIds = cfg.specIds;
103819
104820
  this.online = enpalConnections.getSnapshot(this.integrationId) !== null;
103820
104821
  this.registerAdminLinkCap();
103821
104822
  this.updateSourceInfo({
@@ -103823,6 +104824,37 @@ var EnpalGatewayDevice = class extends BaseDevice {
103823
104824
  system: ENPAL_SYSTEM
103824
104825
  });
103825
104826
  }
104827
+ async onActivate() {
104828
+ await super.onActivate();
104829
+ const metadata = new EnpalMetadataSync({
104830
+ equipment: "box",
104831
+ deviceId: this.id,
104832
+ getSnapshot: () => enpalConnections.getSnapshot(this.integrationId),
104833
+ write: async (patch) => {
104834
+ await this.ctx.api.deviceManager.setMetadata.mutate({
104835
+ deviceId: this.id,
104836
+ patch
104837
+ });
104838
+ },
104839
+ logger: this.ctx.logger
104840
+ });
104841
+ this.snapshotUnsub = enpalConnections.onSnapshot(this.integrationId, () => {
104842
+ metadata.apply();
104843
+ });
104844
+ await metadata.apply();
104845
+ }
104846
+ async removeDevice() {
104847
+ this.snapshotUnsub?.();
104848
+ this.snapshotUnsub = null;
104849
+ await super.removeDevice();
104850
+ }
104851
+ getAccessoryChildren() {
104852
+ return accessoryChildSpecs({
104853
+ equipment: "box",
104854
+ specIds: this.specIds,
104855
+ integrationId: this.integrationId
104856
+ });
104857
+ }
103826
104858
  /** The box's local device page (plain HTTP, the page port); no credentials in the URL. */
103827
104859
  registerAdminLinkCap() {
103828
104860
  this.ctx.registerNativeCap(deviceAdminLinkCapability, { getAdminLink: async ({ deviceId }) => {
@@ -103878,10 +104910,11 @@ var ENPAL_MIGRATIONS_DATE = "2026-11-05";
103878
104910
  /**
103879
104911
  * Enpal solar box provider, `mode: account` and broker-less. An integration is one box: its
103880
104912
  * host, InfluxDB access and token live on the integration's settings, and the addon polls it
103881
- * with one manager per `integrationId`. Discovery offers the box itself
103882
- * ({@link DeviceType.Gateway}, with a link to its device page) and one
103883
- * {@link DeviceType.Container} per piece of equipment it reports (photovoltaic, battery,
103884
- * grid, house, heat pump), and adopting a container adopts its Sensor children.
104913
+ * with one manager per `integrationId`. Discovery offers three pieces, each adopted only when
104914
+ * the operator picks it: the box itself ("Enpal Box", a {@link DeviceType.Gateway} carrying its
104915
+ * firmware, its diagnostics and a link to its device page), the "System" (PV, battery, grid,
104916
+ * house meter) and the "Heat pump (Enpal)", both {@link DeviceType.Container}s. Adopting a
104917
+ * piece adopts its Sensor children; a Container declares which of them stands for it.
103885
104918
  *
103886
104919
  * The box does not advertise itself on the network, so the integration is added by hand.
103887
104920
  */
@@ -103895,6 +104928,8 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
103895
104928
  integrationReconciler = null;
103896
104929
  clients = null;
103897
104930
  instances = null;
104931
+ /** The Python bridge to the box's Blazor channel; null while the node has no embedded Python. */
104932
+ blazorReader = null;
103898
104933
  constructor() {
103899
104934
  super({});
103900
104935
  }
@@ -103917,7 +104952,9 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
103917
104952
  kind: enpalConnectionsEntryKind
103918
104953
  }));
103919
104954
  this.instances = buildEnpalInstances(records, { onChanged: () => this.reconcileIntegrations() });
104955
+ this.blazorReader = await this.createBlazorBridge();
103920
104956
  this.clients = new EnpalClientRegistry(this.ctx.logger, {
104957
+ blazor: () => this.blazorReader ?? void 0,
103921
104958
  onConnected: (integrationId) => this.setIntegrationDevicesOnline(integrationId, true),
103922
104959
  onDisconnected: (integrationId) => this.setIntegrationDevicesOnline(integrationId, false)
103923
104960
  });
@@ -103942,11 +104979,32 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
103942
104979
  this.ctx.logger.warn("Enpal shutdown error", { meta: { error: errMsg(err) } });
103943
104980
  }
103944
104981
  this.clients = null;
104982
+ this.blazorReader = null;
103945
104983
  this.integrationReconciler?.dispose();
103946
104984
  this.integrationReconciler = null;
103947
104985
  enpalConnections.clear();
103948
104986
  await super.onShutdown();
103949
104987
  }
104988
+ /**
104989
+ * The embedded Python and the bundled worker, or null with a logged reason: the addon then
104990
+ * reads InfluxDB or the device page, which on firmware 8.51 and later hold less.
104991
+ */
104992
+ async createBlazorBridge() {
104993
+ try {
104994
+ const pythonPath = await this.ctx.deps.ensurePython();
104995
+ if (pythonPath === null) {
104996
+ this.ctx.logger.warn("Enpal: embedded Python is unavailable, the Blazor channel is not used");
104997
+ return null;
104998
+ }
104999
+ return createBlazorReader({
105000
+ pythonPath,
105001
+ scriptPath: resolveEnpalWorker()
105002
+ });
105003
+ } catch (err) {
105004
+ this.ctx.logger.warn("Enpal: the Python bridge could not be prepared", { meta: { error: errMsg(err) } });
105005
+ return null;
105006
+ }
105007
+ }
103950
105008
  requireInstances() {
103951
105009
  if (!this.instances) throw new Error("Enpal provider not initialised");
103952
105010
  return this.instances;
@@ -104050,7 +105108,7 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
104050
105108
  }
104051
105109
  return out;
104052
105110
  },
104053
- adoptDevice: async ({ equipment, specIds, integrationId, name }) => {
105111
+ adoptDevice: async ({ equipment, specIds, integrationId, name, childLayout, primaryChildPreference }) => {
104054
105112
  const devices = this.ctx.kernel.devices;
104055
105113
  if (!devices) throw new Error("enpal adopt: kernel.devices unavailable");
104056
105114
  const config = {
@@ -104063,7 +105121,9 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
104063
105121
  const device = await devices.create(stableId, EnpalEquipmentDevice, config, null, {
104064
105122
  type: DeviceType.Container,
104065
105123
  name,
104066
- integrationId
105124
+ integrationId,
105125
+ ...childLayout.length > 0 ? { childLayout } : {},
105126
+ ...primaryChildPreference.length > 0 ? { primaryChildPreference } : {}
104067
105127
  });
104068
105128
  const children = await devices.getChildren(device.id);
104069
105129
  return {
@@ -104071,20 +105131,27 @@ var EnpalProviderAddon = class extends BaseDeviceProvider {
104071
105131
  accessoryDeviceIds: children.map((c) => c.id)
104072
105132
  };
104073
105133
  },
104074
- adoptGateway: async ({ integrationId, name }) => {
105134
+ adoptGateway: async ({ integrationId, name, specIds, childLayout }) => {
104075
105135
  const devices = this.ctx.kernel.devices;
104076
105136
  if (!devices) throw new Error("enpal adopt: kernel.devices unavailable");
104077
105137
  const config = {
104078
105138
  equipment: "box",
105139
+ specIds,
104079
105140
  integrationId,
104080
105141
  system: ENPAL_SYSTEM
104081
105142
  };
104082
105143
  const stableId = this.generateStableId(DeviceType.Gateway, config);
104083
- return { deviceId: (await devices.create(stableId, EnpalGatewayDevice, config, null, {
105144
+ const device = await devices.create(stableId, EnpalGatewayDevice, config, null, {
104084
105145
  type: DeviceType.Gateway,
104085
105146
  name,
104086
- integrationId
104087
- })).id };
105147
+ integrationId,
105148
+ ...childLayout.length > 0 ? { childLayout } : {}
105149
+ });
105150
+ const children = await devices.getChildren(device.id);
105151
+ return {
105152
+ deviceId: device.id,
105153
+ accessoryDeviceIds: children.map((c) => c.id)
105154
+ };
104088
105155
  },
104089
105156
  removeDevice: async (id) => {
104090
105157
  await this.ctx.kernel.devices?.remove(id);