@camstack/addon-provider-ecowitt 0.2.146 → 0.2.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -2,7 +2,7 @@ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  let http = require("http");
3
3
  let events = require("events");
4
4
  let dgram = require("dgram");
5
- //#region ../types/dist/event-category-DHXxB8t9.mjs
5
+ //#region ../types/dist/event-category-CT-hz5Rb.mjs
6
6
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
7
7
  EventCategory["SystemBoot"] = "system.boot";
8
8
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -328,6 +328,14 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
328
328
  /** The recovery edge of {@link PipelineMotionNoisy}: the motion fell back, or a detection arrived. */
329
329
  EventCategory["PipelineMotionCalm"] = "pipeline.motion-calm";
330
330
  /**
331
+ * A camera's package LLM confirm has failed closed three times in a row
332
+ * (D931, 2026-10-26). One edge per camera until
333
+ * {@link PipelinePackageLlmAnswered}.
334
+ */
335
+ EventCategory["PipelinePackageLlmSilent"] = "pipeline.package-llm-silent";
336
+ /** The recovery edge of {@link PipelinePackageLlmSilent}: a confirm returned yes or no. */
337
+ EventCategory["PipelinePackageLlmAnswered"] = "pipeline.package-llm-answered";
338
+ /**
331
339
  * Per-camera pipeline config was mutated by the orchestrator
332
340
  * (a settings change via `setAgentInferenceDevices` /
333
341
  * `setCameraStepToggle` / `setCameraStepSettings`). Orchestrator
@@ -5437,7 +5445,7 @@ var ZodIssueCode$1 = {
5437
5445
  var ZodFirstPartyTypeKind$1;
5438
5446
  ZodFirstPartyTypeKind$1 || (ZodFirstPartyTypeKind$1 = {});
5439
5447
  //#endregion
5440
- //#region ../types/dist/sleep-BErAxhul.mjs
5448
+ //#region ../types/dist/sleep-bykKrTgu.mjs
5441
5449
  /**
5442
5450
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5443
5451
  * window to float samples (D455).
@@ -7991,6 +7999,99 @@ var RecordKindDeclarationSchema = object({
7991
7999
  }).strict();
7992
8000
  /** The kind names: lower-case, dash separated, short enough for an index name. */
7993
8001
  var RecordKindNameSchema = string().regex(/^[a-z][a-z0-9-]{0,47}$/);
8002
+ var READING_QUANTITIES = [
8003
+ "temperature",
8004
+ "pressure",
8005
+ "speed",
8006
+ "precipitation",
8007
+ "precipitation-rate",
8008
+ "length",
8009
+ "area",
8010
+ "illuminance",
8011
+ "irradiance",
8012
+ "power",
8013
+ "energy",
8014
+ "voltage",
8015
+ "current",
8016
+ "apparent-power",
8017
+ "humidity",
8018
+ "percentage",
8019
+ "concentration-volume",
8020
+ "concentration-mass",
8021
+ "concentration-ppb",
8022
+ "volume",
8023
+ "duration",
8024
+ "duration-ms",
8025
+ "information",
8026
+ "sound-level",
8027
+ "signal-level",
8028
+ "colour-temperature",
8029
+ "angle",
8030
+ "bitrate",
8031
+ "rate",
8032
+ "mass",
8033
+ "index",
8034
+ "ratio",
8035
+ "count",
8036
+ "none",
8037
+ "device"
8038
+ ];
8039
+ var ReadingQuantitySchema = _enum(READING_QUANTITIES);
8040
+ new Set(READING_QUANTITIES);
8041
+ /**
8042
+ * Stored unit symbol for a quantity. `none` means the number is dimensionless
8043
+ * (or an index / ratio / count). `device` means the cap has no single unit.
8044
+ */
8045
+ var QUANTITY_BASE = {
8046
+ temperature: "°C",
8047
+ pressure: "hPa",
8048
+ speed: "m/s",
8049
+ precipitation: "mm",
8050
+ "precipitation-rate": "mm/h",
8051
+ length: "m",
8052
+ area: "m²",
8053
+ illuminance: "lx",
8054
+ irradiance: "W/m²",
8055
+ power: "W",
8056
+ energy: "kWh",
8057
+ voltage: "V",
8058
+ current: "A",
8059
+ "apparent-power": "VA",
8060
+ humidity: "%",
8061
+ percentage: "%",
8062
+ "concentration-volume": "ppm",
8063
+ "concentration-mass": "µg/m³",
8064
+ "concentration-ppb": "ppb",
8065
+ volume: "L",
8066
+ duration: "s",
8067
+ "duration-ms": "ms",
8068
+ information: "B",
8069
+ "sound-level": "dBFS",
8070
+ "signal-level": "dBm",
8071
+ "colour-temperature": "mired",
8072
+ angle: "°",
8073
+ bitrate: "kbps",
8074
+ rate: "fps",
8075
+ mass: "kg",
8076
+ index: "none",
8077
+ ratio: "none",
8078
+ count: "none",
8079
+ none: "none",
8080
+ device: "device"
8081
+ };
8082
+ /** The stored unit, or `undefined` when the quantity has no unit symbol. */
8083
+ function baseUnitOf(quantity) {
8084
+ const symbol = QUANTITY_BASE[quantity];
8085
+ if (symbol === "none" || symbol === "device") return void 0;
8086
+ return symbol;
8087
+ }
8088
+ function readingMeta(quantity, extras) {
8089
+ return {
8090
+ quantity,
8091
+ ...extras?.precision !== void 0 ? { precision: extras.precision } : {},
8092
+ ...extras?.label !== void 0 ? { label: extras.label } : {}
8093
+ };
8094
+ }
7994
8095
  /** Shared Zod schemas used across streaming capabilities. */
7995
8096
  var CamProfileSchema = _enum([
7996
8097
  "high",
@@ -8018,8 +8119,8 @@ var CamStreamKindSchema = _enum([
8018
8119
  "derived"
8019
8120
  ]);
8020
8121
  var CamStreamResolutionSchema = object({
8021
- width: number().int().positive(),
8022
- height: number().int().positive()
8122
+ width: number().int().positive().meta(readingMeta("none")),
8123
+ height: number().int().positive().meta(readingMeta("none"))
8023
8124
  });
8024
8125
  var CameraStreamSchema = object({
8025
8126
  /** Stable, provider-assigned id unique within the (deviceId) scope. */
@@ -8537,6 +8638,8 @@ var CAP_NAMES = Object.freeze([
8537
8638
  "filesystem-browse",
8538
8639
  "flood",
8539
8640
  "gas",
8641
+ "group-commands",
8642
+ "group-status",
8540
8643
  "heating-valve",
8541
8644
  "humidifier",
8542
8645
  "humidity-sensor",
@@ -8666,14 +8769,6 @@ Object.freeze({
8666
8769
  {
8667
8770
  "path": "aqi",
8668
8771
  "valueType": "number"
8669
- },
8670
- {
8671
- "path": "unit",
8672
- "valueType": "string"
8673
- },
8674
- {
8675
- "path": "precision",
8676
- "valueType": "number"
8677
8772
  }
8678
8773
  ],
8679
8774
  "alarm-panel": [
@@ -8709,20 +8804,10 @@ Object.freeze({
8709
8804
  "valueType": "boolean"
8710
8805
  }
8711
8806
  ],
8712
- "ambient-light-sensor": [
8713
- {
8714
- "path": "lux",
8715
- "valueType": "number"
8716
- },
8717
- {
8718
- "path": "unit",
8719
- "valueType": "string"
8720
- },
8721
- {
8722
- "path": "precision",
8723
- "valueType": "number"
8724
- }
8725
- ],
8807
+ "ambient-light-sensor": [{
8808
+ "path": "lux",
8809
+ "valueType": "number"
8810
+ }],
8726
8811
  "audio-metrics": [
8727
8812
  {
8728
8813
  "path": "ts",
@@ -8802,6 +8887,10 @@ Object.freeze({
8802
8887
  "path": "lastUpdated",
8803
8888
  "valueType": "number"
8804
8889
  },
8890
+ {
8891
+ "path": "volts",
8892
+ "valueType": "number"
8893
+ },
8805
8894
  {
8806
8895
  "path": "lastContactAt",
8807
8896
  "valueType": "number"
@@ -9044,10 +9133,6 @@ Object.freeze({
9044
9133
  "datetime"
9045
9134
  ]
9046
9135
  },
9047
- {
9048
- "path": "unit",
9049
- "valueType": "string"
9050
- },
9051
9136
  {
9052
9137
  "path": "min",
9053
9138
  "valueType": "number"
@@ -9059,19 +9144,6 @@ Object.freeze({
9059
9144
  {
9060
9145
  "path": "step",
9061
9146
  "valueType": "number"
9062
- },
9063
- {
9064
- "path": "precision",
9065
- "valueType": "number"
9066
- },
9067
- {
9068
- "path": "format",
9069
- "valueType": "enum",
9070
- "enumValues": [
9071
- "date",
9072
- "time",
9073
- "datetime"
9074
- ]
9075
9147
  }
9076
9148
  ],
9077
9149
  "cover": [
@@ -9312,13 +9384,8 @@ Object.freeze({
9312
9384
  "path": "value",
9313
9385
  "valueType": "string"
9314
9386
  }, {
9315
- "path": "format",
9316
- "valueType": "enum",
9317
- "enumValues": [
9318
- "date",
9319
- "time",
9320
- "datetime"
9321
- ]
9387
+ "path": "timestamp",
9388
+ "valueType": "boolean"
9322
9389
  }],
9323
9390
  "event-emitter": [
9324
9391
  {
@@ -9391,6 +9458,20 @@ Object.freeze({
9391
9458
  "path": "detected",
9392
9459
  "valueType": "boolean"
9393
9460
  }],
9461
+ "group-status": [
9462
+ {
9463
+ "path": "included",
9464
+ "valueType": "number"
9465
+ },
9466
+ {
9467
+ "path": "excluded",
9468
+ "valueType": "number"
9469
+ },
9470
+ {
9471
+ "path": "total",
9472
+ "valueType": "number"
9473
+ }
9474
+ ],
9394
9475
  "heating-valve": [{
9395
9476
  "path": "position",
9396
9477
  "valueType": "number"
@@ -9425,20 +9506,10 @@ Object.freeze({
9425
9506
  "valueType": "number"
9426
9507
  }
9427
9508
  ],
9428
- "humidity-sensor": [
9429
- {
9430
- "path": "percent",
9431
- "valueType": "number"
9432
- },
9433
- {
9434
- "path": "unit",
9435
- "valueType": "string"
9436
- },
9437
- {
9438
- "path": "precision",
9439
- "valueType": "number"
9440
- }
9441
- ],
9509
+ "humidity-sensor": [{
9510
+ "path": "percent",
9511
+ "valueType": "number"
9512
+ }],
9442
9513
  "image": [{
9443
9514
  "path": "url",
9444
9515
  "valueType": "string"
@@ -9777,20 +9848,10 @@ Object.freeze({
9777
9848
  "valueType": "boolean"
9778
9849
  }
9779
9850
  ],
9780
- "numeric-sensor": [
9781
- {
9782
- "path": "value",
9783
- "valueType": "number"
9784
- },
9785
- {
9786
- "path": "unit",
9787
- "valueType": "string"
9788
- },
9789
- {
9790
- "path": "precision",
9791
- "valueType": "number"
9792
- }
9793
- ],
9851
+ "numeric-sensor": [{
9852
+ "path": "value",
9853
+ "valueType": "number"
9854
+ }],
9794
9855
  "pet-feeder": [
9795
9856
  {
9796
9857
  "path": "foodLevel",
@@ -9882,14 +9943,6 @@ Object.freeze({
9882
9943
  {
9883
9944
  "path": "powerFactor",
9884
9945
  "valueType": "number"
9885
- },
9886
- {
9887
- "path": "unit",
9888
- "valueType": "string"
9889
- },
9890
- {
9891
- "path": "precision",
9892
- "valueType": "number"
9893
9946
  }
9894
9947
  ],
9895
9948
  "presence": [
@@ -9918,20 +9971,10 @@ Object.freeze({
9918
9971
  "valueType": "number"
9919
9972
  }
9920
9973
  ],
9921
- "pressure-sensor": [
9922
- {
9923
- "path": "hpa",
9924
- "valueType": "number"
9925
- },
9926
- {
9927
- "path": "unit",
9928
- "valueType": "string"
9929
- },
9930
- {
9931
- "path": "precision",
9932
- "valueType": "number"
9933
- }
9934
- ],
9974
+ "pressure-sensor": [{
9975
+ "path": "hpa",
9976
+ "valueType": "number"
9977
+ }],
9935
9978
  "privacy-mask": [{
9936
9979
  "path": "enabled",
9937
9980
  "valueType": "boolean"
@@ -10242,20 +10285,10 @@ Object.freeze({
10242
10285
  "path": "tampered",
10243
10286
  "valueType": "boolean"
10244
10287
  }],
10245
- "temperature-sensor": [
10246
- {
10247
- "path": "celsius",
10248
- "valueType": "number"
10249
- },
10250
- {
10251
- "path": "unit",
10252
- "valueType": "string"
10253
- },
10254
- {
10255
- "path": "precision",
10256
- "valueType": "number"
10257
- }
10258
- ],
10288
+ "temperature-sensor": [{
10289
+ "path": "celsius",
10290
+ "valueType": "number"
10291
+ }],
10259
10292
  "update": [
10260
10293
  {
10261
10294
  "path": "currentVersion",
@@ -10366,10 +10399,6 @@ Object.freeze({
10366
10399
  "path": "temperature",
10367
10400
  "valueType": "number"
10368
10401
  },
10369
- {
10370
- "path": "temperatureUnit",
10371
- "valueType": "string"
10372
- },
10373
10402
  {
10374
10403
  "path": "humidity",
10375
10404
  "valueType": "number"
@@ -10378,18 +10407,10 @@ Object.freeze({
10378
10407
  "path": "pressure",
10379
10408
  "valueType": "number"
10380
10409
  },
10381
- {
10382
- "path": "pressureUnit",
10383
- "valueType": "string"
10384
- },
10385
10410
  {
10386
10411
  "path": "windSpeed",
10387
10412
  "valueType": "number"
10388
10413
  },
10389
- {
10390
- "path": "windSpeedUnit",
10391
- "valueType": "string"
10392
- },
10393
10414
  {
10394
10415
  "path": "windBearing",
10395
10416
  "valueType": "number"
@@ -10806,7 +10827,7 @@ function errMsg(err) {
10806
10827
  return String(err);
10807
10828
  }
10808
10829
  //#endregion
10809
- //#region ../types/dist/composition-kEl9mevu.mjs
10830
+ //#region ../types/dist/composition-OhpuespA.mjs
10810
10831
  /**
10811
10832
  * The ffmpeg build every CamStack node downloads, per platform and arch.
10812
10833
  *
@@ -11561,15 +11582,74 @@ function validateExpressionSource(src) {
11561
11582
  }
11562
11583
  return null;
11563
11584
  }
11564
- /** A composed device's stableId is this prefix plus the block id. */
11565
- var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
11566
- /** Longest snippet a `code` source may carry (slice 4). */
11567
- var MAX_COMPOSITION_CODE_LENGTH = 2e4;
11585
+ /**
11586
+ * A composition source identity: `(addonId, stableId)`, never a numeric device
11587
+ * id. Split out so helper schemas can name it without a cycle through
11588
+ * `composition.ts` (D659).
11589
+ */
11568
11590
  var CompositionSourceRefSchema = object({
11569
11591
  /** The owning addon; `stableId` is unique only within it. */
11570
11592
  addonId: string().min(1),
11571
11593
  stableId: string().min(1)
11572
11594
  });
11595
+ var GROUP_TEMPLATE_IDS = [
11596
+ "temperature",
11597
+ "humidity",
11598
+ "pressure",
11599
+ "illuminance",
11600
+ "power",
11601
+ "energy",
11602
+ "battery",
11603
+ "numeric",
11604
+ "motion",
11605
+ "contact",
11606
+ "leak",
11607
+ "smoke",
11608
+ "occupancy",
11609
+ "binary",
11610
+ "switch",
11611
+ "light"
11612
+ ];
11613
+ var NUMERIC_AGGREGATES = [
11614
+ "mean",
11615
+ "median",
11616
+ "min",
11617
+ "max",
11618
+ "sum"
11619
+ ];
11620
+ var BINARY_AGGREGATES = ["any", "all"];
11621
+ /** How an actuator group decides it is on. Unknown members never count as off. */
11622
+ var ACTIVE_CRITERIA = ["at-least-one", "all"];
11623
+ var AGGREGATE_REDUCERS = [
11624
+ "mean",
11625
+ "median",
11626
+ "min",
11627
+ "max",
11628
+ "sum",
11629
+ "any",
11630
+ "all"
11631
+ ];
11632
+ var GroupTemplateIdSchema = _enum(GROUP_TEMPLATE_IDS);
11633
+ var AggregateReducerSchema = _enum(AGGREGATE_REDUCERS);
11634
+ var ActiveCriterionSchema = _enum(ACTIVE_CRITERIA);
11635
+ /** One member. The field is stored so occupancy can name motion or binary. */
11636
+ var HelperMemberSchema = object({
11637
+ source: CompositionSourceRefSchema,
11638
+ cap: string().min(1),
11639
+ fieldPath: string().min(1)
11640
+ });
11641
+ var HelperSpecSchema = discriminatedUnion("kind", [object({
11642
+ kind: literal("group"),
11643
+ templateId: GroupTemplateIdSchema,
11644
+ aggregate: AggregateReducerSchema,
11645
+ members: array(HelperMemberSchema).min(1).max(64),
11646
+ /** Actuator groups only. Readings leave this unset. */
11647
+ active: ActiveCriterionSchema.optional()
11648
+ })]);
11649
+ /** A composed device's stableId is this prefix plus the block id. */
11650
+ var COMPOSED_DEVICE_STABLE_ID_PREFIX = "composed-";
11651
+ /** Longest snippet a `code` source may carry (slice 4). */
11652
+ var MAX_COMPOSITION_CODE_LENGTH = 2e4;
11573
11653
  /** One field of one capability of one source device. */
11574
11654
  var CompositionFieldReadSchema = object({
11575
11655
  source: CompositionSourceRefSchema,
@@ -11613,7 +11693,33 @@ var CompositionCodeSourceSchema = object({
11613
11693
  var CompositionFieldSourceSchema = discriminatedUnion("kind", [
11614
11694
  CompositionFromSourceSchema,
11615
11695
  CompositionExpressionSourceSchema,
11616
- CompositionCodeSourceSchema
11696
+ CompositionCodeSourceSchema,
11697
+ object({
11698
+ kind: literal("aggregate"),
11699
+ reducer: _enum([
11700
+ "mean",
11701
+ "median",
11702
+ "min",
11703
+ "max",
11704
+ "sum",
11705
+ "any",
11706
+ "all"
11707
+ ]),
11708
+ reads: array(CompositionFieldReadSchema).min(1).max(64),
11709
+ /** `value` is the reading; the others are the member counts. */
11710
+ publish: _enum([
11711
+ "value",
11712
+ "included",
11713
+ "excluded",
11714
+ "total"
11715
+ ]),
11716
+ /** What a `value` publish does when nobody is included. Counts ignore this. */
11717
+ empty: _enum([
11718
+ "null",
11719
+ "omit",
11720
+ "offline"
11721
+ ])
11722
+ })
11617
11723
  ]);
11618
11724
  var CompositionCommandTargetSchema = discriminatedUnion("kind", [object({
11619
11725
  kind: literal("forward"),
@@ -11659,7 +11765,13 @@ var CompositionSchema = object({
11659
11765
  kind: literal("existing"),
11660
11766
  device: CompositionSourceRefSchema
11661
11767
  })]),
11662
- features: array(CompositionFeatureSchema).min(1).max(16)
11768
+ features: array(CompositionFeatureSchema).min(1).max(16),
11769
+ /**
11770
+ * Present when the block is a helper template. Absent on a free
11771
+ * composition (the room example, device 4603). The editor reads this,
11772
+ * never the compiled features.
11773
+ */
11774
+ helper: HelperSpecSchema.optional()
11663
11775
  }).superRefine((composition, ctx) => {
11664
11776
  const seen = /* @__PURE__ */ new Set();
11665
11777
  composition.features.forEach((feature, index) => {
@@ -17867,7 +17979,11 @@ var CompositionProblemSchema = object({
17867
17979
  "item-field-reserved",
17868
17980
  "target-missing",
17869
17981
  "target-unreadable",
17870
- "not-replaceable"
17982
+ "not-replaceable",
17983
+ "native-capability-replace",
17984
+ "mixed-quantity",
17985
+ "helper-mismatch",
17986
+ "member-constraint"
17871
17987
  ]),
17872
17988
  path: string(),
17873
17989
  message: string()
@@ -17888,7 +18004,8 @@ var CompositionUnavailableModeSchema = _enum([
17888
18004
  "null",
17889
18005
  "offline",
17890
18006
  "unknown",
17891
- "release"
18007
+ "release",
18008
+ "omit"
17892
18009
  ]);
17893
18010
  /**
17894
18011
  * `derived` reads a source · `clock` reads only `now`, recomputed when a sibling
@@ -17925,9 +18042,11 @@ var CompositionFieldPlanSchema = object({
17925
18042
  reads: array(CompositionFieldReadSchema)
17926
18043
  });
17927
18044
  /**
17928
- * What a feature does to its target (D663): `new` builds a capability on a new
17929
- * device; on an existing device it `add`s a capability the device lacks, or
17930
- * `replace`s named fields of one it has natively.
18045
+ * What a feature does to its target (D663, D944): `new` builds a capability on a
18046
+ * new device; on an existing device it `add`s a capability the device lacks.
18047
+ * Replacing a capability the device already serves is refused
18048
+ * (`native-capability-replace`), not planned. `replace` remains on the claim
18049
+ * wire for a claim the hub already holds.
17931
18050
  */
17932
18051
  var CompositionFeatureModeSchema = _enum([
17933
18052
  "new",
@@ -18100,6 +18219,275 @@ Object.freeze({
18100
18219
  result: ["number", "null"]
18101
18220
  }
18102
18221
  });
18222
+ /**
18223
+ * One catalog row per base reading a group helper can build (D944 §3).
18224
+ * Data, not a form default written into the block.
18225
+ */
18226
+ var NOW = {
18227
+ fieldPath: "lastFetchedAt",
18228
+ expr: "now"
18229
+ };
18230
+ var CHANGED = {
18231
+ fieldPath: "lastChangedAt",
18232
+ expr: "now"
18233
+ };
18234
+ function numeric(input) {
18235
+ return {
18236
+ ...input,
18237
+ family: "numeric",
18238
+ deviceType: DeviceType.Sensor,
18239
+ memberDeviceType: DeviceType.Sensor,
18240
+ commandCaps: [],
18241
+ baseUnit: baseUnitOf(input.quantity),
18242
+ aggregates: NUMERIC_AGGREGATES,
18243
+ accept: [{
18244
+ cap: input.outputCap,
18245
+ fieldPath: input.outputField
18246
+ }]
18247
+ };
18248
+ }
18249
+ function binary(input) {
18250
+ return {
18251
+ ...input,
18252
+ family: "binary",
18253
+ deviceType: DeviceType.Sensor,
18254
+ memberDeviceType: DeviceType.Sensor,
18255
+ commandCaps: [],
18256
+ quantity: "none",
18257
+ baseUnit: void 0,
18258
+ aggregates: BINARY_AGGREGATES,
18259
+ empty: "offline"
18260
+ };
18261
+ }
18262
+ var GROUP_TEMPLATES = [
18263
+ numeric({
18264
+ id: "temperature",
18265
+ summary: "Mean, median, min, max or sum of temperatures. Offline and unknown members are left out, never read as 0 °C.",
18266
+ role: DeviceRole.TemperatureSensor,
18267
+ outputCap: "temperature-sensor",
18268
+ outputField: "celsius",
18269
+ quantity: "temperature",
18270
+ defaultAggregate: "mean",
18271
+ empty: "null",
18272
+ companions: [NOW]
18273
+ }),
18274
+ numeric({
18275
+ id: "humidity",
18276
+ summary: "Humidity in percent. Offline and unknown members are left out, never read as 0 %.",
18277
+ role: DeviceRole.HumiditySensor,
18278
+ outputCap: "humidity-sensor",
18279
+ outputField: "percent",
18280
+ quantity: "humidity",
18281
+ defaultAggregate: "mean",
18282
+ empty: "null",
18283
+ companions: [NOW]
18284
+ }),
18285
+ numeric({
18286
+ id: "pressure",
18287
+ summary: "Pressure in hectopascals. Offline and unknown members are left out, never read as 0 hPa.",
18288
+ role: DeviceRole.PressureSensor,
18289
+ outputCap: "pressure-sensor",
18290
+ outputField: "hpa",
18291
+ quantity: "pressure",
18292
+ defaultAggregate: "mean",
18293
+ empty: "null",
18294
+ companions: [NOW]
18295
+ }),
18296
+ numeric({
18297
+ id: "illuminance",
18298
+ summary: "Illuminance in lux. Offline and unknown members are left out, never read as 0 lx.",
18299
+ role: DeviceRole.AmbientLightSensor,
18300
+ outputCap: "ambient-light-sensor",
18301
+ outputField: "lux",
18302
+ quantity: "illuminance",
18303
+ defaultAggregate: "mean",
18304
+ empty: "null",
18305
+ companions: [NOW]
18306
+ }),
18307
+ numeric({
18308
+ id: "power",
18309
+ summary: "Power in watts, summed by default. Nobody reporting omits the key rather than writing 0 W.",
18310
+ role: DeviceRole.PowerSensor,
18311
+ outputCap: "power-meter",
18312
+ outputField: "watts",
18313
+ quantity: "power",
18314
+ defaultAggregate: "sum",
18315
+ empty: "omit",
18316
+ companions: [NOW]
18317
+ }),
18318
+ numeric({
18319
+ id: "energy",
18320
+ summary: "Energy in kilowatt-hours, summed by default. Nobody reporting omits the key rather than writing 0 kWh.",
18321
+ role: DeviceRole.EnergySensor,
18322
+ outputCap: "power-meter",
18323
+ outputField: "kwhTotal",
18324
+ quantity: "energy",
18325
+ defaultAggregate: "sum",
18326
+ empty: "omit",
18327
+ companions: [NOW]
18328
+ }),
18329
+ numeric({
18330
+ id: "battery",
18331
+ summary: "Battery charge in percent. Offline and unknown members are left out, never read as 0 %.",
18332
+ role: DeviceRole.BatterySensor,
18333
+ outputCap: "battery",
18334
+ outputField: "percentage",
18335
+ quantity: "percentage",
18336
+ defaultAggregate: "mean",
18337
+ empty: "null",
18338
+ companions: [
18339
+ {
18340
+ fieldPath: "charging",
18341
+ expr: "\"unknown\""
18342
+ },
18343
+ {
18344
+ fieldPath: "sleeping",
18345
+ expr: "false"
18346
+ },
18347
+ {
18348
+ fieldPath: "lastUpdated",
18349
+ expr: "now"
18350
+ }
18351
+ ]
18352
+ }),
18353
+ numeric({
18354
+ id: "numeric",
18355
+ summary: "A numeric reading in the members’ shared quantity. Mixed quantities are refused. Nobody reporting publishes null, not 0.",
18356
+ role: DeviceRole.NumericSensor,
18357
+ outputCap: "numeric-sensor",
18358
+ outputField: "value",
18359
+ quantity: "device",
18360
+ defaultAggregate: "mean",
18361
+ empty: "null",
18362
+ companions: [NOW]
18363
+ }),
18364
+ binary({
18365
+ id: "motion",
18366
+ summary: "Any or all motion. Offline and unknown members are left out, never read as off. Nobody reporting takes the device offline.",
18367
+ role: DeviceRole.MotionSensor,
18368
+ outputCap: "motion",
18369
+ outputField: "detected",
18370
+ defaultAggregate: "any",
18371
+ accept: [{
18372
+ cap: "motion",
18373
+ fieldPath: "detected"
18374
+ }],
18375
+ companions: [{
18376
+ fieldPath: "lastDetectedAt",
18377
+ expr: "null"
18378
+ }, {
18379
+ fieldPath: "autoClearAfterMs",
18380
+ expr: "null"
18381
+ }]
18382
+ }),
18383
+ binary({
18384
+ id: "contact",
18385
+ summary: "Any or all open contacts. Offline and unknown members are left out, never read as closed.",
18386
+ role: DeviceRole.ContactSensor,
18387
+ outputCap: "contact",
18388
+ outputField: "entryOpen",
18389
+ defaultAggregate: "any",
18390
+ accept: [{
18391
+ cap: "contact",
18392
+ fieldPath: "entryOpen"
18393
+ }],
18394
+ companions: [CHANGED]
18395
+ }),
18396
+ binary({
18397
+ id: "leak",
18398
+ summary: "Any or all leak detections. Offline and unknown members are left out, never read as dry.",
18399
+ role: DeviceRole.LeakSensor,
18400
+ outputCap: "flood",
18401
+ outputField: "flooded",
18402
+ defaultAggregate: "any",
18403
+ accept: [{
18404
+ cap: "flood",
18405
+ fieldPath: "flooded"
18406
+ }],
18407
+ companions: [CHANGED]
18408
+ }),
18409
+ binary({
18410
+ id: "smoke",
18411
+ summary: "Any or all smoke detections. Offline and unknown members are left out, never read as clear.",
18412
+ role: DeviceRole.SmokeSensor,
18413
+ outputCap: "smoke",
18414
+ outputField: "detected",
18415
+ defaultAggregate: "any",
18416
+ accept: [{
18417
+ cap: "smoke",
18418
+ fieldPath: "detected"
18419
+ }],
18420
+ companions: [CHANGED]
18421
+ }),
18422
+ binary({
18423
+ id: "occupancy",
18424
+ summary: "Any or all of motion or binary sensors, published as a binary. Presence zones are not aggregated. Nobody reporting is offline, not off.",
18425
+ role: DeviceRole.BinarySensor,
18426
+ outputCap: "binary",
18427
+ outputField: "on",
18428
+ defaultAggregate: "any",
18429
+ accept: [{
18430
+ cap: "motion",
18431
+ fieldPath: "detected"
18432
+ }, {
18433
+ cap: "binary",
18434
+ fieldPath: "on"
18435
+ }],
18436
+ companions: [CHANGED]
18437
+ }),
18438
+ binary({
18439
+ id: "binary",
18440
+ summary: "Any or all generic binary sensors. Offline and unknown members are left out, never read as off.",
18441
+ role: DeviceRole.BinarySensor,
18442
+ outputCap: "binary",
18443
+ outputField: "on",
18444
+ defaultAggregate: "any",
18445
+ accept: [{
18446
+ cap: "binary",
18447
+ fieldPath: "on"
18448
+ }],
18449
+ companions: [CHANGED]
18450
+ }),
18451
+ actuator({
18452
+ id: "switch",
18453
+ summary: "On when at least one switch is on, or when all of them are. Offline and unknown members are left out of that decision, never read as off, and still receive the command.",
18454
+ deviceType: DeviceType.Switch,
18455
+ outputCap: "switch",
18456
+ commandCaps: ["switch"]
18457
+ }),
18458
+ actuator({
18459
+ id: "light",
18460
+ summary: "On when at least one light is on, or when all of them are. Brightness and colour are offered only when every member has them. A command is not sent to a member that lacks it.",
18461
+ deviceType: DeviceType.Light,
18462
+ outputCap: "switch",
18463
+ commandCaps: [
18464
+ "switch",
18465
+ "brightness",
18466
+ "color"
18467
+ ]
18468
+ })
18469
+ ];
18470
+ function actuator(input) {
18471
+ return {
18472
+ ...input,
18473
+ family: "actuator",
18474
+ role: void 0,
18475
+ memberDeviceType: input.deviceType,
18476
+ outputField: "on",
18477
+ quantity: "none",
18478
+ baseUnit: void 0,
18479
+ aggregates: BINARY_AGGREGATES,
18480
+ defaultAggregate: "any",
18481
+ empty: "offline",
18482
+ accept: [{
18483
+ cap: "switch",
18484
+ fieldPath: "on"
18485
+ }],
18486
+ companions: [CHANGED]
18487
+ };
18488
+ }
18489
+ new Map(GROUP_TEMPLATES.map((template) => [template.id, template]));
18490
+ DeviceFeature.LightColorRgb, DeviceFeature.LightColorHsv, DeviceFeature.LightColorMired;
18103
18491
  `${COMPOSED_DEVICE_STABLE_ID_PREFIX}`;
18104
18492
  /**
18105
18493
  * What a composition may BUILD on a device type (G2, D676): the caps a new
@@ -19926,12 +20314,12 @@ function buildDeviceAdminUrl(parts) {
19926
20314
  * providers detect renames by matching `uniqueId` and patch `id`
19927
20315
  * in-place so dispatch keeps working.
19928
20316
  *
19929
- * **Rendering metadata is NOT stored here.** Display fields (`unit`,
19930
- * `precision`) live in the cap STATUS SLICE so they flow live with every
19931
- * state push and are the single source of truth. The `device_class` tag is
19932
- * an HA-specific input that is consumed at the discovery boundary
19933
- * (`ha-domain-mapping`) to derive the CamStack `DeviceRole`; it is never
19934
- * stored after that point.
20317
+ * **Display unit and precision are NOT stored here.** They are a read-time
20318
+ * preference (D944). A polymorphic reading stores its `quantity` here once,
20319
+ * at adopt, and a date/time value stores `dateTimeShape` the same way —
20320
+ * neither is repeated on each sample. The `device_class` tag is an
20321
+ * HA-specific input consumed at the discovery boundary (`ha-domain-mapping`)
20322
+ * to derive the CamStack `DeviceRole`; it is never stored after that point.
19935
20323
  *
19936
20324
  * The `raw` escape hatch carries provider-specific attributes that don't
19937
20325
  * fit the structured slots (HA `attributes` whitelist, Reolink `deviceCache`
@@ -19945,6 +20333,14 @@ var SourceInfoSchema = object({
19945
20333
  /** Immutable upstream identifier when available (HA `unique_id`, … ).
19946
20334
  * Used to detect rename when `id` changes. */
19947
20335
  uniqueId: string().optional(),
20336
+ /** Quantity of a polymorphic reading, written once at adopt (D944). */
20337
+ quantity: ReadingQuantitySchema.optional(),
20338
+ /** Date / time / datetime shape of a string reading, written once at adopt. */
20339
+ dateTimeShape: _enum([
20340
+ "date",
20341
+ "time",
20342
+ "datetime"
20343
+ ]).optional(),
19948
20344
  /** Provider-specific extras that don't fit the structured slots.
19949
20345
  * Whitelist what goes here per provider — do NOT dump entire HA
19950
20346
  * attribute blobs (would explode the meta JSON column). */
@@ -19990,6 +20386,80 @@ function mergeSourceInfo(prev, patch) {
19990
20386
  return next;
19991
20387
  }
19992
20388
  /**
20389
+ * Coerce common unit-of-measurement string variants to a canonical form
20390
+ * so the UI's display templates can rely on stable strings. Pure
20391
+ * passthrough for unknown units — kept conservative on purpose, the goal
20392
+ * is to normalise the long tail of HA `unit_of_measurement` values that
20393
+ * have multiple historical spellings (`'°C'` vs `'C'` vs `'celsius'`).
20394
+ *
20395
+ * Extend the map as we encounter real-world data. Returning the raw
20396
+ * input for unknown values is intentional: providers should never
20397
+ * lose information silently here.
20398
+ */
20399
+ function normalizeUnit(raw) {
20400
+ if (raw == null) return void 0;
20401
+ const trimmed = raw.trim();
20402
+ if (trimmed === "") return void 0;
20403
+ const lower = trimmed.toLowerCase();
20404
+ if (lower === "°c" || lower === "c" || lower === "celsius") return "°C";
20405
+ if (lower === "°f" || lower === "f" || lower === "fahrenheit") return "°F";
20406
+ if (lower === "k" || lower === "kelvin") return "K";
20407
+ if (lower === "%" || lower === "percent" || lower === "percentage") return "%";
20408
+ if (lower === "lx" || lower === "lux") return "lx";
20409
+ if (lower === "hpa" || lower === "hectopascal") return "hPa";
20410
+ if (lower === "kpa") return "kPa";
20411
+ if (lower === "pa") return "Pa";
20412
+ if (lower === "mbar" || lower === "millibar") return "mbar";
20413
+ if (lower === "w" || lower === "watt") return "W";
20414
+ if (lower === "kw" || lower === "kilowatt") return "kW";
20415
+ if (lower === "wh" || lower === "watt-hour") return "Wh";
20416
+ if (lower === "kwh" || lower === "kilowatt-hour") return "kWh";
20417
+ if (lower === "v" || lower === "volt") return "V";
20418
+ if (lower === "a" || lower === "amp" || lower === "ampere") return "A";
20419
+ if (lower === "ma" || lower === "milliampere") return "mA";
20420
+ if (lower === "va") return "VA";
20421
+ if (lower === "ppm") return "ppm";
20422
+ if (lower === "ppb") return "ppb";
20423
+ if (lower === "µg/m³" || lower === "ug/m3" || lower === "µg/m3") return "µg/m³";
20424
+ if (lower === "km/h" || lower === "kph") return "km/h";
20425
+ if (lower === "mph") return "mph";
20426
+ if (lower === "m/s") return "m/s";
20427
+ if (lower === "m") return "m";
20428
+ if (lower === "m²" || lower === "m2") return "m²";
20429
+ if (lower === "mm") return "mm";
20430
+ if (lower === "cm") return "cm";
20431
+ if (lower === "km") return "km";
20432
+ if (lower === "in" || lower === "inch" || lower === "inches") return "in";
20433
+ if (lower === "ft" || lower === "foot" || lower === "feet") return "ft";
20434
+ if (lower === "kg") return "kg";
20435
+ if (lower === "g") return "g";
20436
+ if (lower === "l" || lower === "liter" || lower === "litre" || lower === "liters" || lower === "litres") return "L";
20437
+ if (lower === "ml") return "mL";
20438
+ if (lower === "gal" || lower === "gallon" || lower === "gallons") return "gal";
20439
+ if (lower === "s" || lower === "sec" || lower === "second" || lower === "seconds") return "s";
20440
+ if (lower === "ms") return "ms";
20441
+ if (lower === "min" || lower === "minute" || lower === "minutes") return "min";
20442
+ if (lower === "h" || lower === "hr" || lower === "hour" || lower === "hours") return "h";
20443
+ if (lower === "d" || lower === "day" || lower === "days") return "d";
20444
+ if (lower === "b" || lower === "byte" || lower === "bytes") return "B";
20445
+ if (lower === "kib") return "KiB";
20446
+ if (lower === "mib") return "MiB";
20447
+ if (lower === "gib") return "GiB";
20448
+ if (lower === "tib") return "TiB";
20449
+ if (lower === "kb") return "kB";
20450
+ if (lower === "mb") return "MB";
20451
+ if (lower === "gb") return "GB";
20452
+ if (lower === "dbm") return "dBm";
20453
+ if (lower === "dbfs") return "dBFS";
20454
+ if (lower === "inhg") return "inHg";
20455
+ if (lower === "mm/h") return "mm/h";
20456
+ if (lower === "w/m²" || lower === "w/m2") return "W/m²";
20457
+ if (lower === "°" || lower === "deg" || lower === "degree" || lower === "degrees") return "°";
20458
+ if (lower === "kbps") return "kbps";
20459
+ if (lower === "fps") return "fps";
20460
+ return trimmed;
20461
+ }
20462
+ /**
19993
20463
  * Key under which `SourceInfo` lives inside `DeviceMeta.metadata`. The
19994
20464
  * blob is a free-form `Record<string, unknown>` shared with hardware-
19995
20465
  * identity fields (`manufacturer`, `model`, `firmware`, …); SourceInfo
@@ -41049,7 +41519,8 @@ method(object({
41049
41519
  "enum"
41050
41520
  ]),
41051
41521
  enumValues: array(string()).optional(),
41052
- item: boolean().optional()
41522
+ item: boolean().optional(),
41523
+ quantity: ReadingQuantitySchema.optional()
41053
41524
  })).readonly(),
41054
41525
  itemArray: object({
41055
41526
  path: string(),
@@ -44893,8 +45364,8 @@ var MaskPolygonShapeSchema = object({
44893
45364
  /** Boolean cell grid — row-major, length === gridWidth*gridHeight. */
44894
45365
  var MaskGridShapeSchema = object({
44895
45366
  kind: literal("grid"),
44896
- gridWidth: number(),
44897
- gridHeight: number(),
45367
+ gridWidth: number().meta(readingMeta("none")),
45368
+ gridHeight: number().meta(readingMeta("none")),
44898
45369
  cells: array(boolean())
44899
45370
  });
44900
45371
  discriminatedUnion("kind", [
@@ -45006,6 +45477,8 @@ var NcSystemEventKindSchema = _enum([
45006
45477
  "detection-blind",
45007
45478
  "motion-noisy",
45008
45479
  "motion-calm",
45480
+ "package-llm-silent",
45481
+ "package-llm-answered",
45009
45482
  "recording-stalled",
45010
45483
  "recording-healthy",
45011
45484
  "recording-degraded",
@@ -56097,6 +56570,28 @@ var PackageVersionInfoSchema = object({
56097
56570
  deprecated: string().optional(),
56098
56571
  distTags: array(string()).readonly()
56099
56572
  });
56573
+ /**
56574
+ * One published document. `ok` is markdown from the registry. `missing` means
56575
+ * the registry answered and the file was not there. `error` is a registry or
56576
+ * tarball failure — never an empty string standing in for either.
56577
+ */
56578
+ var PackageDocBodySchema = discriminatedUnion("status", [
56579
+ object({
56580
+ status: literal("ok"),
56581
+ markdown: string()
56582
+ }),
56583
+ object({ status: literal("missing") }),
56584
+ object({
56585
+ status: literal("error"),
56586
+ message: string()
56587
+ })
56588
+ ]);
56589
+ var PackageDocsSchema = object({
56590
+ name: string(),
56591
+ version: string(),
56592
+ readme: PackageDocBodySchema,
56593
+ changelog: PackageDocBodySchema
56594
+ });
56100
56595
  var SearchResultSchema = object({
56101
56596
  name: string(),
56102
56597
  version: string(),
@@ -56315,7 +56810,10 @@ method(object({ nodeId: string().optional() }).optional(), array(AddonListItemSc
56315
56810
  }), object({ success: literal(true) }), {
56316
56811
  kind: "mutation",
56317
56812
  auth: "admin"
56318
- }), method(object({ name: string() }), array(PackageVersionInfoSchema).readonly()), method(object({ addonId: string() }), RestartAddonResultSchema, {
56813
+ }), method(object({ name: string() }), array(PackageVersionInfoSchema).readonly()), method(object({
56814
+ name: string().min(1),
56815
+ version: string().min(1).optional()
56816
+ }), PackageDocsSchema, { auth: "admin" }), method(object({ addonId: string() }), RestartAddonResultSchema, {
56319
56817
  kind: "mutation",
56320
56818
  auth: "admin"
56321
56819
  }), method(object({ packageName: string() }), object({ success: literal(true) }), {
@@ -56374,27 +56872,17 @@ method(object({ nodeId: string().optional() }).optional(), array(AddonListItemSc
56374
56872
  */
56375
56873
  var AirQualitySensorStatusSchema = object({
56376
56874
  /** Carbon dioxide concentration in ppm. */
56377
- co2Ppm: number().min(0).optional(),
56875
+ co2Ppm: number().min(0).optional().meta(readingMeta("concentration-volume")),
56378
56876
  /** Total volatile organic compounds in ppb. */
56379
- vocPpb: number().min(0).optional(),
56877
+ vocPpb: number().min(0).optional().meta(readingMeta("concentration-ppb")),
56380
56878
  /** Particulate matter ≤ 2.5 μm in µg/m³. */
56381
- pm25: number().min(0).optional(),
56879
+ pm25: number().min(0).optional().meta(readingMeta("concentration-mass")),
56382
56880
  /** Particulate matter ≤ 10 μm in µg/m³. */
56383
- pm10: number().min(0).optional(),
56881
+ pm10: number().min(0).optional().meta(readingMeta("concentration-mass")),
56384
56882
  /** Composite AQI value (typically 0..500). */
56385
- aqi: number().optional(),
56883
+ aqi: number().optional().meta(readingMeta("index")),
56386
56884
  /** Ms epoch when the slice was last updated. */
56387
- lastFetchedAt: number(),
56388
- /** Live display unit of the single metric this slice carries (e.g. HA
56389
- * `attributes.unit_of_measurement` → 'ppm' / 'ppb' / 'µg/m³'). Each
56390
- * upstream `sensor.*` entity surfaces ONE device_class, so one unit
56391
- * per slice is unambiguous. */
56392
- unit: string().optional(),
56393
- /** Suggested decimal places for numeric display.
56394
- * Populated live from the upstream source when provided (e.g. HA
56395
- * `attributes.suggested_display_precision`). Falls back to
56396
- * auto-formatting when absent. */
56397
- precision: number().int().min(0).max(10).optional()
56885
+ lastFetchedAt: number()
56398
56886
  });
56399
56887
  var airQualitySensorCapability = {
56400
56888
  name: "air-quality-sensor",
@@ -56431,12 +56919,12 @@ var airQualitySensorCapability = {
56431
56919
  },
56432
56920
  pm25: {
56433
56921
  kind: "gauge",
56434
- unit: "ug/m3",
56922
+ unit: "µg/m³",
56435
56923
  deadband: 1
56436
56924
  },
56437
56925
  pm10: {
56438
56926
  kind: "gauge",
56439
- unit: "ug/m3",
56927
+ unit: "µg/m³",
56440
56928
  deadband: 1
56441
56929
  },
56442
56930
  aqi: {
@@ -56454,22 +56942,13 @@ var airQualitySensorCapability = {
56454
56942
  * entries with `device_class: illuminance`.
56455
56943
  */
56456
56944
  var AmbientLightSensorStatusSchema = object({
56457
- /** Current illuminance in lux (lx). */
56458
- lux: number().min(0).meta({
56459
- unit: "lx",
56945
+ /** Current illuminance in lux (lx). `null` when the sample was refused (D393). */
56946
+ lux: number().min(0).nullable().meta(readingMeta("illuminance", {
56947
+ precision: 0,
56460
56948
  label: "Illuminance"
56461
- }),
56949
+ })),
56462
56950
  /** Ms epoch when the slice was last updated. */
56463
- lastFetchedAt: number(),
56464
- /** Live display unit from the upstream source (e.g. HA
56465
- * `attributes.unit_of_measurement`). The UI prefers this over the
56466
- * role's canonical unit. Absent → fall back to the canonical unit. */
56467
- unit: string().optional(),
56468
- /** Suggested decimal places for numeric display.
56469
- * Populated live from the upstream source when provided (e.g. HA
56470
- * `attributes.suggested_display_precision`). Falls back to
56471
- * auto-formatting when absent. */
56472
- precision: number().int().min(0).max(10).optional()
56951
+ lastFetchedAt: number()
56473
56952
  });
56474
56953
  var ambientLightSensorCapability = {
56475
56954
  name: "ambient-light-sensor",
@@ -56501,12 +56980,7 @@ var ambientLightSensorCapability = {
56501
56980
  } } },
56502
56981
  /** Clock fields: written, but excluded from the compare that decides
56503
56982
  * whether persisting is worth a SQLite commit. */
56504
- volatileStateFields: ["lastFetchedAt"],
56505
- customize: {
56506
- version: 1,
56507
- advanced: "derive",
56508
- targets: []
56509
- }
56983
+ volatileStateFields: ["lastFetchedAt"]
56510
56984
  };
56511
56985
  /**
56512
56986
  * Per-class audio metrics aggregated over a sliding window.
@@ -56733,10 +57207,7 @@ var BatteryStatusSchema = object({
56733
57207
  * Consumers must SKIP a null rather than coerce it — `battery-band` already
56734
57208
  * declines to band a non-finite reading, which is the correct shape.
56735
57209
  */
56736
- percentage: number().min(0).max(100).nullable().meta({
56737
- unit: "%",
56738
- label: "Charge"
56739
- }),
57210
+ percentage: number().min(0).max(100).nullable().meta(readingMeta("percentage", { label: "Charge" })),
56740
57211
  /**
56741
57212
  * Charging source. `'dc'` covers wall/USB adapters; `'solar'` is
56742
57213
  * Reolink-specific for the Solar Panel 2 accessory (will become
@@ -56763,7 +57234,14 @@ var BatteryStatusSchema = object({
56763
57234
  */
56764
57235
  sleeping: boolean().meta({ label: "Sleeping" }),
56765
57236
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
56766
- lastUpdated: number(),
57237
+ lastUpdated: number().meta(readingMeta("none")),
57238
+ /**
57239
+ * Battery voltage in volts, for a cell the firmware reports as voltage
57240
+ * rather than a percentage (Ecowitt capacitor packs). Absent on cameras
57241
+ * that only report charge. A second reading on this cap, not a second
57242
+ * `numeric-sensor` on the same device.
57243
+ */
57244
+ volts: number().nullable().optional().meta(readingMeta("voltage", { label: "Voltage" })),
56767
57245
  /**
56768
57246
  * Ms epoch of the last time the device PROVED it was reachable — a
56769
57247
  * completed firmware round-trip, an observed wake, or an inbound push
@@ -56815,7 +57293,6 @@ var BATTERY_DEVICE_TYPES = [
56815
57293
  /** Generic Customize target for the percentage reading (D936). */
56816
57294
  var batteryCustomize = {
56817
57295
  version: 1,
56818
- advanced: "derive",
56819
57296
  targets: [{
56820
57297
  id: "percentage",
56821
57298
  cardinality: "scalar",
@@ -56824,7 +57301,7 @@ var batteryCustomize = {
56824
57301
  stateField: "percentage"
56825
57302
  },
56826
57303
  deviceTypes: BATTERY_DEVICE_TYPES,
56827
- apply: "add-or-replace",
57304
+ apply: "add",
56828
57305
  path: "percentage",
56829
57306
  sources: [{
56830
57307
  kind: "field",
@@ -56945,10 +57422,16 @@ onStatusChanged: { data: object({
56945
57422
  /**
56946
57423
  * History (D850): recorded series. The generic capture replaces the `battery` series of `pipeline-analytics:stats-5m`.
56947
57424
  */
56948
- history: { fields: { percentage: {
56949
- kind: "gauge",
56950
- unit: "%"
56951
- } } },
57425
+ history: { fields: {
57426
+ percentage: {
57427
+ kind: "gauge",
57428
+ unit: "%"
57429
+ },
57430
+ volts: {
57431
+ kind: "gauge",
57432
+ unit: "V"
57433
+ }
57434
+ } },
56952
57435
  customize: batteryCustomize,
56953
57436
  /** Clock fields: written, but excluded from the compare that decides
56954
57437
  * whether persisting is worth a SQLite commit. */
@@ -56995,12 +57478,7 @@ var binaryCapability = {
56995
57478
  history: { fields: { on: { kind: "boolean" } } },
56996
57479
  /** Clock fields: written, but excluded from the compare that decides
56997
57480
  * whether persisting is worth a SQLite commit. */
56998
- volatileStateFields: ["lastChangedAt"],
56999
- customize: {
57000
- version: 1,
57001
- advanced: "derive",
57002
- targets: []
57003
- }
57481
+ volatileStateFields: ["lastChangedAt"]
57004
57482
  };
57005
57483
  /**
57006
57484
  * Dimmable-light brightness control. Co-exists with `switch` on the
@@ -57016,7 +57494,7 @@ var binaryCapability = {
57016
57494
  */
57017
57495
  var BrightnessStatusSchema = object({
57018
57496
  /** Current level as 0..100 inclusive. Firmware-reported. */
57019
- percentage: number().min(0).max(100),
57497
+ percentage: number().min(0).max(100).meta(readingMeta("percentage")),
57020
57498
  /** Ms epoch of the last operator-driven change. Useful for UI freshness. */
57021
57499
  lastChangedAt: number()
57022
57500
  });
@@ -57470,21 +57948,21 @@ var ClimateControlStatusSchema = object({
57470
57948
  /** Single setpoint in Celsius. Used by single-target modes
57471
57949
  * (heat / cool / dry). Null when the device is in a range mode
57472
57950
  * (`heat_cool`) or has no setpoint at all (`fan_only`/`off`). */
57473
- target: number().nullable(),
57951
+ target: number().nullable().meta(readingMeta("temperature")),
57474
57952
  /** Upper bound of the dual setpoint in Celsius. Populated only
57475
57953
  * in `heat_cool` mode. */
57476
- targetHigh: number().nullable(),
57954
+ targetHigh: number().nullable().meta(readingMeta("temperature")),
57477
57955
  /** Lower bound of the dual setpoint in Celsius. Populated only
57478
57956
  * in `heat_cool` mode. */
57479
- targetLow: number().nullable(),
57957
+ targetLow: number().nullable().meta(readingMeta("temperature")),
57480
57958
  /** Target relative humidity (0..100). Null when the device has no
57481
57959
  * humidity control. */
57482
- targetHumidity: number().min(0).max(100).nullable(),
57960
+ targetHumidity: number().min(0).max(100).nullable().meta(readingMeta("humidity")),
57483
57961
  /** Current measured temperature in Celsius. Read-only — pushed by
57484
57962
  * the device's internal sensor. */
57485
- currentTemp: number().nullable(),
57963
+ currentTemp: number().nullable().meta(readingMeta("temperature")),
57486
57964
  /** Current measured relative humidity (0..100). Read-only. */
57487
- currentHumidity: number().min(0).max(100).nullable(),
57965
+ currentHumidity: number().min(0).max(100).nullable().meta(readingMeta("humidity")),
57488
57966
  /** Vertical louver swing on/off. Null when the device has no vertical
57489
57967
  * swing surface (gate with `DeviceFeature.ClimateSwingVertical`). */
57490
57968
  swingVertical: boolean().nullable(),
@@ -57492,11 +57970,11 @@ var ClimateControlStatusSchema = object({
57492
57970
  * horizontal swing surface (gate with `DeviceFeature.ClimateSwingHorizontal`). */
57493
57971
  swingHorizontal: boolean().nullable(),
57494
57972
  /** Lowest setpoint the device accepts, Celsius. Absent / null = not reported. */
57495
- minTemp: number().nullable().optional(),
57973
+ minTemp: number().nullable().optional().meta(readingMeta("temperature")),
57496
57974
  /** Highest setpoint the device accepts, Celsius. Absent / null = not reported. */
57497
- maxTemp: number().nullable().optional(),
57975
+ maxTemp: number().nullable().optional().meta(readingMeta("temperature")),
57498
57976
  /** Setpoint granularity, Celsius (0.5, 1). Absent / null = not reported. */
57499
- tempStep: number().positive().nullable().optional(),
57977
+ tempStep: number().positive().nullable().optional().meta(readingMeta("temperature")),
57500
57978
  /** Ms epoch when the slice was last updated (push or command). */
57501
57979
  lastFetchedAt: number()
57502
57980
  });
@@ -57638,17 +58116,17 @@ var climateControlCapability = {
57638
58116
  * modes to expose in the picker without a slice fetch.
57639
58117
  */
57640
58118
  var RgbTripletSchema = object({
57641
- r: number().int().min(0).max(255),
57642
- g: number().int().min(0).max(255),
57643
- b: number().int().min(0).max(255)
58119
+ r: number().int().min(0).max(255).meta(readingMeta("none")),
58120
+ g: number().int().min(0).max(255).meta(readingMeta("none")),
58121
+ b: number().int().min(0).max(255).meta(readingMeta("none"))
57644
58122
  });
57645
58123
  var HsvTripletSchema = object({
57646
58124
  /** Hue in degrees, 0..360. */
57647
- h: number().min(0).max(360),
58125
+ h: number().min(0).max(360).meta(readingMeta("none")),
57648
58126
  /** Saturation as 0..100 inclusive. */
57649
- s: number().min(0).max(100),
58127
+ s: number().min(0).max(100).meta(readingMeta("none")),
57650
58128
  /** Value/brightness as 0..100 inclusive. */
57651
- v: number().min(0).max(100)
58129
+ v: number().min(0).max(100).meta(readingMeta("none"))
57652
58130
  });
57653
58131
  var ColorInputSchema = discriminatedUnion("mode", [
57654
58132
  object({
@@ -57677,7 +58155,7 @@ var ColorStatusSchema = object({
57677
58155
  /** Populated when `mode === 'hsv'`. */
57678
58156
  hsv: HsvTripletSchema.optional(),
57679
58157
  /** Populated when `mode === 'mired'`. */
57680
- mireds: number().int().optional(),
58158
+ mireds: number().int().optional().meta(readingMeta("colour-temperature")),
57681
58159
  /** Ms epoch of the last operator-driven change. */
57682
58160
  lastChangedAt: number()
57683
58161
  });
@@ -57872,18 +58350,12 @@ _enum([
57872
58350
  "empty",
57873
58351
  "full"
57874
58352
  ]);
57875
- /** Units a remaining-life counter is reported in. Closed: the panel renders
57876
- * each. */
57877
- var ConsumableRemainingUnitSchema = _enum([
57878
- "days",
57879
- "hours",
57880
- "cycles"
57881
- ]);
57882
- /** Remaining life as a COUNT, not a percentage (the PetKit desiccant reports
57883
- * days). `value` null = unknown (D393). */
58353
+ /** Whether `remaining.value` is a duration in seconds or a dimensionless count. */
58354
+ var ConsumableRemainingKindSchema = _enum(["duration", "count"]);
58355
+ /** Remaining life. `value` null = unknown (D393). Duration is seconds. */
57884
58356
  var ConsumableRemainingSchema = object({
57885
- value: number().min(0).nullable(),
57886
- unit: ConsumableRemainingUnitSchema
58357
+ value: number().min(0).nullable().meta(readingMeta("device")),
58358
+ kind: ConsumableRemainingKindSchema
57887
58359
  });
57888
58360
  /** A single consumable item. Either a continuous `level` (remaining
57889
58361
  * life %, or fill % for a `supply` / `waste` item) or a discrete `status`
@@ -57896,7 +58368,7 @@ var ConsumableItemSchema = object({
57896
58368
  /** Display name. */
57897
58369
  label: string().min(1),
57898
58370
  /** Remaining life % (`wear`) or fill % (`supply`, `waste`) when known (0..100). */
57899
- level: number().min(0).max(100).nullable(),
58371
+ level: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
57900
58372
  /** The provider's discrete verdict when known (binary mode). */
57901
58373
  status: ConsumableStatusSchema.nullable(),
57902
58374
  /** What the item is — decides whether low or full is bad. Absent = `wear`. */
@@ -57978,8 +58450,8 @@ var consumablesCustomize = {
57978
58450
  transforms: [{ kind: "identity" }]
57979
58451
  }],
57980
58452
  literals: [{
57981
- path: "remaining.unit",
57982
- values: [...ConsumableRemainingUnitSchema.options]
58453
+ path: "remaining.kind",
58454
+ values: [...ConsumableRemainingKindSchema.options]
57983
58455
  }]
57984
58456
  },
57985
58457
  {
@@ -58108,7 +58580,6 @@ reset: method(object({
58108
58580
  },
58109
58581
  "remaining.value": {
58110
58582
  kind: "gauge",
58111
- unitFrom: "remaining.unit",
58112
58583
  deadband: 1,
58113
58584
  displayName: "Remaining"
58114
58585
  }
@@ -58178,10 +58649,9 @@ var contactCapability = {
58178
58649
  * (empty array for the other kinds). It lives in the slice because
58179
58650
  * HA `select.*` entities may update their options at runtime.
58180
58651
  *
58181
- * The `unit`/`min`/`max`/`step` fields are meaningful for
58182
- * `kind === 'numeric'` and are absent (undefined) for all other
58183
- * kinds. They are populated live from HA attributes on each state
58184
- * push — consistent with `options` and the sensor-unit approach.
58652
+ * Numeric `min` / `max` / `step` live in the slice and share the device
58653
+ * quantity (`SourceInfo.quantity`, D944). Display precision is a read-time
58654
+ * preference. A datetime control's shape is `SourceInfo.dateTimeShape`.
58185
58655
  */
58186
58656
  var ControlStatusSchema = object({
58187
58657
  kind: _enum([
@@ -58193,39 +58663,17 @@ var ControlStatusSchema = object({
58193
58663
  /** Current value. Numeric kind → number; select/text/datetime → string.
58194
58664
  * Datetime values are ISO 8601 strings (full timestamp, date-only,
58195
58665
  * or time-only — HA accepts all three forms). */
58196
- value: union([number(), string()]),
58666
+ value: union([number().meta(readingMeta("device")), string()]),
58197
58667
  /** Acceptable values when `kind === 'select'`. Empty for other kinds. */
58198
58668
  options: array(string()),
58199
58669
  /** Ms epoch when the slice was last updated. */
58200
58670
  lastChangedAt: number(),
58201
- /** Display unit-of-measurement (e.g. '°C', '%', 'lx').
58202
- * Populated live from HA `attributes.unit_of_measurement` on each push.
58203
- * Meaningful for `kind === 'numeric'`; absent for other kinds. */
58204
- unit: string().optional(),
58205
- /** Minimum allowed value. Populated live from HA `attributes.min`.
58206
- * Meaningful for `kind === 'numeric'`; absent for other kinds. */
58207
- min: number().optional(),
58208
- /** Maximum allowed value. Populated live from HA `attributes.max`.
58209
- * Meaningful for `kind === 'numeric'`; absent for other kinds. */
58210
- max: number().optional(),
58211
- /** Step increment. Populated live from HA `attributes.step`.
58212
- * Meaningful for `kind === 'numeric'`; absent for other kinds. */
58213
- step: number().optional(),
58214
- /** Suggested decimal places for numeric display. Populated live from HA
58215
- * `attributes.suggested_display_precision`, or derived from `step` when the
58216
- * HA attribute is absent. Meaningful for `kind === 'numeric'`; absent for
58217
- * other kinds. Falls back to auto-formatting when absent. */
58218
- precision: number().int().min(0).max(10).optional(),
58219
- /** Date-picker granularity for `kind === 'datetime'`. 'date' → date-only,
58220
- * 'time' → time-only, 'datetime' → full timestamp. Absent for other kinds
58221
- * (UI defaults to 'datetime'). Derived live from the device's HA domain
58222
- * (date.* / time.* / datetime.*) or, for input_datetime, from the entity's
58223
- * `has_date`/`has_time` attributes on each push. */
58224
- format: _enum([
58225
- "date",
58226
- "time",
58227
- "datetime"
58228
- ]).optional()
58671
+ /** Minimum allowed value, in the device quantity's base unit. */
58672
+ min: number().optional().meta(readingMeta("device")),
58673
+ /** Maximum allowed value, in the device quantity's base unit. */
58674
+ max: number().optional().meta(readingMeta("device")),
58675
+ /** Step increment, in the device quantity's base unit. */
58676
+ step: number().optional().meta(readingMeta("device"))
58229
58677
  });
58230
58678
  var ControlSetValueInputSchema = discriminatedUnion("kind", [
58231
58679
  object({
@@ -58291,9 +58739,9 @@ var CoverStatusSchema = object({
58291
58739
  ]),
58292
58740
  /** 0 = fully closed, 100 = fully open. Null when the device has
58293
58741
  * no intermediate position surface. */
58294
- position: number().min(0).max(100).nullable(),
58742
+ position: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
58295
58743
  /** 0 = closed slats, 100 = open slats. Null when no tilt support. */
58296
- tiltPosition: number().min(0).max(100).nullable(),
58744
+ tiltPosition: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
58297
58745
  /** Ms epoch when the slice was last updated. */
58298
58746
  lastChangedAt: number()
58299
58747
  });
@@ -58396,9 +58844,9 @@ var NormalizedRangeSchema$1 = object({
58396
58844
  var DayNightStatusSchema = object({
58397
58845
  mode: DayNightModeSchema,
58398
58846
  /** IR-cut trigger sensitivity, NORMALIZED 0–100 (higher = switches to night sooner). */
58399
- sensitivity: number().optional(),
58847
+ sensitivity: number().optional().meta(readingMeta("none")),
58400
58848
  /** Delay before the IR-cut filter flips, in seconds. */
58401
- switchDelaySec: number().optional(),
58849
+ switchDelaySec: number().optional().meta(readingMeta("duration")),
58402
58850
  lastFetchedAt: number()
58403
58851
  });
58404
58852
  /**
@@ -58616,11 +59064,11 @@ var DishwasherStatusSchema = object({
58616
59064
  /** Feature keys of the programs the appliance currently offers — drives the UI selector. */
58617
59065
  availablePrograms: array(string()),
58618
59066
  /** Seconds remaining on the active cycle, or null when not running / not known. */
58619
- remainingSeconds: number().int().nonnegative().nullable(),
59067
+ remainingSeconds: number().int().nonnegative().nullable().meta(readingMeta("duration")),
58620
59068
  /** Seconds the delayed start is set to (time until the cycle starts), or null. */
58621
- delayedStartSeconds: number().int().nonnegative().nullable(),
59069
+ delayedStartSeconds: number().int().nonnegative().nullable().meta(readingMeta("duration")),
58622
59070
  /** Cycle completion 0..100, or null when not applicable. */
58623
- progressPercent: number().min(0).max(100).nullable(),
59071
+ progressPercent: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
58624
59072
  /** True when the door is reported open; null when the door state is not known. */
58625
59073
  doorOpen: boolean().nullable(),
58626
59074
  /** True when the door is latched (locked) during a cycle; null when not reported. */
@@ -58644,7 +59092,7 @@ var DishwasherStatusSchema = object({
58644
59092
  /** True while the machine-care (descaling / cleaning-program) reminder is raised; false when clear; null when not reported. */
58645
59093
  machineCareDue: boolean().nullable(),
58646
59094
  /** Program runs left before the machine-care reminder is due; null when the appliance does not say. */
58647
- machineCareRemainingRuns: number().int().nonnegative().nullable(),
59095
+ machineCareRemainingRuns: number().int().nonnegative().nullable().meta(readingMeta("count")),
58648
59096
  /** Option states: null when the appliance does not expose the option or has not said. */
58649
59097
  halfLoad: boolean().nullable(),
58650
59098
  hygienePlus: boolean().nullable(),
@@ -58659,7 +59107,7 @@ var DishwasherStatusSchema = object({
58659
59107
  /** Feature keys of the events currently raised (salt low, rinse aid low, program finished, …). */
58660
59108
  activeEvents: array(string()),
58661
59109
  /** Numeric error code when the appliance reports one; null otherwise. */
58662
- errorCode: number().nullable(),
59110
+ errorCode: number().nullable().meta(readingMeta("none")),
58663
59111
  /** Name of the raised alert-level event while the appliance is in error; null otherwise. */
58664
59112
  errorLabel: string().nullable(),
58665
59113
  /** Ms epoch of the last value received from the appliance; 0 = never. */
@@ -58890,7 +59338,7 @@ var DoorbellStatusSchema = object({
58890
59338
  /** Ms epoch of the last press. null = never observed since this provider started. */
58891
59339
  lastPressedAt: number().nullable(),
58892
59340
  /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
58893
- pressCountSinceStart: number()
59341
+ pressCountSinceStart: number().meta(readingMeta("count"))
58894
59342
  });
58895
59343
  var DoorbellPressEventSchema = object({
58896
59344
  deviceId: number(),
@@ -58955,17 +59403,7 @@ onPressed: {
58955
59403
  history: "none",
58956
59404
  customize: doorbellCustomize
58957
59405
  };
58958
- /**
58959
- * Enum-state sensor — a string value picked from a finite option set.
58960
- * Drives HA `sensor` entries with `state_class: enum` (HVAC action
58961
- * states, weather conditions, contact-source channels, mode strings).
58962
- * The option set is stable across observations; it is persisted in the
58963
- * device config blob (via the control slice at adoption) and is NOT
58964
- * stored in `sourceInfo`.
58965
- */
58966
- /** Locale-render shape for an ISO `value` carried by a `DateTimeSensor`-role
58967
- * enum sensor: `date` (date-only), `time` (time-only), `datetime` (both). */
58968
- var EnumSensorDateTimeFormatSchema = _enum([
59406
+ _enum([
58969
59407
  "date",
58970
59408
  "time",
58971
59409
  "datetime"
@@ -58973,12 +59411,11 @@ var EnumSensorDateTimeFormatSchema = _enum([
58973
59411
  var EnumSensorStatusSchema = object({
58974
59412
  value: string(),
58975
59413
  /**
58976
- * Set for `DateTimeSensor`-role sensors so the UI renders the ISO `value`
58977
- * as a locale date/time/datetime; absent for plain enum sensors. HA
58978
- * `device_class=timestamp` → `'datetime'`, `device_class=date` → `'date'`,
58979
- * a time-only sensor → `'time'`.
59414
+ * Set when `value` is a timestamp rather than an enum state, so history
59415
+ * does not learn ISO strings as states. The display shape (date / time /
59416
+ * datetime) lives on `SourceInfo.dateTimeShape`, written once at adopt.
58980
59417
  */
58981
- format: EnumSensorDateTimeFormatSchema.optional(),
59418
+ timestamp: boolean().optional(),
58982
59419
  /** Ms epoch when the slice was last updated. */
58983
59420
  lastFetchedAt: number()
58984
59421
  });
@@ -59005,11 +59442,11 @@ var enumSensorCapability = {
59005
59442
  * History (D850): the value as an `enum` series (an appliance's program or
59006
59443
  * operation state, an HVAC action). The option set is per device, so the
59007
59444
  * value list is LEARNED as values are first seen (bounded). A sensor with a
59008
- * `format` carries a timestamp, not a state, and is not recorded.
59445
+ * `timestamp` carries a clock, not a state, and is not recorded.
59009
59446
  */
59010
59447
  history: { fields: { value: {
59011
59448
  kind: "enum",
59012
- skipWhenSet: "format"
59449
+ skipWhenSet: "timestamp"
59013
59450
  } } },
59014
59451
  /** Clock fields: written, but excluded from the compare that decides
59015
59452
  * whether persisting is worth a SQLite commit. */
@@ -59029,13 +59466,13 @@ var EventFireSchema = object({
59029
59466
  deviceId: number(),
59030
59467
  eventType: string(),
59031
59468
  data: record(string(), unknown()).nullable(),
59032
- timestamp: number(),
59033
- seq: number()
59469
+ timestamp: number().meta(readingMeta("none")),
59470
+ seq: number().meta(readingMeta("count"))
59034
59471
  });
59035
59472
  var EventEmitterStatusSchema = object({
59036
59473
  eventTypes: array(string()),
59037
59474
  lastEvent: EventFireSchema.nullable(),
59038
- eventCountSinceStart: number()
59475
+ eventCountSinceStart: number().meta(readingMeta("count"))
59039
59476
  });
59040
59477
  var eventEmitterCapability = {
59041
59478
  name: "event-emitter",
@@ -59448,10 +59885,10 @@ var FanDirectionSchema = _enum(["forward", "reverse"]);
59448
59885
  var FanControlStatusSchema = object({
59449
59886
  /** Active speed as 0..100 inclusive. Null when the device has no
59450
59887
  * speed surface (single-speed fan). */
59451
- percentage: number().min(0).max(100).nullable(),
59888
+ percentage: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
59452
59889
  /** Speed granularity the device accepts (HA `percentage_step`, e.g. 25 for
59453
59890
  * a 4-speed fan). Absent when unknown — the UI then falls back to step 1. */
59454
- percentageStep: number().positive().optional(),
59891
+ percentageStep: number().positive().optional().meta(readingMeta("percentage")),
59455
59892
  /** Active preset mode (`auto` / `quiet` / `turbo` / vendor-specific)
59456
59893
  * — empty string when no preset is active or supported. */
59457
59894
  preset: string(),
@@ -59572,7 +60009,7 @@ var FeatureProbeStatusSchema = object({
59572
60009
  /** Camera/firmware model string. `null` when the firmware doesn't expose it. */
59573
60010
  model: string().nullable(),
59574
60011
  /** Channel count for NVR/Hub devices; `1` for standalone cameras; `null` pre-probe. */
59575
- channelCount: number().nullable(),
60012
+ channelCount: number().nullable().meta(readingMeta("count")),
59576
60013
  /**
59577
60014
  * Ms epoch of the last SUCCESSFUL probe. `0` before the first probe
59578
60015
  * completes — drivers' `getAccessoryChildren()` should treat zero as
@@ -59695,9 +60132,91 @@ var gasCapability = {
59695
60132
  * whether persisting is worth a SQLite commit. */
59696
60133
  volatileStateFields: ["lastChangedAt"]
59697
60134
  };
60135
+ var GroupCommandsSchema = object({
60136
+ /** One row per member that has been commanded. A later command replaces that member's row. */
60137
+ results: array(object({
60138
+ deviceId: number().int().nonnegative(),
60139
+ cap: string().min(1),
60140
+ method: string().min(1),
60141
+ ok: boolean(),
60142
+ error: string().optional(),
60143
+ /** Ms epoch of the attempt. */
60144
+ at: number()
60145
+ })) });
60146
+ var groupCommandsCapability = {
60147
+ name: "group-commands",
60148
+ scope: "device",
60149
+ deviceNative: true,
60150
+ mode: "singleton",
60151
+ deviceTypes: [DeviceType.Switch, DeviceType.Light],
60152
+ methods: {},
60153
+ status: {
60154
+ schema: GroupCommandsSchema,
60155
+ kind: "push"
60156
+ },
60157
+ runtimeState: GroupCommandsSchema,
60158
+ /**
60159
+ * Runtime-state durability: **session** — the last attempt. A restored
60160
+ * failure would outlive the command that caused it.
60161
+ *
60162
+ * See `RuntimeStateDurability`. Enforced by
60163
+ * `scripts/check-runtime-state-durability.ts`.
60164
+ */
60165
+ durability: "session",
60166
+ history: "none"
60167
+ };
60168
+ /**
60169
+ * How many members of a helper group answered (D944). Counts are integers,
60170
+ * not measurements: zero included is a real count, never a stand-in reading.
60171
+ * The reading itself stays on the output capability. History is none — the
60172
+ * series that later statistics read is the output cap's own history block.
60173
+ */
60174
+ var GroupStatusSchema = object({
60175
+ /** Members whose current value entered the aggregate. */
60176
+ included: number().int().nonnegative().meta(readingMeta("count", {
60177
+ precision: 0,
60178
+ label: "Reporting"
60179
+ })),
60180
+ /** Members left out: offline, unknown, or not a finite reading (D393). */
60181
+ excluded: number().int().nonnegative().meta(readingMeta("count", {
60182
+ precision: 0,
60183
+ label: "Excluded"
60184
+ })),
60185
+ /** `included` + `excluded`. */
60186
+ total: number().int().nonnegative().meta(readingMeta("count", {
60187
+ precision: 0,
60188
+ label: "Members"
60189
+ }))
60190
+ });
60191
+ var groupStatusCapability = {
60192
+ name: "group-status",
60193
+ scope: "device",
60194
+ deviceNative: true,
60195
+ mode: "singleton",
60196
+ deviceTypes: [
60197
+ DeviceType.Sensor,
60198
+ DeviceType.Switch,
60199
+ DeviceType.Light
60200
+ ],
60201
+ methods: {},
60202
+ status: {
60203
+ schema: GroupStatusSchema,
60204
+ kind: "push"
60205
+ },
60206
+ runtimeState: GroupStatusSchema,
60207
+ /**
60208
+ * Runtime-state durability: **session** — recomputed from members on the
60209
+ * next source event; a restored count would outlive the readings it named.
60210
+ *
60211
+ * See `RuntimeStateDurability`. Enforced by
60212
+ * `scripts/check-runtime-state-durability.ts`.
60213
+ */
60214
+ durability: "session",
60215
+ history: "none"
60216
+ };
59698
60217
  var HeatingValveStatusSchema = object({
59699
60218
  /** Radiator valve opening 0 (closed) .. 100 (open). Null = not yet known. */
59700
- position: number().min(0).max(100).nullable(),
60219
+ position: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
59701
60220
  /** Ms epoch when the slice was last updated (push or poll). */
59702
60221
  lastFetchedAt: number()
59703
60222
  });
@@ -59740,9 +60259,9 @@ var HumidifierStatusSchema = object({
59740
60259
  /** Whether the humidifier is currently on. */
59741
60260
  on: boolean(),
59742
60261
  /** Current measured relative humidity (0..100). Null when not reported. */
59743
- currentHumidity: number().min(0).max(100).nullable(),
60262
+ currentHumidity: number().min(0).max(100).nullable().meta(readingMeta("humidity")),
59744
60263
  /** Target relative humidity (0..100). Null when no setpoint surface. */
59745
- targetHumidity: number().min(0).max(100).nullable(),
60264
+ targetHumidity: number().min(0).max(100).nullable().meta(readingMeta("humidity")),
59746
60265
  /** Active mode (`auto` / `normal` / `baby` / vendor). Null when the
59747
60266
  * device has no mode surface. */
59748
60267
  mode: string().nullable(),
@@ -59752,9 +60271,9 @@ var HumidifierStatusSchema = object({
59752
60271
  * `idle` / `off`). Null when the device doesn't report it. */
59753
60272
  action: string().nullable(),
59754
60273
  /** HA `min_humidity` attribute. Null → UI uses 0. */
59755
- minHumidity: number().nullable(),
60274
+ minHumidity: number().nullable().meta(readingMeta("humidity")),
59756
60275
  /** HA `max_humidity` attribute. Null → UI uses 100. */
59757
- maxHumidity: number().nullable(),
60276
+ maxHumidity: number().nullable().meta(readingMeta("humidity")),
59758
60277
  /** Ms epoch when the slice was last updated. */
59759
60278
  lastChangedAt: number()
59760
60279
  });
@@ -59824,22 +60343,13 @@ var humidifierCapability = {
59824
60343
  * `sourceInfo`.
59825
60344
  */
59826
60345
  var HumiditySensorStatusSchema = object({
59827
- /** Current relative humidity, 0..100. */
59828
- percent: number().min(0).max(100).meta({
59829
- unit: "%",
60346
+ /** Current relative humidity, 0..100. `null` when the sample was refused (D393). */
60347
+ percent: number().min(0).max(100).nullable().meta(readingMeta("humidity", {
60348
+ precision: 0,
59830
60349
  label: "Humidity"
59831
- }),
60350
+ })),
59832
60351
  /** Ms epoch when the slice was last updated. */
59833
- lastFetchedAt: number(),
59834
- /** Live display unit from the upstream source (e.g. HA
59835
- * `attributes.unit_of_measurement`). The UI prefers this over the
59836
- * role's canonical unit. Absent → fall back to the canonical unit. */
59837
- unit: string().optional(),
59838
- /** Suggested decimal places for numeric display.
59839
- * Populated live from the upstream source when provided (e.g. HA
59840
- * `attributes.suggested_display_precision`). Falls back to
59841
- * auto-formatting when absent. */
59842
- precision: number().int().min(0).max(10).optional()
60352
+ lastFetchedAt: number()
59843
60353
  });
59844
60354
  var humiditySensorCapability = {
59845
60355
  name: "humidity-sensor",
@@ -59870,12 +60380,7 @@ var humiditySensorCapability = {
59870
60380
  } } },
59871
60381
  /** Clock fields: written, but excluded from the compare that decides
59872
60382
  * whether persisting is worth a SQLite commit. */
59873
- volatileStateFields: ["lastFetchedAt"],
59874
- customize: {
59875
- version: 1,
59876
- advanced: "derive",
59877
- targets: []
59878
- }
60383
+ volatileStateFields: ["lastFetchedAt"]
59879
60384
  };
59880
60385
  /**
59881
60386
  * Image display cap. Models a single still image exposed by an integration —
@@ -59896,7 +60401,7 @@ var ImageStatusSchema = object({
59896
60401
  * entity exposes no `entity_picture` (yet). */
59897
60402
  url: string().nullable(),
59898
60403
  /** Ms epoch of the upstream last-updated timestamp. Null at cold-start. */
59899
- lastUpdated: number().nullable()
60404
+ lastUpdated: number().nullable().meta(readingMeta("none"))
59900
60405
  });
59901
60406
  var imageCapability = {
59902
60407
  name: "image",
@@ -59984,19 +60489,19 @@ var NormalizedRangeSchema = object({
59984
60489
  */
59985
60490
  var ImageSettingsStatusSchema = object({
59986
60491
  /** Normalized 0–100. */
59987
- brightness: number().optional(),
60492
+ brightness: number().optional().meta(readingMeta("none")),
59988
60493
  /** Normalized 0–100. */
59989
- contrast: number().optional(),
60494
+ contrast: number().optional().meta(readingMeta("none")),
59990
60495
  /** Normalized 0–100. */
59991
- saturation: number().optional(),
60496
+ saturation: number().optional().meta(readingMeta("none")),
59992
60497
  /** Normalized 0–100. */
59993
- sharpness: number().optional(),
60498
+ sharpness: number().optional().meta(readingMeta("none")),
59994
60499
  mirror: boolean().optional(),
59995
60500
  flip: boolean().optional(),
59996
60501
  rotate: ImageRotateSchema.optional(),
59997
60502
  whiteBalance: WhiteBalanceModeSchema.optional(),
59998
60503
  /** Manual white-balance warmth, NORMALIZED 0–100. Meaningful only when `whiteBalance === 'manual'`. */
59999
- warmth: number().optional(),
60504
+ warmth: number().optional().meta(readingMeta("none")),
60000
60505
  exposureMode: ExposureModeSchema.optional(),
60001
60506
  backlightMode: BacklightModeSchema.optional(),
60002
60507
  lastFetchedAt: number()
@@ -60275,10 +60780,10 @@ var IntercomAbilitySchema = object({
60275
60780
  /** Codecs accepted by firmware, in preference order. Driver-authored strings. */
60276
60781
  codecs: array(string()),
60277
60782
  /** Sample rate (Hz) the firmware expects. Reolink typical: 16000. */
60278
- sampleRate: number(),
60783
+ sampleRate: number().meta(readingMeta("none")),
60279
60784
  duplex: _enum(["half", "full"]),
60280
60785
  /** Max in-flight audio backlog before drop (ms). Server uses this to bound latency. */
60281
- maxBacklogMs: number()
60786
+ maxBacklogMs: number().meta(readingMeta("duration-ms"))
60282
60787
  });
60283
60788
  var IntercomStatusSchema = object({
60284
60789
  /** True while a talk session is open. */
@@ -60505,13 +61010,13 @@ var LawnMowerControlStatusSchema = object({
60505
61010
  activity: LawnMowerActivitySchema,
60506
61011
  /** 0..100 battery percentage. Null when the device has no battery
60507
61012
  * reading. */
60508
- batteryLevel: number().min(0).max(100).nullable(),
61013
+ batteryLevel: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
60509
61014
  /** 0..100 mowing-completion percentage of the current task, or null when no
60510
61015
  * task is active / progress is unavailable. */
60511
- progressPercent: number().min(0).max(100).nullable(),
61016
+ progressPercent: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
60512
61017
  /** Current device/event code (dynamic — mostly status, sometimes an error),
60513
61018
  * or null when unknown. */
60514
- currentCode: number().nullable(),
61019
+ currentCode: number().nullable().meta(readingMeta("none")),
60515
61020
  /** Human label for {@link currentCode}, or null when undecodable. */
60516
61021
  currentCodeLabel: string().nullable(),
60517
61022
  /** Severity of {@link currentCode}. `error` (and often `warning`) warrants UI
@@ -61044,7 +61549,7 @@ var MediaPlayerStatusSchema = object({
61044
61549
  state: MediaPlayerStateSchema,
61045
61550
  /** Volume as 0..100 inclusive. Null when the device has no volume
61046
61551
  * surface. Pair with `DeviceFeature.MediaPlayerVolume`. */
61047
- volumeLevel: number().min(0).max(100).nullable(),
61552
+ volumeLevel: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
61048
61553
  /** Mute toggle distinct from volume=0. Null when no mute surface.
61049
61554
  * Pair with `DeviceFeature.MediaPlayerMute`. */
61050
61555
  isMuted: boolean().nullable(),
@@ -61057,10 +61562,10 @@ var MediaPlayerStatusSchema = object({
61057
61562
  currentMedia: MediaInfoSchema.nullable(),
61058
61563
  /** Current playback position in ms. Null when not seekable or
61059
61564
  * nothing is playing. Pair with `DeviceFeature.MediaPlayerSeek`. */
61060
- positionMs: number().int().nonnegative().nullable(),
61565
+ positionMs: number().int().nonnegative().nullable().meta(readingMeta("duration-ms")),
61061
61566
  /** Total duration of the current media in ms. Null when unknown
61062
61567
  * (live stream) or nothing is playing. */
61063
- durationMs: number().int().nonnegative().nullable(),
61568
+ durationMs: number().int().nonnegative().nullable().meta(readingMeta("duration-ms")),
61064
61569
  /** Shuffle toggle. Null when no shuffle surface.
61065
61570
  * Pair with `DeviceFeature.MediaPlayerShuffle`. */
61066
61571
  shuffle: boolean().nullable(),
@@ -61369,7 +61874,7 @@ var motionCustomize = {
61369
61874
  eventName: "onMotionChanged"
61370
61875
  },
61371
61876
  deviceTypes: [DeviceType.Camera],
61372
- apply: "replace",
61877
+ apply: "add",
61373
61878
  applyAuthority: {
61374
61879
  kind: "camera-sensors",
61375
61880
  timeoutField: "motionCooldownMs"
@@ -61441,7 +61946,7 @@ var MotionStatusSchema = object({
61441
61946
  * it, every provider seeded it at boot, and every session left it
61442
61947
  * `detected: true` after its detach.
61443
61948
  */
61444
- autoClearAfterMs: number().nullable()
61949
+ autoClearAfterMs: number().nullable().meta(readingMeta("duration-ms"))
61445
61950
  });
61446
61951
  /**
61447
61952
  * Payload of `motion.onMotionChanged` event + the corresponding bus
@@ -61478,12 +61983,18 @@ var motionCapability = {
61478
61983
  methods: {
61479
61984
  /**
61480
61985
  * Pull the current motion state synchronously. Convenience shortcut
61481
- * for consumers that don't need the full status object; equivalent
61482
- * to `getStatus()?.detected ?? false`. Will likely be folded into
61483
- * `getStatus` once the auto-injected status surface lands in every
61484
- * consumer.
61986
+ * for consumers that don't need the full status object. A composed
61987
+ * group answers from its slice (D944): unknown throws, it is not off.
61485
61988
  */
61486
61989
  isDetected: method(object({ deviceId: number() }), boolean()) },
61990
+ /**
61991
+ * A composed motion group serves `isDetected` from `detected` (D944).
61992
+ * Without this the cap stays uncomposable (`has-commands`).
61993
+ */
61994
+ composedMethods: { isDetected: {
61995
+ kind: "slice",
61996
+ field: "detected"
61997
+ } },
61487
61998
  events: {
61488
61999
  /**
61489
62000
  * Fires every time the runner transitions a camera between
@@ -61623,7 +62134,7 @@ var MotionZoneRegionSchema = object({
61623
62134
  * the grid region(s). */
61624
62135
  var MotionZoneStatusSchema = object({
61625
62136
  enabled: boolean(),
61626
- sensitivity: number(),
62137
+ sensitivity: number().meta(readingMeta("none")),
61627
62138
  /** Grid region(s). Today exactly one `grid` shape. */
61628
62139
  regions: array(MotionZoneRegionSchema),
61629
62140
  lastFetchedAt: number()
@@ -61993,7 +62504,7 @@ var NavigationStatusSchema = object({
61993
62504
  following: boolean(),
61994
62505
  flash: boolean(),
61995
62506
  lightMode: NavigationLightModeSchema,
61996
- lightLevel: number().min(40).max(100),
62507
+ lightLevel: number().min(40).max(100).meta(readingMeta("none")),
61997
62508
  /** Ms epoch when the slice was last updated. */
61998
62509
  lastChangedAt: number()
61999
62510
  });
@@ -62127,13 +62638,13 @@ var NetworkLinkStatusSchema = object({
62127
62638
  * one whose reading has not landed must not be drawn at 0 %. Consumers
62128
62639
  * SKIP a null rather than coerce it.
62129
62640
  */
62130
- signalPercent: number().min(0).max(100).nullable(),
62641
+ signalPercent: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
62131
62642
  /** Raw received signal strength in dBm, when the firmware reports one. */
62132
- rssiDbm: number().optional(),
62643
+ rssiDbm: number().optional().meta(readingMeta("signal-level")),
62133
62644
  /** Network name of a wireless link, when the firmware reports it. */
62134
62645
  ssid: string().optional(),
62135
62646
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
62136
- lastUpdated: number()
62647
+ lastUpdated: number().meta(readingMeta("none"))
62137
62648
  });
62138
62649
  var networkLinkCapability = {
62139
62650
  name: "network-link",
@@ -62518,7 +63029,7 @@ var NotifierStatusSchema = object({
62518
63029
  lastError: string().nullable(),
62519
63030
  /** Number of deliveries currently buffered server-side (rate-limited
62520
63031
  * notifiers may queue). 0 when the provider sends synchronously. */
62521
- queueDepth: number().int().nonnegative(),
63032
+ queueDepth: number().int().nonnegative().meta(readingMeta("count")),
62522
63033
  /** Per-feature capability matrix — authoritative source for the
62523
63034
  * compose-form field gating. */
62524
63035
  supports: NotifierSupportsSchema
@@ -62610,11 +63121,9 @@ onSent: { data: object({
62610
63121
  * cap fits (HA `sensor` whose `device_class` we don't have a typed cap
62611
63122
  * for: water flow, distance, weight, frequency, signal strength, …).
62612
63123
  *
62613
- * The `unit` and `precision` fields carry the live display metadata that
62614
- * the upstream source provides with each state push (HA
62615
- * `attributes.unit_of_measurement` / `attributes.suggested_display_precision`).
62616
- * The UI reads them directly from the slice — they are the single source
62617
- * of truth for generic numeric rendering and do NOT live on `sourceInfo`.
63124
+ * The quantity is not on this slice. It is chosen when the device is
63125
+ * adopted and stored once on `SourceInfo.quantity` (D944). `value` is in
63126
+ * that quantity's base unit. `null` is a refused sample (D393).
62618
63127
  *
62619
63128
  * Prefer the typed alternatives (`temperature-sensor`, `humidity-sensor`,
62620
63129
  * `ambient-light-sensor`, `pressure-sensor`, `power-meter`,
@@ -62623,15 +63132,8 @@ onSent: { data: object({
62623
63132
  * numeric values land here.
62624
63133
  */
62625
63134
  var NumericSensorStatusSchema = object({
62626
- value: number(),
62627
- /** Display unit-of-measurement (e.g. 'dBm', 's', 'rpm', 'steps').
62628
- * Populated live from the upstream source on each state push.
62629
- * Absent for unitless sensors. */
62630
- unit: string().optional(),
62631
- /** Suggested decimal places for numeric display.
62632
- * Populated live from the upstream source when provided.
62633
- * Falls back to auto-formatting when absent. */
62634
- precision: number().int().min(0).max(10).optional(),
63135
+ /** Reading in the device quantity's base unit. `null` when refused (D393). */
63136
+ value: number().nullable().meta(readingMeta("device")),
62635
63137
  /** Ms epoch when the slice was last updated. */
62636
63138
  lastFetchedAt: number()
62637
63139
  });
@@ -62655,11 +63157,11 @@ var numericSensorCapability = {
62655
63157
  */
62656
63158
  durability: "restored",
62657
63159
  /**
62658
- * History (D850): recorded series. The unit is per device, read from the sibling `unit` field.
63160
+ * History (D850): recorded series. The unit is the device quantity's base,
63161
+ * resolved when the series is read — the cap has no single unit.
62659
63162
  */
62660
63163
  history: { fields: { value: {
62661
63164
  kind: "gauge",
62662
- unitFrom: "unit",
62663
63165
  minIntervalMs: 3e4
62664
63166
  } } },
62665
63167
  /** Clock fields: written, but excluded from the compare that decides
@@ -62951,23 +63453,26 @@ var PetFeederStatusSchema = object({
62951
63453
  /** Food currently in the bowl (grams). Null when the device has not
62952
63454
  * reported a reading yet. On dual-hopper models this is the combined
62953
63455
  * bowl reading; per-hopper levels live in `food1`/`food2`. */
62954
- foodLevel: number().nullable(),
63456
+ foodLevel: number().nullable().meta(readingMeta("none")),
62955
63457
  /** Hopper-1 food level (grams) on dual-hopper feeders; null on
62956
63458
  * single-hopper models. */
62957
- food1: number().nullable(),
63459
+ food1: number().nullable().meta(readingMeta("none")),
62958
63460
  /** Hopper-2 food level (grams) on dual-hopper feeders; null on
62959
63461
  * single-hopper models. */
62960
- food2: number().nullable(),
63462
+ food2: number().nullable().meta(readingMeta("none")),
62961
63463
  /** Derived low-food flag — mirrors the HA `food_level` binary_sensor
62962
63464
  * (`device_class: problem`, on = low). True when the bowl is empty /
62963
63465
  * below the feeder's low threshold. */
62964
63466
  lowFood: boolean(),
62965
63467
  /** Battery charge 0..100 (%). Null on mains-powered models or when the
62966
63468
  * device has no battery reading. */
62967
- batteryPower: number().min(0).max(100).nullable(),
62968
- /** Days of desiccant life remaining. Null when the model has no
62969
- * desiccant sensor. */
62970
- desiccantLeftDays: number().nullable(),
63469
+ batteryPower: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
63470
+ /**
63471
+ * Desiccant life remaining, stored in seconds (duration base, D944).
63472
+ * Providers convert from the days the feeder reports. Null when the
63473
+ * model has no desiccant sensor.
63474
+ */
63475
+ desiccantLeftDays: number().nullable().meta(readingMeta("duration")),
62971
63476
  /** True while a feed is in progress. */
62972
63477
  feeding: boolean(),
62973
63478
  /** Decoded connectivity / power status (HA petkit device-status enum).
@@ -62978,7 +63483,7 @@ var PetFeederStatusSchema = object({
62978
63483
  * with `errorCode` for consumers that want the raw integer. */
62979
63484
  error: string().nullable(),
62980
63485
  /** Raw device error code (0 / null = no error). */
62981
- errorCode: number().nullable(),
63486
+ errorCode: number().nullable().meta(readingMeta("none")),
62982
63487
  /** True for D4S/D4SH dual-hopper hardware — gates the per-hopper UI. */
62983
63488
  isDualHopper: boolean(),
62984
63489
  /** Child-lock (manual-lock) setting — buttons on the unit are disabled. */
@@ -62988,7 +63493,7 @@ var PetFeederStatusSchema = object({
62988
63493
  /** Play a chime when dispensing. */
62989
63494
  feedSound: boolean(),
62990
63495
  /** Speaker / prompt volume level (device-scaled integer). */
62991
- volume: number(),
63496
+ volume: number().meta(readingMeta("none")),
62992
63497
  /** Ms epoch when the slice was last refreshed from the cloud. */
62993
63498
  lastFetchedAt: number()
62994
63499
  });
@@ -63107,7 +63612,7 @@ var petFeederCapability = {
63107
63612
  },
63108
63613
  desiccantLeftDays: {
63109
63614
  kind: "gauge",
63110
- unit: "days"
63615
+ unit: "s"
63111
63616
  }
63112
63617
  } }
63113
63618
  };
@@ -63508,36 +64013,19 @@ method(_void(), PlatformCapabilitiesSchema), method(_void(), HardwareInfoSchema)
63508
64013
  */
63509
64014
  var PowerMeterStatusSchema = object({
63510
64015
  /** Instantaneous power draw in watts. */
63511
- watts: number().optional().meta({
63512
- unit: "W",
63513
- label: "Power"
63514
- }),
64016
+ watts: number().optional().meta(readingMeta("power", { label: "Power" })),
63515
64017
  /** Cumulative energy in kilowatt-hours since the meter was reset. */
63516
- kwhTotal: number().optional().meta({
63517
- unit: "kWh",
63518
- label: "Energy"
63519
- }),
64018
+ kwhTotal: number().optional().meta(readingMeta("energy", { label: "Energy" })),
63520
64019
  /** Voltage in volts. */
63521
- volts: number().optional(),
64020
+ volts: number().optional().meta(readingMeta("voltage")),
63522
64021
  /** Current in amperes. */
63523
- amps: number().optional(),
63524
- /** Apparent power in volt-amperes (W × power-factor reciprocal). */
63525
- va: number().optional(),
64022
+ amps: number().optional().meta(readingMeta("current")),
64023
+ /** Apparent power in volt-amperes. */
64024
+ va: number().optional().meta(readingMeta("apparent-power")),
63526
64025
  /** Power factor (0..1) if reported. */
63527
- powerFactor: number().min(0).max(1).optional(),
64026
+ powerFactor: number().min(0).max(1).optional().meta(readingMeta("ratio")),
63528
64027
  /** Ms epoch when the slice was last updated. */
63529
- lastFetchedAt: number(),
63530
- /** Live display unit of the single metric this slice carries (e.g. HA
63531
- * `attributes.unit_of_measurement` → 'V' / 'A' / 'W' / 'kW' / 'kWh').
63532
- * Each upstream `sensor.*` entity surfaces ONE device_class, so one
63533
- * unit per slice is unambiguous. The UI prefers this over the role's
63534
- * canonical unit so a 'kW' / 'Wh' feed renders verbatim. */
63535
- unit: string().optional(),
63536
- /** Suggested decimal places for numeric display.
63537
- * Populated live from the upstream source when provided (e.g. HA
63538
- * `attributes.suggested_display_precision`). Falls back to
63539
- * auto-formatting when absent. */
63540
- precision: number().int().min(0).max(10).optional()
64028
+ lastFetchedAt: number()
63541
64029
  });
63542
64030
  var powerMeterCapability = {
63543
64031
  name: "power-meter",
@@ -63601,12 +64089,7 @@ var powerMeterCapability = {
63601
64089
  } },
63602
64090
  /** Clock fields: written, but excluded from the compare that decides
63603
64091
  * whether persisting is worth a SQLite commit. */
63604
- volatileStateFields: ["lastFetchedAt"],
63605
- customize: {
63606
- version: 1,
63607
- advanced: "derive",
63608
- targets: []
63609
- }
64092
+ volatileStateFields: ["lastFetchedAt"]
63610
64093
  };
63611
64094
  /**
63612
64095
  * Presence cap. Models HA `person.*` and `device_tracker.*` entities
@@ -63626,11 +64109,11 @@ var powerMeterCapability = {
63626
64109
  */
63627
64110
  var GpsLocationSchema = object({
63628
64111
  /** Latitude in decimal degrees, -90..90. */
63629
- latitude: number().min(-90).max(90),
64112
+ latitude: number().min(-90).max(90).meta(readingMeta("angle")),
63630
64113
  /** Longitude in decimal degrees, -180..180. */
63631
- longitude: number().min(-180).max(180),
64114
+ longitude: number().min(-180).max(180).meta(readingMeta("angle")),
63632
64115
  /** Reported accuracy in meters (lower = better). */
63633
- accuracyMeters: number().nonnegative()
64116
+ accuracyMeters: number().nonnegative().meta(readingMeta("length"))
63634
64117
  });
63635
64118
  var PresenceStatusSchema = object({
63636
64119
  /** `home` / `not_home` / any user-defined zone name. */
@@ -63642,7 +64125,7 @@ var PresenceStatusSchema = object({
63642
64125
  gps: GpsLocationSchema.nullable(),
63643
64126
  /** Optional battery level of the tracking device (0..100). Null
63644
64127
  * when not reported. */
63645
- batteryPercent: number().min(0).max(100).nullable(),
64128
+ batteryPercent: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
63646
64129
  /** Ms epoch when the slice was last updated. */
63647
64130
  lastChangedAt: number()
63648
64131
  });
@@ -63692,19 +64175,10 @@ var presenceCapability = {
63692
64175
  * `sourceInfo`.
63693
64176
  */
63694
64177
  var PressureSensorStatusSchema = object({
63695
- /** Current pressure in hPa. */
63696
- hpa: number(),
64178
+ /** Current pressure in hPa. `null` when the sample was refused (D393). */
64179
+ hpa: number().nullable().meta(readingMeta("pressure", { precision: 0 })),
63697
64180
  /** Ms epoch when the slice was last updated. */
63698
- lastFetchedAt: number(),
63699
- /** Live display unit from the upstream source (e.g. HA
63700
- * `attributes.unit_of_measurement`). The UI prefers this over the
63701
- * role's canonical unit. Absent → fall back to the canonical unit. */
63702
- unit: string().optional(),
63703
- /** Suggested decimal places for numeric display.
63704
- * Populated live from the upstream source when provided (e.g. HA
63705
- * `attributes.suggested_display_precision`). Falls back to
63706
- * auto-formatting when absent. */
63707
- precision: number().int().min(0).max(10).optional()
64181
+ lastFetchedAt: number()
63708
64182
  });
63709
64183
  var pressureSensorCapability = {
63710
64184
  name: "pressure-sensor",
@@ -64118,8 +64592,8 @@ DeviceType.Camera, method(PtzMoveCommandSchema.extend({ deviceId: number() }), _
64118
64592
  }), _void(), { kind: "mutation" });
64119
64593
  var PtzAutotrackSettingsSchema = object({
64120
64594
  targetType: string().describe("Vendor target string (people/vehicle/pet); empty = camera default"),
64121
- stopDelaySeconds: number().int().min(0).max(300),
64122
- disappearDelaySeconds: number().int().min(0).max(300)
64595
+ stopDelaySeconds: number().int().min(0).max(300).meta(readingMeta("duration")),
64596
+ disappearDelaySeconds: number().int().min(0).max(300).meta(readingMeta("duration"))
64123
64597
  });
64124
64598
  /**
64125
64599
  * One option the camera advertises as a valid `targetType`. Drivers
@@ -64273,7 +64747,7 @@ var RunningStateStatusSchema = object({
64273
64747
  /** null = unknown — never coerced to false (D393). */
64274
64748
  running: boolean().nullable(),
64275
64749
  /** Epoch ms the current boolean began; null while `running` is null. */
64276
- since: number().nullable(),
64750
+ since: number().nullable().meta(readingMeta("none")),
64277
64751
  source: RunningStateSourceSchema.meta({ label: "Source" }),
64278
64752
  phase: string().nullable(),
64279
64753
  /** null until a reading has been published (D393). */
@@ -64288,7 +64762,6 @@ var RUNNING_DEVICE_TYPES = Object.values(DeviceType);
64288
64762
  /** Generic Customize target for the running boolean (D936). */
64289
64763
  var runningCustomize = {
64290
64764
  version: 1,
64291
- advanced: "derive",
64292
64765
  targets: [{
64293
64766
  id: "running",
64294
64767
  cardinality: "scalar",
@@ -64308,7 +64781,7 @@ var runningCustomize = {
64308
64781
  DeviceType.Thermostat,
64309
64782
  DeviceType.PetFeeder
64310
64783
  ],
64311
- apply: "add-or-replace",
64784
+ apply: "add",
64312
64785
  path: "running",
64313
64786
  sources: [
64314
64787
  {
@@ -65572,9 +66045,9 @@ var RecordTriggerSchema = _enum([
65572
66045
  */
65573
66046
  var RecordWindowSchema = object({
65574
66047
  trigger: RecordTriggerSchema,
65575
- day: number().int().min(0).max(6),
65576
- startMinute: number().int().min(0).max(1439),
65577
- endMinute: number().int().min(1).max(1440)
66048
+ day: number().int().min(0).max(6).meta(readingMeta("none")),
66049
+ startMinute: number().int().min(0).max(1439).meta(readingMeta("none")),
66050
+ endMinute: number().int().min(1).max(1440).meta(readingMeta("none"))
65578
66051
  });
65579
66052
  /** Status of one physical volume, as the camera itself describes it. */
65580
66053
  var OnboardStorageVolumeSchema = object({
@@ -65650,10 +66123,10 @@ var RecordingOnboardStatusSchema = object({
65650
66123
  /** Master "record to the card at all" switch. */
65651
66124
  enabled: boolean().nullable(),
65652
66125
  overwriteWhenFull: boolean().nullable(),
65653
- preRecordSec: number().nullable(),
65654
- postRecordSec: number().nullable(),
66126
+ preRecordSec: number().nullable().meta(readingMeta("duration")),
66127
+ postRecordSec: number().nullable().meta(readingMeta("duration")),
65655
66128
  /** Length of one recorded file, in minutes. */
65656
- segmentMinutes: number().nullable(),
66129
+ segmentMinutes: number().nullable().meta(readingMeta("none")),
65657
66130
  /** The primary track's weekly windows, flattened. */
65658
66131
  windows: array(RecordWindowSchema),
65659
66132
  /**
@@ -65669,7 +66142,7 @@ var RecordingOnboardStatusSchema = object({
65669
66142
  * provider reporting a non-zero count also reports the schedule as not
65670
66143
  * writable.
65671
66144
  */
65672
- unreadableWindows: number(),
66145
+ unreadableWindows: number().meta(readingMeta("count")),
65673
66146
  /**
65674
66147
  * The camera is scheduled to record and has NO usable storage.
65675
66148
  *
@@ -66514,7 +66987,7 @@ var CamStreamDescriptorSchema = object({
66514
66987
  url: string().optional(),
66515
66988
  codec: string().optional(),
66516
66989
  resolution: CamStreamResolutionSchema.optional(),
66517
- fps: number().positive().optional(),
66990
+ fps: number().positive().optional().meta(readingMeta("rate")),
66518
66991
  label: string().optional(),
66519
66992
  /** Device-level features (e.g. `battery-operated`) — drives broker policy. */
66520
66993
  deviceFeatures: array(string()).optional(),
@@ -66601,18 +67074,18 @@ var StreamProfileSchema = _enum([
66601
67074
  ]);
66602
67075
  /** The current encoding config of one stream profile. */
66603
67076
  var StreamProfileConfigSchema = object({
66604
- width: number(),
66605
- height: number(),
67077
+ width: number().meta(readingMeta("none")),
67078
+ height: number().meta(readingMeta("none")),
66606
67079
  codec: _enum(["h264", "h265"]),
66607
- framerate: number(),
66608
- bitrate: number(),
67080
+ framerate: number().meta(readingMeta("rate")),
67081
+ bitrate: number().meta(readingMeta("bitrate")),
66609
67082
  bitrateMode: _enum(["vbr", "cbr"]).optional(),
66610
67083
  encoderProfile: _enum([
66611
67084
  "high",
66612
67085
  "main",
66613
67086
  "baseline"
66614
67087
  ]).optional(),
66615
- gop: number().optional(),
67088
+ gop: number().optional().meta(readingMeta("none")),
66616
67089
  /**
66617
67090
  * Whether THIS profile currently carries an audio track. READ-ONLY here.
66618
67091
  *
@@ -66833,12 +67306,7 @@ var switchCapability = {
66833
67306
  label: "On",
66834
67307
  kind: "boolean"
66835
67308
  }
66836
- }] },
66837
- customize: {
66838
- version: 1,
66839
- advanced: "derive",
66840
- targets: []
66841
- }
67309
+ }] }
66842
67310
  };
66843
67311
  /** C0 controls, DEL and the C1 range: none of them belongs in a name. */
66844
67312
  function hasControlCharacter(value) {
@@ -67421,35 +67889,21 @@ var tamperCapability = {
67421
67889
  * entries with `device_class: temperature` and any future native
67422
67890
  * thermometer.
67423
67891
  *
67424
- * Unit normalisation: providers convert to Celsius before storing.
67425
- * The slice value is always Celsius so cross-cap aggregators
67426
- * (climate-control's `currentTemp`, energy analytics) can compose
67427
- * without per-source unit fixups. The canonical display unit (`°C`) is
67428
- * a descriptor constant in the UI (ROLE_DESCRIPTOR), not stored in
67429
- * `sourceInfo`.
67892
+ * The number is Celsius (D944). Providers convert on ingest. A failed
67893
+ * conversion is `null`, never a guess. Display unit is a read-time preference.
67430
67894
  *
67431
67895
  * Status `lastFetchedAt` lets staleness-aware consumers detect a
67432
67896
  * frozen feed (provider hung) distinct from a "temperature hasn't
67433
67897
  * changed" steady state.
67434
67898
  */
67435
67899
  var TemperatureSensorStatusSchema = object({
67436
- /** Current temperature in Celsius. */
67437
- celsius: number().meta({
67438
- unit: "°C",
67900
+ /** Current temperature in Celsius. `null` when the sample was refused (D393). */
67901
+ celsius: number().nullable().meta(readingMeta("temperature", {
67902
+ precision: 1,
67439
67903
  label: "Temperature"
67440
- }),
67904
+ })),
67441
67905
  /** Ms epoch when the slice was last updated (push or poll). */
67442
- lastFetchedAt: number(),
67443
- /** Live display unit from the upstream source (e.g. HA
67444
- * `attributes.unit_of_measurement`). The UI prefers this over the
67445
- * role's canonical unit, so a Fahrenheit feed renders '°F' not '°C'.
67446
- * Absent → the UI falls back to the role's canonical unit. */
67447
- unit: string().optional(),
67448
- /** Suggested decimal places for numeric display.
67449
- * Populated live from the upstream source when provided (e.g. HA
67450
- * `attributes.suggested_display_precision`). Falls back to
67451
- * auto-formatting when absent. */
67452
- precision: number().int().min(0).max(10).optional()
67906
+ lastFetchedAt: number()
67453
67907
  });
67454
67908
  var temperatureSensorCapability = {
67455
67909
  name: "temperature-sensor",
@@ -67480,12 +67934,7 @@ var temperatureSensorCapability = {
67480
67934
  } } },
67481
67935
  /** Clock fields: written, but excluded from the compare that decides
67482
67936
  * whether persisting is worth a SQLite commit. */
67483
- volatileStateFields: ["lastFetchedAt"],
67484
- customize: {
67485
- version: 1,
67486
- advanced: "derive",
67487
- targets: []
67488
- }
67937
+ volatileStateFields: ["lastFetchedAt"]
67489
67938
  };
67490
67939
  /**
67491
67940
  * toast — system-scoped singleton capability that streams toast
@@ -67768,16 +68217,16 @@ var VacuumControlStatusSchema = object({
67768
68217
  ]),
67769
68218
  /** 0..100 battery percentage. Null when the device has no battery
67770
68219
  * reading. */
67771
- batteryLevel: number().min(0).max(100).nullable(),
68220
+ batteryLevel: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
67772
68221
  /** Current fan-speed token (provider-verbatim). Null when unknown or
67773
68222
  * the vacuum has no speed control. */
67774
68223
  fanSpeed: string().nullable(),
67775
68224
  /** Speed tokens the hardware accepts — drives the UI selector. */
67776
68225
  availableFanSpeeds: array(string()),
67777
68226
  /** 0..100 cleaning-completion percentage of the current task, or null. */
67778
- progressPercent: number().min(0).max(100).nullable(),
68227
+ progressPercent: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
67779
68228
  /** Current error code (0 / null = no error). */
67780
- errorCode: number().nullable(),
68229
+ errorCode: number().nullable().meta(readingMeta("none")),
67781
68230
  /** Human label for {@link errorCode}, or null when none / undecodable. */
67782
68231
  errorLabel: string().nullable(),
67783
68232
  /** Ms epoch when the slice was last updated. */
@@ -67855,7 +68304,7 @@ var ValveStatusSchema = object({
67855
68304
  ]),
67856
68305
  /** 0 = fully closed, 100 = fully open. Null when the device has no
67857
68306
  * intermediate position surface. */
67858
- position: number().min(0).max(100).nullable(),
68307
+ position: number().min(0).max(100).nullable().meta(readingMeta("percentage")),
67859
68308
  /** Ms epoch when the slice was last updated. */
67860
68309
  lastChangedAt: number()
67861
68310
  });
@@ -67963,9 +68412,9 @@ var vibrationCapability = {
67963
68412
  */
67964
68413
  var WaterHeaterStatusSchema = object({
67965
68414
  /** Current measured temperature. Null when not reported. */
67966
- currentTemp: number().nullable(),
68415
+ currentTemp: number().nullable().meta(readingMeta("temperature")),
67967
68416
  /** Target temperature setpoint. Null when no setpoint surface. */
67968
- targetTemp: number().nullable(),
68417
+ targetTemp: number().nullable().meta(readingMeta("temperature")),
67969
68418
  /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
67970
68419
  * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
67971
68420
  * device reports an unknown state. */
@@ -67976,9 +68425,9 @@ var WaterHeaterStatusSchema = object({
67976
68425
  * has no away surface. */
67977
68426
  away: boolean().nullable(),
67978
68427
  /** HA `min_temp` attribute. Null when not reported. */
67979
- minTemp: number().nullable(),
68428
+ minTemp: number().nullable().meta(readingMeta("temperature")),
67980
68429
  /** HA `max_temp` attribute. Null when not reported. */
67981
- maxTemp: number().nullable(),
68430
+ maxTemp: number().nullable().meta(readingMeta("temperature")),
67982
68431
  /** Ms epoch when the slice was last updated. */
67983
68432
  lastChangedAt: number()
67984
68433
  });
@@ -68063,25 +68512,18 @@ var waterHeaterCapability = {
68063
68512
  * Forecast deferred — current conditions only for v1.
68064
68513
  */
68065
68514
  var WeatherStatusSchema = object({
68066
- /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
68067
- * when no condition has been reported yet. */
68515
+ /** Verbatim condition state (`sunny`, `cloudy`, `rainy`, …). Null when none yet. */
68068
68516
  condition: string().nullable(),
68069
- /** Current temperature in the reported unit. Null when not provided. */
68070
- temperature: number().nullable(),
68071
- /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
68072
- temperatureUnit: string().nullable(),
68517
+ /** Current temperature in Celsius. Null when not provided or refused. */
68518
+ temperature: number().nullable().meta(readingMeta("temperature", { precision: 1 })),
68073
68519
  /** Relative humidity (0..100). Null when not provided. */
68074
- humidity: number().min(0).max(100).nullable(),
68075
- /** Barometric pressure in the reported unit. Null when not provided. */
68076
- pressure: number().nullable(),
68077
- /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
68078
- pressureUnit: string().nullable(),
68079
- /** Wind speed in the reported unit. Null when not provided. */
68080
- windSpeed: number().nullable(),
68081
- /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
68082
- windSpeedUnit: string().nullable(),
68520
+ humidity: number().min(0).max(100).nullable().meta(readingMeta("humidity")),
68521
+ /** Barometric pressure in hPa. Null when not provided or refused. */
68522
+ pressure: number().nullable().meta(readingMeta("pressure")),
68523
+ /** Wind speed in metres per second. Null when not provided or refused. */
68524
+ windSpeed: number().nullable().meta(readingMeta("speed")),
68083
68525
  /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
68084
- windBearing: number().nullable(),
68526
+ windBearing: number().nullable().meta(readingMeta("angle")),
68085
68527
  /** Ms epoch when the slice was last updated. */
68086
68528
  lastFetchedAt: number()
68087
68529
  });
@@ -68109,12 +68551,12 @@ var weatherCapability = {
68109
68551
  */
68110
68552
  durability: "session",
68111
68553
  /**
68112
- * History (D850): recorded series. Units follow the sibling `*Unit` fields.
68554
+ * History (D850): recorded series. Numbers are the quantity base (D944).
68113
68555
  */
68114
68556
  history: { fields: {
68115
68557
  temperature: {
68116
68558
  kind: "gauge",
68117
- unitFrom: "temperatureUnit",
68559
+ unit: "°C",
68118
68560
  deadband: .2
68119
68561
  },
68120
68562
  humidity: {
@@ -68124,12 +68566,12 @@ var weatherCapability = {
68124
68566
  },
68125
68567
  pressure: {
68126
68568
  kind: "gauge",
68127
- unitFrom: "pressureUnit",
68569
+ unit: "hPa",
68128
68570
  deadband: .5
68129
68571
  },
68130
68572
  windSpeed: {
68131
68573
  kind: "gauge",
68132
- unitFrom: "windSpeedUnit",
68574
+ unit: "m/s",
68133
68575
  deadband: .5
68134
68576
  }
68135
68577
  } }
@@ -69030,6 +69472,389 @@ method(object({}), array(TrainingBaseModelSchema).readonly(), { auth: "admin" })
69030
69472
  kind: "mutation",
69031
69473
  auth: "admin"
69032
69474
  });
69475
+ /**
69476
+ * Frozen unit → spec table. `•` in the doc marks each dimension's canonical
69477
+ * unit (`factor: 1, offset: 0`). °F derives from `°C = (°F − 32) × 5/9` ⇒
69478
+ * `factor = 5/9`, `offset = -160/9` (kept as exact expressions, not rounded
69479
+ * decimals). K derives from `°C = K − 273.15`.
69480
+ */
69481
+ var UNIT_TABLE = {
69482
+ "°C": {
69483
+ dimension: "temperature",
69484
+ factor: 1,
69485
+ offset: 0
69486
+ },
69487
+ "°F": {
69488
+ dimension: "temperature",
69489
+ factor: 5 / 9,
69490
+ offset: -160 / 9
69491
+ },
69492
+ K: {
69493
+ dimension: "temperature",
69494
+ factor: 1,
69495
+ offset: -273.15
69496
+ },
69497
+ hPa: {
69498
+ dimension: "pressure",
69499
+ factor: 1,
69500
+ offset: 0
69501
+ },
69502
+ kPa: {
69503
+ dimension: "pressure",
69504
+ factor: 10,
69505
+ offset: 0
69506
+ },
69507
+ Pa: {
69508
+ dimension: "pressure",
69509
+ factor: .01,
69510
+ offset: 0
69511
+ },
69512
+ mbar: {
69513
+ dimension: "pressure",
69514
+ factor: 1,
69515
+ offset: 0
69516
+ },
69517
+ bar: {
69518
+ dimension: "pressure",
69519
+ factor: 1e3,
69520
+ offset: 0
69521
+ },
69522
+ inHg: {
69523
+ dimension: "pressure",
69524
+ factor: 33.8639,
69525
+ offset: 0
69526
+ },
69527
+ mmHg: {
69528
+ dimension: "pressure",
69529
+ factor: 1.33322,
69530
+ offset: 0
69531
+ },
69532
+ psi: {
69533
+ dimension: "pressure",
69534
+ factor: 68.9476,
69535
+ offset: 0
69536
+ },
69537
+ "m/s": {
69538
+ dimension: "speed",
69539
+ factor: 1,
69540
+ offset: 0
69541
+ },
69542
+ "km/h": {
69543
+ dimension: "speed",
69544
+ factor: 1 / 3.6,
69545
+ offset: 0
69546
+ },
69547
+ mph: {
69548
+ dimension: "speed",
69549
+ factor: .44704,
69550
+ offset: 0
69551
+ },
69552
+ kn: {
69553
+ dimension: "speed",
69554
+ factor: .514444,
69555
+ offset: 0
69556
+ },
69557
+ "mm/h": {
69558
+ dimension: "precipitation-rate",
69559
+ factor: 1,
69560
+ offset: 0
69561
+ },
69562
+ "in/h": {
69563
+ dimension: "precipitation-rate",
69564
+ factor: 25.4,
69565
+ offset: 0
69566
+ },
69567
+ m: {
69568
+ dimension: "length",
69569
+ factor: 1,
69570
+ offset: 0
69571
+ },
69572
+ mm: {
69573
+ dimension: "length",
69574
+ factor: .001,
69575
+ offset: 0
69576
+ },
69577
+ cm: {
69578
+ dimension: "length",
69579
+ factor: .01,
69580
+ offset: 0
69581
+ },
69582
+ km: {
69583
+ dimension: "length",
69584
+ factor: 1e3,
69585
+ offset: 0
69586
+ },
69587
+ in: {
69588
+ dimension: "length",
69589
+ factor: .0254,
69590
+ offset: 0
69591
+ },
69592
+ ft: {
69593
+ dimension: "length",
69594
+ factor: .3048,
69595
+ offset: 0
69596
+ },
69597
+ mi: {
69598
+ dimension: "length",
69599
+ factor: 1609.344,
69600
+ offset: 0
69601
+ },
69602
+ "m²": {
69603
+ dimension: "area",
69604
+ factor: 1,
69605
+ offset: 0
69606
+ },
69607
+ lx: {
69608
+ dimension: "illuminance",
69609
+ factor: 1,
69610
+ offset: 0
69611
+ },
69612
+ "W/m²": {
69613
+ dimension: "irradiance",
69614
+ factor: 1,
69615
+ offset: 0
69616
+ },
69617
+ W: {
69618
+ dimension: "power",
69619
+ factor: 1,
69620
+ offset: 0
69621
+ },
69622
+ kW: {
69623
+ dimension: "power",
69624
+ factor: 1e3,
69625
+ offset: 0
69626
+ },
69627
+ VA: {
69628
+ dimension: "apparent-power",
69629
+ factor: 1,
69630
+ offset: 0
69631
+ },
69632
+ Wh: {
69633
+ dimension: "energy",
69634
+ factor: 1,
69635
+ offset: 0
69636
+ },
69637
+ kWh: {
69638
+ dimension: "energy",
69639
+ factor: 1e3,
69640
+ offset: 0
69641
+ },
69642
+ MWh: {
69643
+ dimension: "energy",
69644
+ factor: 1e6,
69645
+ offset: 0
69646
+ },
69647
+ V: {
69648
+ dimension: "voltage",
69649
+ factor: 1,
69650
+ offset: 0
69651
+ },
69652
+ mV: {
69653
+ dimension: "voltage",
69654
+ factor: .001,
69655
+ offset: 0
69656
+ },
69657
+ A: {
69658
+ dimension: "current",
69659
+ factor: 1,
69660
+ offset: 0
69661
+ },
69662
+ mA: {
69663
+ dimension: "current",
69664
+ factor: .001,
69665
+ offset: 0
69666
+ },
69667
+ ppm: {
69668
+ dimension: "concentration-volume",
69669
+ factor: 1,
69670
+ offset: 0
69671
+ },
69672
+ ppb: {
69673
+ dimension: "concentration-volume",
69674
+ factor: .001,
69675
+ offset: 0
69676
+ },
69677
+ "µg/m³": {
69678
+ dimension: "concentration-mass",
69679
+ factor: 1,
69680
+ offset: 0
69681
+ },
69682
+ "mg/m³": {
69683
+ dimension: "concentration-mass",
69684
+ factor: 1e3,
69685
+ offset: 0
69686
+ },
69687
+ kg: {
69688
+ dimension: "mass",
69689
+ factor: 1,
69690
+ offset: 0
69691
+ },
69692
+ g: {
69693
+ dimension: "mass",
69694
+ factor: .001,
69695
+ offset: 0
69696
+ },
69697
+ lb: {
69698
+ dimension: "mass",
69699
+ factor: .453592,
69700
+ offset: 0
69701
+ },
69702
+ oz: {
69703
+ dimension: "mass",
69704
+ factor: .0283495,
69705
+ offset: 0
69706
+ },
69707
+ "%": {
69708
+ dimension: "percentage",
69709
+ factor: 1,
69710
+ offset: 0
69711
+ },
69712
+ L: {
69713
+ dimension: "volume",
69714
+ factor: 1,
69715
+ offset: 0
69716
+ },
69717
+ mL: {
69718
+ dimension: "volume",
69719
+ factor: .001,
69720
+ offset: 0
69721
+ },
69722
+ gal: {
69723
+ dimension: "volume",
69724
+ factor: 3.785411784,
69725
+ offset: 0
69726
+ },
69727
+ s: {
69728
+ dimension: "duration",
69729
+ factor: 1,
69730
+ offset: 0
69731
+ },
69732
+ ms: {
69733
+ dimension: "duration",
69734
+ factor: .001,
69735
+ offset: 0
69736
+ },
69737
+ min: {
69738
+ dimension: "duration",
69739
+ factor: 60,
69740
+ offset: 0
69741
+ },
69742
+ h: {
69743
+ dimension: "duration",
69744
+ factor: 3600,
69745
+ offset: 0
69746
+ },
69747
+ d: {
69748
+ dimension: "duration",
69749
+ factor: 86400,
69750
+ offset: 0
69751
+ },
69752
+ B: {
69753
+ dimension: "information",
69754
+ factor: 1,
69755
+ offset: 0
69756
+ },
69757
+ KiB: {
69758
+ dimension: "information",
69759
+ factor: 1024,
69760
+ offset: 0
69761
+ },
69762
+ MiB: {
69763
+ dimension: "information",
69764
+ factor: 1024 ** 2,
69765
+ offset: 0
69766
+ },
69767
+ GiB: {
69768
+ dimension: "information",
69769
+ factor: 1024 ** 3,
69770
+ offset: 0
69771
+ },
69772
+ TiB: {
69773
+ dimension: "information",
69774
+ factor: 1024 ** 4,
69775
+ offset: 0
69776
+ },
69777
+ kB: {
69778
+ dimension: "information",
69779
+ factor: 1e3,
69780
+ offset: 0
69781
+ },
69782
+ MB: {
69783
+ dimension: "information",
69784
+ factor: 1e6,
69785
+ offset: 0
69786
+ },
69787
+ GB: {
69788
+ dimension: "information",
69789
+ factor: 1e9,
69790
+ offset: 0
69791
+ },
69792
+ "°": {
69793
+ dimension: "angle",
69794
+ factor: 1,
69795
+ offset: 0
69796
+ },
69797
+ dBFS: {
69798
+ dimension: "sound-level",
69799
+ factor: 1,
69800
+ offset: 0
69801
+ },
69802
+ dBm: {
69803
+ dimension: "signal-level",
69804
+ factor: 1,
69805
+ offset: 0
69806
+ },
69807
+ mired: {
69808
+ dimension: "colour-temperature",
69809
+ factor: 1,
69810
+ offset: 0
69811
+ },
69812
+ kbps: {
69813
+ dimension: "bitrate",
69814
+ factor: 1,
69815
+ offset: 0
69816
+ },
69817
+ Mbps: {
69818
+ dimension: "bitrate",
69819
+ factor: 1e3,
69820
+ offset: 0
69821
+ },
69822
+ fps: {
69823
+ dimension: "rate",
69824
+ factor: 1,
69825
+ offset: 0
69826
+ }
69827
+ };
69828
+ /** Resolve a unit's `UnitSpec`, or `undefined` when the spelling is unknown. */
69829
+ function specFor(unit) {
69830
+ return Object.hasOwn(UNIT_TABLE, unit) ? UNIT_TABLE[unit] : void 0;
69831
+ }
69832
+ /** The dimension a unit belongs to, or `undefined` for an unknown unit. */
69833
+ function unitDimension(unit) {
69834
+ return specFor(unit)?.dimension;
69835
+ }
69836
+ /** Value in `spec`'s unit → its dimension's canonical unit. */
69837
+ function toCanonical(value, spec) {
69838
+ return value * spec.factor + spec.offset;
69839
+ }
69840
+ /** Canonical-unit value → the unit described by `spec`. */
69841
+ function fromCanonical(canonical, spec) {
69842
+ return (canonical - spec.offset) / spec.factor;
69843
+ }
69844
+ /**
69845
+ * Graceful variant for the render path: returns the converted value, or
69846
+ * `undefined` when the conversion is refused (unknown unit / cross-dimension).
69847
+ * Never throws — a render caller falls back to the raw value + source unit.
69848
+ */
69849
+ function tryConvertUnit(value, from, to) {
69850
+ if (from === to) return value;
69851
+ const fromSpec = specFor(from);
69852
+ if (!fromSpec) return void 0;
69853
+ const toSpec = specFor(to);
69854
+ if (!toSpec) return void 0;
69855
+ if (fromSpec.dimension !== toSpec.dimension) return void 0;
69856
+ return fromCanonical(toCanonical(value, fromSpec), toSpec);
69857
+ }
69033
69858
  SOURCE_CAPS.filter((cap) => cap !== "switch" && cap !== "motion");
69034
69859
  DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch, DeviceType.PetFeeder, DeviceType.Container, DeviceType.Thermostat, DeviceType.Camera, DeviceType.Hub, DeviceType.Light, DeviceType.Siren, DeviceType.Switch, DeviceType.Sensor, DeviceType.Thermostat, DeviceType.Climate, DeviceType.Button, DeviceType.EventEmitter, DeviceType.Update, DeviceType.Generic, DeviceType.Notifier, DeviceType.Script, DeviceType.Automation, DeviceType.Lock, DeviceType.Cover, DeviceType.Valve, DeviceType.Humidifier, DeviceType.WaterHeater, DeviceType.Fan, DeviceType.MediaPlayer, DeviceType.AlarmPanel, DeviceType.Control, DeviceType.Presence, DeviceType.Weather, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.Container, DeviceType.Image, DeviceType.PetFeeder, DeviceType.Dishwasher, DeviceType.Camera, DeviceType.Camera, DeviceType.Dishwasher, DeviceType.Vacuum, DeviceType.LawnMower, DeviceType.MediaPlayer, DeviceType.WaterHeater, DeviceType.Humidifier, DeviceType.Fan, DeviceType.Climate, DeviceType.Thermostat, DeviceType.PetFeeder;
69035
69860
  String.raw`rose\(active(\d+), changed(\d+), (\d+(?:\.\d+)?)\)`;
@@ -69360,6 +70185,8 @@ var DEVICE_LOCAL_STATE_CAPS = {
69360
70185
  featureProbe: featureProbeCapability,
69361
70186
  flood: floodCapability,
69362
70187
  gas: gasCapability,
70188
+ groupCommands: groupCommandsCapability,
70189
+ groupStatus: groupStatusCapability,
69363
70190
  heatingValve: heatingValveCapability,
69364
70191
  humidifier: humidifierCapability,
69365
70192
  humiditySensor: humiditySensorCapability,
@@ -70736,6 +71563,13 @@ Object.freeze({
70736
71563
  access: "view",
70737
71564
  role: "admin"
70738
71565
  },
71566
+ "addons.getPackageDocs": {
71567
+ capName: "addons",
71568
+ capScope: "system",
71569
+ addonId: null,
71570
+ access: "view",
71571
+ role: "admin"
71572
+ },
70739
71573
  "addons.getVersions": {
70740
71574
  capName: "addons",
70741
71575
  capScope: "system",
@@ -73844,6 +74678,20 @@ Object.freeze({
73844
74678
  access: "view",
73845
74679
  role: "viewer"
73846
74680
  },
74681
+ "groupCommands.getStatus": {
74682
+ capName: "group-commands",
74683
+ capScope: "device",
74684
+ addonId: null,
74685
+ access: "view",
74686
+ role: "viewer"
74687
+ },
74688
+ "groupStatus.getStatus": {
74689
+ capName: "group-status",
74690
+ capScope: "device",
74691
+ addonId: null,
74692
+ access: "view",
74693
+ role: "viewer"
74694
+ },
73847
74695
  "heatingValve.getStatus": {
73848
74696
  capName: "heating-valve",
73849
74697
  capScope: "device",
@@ -83666,6 +84514,37 @@ var SENSOR_CAP_VOCABULARY = {
83666
84514
  icon: "flame"
83667
84515
  }]
83668
84516
  },
84517
+ "group-commands": {
84518
+ scope: "device",
84519
+ noun: t("Group command", "Comando di gruppo")
84520
+ },
84521
+ "group-status": {
84522
+ scope: "device",
84523
+ noun: t("Group", "Gruppo"),
84524
+ state: [
84525
+ {
84526
+ kind: "numeric",
84527
+ field: "included",
84528
+ quantity: t("the members reporting", "i membri che rispondono"),
84529
+ label: t("Reporting", "Rispondono"),
84530
+ icon: "users"
84531
+ },
84532
+ {
84533
+ kind: "numeric",
84534
+ field: "excluded",
84535
+ quantity: t("the members left out", "i membri esclusi"),
84536
+ label: t("Excluded", "Esclusi"),
84537
+ icon: "users"
84538
+ },
84539
+ {
84540
+ kind: "numeric",
84541
+ field: "total",
84542
+ quantity: t("the member count", "il numero di membri"),
84543
+ label: t("Members", "Membri"),
84544
+ icon: "sigma"
84545
+ }
84546
+ ]
84547
+ },
83669
84548
  "humidity-sensor": {
83670
84549
  scope: "device",
83671
84550
  noun: t("Humidity", "Umidità"),
@@ -83699,7 +84578,6 @@ var SENSOR_CAP_VOCABULARY = {
83699
84578
  field: "value",
83700
84579
  quantity: t("the reading", "il valore"),
83701
84580
  label: t("Reading", "Valore"),
83702
- unitField: "unit",
83703
84581
  icon: "gauge"
83704
84582
  }]
83705
84583
  },
@@ -83835,7 +84713,7 @@ var SENSOR_CAP_VOCABULARY = {
83835
84713
  field: "temperature",
83836
84714
  quantity: t("the outdoor temperature", "la temperatura esterna"),
83837
84715
  label: t("Temperature", "Temperatura"),
83838
- unitField: "temperatureUnit",
84716
+ unit: "°C",
83839
84717
  icon: "thermometer"
83840
84718
  },
83841
84719
  {
@@ -83851,7 +84729,7 @@ var SENSOR_CAP_VOCABULARY = {
83851
84729
  field: "windSpeed",
83852
84730
  quantity: t("the wind speed", "la velocità del vento"),
83853
84731
  label: t("Wind", "Vento"),
83854
- unitField: "windSpeedUnit",
84732
+ unit: "m/s",
83855
84733
  icon: "wind"
83856
84734
  },
83857
84735
  {
@@ -83859,7 +84737,7 @@ var SENSOR_CAP_VOCABULARY = {
83859
84737
  field: "pressure",
83860
84738
  quantity: t("the air pressure", "la pressione atmosferica"),
83861
84739
  label: t("Pressure", "Pressione"),
83862
- unitField: "pressureUnit",
84740
+ unit: "hPa",
83863
84741
  icon: "gauge"
83864
84742
  }
83865
84743
  ]
@@ -85132,6 +86010,16 @@ Object.freeze({
85132
86010
  form: "single",
85133
86011
  optional: false
85134
86012
  }],
86013
+ "groupCommands.getStatus": [{
86014
+ name: "deviceId",
86015
+ form: "single",
86016
+ optional: false
86017
+ }],
86018
+ "groupStatus.getStatus": [{
86019
+ name: "deviceId",
86020
+ form: "single",
86021
+ optional: false
86022
+ }],
85135
86023
  "heatingValve.getStatus": [{
85136
86024
  name: "deviceId",
85137
86025
  form: "single",
@@ -87116,6 +88004,8 @@ new Map(Object.entries({
87116
88004
  "detection-blind": "device-events",
87117
88005
  "motion-noisy": "device-events",
87118
88006
  "motion-calm": "device-events",
88007
+ "package-llm-silent": "device-events",
88008
+ "package-llm-answered": "device-events",
87119
88009
  "recording-stalled": "device-events",
87120
88010
  "recording-healthy": "device-events",
87121
88011
  "recording-degraded": "device-events",
@@ -87199,6 +88089,12 @@ var NC_SYSTEM_EVENT_KIND_GROUP_SPECS = [
87199
88089
  label: "Motion without a subject",
87200
88090
  kinds: ["motion-noisy", "motion-calm"]
87201
88091
  },
88092
+ {
88093
+ id: "package-llm",
88094
+ family: "device-events",
88095
+ label: "Package confirm",
88096
+ kinds: ["package-llm-silent", "package-llm-answered"]
88097
+ },
87202
88098
  {
87203
88099
  id: "recording",
87204
88100
  family: "device-events",
@@ -87380,6 +88276,8 @@ var DEVICE_EVENTS = [
87380
88276
  "detection-blind",
87381
88277
  "motion-noisy",
87382
88278
  "motion-calm",
88279
+ "package-llm-silent",
88280
+ "package-llm-answered",
87383
88281
  "recording-stalled",
87384
88282
  "recording-healthy",
87385
88283
  "recording-degraded",
@@ -87648,6 +88546,15 @@ new Map([
87648
88546
  snapshotLabel: "Cameras with constant motion now",
87649
88547
  snapshotSection: null
87650
88548
  },
88549
+ {
88550
+ id: "package-llm-confirm",
88551
+ family: "device-events",
88552
+ label: "Package confirm (not answering / answering again)",
88553
+ bad: ["package-llm-silent"],
88554
+ recovery: ["package-llm-answered"],
88555
+ snapshotLabel: "Cameras whose package confirm is not answering",
88556
+ snapshotSection: null
88557
+ },
87651
88558
  {
87652
88559
  id: "recording-health",
87653
88560
  family: "device-events",
@@ -88224,6 +89131,96 @@ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
88224
89131
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
88225
89132
  DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
88226
89133
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
89134
+ /**
89135
+ * Convert an upstream number into the stored base of its quantity (D944 §4).
89136
+ * An unknown unit or a unit from another dimension is a refusal: the caller
89137
+ * logs it and publishes null (D393). This function never guesses.
89138
+ */
89139
+ var DIMENSION_QUANTITY = {
89140
+ temperature: "temperature",
89141
+ pressure: "pressure",
89142
+ speed: "speed",
89143
+ "precipitation-rate": "precipitation-rate",
89144
+ length: "length",
89145
+ area: "area",
89146
+ illuminance: "illuminance",
89147
+ irradiance: "irradiance",
89148
+ power: "power",
89149
+ "apparent-power": "apparent-power",
89150
+ energy: "energy",
89151
+ voltage: "voltage",
89152
+ current: "current",
89153
+ "concentration-volume": "concentration-volume",
89154
+ "concentration-mass": "concentration-mass",
89155
+ mass: "mass",
89156
+ percentage: "percentage",
89157
+ volume: "volume",
89158
+ duration: "duration",
89159
+ information: "information",
89160
+ angle: "angle",
89161
+ "sound-level": "sound-level",
89162
+ "signal-level": "signal-level",
89163
+ "colour-temperature": "colour-temperature",
89164
+ bitrate: "bitrate",
89165
+ rate: "rate"
89166
+ };
89167
+ /**
89168
+ * Quantity implied by a unit symbol. `ppb` and `ms` are the two symbols
89169
+ * whose stored base is not the dimension's canonical unit. `mm` is length;
89170
+ * precipitation is chosen by the caller (a device class), not by the symbol.
89171
+ */
89172
+ function quantityFromUnit(rawUnit) {
89173
+ const unit = normalizeUnit(rawUnit);
89174
+ if (unit === void 0) return void 0;
89175
+ if (unit === "ppb") return "concentration-ppb";
89176
+ if (unit === "ms") return "duration-ms";
89177
+ const dimension = unitDimension(unit);
89178
+ if (dimension === void 0) return void 0;
89179
+ return DIMENSION_QUANTITY[dimension];
89180
+ }
89181
+ function ingestReading(input) {
89182
+ if (!Number.isFinite(input.value)) return {
89183
+ ok: false,
89184
+ reason: "non-finite"
89185
+ };
89186
+ if (input.quantity === "device") return {
89187
+ ok: false,
89188
+ reason: "unknown-unit"
89189
+ };
89190
+ const raw = input.rawUnit?.trim();
89191
+ if (raw === void 0 || raw.length === 0) return {
89192
+ ok: true,
89193
+ value: input.value
89194
+ };
89195
+ const unit = normalizeUnit(raw);
89196
+ if (unit === void 0) return {
89197
+ ok: false,
89198
+ reason: "unknown-unit"
89199
+ };
89200
+ const base = baseUnitOf(input.quantity);
89201
+ if (base === void 0) {
89202
+ if (unitDimension(unit) !== void 0) return {
89203
+ ok: false,
89204
+ reason: "cross-dimension"
89205
+ };
89206
+ return {
89207
+ ok: false,
89208
+ reason: "unknown-unit"
89209
+ };
89210
+ }
89211
+ const converted = tryConvertUnit(input.value, unit, base);
89212
+ if (converted === void 0) return unitDimension(unit) === void 0 ? {
89213
+ ok: false,
89214
+ reason: "unknown-unit"
89215
+ } : {
89216
+ ok: false,
89217
+ reason: "cross-dimension"
89218
+ };
89219
+ return {
89220
+ ok: true,
89221
+ value: converted
89222
+ };
89223
+ }
88227
89224
  var MB = 1024 * 1024;
88228
89225
  1024 * MB, 3072 * MB;
88229
89226
  //#endregion
@@ -94610,6 +95607,23 @@ function capForQuantity(quantity) {
94610
95607
  * the generic `numeric-sensor` cap but carry a typed role, so the UI renders a
94611
95608
  * wind / rain / UV / solar glyph instead of a bare hash.
94612
95609
  */
95610
+ /** Ecowitt quantity → the stored reading quantity. Unknown names return null. */
95611
+ function readingQuantityFor(quantity) {
95612
+ switch (quantity) {
95613
+ case "temperature": return "temperature";
95614
+ case "humidity": return "humidity";
95615
+ case "pressure": return "pressure";
95616
+ case "wind_speed": return "speed";
95617
+ case "wind_direction": return "angle";
95618
+ case "precipitation": return "precipitation";
95619
+ case "precipitation_rate": return "precipitation-rate";
95620
+ case "uv": return "index";
95621
+ case "irradiance": return "irradiance";
95622
+ case "lightning_distance": return "length";
95623
+ case "lightning_count": return "count";
95624
+ default: return null;
95625
+ }
95626
+ }
94613
95627
  function roleForQuantity(quantity) {
94614
95628
  switch (quantity) {
94615
95629
  case "temperature": return DeviceRole.TemperatureSensor;
@@ -94868,13 +95882,6 @@ var ecowittSensorSchema = object({
94868
95882
  system: literal("ecowitt").optional(),
94869
95883
  integrationId: string().optional()
94870
95884
  });
94871
- /**
94872
- * Slice key the V-battery `numeric-sensor` cap writes/reads. It is the cap's own
94873
- * name (`'numeric-sensor'`) — the same key the codegen `getStatus` reads — so
94874
- * the manual `setCapState` here lands in the slice the framework serves. Only
94875
- * registered when the PRIMARY cap is NOT itself `numeric-sensor` (no collision).
94876
- */
94877
- var BATTERY_VOLTAGE_CAP = numericSensorCapability.name;
94878
95885
  var SUPPORTED_CAPS = new Set([
94879
95886
  "temperature-sensor",
94880
95887
  "humidity-sensor",
@@ -94893,16 +95900,10 @@ var SUPPORTED_CAPS = new Set([
94893
95900
  * library discriminates two battery forms via `batteryUnit`:
94894
95901
  * - `'%'` (binary/bar batteries, already decoded to a percent) → the typed
94895
95902
  * `battery` cap (which models a 0..100 percentage).
94896
- * - `'V'` (raw voltage, e.g. WS90/WH90/WS85 capacitor + soil/leaf push) → a
94897
- * `numeric-sensor` cap carrying the voltage with `unit: 'V'`. A voltage has
94898
- * no honest percentage, so the percentage-shaped `battery` cap would be
94899
- * wrong; the generic numeric cap renders the volts verbatim.
94900
- *
94901
- * A child registers AT MOST one battery cap. When the child's PRIMARY sensor cap
94902
- * is itself `numeric-sensor` (e.g. a precipitation channel) a V-battery cannot
94903
- * also be hosted as a second `numeric-sensor` on the same device (the cap slice
94904
- * is keyed by cap name) — that rare case is skipped with a debug note rather
94905
- * than adding a new cap.
95903
+ * - `'V'` (raw voltage, e.g. WS90/WH90/WS85 capacitor + soil/leaf push) →
95904
+ * `battery.volts`. A voltage has no honest percentage, so `percentage`
95905
+ * stays null. The field lives on `battery`, so a numeric primary sensor
95906
+ * and a voltage battery do not share a slice.
94906
95907
  */
94907
95908
  var EcowittSensorDevice = class extends BaseDevice {
94908
95909
  features = [];
@@ -94910,12 +95911,12 @@ var EcowittSensorDevice = class extends BaseDevice {
94910
95911
  gatewayId;
94911
95912
  capName;
94912
95913
  valueKey;
95914
+ ecowittQuantity;
94913
95915
  /**
94914
95916
  * Which battery cap (if any) was registered for this sensor:
94915
95917
  * - `'percent'` → the typed `battery` cap (percentage),
94916
- * - `'voltage'` → a `numeric-sensor` cap carrying volts (unit `'V'`),
94917
- * - `'none'` → no battery cap (no battery, or a V-battery on a sensor
94918
- * whose primary cap is already `numeric-sensor`).
95918
+ * - `'voltage'` → `battery.volts` (percentage stays null),
95919
+ * - `'none'` → no battery cap.
94919
95920
  */
94920
95921
  batteryMode = "none";
94921
95922
  snapshotUnsub = null;
@@ -94927,10 +95928,13 @@ var EcowittSensorDevice = class extends BaseDevice {
94927
95928
  const cap = capForQuantity(persisted.quantity);
94928
95929
  this.capName = SUPPORTED_CAPS.has(cap) ? cap : "numeric-sensor";
94929
95930
  this.valueKey = valueFieldForCap(this.capName);
95931
+ this.ecowittQuantity = String(persisted.quantity);
94930
95932
  this.online = true;
95933
+ const named = readingQuantityFor(persisted.quantity);
94931
95934
  this.updateSourceInfo({
94932
95935
  id: this.sensorId,
94933
- system: "ecowitt"
95936
+ system: "ecowitt",
95937
+ ...this.capName === "numeric-sensor" && named !== null ? { quantity: named } : {}
94934
95938
  });
94935
95939
  }
94936
95940
  resolveSensor() {
@@ -94974,10 +95978,10 @@ var EcowittSensorDevice = class extends BaseDevice {
94974
95978
  const sensor = this.resolveSensor();
94975
95979
  if (sensor === null) return;
94976
95980
  const lastFetchedAt = sensor.lastUpdated || Date.now();
95981
+ const stored = this.toBase(sensor.value, sensor.unit);
94977
95982
  const slice = {
94978
- [this.valueKey]: clampForCap(this.capName, sensor.value),
94979
- lastFetchedAt,
94980
- ...sensor.unit.length > 0 ? { unit: sensor.unit } : {}
95983
+ [this.valueKey]: stored === null ? null : clampForCap(this.capName, stored),
95984
+ lastFetchedAt
94981
95985
  };
94982
95986
  this.runtimeState.setCapState(this.capName, slice);
94983
95987
  if (this.batteryMode === "percent" && sensor.batteryUnit === "%" && typeof sensor.battery === "number") {
@@ -94991,11 +95995,13 @@ var EcowittSensorDevice = class extends BaseDevice {
94991
95995
  }
94992
95996
  if (this.batteryMode === "voltage" && sensor.batteryUnit === "V" && typeof sensor.battery === "number" && Number.isFinite(sensor.battery)) {
94993
95997
  const next = {
94994
- value: sensor.battery,
94995
- unit: "V",
94996
- lastFetchedAt
95998
+ percentage: null,
95999
+ volts: sensor.battery,
96000
+ charging: "none",
96001
+ sleeping: false,
96002
+ lastUpdated: lastFetchedAt
94997
96003
  };
94998
- this.runtimeState.setCapState(BATTERY_VOLTAGE_CAP, next);
96004
+ this.runtimeState.setCapState("battery", next);
94999
96005
  }
95000
96006
  }
95001
96007
  registerSensorCap() {
@@ -95048,11 +96054,8 @@ var EcowittSensorDevice = class extends BaseDevice {
95048
96054
  }
95049
96055
  }
95050
96056
  /**
95051
- * Register the per-sensor battery cap matching the sensor's battery form:
95052
- * - `'%'` → typed `battery` cap (percentage),
95053
- * - `'V'` → `numeric-sensor` cap carrying volts (unit `'V'`), unless the
95054
- * sensor's primary cap is already `numeric-sensor` (slice-key collision),
95055
- * in which case it is skipped with a debug note.
96057
+ * Register the `battery` cap. Percent batteries fill `percentage`. Voltage
96058
+ * batteries fill `volts` and leave `percentage` null.
95056
96059
  */
95057
96060
  maybeRegisterBatteryCap() {
95058
96061
  const sensor = this.resolveSensor();
@@ -95079,29 +96082,63 @@ var EcowittSensorDevice = class extends BaseDevice {
95079
96082
  this.runtimeState.setCapState("battery", COLD);
95080
96083
  this.batteryMode = "percent";
95081
96084
  }
95082
- /**
95083
- * Register a `numeric-sensor` (unit `'V'`) cap for a raw-voltage battery. The
95084
- * voltage has no honest percentage, so the percentage-shaped `battery` cap is
95085
- * not used. Skipped when the primary cap already occupies the `numeric-sensor`
95086
- * slice on this device (a second instance would collide).
95087
- */
96085
+ /** Voltage batteries share the `battery` cap. `percentage` stays unknown. */
95088
96086
  registerVoltageBatteryCap() {
95089
- if (this.capName === "numeric-sensor") {
95090
- this.ctx.logger.debug("ecowitt sensor: V-battery not surfaced — primary cap already numeric-sensor", { meta: { sensorId: this.sensorId } });
95091
- return;
95092
- }
95093
96087
  const COLD = {
95094
- value: 0,
95095
- unit: "V",
95096
- lastFetchedAt: 0
96088
+ percentage: null,
96089
+ volts: null,
96090
+ charging: "unknown",
96091
+ sleeping: false,
96092
+ lastUpdated: 0
95097
96093
  };
95098
- this.ctx.registerNativeCap(numericSensorCapability, { getStatus: async () => this.runtimeState.getCapState(BATTERY_VOLTAGE_CAP) ?? COLD });
95099
- this.runtimeState.setCapState(BATTERY_VOLTAGE_CAP, COLD);
96094
+ this.ctx.registerNativeCap(batteryCapability, {
96095
+ getStatus: async () => this.runtimeState.getCapState("battery") ?? COLD,
96096
+ wakeForStream: async () => ({
96097
+ awoke: true,
96098
+ durationMs: 0
96099
+ })
96100
+ });
96101
+ this.runtimeState.setCapState("battery", COLD);
95100
96102
  this.batteryMode = "voltage";
95101
96103
  }
95102
96104
  readSlice(fallback) {
95103
96105
  return this.runtimeState.getCapState(this.capName) ?? fallback;
95104
96106
  }
96107
+ /** Station number → the quantity's base. An unknown unit publishes null. */
96108
+ toBase(value, unit) {
96109
+ const named = readingQuantityFor(this.ecowittQuantity);
96110
+ const fromUnit = unit.length === 0 ? void 0 : quantityFromUnit(unit);
96111
+ const quantity = named ?? (unit.length === 0 ? "none" : fromUnit ?? null);
96112
+ if (quantity === null || quantity === "device") {
96113
+ this.ctx.logger.warn("numeric reading refused", {
96114
+ tags: { deviceId: this.id },
96115
+ meta: {
96116
+ quantity: "device",
96117
+ unit,
96118
+ reason: "unknown-unit"
96119
+ }
96120
+ });
96121
+ return null;
96122
+ }
96123
+ const result = ingestReading({
96124
+ value,
96125
+ rawUnit: unit.length > 0 ? unit : void 0,
96126
+ quantity
96127
+ });
96128
+ if (!result.ok) {
96129
+ this.ctx.logger.warn("numeric reading refused", {
96130
+ tags: { deviceId: this.id },
96131
+ meta: {
96132
+ quantity,
96133
+ unit,
96134
+ reason: result.reason
96135
+ }
96136
+ });
96137
+ return null;
96138
+ }
96139
+ if (this.capName === "numeric-sensor" && this.sourceInfo.quantity !== quantity) this.updateSourceInfo({ quantity });
96140
+ return result.value;
96141
+ }
95105
96142
  };
95106
96143
  /** Clamp a raw SI value to the cap's slice constraints (humidity/lux are bounded). */
95107
96144
  function clampForCap(cap, value) {