@camstack/addon-admin-ui 0.1.46 → 0.1.47

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.
Files changed (49) hide show
  1. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs-CRKSbdgN.js → __mfe_internal__admin_ui_host__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs-CawbgE5L.js} +1 -1
  2. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs_commonjs-proxy-BGPfTw3V.js → __mfe_internal__admin_ui_host__loadShare___mf_0_tanstack_mf_1_react_mf_2_query__loadShare__.mjs_commonjs-proxy-GKALXIcl.js} +1 -1
  3. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-jPpiOa0I.js → __mfe_internal__admin_ui_host__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs-JVZxVBTk.js} +1 -1
  4. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs_commonjs-proxy-DYNYuSL7.js → __mfe_internal__admin_ui_host__loadShare___mf_0_trpc_mf_1_react_mf_2_query__loadShare__.mjs_commonjs-proxy-fDAP1KhM.js} +1 -1
  5. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare__react__loadShare__.mjs-ij0ODm-n.js → __mfe_internal__admin_ui_host__loadShare__react__loadShare__.mjs-hk6goaRc.js} +1 -1
  6. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare__react__loadShare__.mjs_commonjs-proxy-C4Ko8zXp.js → __mfe_internal__admin_ui_host__loadShare__react__loadShare__.mjs_commonjs-proxy-CKOygvIQ.js} +1 -1
  7. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare__react_mf_2_dom__loadShare__.mjs-Cf5gSqc_.js → __mfe_internal__admin_ui_host__loadShare__react_mf_2_dom__loadShare__.mjs-DGvrhOC3.js} +1 -1
  8. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-CYaX3FDr.js → __mfe_internal__admin_ui_host__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-DG9hE2tk.js} +1 -1
  9. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-rHwdrynu.js → __mfe_internal__admin_ui_host__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs-ByazpYAv.js} +1 -1
  10. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs_commonjs-proxy-DRDS6j-Y.js → __mfe_internal__admin_ui_host__loadShare__react_mf_2_dom_mf_1_client__loadShare__.mjs_commonjs-proxy-CxaW4xr1.js} +1 -1
  11. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare__react_mf_2_konva__loadShare__.mjs-cUuqcEcL.js → __mfe_internal__admin_ui_host__loadShare__react_mf_2_konva__loadShare__.mjs-BLBZm4ju.js} +1 -1
  12. package/dist/assets/{__mfe_internal__admin_ui_host__loadShare__react_mf_2_konva__loadShare__.mjs_commonjs-proxy-CzMP41OH.js → __mfe_internal__admin_ui_host__loadShare__react_mf_2_konva__loadShare__.mjs_commonjs-proxy-LyTotJxo.js} +1 -1
  13. package/dist/assets/{_virtual_mf-localSharedImportMap___mfe_internal__admin_ui_host-DNAkeUTZ.js → _virtual_mf-localSharedImportMap___mfe_internal__admin_ui_host-C3ppGtyE.js} +1 -1
  14. package/dist/assets/{devices-DaqavVOg.js → devices-DmCyuEuQ.js} +1 -1
  15. package/dist/assets/{hostInit-ByQycOVJ.js → hostInit-BhyqZ3AI.js} +1 -1
  16. package/dist/assets/index-BH5Ezf7x.js +1 -0
  17. package/dist/assets/index-BPRNeJ5M.js +126 -0
  18. package/dist/assets/index-BViRW9IB.js +1 -0
  19. package/dist/assets/index-BpGkKz46.js +1 -0
  20. package/dist/assets/index-C2d2NdfQ.css +1 -0
  21. package/dist/assets/index-C9MS3ovr.js +1 -0
  22. package/dist/assets/index-CAZNi1Jm.js +1 -0
  23. package/dist/assets/index-CDGj60rb.js +190 -0
  24. package/dist/assets/index-DBG7Fygw.js +1 -0
  25. package/dist/assets/index-DeFTFjpg.js +1514 -0
  26. package/dist/assets/{index-BpC2rqYA.js → index-DuP0eN9F.js} +1 -1
  27. package/dist/assets/index-na_oszC9.js +1 -0
  28. package/dist/assets/method-access-map-BCPBTC0g.js +1 -0
  29. package/dist/assets/{remoteEntry-iRKuVHUX.js → remoteEntry-0I-ik-5b.js} +1 -1
  30. package/dist/assets/{schemas-BdJePqbR.js → schemas-DSjJ1TGP.js} +4 -4
  31. package/dist/assets/{virtual_mf-REMOTE_ENTRY_ID___mfe_internal__admin_ui_host__remoteEntry-_hash_-QVZxTxLo.js → virtual_mf-REMOTE_ENTRY_ID___mfe_internal__admin_ui_host__remoteEntry-_hash_-ChhI9HQF.js} +2 -2
  32. package/dist/index.html +31 -11
  33. package/dist/mf-entry-bootstrap-0.js +2 -2
  34. package/dist/server/addon.js +3332 -564
  35. package/dist/server/addon.js.map +1 -1
  36. package/dist/sw.js +1 -1
  37. package/package.json +2 -1
  38. package/dist/assets/index-B6kubsZI.js +0 -151
  39. package/dist/assets/index-BI1a6Whs.js +0 -1
  40. package/dist/assets/index-BVNg3Krt.js +0 -1185
  41. package/dist/assets/index-BbwPCEeT.js +0 -1
  42. package/dist/assets/index-CukoHCyD.js +0 -1
  43. package/dist/assets/index-Cyva9RmE.js +0 -1
  44. package/dist/assets/index-DLnW8nGB.css +0 -1
  45. package/dist/assets/index-DNydUTOB.js +0 -1
  46. package/dist/assets/index-DVLRgczW.js +0 -1
  47. package/dist/assets/index-PXbclFuZ.js +0 -87
  48. package/dist/assets/index-QqQmcu5i.js +0 -1
  49. package/dist/assets/method-access-map-DjDdPRYC.js +0 -1
@@ -1274,10 +1274,10 @@ const $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
1274
1274
  return;
1275
1275
  }
1276
1276
  }
1277
- const url = new URL(trimmed);
1277
+ const url2 = new URL(trimmed);
1278
1278
  if (def.hostname) {
1279
1279
  def.hostname.lastIndex = 0;
1280
- if (!def.hostname.test(url.hostname)) {
1280
+ if (!def.hostname.test(url2.hostname)) {
1281
1281
  payload.issues.push({
1282
1282
  code: "invalid_format",
1283
1283
  format: "url",
@@ -1291,7 +1291,7 @@ const $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
1291
1291
  }
1292
1292
  if (def.protocol) {
1293
1293
  def.protocol.lastIndex = 0;
1294
- if (!def.protocol.test(url.protocol.endsWith(":") ? url.protocol.slice(0, -1) : url.protocol)) {
1294
+ if (!def.protocol.test(url2.protocol.endsWith(":") ? url2.protocol.slice(0, -1) : url2.protocol)) {
1295
1295
  payload.issues.push({
1296
1296
  code: "invalid_format",
1297
1297
  format: "url",
@@ -1304,7 +1304,7 @@ const $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
1304
1304
  }
1305
1305
  }
1306
1306
  if (def.normalize) {
1307
- payload.value = url.href;
1307
+ payload.value = url2.href;
1308
1308
  } else {
1309
1309
  payload.value = trimmed;
1310
1310
  }
@@ -4428,6 +4428,9 @@ const ZodURL = /* @__PURE__ */ $constructor("ZodURL", (inst, def) => {
4428
4428
  $ZodURL.init(inst, def);
4429
4429
  ZodStringFormat.init(inst, def);
4430
4430
  });
4431
+ function url(params) {
4432
+ return /* @__PURE__ */ _url(ZodURL, params);
4433
+ }
4431
4434
  const ZodEmoji = /* @__PURE__ */ $constructor("ZodEmoji", (inst, def) => {
4432
4435
  $ZodEmoji.init(inst, def);
4433
4436
  ZodStringFormat.init(inst, def);
@@ -5111,6 +5114,7 @@ const WELL_KNOWN_TABS = [
5111
5114
  { id: "stream-broker", label: "Stream Broker", icon: "radio", order: 20 },
5112
5115
  { id: "streaming", label: "Streaming", icon: "video", order: 35 },
5113
5116
  { id: "ptz", label: "PTZ", icon: "move", order: 40 },
5117
+ { id: "consumables", label: "Consumables", icon: "recycle", order: 44 },
5114
5118
  { id: "pipeline", label: "Detection Pipeline", icon: "cpu", order: 39 },
5115
5119
  { id: "zones", label: "Detection", icon: "shapes", order: 38 },
5116
5120
  { id: "live-stats", label: "Live Stats", icon: "activity", order: 39 },
@@ -5130,7 +5134,7 @@ const WELL_KNOWN_TABS = [
5130
5134
  ];
5131
5135
  Object.fromEntries(WELL_KNOWN_TABS.map((t) => [t.id, t]));
5132
5136
  function isValuelessField(field) {
5133
- return field.type === "separator" || field.type === "info" || field.type === "button" || field.type === "object-array" || field.type === "widget" || field.type === "addon-action-button";
5137
+ return field.type === "separator" || field.type === "info" || field.type === "qr-code" || field.type === "button" || field.type === "object-array" || field.type === "widget" || field.type === "addon-action-button" || field.type === "device-action-button";
5134
5138
  }
5135
5139
  function hydrateSchema(schema, values) {
5136
5140
  return {
@@ -5192,6 +5196,8 @@ function typeOf(field) {
5192
5196
  case "password":
5193
5197
  case "color":
5194
5198
  case "probe":
5199
+ case "timezone":
5200
+ case "datetime":
5195
5201
  return "string";
5196
5202
  case "number":
5197
5203
  case "slider":
@@ -5202,32 +5208,262 @@ function typeOf(field) {
5202
5208
  return "other";
5203
5209
  }
5204
5210
  }
5205
- const StorageLocationTypeSchema = _enum([
5206
- "data",
5207
- "media",
5208
- "recordings",
5209
- // Recording-stream slots — addons pick high/low/clips per use case.
5210
- // The legacy `IStorageProvider` interface enumerated these; keeping
5211
- // them in the Zod enum means the `storage` cap can route per-slot
5212
- // location refs (e.g. `'recordings-high:nas-01'`) without losing
5213
- // type safety.
5214
- "recordings-high",
5215
- "recordings-low",
5216
- "recordings-clips",
5217
- // Detection snapshots / thumbnails / crops.
5218
- "event-images",
5219
- "models",
5220
- "cache",
5221
- "logs",
5222
- "addons-data",
5223
- // `backups` owned by the backup-orchestrator builtin. Default
5224
- // root: `<dataDir>/backups`. Operators can repoint via the same
5225
- // override mechanism every other location uses (storage settings).
5226
- "backups"
5211
+ const CamProfileSchema = _enum(["high", "mid", "low"]);
5212
+ const CamStreamKindSchema = _enum([
5213
+ "pull-rtsp",
5214
+ "pull-rtmp",
5215
+ "pull-http",
5216
+ "pull-rfc4571",
5217
+ "push-annexb",
5218
+ /** Broker-spawned ffmpeg transcode plane reads from a source cam-stream
5219
+ * and writes a re-encoded RTP plane. The broker routes demand signals to
5220
+ * the source broker; the transcode process is single-flighted per
5221
+ * identical EncodeProfile so multiple derived subscribers share one ffmpeg
5222
+ * process. */
5223
+ "derived"
5224
+ ]);
5225
+ const CamStreamResolutionSchema = object({
5226
+ width: number().int().positive(),
5227
+ height: number().int().positive()
5228
+ });
5229
+ const CameraStreamSchema = object({
5230
+ /** Stable, provider-assigned id unique within the (deviceId) scope. */
5231
+ camStreamId: string().min(1),
5232
+ deviceId: number().int().nonnegative(),
5233
+ kind: CamStreamKindSchema,
5234
+ /** Required for pull-* kinds. Ignored for push-annexb. */
5235
+ url: string().optional(),
5236
+ codec: string().optional(),
5237
+ resolution: CamStreamResolutionSchema.optional(),
5238
+ fps: number().positive().optional(),
5239
+ /** Human label surfaced in the Admin UI "Camera Stream" dropdown. */
5240
+ label: string().optional(),
5241
+ /**
5242
+ * Device-level features the publisher advertised (e.g. `battery-operated`).
5243
+ * The broker, snapshot orchestrator, and prebuffer manager all consult
5244
+ * this list to derive policy — relaxed stall watchdog for battery
5245
+ * cams, prebuffer off by default, longer snapshot rate-limit, etc.
5246
+ *
5247
+ * Single source of truth replacing per-stream flags like the
5248
+ * historical `allowStall`: if the publisher knows the camera is
5249
+ * battery-powered, every downstream service derives the right policy
5250
+ * from this list.
5251
+ */
5252
+ deviceFeatures: array(string()).optional(),
5253
+ /**
5254
+ * Whether this stream participates in the broker's automatic profile
5255
+ * assignment (`computeInitialAssignment`). Defaults to `true`. Publishers
5256
+ * use `false` when they want a stream to be SELECTABLE in the UI but not
5257
+ * picked by default — e.g. Reolink publishes its native Baichuan streams
5258
+ * as `autoEligible: true` (the recommended path) and its RTSP / RTMP
5259
+ * mirrors as `autoEligible: false` (still pickable per slot, just not
5260
+ * the auto choice). Manual `assignProfile` calls remain valid for
5261
+ * non-eligible streams.
5262
+ */
5263
+ autoEligible: boolean().optional(),
5264
+ /**
5265
+ * Transport-specific opaque metadata. The broker passes it through to
5266
+ * the source reader without inspecting it. Currently used by
5267
+ * `pull-rfc4571` streams to carry the upstream SDP (so the reader can
5268
+ * route RTP packets to the right depacketizer without an in-band
5269
+ * DESCRIBE phase). Other kinds typically leave it undefined.
5270
+ */
5271
+ metadata: record(string(), unknown()).optional()
5272
+ });
5273
+ const ProfileSlotStatusSchema = _enum([
5274
+ "unassigned",
5275
+ "idle",
5276
+ "connecting",
5277
+ "streaming",
5278
+ "error"
5279
+ ]);
5280
+ const ProfileSlotSchema = object({
5281
+ deviceId: number().int().nonnegative(),
5282
+ profile: CamProfileSchema,
5283
+ /** Broker id the rest of the system addresses: `${deviceId}/${profile}`. */
5284
+ brokerId: string(),
5285
+ /** `null` when the profile is unassigned. */
5286
+ sourceCamStreamId: string().nullable(),
5287
+ status: ProfileSlotStatusSchema,
5288
+ resolution: CamStreamResolutionSchema.optional(),
5289
+ codec: string().optional(),
5290
+ preBufferSec: number().nonnegative().optional(),
5291
+ errorMessage: string().optional()
5292
+ });
5293
+ const StreamSourceEntrySchema$1 = object({
5294
+ id: string(),
5295
+ label: string(),
5296
+ protocol: _enum(["rtsp", "rtmp", "annexb", "http-mjpeg", "webrtc", "custom"]),
5297
+ url: string().optional(),
5298
+ resolution: object({ width: number(), height: number() }).readonly().optional(),
5299
+ fps: number().optional(),
5300
+ bitrate: number().optional(),
5301
+ codec: string().optional(),
5302
+ profileHint: CamProfileSchema.optional()
5303
+ });
5304
+ object({
5305
+ type: string(),
5306
+ url: string(),
5307
+ videoCodec: string().optional(),
5308
+ audioCodec: string().optional(),
5309
+ metadata: record(string(), unknown()).readonly().optional()
5310
+ });
5311
+ const EncodedPacketSchema = object({
5312
+ type: _enum(["video", "audio"]),
5313
+ data: _instanceof(Uint8Array),
5314
+ pts: number(),
5315
+ dts: number(),
5316
+ keyframe: boolean(),
5317
+ codec: string()
5318
+ });
5319
+ const DecodedFrameSchema = object({
5320
+ data: _instanceof(Uint8Array),
5321
+ width: number(),
5322
+ height: number(),
5323
+ format: _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]),
5324
+ timestamp: number()
5325
+ });
5326
+ const FrameHandleSchema = object({
5327
+ shmId: string(),
5328
+ slot: number().int().nonnegative(),
5329
+ seq: number().int().nonnegative(),
5330
+ width: number().int().positive(),
5331
+ height: number().int().positive(),
5332
+ format: _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]),
5333
+ pts: number(),
5334
+ byteLength: number().int().nonnegative(),
5335
+ nodeId: string(),
5336
+ slotCount: number().int().positive()
5337
+ });
5338
+ const FrameHandleFormatSchema = _enum(["rgb", "bgr", "yuv420", "gray"]);
5339
+ const SubscribeFramesInputSchema = object({
5340
+ brokerId: string(),
5341
+ format: FrameHandleFormatSchema,
5342
+ /**
5343
+ * Optional reader-side cadence hint in frames per second. The broker does
5344
+ * NOT throttle — latest-wins ring reads drop frames implicitly for a slow
5345
+ * consumer. The value is echoed back in `SubscribeFramesResult.maxFps` so
5346
+ * the consumer can pace its own `pullFrameHandles` polling.
5347
+ */
5348
+ maxFps: number().positive().optional(),
5349
+ /** Short caller-identity tag (`motion`, `detection`, …) for diagnostics. */
5350
+ tag: string().optional()
5351
+ });
5352
+ const SubscribeFramesResultSchema = object({
5353
+ /** Opaque id the consumer passes to `pullFrameHandles` / `unsubscribeFrames`. */
5354
+ subscriptionId: string(),
5355
+ /** Reader-side cadence hint (frames/s) — echoes `SubscribeFramesInput.maxFps`. */
5356
+ maxFps: number().nonnegative()
5357
+ });
5358
+ const DecodedAudioChunkSchema = object({
5359
+ data: _instanceof(Uint8Array),
5360
+ sampleRate: number().int().positive(),
5361
+ channels: number().int().positive(),
5362
+ timestamp: number()
5363
+ });
5364
+ const SubscribeAudioChunksInputSchema = object({
5365
+ brokerId: string(),
5366
+ /** Short caller-identity tag (`audio-analyzer`, …) for `listClients`. */
5367
+ tag: string().optional()
5368
+ });
5369
+ const SubscribeAudioChunksResultSchema = object({
5370
+ /** Opaque id passed to `pullAudioChunks` / `unsubscribeAudioChunks`. */
5371
+ subscriptionId: string()
5372
+ });
5373
+ const BrokerStatusSchema$1 = _enum(["idle", "connecting", "streaming", "error", "stopped"]);
5374
+ const BrokerStatsSchema = object({
5375
+ status: BrokerStatusSchema$1,
5376
+ inputFps: number(),
5377
+ decodeFps: number(),
5378
+ encodedSubscribers: number(),
5379
+ decodedSubscribers: number(),
5380
+ uptimeMs: number(),
5381
+ bitrateKbps: number(),
5382
+ idrIntervalMs: number(),
5383
+ codec: string().optional(),
5384
+ totalBytes: number(),
5385
+ packetCount: number(),
5386
+ rtspClients: number(),
5387
+ pipeClients: number(),
5388
+ preBufferSec: number(),
5389
+ preBufferMs: number(),
5390
+ preBufferPackets: number(),
5391
+ /**
5392
+ * Moleculer node id of the decoder provider currently servicing this
5393
+ * stream's decoded subscribers. `null` until the deferred decoder is
5394
+ * created (no decoded clients yet, or codec not detected). Surfaces the
5395
+ * runtime decoder placement so the UI can show "Decoder: <agent>" without
5396
+ * a separate cap call per broker.
5397
+ */
5398
+ decoderNodeId: string().nullable(),
5399
+ /**
5400
+ * Detected audio track parameters from the RTSP DESCRIBE / SDP. `null`
5401
+ * when the stream has no audio track or the broker is in cold start.
5402
+ * `supported = false` means the codec was detected but the local
5403
+ * decoder pipeline cannot produce PCM chunks (e.g. AAC without the
5404
+ * AAC pipeline wired). Surfaced in the UI device-overview so operators
5405
+ * can pre-pick the audio-analysis model that matches the codec.
5406
+ */
5407
+ audio: object({
5408
+ codec: string(),
5409
+ sampleRate: number(),
5410
+ channels: number(),
5411
+ supported: boolean()
5412
+ }).nullable().optional()
5413
+ });
5414
+ const ProfileRtspEntrySchema = object({
5415
+ profile: CamProfileSchema,
5416
+ /** Profile-keyed broker id, format `${deviceId}/${profile}` (e.g. `"15/high"`). */
5417
+ brokerId: string(),
5418
+ url: string(),
5419
+ mutedUrl: string(),
5420
+ enabled: boolean(),
5421
+ codec: string().optional(),
5422
+ resolution: CamStreamResolutionSchema.optional()
5423
+ });
5424
+ const RecordingWeekdaySchema = number().int().min(0).max(6);
5425
+ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
5426
+ const RecordingScheduleSchema = discriminatedUnion("kind", [
5427
+ object({ kind: literal("always") }),
5428
+ object({
5429
+ kind: literal("timeOfDay"),
5430
+ start: string().regex(HHMM),
5431
+ end: string().regex(HHMM),
5432
+ /** Restrict to these weekdays; omit = every day. */
5433
+ days: array(RecordingWeekdaySchema).optional()
5434
+ })
5227
5435
  ]);
5436
+ const RecordingModeSchema = _enum(["continuous", "onMotion", "onAudioThreshold"]);
5437
+ const RecordingRuleSchema = object({
5438
+ schedule: RecordingScheduleSchema,
5439
+ mode: RecordingModeSchema,
5440
+ /** Seconds of footage to retain BEFORE a trigger (applied at keep/discard). */
5441
+ preBufferSec: number().min(0).default(0),
5442
+ /** Keep recording until this many seconds after the last trigger. */
5443
+ postBufferSec: number().min(0).default(0),
5444
+ /** Each new trigger restarts the post-buffer window. */
5445
+ resetTimeoutOnNewEvent: boolean().default(true),
5446
+ /** onAudioThreshold only — dBFS level that counts as a trigger. */
5447
+ thresholdDbfs: number().optional()
5448
+ });
5449
+ const RecordingRetentionSchema = object({
5450
+ maxAgeDays: number().min(0).optional(),
5451
+ maxSizeGb: number().min(0).optional()
5452
+ });
5453
+ const RecordingConfigSchema = object({
5454
+ enabled: boolean(),
5455
+ profiles: array(CamProfileSchema).optional(),
5456
+ segmentSeconds: number().int().positive().optional(),
5457
+ rules: array(RecordingRuleSchema).optional(),
5458
+ retention: RecordingRetentionSchema.optional()
5459
+ });
5460
+ const StorageLocationTypeSchema = string().regex(/^[a-z][a-zA-Z0-9-]*$/);
5228
5461
  const StorageLocationSchema = object({
5229
- id: string().regex(/^[a-z][a-z0-9-]*:[a-z0-9-]+$/),
5230
- type: StorageLocationTypeSchema,
5462
+ id: string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/),
5463
+ // Open string — the location's *kind* is an addon-declared id matching
5464
+ // `StorageLocationDeclaration.id`. All built-in ids are valid plain
5465
+ // strings, and addon-declared ids are admitted without schema changes.
5466
+ type: string(),
5231
5467
  displayName: string().min(1),
5232
5468
  providerId: string().min(1),
5233
5469
  config: record(string(), unknown()),
@@ -5238,8 +5474,36 @@ const StorageLocationSchema = object({
5238
5474
  });
5239
5475
  const StorageLocationRefSchema = union([
5240
5476
  StorageLocationTypeSchema,
5241
- string().regex(/^[a-z][a-z0-9-]*:[a-z0-9-]+$/)
5477
+ string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/)
5242
5478
  ]);
5479
+ const StorageLocationDeclarationSchema = object({
5480
+ /**
5481
+ * Global location identifier, e.g. `recordings` or `recordingsLow`.
5482
+ * Must start with a lowercase letter and may contain letters, digits, and
5483
+ * hyphens.
5484
+ */
5485
+ id: string().regex(/^[a-z][a-zA-Z0-9-]*$/, {
5486
+ message: "id must start with a lowercase letter and contain only letters, digits, or hyphens"
5487
+ }),
5488
+ /** Human-readable name shown in the admin UI. */
5489
+ displayName: string().min(1, { message: "displayName must not be empty" }),
5490
+ /** Optional longer explanation of what data this location stores. */
5491
+ description: string().optional(),
5492
+ /**
5493
+ * `single` — exactly one instance of this location is allowed system-wide
5494
+ * (e.g. `logs`, `models`). The operator can edit it but not add more.
5495
+ * `multi` — the operator may register several instances (e.g. a second
5496
+ * `recordings` on a NAS for disk tiering); one is the default at any time.
5497
+ */
5498
+ cardinality: _enum(["single", "multi"]),
5499
+ /**
5500
+ * When set, the default instance for this location inherits its resolved
5501
+ * root from the named location's default instance. Useful for derivative
5502
+ * slots (e.g. `recordingsLow` → `recordings`) so operators only need to
5503
+ * configure the primary location.
5504
+ */
5505
+ defaultsTo: string().optional()
5506
+ });
5243
5507
  const DecoderStatsSchema = object({
5244
5508
  inputFps: number(),
5245
5509
  outputFps: number(),
@@ -5281,6 +5545,51 @@ const DecoderSessionConfigSchema = object({
5281
5545
  */
5282
5546
  frameSink: _enum(["callback", "shm"]).default("callback")
5283
5547
  });
5548
+ const VideoEncodeSchema = object({
5549
+ codec: _enum(["h264", "h265", "copy"]),
5550
+ profile: _enum(["baseline", "main", "high"]).optional(),
5551
+ width: number().int().positive().optional(),
5552
+ height: number().int().positive().optional(),
5553
+ fps: number().positive().optional(),
5554
+ bitrateKbps: number().int().positive().optional(),
5555
+ gopFrames: number().int().positive().optional(),
5556
+ bf: number().int().min(0).optional(),
5557
+ preset: _enum([
5558
+ "ultrafast",
5559
+ "superfast",
5560
+ "veryfast",
5561
+ "faster",
5562
+ "fast",
5563
+ "medium"
5564
+ ]).optional(),
5565
+ tune: _enum(["zerolatency", "film", "animation"]).optional()
5566
+ });
5567
+ const AudioEncodeSchema = union([
5568
+ literal("passthrough"),
5569
+ object({
5570
+ codec: _enum(["opus", "aac", "pcmu", "pcma", "copy"]),
5571
+ bitrateKbps: number().int().positive().optional(),
5572
+ sampleRateHz: number().int().positive().optional(),
5573
+ channels: union([literal(1), literal(2)]).optional()
5574
+ })
5575
+ ]);
5576
+ const EncodeProfileSchema = object({
5577
+ video: VideoEncodeSchema,
5578
+ audio: AudioEncodeSchema,
5579
+ /**
5580
+ * ffmpeg input-side args, inserted between the fixed global flags
5581
+ * (`-hide_banner -loglevel error`) and `-i pipe:0`. Free-text array
5582
+ * — the widget surfaces a textarea + suggestion chips for the most-
5583
+ * used demuxer/format options.
5584
+ */
5585
+ inputArgs: array(string()).optional(),
5586
+ /**
5587
+ * ffmpeg output-side args, inserted between the encode block and
5588
+ * the final `-f <muxer> pipe:1`. Use for muxer options, bitstream
5589
+ * filters, codec-specific overrides. Free-text array.
5590
+ */
5591
+ outputArgs: array(string()).optional()
5592
+ });
5284
5593
  const YAMNET_TO_MACRO = {
5285
5594
  mapping: {
5286
5595
  // Speech
@@ -5582,18 +5891,74 @@ var DeviceType = /* @__PURE__ */ ((DeviceType2) => {
5582
5891
  DeviceType2["Sensor"] = "sensor";
5583
5892
  DeviceType2["Thermostat"] = "thermostat";
5584
5893
  DeviceType2["Button"] = "button";
5894
+ DeviceType2["EventEmitter"] = "event-emitter";
5895
+ DeviceType2["Update"] = "update";
5585
5896
  DeviceType2["Generic"] = "generic";
5897
+ DeviceType2["Notifier"] = "notifier";
5898
+ DeviceType2["Script"] = "script";
5899
+ DeviceType2["Automation"] = "automation";
5900
+ DeviceType2["Lock"] = "lock";
5901
+ DeviceType2["Cover"] = "cover";
5902
+ DeviceType2["Valve"] = "valve";
5903
+ DeviceType2["Humidifier"] = "humidifier";
5904
+ DeviceType2["WaterHeater"] = "water-heater";
5905
+ DeviceType2["Fan"] = "fan";
5906
+ DeviceType2["MediaPlayer"] = "media-player";
5907
+ DeviceType2["AlarmPanel"] = "alarm-panel";
5908
+ DeviceType2["Control"] = "control";
5909
+ DeviceType2["Presence"] = "presence";
5910
+ DeviceType2["Weather"] = "weather";
5911
+ DeviceType2["Vacuum"] = "vacuum";
5912
+ DeviceType2["LawnMower"] = "lawn-mower";
5913
+ DeviceType2["Container"] = "container";
5914
+ DeviceType2["Image"] = "image";
5586
5915
  return DeviceType2;
5587
5916
  })(DeviceType || {});
5588
5917
  var DeviceFeature = /* @__PURE__ */ ((DeviceFeature2) => {
5589
5918
  DeviceFeature2["BatteryOperated"] = "battery-operated";
5590
5919
  DeviceFeature2["Rebootable"] = "rebootable";
5920
+ DeviceFeature2["Resyncable"] = "resyncable";
5591
5921
  DeviceFeature2["NativeSnapshot"] = "native-snapshot";
5592
5922
  DeviceFeature2["DoorbellButton"] = "doorbell-button";
5593
5923
  DeviceFeature2["TwoWayAudio"] = "two-way-audio";
5594
5924
  DeviceFeature2["PanTiltZoom"] = "pan-tilt-zoom";
5595
5925
  DeviceFeature2["PtzAutotrack"] = "ptz-autotrack";
5596
5926
  DeviceFeature2["MotionTrigger"] = "motion-trigger";
5927
+ DeviceFeature2["LightColorRgb"] = "light-color-rgb";
5928
+ DeviceFeature2["LightColorHsv"] = "light-color-hsv";
5929
+ DeviceFeature2["LightColorMired"] = "light-color-mired";
5930
+ DeviceFeature2["ClimateDualSetpoint"] = "climate-dual-setpoint";
5931
+ DeviceFeature2["ClimateHumidity"] = "climate-humidity";
5932
+ DeviceFeature2["ClimateFanMode"] = "climate-fan-mode";
5933
+ DeviceFeature2["ClimatePreset"] = "climate-preset";
5934
+ DeviceFeature2["CoverPositionable"] = "cover-positionable";
5935
+ DeviceFeature2["CoverTilt"] = "cover-tilt";
5936
+ DeviceFeature2["ValvePositionable"] = "valve-positionable";
5937
+ DeviceFeature2["FanSpeed"] = "fan-speed";
5938
+ DeviceFeature2["FanPreset"] = "fan-preset";
5939
+ DeviceFeature2["FanDirection"] = "fan-direction";
5940
+ DeviceFeature2["FanOscillating"] = "fan-oscillating";
5941
+ DeviceFeature2["LockPinRequired"] = "lock-pin-required";
5942
+ DeviceFeature2["LockOpen"] = "lock-open";
5943
+ DeviceFeature2["MediaPlayerSeek"] = "media-player-seek";
5944
+ DeviceFeature2["MediaPlayerVolume"] = "media-player-volume";
5945
+ DeviceFeature2["MediaPlayerMute"] = "media-player-mute";
5946
+ DeviceFeature2["MediaPlayerShuffle"] = "media-player-shuffle";
5947
+ DeviceFeature2["MediaPlayerRepeat"] = "media-player-repeat";
5948
+ DeviceFeature2["MediaPlayerSelectSource"] = "media-player-select-source";
5949
+ DeviceFeature2["MediaPlayerPlayMedia"] = "media-player-play-media";
5950
+ DeviceFeature2["MediaPlayerNext"] = "media-player-next";
5951
+ DeviceFeature2["MediaPlayerPrevious"] = "media-player-previous";
5952
+ DeviceFeature2["MediaPlayerStop"] = "media-player-stop";
5953
+ DeviceFeature2["AlarmPinRequired"] = "alarm-pin-required";
5954
+ DeviceFeature2["PresenceGps"] = "presence-gps";
5955
+ DeviceFeature2["NotifierImage"] = "notifier-image";
5956
+ DeviceFeature2["NotifierPriority"] = "notifier-priority";
5957
+ DeviceFeature2["NotifierData"] = "notifier-data";
5958
+ DeviceFeature2["NotifierActions"] = "notifier-actions";
5959
+ DeviceFeature2["NotifierRecipients"] = "notifier-recipients";
5960
+ DeviceFeature2["ScriptVariables"] = "script-variables";
5961
+ DeviceFeature2["AutomationSkipCondition"] = "automation-skip-condition";
5597
5962
  return DeviceFeature2;
5598
5963
  })(DeviceFeature || {});
5599
5964
  var DeviceRole = /* @__PURE__ */ ((DeviceRole2) => {
@@ -5606,6 +5971,40 @@ var DeviceRole = /* @__PURE__ */ ((DeviceRole2) => {
5606
5971
  DeviceRole2["Nightvision"] = "nightvision";
5607
5972
  DeviceRole2["PrivacyMask"] = "privacy-mask";
5608
5973
  DeviceRole2["Doorbell"] = "doorbell";
5974
+ DeviceRole2["BinaryHelper"] = "binary-helper";
5975
+ DeviceRole2["MotionSensor"] = "motion-sensor";
5976
+ DeviceRole2["ContactSensor"] = "contact-sensor";
5977
+ DeviceRole2["LeakSensor"] = "leak-sensor";
5978
+ DeviceRole2["SmokeSensor"] = "smoke-sensor";
5979
+ DeviceRole2["COSensor"] = "co-sensor";
5980
+ DeviceRole2["GasSensor"] = "gas-sensor";
5981
+ DeviceRole2["TamperSensor"] = "tamper-sensor";
5982
+ DeviceRole2["VibrationSensor"] = "vibration-sensor";
5983
+ DeviceRole2["ConnectivitySensor"] = "connectivity-sensor";
5984
+ DeviceRole2["SoundSensor"] = "sound-sensor";
5985
+ DeviceRole2["BinarySensor"] = "binary-sensor";
5986
+ DeviceRole2["TemperatureSensor"] = "temperature-sensor";
5987
+ DeviceRole2["HumiditySensor"] = "humidity-sensor";
5988
+ DeviceRole2["AmbientLightSensor"] = "ambient-light-sensor";
5989
+ DeviceRole2["PressureSensor"] = "pressure-sensor";
5990
+ DeviceRole2["PowerSensor"] = "power-sensor";
5991
+ DeviceRole2["EnergySensor"] = "energy-sensor";
5992
+ DeviceRole2["VoltageSensor"] = "voltage-sensor";
5993
+ DeviceRole2["CurrentSensor"] = "current-sensor";
5994
+ DeviceRole2["AirQualitySensor"] = "air-quality-sensor";
5995
+ DeviceRole2["BatterySensor"] = "battery-sensor";
5996
+ DeviceRole2["NumericSensor"] = "numeric-sensor";
5997
+ DeviceRole2["EnumSensor"] = "enum-sensor";
5998
+ DeviceRole2["DateTimeSensor"] = "datetime-sensor";
5999
+ DeviceRole2["GenericSensor"] = "generic-sensor";
6000
+ DeviceRole2["NumericControl"] = "numeric-control";
6001
+ DeviceRole2["SelectControl"] = "select-control";
6002
+ DeviceRole2["TextControl"] = "text-control";
6003
+ DeviceRole2["DateTimeControl"] = "datetime-control";
6004
+ DeviceRole2["MobilePushNotifier"] = "mobile-push-notifier";
6005
+ DeviceRole2["MessagingNotifier"] = "messaging-notifier";
6006
+ DeviceRole2["EmailNotifier"] = "email-notifier";
6007
+ DeviceRole2["GenericNotifier"] = "generic-notifier";
5609
6008
  return DeviceRole2;
5610
6009
  })(DeviceRole || {});
5611
6010
  ({
@@ -5687,6 +6086,33 @@ const FeatureProbeStatusSchema = object({
5687
6086
  }) }
5688
6087
  }
5689
6088
  });
6089
+ object({
6090
+ /** Carbon dioxide concentration in ppm. */
6091
+ co2Ppm: number().min(0).optional(),
6092
+ /** Total volatile organic compounds in ppb. */
6093
+ vocPpb: number().min(0).optional(),
6094
+ /** Particulate matter ≤ 2.5 μm in µg/m³. */
6095
+ pm25: number().min(0).optional(),
6096
+ /** Particulate matter ≤ 10 μm in µg/m³. */
6097
+ pm10: number().min(0).optional(),
6098
+ /** Composite AQI value (typically 0..500). */
6099
+ aqi: number().optional(),
6100
+ /** Ms epoch when the slice was last updated. */
6101
+ lastFetchedAt: number(),
6102
+ /** Live display unit of the single metric this slice carries (e.g. HA
6103
+ * `attributes.unit_of_measurement` → 'ppm' / 'ppb' / 'µg/m³'). Each
6104
+ * upstream `sensor.*` entity surfaces ONE device_class, so one unit
6105
+ * per slice is unambiguous. */
6106
+ unit: string().optional(),
6107
+ /** Suggested decimal places for numeric display.
6108
+ * Populated live from the upstream source when provided (e.g. HA
6109
+ * `attributes.suggested_display_precision`). Falls back to
6110
+ * auto-formatting when absent. */
6111
+ precision: number().int().min(0).max(10).optional()
6112
+ });
6113
+ ({
6114
+ deviceTypes: [DeviceType.Sensor]
6115
+ });
5690
6116
  const ContributionSectionSchema = object({
5691
6117
  id: string(),
5692
6118
  title: string(),
@@ -5744,27 +6170,112 @@ function method(input, output, options) {
5744
6170
  function event(data) {
5745
6171
  return { data };
5746
6172
  }
5747
- const AudioClassSummarySchema = object({
5748
- className: string(),
5749
- /** Number of windows (chunks) where this class was the top hit. */
5750
- hits: number().int().nonnegative(),
5751
- /** Mean score across those hits, clamped to [0,1]. */
5752
- avgScore: number().min(0).max(1),
5753
- /** Peak score in the window. */
5754
- peakScore: number().min(0).max(1)
6173
+ const AlarmStateSchema = _enum([
6174
+ "disarmed",
6175
+ "armed_home",
6176
+ "armed_away",
6177
+ "armed_night",
6178
+ "armed_vacation",
6179
+ "armed_custom_bypass",
6180
+ "arming",
6181
+ "disarming",
6182
+ "pending",
6183
+ "triggered"
6184
+ ]);
6185
+ const AlarmArmModeSchema = _enum([
6186
+ "home",
6187
+ "away",
6188
+ "night",
6189
+ "vacation",
6190
+ "custom_bypass"
6191
+ ]);
6192
+ object({
6193
+ /** Current lifecycle state. */
6194
+ state: AlarmStateSchema,
6195
+ /** Subset of arm modes the panel accepts. UI renders one button per
6196
+ * mode in this list. */
6197
+ availableModes: array(AlarmArmModeSchema),
6198
+ /** Whether the panel requires a PIN on arm / disarm. Mirrors
6199
+ * `DeviceFeature.AlarmPinRequired` for slice consumers. */
6200
+ requiresCode: boolean(),
6201
+ /** Ms epoch when the slice was last updated. */
6202
+ lastChangedAt: number()
5755
6203
  });
5756
- const AudioMetricsSnapshotSchema = object({
5757
- /** Wall-clock timestamp (ms) of the most recent audio window. */
5758
- ts: number().int(),
5759
- /** Sliding-window length (seconds) used for aggregation. */
5760
- windowSec: number().int().positive(),
5761
- /** Latest level reading from the most recent window. */
5762
- level: object({
5763
- rms: number(),
5764
- dbfs: number()
5765
- }),
5766
- /** Peak dBFS observed across the rolling window. */
5767
- peakDbfs: number(),
6204
+ ({
6205
+ deviceTypes: [DeviceType.AlarmPanel],
6206
+ methods: {
6207
+ arm: method(
6208
+ object({
6209
+ deviceId: number().int().nonnegative(),
6210
+ mode: AlarmArmModeSchema,
6211
+ /** Optional PIN code. Required when `requiresCode === true`.
6212
+ * Passed through to the upstream service; never persisted. */
6213
+ code: string().min(1).optional()
6214
+ }),
6215
+ _void(),
6216
+ { kind: "mutation", auth: "admin" }
6217
+ ),
6218
+ disarm: method(
6219
+ object({
6220
+ deviceId: number().int().nonnegative(),
6221
+ code: string().min(1).optional()
6222
+ }),
6223
+ _void(),
6224
+ { kind: "mutation", auth: "admin" }
6225
+ ),
6226
+ /**
6227
+ * Force the panel into the `triggered` state — used by HA
6228
+ * automations to surface external sensor events through the panel
6229
+ * (e.g. a Reolink camera intrusion event firing the security
6230
+ * system). Provider rejects when the panel hardware doesn't
6231
+ * support a software-initiated trigger.
6232
+ */
6233
+ trigger: method(
6234
+ object({ deviceId: number().int().nonnegative() }),
6235
+ _void(),
6236
+ { kind: "mutation", auth: "admin" }
6237
+ )
6238
+ }
6239
+ });
6240
+ object({
6241
+ /** Current illuminance in lux (lx). */
6242
+ lux: number().min(0),
6243
+ /** Ms epoch when the slice was last updated. */
6244
+ lastFetchedAt: number(),
6245
+ /** Live display unit from the upstream source (e.g. HA
6246
+ * `attributes.unit_of_measurement`). The UI prefers this over the
6247
+ * role's canonical unit. Absent → fall back to the canonical unit. */
6248
+ unit: string().optional(),
6249
+ /** Suggested decimal places for numeric display.
6250
+ * Populated live from the upstream source when provided (e.g. HA
6251
+ * `attributes.suggested_display_precision`). Falls back to
6252
+ * auto-formatting when absent. */
6253
+ precision: number().int().min(0).max(10).optional()
6254
+ });
6255
+ ({
6256
+ deviceTypes: [DeviceType.Sensor]
6257
+ });
6258
+ const AudioClassSummarySchema = object({
6259
+ className: string(),
6260
+ /** Number of windows (chunks) where this class was the top hit. */
6261
+ hits: number().int().nonnegative(),
6262
+ /** Mean score across those hits, clamped to [0,1]. */
6263
+ avgScore: number().min(0).max(1),
6264
+ /** Peak score in the window. */
6265
+ peakScore: number().min(0).max(1)
6266
+ });
6267
+ const AudioMetricsSnapshotSchema = object({
6268
+ /** Wall-clock timestamp (ms) of the most recent audio window. */
6269
+ ts: number().int(),
6270
+ /** Sliding-window length (seconds) used for aggregation. */
6271
+ windowSec: number().int().positive(),
6272
+ /** Latest level reading from the most recent window. */
6273
+ level: object({
6274
+ rms: number(),
6275
+ dbfs: number()
6276
+ }),
6277
+ /** Peak dBFS observed across the rolling window. */
6278
+ peakDbfs: number(),
5768
6279
  /** Mean dBFS across the rolling window. */
5769
6280
  avgDbfs: number(),
5770
6281
  /** Most recent above-threshold classification, or null on silence. */
@@ -5835,6 +6346,46 @@ const AudioMetricsHistorySchema = object({
5835
6346
  )
5836
6347
  }
5837
6348
  });
6349
+ object({
6350
+ /** Whether the automation is currently enabled. Disabled automations
6351
+ * ignore their trigger block — manual `trigger` still works. */
6352
+ enabled: boolean(),
6353
+ /** Whether the automation is currently executing its action block. */
6354
+ isRunning: boolean(),
6355
+ /** Ms epoch of the last successful run. 0 when never run. */
6356
+ lastTriggeredAt: number(),
6357
+ /** Failure description from the last completed run. Null on success
6358
+ * or when never run. */
6359
+ lastError: string().nullable(),
6360
+ /** Ms epoch when the slice was last updated. */
6361
+ lastChangedAt: number()
6362
+ });
6363
+ ({
6364
+ deviceTypes: [DeviceType.Automation],
6365
+ methods: {
6366
+ enable: method(
6367
+ object({ deviceId: number().int().nonnegative() }),
6368
+ _void(),
6369
+ { kind: "mutation", auth: "admin" }
6370
+ ),
6371
+ disable: method(
6372
+ object({ deviceId: number().int().nonnegative() }),
6373
+ _void(),
6374
+ { kind: "mutation", auth: "admin" }
6375
+ ),
6376
+ trigger: method(
6377
+ object({
6378
+ deviceId: number().int().nonnegative(),
6379
+ /** When true, fires the action block while bypassing the
6380
+ * automation's condition evaluation. Gated by
6381
+ * `DeviceFeature.AutomationSkipCondition`. */
6382
+ skipCondition: boolean().optional()
6383
+ }),
6384
+ _void(),
6385
+ { kind: "mutation", auth: "admin" }
6386
+ )
6387
+ }
6388
+ });
5838
6389
  const BatteryStatusSchema = object({
5839
6390
  /** 0..100 inclusive. Firmware-reported. */
5840
6391
  percentage: number().min(0).max(100),
@@ -5852,10 +6403,48 @@ const BatteryStatusSchema = object({
5852
6403
  */
5853
6404
  sleeping: boolean(),
5854
6405
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
5855
- lastUpdated: number()
6406
+ lastUpdated: number(),
6407
+ /**
6408
+ * True when the source is a BINARY low-battery indicator (HA
6409
+ * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
6410
+ * charge level — `percentage` is then a coarse stand-in (100 = normal,
6411
+ * sub-threshold = low). UI MUST render "Normal"/"Low" instead of a
6412
+ * misleading exact percentage. Absent/false → genuine 0–100 % reading.
6413
+ */
6414
+ binary: boolean().optional()
5856
6415
  });
5857
6416
  ({
5858
6417
  deviceTypes: [DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch],
6418
+ methods: {
6419
+ /**
6420
+ * Explicitly wake the camera from low-power sleep ahead of a
6421
+ * streaming session start. Consumers that initiate a stream
6422
+ * against a sleeping battery cam (HomeKit Secure Video, Alexa
6423
+ * RTCSession, snapshot wrappers) call this with a short timeout
6424
+ * before establishing the media pipeline — the broker's own
6425
+ * passive wake-on-dial works but adds 5–7 seconds to first-frame,
6426
+ * during which the consumer renders a black screen. Pre-waking
6427
+ * compresses that gap.
6428
+ *
6429
+ * Returns `awoke: true` when the firmware acknowledged the wake
6430
+ * before `timeoutMs`. Returns `awoke: false` when it timed out OR
6431
+ * the cap surface is unavailable (no Baichuan / firmware
6432
+ * channel); the caller should still attempt the stream — the
6433
+ * passive broker wake remains as fallback.
6434
+ */
6435
+ wakeForStream: method(
6436
+ object({
6437
+ deviceId: number(),
6438
+ /** Bound on the wait. Sensible range 3000–10000ms. */
6439
+ timeoutMs: number().int().min(500).max(3e4).default(8e3)
6440
+ }),
6441
+ object({
6442
+ awoke: boolean(),
6443
+ durationMs: number()
6444
+ }),
6445
+ { kind: "mutation" }
6446
+ )
6447
+ },
5859
6448
  events: {
5860
6449
  /**
5861
6450
  * Emitted whenever the cached status changes (firmware push OR
@@ -5869,6 +6458,14 @@ const BatteryStatusSchema = object({
5869
6458
  }) }
5870
6459
  }
5871
6460
  });
6461
+ object({
6462
+ on: boolean(),
6463
+ /** Ms epoch of the last transition. 0 if never observed. */
6464
+ lastChangedAt: number()
6465
+ });
6466
+ ({
6467
+ deviceTypes: [DeviceType.Sensor]
6468
+ });
5872
6469
  object({
5873
6470
  /** Current level as 0..100 inclusive. Firmware-reported. */
5874
6471
  percentage: number().min(0).max(100),
@@ -5900,203 +6497,6 @@ object({
5900
6497
  }) }
5901
6498
  }
5902
6499
  });
5903
- const CamProfileSchema = _enum(["high", "mid", "low"]);
5904
- const CamStreamKindSchema = _enum([
5905
- "pull-rtsp",
5906
- "pull-rtmp",
5907
- "pull-http",
5908
- "pull-rfc4571",
5909
- "push-annexb"
5910
- ]);
5911
- const CamStreamResolutionSchema = object({
5912
- width: number().int().positive(),
5913
- height: number().int().positive()
5914
- });
5915
- const CameraStreamSchema = object({
5916
- /** Stable, provider-assigned id unique within the (deviceId) scope. */
5917
- camStreamId: string().min(1),
5918
- deviceId: number().int().nonnegative(),
5919
- kind: CamStreamKindSchema,
5920
- /** Required for pull-* kinds. Ignored for push-annexb. */
5921
- url: string().optional(),
5922
- codec: string().optional(),
5923
- resolution: CamStreamResolutionSchema.optional(),
5924
- fps: number().positive().optional(),
5925
- /** Human label surfaced in the Admin UI "Camera Stream" dropdown. */
5926
- label: string().optional(),
5927
- /**
5928
- * Device-level features the publisher advertised (e.g. `battery-operated`).
5929
- * The broker, snapshot orchestrator, and prebuffer manager all consult
5930
- * this list to derive policy — relaxed stall watchdog for battery
5931
- * cams, prebuffer off by default, longer snapshot rate-limit, etc.
5932
- *
5933
- * Single source of truth replacing per-stream flags like the
5934
- * historical `allowStall`: if the publisher knows the camera is
5935
- * battery-powered, every downstream service derives the right policy
5936
- * from this list.
5937
- */
5938
- deviceFeatures: array(string()).optional(),
5939
- /**
5940
- * Whether this stream participates in the broker's automatic profile
5941
- * assignment (`computeInitialAssignment`). Defaults to `true`. Publishers
5942
- * use `false` when they want a stream to be SELECTABLE in the UI but not
5943
- * picked by default — e.g. Reolink publishes its native Baichuan streams
5944
- * as `autoEligible: true` (the recommended path) and its RTSP / RTMP
5945
- * mirrors as `autoEligible: false` (still pickable per slot, just not
5946
- * the auto choice). Manual `assignProfile` calls remain valid for
5947
- * non-eligible streams.
5948
- */
5949
- autoEligible: boolean().optional(),
5950
- /**
5951
- * Transport-specific opaque metadata. The broker passes it through to
5952
- * the source reader without inspecting it. Currently used by
5953
- * `pull-rfc4571` streams to carry the upstream SDP (so the reader can
5954
- * route RTP packets to the right depacketizer without an in-band
5955
- * DESCRIBE phase). Other kinds typically leave it undefined.
5956
- */
5957
- metadata: record(string(), unknown()).optional()
5958
- });
5959
- const ProfileSlotStatusSchema = _enum([
5960
- "unassigned",
5961
- "idle",
5962
- "connecting",
5963
- "streaming",
5964
- "error"
5965
- ]);
5966
- const ProfileSlotSchema = object({
5967
- deviceId: number().int().nonnegative(),
5968
- profile: CamProfileSchema,
5969
- /** Broker id the rest of the system addresses: `${deviceId}/${profile}`. */
5970
- brokerId: string(),
5971
- /** `null` when the profile is unassigned. */
5972
- sourceCamStreamId: string().nullable(),
5973
- status: ProfileSlotStatusSchema,
5974
- resolution: CamStreamResolutionSchema.optional(),
5975
- codec: string().optional(),
5976
- preBufferSec: number().nonnegative().optional(),
5977
- errorMessage: string().optional()
5978
- });
5979
- const StreamSourceEntrySchema$1 = object({
5980
- id: string(),
5981
- label: string(),
5982
- protocol: _enum(["rtsp", "rtmp", "annexb", "http-mjpeg", "webrtc", "custom"]),
5983
- url: string().optional(),
5984
- resolution: object({ width: number(), height: number() }).readonly().optional(),
5985
- fps: number().optional(),
5986
- bitrate: number().optional(),
5987
- codec: string().optional(),
5988
- profileHint: _enum(["high", "mid", "low"]).optional()
5989
- });
5990
- object({
5991
- type: string(),
5992
- url: string(),
5993
- videoCodec: string().optional(),
5994
- audioCodec: string().optional(),
5995
- metadata: record(string(), unknown()).readonly().optional()
5996
- });
5997
- const EncodedPacketSchema = object({
5998
- type: _enum(["video", "audio"]),
5999
- data: _instanceof(Uint8Array),
6000
- pts: number(),
6001
- dts: number(),
6002
- keyframe: boolean(),
6003
- codec: string()
6004
- });
6005
- const DecodedFrameSchema = object({
6006
- data: _instanceof(Uint8Array),
6007
- width: number(),
6008
- height: number(),
6009
- format: _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]),
6010
- timestamp: number()
6011
- });
6012
- const FrameHandleSchema = object({
6013
- shmId: string(),
6014
- slot: number().int().nonnegative(),
6015
- seq: number().int().nonnegative(),
6016
- width: number().int().positive(),
6017
- height: number().int().positive(),
6018
- format: _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]),
6019
- pts: number(),
6020
- byteLength: number().int().nonnegative(),
6021
- nodeId: string(),
6022
- slotCount: number().int().positive()
6023
- });
6024
- const FrameHandleFormatSchema = _enum(["rgb", "bgr", "yuv420", "gray"]);
6025
- const SubscribeFramesInputSchema = object({
6026
- brokerId: string(),
6027
- format: FrameHandleFormatSchema,
6028
- /**
6029
- * Optional reader-side cadence hint in frames per second. The broker does
6030
- * NOT throttle — latest-wins ring reads drop frames implicitly for a slow
6031
- * consumer. The value is echoed back in `SubscribeFramesResult.maxFps` so
6032
- * the consumer can pace its own `pullFrameHandles` polling.
6033
- */
6034
- maxFps: number().positive().optional(),
6035
- /** Short caller-identity tag (`motion`, `detection`, …) for diagnostics. */
6036
- tag: string().optional()
6037
- });
6038
- const SubscribeFramesResultSchema = object({
6039
- /** Opaque id the consumer passes to `pullFrameHandles` / `unsubscribeFrames`. */
6040
- subscriptionId: string(),
6041
- /** Reader-side cadence hint (frames/s) — echoes `SubscribeFramesInput.maxFps`. */
6042
- maxFps: number().nonnegative()
6043
- });
6044
- const DecodedAudioChunkSchema = object({
6045
- data: _instanceof(Uint8Array),
6046
- sampleRate: number().int().positive(),
6047
- channels: number().int().positive(),
6048
- timestamp: number()
6049
- });
6050
- const SubscribeAudioChunksInputSchema = object({
6051
- brokerId: string(),
6052
- /** Short caller-identity tag (`audio-analyzer`, …) for `listClients`. */
6053
- tag: string().optional()
6054
- });
6055
- const SubscribeAudioChunksResultSchema = object({
6056
- /** Opaque id passed to `pullAudioChunks` / `unsubscribeAudioChunks`. */
6057
- subscriptionId: string()
6058
- });
6059
- const BrokerStatusSchema$1 = _enum(["idle", "connecting", "streaming", "error", "stopped"]);
6060
- const BrokerStatsSchema = object({
6061
- status: BrokerStatusSchema$1,
6062
- inputFps: number(),
6063
- decodeFps: number(),
6064
- encodedSubscribers: number(),
6065
- decodedSubscribers: number(),
6066
- uptimeMs: number(),
6067
- bitrateKbps: number(),
6068
- idrIntervalMs: number(),
6069
- codec: string().optional(),
6070
- totalBytes: number(),
6071
- packetCount: number(),
6072
- rtspClients: number(),
6073
- pipeClients: number(),
6074
- preBufferSec: number(),
6075
- preBufferMs: number(),
6076
- preBufferPackets: number(),
6077
- /**
6078
- * Moleculer node id of the decoder provider currently servicing this
6079
- * stream's decoded subscribers. `null` until the deferred decoder is
6080
- * created (no decoded clients yet, or codec not detected). Surfaces the
6081
- * runtime decoder placement so the UI can show "Decoder: <agent>" without
6082
- * a separate cap call per broker.
6083
- */
6084
- decoderNodeId: string().nullable(),
6085
- /**
6086
- * Detected audio track parameters from the RTSP DESCRIBE / SDP. `null`
6087
- * when the stream has no audio track or the broker is in cold start.
6088
- * `supported = false` means the codec was detected but the local
6089
- * decoder pipeline cannot produce PCM chunks (e.g. AAC without the
6090
- * AAC pipeline wired). Surfaced in the UI device-overview so operators
6091
- * can pre-pick the audio-analysis model that matches the codec.
6092
- */
6093
- audio: object({
6094
- codec: string(),
6095
- sampleRate: number(),
6096
- channels: number(),
6097
- supported: boolean()
6098
- }).nullable().optional()
6099
- });
6100
6500
  const StreamFormatSchema = _enum(["webrtc", "hls", "mjpeg", "rtsp"]);
6101
6501
  const StreamInfoSchema = object({
6102
6502
  streamId: string(),
@@ -6123,7 +6523,22 @@ const RtspRestreamEntrySchema = object({
6123
6523
  brokerId: string(),
6124
6524
  url: string(),
6125
6525
  mutedUrl: string(),
6126
- enabled: boolean()
6526
+ enabled: boolean(),
6527
+ /**
6528
+ * Source-stream codec / resolution for the camStream this entry serves
6529
+ * (the broker's "high"/"mid"/"low" profile slot for this device).
6530
+ * Used by exporter pickers (`pickPreferredRtspEntry`) to resolve
6531
+ * `streamPreference: 'auto'` to the slot whose source is closest to
6532
+ * the consumer's target — Alexa wants ~720p, HomeKit wants ~1080p,
6533
+ * and there's no point dialling the 4K slot for an Echo Show. Absent
6534
+ * when the source publisher never advertised the field; pickers fall
6535
+ * back to first-enabled in that case.
6536
+ */
6537
+ codec: string().optional(),
6538
+ resolution: object({
6539
+ width: number().int().positive(),
6540
+ height: number().int().positive()
6541
+ }).optional()
6127
6542
  });
6128
6543
  const BrokerRtspClientSchema = object({
6129
6544
  sessionId: string(),
@@ -6146,11 +6561,90 @@ const BrokerAudioClientSchema = object({
6146
6561
  subscribedAt: number(),
6147
6562
  chunksDelivered: number()
6148
6563
  });
6564
+ const BrokerConsumerKindSchema = _enum([
6565
+ "alexa",
6566
+ "homekit",
6567
+ "webrtc-browser",
6568
+ "webrtc-mobile",
6569
+ "webrtc-whep",
6570
+ "rtsp-listen",
6571
+ "derived-broker",
6572
+ "recording",
6573
+ "pipeline",
6574
+ "snapshot",
6575
+ "warmup",
6576
+ "unknown"
6577
+ ]);
6578
+ const BrokerConsumerAttributionSchema = object({
6579
+ kind: BrokerConsumerKindSchema,
6580
+ /**
6581
+ * Free-form label intended to disambiguate consumers OF THE SAME kind
6582
+ * on the same broker — e.g. user name, device alias. Should NOT repeat
6583
+ * the kind / cam / cam-stream / sessionId (those are surfaced by
6584
+ * dedicated fields). When empty the widget falls back to `${kind} ·
6585
+ * <sessionId tail>`.
6586
+ */
6587
+ label: string().optional(),
6588
+ /**
6589
+ * Which part of the encoded plane this subscription consumes:
6590
+ * - `'audio'` — audio packets only
6591
+ * - `'video'` — video packets only
6592
+ * - `'both'` — both video + audio (typical for AnnexB push paths
6593
+ * that don't split the source RTP)
6594
+ *
6595
+ * Missing field means "unknown" — older callers and the legacy
6596
+ * paths that haven't been migrated yet.
6597
+ */
6598
+ media: _enum(["audio", "video", "both"]).optional(),
6599
+ /**
6600
+ * Codec the consumer receives AFTER any per-session re-encoding. For
6601
+ * a WebRTC session this is the negotiated egress codec (e.g. `H264`,
6602
+ * `H265`, `Opus`, `Pcmu`); for an RTSP restreamer it mirrors the
6603
+ * source codec. Surfaced in the widget chip so operators see at a
6604
+ * glance whether a viewer is on H.264 (Echo) or H.265 (Safari).
6605
+ */
6606
+ targetCodec: string().optional(),
6607
+ /**
6608
+ * Whether the broker is re-encoding for this consumer:
6609
+ * - `'passthrough'` — bytes flow source→consumer without ffmpeg
6610
+ * - `'repacketize'` — RTP payload is re-packetized but NOT re-encoded
6611
+ * (preserves frames, just rebuilds headers; e.g.
6612
+ * the H.265 repacketizer path)
6613
+ * - `'transcode'` — a full encode/decode round (ffmpeg, libx264,
6614
+ * libav…); the most expensive path
6615
+ */
6616
+ transport: _enum(["passthrough", "repacketize", "transcode"]).optional(),
6617
+ /** Remote peer IP if the consumer terminates a network socket. */
6618
+ remoteAddr: string().optional(),
6619
+ /**
6620
+ * Server-read User-Agent of the originating client; enriched by the hub
6621
+ * from the tRPC request context (browser sessions).
6622
+ */
6623
+ userAgent: string().optional(),
6624
+ /** Authenticated user id (CamStack OAuth subject) when known. */
6625
+ userId: string().optional(),
6626
+ /** Higher-level session identifier (Alexa Echo sessionId, HAP session, …). */
6627
+ sessionId: string().optional(),
6628
+ /** Free-form key/value extras (e.g. clientHints from a WebRTC offer). */
6629
+ extra: record(string(), string()).optional()
6630
+ }).readonly();
6631
+ const BrokerEncodedClientSchema = object({
6632
+ /** Stable id assigned on attach; used by `killClient`. */
6633
+ id: string(),
6634
+ /** Which broker fanout the subscriber rides — annexB encoded packets vs raw RTP. */
6635
+ channel: _enum(["annexb", "rtp"]),
6636
+ attribution: BrokerConsumerAttributionSchema,
6637
+ subscribedAt: number(),
6638
+ /** Total packets delivered (annex-B EncodedPackets or RTP byte-buffers). */
6639
+ packetsDelivered: number()
6640
+ });
6149
6641
  const BrokerClientsSchema = object({
6150
6642
  rtsp: array(BrokerRtspClientSchema).readonly(),
6151
6643
  decoded: array(BrokerDecodedClientSchema).readonly(),
6152
6644
  audio: array(BrokerAudioClientSchema).readonly(),
6645
+ encoded: array(BrokerEncodedClientSchema).readonly(),
6153
6646
  pipeClients: number(),
6647
+ /** Total encoded + raw-RTP callback subscriber count (sum of `encoded[].length`). */
6154
6648
  encodedSubscribers: number()
6155
6649
  });
6156
6650
  _enum([
@@ -6160,24 +6654,39 @@ _enum([
6160
6654
  "disabled",
6161
6655
  "waking"
6162
6656
  ]);
6163
- const VideoCodecTargetSchema = _enum(["H264", "H265", "auto"]);
6164
- const AudioCodecTargetSchema = _enum(["AAC", "Opus", "PCMU", "none"]);
6165
- const MaxResolutionSchema = object({
6166
- width: number().int().positive(),
6167
- height: number().int().positive()
6168
- });
6657
+ const VideoCodecTargetSchema = _enum(["h264", "h265", "copy"]);
6658
+ const AudioCodecTargetSchema = _enum(["aac", "opus", "pcmu", "copy", "none"]);
6169
6659
  const GetStreamWithCodecInputSchema = object({
6170
6660
  deviceId: number().int().nonnegative(),
6171
- videoCodec: VideoCodecTargetSchema,
6172
- audioCodec: AudioCodecTargetSchema.optional(),
6173
- maxResolution: MaxResolutionSchema.optional(),
6661
+ /** Target codec. `'copy'` = passthrough (no re-encode). */
6662
+ video: VideoCodecTargetSchema,
6663
+ /** Target audio codec. `'copy'` = passthrough. Defaults to `'aac'`. */
6664
+ audio: AudioCodecTargetSchema.optional(),
6665
+ /** Target a profile's assigned source camStream. Required to resolve a source. */
6666
+ profile: CamProfileSchema.optional(),
6667
+ /** Optional output resolution target. When set: drives the transcode
6668
+ * output scale (downscale toward WxH), and — when `profile` is absent —
6669
+ * selects the published source closest to this resolution. Ignored for
6670
+ * `video:'copy'` scaling (a copy can't be rescaled), but still used for
6671
+ * source selection. */
6672
+ targetResolution: object({ width: number().int().positive(), height: number().int().positive() }).optional(),
6673
+ /** Extra ffmpeg output flags appended verbatim by the consumer (in code).
6674
+ * Folded into the pipeline key so different args never share a child. */
6675
+ outputArgs: array(string()).optional(),
6676
+ /** Opt-in: carry transcoded audio over an RTP sidecar grafted into the
6677
+ * restreamer SDP (only for re-encoded audio targets — aac/opus/pcmu).
6678
+ * Default/absent = the live-verified video-only egress. */
6679
+ audioEgress: boolean().optional(),
6174
6680
  tag: string().optional()
6175
6681
  });
6176
6682
  const RtpSourceSchema = object({
6177
6683
  url: string(),
6178
6684
  videoCodec: _enum(["H264", "H265"]),
6179
6685
  audioCodec: string(),
6180
- resolution: MaxResolutionSchema,
6686
+ resolution: object({
6687
+ width: number().int().positive(),
6688
+ height: number().int().positive()
6689
+ }),
6181
6690
  transcoded: boolean(),
6182
6691
  encoder: string(),
6183
6692
  pipelineKey: string()
@@ -6196,7 +6705,17 @@ const RtpSourceSchema = object({
6196
6705
  label: string().optional(),
6197
6706
  deviceFeatures: array(string()).optional(),
6198
6707
  autoEligible: boolean().optional(),
6199
- metadata: record(string(), unknown()).optional()
6708
+ metadata: record(string(), unknown()).optional(),
6709
+ /** Required when kind === 'derived' — the source camStreamId the
6710
+ * derived broker reads its encoded plane from.
6711
+ * The runtime guard (broker manager, Task 10) enforces presence for
6712
+ * kind === 'derived'; the schema stays permissive so ts-morph emits
6713
+ * full field types instead of collapsing to ZodEffects. */
6714
+ sourceCamStreamId: string().min(1).optional(),
6715
+ /** Required when kind === 'derived' — the ffmpeg params for the
6716
+ * spawn. Reuses the same brokerId for identical params (single-flight).
6717
+ * Runtime guard in broker manager; schema is permissive (see above). */
6718
+ encodeProfile: EncodeProfileSchema.optional()
6200
6719
  }),
6201
6720
  object({ success: literal(true) }),
6202
6721
  { kind: "mutation", auth: "admin" }
@@ -6259,12 +6778,21 @@ const RtpSourceSchema = object({
6259
6778
  object({ success: boolean() }),
6260
6779
  { kind: "mutation", auth: "admin" }
6261
6780
  ),
6781
+ /**
6782
+ * LEGACY / observability. Returns a raw per-broker restream URL for a
6783
+ * single stream. Prefer {@link getStreamWithCodec} for programmatic
6784
+ * CONSUMPTION — it is the single demand-counted entry that rides the
6785
+ * broker's one source dial and returns a codec-correct passthrough.
6786
+ */
6262
6787
  getStreamUrl: method(
6263
6788
  object({ streamId: string(), format: StreamFormatSchema }),
6264
6789
  object({ url: string() })
6265
6790
  ),
6266
6791
  /**
6267
- * Shared codec-targeted stream API — see Task #184.
6792
+ * Shared codec-targeted stream API — see Task #184. THE single public
6793
+ * stream-acquisition surface for programmatic consumers: single-dial
6794
+ * (rides the broker's one source pull) and passthrough-correct
6795
+ * (`videoCodec:'auto'` → `-c copy`, never a needless re-encode).
6268
6796
  * Resolution order: source-select → HW transcode → libx264/libx265.
6269
6797
  */
6270
6798
  getStreamWithCodec: method(
@@ -6358,10 +6886,20 @@ const RtpSourceSchema = object({
6358
6886
  object({ configuredSec: number(), bufferedMs: number(), packetCount: number() })
6359
6887
  ),
6360
6888
  getRtspPort: method(_void(), number()),
6889
+ /**
6890
+ * Enumeration surface — backs the admin RTSP-export picker and
6891
+ * `pickPreferredRtspEntry`. Lists every per-broker restream entry. NOT a
6892
+ * consumption API: programmatic readers use {@link getStreamWithCodec}.
6893
+ */
6361
6894
  getAllRtspEntries: method(
6362
6895
  object({ hostname: string().optional() }),
6363
6896
  array(RtspRestreamEntrySchema).readonly()
6364
6897
  ),
6898
+ /**
6899
+ * LEGACY / observability. Resolves one broker's restream entry. Prefer
6900
+ * {@link getStreamWithCodec} for programmatic CONSUMPTION so the read is
6901
+ * demand-counted and rides the single source dial.
6902
+ */
6365
6903
  getRtspEntry: method(
6366
6904
  object({ brokerId: string(), hostname: string().optional() }),
6367
6905
  RtspRestreamEntrySchema.nullable()
@@ -6398,6 +6936,57 @@ const RtpSourceSchema = object({
6398
6936
  }))
6399
6937
  }
6400
6938
  });
6939
+ const STREAM_CODEC_VALUES = ["h264", "h265", "hevc", "av1", "mjpeg", "vp8", "vp9"];
6940
+ const StreamCodecSchema = _enum(STREAM_CODEC_VALUES);
6941
+ const PickStreamRequirementsSchema = object({
6942
+ /**
6943
+ * Codecs the consumer can decode without an intermediate transcode.
6944
+ * Match is case-insensitive against the camera stream's `codec`.
6945
+ * Default (omitted / empty array): no codec filter — useful when the
6946
+ * consumer just wants "the highest-quality stream of any codec".
6947
+ */
6948
+ acceptCodecs: array(StreamCodecSchema).readonly().optional(),
6949
+ /** Minimum vertical resolution. Streams shorter than this are dropped. */
6950
+ minHeight: number().int().positive().optional(),
6951
+ /** Minimum horizontal resolution. */
6952
+ minWidth: number().int().positive().optional(),
6953
+ /**
6954
+ * When true (default), `derived:*` streams (transcoded broker pipes)
6955
+ * are excluded — the caller wants a SOURCE stream the broker can
6956
+ * forward without a re-encode. Set false to allow derived in the
6957
+ * picker (useful for a "best playable" fallback).
6958
+ */
6959
+ excludeDerived: boolean().optional(),
6960
+ /**
6961
+ * Bypass gate. When set, the picker returns a hit ONLY IF the device
6962
+ * also exposes a non-derived stream in one of these (rejected) codecs.
6963
+ * Mirrors the Alexa "bypass only when the natural path WOULD have
6964
+ * transcoded" guard: if the device is already serving the consumer's
6965
+ * codec end-to-end, there's nothing to optimise.
6966
+ */
6967
+ requireSiblingCodec: array(StreamCodecSchema).readonly().optional()
6968
+ }).readonly();
6969
+ const PickStreamPreferencesSchema = object({
6970
+ /**
6971
+ * Ordered list of provider prefixes (e.g. `['native:', 'rtsp:']`) the
6972
+ * picker prefers — earlier entries win ties. Defaults to
6973
+ * `['native:']`, mirroring Alexa's "prefer the native dial path".
6974
+ */
6975
+ preferredProviders: array(string()).readonly().optional(),
6976
+ /**
6977
+ * Resolution preference within the surviving candidates. `'highest'`
6978
+ * picks the tallest stream; `'lowest'` picks the shortest (used by
6979
+ * memory-constrained consumers / Apple Home guest sessions).
6980
+ */
6981
+ resolutionPreference: _enum(["highest", "lowest"]).optional()
6982
+ }).readonly();
6983
+ const PickedCamStreamSchema = object({
6984
+ camStreamId: string(),
6985
+ codec: string().optional(),
6986
+ resolution: CamStreamResolutionSchema.optional(),
6987
+ /** One-line explanation of why this stream won — for logs / debug UI. */
6988
+ reason: string()
6989
+ });
6401
6990
  ({
6402
6991
  deviceTypes: [DeviceType.Camera],
6403
6992
  methods: {
@@ -6410,17 +6999,16 @@ const RtpSourceSchema = object({
6410
6999
  array(ProfileSlotSchema).readonly()
6411
7000
  ),
6412
7001
  /**
6413
- * Per-device RTSP restream entries. Returns the broker's published
6414
- * RTSP URLs (one per `${deviceId}/${profile}`) for THIS device only,
6415
- * including the rendered `url` field with the RTSP token applied.
6416
- * Consumers (snapshot wrapper, recording, external probes) use this
6417
- * to pick a stream URL without scanning the whole cluster.
7002
+ * Per-device RAW RTSP restream entries one per published camStream
7003
+ * that has RTSP restream enabled (`native:main`, `rtsp:sub`, ).
6418
7004
  *
6419
- * The system `stream-broker.getAllRtspEntries({hostname?})` still
6420
- * exists for whole-cluster use cases (admin dashboard, settings
6421
- * exports). This device-scoped accessor is the supported handle for
6422
- * code that already has a `deviceId` in hand keeps device-keyed
6423
- * filtering server-side and rides the DeviceProxy auto-injection.
7005
+ * LIVE-VIEW ONLY. This is the surface the device-details stream
7006
+ * picker uses so an operator can hit each physical stream directly.
7007
+ * Programmatic / external consumers (HAP, Alexa, ha-mqtt, recording)
7008
+ * MUST use `getProfileRtspEntries` insteadpicking from raw
7009
+ * variants makes two consumers of the same camera land on two
7010
+ * different physical pulls (e.g. Reolink `native:main` vs
7011
+ * `rtsp:main`) and trips the camera's concurrent-session limit.
6424
7012
  */
6425
7013
  getRtspEntries: method(
6426
7014
  object({
@@ -6429,6 +7017,43 @@ const RtpSourceSchema = object({
6429
7017
  hostname: string().optional()
6430
7018
  }),
6431
7019
  array(RtspRestreamEntrySchema).readonly()
7020
+ ),
7021
+ /**
7022
+ * Per-device PROFILE RTSP restream entries — one per ASSIGNED
7023
+ * profile slot (high/mid/low). Each entry's `url` is a profile-keyed
7024
+ * broker restream that aliases the profile's assigned source broker,
7025
+ * so HAP / Alexa / recording / WebRTC all converge on the broker's
7026
+ * single on-demand pull for that profile. This is the supported
7027
+ * exporter-facing surface; the raw `getRtspEntries` is live-view
7028
+ * only. Returns `[]` for a device with no assigned profiles
7029
+ * (cold-start before first publish).
7030
+ */
7031
+ getProfileRtspEntries: method(
7032
+ object({
7033
+ deviceId: number().int().nonnegative(),
7034
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
7035
+ hostname: string().optional()
7036
+ }),
7037
+ array(ProfileRtspEntrySchema).readonly()
7038
+ ),
7039
+ /**
7040
+ * "Best source stream for these decode constraints". Returns the
7041
+ * camStreamId the caller should dial (or null when no stream
7042
+ * matches). See `PickStreamRequirementsSchema` for the filter shape
7043
+ * and `PickStreamPreferencesSchema` for the ranking inputs.
7044
+ *
7045
+ * Returning null instructs the caller to fall back to its existing
7046
+ * path (derived-broker transcode, profile-slot pick, etc.) — the
7047
+ * picker NEVER ranks `derived:*` candidates as a "match" because
7048
+ * its whole job is to avoid the transcode.
7049
+ */
7050
+ pickStream: method(
7051
+ object({
7052
+ deviceId: number().int().nonnegative(),
7053
+ requirements: PickStreamRequirementsSchema,
7054
+ preferences: PickStreamPreferencesSchema.optional()
7055
+ }),
7056
+ PickedCamStreamSchema.nullable()
6432
7057
  )
6433
7058
  },
6434
7059
  events: {
@@ -6481,35 +7106,434 @@ const RtpSourceSchema = object({
6481
7106
  lastChangedAt: number()
6482
7107
  })
6483
7108
  });
6484
- const DiscoveredChildStatusSchema = _enum(["online", "sleeping", "offline", "unknown"]);
6485
- const DiscoveredChildDeviceSchema = object({
6486
- /** Stable, integration-defined identifier. Mirrors Scrypted's `nativeId`. */
6487
- childNativeId: string(),
6488
- /** Friendly name as reported by the source (Reolink camera name, ONVIF profile, ...). */
6489
- name: string(),
6490
- /** DeviceType the child should be created with on adopt. */
6491
- type: _enum(DeviceType),
6492
- status: DiscoveredChildStatusSchema,
6493
- /** Free-form integration-specific metadata surfaced in the panel. */
6494
- metadata: object({
6495
- model: string().optional(),
6496
- serialNumber: string().optional(),
6497
- uid: string().optional(),
6498
- /** Reolink: 0-based channel index inside the parent NVR/Hub. */
6499
- rtspChannel: number().int().nonnegative().optional(),
6500
- isBattery: boolean().optional(),
6501
- isDoorbell: boolean().optional(),
6502
- isMultifocal: boolean().optional()
6503
- }).default({}),
6504
- /**
6505
- * `true` when the framework already created a child device for this
6506
- * `childNativeId` under the current parent. The panel uses it to
6507
- * gate the Add/Remove button and surface the existing `deviceId`.
6508
- */
6509
- alreadyAdopted: boolean(),
6510
- /** When `alreadyAdopted=true`, the framework-assigned child device id. */
6511
- adoptedDeviceId: number().int().nonnegative().nullable()
7109
+ object({
7110
+ detected: boolean(),
7111
+ /** Ms epoch of the last transition. 0 if never observed. */
7112
+ lastChangedAt: number()
7113
+ });
7114
+ ({
7115
+ deviceTypes: [DeviceType.Sensor]
7116
+ });
7117
+ const HvacModeSchema = _enum([
7118
+ "off",
7119
+ "heat",
7120
+ "cool",
7121
+ "auto",
7122
+ "heat_cool",
7123
+ "fan_only",
7124
+ "dry"
7125
+ ]);
7126
+ object({
7127
+ /** Active HVAC mode. */
7128
+ mode: HvacModeSchema,
7129
+ /** Available HVAC modes the device accepts. Subset of HvacMode. */
7130
+ availableModes: array(HvacModeSchema),
7131
+ /** Active fan mode (`auto` / `low` / `medium` / `high` / `on` / `off`
7132
+ * / vendor-specific) empty string when the device has no fan
7133
+ * surface. */
7134
+ fanMode: string(),
7135
+ /** Available fan modes the device accepts. */
7136
+ availableFanModes: array(string()),
7137
+ /** Active preset (`eco` / `away` / `sleep` / vendor-specific) —
7138
+ * empty string when no preset is active or the device has no
7139
+ * preset surface. */
7140
+ preset: string(),
7141
+ /** Available presets the device accepts. */
7142
+ availablePresets: array(string()),
7143
+ /** Single setpoint in Celsius. Used by single-target modes
7144
+ * (heat / cool / dry). Null when the device is in a range mode
7145
+ * (`heat_cool`) or has no setpoint at all (`fan_only`/`off`). */
7146
+ target: number().nullable(),
7147
+ /** Upper bound of the dual setpoint in Celsius. Populated only
7148
+ * in `heat_cool` mode. */
7149
+ targetHigh: number().nullable(),
7150
+ /** Lower bound of the dual setpoint in Celsius. Populated only
7151
+ * in `heat_cool` mode. */
7152
+ targetLow: number().nullable(),
7153
+ /** Target relative humidity (0..100). Null when the device has no
7154
+ * humidity control. */
7155
+ targetHumidity: number().min(0).max(100).nullable(),
7156
+ /** Current measured temperature in Celsius. Read-only — pushed by
7157
+ * the device's internal sensor. */
7158
+ currentTemp: number().nullable(),
7159
+ /** Current measured relative humidity (0..100). Read-only. */
7160
+ currentHumidity: number().min(0).max(100).nullable(),
7161
+ /** Ms epoch when the slice was last updated (push or command). */
7162
+ lastFetchedAt: number()
7163
+ });
7164
+ ({
7165
+ deviceTypes: [DeviceType.Thermostat],
7166
+ methods: {
7167
+ setMode: method(
7168
+ object({
7169
+ deviceId: number().int().nonnegative(),
7170
+ mode: HvacModeSchema
7171
+ }),
7172
+ _void(),
7173
+ { kind: "mutation", auth: "admin" }
7174
+ ),
7175
+ setFanMode: method(
7176
+ object({
7177
+ deviceId: number().int().nonnegative(),
7178
+ fanMode: string().min(1)
7179
+ }),
7180
+ _void(),
7181
+ { kind: "mutation", auth: "admin" }
7182
+ ),
7183
+ setPreset: method(
7184
+ object({
7185
+ deviceId: number().int().nonnegative(),
7186
+ preset: string().min(1)
7187
+ }),
7188
+ _void(),
7189
+ { kind: "mutation", auth: "admin" }
7190
+ ),
7191
+ setTarget: method(
7192
+ object({
7193
+ deviceId: number().int().nonnegative(),
7194
+ target: number()
7195
+ }),
7196
+ _void(),
7197
+ { kind: "mutation", auth: "admin" }
7198
+ ),
7199
+ setTargetRange: method(
7200
+ object({
7201
+ deviceId: number().int().nonnegative(),
7202
+ targetLow: number(),
7203
+ targetHigh: number()
7204
+ }),
7205
+ _void(),
7206
+ { kind: "mutation", auth: "admin" }
7207
+ ),
7208
+ setTargetHumidity: method(
7209
+ object({
7210
+ deviceId: number().int().nonnegative(),
7211
+ targetHumidity: number().min(0).max(100)
7212
+ }),
7213
+ _void(),
7214
+ { kind: "mutation", auth: "admin" }
7215
+ )
7216
+ }
6512
7217
  });
7218
+ const RgbTripletSchema = object({
7219
+ r: number().int().min(0).max(255),
7220
+ g: number().int().min(0).max(255),
7221
+ b: number().int().min(0).max(255)
7222
+ });
7223
+ const HsvTripletSchema = object({
7224
+ /** Hue in degrees, 0..360. */
7225
+ h: number().min(0).max(360),
7226
+ /** Saturation as 0..100 inclusive. */
7227
+ s: number().min(0).max(100),
7228
+ /** Value/brightness as 0..100 inclusive. */
7229
+ v: number().min(0).max(100)
7230
+ });
7231
+ const ColorInputSchema = discriminatedUnion("mode", [
7232
+ object({ mode: literal("rgb"), rgb: RgbTripletSchema }),
7233
+ object({ mode: literal("hsv"), hsv: HsvTripletSchema }),
7234
+ object({ mode: literal("mired"), mireds: number().int().min(50).max(1e3) })
7235
+ ]);
7236
+ object({
7237
+ /** Active color mode — which of `rgb` / `hsv` / `mireds` reflects
7238
+ * the bulb's current state. */
7239
+ mode: _enum(["rgb", "hsv", "mired"]),
7240
+ /** Populated when `mode === 'rgb'`. */
7241
+ rgb: RgbTripletSchema.optional(),
7242
+ /** Populated when `mode === 'hsv'`. */
7243
+ hsv: HsvTripletSchema.optional(),
7244
+ /** Populated when `mode === 'mired'`. */
7245
+ mireds: number().int().optional(),
7246
+ /** Ms epoch of the last operator-driven change. */
7247
+ lastChangedAt: number()
7248
+ });
7249
+ ({
7250
+ deviceTypes: [DeviceType.Light],
7251
+ methods: {
7252
+ setColor: method(
7253
+ object({
7254
+ deviceId: number().int().nonnegative(),
7255
+ color: ColorInputSchema
7256
+ }),
7257
+ _void(),
7258
+ { kind: "mutation", auth: "admin" }
7259
+ )
7260
+ },
7261
+ events: {
7262
+ /**
7263
+ * Emitted whenever the color changes — operator action OR firmware
7264
+ * push. Subscribers (UI color pickers, automation engines) react
7265
+ * without polling.
7266
+ */
7267
+ onColorChanged: { data: object({
7268
+ deviceId: number(),
7269
+ mode: _enum(["rgb", "hsv", "mired"]),
7270
+ rgb: RgbTripletSchema.optional(),
7271
+ hsv: HsvTripletSchema.optional(),
7272
+ mireds: number().int().optional(),
7273
+ lastChangedAt: number()
7274
+ }) }
7275
+ }
7276
+ });
7277
+ object({
7278
+ /** True when the upstream system considers the entity connected. */
7279
+ connected: boolean(),
7280
+ /** Ms epoch of the last transition. 0 if never observed. */
7281
+ lastChangedAt: number()
7282
+ });
7283
+ ({
7284
+ deviceTypes: [DeviceType.Sensor]
7285
+ });
7286
+ const ConsumableItemSchema = object({
7287
+ /** Stable id, e.g. 'main-brush'. */
7288
+ key: string().min(1),
7289
+ /** Display name. */
7290
+ label: string().min(1),
7291
+ /** Remaining life % when known (0..100). */
7292
+ level: number().min(0).max(100).nullable(),
7293
+ /** Discrete state when known (binary mode). */
7294
+ status: _enum(["ok", "replace"]).nullable(),
7295
+ /** Ms epoch of the last replace, when known. */
7296
+ lastResetAt: number().nullable(),
7297
+ /** Whether `reset()` is meaningful for this item. */
7298
+ resettable: boolean()
7299
+ });
7300
+ const ConsumablesStatusSchema = object({
7301
+ items: array(ConsumableItemSchema),
7302
+ lastChangedAt: number()
7303
+ });
7304
+ ({
7305
+ // Device-agnostic — any device may expose consumables, so the cap
7306
+ // binds on EVERY DeviceType. The list enumerates every enum member
7307
+ // (kept in sync with `device-type.ts`) rather than a subset, so a
7308
+ // provider on any device type — vacuums/mowers included — can register
7309
+ // it and no future device type silently fails to bind.
7310
+ deviceTypes: [
7311
+ DeviceType.Camera,
7312
+ DeviceType.Hub,
7313
+ DeviceType.Light,
7314
+ DeviceType.Siren,
7315
+ DeviceType.Switch,
7316
+ DeviceType.Sensor,
7317
+ DeviceType.Thermostat,
7318
+ DeviceType.Button,
7319
+ DeviceType.EventEmitter,
7320
+ DeviceType.Update,
7321
+ DeviceType.Generic,
7322
+ DeviceType.Notifier,
7323
+ DeviceType.Script,
7324
+ DeviceType.Automation,
7325
+ DeviceType.Lock,
7326
+ DeviceType.Cover,
7327
+ DeviceType.Valve,
7328
+ DeviceType.Humidifier,
7329
+ DeviceType.WaterHeater,
7330
+ DeviceType.Fan,
7331
+ DeviceType.MediaPlayer,
7332
+ DeviceType.AlarmPanel,
7333
+ DeviceType.Control,
7334
+ DeviceType.Presence,
7335
+ DeviceType.Weather,
7336
+ DeviceType.Vacuum,
7337
+ DeviceType.LawnMower,
7338
+ DeviceType.Container,
7339
+ DeviceType.Image
7340
+ ],
7341
+ methods: {
7342
+ /** Mark a consumable as replaced — resets its remaining life. Only
7343
+ * meaningful when the item's `resettable` is true. */
7344
+ reset: method(
7345
+ object({ deviceId: number().int().nonnegative(), key: string().min(1) }),
7346
+ _void(),
7347
+ { kind: "mutation", auth: "admin" }
7348
+ )
7349
+ },
7350
+ // Per the cap checklist, runtimeState carries the bridge helper's
7351
+ // freshness field. The status surface stays the plain schema; only the
7352
+ // persistent slice gains `lastFetchedAt` so future poll-backed
7353
+ // providers can use the runtime-state bridge unchanged.
7354
+ runtimeState: ConsumablesStatusSchema.extend({ lastFetchedAt: number() })
7355
+ });
7356
+ object({
7357
+ /** True when the entry is open; false when closed. */
7358
+ entryOpen: boolean(),
7359
+ /** Ms epoch of the last open↔closed transition. 0 if never observed. */
7360
+ lastChangedAt: number()
7361
+ });
7362
+ ({
7363
+ deviceTypes: [DeviceType.Sensor]
7364
+ });
7365
+ const ControlKindSchema = _enum(["numeric", "select", "text", "datetime"]);
7366
+ object({
7367
+ kind: ControlKindSchema,
7368
+ /** Current value. Numeric kind → number; select/text/datetime → string.
7369
+ * Datetime values are ISO 8601 strings (full timestamp, date-only,
7370
+ * or time-only — HA accepts all three forms). */
7371
+ value: union([number(), string()]),
7372
+ /** Acceptable values when `kind === 'select'`. Empty for other kinds. */
7373
+ options: array(string()),
7374
+ /** Ms epoch when the slice was last updated. */
7375
+ lastChangedAt: number(),
7376
+ /** Display unit-of-measurement (e.g. '°C', '%', 'lx').
7377
+ * Populated live from HA `attributes.unit_of_measurement` on each push.
7378
+ * Meaningful for `kind === 'numeric'`; absent for other kinds. */
7379
+ unit: string().optional(),
7380
+ /** Minimum allowed value. Populated live from HA `attributes.min`.
7381
+ * Meaningful for `kind === 'numeric'`; absent for other kinds. */
7382
+ min: number().optional(),
7383
+ /** Maximum allowed value. Populated live from HA `attributes.max`.
7384
+ * Meaningful for `kind === 'numeric'`; absent for other kinds. */
7385
+ max: number().optional(),
7386
+ /** Step increment. Populated live from HA `attributes.step`.
7387
+ * Meaningful for `kind === 'numeric'`; absent for other kinds. */
7388
+ step: number().optional(),
7389
+ /** Suggested decimal places for numeric display. Populated live from HA
7390
+ * `attributes.suggested_display_precision`, or derived from `step` when the
7391
+ * HA attribute is absent. Meaningful for `kind === 'numeric'`; absent for
7392
+ * other kinds. Falls back to auto-formatting when absent. */
7393
+ precision: number().int().min(0).max(10).optional(),
7394
+ /** Date-picker granularity for `kind === 'datetime'`. 'date' → date-only,
7395
+ * 'time' → time-only, 'datetime' → full timestamp. Absent for other kinds
7396
+ * (UI defaults to 'datetime'). Derived live from the device's HA domain
7397
+ * (date.* / time.* / datetime.*) or, for input_datetime, from the entity's
7398
+ * `has_date`/`has_time` attributes on each push. */
7399
+ format: _enum(["date", "time", "datetime"]).optional()
7400
+ });
7401
+ const ControlSetValueInputSchema = discriminatedUnion("kind", [
7402
+ object({ kind: literal("numeric"), value: number() }),
7403
+ object({ kind: literal("select"), value: string().min(1) }),
7404
+ object({ kind: literal("text"), value: string() }),
7405
+ object({ kind: literal("datetime"), value: string().min(1) })
7406
+ ]);
7407
+ ({
7408
+ deviceTypes: [DeviceType.Control],
7409
+ methods: {
7410
+ setValue: method(
7411
+ object({
7412
+ deviceId: number().int().nonnegative(),
7413
+ control: ControlSetValueInputSchema
7414
+ }),
7415
+ _void(),
7416
+ { kind: "mutation", auth: "admin" }
7417
+ )
7418
+ }
7419
+ });
7420
+ const CoverStateSchema = _enum([
7421
+ "open",
7422
+ "opening",
7423
+ "closing",
7424
+ "closed",
7425
+ "stopped"
7426
+ ]);
7427
+ object({
7428
+ /** Lifecycle state of the cover. */
7429
+ state: CoverStateSchema,
7430
+ /** 0 = fully closed, 100 = fully open. Null when the device has
7431
+ * no intermediate position surface. */
7432
+ position: number().min(0).max(100).nullable(),
7433
+ /** 0 = closed slats, 100 = open slats. Null when no tilt support. */
7434
+ tiltPosition: number().min(0).max(100).nullable(),
7435
+ /** Ms epoch when the slice was last updated. */
7436
+ lastChangedAt: number()
7437
+ });
7438
+ ({
7439
+ deviceTypes: [DeviceType.Cover],
7440
+ methods: {
7441
+ open: method(
7442
+ object({ deviceId: number().int().nonnegative() }),
7443
+ _void(),
7444
+ { kind: "mutation", auth: "admin" }
7445
+ ),
7446
+ close: method(
7447
+ object({ deviceId: number().int().nonnegative() }),
7448
+ _void(),
7449
+ { kind: "mutation", auth: "admin" }
7450
+ ),
7451
+ stop: method(
7452
+ object({ deviceId: number().int().nonnegative() }),
7453
+ _void(),
7454
+ { kind: "mutation", auth: "admin" }
7455
+ ),
7456
+ setPosition: method(
7457
+ object({
7458
+ deviceId: number().int().nonnegative(),
7459
+ position: number().min(0).max(100)
7460
+ }),
7461
+ _void(),
7462
+ { kind: "mutation", auth: "admin" }
7463
+ ),
7464
+ setTiltPosition: method(
7465
+ object({
7466
+ deviceId: number().int().nonnegative(),
7467
+ tiltPosition: number().min(0).max(100)
7468
+ }),
7469
+ _void(),
7470
+ { kind: "mutation", auth: "admin" }
7471
+ )
7472
+ }
7473
+ });
7474
+ const SourceInfoSchema = object({
7475
+ // ── Identity (required) ─────────────────────────────────────────
7476
+ /** Live dispatch key — mutable when the source system allows rename. */
7477
+ id: string(),
7478
+ /** Source system tag — e.g. 'homeassistant' | 'reolink' | 'frigate'. */
7479
+ system: string(),
7480
+ // ── Stability (optional) ────────────────────────────────────────
7481
+ /** Immutable upstream identifier when available (HA `unique_id`, … ).
7482
+ * Used to detect rename when `id` changes. */
7483
+ uniqueId: string().optional(),
7484
+ // ── Free-form passthrough ───────────────────────────────────────
7485
+ /** Provider-specific extras that don't fit the structured slots.
7486
+ * Whitelist what goes here per provider — do NOT dump entire HA
7487
+ * attribute blobs (would explode the meta JSON column). */
7488
+ raw: record(string(), unknown()).optional()
7489
+ });
7490
+ const DiscoveredChildStatusSchema = _enum(["online", "sleeping", "offline", "unknown"]);
7491
+ const DiscoveredChildDeviceSchema = lazy(
7492
+ () => object({
7493
+ /** Stable, integration-defined identifier. Must be unique per parent and persistent across reboots. */
7494
+ childNativeId: string(),
7495
+ /** Friendly name as reported by the source (Reolink camera name, ONVIF profile, ...). */
7496
+ name: string(),
7497
+ /** DeviceType the child should be created with on adopt. */
7498
+ type: _enum(DeviceType),
7499
+ status: DiscoveredChildStatusSchema,
7500
+ /** Free-form integration-specific metadata surfaced in the panel. */
7501
+ metadata: object({
7502
+ model: string().optional(),
7503
+ serialNumber: string().optional(),
7504
+ uid: string().optional(),
7505
+ /** Reolink: 0-based channel index inside the parent NVR/Hub. */
7506
+ rtspChannel: number().int().nonnegative().optional(),
7507
+ isBattery: boolean().optional(),
7508
+ isDoorbell: boolean().optional(),
7509
+ isMultifocal: boolean().optional(),
7510
+ externalLocation: string().optional(),
7511
+ /** Vendor / manufacturer name as reported by the source (HA device
7512
+ * `manufacturer`, …). Surfaced in the adoption modal's fuzzy search. */
7513
+ manufacturer: string().optional(),
7514
+ /** Integration / platform domain the candidate belongs to (HA
7515
+ * `entity_registry.platform`, e.g. `dreame_vacuum`). Surfaced in the
7516
+ * adoption modal's fuzzy search. */
7517
+ integration: string().optional()
7518
+ }).default({}),
7519
+ /**
7520
+ * `true` when the framework already created a child device for this
7521
+ * `childNativeId` under the current parent. The panel uses it to
7522
+ * gate the Add/Remove button and surface the existing `deviceId`.
7523
+ */
7524
+ alreadyAdopted: boolean(),
7525
+ /** When `alreadyAdopted=true`, the framework-assigned child device id. */
7526
+ adoptedDeviceId: number().int().nonnegative().nullable(),
7527
+ /** Capability names the candidate advertises — integrations populate, Hubs omit. */
7528
+ capabilities: array(string()).readonly().optional(),
7529
+ /** Feature flags the candidate advertises — integrations populate, Hubs omit. */
7530
+ features: array(string()).readonly().optional(),
7531
+ /** Upstream-system identity + rendering hints for the candidate. */
7532
+ sourceInfo: SourceInfoSchema.optional(),
7533
+ /** Nested entity-children for accordion preview — integrations populate, Hubs omit. */
7534
+ children: array(DiscoveredChildDeviceSchema).readonly().optional()
7535
+ })
7536
+ );
6513
7537
  const DeviceDiscoveryStatusSchema = object({
6514
7538
  discovered: array(DiscoveredChildDeviceSchema),
6515
7539
  /** Wall-clock ms of the last successful enumeration. */
@@ -6595,6 +7619,416 @@ object({
6595
7619
  ({
6596
7620
  deviceTypes: [DeviceType.Button]
6597
7621
  });
7622
+ const EnumSensorDateTimeFormatSchema = _enum(["date", "time", "datetime"]);
7623
+ object({
7624
+ value: string(),
7625
+ /**
7626
+ * Set for `DateTimeSensor`-role sensors so the UI renders the ISO `value`
7627
+ * as a locale date/time/datetime; absent for plain enum sensors. HA
7628
+ * `device_class=timestamp` → `'datetime'`, `device_class=date` → `'date'`,
7629
+ * a time-only sensor → `'time'`.
7630
+ */
7631
+ format: EnumSensorDateTimeFormatSchema.optional(),
7632
+ /** Ms epoch when the slice was last updated. */
7633
+ lastFetchedAt: number()
7634
+ });
7635
+ ({
7636
+ deviceTypes: [DeviceType.Sensor]
7637
+ });
7638
+ const EventFireSchema = object({
7639
+ deviceId: number(),
7640
+ eventType: string(),
7641
+ data: record(string(), unknown()).nullable(),
7642
+ timestamp: number(),
7643
+ seq: number()
7644
+ });
7645
+ object({
7646
+ eventTypes: array(string()),
7647
+ lastEvent: EventFireSchema.nullable(),
7648
+ eventCountSinceStart: number()
7649
+ });
7650
+ ({
7651
+ deviceTypes: [DeviceType.EventEmitter]
7652
+ });
7653
+ const FanDirectionSchema = _enum(["forward", "reverse"]);
7654
+ object({
7655
+ /** Active speed as 0..100 inclusive. Null when the device has no
7656
+ * speed surface (single-speed fan). */
7657
+ percentage: number().min(0).max(100).nullable(),
7658
+ /** Speed granularity the device accepts (HA `percentage_step`, e.g. 25 for
7659
+ * a 4-speed fan). Absent when unknown — the UI then falls back to step 1. */
7660
+ percentageStep: number().positive().optional(),
7661
+ /** Active preset mode (`auto` / `quiet` / `turbo` / vendor-specific)
7662
+ * — empty string when no preset is active or supported. */
7663
+ preset: string(),
7664
+ /** Available preset modes the device accepts. */
7665
+ availablePresets: array(string()),
7666
+ /** Ceiling-fan blade direction. Null when the device has no
7667
+ * direction surface. */
7668
+ direction: FanDirectionSchema.nullable(),
7669
+ /** Oscillation toggle. Null when the device has no oscillation
7670
+ * surface. */
7671
+ oscillating: boolean().nullable(),
7672
+ /** Ms epoch when the slice was last updated. */
7673
+ lastChangedAt: number()
7674
+ });
7675
+ ({
7676
+ deviceTypes: [DeviceType.Fan],
7677
+ methods: {
7678
+ setPercentage: method(
7679
+ object({
7680
+ deviceId: number().int().nonnegative(),
7681
+ percentage: number().min(0).max(100)
7682
+ }),
7683
+ _void(),
7684
+ { kind: "mutation", auth: "admin" }
7685
+ ),
7686
+ setPreset: method(
7687
+ object({
7688
+ deviceId: number().int().nonnegative(),
7689
+ preset: string().min(1)
7690
+ }),
7691
+ _void(),
7692
+ { kind: "mutation", auth: "admin" }
7693
+ ),
7694
+ setDirection: method(
7695
+ object({
7696
+ deviceId: number().int().nonnegative(),
7697
+ direction: FanDirectionSchema
7698
+ }),
7699
+ _void(),
7700
+ { kind: "mutation", auth: "admin" }
7701
+ ),
7702
+ setOscillating: method(
7703
+ object({
7704
+ deviceId: number().int().nonnegative(),
7705
+ oscillating: boolean()
7706
+ }),
7707
+ _void(),
7708
+ { kind: "mutation", auth: "admin" }
7709
+ )
7710
+ }
7711
+ });
7712
+ object({
7713
+ /** True when leak is currently detected. */
7714
+ flooded: boolean(),
7715
+ /** Ms epoch of the last flooded↔dry transition. 0 if never observed. */
7716
+ lastChangedAt: number()
7717
+ });
7718
+ ({
7719
+ deviceTypes: [DeviceType.Sensor]
7720
+ });
7721
+ object({
7722
+ detected: boolean(),
7723
+ /** Ms epoch of the last transition. 0 if never observed. */
7724
+ lastChangedAt: number()
7725
+ });
7726
+ ({
7727
+ deviceTypes: [DeviceType.Sensor]
7728
+ });
7729
+ object({
7730
+ /** Whether the humidifier is currently on. */
7731
+ on: boolean(),
7732
+ /** Current measured relative humidity (0..100). Null when not reported. */
7733
+ currentHumidity: number().min(0).max(100).nullable(),
7734
+ /** Target relative humidity (0..100). Null when no setpoint surface. */
7735
+ targetHumidity: number().min(0).max(100).nullable(),
7736
+ /** Active mode (`auto` / `normal` / `baby` / vendor). Null when the
7737
+ * device has no mode surface. */
7738
+ mode: string().nullable(),
7739
+ /** Available modes the device accepts. */
7740
+ availableModes: array(string()),
7741
+ /** HA `action` attribute, verbatim (`humidifying` / `drying` /
7742
+ * `idle` / `off`). Null when the device doesn't report it. */
7743
+ action: string().nullable(),
7744
+ /** HA `min_humidity` attribute. Null → UI uses 0. */
7745
+ minHumidity: number().nullable(),
7746
+ /** HA `max_humidity` attribute. Null → UI uses 100. */
7747
+ maxHumidity: number().nullable(),
7748
+ /** Ms epoch when the slice was last updated. */
7749
+ lastChangedAt: number()
7750
+ });
7751
+ ({
7752
+ deviceTypes: [DeviceType.Humidifier],
7753
+ methods: {
7754
+ setOn: method(
7755
+ object({
7756
+ deviceId: number().int().nonnegative(),
7757
+ on: boolean()
7758
+ }),
7759
+ _void(),
7760
+ { kind: "mutation", auth: "admin" }
7761
+ ),
7762
+ setTargetHumidity: method(
7763
+ object({
7764
+ deviceId: number().int().nonnegative(),
7765
+ humidity: number().min(0).max(100)
7766
+ }),
7767
+ _void(),
7768
+ { kind: "mutation", auth: "admin" }
7769
+ ),
7770
+ setMode: method(
7771
+ object({
7772
+ deviceId: number().int().nonnegative(),
7773
+ mode: string().min(1)
7774
+ }),
7775
+ _void(),
7776
+ { kind: "mutation", auth: "admin" }
7777
+ )
7778
+ }
7779
+ });
7780
+ object({
7781
+ /** Current relative humidity, 0..100. */
7782
+ percent: number().min(0).max(100),
7783
+ /** Ms epoch when the slice was last updated. */
7784
+ lastFetchedAt: number(),
7785
+ /** Live display unit from the upstream source (e.g. HA
7786
+ * `attributes.unit_of_measurement`). The UI prefers this over the
7787
+ * role's canonical unit. Absent → fall back to the canonical unit. */
7788
+ unit: string().optional(),
7789
+ /** Suggested decimal places for numeric display.
7790
+ * Populated live from the upstream source when provided (e.g. HA
7791
+ * `attributes.suggested_display_precision`). Falls back to
7792
+ * auto-formatting when absent. */
7793
+ precision: number().int().min(0).max(10).optional()
7794
+ });
7795
+ ({
7796
+ deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat]
7797
+ });
7798
+ object({
7799
+ /** Absolute signed URL the browser loads directly. Null when the
7800
+ * entity exposes no `entity_picture` (yet). */
7801
+ url: string().nullable(),
7802
+ /** Ms epoch of the upstream last-updated timestamp. Null at cold-start. */
7803
+ lastUpdated: number().nullable()
7804
+ });
7805
+ ({
7806
+ deviceTypes: [DeviceType.Image]
7807
+ });
7808
+ const LawnMowerActivitySchema = _enum([
7809
+ "idle",
7810
+ "mowing",
7811
+ "paused",
7812
+ "docked",
7813
+ "error"
7814
+ ]);
7815
+ object({
7816
+ /** Lifecycle activity of the mower. */
7817
+ activity: LawnMowerActivitySchema,
7818
+ /** 0..100 battery percentage. Null when the device has no battery
7819
+ * reading. */
7820
+ batteryLevel: number().min(0).max(100).nullable(),
7821
+ /** Ms epoch when the slice was last updated. */
7822
+ lastChangedAt: number()
7823
+ });
7824
+ ({
7825
+ deviceTypes: [DeviceType.LawnMower],
7826
+ methods: {
7827
+ startMowing: method(
7828
+ object({ deviceId: number().int().nonnegative() }),
7829
+ _void(),
7830
+ { kind: "mutation", auth: "admin" }
7831
+ ),
7832
+ pause: method(
7833
+ object({ deviceId: number().int().nonnegative() }),
7834
+ _void(),
7835
+ { kind: "mutation", auth: "admin" }
7836
+ ),
7837
+ dock: method(
7838
+ object({ deviceId: number().int().nonnegative() }),
7839
+ _void(),
7840
+ { kind: "mutation", auth: "admin" }
7841
+ )
7842
+ }
7843
+ });
7844
+ const LockStateSchema = _enum([
7845
+ "locked",
7846
+ "unlocked",
7847
+ "locking",
7848
+ "unlocking",
7849
+ "jammed"
7850
+ ]);
7851
+ object({
7852
+ /** Lifecycle state of the lock. `jammed` means the motor reported
7853
+ * failure to reach the target — operator intervention required. */
7854
+ state: LockStateSchema,
7855
+ /** Ms epoch when the slice was last updated. */
7856
+ lastChangedAt: number()
7857
+ });
7858
+ ({
7859
+ deviceTypes: [DeviceType.Lock],
7860
+ methods: {
7861
+ lock: method(
7862
+ object({
7863
+ deviceId: number().int().nonnegative(),
7864
+ /** Optional PIN code required by some keypad locks. NOT
7865
+ * persisted — passed directly to the upstream service. */
7866
+ code: string().min(1).optional()
7867
+ }),
7868
+ _void(),
7869
+ { kind: "mutation", auth: "admin" }
7870
+ ),
7871
+ unlock: method(
7872
+ object({
7873
+ deviceId: number().int().nonnegative(),
7874
+ code: string().min(1).optional()
7875
+ }),
7876
+ _void(),
7877
+ { kind: "mutation", auth: "admin" }
7878
+ ),
7879
+ open: method(
7880
+ object({
7881
+ deviceId: number().int().nonnegative()
7882
+ }),
7883
+ _void(),
7884
+ { kind: "mutation", auth: "admin" }
7885
+ )
7886
+ }
7887
+ });
7888
+ const MediaPlayerStateSchema = _enum([
7889
+ "off",
7890
+ "on",
7891
+ "idle",
7892
+ "playing",
7893
+ "paused",
7894
+ "buffering",
7895
+ "standby"
7896
+ ]);
7897
+ const MediaPlayerRepeatSchema = _enum(["off", "all", "one"]);
7898
+ const MediaInfoSchema = object({
7899
+ /** Free-form media kind (`music`, `tvshow`, `movie`, `app`, `channel`,
7900
+ * `podcast`, …). */
7901
+ type: string(),
7902
+ /** Human-readable title. */
7903
+ title: string(),
7904
+ /** Optional artist / channel / station label. */
7905
+ artist: string().optional(),
7906
+ /** Optional album / season / show name. */
7907
+ album: string().optional(),
7908
+ /** Optional cover-art / thumbnail URL. */
7909
+ imageUrl: string().optional()
7910
+ });
7911
+ object({
7912
+ /** Playback lifecycle state. */
7913
+ state: MediaPlayerStateSchema,
7914
+ /** Volume as 0..100 inclusive. Null when the device has no volume
7915
+ * surface. Pair with `DeviceFeature.MediaPlayerVolume`. */
7916
+ volumeLevel: number().min(0).max(100).nullable(),
7917
+ /** Mute toggle distinct from volume=0. Null when no mute surface.
7918
+ * Pair with `DeviceFeature.MediaPlayerMute`. */
7919
+ isMuted: boolean().nullable(),
7920
+ /** Active source / input. Empty when no source surface. */
7921
+ source: string(),
7922
+ /** Selectable sources. Empty when no source surface.
7923
+ * Pair with `DeviceFeature.MediaPlayerSelectSource`. */
7924
+ availableSources: array(string()),
7925
+ /** Currently-playing media info. Null when nothing is playing. */
7926
+ currentMedia: MediaInfoSchema.nullable(),
7927
+ /** Current playback position in ms. Null when not seekable or
7928
+ * nothing is playing. Pair with `DeviceFeature.MediaPlayerSeek`. */
7929
+ positionMs: number().int().nonnegative().nullable(),
7930
+ /** Total duration of the current media in ms. Null when unknown
7931
+ * (live stream) or nothing is playing. */
7932
+ durationMs: number().int().nonnegative().nullable(),
7933
+ /** Shuffle toggle. Null when no shuffle surface.
7934
+ * Pair with `DeviceFeature.MediaPlayerShuffle`. */
7935
+ shuffle: boolean().nullable(),
7936
+ /** Repeat mode. Null when no repeat surface.
7937
+ * Pair with `DeviceFeature.MediaPlayerRepeat`. */
7938
+ repeat: MediaPlayerRepeatSchema.nullable(),
7939
+ /** Ms epoch when the slice was last updated. */
7940
+ lastChangedAt: number()
7941
+ });
7942
+ ({
7943
+ deviceTypes: [DeviceType.MediaPlayer],
7944
+ methods: {
7945
+ play: method(
7946
+ object({ deviceId: number().int().nonnegative() }),
7947
+ _void(),
7948
+ { kind: "mutation", auth: "admin" }
7949
+ ),
7950
+ pause: method(
7951
+ object({ deviceId: number().int().nonnegative() }),
7952
+ _void(),
7953
+ { kind: "mutation", auth: "admin" }
7954
+ ),
7955
+ stop: method(
7956
+ object({ deviceId: number().int().nonnegative() }),
7957
+ _void(),
7958
+ { kind: "mutation", auth: "admin" }
7959
+ ),
7960
+ next: method(
7961
+ object({ deviceId: number().int().nonnegative() }),
7962
+ _void(),
7963
+ { kind: "mutation", auth: "admin" }
7964
+ ),
7965
+ previous: method(
7966
+ object({ deviceId: number().int().nonnegative() }),
7967
+ _void(),
7968
+ { kind: "mutation", auth: "admin" }
7969
+ ),
7970
+ seek: method(
7971
+ object({
7972
+ deviceId: number().int().nonnegative(),
7973
+ positionMs: number().int().nonnegative()
7974
+ }),
7975
+ _void(),
7976
+ { kind: "mutation", auth: "admin" }
7977
+ ),
7978
+ setVolume: method(
7979
+ object({
7980
+ deviceId: number().int().nonnegative(),
7981
+ volumeLevel: number().min(0).max(100)
7982
+ }),
7983
+ _void(),
7984
+ { kind: "mutation", auth: "admin" }
7985
+ ),
7986
+ setMute: method(
7987
+ object({
7988
+ deviceId: number().int().nonnegative(),
7989
+ muted: boolean()
7990
+ }),
7991
+ _void(),
7992
+ { kind: "mutation", auth: "admin" }
7993
+ ),
7994
+ setShuffle: method(
7995
+ object({
7996
+ deviceId: number().int().nonnegative(),
7997
+ shuffle: boolean()
7998
+ }),
7999
+ _void(),
8000
+ { kind: "mutation", auth: "admin" }
8001
+ ),
8002
+ setRepeat: method(
8003
+ object({
8004
+ deviceId: number().int().nonnegative(),
8005
+ repeat: MediaPlayerRepeatSchema
8006
+ }),
8007
+ _void(),
8008
+ { kind: "mutation", auth: "admin" }
8009
+ ),
8010
+ selectSource: method(
8011
+ object({
8012
+ deviceId: number().int().nonnegative(),
8013
+ source: string().min(1)
8014
+ }),
8015
+ _void(),
8016
+ { kind: "mutation", auth: "admin" }
8017
+ ),
8018
+ playMedia: method(
8019
+ object({
8020
+ deviceId: number().int().nonnegative(),
8021
+ /** Media identifier / URL. */
8022
+ mediaId: string().min(1),
8023
+ /** Media kind (`music`, `tvshow`, `movie`, `app`, …) — provider
8024
+ * passes it through to the upstream service. */
8025
+ mediaType: string().min(1)
8026
+ }),
8027
+ _void(),
8028
+ { kind: "mutation", auth: "admin" }
8029
+ )
8030
+ }
8031
+ });
6598
8032
  const FrameFormatSchema = _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]);
6599
8033
  const FrameInputSchema = object({
6600
8034
  data: custom(),
@@ -7001,8 +8435,8 @@ const PipelineRunResultBridge = custom();
7001
8435
  * calls. Single root step + uniform model assumed; trees with crop
7002
8436
  * children fall back to sequential execution.
7003
8437
  *
7004
- * Used by `scripts/bench-scrypted-style.mts` to mirror Scrypted's
7005
- * `detectObjects(media, {batch})` semantics for fair comparison.
8438
+ * Used by `scripts/bench-batch-style.mts` for batch benchmarking —
8439
+ * N frames in one call to amortise per-call IPC overhead.
7006
8440
  */
7007
8441
  runPipelineBatch: method(
7008
8442
  object({
@@ -7383,8 +8817,8 @@ object({
7383
8817
  lastDetectedAt: number().nullable(),
7384
8818
  /**
7385
8819
  * Ms after which `detected` auto-reverts to false if no fresh push
7386
- * arrives. Mirrors the scrypted-reolink-native default. Null means
7387
- * the provider leaves detected state until a native "clear" event.
8820
+ * arrives. Null means the provider leaves detected state until a
8821
+ * native "clear" event.
7388
8822
  */
7389
8823
  autoClearAfterMs: number().nullable()
7390
8824
  });
@@ -7570,6 +9004,194 @@ NativeObjectDetectionStatusSchema.extend({
7570
9004
  }) }
7571
9005
  }
7572
9006
  });
9007
+ const NotifierPrioritySchema = _enum(["min", "low", "normal", "high"]);
9008
+ const NotifierActionSchema = object({
9009
+ /** Stable id used in the callback when the user taps the button. */
9010
+ id: string().min(1),
9011
+ /** User-visible button label. */
9012
+ title: string().min(1),
9013
+ /** Optional deep-link URI invoked on tap (rich notifiers only). */
9014
+ uri: url().optional(),
9015
+ /** Optional flag — when true, the action is destructive and the
9016
+ * client should render the button in a warning style. */
9017
+ destructive: boolean().optional()
9018
+ });
9019
+ const NotifierSupportsSchema = object({
9020
+ /** Inline / URL image attachment. Pair with `DeviceFeature.NotifierImage`. */
9021
+ image: boolean(),
9022
+ /** Priority hint. Pair with `DeviceFeature.NotifierPriority`. */
9023
+ priority: boolean(),
9024
+ /** Free-form platform-specific data block.
9025
+ * Pair with `DeviceFeature.NotifierData`. */
9026
+ data: boolean(),
9027
+ /** Interactive action buttons. Pair with `DeviceFeature.NotifierActions`. */
9028
+ actions: boolean(),
9029
+ /** Per-call recipient targeting (multi-user notifiers).
9030
+ * Pair with `DeviceFeature.NotifierRecipients`. */
9031
+ recipients: boolean()
9032
+ });
9033
+ object({
9034
+ /** Ms epoch of the most recent successful send. 0 if none yet. */
9035
+ lastSentAt: number(),
9036
+ /** Failure description from the most recent send attempt. Null on
9037
+ * success or when nothing has been sent yet. */
9038
+ lastError: string().nullable(),
9039
+ /** Number of deliveries currently buffered server-side (rate-limited
9040
+ * notifiers may queue). 0 when the provider sends synchronously. */
9041
+ queueDepth: number().int().nonnegative(),
9042
+ /** Per-feature capability matrix — authoritative source for the
9043
+ * compose-form field gating. */
9044
+ supports: NotifierSupportsSchema
9045
+ });
9046
+ const NotifierSendInputSchema = object({
9047
+ deviceId: number().int().nonnegative(),
9048
+ /** Optional title — many platforms render it bolder than the body. */
9049
+ title: string().optional(),
9050
+ /** Required message body. */
9051
+ body: string().min(1),
9052
+ /** Optional image URL or inline data URI (`data:image/...;base64,...`). */
9053
+ image: string().optional(),
9054
+ /** Priority hint — providers without priority support ignore the field. */
9055
+ priority: NotifierPrioritySchema.optional(),
9056
+ /** Channel / topic / tag the notifier should route through (Android
9057
+ * notification channel, Telegram chat id, ntfy topic, …). */
9058
+ channel: string().optional(),
9059
+ /** Optional list of recipient ids (multi-user notifiers). Empty /
9060
+ * omitted = the device's default recipient. */
9061
+ recipients: array(string()).optional(),
9062
+ /** Optional interactive action buttons. */
9063
+ actions: array(NotifierActionSchema).optional(),
9064
+ /** Free-form platform-specific payload — passed through to the
9065
+ * upstream service untouched. */
9066
+ data: record(string(), unknown()).optional()
9067
+ });
9068
+ const NotifierSendResultSchema = object({
9069
+ /** Provider-assigned id for the delivery. Used for `cancel`. */
9070
+ notificationId: string(),
9071
+ /** Ms epoch when the notifier accepted the send (not when delivered). */
9072
+ acceptedAt: number()
9073
+ });
9074
+ ({
9075
+ deviceTypes: [DeviceType.Notifier],
9076
+ methods: {
9077
+ send: method(
9078
+ NotifierSendInputSchema,
9079
+ NotifierSendResultSchema,
9080
+ { kind: "mutation", auth: "admin" }
9081
+ ),
9082
+ cancel: method(
9083
+ object({
9084
+ deviceId: number().int().nonnegative(),
9085
+ notificationId: string().min(1)
9086
+ }),
9087
+ _void(),
9088
+ { kind: "mutation", auth: "admin" }
9089
+ )
9090
+ },
9091
+ events: {
9092
+ /**
9093
+ * Emitted after every send attempt — success or failure. Subscribers
9094
+ * (admin UI history pane, automation engines, retry workers) react
9095
+ * without polling the provider's `lastSentAt`.
9096
+ */
9097
+ onSent: { data: object({
9098
+ deviceId: number(),
9099
+ notificationId: string(),
9100
+ success: boolean(),
9101
+ error: string().nullable(),
9102
+ acceptedAt: number()
9103
+ }) }
9104
+ }
9105
+ });
9106
+ object({
9107
+ value: number(),
9108
+ /** Display unit-of-measurement (e.g. 'dBm', 's', 'rpm', 'steps').
9109
+ * Populated live from the upstream source on each state push.
9110
+ * Absent for unitless sensors. */
9111
+ unit: string().optional(),
9112
+ /** Suggested decimal places for numeric display.
9113
+ * Populated live from the upstream source when provided.
9114
+ * Falls back to auto-formatting when absent. */
9115
+ precision: number().int().min(0).max(10).optional(),
9116
+ /** Ms epoch when the slice was last updated. */
9117
+ lastFetchedAt: number()
9118
+ });
9119
+ ({
9120
+ deviceTypes: [DeviceType.Sensor]
9121
+ });
9122
+ object({
9123
+ /** Instantaneous power draw in watts. */
9124
+ watts: number().optional(),
9125
+ /** Cumulative energy in kilowatt-hours since the meter was reset. */
9126
+ kwhTotal: number().optional(),
9127
+ /** Voltage in volts. */
9128
+ volts: number().optional(),
9129
+ /** Current in amperes. */
9130
+ amps: number().optional(),
9131
+ /** Apparent power in volt-amperes (W × power-factor reciprocal). */
9132
+ va: number().optional(),
9133
+ /** Power factor (0..1) if reported. */
9134
+ powerFactor: number().min(0).max(1).optional(),
9135
+ /** Ms epoch when the slice was last updated. */
9136
+ lastFetchedAt: number(),
9137
+ /** Live display unit of the single metric this slice carries (e.g. HA
9138
+ * `attributes.unit_of_measurement` → 'V' / 'A' / 'W' / 'kW' / 'kWh').
9139
+ * Each upstream `sensor.*` entity surfaces ONE device_class, so one
9140
+ * unit per slice is unambiguous. The UI prefers this over the role's
9141
+ * canonical unit so a 'kW' / 'Wh' feed renders verbatim. */
9142
+ unit: string().optional(),
9143
+ /** Suggested decimal places for numeric display.
9144
+ * Populated live from the upstream source when provided (e.g. HA
9145
+ * `attributes.suggested_display_precision`). Falls back to
9146
+ * auto-formatting when absent. */
9147
+ precision: number().int().min(0).max(10).optional()
9148
+ });
9149
+ ({
9150
+ deviceTypes: [DeviceType.Sensor]
9151
+ });
9152
+ const GpsLocationSchema = object({
9153
+ /** Latitude in decimal degrees, -90..90. */
9154
+ latitude: number().min(-90).max(90),
9155
+ /** Longitude in decimal degrees, -180..180. */
9156
+ longitude: number().min(-180).max(180),
9157
+ /** Reported accuracy in meters (lower = better). */
9158
+ accuracyMeters: number().nonnegative()
9159
+ });
9160
+ object({
9161
+ /** `home` / `not_home` / any user-defined zone name. */
9162
+ state: string(),
9163
+ /** Optional textual location label (zone name, city, address). Null
9164
+ * when only the binary state is known. */
9165
+ location: string().nullable(),
9166
+ /** GPS coordinates when available. Null otherwise. */
9167
+ gps: GpsLocationSchema.nullable(),
9168
+ /** Optional battery level of the tracking device (0..100). Null
9169
+ * when not reported. */
9170
+ batteryPercent: number().min(0).max(100).nullable(),
9171
+ /** Ms epoch when the slice was last updated. */
9172
+ lastChangedAt: number()
9173
+ });
9174
+ ({
9175
+ deviceTypes: [DeviceType.Presence]
9176
+ });
9177
+ object({
9178
+ /** Current pressure in hPa. */
9179
+ hpa: number(),
9180
+ /** Ms epoch when the slice was last updated. */
9181
+ lastFetchedAt: number(),
9182
+ /** Live display unit from the upstream source (e.g. HA
9183
+ * `attributes.unit_of_measurement`). The UI prefers this over the
9184
+ * role's canonical unit. Absent → fall back to the canonical unit. */
9185
+ unit: string().optional(),
9186
+ /** Suggested decimal places for numeric display.
9187
+ * Populated live from the upstream source when provided (e.g. HA
9188
+ * `attributes.suggested_display_precision`). Falls back to
9189
+ * auto-formatting when absent. */
9190
+ precision: number().int().min(0).max(10).optional()
9191
+ });
9192
+ ({
9193
+ deviceTypes: [DeviceType.Sensor]
9194
+ });
7573
9195
  const PrivacyMaskShapeSchema = discriminatedUnion("kind", [
7574
9196
  MaskRectShapeSchema,
7575
9197
  MaskPolygonShapeSchema
@@ -7698,6 +9320,51 @@ PtzAutotrackStatusSchema.extend({
7698
9320
  }) }
7699
9321
  }
7700
9322
  });
9323
+ object({
9324
+ /** Whether the script is currently executing. */
9325
+ isRunning: boolean(),
9326
+ /** Ms epoch of the last invocation start. 0 when never run. */
9327
+ lastRunAt: number(),
9328
+ /** Outcome of the last completed run. Null when never run or still
9329
+ * running. */
9330
+ lastRunSuccess: boolean().nullable(),
9331
+ /** Failure description from the last completed run. Null on success
9332
+ * or when never run. */
9333
+ lastError: string().nullable(),
9334
+ /** Ms epoch when the slice was last updated. */
9335
+ lastChangedAt: number()
9336
+ });
9337
+ ({
9338
+ deviceTypes: [DeviceType.Script],
9339
+ methods: {
9340
+ run: method(
9341
+ object({
9342
+ deviceId: number().int().nonnegative(),
9343
+ /** Optional variables map — passed through to the upstream
9344
+ * script. Provider rejects when the script doesn't declare
9345
+ * input fields (gated by `DeviceFeature.ScriptVariables`). */
9346
+ variables: record(string(), unknown()).optional()
9347
+ }),
9348
+ _void(),
9349
+ { kind: "mutation", auth: "admin" }
9350
+ ),
9351
+ /** Cancel a running script. Provider rejects when the script
9352
+ * isn't currently running. */
9353
+ stop: method(
9354
+ object({ deviceId: number().int().nonnegative() }),
9355
+ _void(),
9356
+ { kind: "mutation", auth: "admin" }
9357
+ )
9358
+ }
9359
+ });
9360
+ object({
9361
+ detected: boolean(),
9362
+ /** Ms epoch of the last transition. 0 if never observed. */
9363
+ lastChangedAt: number()
9364
+ });
9365
+ ({
9366
+ deviceTypes: [DeviceType.Sensor]
9367
+ });
7701
9368
  const StreamProfileSchema = _enum(["main", "sub", "ext"]);
7702
9369
  const StreamProfileConfigSchema = object({
7703
9370
  width: number(),
@@ -7814,6 +9481,257 @@ object({
7814
9481
  }) }
7815
9482
  }
7816
9483
  });
9484
+ object({
9485
+ /** True when the device's tamper switch / case-open contact is
9486
+ * currently triggered. */
9487
+ tampered: boolean(),
9488
+ /** Ms epoch of the last transition. 0 if never observed. */
9489
+ lastChangedAt: number()
9490
+ });
9491
+ ({
9492
+ deviceTypes: [DeviceType.Sensor]
9493
+ });
9494
+ object({
9495
+ /** Current temperature in Celsius. */
9496
+ celsius: number(),
9497
+ /** Ms epoch when the slice was last updated (push or poll). */
9498
+ lastFetchedAt: number(),
9499
+ /** Live display unit from the upstream source (e.g. HA
9500
+ * `attributes.unit_of_measurement`). The UI prefers this over the
9501
+ * role's canonical unit, so a Fahrenheit feed renders '°F' not '°C'.
9502
+ * Absent → the UI falls back to the role's canonical unit. */
9503
+ unit: string().optional(),
9504
+ /** Suggested decimal places for numeric display.
9505
+ * Populated live from the upstream source when provided (e.g. HA
9506
+ * `attributes.suggested_display_precision`). Falls back to
9507
+ * auto-formatting when absent. */
9508
+ precision: number().int().min(0).max(10).optional()
9509
+ });
9510
+ ({
9511
+ deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat]
9512
+ });
9513
+ object({
9514
+ currentVersion: string().nullable(),
9515
+ availableVersion: string().nullable(),
9516
+ /**
9517
+ * DEVICE-CAPABILITY flag: the entity supports firmware updates at all — NOT
9518
+ * a per-state "an update is available right now" flag. The canonical
9519
+ * "update available" signal is `availableVersion !== currentVersion` (which
9520
+ * is what the UI uses); HA's `state === 'on'` mirrors that same condition.
9521
+ */
9522
+ updatable: boolean(),
9523
+ state: string().nullable(),
9524
+ // e.g. 'UP_TO_DATE' / 'DELIVER_FIRMWARE_IMAGE' (provider-verbatim)
9525
+ inProgress: boolean()
9526
+ });
9527
+ ({
9528
+ deviceTypes: [DeviceType.Update],
9529
+ methods: {
9530
+ installUpdate: method(_void(), _void(), { kind: "mutation", auth: "admin" })
9531
+ }
9532
+ });
9533
+ const VacuumStateSchema = _enum([
9534
+ "idle",
9535
+ "cleaning",
9536
+ "paused",
9537
+ "returning",
9538
+ "docked",
9539
+ "error"
9540
+ ]);
9541
+ const TankStatusSchema = object({
9542
+ /** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
9543
+ level: number().min(0).max(100).nullable(),
9544
+ /** Discrete state when the hardware is binary-mode; null otherwise. */
9545
+ status: _enum(["ok", "low", "full"]).nullable()
9546
+ });
9547
+ object({
9548
+ /** Lifecycle state of the vacuum. */
9549
+ state: VacuumStateSchema,
9550
+ /** 0..100 battery percentage. Null when the device has no battery
9551
+ * reading. */
9552
+ batteryLevel: number().min(0).max(100).nullable(),
9553
+ /** Current fan-speed token (provider-verbatim). Null when unknown or
9554
+ * the vacuum has no speed control. */
9555
+ fanSpeed: string().nullable(),
9556
+ /** Speed tokens the hardware accepts — drives the UI selector. */
9557
+ availableFanSpeeds: array(string()),
9558
+ /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
9559
+ cleanWater: TankStatusSchema.nullable(),
9560
+ /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
9561
+ dirtyWater: TankStatusSchema.nullable(),
9562
+ /** Detergent tank. Null when the hardware has no detergent tank. */
9563
+ detergent: TankStatusSchema.nullable(),
9564
+ /** Dust bin. Null when the hardware has no dust bin. */
9565
+ dustBin: TankStatusSchema.nullable(),
9566
+ /** Ms epoch when the slice was last updated. */
9567
+ lastChangedAt: number()
9568
+ });
9569
+ ({
9570
+ deviceTypes: [DeviceType.Vacuum],
9571
+ methods: {
9572
+ start: method(
9573
+ object({ deviceId: number().int().nonnegative() }),
9574
+ _void(),
9575
+ { kind: "mutation", auth: "admin" }
9576
+ ),
9577
+ pause: method(
9578
+ object({ deviceId: number().int().nonnegative() }),
9579
+ _void(),
9580
+ { kind: "mutation", auth: "admin" }
9581
+ ),
9582
+ stop: method(
9583
+ object({ deviceId: number().int().nonnegative() }),
9584
+ _void(),
9585
+ { kind: "mutation", auth: "admin" }
9586
+ ),
9587
+ returnToBase: method(
9588
+ object({ deviceId: number().int().nonnegative() }),
9589
+ _void(),
9590
+ { kind: "mutation", auth: "admin" }
9591
+ ),
9592
+ locate: method(
9593
+ object({ deviceId: number().int().nonnegative() }),
9594
+ _void(),
9595
+ { kind: "mutation", auth: "admin" }
9596
+ ),
9597
+ setFanSpeed: method(
9598
+ object({
9599
+ deviceId: number().int().nonnegative(),
9600
+ speed: string().min(1)
9601
+ }),
9602
+ _void(),
9603
+ { kind: "mutation", auth: "admin" }
9604
+ )
9605
+ }
9606
+ });
9607
+ const ValveStateSchema = _enum([
9608
+ "open",
9609
+ "opening",
9610
+ "closing",
9611
+ "closed",
9612
+ "stopped"
9613
+ ]);
9614
+ object({
9615
+ /** Lifecycle state of the valve. */
9616
+ state: ValveStateSchema,
9617
+ /** 0 = fully closed, 100 = fully open. Null when the device has no
9618
+ * intermediate position surface. */
9619
+ position: number().min(0).max(100).nullable(),
9620
+ /** Ms epoch when the slice was last updated. */
9621
+ lastChangedAt: number()
9622
+ });
9623
+ ({
9624
+ deviceTypes: [DeviceType.Valve],
9625
+ methods: {
9626
+ open: method(
9627
+ object({ deviceId: number().int().nonnegative() }),
9628
+ _void(),
9629
+ { kind: "mutation", auth: "admin" }
9630
+ ),
9631
+ close: method(
9632
+ object({ deviceId: number().int().nonnegative() }),
9633
+ _void(),
9634
+ { kind: "mutation", auth: "admin" }
9635
+ ),
9636
+ stop: method(
9637
+ object({ deviceId: number().int().nonnegative() }),
9638
+ _void(),
9639
+ { kind: "mutation", auth: "admin" }
9640
+ ),
9641
+ setPosition: method(
9642
+ object({
9643
+ deviceId: number().int().nonnegative(),
9644
+ position: number().min(0).max(100)
9645
+ }),
9646
+ _void(),
9647
+ { kind: "mutation", auth: "admin" }
9648
+ )
9649
+ }
9650
+ });
9651
+ object({
9652
+ detected: boolean(),
9653
+ /** Ms epoch of the last transition. 0 if never observed. */
9654
+ lastChangedAt: number()
9655
+ });
9656
+ ({
9657
+ deviceTypes: [DeviceType.Sensor]
9658
+ });
9659
+ object({
9660
+ /** Current measured temperature. Null when not reported. */
9661
+ currentTemp: number().nullable(),
9662
+ /** Target temperature setpoint. Null when no setpoint surface. */
9663
+ targetTemp: number().nullable(),
9664
+ /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
9665
+ * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
9666
+ * device reports an unknown state. */
9667
+ operationMode: string().nullable(),
9668
+ /** Available operation modes = HA `operation_list`. */
9669
+ availableModes: array(string()),
9670
+ /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
9671
+ * has no away surface. */
9672
+ away: boolean().nullable(),
9673
+ /** HA `min_temp` attribute. Null when not reported. */
9674
+ minTemp: number().nullable(),
9675
+ /** HA `max_temp` attribute. Null when not reported. */
9676
+ maxTemp: number().nullable(),
9677
+ /** Ms epoch when the slice was last updated. */
9678
+ lastChangedAt: number()
9679
+ });
9680
+ ({
9681
+ deviceTypes: [DeviceType.WaterHeater],
9682
+ methods: {
9683
+ setTargetTemp: method(
9684
+ object({
9685
+ deviceId: number().int().nonnegative(),
9686
+ temp: number().finite()
9687
+ }),
9688
+ _void(),
9689
+ { kind: "mutation", auth: "admin" }
9690
+ ),
9691
+ setOperationMode: method(
9692
+ object({
9693
+ deviceId: number().int().nonnegative(),
9694
+ mode: string().min(1)
9695
+ }),
9696
+ _void(),
9697
+ { kind: "mutation", auth: "admin" }
9698
+ ),
9699
+ setAway: method(
9700
+ object({
9701
+ deviceId: number().int().nonnegative(),
9702
+ on: boolean()
9703
+ }),
9704
+ _void(),
9705
+ { kind: "mutation", auth: "admin" }
9706
+ )
9707
+ }
9708
+ });
9709
+ object({
9710
+ /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
9711
+ * when no condition has been reported yet. */
9712
+ condition: string().nullable(),
9713
+ /** Current temperature in the reported unit. Null when not provided. */
9714
+ temperature: number().nullable(),
9715
+ /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
9716
+ temperatureUnit: string().nullable(),
9717
+ /** Relative humidity (0..100). Null when not provided. */
9718
+ humidity: number().min(0).max(100).nullable(),
9719
+ /** Barometric pressure in the reported unit. Null when not provided. */
9720
+ pressure: number().nullable(),
9721
+ /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
9722
+ pressureUnit: string().nullable(),
9723
+ /** Wind speed in the reported unit. Null when not provided. */
9724
+ windSpeed: number().nullable(),
9725
+ /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
9726
+ windSpeedUnit: string().nullable(),
9727
+ /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
9728
+ windBearing: number().nullable(),
9729
+ /** Ms epoch when the slice was last updated. */
9730
+ lastFetchedAt: number()
9731
+ });
9732
+ ({
9733
+ deviceTypes: [DeviceType.Weather]
9734
+ });
7817
9735
  const PerScopeBreakdownSchema = object({
7818
9736
  /** Total tracked objects in this scope (frame / zone / unzoned). */
7819
9737
  totalObjects: number().int().nonnegative(),
@@ -7998,7 +9916,14 @@ const DiscoveryCandidateSchema = object({
7998
9916
  stableId: string(),
7999
9917
  type: _enum(DeviceType),
8000
9918
  suggestedName: string(),
8001
- prefilledConfig: record(string(), unknown())
9919
+ prefilledConfig: record(string(), unknown()),
9920
+ /**
9921
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
9922
+ * Discovery pre-populates this for systems that know the upstream
9923
+ * identity ahead of adoption. Rendering metadata (unit, precision)
9924
+ * flows live through the cap STATUS SLICE after adoption.
9925
+ */
9926
+ sourceInfo: SourceInfoSchema.optional()
8002
9927
  });
8003
9928
  const DeviceSummarySchema = object({
8004
9929
  id: number(),
@@ -8009,7 +9934,12 @@ const DeviceSummarySchema = object({
8009
9934
  parentDeviceId: number().nullable(),
8010
9935
  online: boolean(),
8011
9936
  features: array(string()),
8012
- config: record(string(), unknown())
9937
+ config: record(string(), unknown()),
9938
+ /** Optional upstream-system identity (dispatch key + system tag).
9939
+ * See `SourceInfo`. Present when the device has a non-synthetic
9940
+ * source identifier (HA entities, vendor MAC, …); omitted when the
9941
+ * synthetic backfill is in effect. */
9942
+ sourceInfo: SourceInfoSchema.optional()
8013
9943
  });
8014
9944
  const FieldProbeResultSchema = object({
8015
9945
  status: _enum(["ok", "error"]),
@@ -8188,6 +10118,22 @@ const AlertSchema = object({
8188
10118
  dismiss: method(object({ alertId: string() }), _void(), { kind: "mutation" })
8189
10119
  }
8190
10120
  });
10121
+ const ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [
10122
+ object({
10123
+ providerId: string().min(1),
10124
+ displayName: string().min(1),
10125
+ configSchema: unknown(),
10126
+ shouldSaveDiskSpace: literal(true),
10127
+ minFreePercent: number().min(0).max(100)
10128
+ }),
10129
+ object({
10130
+ providerId: string().min(1),
10131
+ displayName: string().min(1),
10132
+ configSchema: unknown(),
10133
+ shouldSaveDiskSpace: literal(false),
10134
+ minFreePercent: literal(null)
10135
+ })
10136
+ ]);
8191
10137
  ({
8192
10138
  methods: {
8193
10139
  // ── Small-file primitives ────────────────────────────────────────
@@ -8262,6 +10208,16 @@ const AlertSchema = object({
8262
10208
  object({ type: StorageLocationTypeSchema }),
8263
10209
  StorageLocationSchema.nullable()
8264
10210
  ),
10211
+ // The admin-UI Data screen renders one group per declared location
10212
+ // (header = `displayName`, "+ Add" shown only for `cardinality:
10213
+ // 'multi'`). Source: the kernel-aggregated `storageLocations`
10214
+ // declarations injected into the orchestrator via `setRegistry`.
10215
+ // No closed type enum in the UI — the screen is fully declaration-
10216
+ // driven.
10217
+ listLocationDeclarations: method(
10218
+ _void(),
10219
+ array(StorageLocationDeclarationSchema).readonly()
10220
+ ),
8265
10221
  upsertLocation: method(
8266
10222
  StorageLocationSchema.omit({ createdAt: true, updatedAt: true }),
8267
10223
  StorageLocationSchema,
@@ -8286,12 +10242,7 @@ const AlertSchema = object({
8286
10242
  // never changes after boot).
8287
10243
  listProviders: method(
8288
10244
  _void(),
8289
- array(object({
8290
- providerId: string(),
8291
- displayName: string(),
8292
- supportedLocationTypes: array(StorageLocationTypeSchema).readonly(),
8293
- configSchema: unknown()
8294
- })).readonly()
10245
+ array(ProviderListEntrySchema).readonly()
8295
10246
  ),
8296
10247
  // Validate a candidate config against a provider BEFORE persisting.
8297
10248
  // Used by the wizard so operators can preflight a connection
@@ -8307,12 +10258,24 @@ const AlertSchema = object({
8307
10258
  )
8308
10259
  }
8309
10260
  });
8310
- const ProviderInfoSchema = object({
8311
- providerId: string().min(1),
8312
- displayName: string().min(1),
8313
- supportedLocationTypes: array(StorageLocationTypeSchema).readonly(),
8314
- configSchema: unknown()
8315
- });
10261
+ const ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [
10262
+ object({
10263
+ providerId: string().min(1),
10264
+ displayName: string().min(1),
10265
+ configSchema: unknown(),
10266
+ // Provider manages a finite volume → declares a default free-space threshold.
10267
+ shouldSaveDiskSpace: literal(true),
10268
+ minFreePercent: number().min(0).max(100)
10269
+ }),
10270
+ object({
10271
+ providerId: string().min(1),
10272
+ displayName: string().min(1),
10273
+ configSchema: unknown(),
10274
+ // No local free-space concept (remote/object store) → no threshold.
10275
+ shouldSaveDiskSpace: literal(false),
10276
+ minFreePercent: literal(null)
10277
+ })
10278
+ ]);
8316
10279
  const TestLocationResultSchema = object({
8317
10280
  ok: boolean(),
8318
10281
  error: string().optional()
@@ -8398,6 +10361,35 @@ const EndDownloadInputSchema = object({ downloadId: string() });
8398
10361
  endDownload: method(EndDownloadInputSchema, _void(), { kind: "mutation" })
8399
10362
  }
8400
10363
  });
10364
+ const EvictableUsageSchema = object({
10365
+ /** Bytes this provider currently holds on the location (0 if none). */
10366
+ bytes: number().int().nonnegative()
10367
+ });
10368
+ const EvictResultSchema = object({
10369
+ /** Bytes actually reclaimed (only successfully-deleted data counts). */
10370
+ reclaimedBytes: number().int().nonnegative(),
10371
+ /** True when the provider has nothing left it is willing to drop on this location. */
10372
+ exhausted: boolean()
10373
+ });
10374
+ ({
10375
+ methods: {
10376
+ /** Bytes this provider holds on the given location — drives proportional fan-out. */
10377
+ getEvictableUsage: method(
10378
+ object({ locationId: string() }),
10379
+ EvictableUsageSchema
10380
+ ),
10381
+ /**
10382
+ * Free approximately `targetBytes` of this provider's OWN least-valuable
10383
+ * data on the location (oldest footage, expired clips, …). Returns what it
10384
+ * actually reclaimed + whether it is now exhausted.
10385
+ */
10386
+ evict: method(
10387
+ object({ locationId: string(), targetBytes: number().int().positive() }),
10388
+ EvictResultSchema,
10389
+ { kind: "mutation" }
10390
+ )
10391
+ }
10392
+ });
8401
10393
  const BackupSubDestinationInfoSchema = object({
8402
10394
  /**
8403
10395
  * Sub-id within this addon. Convention `default` for single-
@@ -8608,7 +10600,7 @@ const QueryFilterSchema = object({
8608
10600
  limit: number().optional(),
8609
10601
  offset: number().optional()
8610
10602
  });
8611
- const SettingsRecordSchema = object({
10603
+ const SettingsRecordSchema$1 = object({
8612
10604
  id: string(),
8613
10605
  data: record(string(), unknown())
8614
10606
  });
@@ -8640,11 +10632,11 @@ const CollectionIndexSchema = object({
8640
10632
  /** Get all entries matching an optional filter. */
8641
10633
  query: method(
8642
10634
  object({ namespace: string().optional(), collection: string(), filter: QueryFilterSchema.optional() }),
8643
- array(SettingsRecordSchema).readonly()
10635
+ array(SettingsRecordSchema$1).readonly()
8644
10636
  ),
8645
10637
  /** Insert a new record. */
8646
10638
  insert: method(
8647
- object({ namespace: string().optional(), collection: string(), record: SettingsRecordSchema }),
10639
+ object({ namespace: string().optional(), collection: string(), record: SettingsRecordSchema$1 }),
8648
10640
  _void(),
8649
10641
  { kind: "mutation" }
8650
10642
  ),
@@ -8665,6 +10657,18 @@ const CollectionIndexSchema = object({
8665
10657
  object({ namespace: string().optional(), collection: string(), filter: QueryFilterSchema.optional() }),
8666
10658
  number()
8667
10659
  ),
10660
+ /** Grouped counts per ((field-origin)/bucketSize) bucket, filtered. */
10661
+ histogram: method(
10662
+ object({
10663
+ namespace: string().optional(),
10664
+ collection: string(),
10665
+ field: string(),
10666
+ bucketSize: number().int().positive(),
10667
+ origin: number().int(),
10668
+ filter: QueryFilterSchema.optional()
10669
+ }),
10670
+ array(object({ bucket: number().int(), count: number().int() })).readonly()
10671
+ ),
8668
10672
  /** Check if a collection is empty. */
8669
10673
  isEmpty: method(
8670
10674
  object({ namespace: string().optional(), collection: string() }),
@@ -8989,6 +10993,297 @@ const SmtpStatusSchema = object({
8989
10993
  )
8990
10994
  }
8991
10995
  });
10996
+ const AdoptionFilterSchema = object({
10997
+ id: string(),
10998
+ label: string(),
10999
+ isDefault: boolean().optional()
11000
+ });
11001
+ const CandidateQueryFilterSchema = object({
11002
+ /** Substring filter on name + manufacturer + model. */
11003
+ search: string().optional(),
11004
+ /** Area-name exact match. */
11005
+ area: string().optional(),
11006
+ /** Manufacturer exact match. */
11007
+ manufacturer: string().optional(),
11008
+ /** When true, only return candidates the operator already adopted. */
11009
+ adoptedOnly: boolean().optional(),
11010
+ /** When true, only return candidates the operator hasn't adopted yet. */
11011
+ unadoptedOnly: boolean().optional()
11012
+ });
11013
+ const ListCandidatesInputSchema = object({
11014
+ integrationId: string(),
11015
+ page: number().int().positive().default(1),
11016
+ // Pagination is client-side in the adoption UI: the modal fetches the whole
11017
+ // candidate set in one page and the shared list paginates locally. The cap
11018
+ // stays high enough to return every candidate at once. The large case is the
11019
+ // HA `entities` granularity — one HA device maps to many entities, so an
11020
+ // install can expose several thousand candidates; the ceiling is sized for it.
11021
+ pageSize: number().int().positive().max(2e4).default(50),
11022
+ /**
11023
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11024
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11025
+ * exactly the pre-existing behavior (fully back-compatible).
11026
+ */
11027
+ filter: string().optional(),
11028
+ /** Optional candidate-list text/query narrowing within the granularity. */
11029
+ filterText: CandidateQueryFilterSchema.optional()
11030
+ });
11031
+ const ListCandidatesOutputSchema = object({
11032
+ candidates: array(DiscoveredChildDeviceSchema).readonly(),
11033
+ totalCount: number().int().nonnegative(),
11034
+ page: number().int().positive(),
11035
+ pageSize: number().int().positive()
11036
+ });
11037
+ const GetCandidateInputSchema = object({
11038
+ integrationId: string(),
11039
+ childNativeId: string()
11040
+ });
11041
+ const AdoptionStatusSchema = object({
11042
+ /** Last refresh timestamp (ms epoch) — null when never refreshed. */
11043
+ lastDiscoveryAt: number().int().nonnegative().nullable(),
11044
+ /** Count of candidates in the discovery cache. */
11045
+ candidateCount: number().int().nonnegative(),
11046
+ /** Count of candidates the operator has already adopted. */
11047
+ adoptedCount: number().int().nonnegative(),
11048
+ /** Last error message from a refresh attempt. */
11049
+ lastError: string().nullable()
11050
+ });
11051
+ const PerCandidateSchema = object({
11052
+ /** Override the default display name for this candidate's parent. */
11053
+ name: string().min(1).optional(),
11054
+ /** Pre-hide a subset of entity-children — created (state still flows)
11055
+ * but listed in the parent's `accessories.hiddenChildIds`. */
11056
+ hiddenChildIds: array(string()).optional()
11057
+ });
11058
+ const AdoptInputSchema = object({
11059
+ integrationId: string(),
11060
+ /**
11061
+ * Candidate native ids to adopt. Their MEANING is filter-relative: under the
11062
+ * default `'devices'` granularity these are device-native ids; under a
11063
+ * provider-declared granularity (e.g. HA `'entities'`) they are that
11064
+ * granularity's native ids (e.g. entity ids). The field name is kept stable
11065
+ * to avoid a breaking rename.
11066
+ */
11067
+ childNativeIds: array(string()).min(1),
11068
+ /**
11069
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11070
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11071
+ * exactly the pre-existing behavior (fully back-compatible).
11072
+ */
11073
+ filter: string().optional(),
11074
+ /** When true, import each adopted device's source-system location (e.g. HA
11075
+ * area) into CamStack — fuzzy-match an existing location or create it, then
11076
+ * assign. Omitted/false = no location work (back-compat). */
11077
+ importLocations: boolean().optional(),
11078
+ perCandidate: record(string(), PerCandidateSchema).optional()
11079
+ });
11080
+ const AdoptResultSchema = object({
11081
+ adopted: array(
11082
+ object({
11083
+ childNativeId: string(),
11084
+ parentDeviceId: number().int().nonnegative(),
11085
+ accessoryDeviceIds: array(number().int().nonnegative()).readonly()
11086
+ })
11087
+ ).readonly()
11088
+ });
11089
+ const ReleaseInputSchema = object({
11090
+ integrationId: string(),
11091
+ /** Parent CamStack device id (NOT an accessory child id). Removing
11092
+ * the parent cascades into every accessory. */
11093
+ camDeviceId: number().int().nonnegative()
11094
+ });
11095
+ const ResyncInputSchema = object({
11096
+ /** Parent CamStack device id of an adopted device. The provider resolves its
11097
+ * source (integration/broker + native id) and re-aligns the device's
11098
+ * structural spec (type/role/capabilities/units) with the live mapping,
11099
+ * rebuilding any child whose class changed while preserving operator edits. */
11100
+ camDeviceId: number().int().nonnegative()
11101
+ });
11102
+ const ResyncResultSchema = object({
11103
+ /** True when the persisted spec actually changed (children may have been rebuilt). */
11104
+ changed: boolean(),
11105
+ /** Number of child devices rebuilt into a new class by this re-sync. */
11106
+ rebuiltChildren: number().int().nonnegative()
11107
+ });
11108
+ ({
11109
+ methods: {
11110
+ listCandidateFilters: method(
11111
+ object({ integrationId: string() }),
11112
+ object({ filters: array(AdoptionFilterSchema) }),
11113
+ { auth: "admin" }
11114
+ ),
11115
+ listCandidates: method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }),
11116
+ getCandidate: method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }),
11117
+ refresh: method(object({ integrationId: string() }), AdoptionStatusSchema, { kind: "mutation", auth: "admin" }),
11118
+ adopt: method(AdoptInputSchema, AdoptResultSchema, { kind: "mutation", auth: "admin" }),
11119
+ release: method(ReleaseInputSchema, _void(), { kind: "mutation", auth: "admin" }),
11120
+ resync: method(ResyncInputSchema, ResyncResultSchema, { kind: "mutation", auth: "admin" })
11121
+ }
11122
+ });
11123
+ const BrokerStatusEnum = _enum([
11124
+ "connected",
11125
+ "disconnected",
11126
+ "connecting",
11127
+ "auth-failed",
11128
+ "unreachable",
11129
+ "error"
11130
+ ]);
11131
+ const BrokerInfoSchema$1 = object({
11132
+ /** Stable broker id. Persisted; survives addon restarts. */
11133
+ id: string(),
11134
+ /** Addon id of the provider that OWNS this broker.
11135
+ *
11136
+ * The `broker` cap is a system-scoped collection: several addons
11137
+ * register a `broker` provider, each owning a DISJOINT set of
11138
+ * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
11139
+ * `ha_*`). A broker therefore belongs to exactly one addon — like a
11140
+ * device belongs to one integration. The admin UI threads this id
11141
+ * back as the `{ addonId }` system-collection selector on every
11142
+ * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
11143
+ * `testConnection`), so the call routes to the OWNING provider
11144
+ * instead of defaulting to the first-registered one. */
11145
+ addonId: string(),
11146
+ /** Human-readable name (operator-chosen at add-time). */
11147
+ name: string(),
11148
+ /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
11149
+ kind: string(),
11150
+ status: BrokerStatusEnum,
11151
+ /** Free-form provider-specific info (HA version, MQTT broker
11152
+ * flavour, latency, mTLS active, …). */
11153
+ info: record(string(), unknown()),
11154
+ /** Ms epoch of the last connection probe / status update. */
11155
+ lastCheckedAt: number().nullable(),
11156
+ /** Last error message, when `status` indicates failure. */
11157
+ error: string().nullable()
11158
+ });
11159
+ const RegistryStatusSchema = object({
11160
+ brokerCount: number().int().nonnegative(),
11161
+ connectedCount: number().int().nonnegative()
11162
+ });
11163
+ const BrokerProviderInfoSchema = object({
11164
+ /** Addon id of the `broker` provider this entry describes. */
11165
+ addonId: string(),
11166
+ /** Broker kinds this provider can create, with a display label. */
11167
+ kinds: array(object({ kind: string(), label: string() }))
11168
+ });
11169
+ const ListInputSchema = object({
11170
+ /** Optional kind filter — `list({kind:'home-assistant'})` returns
11171
+ * only HA brokers. Omit to list every broker. */
11172
+ kind: string().optional()
11173
+ });
11174
+ const GetInputSchema = object({ id: string() });
11175
+ const AddInputSchema = object({
11176
+ kind: string().min(1),
11177
+ name: string().min(1),
11178
+ /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
11179
+ * `{baseUrl,accessToken}`). Validated by the kind-specific provider
11180
+ * branch on receipt — invalid shape rejects the add. */
11181
+ settings: record(string(), unknown())
11182
+ });
11183
+ const AddResultSchema = object({ id: string() });
11184
+ const RemoveInputSchema = object({ id: string() });
11185
+ const TestConnectionResultSchema$1 = discriminatedUnion("ok", [
11186
+ object({ ok: literal(true), latencyMs: number().nonnegative() }),
11187
+ object({ ok: literal(false), error: string() })
11188
+ ]);
11189
+ const SettingsRecordSchema = record(string(), unknown());
11190
+ const SettingsSchemaInputSchema = object({ kind: string() });
11191
+ const TestSettingsInputSchema = object({
11192
+ kind: string(),
11193
+ settings: SettingsRecordSchema
11194
+ });
11195
+ const TestSettingsResultSchema = discriminatedUnion("ok", [
11196
+ // `.strict()` on the success branch so a stray `error` field is rejected,
11197
+ // not silently stripped — a success result must never carry an error.
11198
+ object({ ok: literal(true), latencyMs: number().nonnegative().optional() }).strict(),
11199
+ object({ ok: literal(false), error: string() })
11200
+ ]);
11201
+ const SettingsSchemaResultSchema = unknown().nullable();
11202
+ const PublishInputSchema = object({
11203
+ brokerId: string(),
11204
+ /** Kind-specific routing target.
11205
+ * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
11206
+ * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
11207
+ target: record(string(), unknown()),
11208
+ /** Kind-specific payload.
11209
+ * MQTT: raw string / number / object (provider serialises).
11210
+ * HA: service-call `data` object (transition, brightness, …). */
11211
+ payload: unknown().optional()
11212
+ });
11213
+ const SubscribeInputSchema = object({
11214
+ brokerId: string(),
11215
+ /** Kind-specific filter.
11216
+ * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
11217
+ * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
11218
+ filter: record(string(), unknown())
11219
+ });
11220
+ const SubscribeResultSchema = object({
11221
+ /** Stable subscription id. Used to unsubscribe and to filter the
11222
+ * matching `broker.message` events on the bus. */
11223
+ subscriptionId: string()
11224
+ });
11225
+ const UnsubscribeInputSchema = object({
11226
+ brokerId: string(),
11227
+ subscriptionId: string()
11228
+ });
11229
+ const GetStateInputSchema = object({
11230
+ brokerId: string(),
11231
+ /** Kind-specific lookup key.
11232
+ * MQTT: topic string (returns the last retained message).
11233
+ * HA: entity_id (returns the cached entity state). */
11234
+ key: string()
11235
+ });
11236
+ ({
11237
+ methods: {
11238
+ // ── Registry CRUD ────────────────────────────────────────────────
11239
+ list: method(ListInputSchema, array(BrokerInfoSchema$1)),
11240
+ get: method(GetInputSchema, BrokerInfoSchema$1.nullable()),
11241
+ /** Enumerate which addon provides which broker kind(s) for the
11242
+ * unified create picker. The auto-mount fans this array across
11243
+ * every registered `broker` provider (array-output method), so the
11244
+ * picker sees every kind from every provider in one call. */
11245
+ listProviders: method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }),
11246
+ add: method(AddInputSchema, AddResultSchema, { kind: "mutation", auth: "admin" }),
11247
+ remove: method(RemoveInputSchema, _void(), { kind: "mutation", auth: "admin" }),
11248
+ testConnection: method(GetInputSchema, TestConnectionResultSchema$1, { kind: "mutation", auth: "admin" }),
11249
+ // ── Settings ─────────────────────────────────────────────────────
11250
+ /** Read the persisted settings record for a broker (kind-specific
11251
+ * shape). Admin-only — settings may contain secrets. Returns `null`
11252
+ * when the broker id is unknown to the provider (the collection
11253
+ * fallback may route a foreign id to the first provider). */
11254
+ getSettings: method(GetInputSchema, SettingsRecordSchema.nullable(), { auth: "admin" }),
11255
+ /** Overwrite the persisted settings record. The kind-specific
11256
+ * provider validates the shape and applies the change (reconnects
11257
+ * if credentials changed). */
11258
+ setSettings: method(
11259
+ object({ id: string(), settings: SettingsRecordSchema }),
11260
+ _void(),
11261
+ { kind: "mutation", auth: "admin" }
11262
+ ),
11263
+ // ── Connection-config (for consumers that open their own client) ─
11264
+ /** Returns the kind-specific connection config the consumer needs
11265
+ * to open its own client (MQTT pattern: `{url, username, password,
11266
+ * clientIdPrefix}`). HA providers MAY return the auth envelope
11267
+ * but typical HA consumers use `publish` / `subscribe` instead.
11268
+ * Returns `null` when the broker id is unknown to the provider. */
11269
+ getBrokerConfig: method(GetInputSchema, SettingsRecordSchema.nullable(), { auth: "admin" }),
11270
+ // ── Per-kind creation schema + pre-creation test ─────────────────
11271
+ getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
11272
+ testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, { kind: "mutation", auth: "admin" }),
11273
+ // ── Pub/sub primitives ───────────────────────────────────────────
11274
+ publish: method(PublishInputSchema, unknown(), { kind: "mutation", auth: "admin" }),
11275
+ subscribe: method(SubscribeInputSchema, SubscribeResultSchema, { kind: "mutation", auth: "admin" }),
11276
+ unsubscribe: method(UnsubscribeInputSchema, _void(), { kind: "mutation", auth: "admin" }),
11277
+ /** Read the broker's cached state for a key. Returns `null` when
11278
+ * unknown to the broker (never published / unknown entity). */
11279
+ getState: method(GetStateInputSchema, unknown().nullable()),
11280
+ /** Status method — explicit registration with a `z.void()` input so
11281
+ * the codegen-generated tRPC router types its input as
11282
+ * `{addonId?: string, nodeId?: string}` (system-scoped collection
11283
+ * shape) instead of the device-scoped `{deviceId}` fallback. */
11284
+ getStatus: method(_void(), RegistryStatusSchema)
11285
+ }
11286
+ });
8992
11287
  const BrokerKindSchema = _enum(["external", "embedded"]);
8993
11288
  const BrokerStatusSchema = _enum([
8994
11289
  "connected",
@@ -9498,7 +11793,15 @@ const WebrtcStreamChoiceSchema = object({
9498
11793
  * direct (host/srflx) path. Clients MUST NOT send this — the
9499
11794
  * server overwrites it from the request context.
9500
11795
  */
9501
- relayOnly: boolean().optional()
11796
+ relayOnly: boolean().optional(),
11797
+ /**
11798
+ * Subscriber attribution surfaced in the stream-broker widget's
11799
+ * client list. Callers (Alexa addon, browser hooks, WHEP server)
11800
+ * populate `kind` and any of `label`/`userId`/`sessionId`/
11801
+ * `remoteAddr` they know. The hub layer enriches `remoteAddr`
11802
+ * from the tRPC request context.
11803
+ */
11804
+ consumerAttribution: BrokerConsumerAttributionSchema.optional()
9502
11805
  }),
9503
11806
  object({ sessionId: string(), sdpOffer: string() }),
9504
11807
  { kind: "mutation" }
@@ -9536,7 +11839,62 @@ const WebrtcStreamChoiceSchema = object({
9536
11839
  * Untrusted browser clients MUST NOT send it — the hub overwrites
9537
11840
  * it from the request context.
9538
11841
  */
9539
- relayOnly: boolean().optional()
11842
+ relayOnly: boolean().optional(),
11843
+ /**
11844
+ * Force a NON-TRICKLE answer: the server awaits ICE gathering to
11845
+ * COMPLETE and returns a full-candidate answer SDP (with `a=candidate`
11846
+ * lines) instead of the default bare/trickle answer.
11847
+ *
11848
+ * Set `true` by callers that DON'T support trickle ICE — notably
11849
+ * Alexa's RTCSessionController, which never polls `getIceCandidates`
11850
+ * nor trickles back via `addIceCandidate`, so without server
11851
+ * candidates in the answer SDP its ICE agent stalls (no nominated
11852
+ * pair → DTLS never connects → Echo shows black → SessionDisconnected).
11853
+ *
11854
+ * Browser viewers leave it absent/false: they poll `getIceCandidates`
11855
+ * and trickle, so the bare answer lets media start in ~0s instead of
11856
+ * blocking on full gathering.
11857
+ */
11858
+ nonTrickle: boolean().optional(),
11859
+ /**
11860
+ * Suppress TURN servers from the per-session iceConfig: the answer
11861
+ * will carry ONLY host + srflx candidates, never a relay one.
11862
+ *
11863
+ * Set `true` by peers that are themselves directly reachable on the
11864
+ * public internet (Alexa's RTCSessionController media gateway is
11865
+ * AWS-hosted with public host candidates). Including a TURN relay
11866
+ * for such a peer is counterproductive: (a) gathering blocks on the
11867
+ * TURN allocation round-trip and slows the answer; (b) werift
11868
+ * spends ICE-checking budget on a relay-pair that the peer cannot
11869
+ * use, sometimes stalling the host/srflx pair validation enough
11870
+ * that Echo's agent times out before nominating one — visible as
11871
+ * 'ICE checking → DTLS never connected'. Browser viewers leave it
11872
+ * absent/false so the relay candidate remains as a fallback for
11873
+ * symmetric-NAT clients.
11874
+ *
11875
+ * Independent from `relayOnly`. `relayOnly: true` forces ICE to a
11876
+ * relay-only pair set; `disableTurn: true` strips the relay
11877
+ * candidate entirely. They are mutually exclusive in practice but
11878
+ * not policed at the schema level — callers MUST NOT set both.
11879
+ */
11880
+ disableTurn: boolean().optional(),
11881
+ /**
11882
+ * Suppress IPv6 host candidates from gathering and from
11883
+ * `additionalHostAddresses`. The answer SDP will contain only IPv4
11884
+ * host/srflx (and TURN, unless `disableTurn` is also set).
11885
+ *
11886
+ * Set `true` by peers that document IPv4-only ICE — Amazon's
11887
+ * `Alexa.RTCSessionController` spec states: "For ICE candidates,
11888
+ * you can use either UDP or TCP but you must use IPv4." Echo's
11889
+ * media gateway ignores IPv6 candidates and they consume budget
11890
+ * against the gateway's documented 6-second SDP processing limit.
11891
+ *
11892
+ * Browser viewers leave it absent/false so IPv6-only paths
11893
+ * (Tailscale, native dual-stack) remain available.
11894
+ */
11895
+ disableIpv6: boolean().optional(),
11896
+ /** Subscriber attribution. See `createSession` for the contract. */
11897
+ consumerAttribution: BrokerConsumerAttributionSchema.optional()
9540
11898
  }),
9541
11899
  object({ sessionId: string(), sdpAnswer: string() }),
9542
11900
  { kind: "mutation" }
@@ -9555,7 +11913,7 @@ const WebrtcStreamChoiceSchema = object({
9555
11913
  * Lets the client send its SDP offer/answer IMMEDIATELY (before ICE
9556
11914
  * gathering finishes) and deliver candidates as they arrive, so the
9557
11915
  * connection establishes in ~0s instead of waiting for full gathering.
9558
- * The dual of `getIceCandidates`. Mirrors Scrypted's signaling.
11916
+ * The dual of `getIceCandidates` in the trickle-ICE signaling exchange.
9559
11917
  */
9560
11918
  addIceCandidate: method(
9561
11919
  object({
@@ -9605,6 +11963,18 @@ const WebrtcStreamChoiceSchema = object({
9605
11963
  profile: CamProfileSchema
9606
11964
  }),
9607
11965
  boolean()
11966
+ ),
11967
+ /** Poll the live signaling state of a session. `pendingRenegotiation` is
11968
+ * non-null after an adaptive tier switch — the client re-offers on the
11969
+ * same sessionId; `epoch` guards against acting on a stale poll twice. */
11970
+ getSessionState: method(
11971
+ object({
11972
+ deviceId: number().int().nonnegative(),
11973
+ sessionId: string()
11974
+ }),
11975
+ object({
11976
+ pendingRenegotiation: object({ target: WebrtcStreamTargetSchema, epoch: number() }).nullable()
11977
+ })
9608
11978
  )
9609
11979
  }
9610
11980
  });
@@ -10213,6 +12583,26 @@ const EmbeddingInfoSchema = object({
10213
12583
  getInfo: method(_void(), EmbeddingInfoSchema)
10214
12584
  }
10215
12585
  });
12586
+ const ChildLayoutEntrySchema = object({
12587
+ childKey: string(),
12588
+ section: string(),
12589
+ order: number().optional(),
12590
+ // Section-level default-collapsed hint: when true the accordion section this
12591
+ // entry belongs to renders closed by default. The section is treated as
12592
+ // collapsed if ANY of its entries sets collapsed:true; consistent values
12593
+ // across a section's entries are recommended but not enforced. Absent ⇒ open.
12594
+ collapsed: boolean().optional()
12595
+ });
12596
+ const DeviceLinkSchema = object({
12597
+ id: string(),
12598
+ source: object({ sourceKey: string(), cap: string(), fieldPath: string() }),
12599
+ target: object({ cap: string(), fieldPath: string(), itemKey: string().optional() }),
12600
+ transform: discriminatedUnion("kind", [
12601
+ object({ kind: literal("identity") }),
12602
+ object({ kind: literal("enum-map"), mapping: record(string(), union([string(), number(), boolean()])), fallback: union([string(), number(), boolean()]).optional() }),
12603
+ object({ kind: literal("linear"), scale: number(), offset: number(), clamp: tuple([number(), number()]).readonly().optional() })
12604
+ ]).optional()
12605
+ });
10216
12606
  const DeviceInfoSchema = object({
10217
12607
  /** Progressive, system-wide unique number. Allocated synchronously by
10218
12608
  * `device-manager.allocateDeviceId` BEFORE the owning `IDevice` is
@@ -10232,6 +12622,12 @@ const DeviceInfoSchema = object({
10232
12622
  /** Optional semantic role — `DeviceRole` string. null for top-level devices. */
10233
12623
  role: string().nullable().optional(),
10234
12624
  online: boolean(),
12625
+ /** True when the device's initial feature-probe has completed (or it has
12626
+ * no probe) — exported shape is stable; exporters gate advertise on this.
12627
+ * Optional for deploy-order resilience: a record produced by an older
12628
+ * device-manager build omits it, and consumers treat absent as "not ready"
12629
+ * (export gate carries forward, never advertises a partial shape). */
12630
+ probed: boolean().optional(),
10235
12631
  features: array(string()),
10236
12632
  /** true when the device has a getStreamSources() method (ICameraDevice) */
10237
12633
  isCamera: boolean(),
@@ -10239,7 +12635,25 @@ const DeviceInfoSchema = object({
10239
12635
  config: record(string(), unknown()),
10240
12636
  /** Hardware + identity blob (manufacturer / model / firmware / sn /
10241
12637
  * uid / mac / …). Populated by drivers; editable via setMetadata. */
10242
- metadata: record(string(), unknown()).nullable().optional()
12638
+ metadata: record(string(), unknown()).nullable().optional(),
12639
+ /** Optional upstream-system identity + rendering envelope. See
12640
+ * `SourceInfo` in `packages/types/src/device/source-info.ts`. */
12641
+ sourceInfo: SourceInfoSchema.optional(),
12642
+ /** Owning integration id, stamped at create — used by the core to
12643
+ * cascade-delete an integration's devices. Optional: only present
12644
+ * when the device was created through an integration (e.g. HA). */
12645
+ integrationId: string().optional(),
12646
+ linkDeviceId: number().nullable().optional(),
12647
+ /** Durable primary-child override for a CONTAINER device, keyed on the
12648
+ * chosen child's re-sync/rename-stable `entityId`. Survives a re-sync
12649
+ * that reallocates the child's numeric id. `null` clears the override. */
12650
+ primaryChildEntityId: string().nullable().optional(),
12651
+ /** Container-level layout hint: assigns specific child/accessory devices to
12652
+ * named accordion sections (with optional intra-section order). See
12653
+ * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12654
+ childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12655
+ /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12656
+ deviceLinks: array(DeviceLinkSchema).readonly().optional()
10243
12657
  });
10244
12658
  const ConfigEntrySchema$1 = object({
10245
12659
  key: string(),
@@ -10270,7 +12684,32 @@ const DeviceMetaSchema = object({
10270
12684
  location: string().nullable(),
10271
12685
  disabled: boolean(),
10272
12686
  parentDeviceId: number().nullable(),
10273
- metadata: record(string(), unknown()).nullable().optional()
12687
+ metadata: record(string(), unknown()).nullable().optional(),
12688
+ /** Optional upstream-system identity + rendering envelope. See
12689
+ * `SourceInfo`. Present when the device has been persisted with a
12690
+ * non-synthetic source identifier (HA entities, vendor MAC, …);
12691
+ * omitted when the synthetic `{ id: stableId, system: addonId }`
12692
+ * fallback is in effect. Persisted under
12693
+ * `DeviceMeta.metadata.sourceInfo` — the field on this schema is
12694
+ * the typed projection callers consume directly. */
12695
+ sourceInfo: SourceInfoSchema.optional(),
12696
+ /** Owning integration id, stamped at create — used by the core to
12697
+ * cascade-delete an integration's devices. Optional: only present
12698
+ * when the device was created through an integration (e.g. HA). */
12699
+ integrationId: string().optional(),
12700
+ linkDeviceId: number().nullable().optional(),
12701
+ /** Durable primary-child override for a CONTAINER device, keyed on the
12702
+ * chosen child's re-sync/rename-stable `entityId`. See `DeviceMeta`. */
12703
+ primaryChildEntityId: string().nullable().optional(),
12704
+ /** Container-level layout hint: assigns child/accessory devices to named
12705
+ * accordion sections (with optional intra-section order). See
12706
+ * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12707
+ childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12708
+ /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12709
+ deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12710
+ /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
12711
+ * Optional: only present for accessory children that carry a known role. */
12712
+ role: string().nullable().optional()
10274
12713
  });
10275
12714
  const ConfigUISchemaOutput = unknown().nullable();
10276
12715
  const StreamProbeResultSchema = object({
@@ -10364,6 +12803,109 @@ const DevicePersistConfigPayloadSchema = object({
10364
12803
  _void(),
10365
12804
  { kind: "mutation", auth: "admin" }
10366
12805
  ),
12806
+ /** Update the device type. Writes the meta row, emits a
12807
+ * `DeviceMetaChanged` event. Used by the kernel to apply
12808
+ * `initialMeta.type` before device construction so the device
12809
+ * constructs with its real type instead of the allocateDeviceId
12810
+ * placeholder `'generic'`. */
12811
+ setType: method(
12812
+ object({ deviceId: number(), type: _enum(DeviceType) }),
12813
+ _void(),
12814
+ { kind: "mutation", auth: "admin" }
12815
+ ),
12816
+ /** Stamp (or update) the owning integration id on the device's meta
12817
+ * row. Called by the kernel's `create()` pre-seed path when
12818
+ * `initialMeta.integrationId` is set (analogous to `setName` /
12819
+ * `setType`). Idempotent. */
12820
+ setIntegrationId: method(
12821
+ object({ deviceId: number(), integrationId: string() }),
12822
+ _void(),
12823
+ { kind: "mutation", auth: "admin" }
12824
+ ),
12825
+ /** Stamp (or update) the soft-link device id on the device's meta
12826
+ * row. Called by the kernel `create()` pre-seed when
12827
+ * `initialMeta.linkDeviceId !== undefined`. Idempotent. */
12828
+ setLinkDeviceId: method(
12829
+ object({ deviceId: number(), linkDeviceId: number().nullable() }),
12830
+ _void(),
12831
+ { kind: "mutation", auth: "admin" }
12832
+ ),
12833
+ /** Set (or clear) the durable primary-child override on a CONTAINER
12834
+ * device's meta row, keyed on the chosen child's re-sync/rename-stable
12835
+ * `entityId`. Unlike `setLinkDeviceId` (numeric child id, goes stale on
12836
+ * re-sync), this survives a re-sync that reallocates the child's numeric
12837
+ * id. `null` clears the override → priority default. Idempotent. */
12838
+ setPrimaryChildEntityId: method(
12839
+ object({ deviceId: number(), primaryChildEntityId: string().nullable() }),
12840
+ _void(),
12841
+ { kind: "mutation", auth: "admin" }
12842
+ ),
12843
+ /** Set (or replace) the container-level `childLayout` on a device's meta
12844
+ * row — assigns specific child/accessory devices to named accordion
12845
+ * sections (with optional intra-section order). Mirrors
12846
+ * `setPrimaryChildEntityId`; the layout is parent-only. Persisted,
12847
+ * projected, and preserved across re-register/restore. Idempotent. */
12848
+ setChildLayout: method(
12849
+ object({ deviceId: number(), childLayout: array(ChildLayoutEntrySchema).readonly() }),
12850
+ _void(),
12851
+ { kind: "mutation", auth: "admin" }
12852
+ ),
12853
+ /** Set (or replace) the cross-device field wirings on a device's meta row.
12854
+ * Mirrors `setChildLayout`; persisted, projected, preserved across
12855
+ * re-register/restore. Idempotent. */
12856
+ setDeviceLinks: method(
12857
+ object({ deviceId: number(), deviceLinks: array(DeviceLinkSchema).readonly() }),
12858
+ _void(),
12859
+ { kind: "mutation", auth: "admin" }
12860
+ ),
12861
+ /** List the wireable status-schema fields per cap bound to a device.
12862
+ * Powers the Wiring tab's field pickers. Caps without a status schema are omitted. */
12863
+ getWireableFields: method(
12864
+ object({ deviceId: number() }),
12865
+ object({
12866
+ caps: array(object({
12867
+ cap: string(),
12868
+ fields: array(object({
12869
+ path: string(),
12870
+ kind: _enum(["string", "number", "boolean", "enum"]),
12871
+ enumValues: array(string()).optional()
12872
+ })).readonly()
12873
+ })).readonly()
12874
+ }),
12875
+ { kind: "query" }
12876
+ ),
12877
+ /** Stamp (or update) the semantic role on the device's meta row.
12878
+ * Called by the kernel's `create()` / `spawnAccessoryChild` pre-seed
12879
+ * path when `initialMeta.role` is set (analogous to `setIntegrationId`).
12880
+ * Idempotent. `null` explicitly clears a previous role. */
12881
+ setRole: method(
12882
+ object({ deviceId: number(), role: string().nullable() }),
12883
+ _void(),
12884
+ { kind: "mutation", auth: "admin" }
12885
+ ),
12886
+ /** Batched meta pre-seed — applies every provided field to the device's
12887
+ * meta row in ONE settings round-trip (one lock acquisition, one
12888
+ * `deviceMeta` write) and emits one `DeviceMetaChanged` event per field
12889
+ * that was supplied. Each field carries the SAME semantics as its
12890
+ * individual setter (`setName` / `setLocation` / `setType` /
12891
+ * `setIntegrationId` / `setLinkDeviceId` / `setRole`) — omitted fields are
12892
+ * left untouched; `null` clears `location` / `linkDeviceId` / `role`.
12893
+ * Idempotent. Used by the kernel's `spawnAccessoryChild` flow to collapse
12894
+ * the per-child meta pre-seed (4-6 individual setter calls) into one,
12895
+ * which is the dominant cost when reconciling a many-entity container. */
12896
+ applyInitialMeta: method(
12897
+ object({
12898
+ deviceId: number(),
12899
+ name: string().optional(),
12900
+ location: string().nullable().optional(),
12901
+ type: _enum(DeviceType).optional(),
12902
+ integrationId: string().optional(),
12903
+ linkDeviceId: number().nullable().optional(),
12904
+ role: string().nullable().optional()
12905
+ }),
12906
+ _void(),
12907
+ { kind: "mutation", auth: "admin" }
12908
+ ),
10367
12909
  /** Patch the device's hardware-identity metadata blob. Merges
10368
12910
  * `patch` over the current blob (shallow). Value `null` for a
10369
12911
  * given key removes that key. Drivers populate factual fields
@@ -10477,6 +13019,15 @@ const DevicePersistConfigPayloadSchema = object({
10477
13019
  object({ success: literal(true) }),
10478
13020
  { kind: "mutation", auth: "admin" }
10479
13021
  ),
13022
+ /** Cascade-delete every top-level device whose `integrationId` matches.
13023
+ * Children cascade automatically via the per-parent remove path.
13024
+ * Idempotent: a device whose `integrationId` was never set never matches.
13025
+ * Returns the count of top-level parents actually removed. */
13026
+ removeByIntegration: method(
13027
+ object({ integrationId: string() }),
13028
+ object({ removed: number() }),
13029
+ { kind: "mutation", auth: "admin" }
13030
+ ),
10480
13031
  // ── Stream profile map ────────────────────────────────────────────────────
10481
13032
  /** Get quality→streamId mapping for a camera. Derived from profileHint if not explicitly set. */
10482
13033
  getStreamProfileMap: method(
@@ -10620,6 +13171,15 @@ const DevicePersistConfigPayloadSchema = object({
10620
13171
  live: SettingsSchemaWithValuesSchema.nullable()
10621
13172
  })
10622
13173
  ),
13174
+ runDeviceAction: method(
13175
+ object({
13176
+ deviceId: number().int().nonnegative(),
13177
+ action: string().min(1),
13178
+ input: unknown()
13179
+ }),
13180
+ unknown(),
13181
+ { kind: "mutation" }
13182
+ ),
10623
13183
  updateDeviceField: method(
10624
13184
  object({
10625
13185
  deviceId: number(),
@@ -10674,7 +13234,11 @@ const DevicePersistConfigPayloadSchema = object({
10674
13234
  adoptDevice: method(
10675
13235
  object({
10676
13236
  addonId: string(),
10677
- candidate: DiscoveryCandidateSchema
13237
+ candidate: DiscoveryCandidateSchema,
13238
+ /** Owning integration id, stamped onto the new device's meta by the
13239
+ * device-manager forwarder so `removeByIntegration` can cascade it.
13240
+ * Optional for back-compat (omitted = no stamp = pre-existing behavior). */
13241
+ integrationId: string().optional()
10678
13242
  }),
10679
13243
  DeviceSummarySchema,
10680
13244
  { kind: "mutation", auth: "admin" }
@@ -10695,7 +13259,11 @@ const DevicePersistConfigPayloadSchema = object({
10695
13259
  object({
10696
13260
  addonId: string(),
10697
13261
  type: _enum(DeviceType),
10698
- config: record(string(), unknown())
13262
+ config: record(string(), unknown()),
13263
+ /** Owning integration id, stamped onto the new device's meta by the
13264
+ * device-manager forwarder so `removeByIntegration` can cascade it.
13265
+ * Optional for back-compat (omitted = no stamp = pre-existing behavior). */
13266
+ integrationId: string().optional()
10699
13267
  }),
10700
13268
  DeviceSummarySchema,
10701
13269
  { kind: "mutation", auth: "admin" }
@@ -10719,6 +13287,41 @@ const DevicePersistConfigPayloadSchema = object({
10719
13287
  FieldProbeResultSchema,
10720
13288
  { kind: "mutation", auth: "admin" }
10721
13289
  ),
13290
+ // ── Device-adoption operations (routed via CapabilityRegistry) ────────────
13291
+ //
13292
+ // These methods proxy to the `device-adoption` capability for an
13293
+ // integration-style addon (Home Assistant, …), routing by addonId —
13294
+ // the same hub-side proxy pattern as `discoverDevices`/`adoptDevice`
13295
+ // above. The admin UI's generic adopt modal calls them through this
13296
+ // singleton so it never needs a direct handle on the addon's
13297
+ // `device-adoption` provider.
13298
+ // getCandidate is not proxied — the adopt modal expands rows from
13299
+ // listCandidates' nested `children`, so a single-candidate fetch is
13300
+ // never needed at this layer.
13301
+ /** List adoption candidates for an integration via its device-adoption provider. */
13302
+ adoptionListCandidates: method(
13303
+ ListCandidatesInputSchema.extend({ addonId: string() }),
13304
+ ListCandidatesOutputSchema,
13305
+ { auth: "admin" }
13306
+ ),
13307
+ /** Trigger a discovery refresh on the integration's device-adoption provider. */
13308
+ adoptionRefresh: method(
13309
+ object({ addonId: string(), integrationId: string() }),
13310
+ AdoptionStatusSchema,
13311
+ { kind: "mutation", auth: "admin" }
13312
+ ),
13313
+ /** Adopt one or more discovered candidates via the device-adoption provider. */
13314
+ adoptionAdopt: method(
13315
+ AdoptInputSchema.extend({ addonId: string() }),
13316
+ AdoptResultSchema,
13317
+ { kind: "mutation", auth: "admin" }
13318
+ ),
13319
+ /** Release an adopted parent device via the device-adoption provider. */
13320
+ adoptionRelease: method(
13321
+ ReleaseInputSchema.extend({ addonId: string() }),
13322
+ _void(),
13323
+ { kind: "mutation", auth: "admin" }
13324
+ ),
10722
13325
  /**
10723
13326
  * Test a field value on an existing device (e.g. probe an RTSP URL).
10724
13327
  * Routes through the device-provider for the owning addon.
@@ -11006,7 +13609,11 @@ const NotificationRuleConditionsSchema = object({
11006
13609
  endHour: number()
11007
13610
  }).optional(),
11008
13611
  cooldownSeconds: number().optional(),
11009
- minDwellSeconds: number().optional()
13612
+ minDwellSeconds: number().optional(),
13613
+ /** Match against `event.data.eventType` token (e.g. `'press_long'`). When non-empty, only events
13614
+ * carrying a matching `data.eventType` string pass this condition. Rules without this field are
13615
+ * unaffected (back-compat). Distinct from `rule.eventTypes` which holds EventCategory strings. */
13616
+ eventTypeTokens: array(string()).readonly().optional()
11010
13617
  });
11011
13618
  const NotificationRuleTemplateSchema = object({
11012
13619
  title: string(),
@@ -11075,189 +13682,6 @@ const NotificationHistoryFilterSchema = object({
11075
13682
  )
11076
13683
  }
11077
13684
  });
11078
- const RecordingModeSchema = _enum(["continuous", "motion", "scheduled", "composite"]);
11079
- const StreamPolicySchema = object({
11080
- streamId: string(),
11081
- mode: _enum(["always", "inherit"])
11082
- });
11083
- const ScheduleRuleSchema = object({
11084
- days: array(number()).readonly(),
11085
- startTime: string(),
11086
- endTime: string(),
11087
- mode: _enum(["continuous", "motion"])
11088
- });
11089
- const RecordingPolicySchema = object({
11090
- deviceId: number(),
11091
- mode: RecordingModeSchema,
11092
- streams: array(StreamPolicySchema).readonly(),
11093
- enabled: boolean(),
11094
- preBufferSec: number(),
11095
- postBufferSec: number(),
11096
- scheduleRules: array(ScheduleRuleSchema).readonly().optional()
11097
- });
11098
- const DataCategorySchema = _enum([
11099
- "recording:main",
11100
- "recording:mid",
11101
- "recording:sub",
11102
- "thumbnail:scrub",
11103
- "thumbnail:event"
11104
- ]);
11105
- const RecordingStorageConfigSchema = object({
11106
- deviceId: number(),
11107
- dataCategory: DataCategorySchema,
11108
- storageName: string(),
11109
- subDirectory: string(),
11110
- retentionDays: number().nullable(),
11111
- retentionGb: number().nullable()
11112
- });
11113
- const RecordingSegmentSchema = object({
11114
- id: string(),
11115
- deviceId: number(),
11116
- streamId: string(),
11117
- startTime: number(),
11118
- endTime: number(),
11119
- duration: number(),
11120
- path: string(),
11121
- storageName: string(),
11122
- subDirectory: string(),
11123
- sizeBytes: number(),
11124
- codec: _enum(["h264", "h265"]),
11125
- hasAudio: boolean()
11126
- });
11127
- const RecordingThumbnailSchema = object({
11128
- deviceId: number(),
11129
- timestamp: number(),
11130
- path: string(),
11131
- storageName: string(),
11132
- subDirectory: string(),
11133
- sizeBytes: number(),
11134
- category: _enum(["scrub", "event"])
11135
- });
11136
- const AvailabilityRangeSchema = object({
11137
- startTime: number(),
11138
- endTime: number(),
11139
- streams: array(string()).readonly()
11140
- });
11141
- const StorageUsageSchema = object({
11142
- totalBytes: number(),
11143
- segmentCount: number()
11144
- });
11145
- const StreamEstimateSchema = object({
11146
- bitrateKbps: number(),
11147
- retentionDays: number().nullable(),
11148
- retentionGb: number().nullable(),
11149
- estimatedGb: number(),
11150
- estimatedDaysAtCapacity: number().nullable()
11151
- });
11152
- const StorageEstimateSchema = object({
11153
- perStream: record(string(), StreamEstimateSchema),
11154
- thumbnails: object({ estimatedGb: number() }),
11155
- totalEstimatedGb: number(),
11156
- motionEstimate: object({
11157
- avgEventsPerDay: number(),
11158
- avgDurationSec: number(),
11159
- dutyCyclePercent: number()
11160
- }).optional()
11161
- });
11162
- const MotionStatsSchema = object({
11163
- totalEvents: number(),
11164
- avgDurationSec: number(),
11165
- avgEventsPerDay: number(),
11166
- dutyCyclePercent: number()
11167
- });
11168
- const DeviceIdInput = object({ deviceId: number() });
11169
- const EnableInput = object({
11170
- deviceId: number(),
11171
- policy: RecordingPolicySchema.omit({ deviceId: true }),
11172
- storageOverrides: array(RecordingStorageConfigSchema.omit({ deviceId: true })).readonly().optional(),
11173
- ffmpegOverrides: record(string(), unknown()).optional()
11174
- });
11175
- const TimeRangeInput = object({
11176
- deviceId: number(),
11177
- startTime: number(),
11178
- endTime: number()
11179
- });
11180
- const StreamTimeRangeInput = object({
11181
- deviceId: number(),
11182
- streamId: string(),
11183
- startTime: number(),
11184
- endTime: number()
11185
- });
11186
- const PlaylistInput = object({
11187
- deviceId: number(),
11188
- streamId: string(),
11189
- startTime: number(),
11190
- endTime: number(),
11191
- live: boolean().optional()
11192
- });
11193
- const ThumbnailInput = object({
11194
- deviceId: number(),
11195
- timestamp: number(),
11196
- category: string().optional()
11197
- });
11198
- const DeviceStreamInput = object({
11199
- deviceId: number(),
11200
- streamId: string()
11201
- });
11202
- const StorageEstimateInput = object({
11203
- deviceId: number(),
11204
- motionInput: object({
11205
- avgEventsPerDay: number(),
11206
- avgDurationSec: number()
11207
- }).optional()
11208
- });
11209
- const RetentionConfigInput = object({
11210
- deviceId: number(),
11211
- dataCategory: DataCategorySchema
11212
- });
11213
- const SetPolicyInput = object({
11214
- deviceId: number(),
11215
- policy: RecordingPolicySchema.omit({ deviceId: true })
11216
- });
11217
- const UpdateConfigInput = object({
11218
- deviceId: number(),
11219
- policy: RecordingPolicySchema.omit({ deviceId: true }),
11220
- ffmpegOverrides: record(string(), unknown()).optional()
11221
- });
11222
- ({
11223
- methods: {
11224
- // ── Status ────────────────────────────────────────────────────────
11225
- getStatus: method(_void(), object({
11226
- activeRecordings: number(),
11227
- totalSegments: number(),
11228
- totalSizeMB: number()
11229
- })),
11230
- // ── Lifecycle ─────────────────────────────────────────────────────
11231
- enable: method(EnableInput, _void(), { kind: "mutation", auth: "admin" }),
11232
- disable: method(DeviceIdInput, _void(), { kind: "mutation", auth: "admin" }),
11233
- // ── Config ────────────────────────────────────────────────────────
11234
- getConfig: method(DeviceIdInput, RecordingPolicySchema.nullable()),
11235
- updateConfig: method(UpdateConfigInput, _void(), { kind: "mutation", auth: "admin" }),
11236
- // ── Playback ──────────────────────────────────────────────────────
11237
- getPlaylist: method(PlaylistInput, string()),
11238
- getThumbnail: method(ThumbnailInput, RecordingThumbnailSchema.nullable()),
11239
- getSegments: method(StreamTimeRangeInput, array(RecordingSegmentSchema).readonly()),
11240
- getAvailability: method(TimeRangeInput, array(AvailabilityRangeSchema).readonly()),
11241
- // ── Storage ───────────────────────────────────────────────────────
11242
- estimateStorage: method(StorageEstimateInput, StorageEstimateSchema),
11243
- estimateGlobalStorage: method(_void(), StorageEstimateSchema),
11244
- getStorageUsage: method(DeviceStreamInput, StorageUsageSchema),
11245
- // ── Policy ────────────────────────────────────────────────────────
11246
- setPolicy: method(SetPolicyInput, _void(), { kind: "mutation", auth: "admin" }),
11247
- getPolicy: method(DeviceIdInput, RecordingPolicySchema.nullable()),
11248
- getPolicyStatus: method(DeviceIdInput, object({
11249
- deviceId: number(),
11250
- enabled: boolean(),
11251
- mode: RecordingModeSchema,
11252
- activeStreams: number()
11253
- }).nullable()),
11254
- // ── Retention ─────────────────────────────────────────────────────
11255
- getRetentionConfig: method(RetentionConfigInput, RecordingStorageConfigSchema.nullable()),
11256
- updateRetentionConfig: method(RecordingStorageConfigSchema, _void(), { kind: "mutation", auth: "admin" }),
11257
- // ── Motion ────────────────────────────────────────────────────────
11258
- getMotionStats: method(TimeRangeInput, MotionStatsSchema)
11259
- }
11260
- });
11261
13685
  ({
11262
13686
  deviceTypes: [DeviceType.Camera]
11263
13687
  });
@@ -11306,26 +13730,38 @@ const MotionEventSchema = object({
11306
13730
  ...BaseEventFields,
11307
13731
  kind: literal("motion"),
11308
13732
  regionCount: number(),
13733
+ /** Heavy JSON array — omitted in slim projection. */
11309
13734
  regions: array(object({
11310
13735
  bbox: BoundingBoxSchema,
11311
13736
  pixelCount: number(),
11312
13737
  intensity: number()
11313
- })).readonly(),
11314
- frameWidth: number(),
11315
- frameHeight: number()
13738
+ })).readonly().optional(),
13739
+ /** Omitted in slim projection. */
13740
+ frameWidth: number().optional(),
13741
+ /** Omitted in slim projection. */
13742
+ frameHeight: number().optional(),
13743
+ /** Populated by B5 (recording playback URL for this event). */
13744
+ mediaUrl: string().optional()
11316
13745
  });
11317
13746
  const ObjectEventSchema = object({
11318
13747
  ...BaseEventFields,
11319
13748
  kind: literal("object"),
11320
- trackId: string(),
13749
+ /** Omitted in slim projection. */
13750
+ trackId: string().optional(),
11321
13751
  className: string(),
11322
13752
  label: string().optional(),
11323
- confidence: number(),
11324
- bbox: BoundingBoxSchema,
11325
- zones: array(string()).readonly(),
11326
- state: TrackStateSchema,
13753
+ /** Omitted in slim projection. */
13754
+ confidence: number().optional(),
13755
+ /** Heavy JSON — omitted in slim projection. */
13756
+ bbox: BoundingBoxSchema.optional(),
13757
+ /** Heavy JSON — omitted in slim projection. */
13758
+ zones: array(string()).readonly().optional(),
13759
+ /** Omitted in slim projection. */
13760
+ state: TrackStateSchema.optional(),
11327
13761
  /** MediaStore key for the crop attached to this event (if any). */
11328
- mediaKey: string().optional()
13762
+ mediaKey: string().optional(),
13763
+ /** Populated by B5 (recording playback URL for this event). */
13764
+ mediaUrl: string().optional()
11329
13765
  });
11330
13766
  const AudioEventSchema = object({
11331
13767
  ...BaseEventFields,
@@ -11336,7 +13772,9 @@ const AudioEventSchema = object({
11336
13772
  className: string(),
11337
13773
  originalClass: string().optional(),
11338
13774
  score: number()
11339
- }).optional()
13775
+ }).optional(),
13776
+ /** Populated by B5 (recording playback URL for this event). */
13777
+ mediaUrl: string().optional()
11340
13778
  });
11341
13779
  const MediaFileSchema = object({
11342
13780
  key: string(),
@@ -11351,7 +13789,12 @@ const DeviceEventQueryInput = object({
11351
13789
  deviceId: number(),
11352
13790
  since: number().optional(),
11353
13791
  until: number().optional(),
11354
- limit: number().int().min(1).max(MAX_EVENT_QUERY_LIMIT).default(DEFAULT_EVENT_QUERY_LIMIT)
13792
+ limit: number().int().min(1).max(MAX_EVENT_QUERY_LIMIT).default(DEFAULT_EVENT_QUERY_LIMIT),
13793
+ /** `slim` drops heavy JSON fields (regions/bbox/zones) and carries an
13794
+ * optional `mediaUrl` (populated by B5). `full` (default) keeps today's
13795
+ * exact behaviour. Callers may omit this field — the store defaults to
13796
+ * `full` when not provided. */
13797
+ projection: _enum(["full", "slim"]).optional()
11355
13798
  });
11356
13799
  const ObjectEventQueryInput = DeviceEventQueryInput.extend({
11357
13800
  classFilter: string().optional()
@@ -11406,6 +13849,37 @@ const TrackedDetectionSchema = object({
11406
13849
  DeviceEventQueryInput,
11407
13850
  array(AudioEventSchema).readonly()
11408
13851
  ),
13852
+ // ── Density (timeline histogram) ──────────────────────────
13853
+ /** Server-side bucketed event counts for the 24-hour timeline.
13854
+ * Returns one entry per non-empty bucket; empty buckets are omitted. */
13855
+ getEventDensity: method(
13856
+ object({
13857
+ deviceId: number(),
13858
+ since: number(),
13859
+ until: number(),
13860
+ bucketMs: number().int().positive()
13861
+ }),
13862
+ array(object({
13863
+ bucketStart: number(),
13864
+ motion: number().int(),
13865
+ object: number().int(),
13866
+ audio: number().int()
13867
+ })).readonly()
13868
+ ),
13869
+ // ── Maintenance ───────────────────────────────────────────
13870
+ /**
13871
+ * Delete all events (motion + object + audio) for the given device
13872
+ * that are older than `cutoffMs` (exclusive), and delete their
13873
+ * thumbnails in lockstep. Returns per-kind deleted counts.
13874
+ *
13875
+ * Called by Phase B3 recorder orchestration to keep event history
13876
+ * aligned with available footage.
13877
+ */
13878
+ pruneEventsBefore: method(
13879
+ object({ deviceId: number(), cutoffMs: number() }),
13880
+ object({ motion: number().int(), object: number().int(), audio: number().int() }),
13881
+ { kind: "mutation", auth: "admin" }
13882
+ ),
11409
13883
  // ── Media ─────────────────────────────────────────────────
11410
13884
  getEventMedia: method(
11411
13885
  object({ eventId: string() }),
@@ -11759,32 +14233,102 @@ const EventItemSchema = object({
11759
14233
  )
11760
14234
  }
11761
14235
  });
11762
- const SegmentSchema = object({
11763
- id: string(),
11764
- startTs: number(),
11765
- // unix ms
11766
- endTs: number(),
11767
- durationSec: number(),
11768
- sizeBytes: number().optional()
14236
+ const RecordingStatusSchema = object({
14237
+ deviceId: number(),
14238
+ enabled: boolean(),
14239
+ activeMode: _enum(["off", "continuous"]),
14240
+ nodeId: string(),
14241
+ storageBytes: number()
14242
+ });
14243
+ const RecordingRangeSchema = object({
14244
+ profile: string(),
14245
+ startMs: number(),
14246
+ endMs: number()
14247
+ });
14248
+ const RecordingAvailabilitySchema = object({
14249
+ deviceId: number(),
14250
+ ranges: array(RecordingRangeSchema)
14251
+ });
14252
+ const RecordingManifestSchema = object({
14253
+ deviceId: number(),
14254
+ /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
14255
+ localMasterPath: string().nullable(),
14256
+ /** HTTP(S) URL to the master playlist on the recording node's playback server
14257
+ * (the PRIMARY candidate); null when no recording / server. Carries the
14258
+ * scoped playback token in its path. */
14259
+ playbackUrl: string().nullable(),
14260
+ /**
14261
+ * Candidate master-playlist URLs the client tries in order (LAN first, then
14262
+ * remote — Tailscale/Cloudflare if the operator configured extra hosts), each
14263
+ * carrying the same scoped token. `playbackUrl` is the first entry. Empty when
14264
+ * there is no recording / server.
14265
+ */
14266
+ playbackEndpoints: array(string())
14267
+ });
14268
+ const RecordingDeviceUsageSchema = object({
14269
+ deviceId: number(),
14270
+ usedBytes: number()
14271
+ });
14272
+ const RecordingLocationUsageSchema = object({
14273
+ /** StorageLocation id; null for the legacy/degraded single-root fallback. */
14274
+ locationId: string().nullable(),
14275
+ /** Bytes of recordings stored on this location. */
14276
+ usedBytes: number(),
14277
+ /** Free bytes on the location's volume; null when capacity is unknown (remote). */
14278
+ availableBytes: number().nullable(),
14279
+ /** Total bytes of the location's volume; null when unknown. */
14280
+ totalBytes: number().nullable()
14281
+ });
14282
+ const RecordingStorageUsageSchema = object({
14283
+ nodeId: string(),
14284
+ totalUsedBytes: number(),
14285
+ devices: array(RecordingDeviceUsageSchema),
14286
+ locations: array(RecordingLocationUsageSchema)
11769
14287
  });
11770
14288
  ({
11771
- deviceTypes: [DeviceType.Camera],
11772
14289
  methods: {
11773
- getSegments: method(
11774
- object({
11775
- deviceId: number(),
11776
- from: number().optional(),
11777
- to: number().optional()
11778
- }),
11779
- array(SegmentSchema)
14290
+ getAvailability: method(
14291
+ object({ deviceId: number(), fromMs: number(), toMs: number() }),
14292
+ RecordingAvailabilitySchema,
14293
+ { kind: "query", auth: "admin" }
11780
14294
  ),
11781
- getPlaybackUrl: method(
11782
- object({ deviceId: number(), segmentId: string() }),
11783
- string().nullable()
14295
+ getPlaybackManifest: method(
14296
+ object({ deviceId: number(), fromMs: number(), toMs: number() }),
14297
+ RecordingManifestSchema,
14298
+ { kind: "query", auth: "admin" }
11784
14299
  ),
11785
- getThumbnailAt: method(
11786
- object({ deviceId: number(), timestamp: number() }),
11787
- object({ base64: string(), contentType: string() }).nullable()
14300
+ getStorageUsage: method(
14301
+ object({}),
14302
+ RecordingStorageUsageSchema,
14303
+ { kind: "query", auth: "admin" }
14304
+ ),
14305
+ getDeviceConfig: method(
14306
+ object({ deviceId: number() }),
14307
+ RecordingConfigSchema,
14308
+ { kind: "query", auth: "admin" }
14309
+ ),
14310
+ setDeviceConfig: method(
14311
+ object({ deviceId: number(), config: RecordingConfigSchema }),
14312
+ RecordingConfigSchema,
14313
+ { kind: "mutation", auth: "admin" }
14314
+ ),
14315
+ /** Re-scan this device's footage from disk (stat sizes) and reseed the
14316
+ * index, then return the fresh status. */
14317
+ rescanStorage: method(
14318
+ object({ deviceId: number() }),
14319
+ RecordingStatusSchema,
14320
+ { kind: "mutation", auth: "admin" }
14321
+ ),
14322
+ /** Apply this device's retention policy to footage now; returns the oldest
14323
+ * surviving footage start (the retention floor) or null if no footage. */
14324
+ pruneFootage: method(
14325
+ object({ deviceId: number() }),
14326
+ object({
14327
+ floorMs: number().nullable(),
14328
+ deletedBuckets: number().int(),
14329
+ reclaimedBytes: number().int()
14330
+ }),
14331
+ { kind: "mutation", auth: "admin" }
11788
14332
  )
11789
14333
  }
11790
14334
  });
@@ -11797,13 +14341,19 @@ const StreamSourceEntrySchema = object({
11797
14341
  fps: number().optional(),
11798
14342
  bitrate: number().optional(),
11799
14343
  codec: string().optional(),
11800
- profileHint: _enum(["high", "mid", "low"]).optional(),
14344
+ profileHint: CamProfileSchema.optional(),
11801
14345
  sdp: string().optional()
11802
14346
  });
11803
14347
  const ConfigEntrySchema = object({
11804
14348
  key: string(),
11805
14349
  value: unknown()
11806
14350
  });
14351
+ const RawStateResultSchema = object({
14352
+ /** Originating provider id, e.g. 'homeassistant' | 'reolink' | 'hikvision'. */
14353
+ source: string(),
14354
+ /** Opaque, DISPLAY-SAFE upstream blob (no secrets/PII). */
14355
+ data: record(string(), unknown())
14356
+ });
11807
14357
  ({
11808
14358
  methods: {
11809
14359
  /**
@@ -11835,6 +14385,21 @@ const ConfigEntrySchema = object({
11835
14385
  _void(),
11836
14386
  { kind: "mutation" }
11837
14387
  ),
14388
+ /**
14389
+ * Invoke a device custom action on a forked/remote device (the
14390
+ * cross-process transport for `IDevice.runDeviceAction`). Mirrors
14391
+ * `setConfig` — the device-manager calls this when the device is not
14392
+ * hub-local.
14393
+ */
14394
+ runAction: method(
14395
+ object({
14396
+ deviceId: number(),
14397
+ action: string().min(1),
14398
+ input: unknown()
14399
+ }),
14400
+ unknown(),
14401
+ { kind: "mutation" }
14402
+ ),
11838
14403
  /**
11839
14404
  * Invoke `IDevice.removeDevice()` so the driver can release resources
11840
14405
  * (close sockets, stop background tasks, …). The device-manager still
@@ -11862,6 +14427,18 @@ const ConfigEntrySchema = object({
11862
14427
  getSettingsSchema: method(
11863
14428
  object({ deviceId: number() }),
11864
14429
  unknown().nullable()
14430
+ ),
14431
+ /**
14432
+ * Opt-in: return the device's RAW upstream state (the provider's
14433
+ * cached values) as a display-safe `{ source, data }` blob. Returns
14434
+ * `null` when the device exposes no raw state — the State panel hides
14435
+ * its Raw toggle in that case. One-shot (read the provider's existing
14436
+ * cache; no upstream round-trip).
14437
+ */
14438
+ getRawState: method(
14439
+ object({ deviceId: number() }),
14440
+ RawStateResultSchema.nullable(),
14441
+ { auth: "protected" }
11865
14442
  )
11866
14443
  }
11867
14444
  });
@@ -11919,6 +14496,16 @@ object({
11919
14496
  )
11920
14497
  }
11921
14498
  });
14499
+ ({
14500
+ deviceTypes: [DeviceType.Button],
14501
+ methods: {
14502
+ press: method(
14503
+ object({ deviceId: number().int().nonnegative() }),
14504
+ _void(),
14505
+ { kind: "mutation", auth: "admin" }
14506
+ )
14507
+ }
14508
+ });
11922
14509
  const OsdOverlayKindEnum = _enum(["text", "timestamp", "watermark"]);
11923
14510
  const OsdPositionEnum = _enum([
11924
14511
  "top-left",
@@ -11981,18 +14568,72 @@ const OsdOverlayPatchSchema = object({
11981
14568
  }
11982
14569
  });
11983
14570
  object({
11984
- childDeviceIds: array(number()).readonly()
14571
+ /** All accessory children of the parent. */
14572
+ childDeviceIds: array(number()).readonly(),
14573
+ /** Subset of `childDeviceIds` the operator hid from the UI. The order
14574
+ * is irrelevant; the array is treated as a set by consumers. */
14575
+ hiddenChildIds: array(number()).readonly()
11985
14576
  });
11986
14577
  ({
11987
- deviceTypes: [DeviceType.Camera, DeviceType.Hub],
14578
+ // Cameras + Hubs are the historical consumers; the HA integration
14579
+ // landed in Phase E adds Switch / Light / Sensor / Thermostat / Cover
14580
+ // / Lock / Fan / MediaPlayer / AlarmPanel / Generic as parent types
14581
+ // for adopted HA devices whose entity-children sit under accessories.
14582
+ deviceTypes: [
14583
+ DeviceType.Camera,
14584
+ DeviceType.Hub,
14585
+ DeviceType.Switch,
14586
+ DeviceType.Light,
14587
+ DeviceType.Sensor,
14588
+ DeviceType.Thermostat,
14589
+ DeviceType.Cover,
14590
+ DeviceType.Lock,
14591
+ DeviceType.Fan,
14592
+ DeviceType.MediaPlayer,
14593
+ DeviceType.AlarmPanel,
14594
+ DeviceType.Generic
14595
+ ],
14596
+ methods: {
14597
+ /**
14598
+ * Toggle the UI-visibility of a single accessory child. Hidden
14599
+ * children stay registered and continue to receive state updates;
14600
+ * they're omitted only from the parent's UI accessories panel.
14601
+ *
14602
+ * Idempotent — hiding an already-hidden child or unhiding a
14603
+ * non-hidden child is a no-op. Providers that don't support
14604
+ * per-child visibility may implement this as a no-op (the UI hide
14605
+ * toggle gracefully degrades).
14606
+ */
14607
+ setChildHidden: method(
14608
+ object({
14609
+ deviceId: number().int().nonnegative(),
14610
+ childDeviceId: number().int().nonnegative(),
14611
+ hidden: boolean()
14612
+ }),
14613
+ _void(),
14614
+ { kind: "mutation", auth: "admin" }
14615
+ )
14616
+ },
11988
14617
  events: {
11989
14618
  /**
11990
14619
  * Emitted when a child device is created, removed, or its parent
11991
- * assignment changes. Payload carries the fresh list.
14620
+ * assignment changes. Payload carries the fresh list and the
14621
+ * hidden subset.
11992
14622
  */
11993
14623
  onAccessoriesChanged: { data: object({
11994
14624
  deviceId: number(),
11995
- childDeviceIds: array(number()).readonly()
14625
+ childDeviceIds: array(number()).readonly(),
14626
+ hiddenChildIds: array(number()).readonly()
14627
+ }) },
14628
+ /**
14629
+ * Emitted when the operator toggles a child's UI-visibility.
14630
+ * Subscribers (admin UI accessories panel) re-render off this
14631
+ * signal without re-fetching the full status block.
14632
+ */
14633
+ onChildVisibilityChanged: { data: object({
14634
+ deviceId: number(),
14635
+ childDeviceId: number(),
14636
+ hidden: boolean()
11996
14637
  }) }
11997
14638
  }
11998
14639
  });
@@ -12013,6 +14654,12 @@ const IntercomStatusSchema = object({
12013
14654
  /** Firmware ability cached at first session creation. Null until probed. */
12014
14655
  ability: IntercomAbilitySchema.nullable()
12015
14656
  });
14657
+ const TalkAudioCodecSchema = _enum([
14658
+ "opus",
14659
+ "s16le",
14660
+ "g711ulaw",
14661
+ "g711alaw"
14662
+ ]);
12016
14663
  ({
12017
14664
  deviceTypes: [DeviceType.Camera],
12018
14665
  methods: {
@@ -12057,20 +14704,35 @@ const IntercomStatusSchema = object({
12057
14704
  { kind: "mutation", auth: "admin" }
12058
14705
  ),
12059
14706
  /**
12060
- * Push a chunk of PCM s16le mono onto the active raw-PCM talk
12061
- * session. Frames are encoded to the firmware's expected codec
12062
- * (Reolink: IMA ADPCM @ camera sample rate; Hikvision: G.711 @
12063
- * 8 kHz) inside the provider callers must resample upstream to
12064
- * the rate the camera negotiated (typical: 16 kHz Reolink,
12065
- * 8 kHz Hikvision). PCM is shipped base64-encoded so the payload
12066
- * survives JSON serialization across tRPC.
14707
+ * Push one chunk of talk-back audio onto the active talk session.
14708
+ * The cap is codec-agnostic: the caller declares (or omits) the
14709
+ * wire format via `codec`; the provider decides between passthrough
14710
+ * (when the wire codec matches the camera's native talk channel),
14711
+ * transcoding via the `audio-codec` cap, or rejecting the call.
14712
+ *
14713
+ * Callers do NOT need to know the camera's wire format or sample
14714
+ * rate — that information lives entirely inside the provider.
14715
+ *
14716
+ * Sequence numbers MUST be monotonic per talk session; older frames
14717
+ * arriving after newer ones are dropped to avoid smearing the
14718
+ * downstream encoder state (G.711 is stateless but IMA ADPCM's
14719
+ * predictor would corrupt with re-ordering).
12067
14720
  */
12068
- pushTalkPcm: method(
14721
+ pushTalkAudio: method(
12069
14722
  object({
12070
14723
  deviceId: number(),
12071
- /** PCM frames as little-endian s16, mono. Base64-encoded so
12072
- * the payload survives tRPC JSON serialization. */
12073
- pcmBase64: string(),
14724
+ /** Audio bytes for ONE frame, base64-encoded so the payload
14725
+ * survives tRPC JSON serialization. */
14726
+ audioBase64: string(),
14727
+ /** Wire codec of the payload. Omit to let the provider default
14728
+ * to its native expected format (s16le @ provider-native rate,
14729
+ * mono). See {@link TalkAudioCodecSchema} for the supported set. */
14730
+ codec: TalkAudioCodecSchema.optional(),
14731
+ /** Sample rate (Hz). REQUIRED for `s16le`; advisory for
14732
+ * `opus` (encoder clock); ignored for `g711*` (implied 8000). */
14733
+ sampleRate: number().int().positive().optional(),
14734
+ /** Channel count. Default 1. */
14735
+ channels: number().int().positive().optional(),
12074
14736
  /** Sequence number for ordering / dropping out-of-order frames. */
12075
14737
  sequenceNumber: number().int()
12076
14738
  }),
@@ -12164,6 +14826,10 @@ const HardwareEncodersSchema = object({
12164
14826
  defaultH265: HardwareEncoderIdSchema,
12165
14827
  probedAt: number()
12166
14828
  });
14829
+ const HardwareDecodeAccelsSchema = object({
14830
+ methods: array(string()).readonly(),
14831
+ probedAt: number()
14832
+ });
12167
14833
  const HardwarePlatformSchema = _enum(["darwin", "linux", "win32"]);
12168
14834
  const HardwareArchSchema = _enum(["arm64", "x64"]);
12169
14835
  const GpuInfoSchema = object({
@@ -12232,6 +14898,16 @@ const ResolvedInferenceConfigSchema = object({
12232
14898
  _void(),
12233
14899
  HardwareEncodersSchema,
12234
14900
  { kind: "mutation", auth: "admin" }
14901
+ ),
14902
+ /**
14903
+ * Decode-side hw-accel probe — the `-hwaccel` methods the configured ffmpeg
14904
+ * binary supports (via `ffmpeg -hwaccels`). Cached after first call.
14905
+ */
14906
+ getHardwareDecodeAccels: method(_void(), HardwareDecodeAccelsSchema),
14907
+ refreshHardwareDecodeAccels: method(
14908
+ _void(),
14909
+ HardwareDecodeAccelsSchema,
14910
+ { kind: "mutation", auth: "admin" }
12235
14911
  )
12236
14912
  }
12237
14913
  });
@@ -12921,6 +15597,7 @@ const ClientNetworkStatsSchema = object({
12921
15597
  rttMs: number(),
12922
15598
  jitterMs: number(),
12923
15599
  estimatedBandwidthKbps: number(),
15600
+ packetLossPercent: number().min(0).max(100),
12924
15601
  lastUpdated: number()
12925
15602
  });
12926
15603
  const DeviceNetworkStatsSchema = object({
@@ -12943,7 +15620,8 @@ const DeviceNetworkStatsSchema = object({
12943
15620
  deviceId: number(),
12944
15621
  rttMs: number().min(0).max(6e4),
12945
15622
  jitterMs: number().min(0).max(1e4),
12946
- estimatedBandwidthKbps: number().min(0).max(1e6)
15623
+ estimatedBandwidthKbps: number().min(0).max(1e6),
15624
+ packetLossPercent: number().min(0).max(100)
12947
15625
  }),
12948
15626
  _void(),
12949
15627
  { kind: "mutation" }
@@ -13168,6 +15846,24 @@ const AvailableIntegrationTypeSchema = object({
13168
15846
  color: string(),
13169
15847
  instanceMode: string(),
13170
15848
  discoveryMode: string(),
15849
+ /**
15850
+ * Which integration-marker cap the addon declared, so the wizard can
15851
+ * branch on CAP — never on addon name. `device-adoption` integrations
15852
+ * (Home Assistant, …) route through the broker step (Approach A);
15853
+ * `device-provider` integrations (Reolink/Frigate/ONVIF) keep the
15854
+ * legacy config → discovery flow.
15855
+ */
15856
+ kind: _enum(["device-adoption", "device-provider"]),
15857
+ /**
15858
+ * For `device-adoption` addons: the broker kind to create/link
15859
+ * (e.g. `home-assistant`), declared in the addon manifest. `null` for
15860
+ * `device-provider` addons, which carry no broker.
15861
+ */
15862
+ brokerKind: string().nullable(),
15863
+ /** True when the integration's source system exposes locations the adoption
15864
+ * flow can import (e.g. HA areas). Drives the adopt modal's "import
15865
+ * locations" checkbox. Provider-declared in the addon manifest. */
15866
+ supportsLocationImport: boolean(),
13171
15867
  existingInstances: array(object({ id: string(), name: string() })),
13172
15868
  canAdd: boolean()
13173
15869
  });
@@ -13774,10 +16470,15 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13774
16470
  EventCategory2["DeviceSettingsUpdated"] = "device.settings-updated";
13775
16471
  EventCategory2["DeviceBindingsChanged"] = "device.bindings-changed";
13776
16472
  EventCategory2["DeviceMetaChanged"] = "device.meta-changed";
16473
+ EventCategory2["DeviceSourceInfoChanged"] = "device.source-info-changed";
13777
16474
  EventCategory2["DeviceStreamsRegistered"] = "device.streams-registered";
16475
+ EventCategory2["DeviceProvisioned"] = "device.provisioned";
16476
+ EventCategory2["DeviceReady"] = "device.ready";
13778
16477
  EventCategory2["IntegrationEnabled"] = "integration.enabled";
13779
16478
  EventCategory2["IntegrationDisabled"] = "integration.disabled";
13780
16479
  EventCategory2["IntegrationDeleted"] = "integration.deleted";
16480
+ EventCategory2["BrokerStatusChanged"] = "broker.status-changed";
16481
+ EventCategory2["BrokerMessage"] = "broker.message";
13781
16482
  EventCategory2["ProviderStarted"] = "provider.started";
13782
16483
  EventCategory2["ProviderStopped"] = "provider.stopped";
13783
16484
  EventCategory2["ProcessCrashed"] = "process.crashed";
@@ -13811,7 +16512,9 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13811
16512
  EventCategory2["StreamParamsChanged"] = "stream-params.changed";
13812
16513
  EventCategory2["DeviceStateChanged"] = "device.state-changed";
13813
16514
  EventCategory2["BatteryOnStatusChanged"] = "battery.onStatusChanged";
16515
+ EventCategory2["BatteryOnWakeStarted"] = "battery.onWakeStarted";
13814
16516
  EventCategory2["DoorbellOnPressed"] = "doorbell.onPressed";
16517
+ EventCategory2["EventEmitted"] = "event-emitter.event";
13815
16518
  EventCategory2["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
13816
16519
  EventCategory2["ClusterTopologySnapshot"] = "cluster.topology-snapshot";
13817
16520
  EventCategory2["MetricsNodeResourcesSnapshot"] = "metrics.node-resources-snapshot";
@@ -13894,6 +16597,27 @@ function emitReadiness(bus, params) {
13894
16597
  }
13895
16598
  ));
13896
16599
  }
16600
+ function createDurableState(deps) {
16601
+ const get = async () => {
16602
+ const store = await deps.read();
16603
+ const raw = store[deps.key];
16604
+ if (raw === void 0) return deps.fallback;
16605
+ const parsed = deps.schema.safeParse(raw);
16606
+ if (!parsed.success) {
16607
+ deps.onParseError?.(deps.key, parsed.error);
16608
+ return deps.fallback;
16609
+ }
16610
+ return parsed.data;
16611
+ };
16612
+ const set = async (next) => {
16613
+ const validated = deps.schema.parse(next);
16614
+ await deps.write({ [deps.key]: validated });
16615
+ };
16616
+ const update = async (fn2) => {
16617
+ await set(fn2(await get()));
16618
+ };
16619
+ return { get, set, update };
16620
+ }
13897
16621
  class BaseAddon {
13898
16622
  _ctx = null;
13899
16623
  _config;
@@ -14137,12 +16861,18 @@ class BaseAddon {
14137
16861
  // ── Lifecycle event emission ────────────────────────────────────────────
14138
16862
  emitLifecycle(category, data) {
14139
16863
  try {
14140
- this._ctx?.eventBus.emit({
14141
- id: `${this._ctx.id}-${Date.now()}`,
16864
+ const ctx = this._ctx;
16865
+ if (!ctx) return;
16866
+ ctx.eventBus.emit({
16867
+ id: `${ctx.id}-${Date.now()}`,
14142
16868
  timestamp: /* @__PURE__ */ new Date(),
14143
- source: { type: "addon", id: this._ctx.id, nodeId: this._ctx.kernel.localNodeId ?? "hub" },
16869
+ source: { type: "addon", id: ctx.id, nodeId: ctx.kernel.localNodeId ?? "hub" },
14144
16870
  category,
14145
- data: data ?? {}
16871
+ // Always carry `addonId` in the payload (the lifecycle event
16872
+ // contract declares it) so consumers — e.g. the alerts builder —
16873
+ // can name the addon without reaching into `source`. An explicit
16874
+ // `data.addonId` still wins if a caller provides one.
16875
+ data: { addonId: ctx.id, ...data ?? {} }
14146
16876
  });
14147
16877
  } catch {
14148
16878
  }
@@ -14215,6 +16945,44 @@ class BaseAddon {
14215
16945
  }
14216
16946
  this._config = resolved;
14217
16947
  }
16948
+ /**
16949
+ * Typed durable handle over ONE key of this addon's store. The whole
16950
+ * Zod-validated value round-trips on every read/write — no hand-listed
16951
+ * fields, so a field can never be silently dropped on persist. Reads use
16952
+ * the same retry budget as config resolution; a corrupt/legacy blob logs
16953
+ * a warning and falls back rather than crashing boot.
16954
+ */
16955
+ state(key, schema, fallback) {
16956
+ return createDurableState({
16957
+ key,
16958
+ schema,
16959
+ fallback,
16960
+ read: () => this.readAddonStoreWithRetry(),
16961
+ write: async (patch) => {
16962
+ await this._ctx?.settings?.writeAddonStore(patch);
16963
+ },
16964
+ onParseError: (k, e) => this._ctx?.logger?.warn?.(
16965
+ `durable-state: stored "${k}" failed validation — using fallback`,
16966
+ { meta: { addonId: this._ctx?.id, key: k, error: String(e) } }
16967
+ )
16968
+ });
16969
+ }
16970
+ /** Per-device variant of {@link state}, backed by the per-device store. */
16971
+ deviceState(deviceId, key, schema, fallback) {
16972
+ return createDurableState({
16973
+ key,
16974
+ schema,
16975
+ fallback,
16976
+ read: async () => await this._ctx?.settings?.readDeviceStore(deviceId) ?? {},
16977
+ write: async (patch) => {
16978
+ await this._ctx?.settings?.writeDeviceStore(deviceId, patch);
16979
+ },
16980
+ onParseError: (k, e) => this._ctx?.logger?.warn?.(
16981
+ `durable-state: stored device ${deviceId} "${k}" failed validation — using fallback`,
16982
+ { meta: { addonId: this._ctx?.id, deviceId, key: k, error: String(e) } }
16983
+ )
16984
+ });
16985
+ }
14218
16986
  /**
14219
16987
  * Wrap `ctx.settings.readAddonStore()` with a short retry budget so a
14220
16988
  * transient settings-store outage (mid-restart of sqlite-settings, tsx-watch