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