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