@camstack/addon-post-analysis 0.1.19 → 0.1.20

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 (67) hide show
  1. package/dist/embedding-encoder/index.js +1 -1
  2. package/dist/embedding-encoder/index.mjs +1 -1
  3. package/dist/enrichment-engine/index.js +1 -1
  4. package/dist/enrichment-engine/index.mjs +1 -1
  5. package/dist/{index-BFbwYH1P.js → index-B0RhVv1c.js} +3514 -750
  6. package/dist/index-B0RhVv1c.js.map +1 -0
  7. package/dist/{index-BrTlzsrE.mjs → index-ot5PeFg_.mjs} +3517 -753
  8. package/dist/index-ot5PeFg_.mjs.map +1 -0
  9. package/dist/pipeline-analytics/@mf-types.zip +0 -0
  10. package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-h5aXOPSA.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_sdk__loadShare__.mjs-lantnv8e.mjs} +1 -1
  11. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-BD3oMNGB.mjs +29 -0
  12. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-BgOHCakr.mjs +18 -0
  13. package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-BZTB2scQ.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs-D1qPKjvR.mjs} +2 -1
  14. package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-CJO5YKGV.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-B5X50Xa4.mjs} +1 -1
  15. package/dist/pipeline-analytics/{__mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-B0h0AGOH.mjs → __mfe_internal__addon_pipeline_analytics_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-B10b5k5J.mjs} +1 -1
  16. package/dist/pipeline-analytics/_stub.js +2 -3
  17. package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-kZBmgzMg.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DWB3apaJ.mjs} +6 -6
  18. package/dist/pipeline-analytics/{client-BlxIUpgf.mjs → client-C6xdgLZU.mjs} +2 -2
  19. package/dist/pipeline-analytics/{hostInit-qBB1Thhi.mjs → hostInit-3cyL9eyG.mjs} +12 -12
  20. package/dist/pipeline-analytics/{index-Dw6Q30NI.mjs → index-BCTHeI2m.mjs} +253 -267
  21. package/dist/pipeline-analytics/{index-DlhiA9R0.mjs → index-BuWLz0GG.mjs} +1 -1
  22. package/dist/pipeline-analytics/{index-DtdgkNgf.mjs → index-CIwq-tQL.mjs} +1 -1
  23. package/dist/pipeline-analytics/{index-BoL0rgZt.mjs → index-CWBMDbou.mjs} +1 -1
  24. package/dist/pipeline-analytics/index-CZhagnlH.mjs +67784 -0
  25. package/dist/pipeline-analytics/{index-CR1aiZDH.mjs → index-D883Q5B8.mjs} +1 -1
  26. package/dist/pipeline-analytics/{index-Dy2V7VOm.mjs → index-DtOI1aTU.mjs} +10112 -5987
  27. package/dist/pipeline-analytics/index.js +605 -42
  28. package/dist/pipeline-analytics/index.js.map +1 -1
  29. package/dist/pipeline-analytics/index.mjs +604 -42
  30. package/dist/pipeline-analytics/index.mjs.map +1 -1
  31. package/dist/pipeline-analytics/{jsx-runtime-Dlbl3gpr.mjs → jsx-runtime-DdLhuHmJ.mjs} +1 -1
  32. package/dist/pipeline-analytics/remoteEntry.js +1 -1
  33. package/dist/pipeline-analytics/{schemas-ClCuS4qa.mjs → schemas-B7L0qZtq.mjs} +411 -406
  34. package/package.json +12 -27
  35. package/dist/ffmpeg-config-DRONlBsj.mjs +0 -56
  36. package/dist/ffmpeg-config-DRONlBsj.mjs.map +0 -1
  37. package/dist/ffmpeg-config-uANz3sV5.js +0 -73
  38. package/dist/ffmpeg-config-uANz3sV5.js.map +0 -1
  39. package/dist/index-BFbwYH1P.js.map +0 -1
  40. package/dist/index-BrTlzsrE.mjs.map +0 -1
  41. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-NjF4kxzW.mjs +0 -19
  42. package/dist/pipeline-analytics/__mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-7HAAnpQu.mjs +0 -18
  43. package/dist/pipeline-analytics/index-i47purqY.mjs +0 -37880
  44. package/dist/playlist-generator-EhPaB7Hn.js +0 -48
  45. package/dist/playlist-generator-EhPaB7Hn.js.map +0 -1
  46. package/dist/playlist-generator-VTkgn53O.mjs +0 -48
  47. package/dist/playlist-generator-VTkgn53O.mjs.map +0 -1
  48. package/dist/recording/index.js +0 -257
  49. package/dist/recording/index.js.map +0 -1
  50. package/dist/recording/index.mjs +0 -235
  51. package/dist/recording/index.mjs.map +0 -1
  52. package/dist/recording-coordinator-BoGr5moz.js +0 -1052
  53. package/dist/recording-coordinator-BoGr5moz.js.map +0 -1
  54. package/dist/recording-coordinator-CsYH9LqF.mjs +0 -1012
  55. package/dist/recording-coordinator-CsYH9LqF.mjs.map +0 -1
  56. package/dist/recording-db-gOgaoQh0.js +0 -348
  57. package/dist/recording-db-gOgaoQh0.js.map +0 -1
  58. package/dist/recording-db-lIkSMTLq.mjs +0 -348
  59. package/dist/recording-db-lIkSMTLq.mjs.map +0 -1
  60. package/dist/recording-service-facade-B9lG6OFn.mjs +0 -123
  61. package/dist/recording-service-facade-B9lG6OFn.mjs.map +0 -1
  62. package/dist/recording-service-facade-Do1PKlAL.js +0 -123
  63. package/dist/recording-service-facade-Do1PKlAL.js.map +0 -1
  64. package/dist/storage-estimator-CRpoQc9j.js +0 -72
  65. package/dist/storage-estimator-CRpoQc9j.js.map +0 -1
  66. package/dist/storage-estimator-DzD8gWJH.mjs +0 -72
  67. package/dist/storage-estimator-DzD8gWJH.mjs.map +0 -1
@@ -1272,10 +1272,10 @@ const $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
1272
1272
  return;
1273
1273
  }
1274
1274
  }
1275
- const url = new URL(trimmed);
1275
+ const url2 = new URL(trimmed);
1276
1276
  if (def.hostname) {
1277
1277
  def.hostname.lastIndex = 0;
1278
- if (!def.hostname.test(url.hostname)) {
1278
+ if (!def.hostname.test(url2.hostname)) {
1279
1279
  payload.issues.push({
1280
1280
  code: "invalid_format",
1281
1281
  format: "url",
@@ -1289,7 +1289,7 @@ const $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
1289
1289
  }
1290
1290
  if (def.protocol) {
1291
1291
  def.protocol.lastIndex = 0;
1292
- if (!def.protocol.test(url.protocol.endsWith(":") ? url.protocol.slice(0, -1) : url.protocol)) {
1292
+ if (!def.protocol.test(url2.protocol.endsWith(":") ? url2.protocol.slice(0, -1) : url2.protocol)) {
1293
1293
  payload.issues.push({
1294
1294
  code: "invalid_format",
1295
1295
  format: "url",
@@ -1302,7 +1302,7 @@ const $ZodURL = /* @__PURE__ */ $constructor("$ZodURL", (inst, def) => {
1302
1302
  }
1303
1303
  }
1304
1304
  if (def.normalize) {
1305
- payload.value = url.href;
1305
+ payload.value = url2.href;
1306
1306
  } else {
1307
1307
  payload.value = trimmed;
1308
1308
  }
@@ -4426,6 +4426,9 @@ const ZodURL = /* @__PURE__ */ $constructor("ZodURL", (inst, def) => {
4426
4426
  $ZodURL.init(inst, def);
4427
4427
  ZodStringFormat.init(inst, def);
4428
4428
  });
4429
+ function url(params) {
4430
+ return /* @__PURE__ */ _url(ZodURL, params);
4431
+ }
4429
4432
  const ZodEmoji = /* @__PURE__ */ $constructor("ZodEmoji", (inst, def) => {
4430
4433
  $ZodEmoji.init(inst, def);
4431
4434
  ZodStringFormat.init(inst, def);
@@ -5109,6 +5112,7 @@ const WELL_KNOWN_TABS = [
5109
5112
  { id: "stream-broker", label: "Stream Broker", icon: "radio", order: 20 },
5110
5113
  { id: "streaming", label: "Streaming", icon: "video", order: 35 },
5111
5114
  { id: "ptz", label: "PTZ", icon: "move", order: 40 },
5115
+ { id: "consumables", label: "Consumables", icon: "recycle", order: 44 },
5112
5116
  { id: "pipeline", label: "Detection Pipeline", icon: "cpu", order: 39 },
5113
5117
  { id: "zones", label: "Detection", icon: "shapes", order: 38 },
5114
5118
  { id: "live-stats", label: "Live Stats", icon: "activity", order: 39 },
@@ -5128,7 +5132,7 @@ const WELL_KNOWN_TABS = [
5128
5132
  ];
5129
5133
  Object.fromEntries(WELL_KNOWN_TABS.map((t) => [t.id, t]));
5130
5134
  function isValuelessField(field) {
5131
- return field.type === "separator" || field.type === "info" || field.type === "button" || field.type === "object-array" || field.type === "widget" || field.type === "addon-action-button";
5135
+ 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";
5132
5136
  }
5133
5137
  function hydrateSchema(schema, values) {
5134
5138
  return {
@@ -5190,6 +5194,8 @@ function typeOf(field) {
5190
5194
  case "password":
5191
5195
  case "color":
5192
5196
  case "probe":
5197
+ case "timezone":
5198
+ case "datetime":
5193
5199
  return "string";
5194
5200
  case "number":
5195
5201
  case "slider":
@@ -5200,32 +5206,262 @@ function typeOf(field) {
5200
5206
  return "other";
5201
5207
  }
5202
5208
  }
5203
- const StorageLocationTypeSchema = _enum([
5204
- "data",
5205
- "media",
5206
- "recordings",
5207
- // Recording-stream slots — addons pick high/low/clips per use case.
5208
- // The legacy `IStorageProvider` interface enumerated these; keeping
5209
- // them in the Zod enum means the `storage` cap can route per-slot
5210
- // location refs (e.g. `'recordings-high:nas-01'`) without losing
5211
- // type safety.
5212
- "recordings-high",
5213
- "recordings-low",
5214
- "recordings-clips",
5215
- // Detection snapshots / thumbnails / crops.
5216
- "event-images",
5217
- "models",
5218
- "cache",
5219
- "logs",
5220
- "addons-data",
5221
- // `backups` owned by the backup-orchestrator builtin. Default
5222
- // root: `<dataDir>/backups`. Operators can repoint via the same
5223
- // override mechanism every other location uses (storage settings).
5224
- "backups"
5209
+ const CamProfileSchema = _enum(["high", "mid", "low"]);
5210
+ const CamStreamKindSchema = _enum([
5211
+ "pull-rtsp",
5212
+ "pull-rtmp",
5213
+ "pull-http",
5214
+ "pull-rfc4571",
5215
+ "push-annexb",
5216
+ /** Broker-spawned ffmpeg transcode plane reads from a source cam-stream
5217
+ * and writes a re-encoded RTP plane. The broker routes demand signals to
5218
+ * the source broker; the transcode process is single-flighted per
5219
+ * identical EncodeProfile so multiple derived subscribers share one ffmpeg
5220
+ * process. */
5221
+ "derived"
5222
+ ]);
5223
+ const CamStreamResolutionSchema = object({
5224
+ width: number().int().positive(),
5225
+ height: number().int().positive()
5226
+ });
5227
+ const CameraStreamSchema = object({
5228
+ /** Stable, provider-assigned id unique within the (deviceId) scope. */
5229
+ camStreamId: string().min(1),
5230
+ deviceId: number().int().nonnegative(),
5231
+ kind: CamStreamKindSchema,
5232
+ /** Required for pull-* kinds. Ignored for push-annexb. */
5233
+ url: string().optional(),
5234
+ codec: string().optional(),
5235
+ resolution: CamStreamResolutionSchema.optional(),
5236
+ fps: number().positive().optional(),
5237
+ /** Human label surfaced in the Admin UI "Camera Stream" dropdown. */
5238
+ label: string().optional(),
5239
+ /**
5240
+ * Device-level features the publisher advertised (e.g. `battery-operated`).
5241
+ * The broker, snapshot orchestrator, and prebuffer manager all consult
5242
+ * this list to derive policy — relaxed stall watchdog for battery
5243
+ * cams, prebuffer off by default, longer snapshot rate-limit, etc.
5244
+ *
5245
+ * Single source of truth replacing per-stream flags like the
5246
+ * historical `allowStall`: if the publisher knows the camera is
5247
+ * battery-powered, every downstream service derives the right policy
5248
+ * from this list.
5249
+ */
5250
+ deviceFeatures: array(string()).optional(),
5251
+ /**
5252
+ * Whether this stream participates in the broker's automatic profile
5253
+ * assignment (`computeInitialAssignment`). Defaults to `true`. Publishers
5254
+ * use `false` when they want a stream to be SELECTABLE in the UI but not
5255
+ * picked by default — e.g. Reolink publishes its native Baichuan streams
5256
+ * as `autoEligible: true` (the recommended path) and its RTSP / RTMP
5257
+ * mirrors as `autoEligible: false` (still pickable per slot, just not
5258
+ * the auto choice). Manual `assignProfile` calls remain valid for
5259
+ * non-eligible streams.
5260
+ */
5261
+ autoEligible: boolean().optional(),
5262
+ /**
5263
+ * Transport-specific opaque metadata. The broker passes it through to
5264
+ * the source reader without inspecting it. Currently used by
5265
+ * `pull-rfc4571` streams to carry the upstream SDP (so the reader can
5266
+ * route RTP packets to the right depacketizer without an in-band
5267
+ * DESCRIBE phase). Other kinds typically leave it undefined.
5268
+ */
5269
+ metadata: record(string(), unknown()).optional()
5270
+ });
5271
+ const ProfileSlotStatusSchema = _enum([
5272
+ "unassigned",
5273
+ "idle",
5274
+ "connecting",
5275
+ "streaming",
5276
+ "error"
5277
+ ]);
5278
+ const ProfileSlotSchema = object({
5279
+ deviceId: number().int().nonnegative(),
5280
+ profile: CamProfileSchema,
5281
+ /** Broker id the rest of the system addresses: `${deviceId}/${profile}`. */
5282
+ brokerId: string(),
5283
+ /** `null` when the profile is unassigned. */
5284
+ sourceCamStreamId: string().nullable(),
5285
+ status: ProfileSlotStatusSchema,
5286
+ resolution: CamStreamResolutionSchema.optional(),
5287
+ codec: string().optional(),
5288
+ preBufferSec: number().nonnegative().optional(),
5289
+ errorMessage: string().optional()
5290
+ });
5291
+ const StreamSourceEntrySchema$1 = object({
5292
+ id: string(),
5293
+ label: string(),
5294
+ protocol: _enum(["rtsp", "rtmp", "annexb", "http-mjpeg", "webrtc", "custom"]),
5295
+ url: string().optional(),
5296
+ resolution: object({ width: number(), height: number() }).readonly().optional(),
5297
+ fps: number().optional(),
5298
+ bitrate: number().optional(),
5299
+ codec: string().optional(),
5300
+ profileHint: CamProfileSchema.optional()
5301
+ });
5302
+ object({
5303
+ type: string(),
5304
+ url: string(),
5305
+ videoCodec: string().optional(),
5306
+ audioCodec: string().optional(),
5307
+ metadata: record(string(), unknown()).readonly().optional()
5308
+ });
5309
+ const EncodedPacketSchema = object({
5310
+ type: _enum(["video", "audio"]),
5311
+ data: _instanceof(Uint8Array),
5312
+ pts: number(),
5313
+ dts: number(),
5314
+ keyframe: boolean(),
5315
+ codec: string()
5316
+ });
5317
+ const DecodedFrameSchema = object({
5318
+ data: _instanceof(Uint8Array),
5319
+ width: number(),
5320
+ height: number(),
5321
+ format: _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]),
5322
+ timestamp: number()
5323
+ });
5324
+ const FrameHandleSchema = object({
5325
+ shmId: string(),
5326
+ slot: number().int().nonnegative(),
5327
+ seq: number().int().nonnegative(),
5328
+ width: number().int().positive(),
5329
+ height: number().int().positive(),
5330
+ format: _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]),
5331
+ pts: number(),
5332
+ byteLength: number().int().nonnegative(),
5333
+ nodeId: string(),
5334
+ slotCount: number().int().positive()
5335
+ });
5336
+ const FrameHandleFormatSchema = _enum(["rgb", "bgr", "yuv420", "gray"]);
5337
+ const SubscribeFramesInputSchema = object({
5338
+ brokerId: string(),
5339
+ format: FrameHandleFormatSchema,
5340
+ /**
5341
+ * Optional reader-side cadence hint in frames per second. The broker does
5342
+ * NOT throttle — latest-wins ring reads drop frames implicitly for a slow
5343
+ * consumer. The value is echoed back in `SubscribeFramesResult.maxFps` so
5344
+ * the consumer can pace its own `pullFrameHandles` polling.
5345
+ */
5346
+ maxFps: number().positive().optional(),
5347
+ /** Short caller-identity tag (`motion`, `detection`, …) for diagnostics. */
5348
+ tag: string().optional()
5349
+ });
5350
+ const SubscribeFramesResultSchema = object({
5351
+ /** Opaque id the consumer passes to `pullFrameHandles` / `unsubscribeFrames`. */
5352
+ subscriptionId: string(),
5353
+ /** Reader-side cadence hint (frames/s) — echoes `SubscribeFramesInput.maxFps`. */
5354
+ maxFps: number().nonnegative()
5355
+ });
5356
+ const DecodedAudioChunkSchema = object({
5357
+ data: _instanceof(Uint8Array),
5358
+ sampleRate: number().int().positive(),
5359
+ channels: number().int().positive(),
5360
+ timestamp: number()
5361
+ });
5362
+ const SubscribeAudioChunksInputSchema = object({
5363
+ brokerId: string(),
5364
+ /** Short caller-identity tag (`audio-analyzer`, …) for `listClients`. */
5365
+ tag: string().optional()
5366
+ });
5367
+ const SubscribeAudioChunksResultSchema = object({
5368
+ /** Opaque id passed to `pullAudioChunks` / `unsubscribeAudioChunks`. */
5369
+ subscriptionId: string()
5370
+ });
5371
+ const BrokerStatusSchema$1 = _enum(["idle", "connecting", "streaming", "error", "stopped"]);
5372
+ const BrokerStatsSchema = object({
5373
+ status: BrokerStatusSchema$1,
5374
+ inputFps: number(),
5375
+ decodeFps: number(),
5376
+ encodedSubscribers: number(),
5377
+ decodedSubscribers: number(),
5378
+ uptimeMs: number(),
5379
+ bitrateKbps: number(),
5380
+ idrIntervalMs: number(),
5381
+ codec: string().optional(),
5382
+ totalBytes: number(),
5383
+ packetCount: number(),
5384
+ rtspClients: number(),
5385
+ pipeClients: number(),
5386
+ preBufferSec: number(),
5387
+ preBufferMs: number(),
5388
+ preBufferPackets: number(),
5389
+ /**
5390
+ * Moleculer node id of the decoder provider currently servicing this
5391
+ * stream's decoded subscribers. `null` until the deferred decoder is
5392
+ * created (no decoded clients yet, or codec not detected). Surfaces the
5393
+ * runtime decoder placement so the UI can show "Decoder: <agent>" without
5394
+ * a separate cap call per broker.
5395
+ */
5396
+ decoderNodeId: string().nullable(),
5397
+ /**
5398
+ * Detected audio track parameters from the RTSP DESCRIBE / SDP. `null`
5399
+ * when the stream has no audio track or the broker is in cold start.
5400
+ * `supported = false` means the codec was detected but the local
5401
+ * decoder pipeline cannot produce PCM chunks (e.g. AAC without the
5402
+ * AAC pipeline wired). Surfaced in the UI device-overview so operators
5403
+ * can pre-pick the audio-analysis model that matches the codec.
5404
+ */
5405
+ audio: object({
5406
+ codec: string(),
5407
+ sampleRate: number(),
5408
+ channels: number(),
5409
+ supported: boolean()
5410
+ }).nullable().optional()
5411
+ });
5412
+ const ProfileRtspEntrySchema = object({
5413
+ profile: CamProfileSchema,
5414
+ /** Profile-keyed broker id, format `${deviceId}/${profile}` (e.g. `"15/high"`). */
5415
+ brokerId: string(),
5416
+ url: string(),
5417
+ mutedUrl: string(),
5418
+ enabled: boolean(),
5419
+ codec: string().optional(),
5420
+ resolution: CamStreamResolutionSchema.optional()
5421
+ });
5422
+ const RecordingWeekdaySchema = number().int().min(0).max(6);
5423
+ const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
5424
+ const RecordingScheduleSchema = discriminatedUnion("kind", [
5425
+ object({ kind: literal("always") }),
5426
+ object({
5427
+ kind: literal("timeOfDay"),
5428
+ start: string().regex(HHMM),
5429
+ end: string().regex(HHMM),
5430
+ /** Restrict to these weekdays; omit = every day. */
5431
+ days: array(RecordingWeekdaySchema).optional()
5432
+ })
5225
5433
  ]);
5434
+ const RecordingModeSchema = _enum(["continuous", "onMotion", "onAudioThreshold"]);
5435
+ const RecordingRuleSchema = object({
5436
+ schedule: RecordingScheduleSchema,
5437
+ mode: RecordingModeSchema,
5438
+ /** Seconds of footage to retain BEFORE a trigger (applied at keep/discard). */
5439
+ preBufferSec: number().min(0).default(0),
5440
+ /** Keep recording until this many seconds after the last trigger. */
5441
+ postBufferSec: number().min(0).default(0),
5442
+ /** Each new trigger restarts the post-buffer window. */
5443
+ resetTimeoutOnNewEvent: boolean().default(true),
5444
+ /** onAudioThreshold only — dBFS level that counts as a trigger. */
5445
+ thresholdDbfs: number().optional()
5446
+ });
5447
+ const RecordingRetentionSchema = object({
5448
+ maxAgeDays: number().min(0).optional(),
5449
+ maxSizeGb: number().min(0).optional()
5450
+ });
5451
+ const RecordingConfigSchema = object({
5452
+ enabled: boolean(),
5453
+ profiles: array(CamProfileSchema).optional(),
5454
+ segmentSeconds: number().int().positive().optional(),
5455
+ rules: array(RecordingRuleSchema).optional(),
5456
+ retention: RecordingRetentionSchema.optional()
5457
+ });
5458
+ const StorageLocationTypeSchema = string().regex(/^[a-z][a-zA-Z0-9-]*$/);
5226
5459
  const StorageLocationSchema = object({
5227
- id: string().regex(/^[a-z][a-z0-9-]*:[a-z0-9-]+$/),
5228
- type: StorageLocationTypeSchema,
5460
+ id: string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/),
5461
+ // Open string — the location's *kind* is an addon-declared id matching
5462
+ // `StorageLocationDeclaration.id`. All built-in ids are valid plain
5463
+ // strings, and addon-declared ids are admitted without schema changes.
5464
+ type: string(),
5229
5465
  displayName: string().min(1),
5230
5466
  providerId: string().min(1),
5231
5467
  config: record(string(), unknown()),
@@ -5236,8 +5472,36 @@ const StorageLocationSchema = object({
5236
5472
  });
5237
5473
  const StorageLocationRefSchema = union([
5238
5474
  StorageLocationTypeSchema,
5239
- string().regex(/^[a-z][a-z0-9-]*:[a-z0-9-]+$/)
5475
+ string().regex(/^[a-z][a-zA-Z0-9-]*:[a-zA-Z0-9-]+$/)
5240
5476
  ]);
5477
+ const StorageLocationDeclarationSchema = object({
5478
+ /**
5479
+ * Global location identifier, e.g. `recordings` or `recordingsLow`.
5480
+ * Must start with a lowercase letter and may contain letters, digits, and
5481
+ * hyphens.
5482
+ */
5483
+ id: string().regex(/^[a-z][a-zA-Z0-9-]*$/, {
5484
+ message: "id must start with a lowercase letter and contain only letters, digits, or hyphens"
5485
+ }),
5486
+ /** Human-readable name shown in the admin UI. */
5487
+ displayName: string().min(1, { message: "displayName must not be empty" }),
5488
+ /** Optional longer explanation of what data this location stores. */
5489
+ description: string().optional(),
5490
+ /**
5491
+ * `single` — exactly one instance of this location is allowed system-wide
5492
+ * (e.g. `logs`, `models`). The operator can edit it but not add more.
5493
+ * `multi` — the operator may register several instances (e.g. a second
5494
+ * `recordings` on a NAS for disk tiering); one is the default at any time.
5495
+ */
5496
+ cardinality: _enum(["single", "multi"]),
5497
+ /**
5498
+ * When set, the default instance for this location inherits its resolved
5499
+ * root from the named location's default instance. Useful for derivative
5500
+ * slots (e.g. `recordingsLow` → `recordings`) so operators only need to
5501
+ * configure the primary location.
5502
+ */
5503
+ defaultsTo: string().optional()
5504
+ });
5241
5505
  const DecoderStatsSchema = object({
5242
5506
  inputFps: number(),
5243
5507
  outputFps: number(),
@@ -5279,6 +5543,51 @@ const DecoderSessionConfigSchema = object({
5279
5543
  */
5280
5544
  frameSink: _enum(["callback", "shm"]).default("callback")
5281
5545
  });
5546
+ const VideoEncodeSchema = object({
5547
+ codec: _enum(["h264", "h265", "copy"]),
5548
+ profile: _enum(["baseline", "main", "high"]).optional(),
5549
+ width: number().int().positive().optional(),
5550
+ height: number().int().positive().optional(),
5551
+ fps: number().positive().optional(),
5552
+ bitrateKbps: number().int().positive().optional(),
5553
+ gopFrames: number().int().positive().optional(),
5554
+ bf: number().int().min(0).optional(),
5555
+ preset: _enum([
5556
+ "ultrafast",
5557
+ "superfast",
5558
+ "veryfast",
5559
+ "faster",
5560
+ "fast",
5561
+ "medium"
5562
+ ]).optional(),
5563
+ tune: _enum(["zerolatency", "film", "animation"]).optional()
5564
+ });
5565
+ const AudioEncodeSchema = union([
5566
+ literal("passthrough"),
5567
+ object({
5568
+ codec: _enum(["opus", "aac", "pcmu", "pcma", "copy"]),
5569
+ bitrateKbps: number().int().positive().optional(),
5570
+ sampleRateHz: number().int().positive().optional(),
5571
+ channels: union([literal(1), literal(2)]).optional()
5572
+ })
5573
+ ]);
5574
+ const EncodeProfileSchema = object({
5575
+ video: VideoEncodeSchema,
5576
+ audio: AudioEncodeSchema,
5577
+ /**
5578
+ * ffmpeg input-side args, inserted between the fixed global flags
5579
+ * (`-hide_banner -loglevel error`) and `-i pipe:0`. Free-text array
5580
+ * — the widget surfaces a textarea + suggestion chips for the most-
5581
+ * used demuxer/format options.
5582
+ */
5583
+ inputArgs: array(string()).optional(),
5584
+ /**
5585
+ * ffmpeg output-side args, inserted between the encode block and
5586
+ * the final `-f <muxer> pipe:1`. Use for muxer options, bitstream
5587
+ * filters, codec-specific overrides. Free-text array.
5588
+ */
5589
+ outputArgs: array(string()).optional()
5590
+ });
5282
5591
  function errMsg(err) {
5283
5592
  if (err instanceof Error) return err.message;
5284
5593
  if (typeof err === "string") return err;
@@ -5585,18 +5894,74 @@ var DeviceType = /* @__PURE__ */ ((DeviceType2) => {
5585
5894
  DeviceType2["Sensor"] = "sensor";
5586
5895
  DeviceType2["Thermostat"] = "thermostat";
5587
5896
  DeviceType2["Button"] = "button";
5897
+ DeviceType2["EventEmitter"] = "event-emitter";
5898
+ DeviceType2["Update"] = "update";
5588
5899
  DeviceType2["Generic"] = "generic";
5900
+ DeviceType2["Notifier"] = "notifier";
5901
+ DeviceType2["Script"] = "script";
5902
+ DeviceType2["Automation"] = "automation";
5903
+ DeviceType2["Lock"] = "lock";
5904
+ DeviceType2["Cover"] = "cover";
5905
+ DeviceType2["Valve"] = "valve";
5906
+ DeviceType2["Humidifier"] = "humidifier";
5907
+ DeviceType2["WaterHeater"] = "water-heater";
5908
+ DeviceType2["Fan"] = "fan";
5909
+ DeviceType2["MediaPlayer"] = "media-player";
5910
+ DeviceType2["AlarmPanel"] = "alarm-panel";
5911
+ DeviceType2["Control"] = "control";
5912
+ DeviceType2["Presence"] = "presence";
5913
+ DeviceType2["Weather"] = "weather";
5914
+ DeviceType2["Vacuum"] = "vacuum";
5915
+ DeviceType2["LawnMower"] = "lawn-mower";
5916
+ DeviceType2["Container"] = "container";
5917
+ DeviceType2["Image"] = "image";
5589
5918
  return DeviceType2;
5590
5919
  })(DeviceType || {});
5591
5920
  var DeviceFeature = /* @__PURE__ */ ((DeviceFeature2) => {
5592
5921
  DeviceFeature2["BatteryOperated"] = "battery-operated";
5593
5922
  DeviceFeature2["Rebootable"] = "rebootable";
5923
+ DeviceFeature2["Resyncable"] = "resyncable";
5594
5924
  DeviceFeature2["NativeSnapshot"] = "native-snapshot";
5595
5925
  DeviceFeature2["DoorbellButton"] = "doorbell-button";
5596
5926
  DeviceFeature2["TwoWayAudio"] = "two-way-audio";
5597
5927
  DeviceFeature2["PanTiltZoom"] = "pan-tilt-zoom";
5598
5928
  DeviceFeature2["PtzAutotrack"] = "ptz-autotrack";
5599
5929
  DeviceFeature2["MotionTrigger"] = "motion-trigger";
5930
+ DeviceFeature2["LightColorRgb"] = "light-color-rgb";
5931
+ DeviceFeature2["LightColorHsv"] = "light-color-hsv";
5932
+ DeviceFeature2["LightColorMired"] = "light-color-mired";
5933
+ DeviceFeature2["ClimateDualSetpoint"] = "climate-dual-setpoint";
5934
+ DeviceFeature2["ClimateHumidity"] = "climate-humidity";
5935
+ DeviceFeature2["ClimateFanMode"] = "climate-fan-mode";
5936
+ DeviceFeature2["ClimatePreset"] = "climate-preset";
5937
+ DeviceFeature2["CoverPositionable"] = "cover-positionable";
5938
+ DeviceFeature2["CoverTilt"] = "cover-tilt";
5939
+ DeviceFeature2["ValvePositionable"] = "valve-positionable";
5940
+ DeviceFeature2["FanSpeed"] = "fan-speed";
5941
+ DeviceFeature2["FanPreset"] = "fan-preset";
5942
+ DeviceFeature2["FanDirection"] = "fan-direction";
5943
+ DeviceFeature2["FanOscillating"] = "fan-oscillating";
5944
+ DeviceFeature2["LockPinRequired"] = "lock-pin-required";
5945
+ DeviceFeature2["LockOpen"] = "lock-open";
5946
+ DeviceFeature2["MediaPlayerSeek"] = "media-player-seek";
5947
+ DeviceFeature2["MediaPlayerVolume"] = "media-player-volume";
5948
+ DeviceFeature2["MediaPlayerMute"] = "media-player-mute";
5949
+ DeviceFeature2["MediaPlayerShuffle"] = "media-player-shuffle";
5950
+ DeviceFeature2["MediaPlayerRepeat"] = "media-player-repeat";
5951
+ DeviceFeature2["MediaPlayerSelectSource"] = "media-player-select-source";
5952
+ DeviceFeature2["MediaPlayerPlayMedia"] = "media-player-play-media";
5953
+ DeviceFeature2["MediaPlayerNext"] = "media-player-next";
5954
+ DeviceFeature2["MediaPlayerPrevious"] = "media-player-previous";
5955
+ DeviceFeature2["MediaPlayerStop"] = "media-player-stop";
5956
+ DeviceFeature2["AlarmPinRequired"] = "alarm-pin-required";
5957
+ DeviceFeature2["PresenceGps"] = "presence-gps";
5958
+ DeviceFeature2["NotifierImage"] = "notifier-image";
5959
+ DeviceFeature2["NotifierPriority"] = "notifier-priority";
5960
+ DeviceFeature2["NotifierData"] = "notifier-data";
5961
+ DeviceFeature2["NotifierActions"] = "notifier-actions";
5962
+ DeviceFeature2["NotifierRecipients"] = "notifier-recipients";
5963
+ DeviceFeature2["ScriptVariables"] = "script-variables";
5964
+ DeviceFeature2["AutomationSkipCondition"] = "automation-skip-condition";
5600
5965
  return DeviceFeature2;
5601
5966
  })(DeviceFeature || {});
5602
5967
  var DeviceRole = /* @__PURE__ */ ((DeviceRole2) => {
@@ -5609,6 +5974,40 @@ var DeviceRole = /* @__PURE__ */ ((DeviceRole2) => {
5609
5974
  DeviceRole2["Nightvision"] = "nightvision";
5610
5975
  DeviceRole2["PrivacyMask"] = "privacy-mask";
5611
5976
  DeviceRole2["Doorbell"] = "doorbell";
5977
+ DeviceRole2["BinaryHelper"] = "binary-helper";
5978
+ DeviceRole2["MotionSensor"] = "motion-sensor";
5979
+ DeviceRole2["ContactSensor"] = "contact-sensor";
5980
+ DeviceRole2["LeakSensor"] = "leak-sensor";
5981
+ DeviceRole2["SmokeSensor"] = "smoke-sensor";
5982
+ DeviceRole2["COSensor"] = "co-sensor";
5983
+ DeviceRole2["GasSensor"] = "gas-sensor";
5984
+ DeviceRole2["TamperSensor"] = "tamper-sensor";
5985
+ DeviceRole2["VibrationSensor"] = "vibration-sensor";
5986
+ DeviceRole2["ConnectivitySensor"] = "connectivity-sensor";
5987
+ DeviceRole2["SoundSensor"] = "sound-sensor";
5988
+ DeviceRole2["BinarySensor"] = "binary-sensor";
5989
+ DeviceRole2["TemperatureSensor"] = "temperature-sensor";
5990
+ DeviceRole2["HumiditySensor"] = "humidity-sensor";
5991
+ DeviceRole2["AmbientLightSensor"] = "ambient-light-sensor";
5992
+ DeviceRole2["PressureSensor"] = "pressure-sensor";
5993
+ DeviceRole2["PowerSensor"] = "power-sensor";
5994
+ DeviceRole2["EnergySensor"] = "energy-sensor";
5995
+ DeviceRole2["VoltageSensor"] = "voltage-sensor";
5996
+ DeviceRole2["CurrentSensor"] = "current-sensor";
5997
+ DeviceRole2["AirQualitySensor"] = "air-quality-sensor";
5998
+ DeviceRole2["BatterySensor"] = "battery-sensor";
5999
+ DeviceRole2["NumericSensor"] = "numeric-sensor";
6000
+ DeviceRole2["EnumSensor"] = "enum-sensor";
6001
+ DeviceRole2["DateTimeSensor"] = "datetime-sensor";
6002
+ DeviceRole2["GenericSensor"] = "generic-sensor";
6003
+ DeviceRole2["NumericControl"] = "numeric-control";
6004
+ DeviceRole2["SelectControl"] = "select-control";
6005
+ DeviceRole2["TextControl"] = "text-control";
6006
+ DeviceRole2["DateTimeControl"] = "datetime-control";
6007
+ DeviceRole2["MobilePushNotifier"] = "mobile-push-notifier";
6008
+ DeviceRole2["MessagingNotifier"] = "messaging-notifier";
6009
+ DeviceRole2["EmailNotifier"] = "email-notifier";
6010
+ DeviceRole2["GenericNotifier"] = "generic-notifier";
5612
6011
  return DeviceRole2;
5613
6012
  })(DeviceRole || {});
5614
6013
  ({
@@ -5690,6 +6089,33 @@ const FeatureProbeStatusSchema = object({
5690
6089
  }) }
5691
6090
  }
5692
6091
  });
6092
+ object({
6093
+ /** Carbon dioxide concentration in ppm. */
6094
+ co2Ppm: number().min(0).optional(),
6095
+ /** Total volatile organic compounds in ppb. */
6096
+ vocPpb: number().min(0).optional(),
6097
+ /** Particulate matter ≤ 2.5 μm in µg/m³. */
6098
+ pm25: number().min(0).optional(),
6099
+ /** Particulate matter ≤ 10 μm in µg/m³. */
6100
+ pm10: number().min(0).optional(),
6101
+ /** Composite AQI value (typically 0..500). */
6102
+ aqi: number().optional(),
6103
+ /** Ms epoch when the slice was last updated. */
6104
+ lastFetchedAt: number(),
6105
+ /** Live display unit of the single metric this slice carries (e.g. HA
6106
+ * `attributes.unit_of_measurement` → 'ppm' / 'ppb' / 'µg/m³'). Each
6107
+ * upstream `sensor.*` entity surfaces ONE device_class, so one unit
6108
+ * per slice is unambiguous. */
6109
+ unit: string().optional(),
6110
+ /** Suggested decimal places for numeric display.
6111
+ * Populated live from the upstream source when provided (e.g. HA
6112
+ * `attributes.suggested_display_precision`). Falls back to
6113
+ * auto-formatting when absent. */
6114
+ precision: number().int().min(0).max(10).optional()
6115
+ });
6116
+ ({
6117
+ deviceTypes: [DeviceType.Sensor]
6118
+ });
5693
6119
  const ContributionSectionSchema = object({
5694
6120
  id: string(),
5695
6121
  title: string(),
@@ -5747,27 +6173,112 @@ function method(input, output, options) {
5747
6173
  function event(data) {
5748
6174
  return { data };
5749
6175
  }
5750
- const AudioClassSummarySchema = object({
5751
- className: string(),
5752
- /** Number of windows (chunks) where this class was the top hit. */
5753
- hits: number().int().nonnegative(),
5754
- /** Mean score across those hits, clamped to [0,1]. */
5755
- avgScore: number().min(0).max(1),
5756
- /** Peak score in the window. */
5757
- peakScore: number().min(0).max(1)
6176
+ const AlarmStateSchema = _enum([
6177
+ "disarmed",
6178
+ "armed_home",
6179
+ "armed_away",
6180
+ "armed_night",
6181
+ "armed_vacation",
6182
+ "armed_custom_bypass",
6183
+ "arming",
6184
+ "disarming",
6185
+ "pending",
6186
+ "triggered"
6187
+ ]);
6188
+ const AlarmArmModeSchema = _enum([
6189
+ "home",
6190
+ "away",
6191
+ "night",
6192
+ "vacation",
6193
+ "custom_bypass"
6194
+ ]);
6195
+ object({
6196
+ /** Current lifecycle state. */
6197
+ state: AlarmStateSchema,
6198
+ /** Subset of arm modes the panel accepts. UI renders one button per
6199
+ * mode in this list. */
6200
+ availableModes: array(AlarmArmModeSchema),
6201
+ /** Whether the panel requires a PIN on arm / disarm. Mirrors
6202
+ * `DeviceFeature.AlarmPinRequired` for slice consumers. */
6203
+ requiresCode: boolean(),
6204
+ /** Ms epoch when the slice was last updated. */
6205
+ lastChangedAt: number()
5758
6206
  });
5759
- const AudioMetricsSnapshotSchema = object({
5760
- /** Wall-clock timestamp (ms) of the most recent audio window. */
5761
- ts: number().int(),
5762
- /** Sliding-window length (seconds) used for aggregation. */
5763
- windowSec: number().int().positive(),
5764
- /** Latest level reading from the most recent window. */
5765
- level: object({
5766
- rms: number(),
5767
- dbfs: number()
5768
- }),
5769
- /** Peak dBFS observed across the rolling window. */
5770
- peakDbfs: number(),
6207
+ ({
6208
+ deviceTypes: [DeviceType.AlarmPanel],
6209
+ methods: {
6210
+ arm: method(
6211
+ object({
6212
+ deviceId: number().int().nonnegative(),
6213
+ mode: AlarmArmModeSchema,
6214
+ /** Optional PIN code. Required when `requiresCode === true`.
6215
+ * Passed through to the upstream service; never persisted. */
6216
+ code: string().min(1).optional()
6217
+ }),
6218
+ _void(),
6219
+ { kind: "mutation", auth: "admin" }
6220
+ ),
6221
+ disarm: method(
6222
+ object({
6223
+ deviceId: number().int().nonnegative(),
6224
+ code: string().min(1).optional()
6225
+ }),
6226
+ _void(),
6227
+ { kind: "mutation", auth: "admin" }
6228
+ ),
6229
+ /**
6230
+ * Force the panel into the `triggered` state — used by HA
6231
+ * automations to surface external sensor events through the panel
6232
+ * (e.g. a Reolink camera intrusion event firing the security
6233
+ * system). Provider rejects when the panel hardware doesn't
6234
+ * support a software-initiated trigger.
6235
+ */
6236
+ trigger: method(
6237
+ object({ deviceId: number().int().nonnegative() }),
6238
+ _void(),
6239
+ { kind: "mutation", auth: "admin" }
6240
+ )
6241
+ }
6242
+ });
6243
+ object({
6244
+ /** Current illuminance in lux (lx). */
6245
+ lux: number().min(0),
6246
+ /** Ms epoch when the slice was last updated. */
6247
+ lastFetchedAt: number(),
6248
+ /** Live display unit from the upstream source (e.g. HA
6249
+ * `attributes.unit_of_measurement`). The UI prefers this over the
6250
+ * role's canonical unit. Absent → fall back to the canonical unit. */
6251
+ unit: string().optional(),
6252
+ /** Suggested decimal places for numeric display.
6253
+ * Populated live from the upstream source when provided (e.g. HA
6254
+ * `attributes.suggested_display_precision`). Falls back to
6255
+ * auto-formatting when absent. */
6256
+ precision: number().int().min(0).max(10).optional()
6257
+ });
6258
+ ({
6259
+ deviceTypes: [DeviceType.Sensor]
6260
+ });
6261
+ const AudioClassSummarySchema = object({
6262
+ className: string(),
6263
+ /** Number of windows (chunks) where this class was the top hit. */
6264
+ hits: number().int().nonnegative(),
6265
+ /** Mean score across those hits, clamped to [0,1]. */
6266
+ avgScore: number().min(0).max(1),
6267
+ /** Peak score in the window. */
6268
+ peakScore: number().min(0).max(1)
6269
+ });
6270
+ const AudioMetricsSnapshotSchema = object({
6271
+ /** Wall-clock timestamp (ms) of the most recent audio window. */
6272
+ ts: number().int(),
6273
+ /** Sliding-window length (seconds) used for aggregation. */
6274
+ windowSec: number().int().positive(),
6275
+ /** Latest level reading from the most recent window. */
6276
+ level: object({
6277
+ rms: number(),
6278
+ dbfs: number()
6279
+ }),
6280
+ /** Peak dBFS observed across the rolling window. */
6281
+ peakDbfs: number(),
5771
6282
  /** Mean dBFS across the rolling window. */
5772
6283
  avgDbfs: number(),
5773
6284
  /** Most recent above-threshold classification, or null on silence. */
@@ -5843,6 +6354,46 @@ const audioMetricsCapability = {
5843
6354
  /** Reactive runtime-state mirror — live `device.state.audioMetrics.value`. */
5844
6355
  runtimeState: AudioMetricsSnapshotSchema
5845
6356
  };
6357
+ object({
6358
+ /** Whether the automation is currently enabled. Disabled automations
6359
+ * ignore their trigger block — manual `trigger` still works. */
6360
+ enabled: boolean(),
6361
+ /** Whether the automation is currently executing its action block. */
6362
+ isRunning: boolean(),
6363
+ /** Ms epoch of the last successful run. 0 when never run. */
6364
+ lastTriggeredAt: number(),
6365
+ /** Failure description from the last completed run. Null on success
6366
+ * or when never run. */
6367
+ lastError: string().nullable(),
6368
+ /** Ms epoch when the slice was last updated. */
6369
+ lastChangedAt: number()
6370
+ });
6371
+ ({
6372
+ deviceTypes: [DeviceType.Automation],
6373
+ methods: {
6374
+ enable: method(
6375
+ object({ deviceId: number().int().nonnegative() }),
6376
+ _void(),
6377
+ { kind: "mutation", auth: "admin" }
6378
+ ),
6379
+ disable: method(
6380
+ object({ deviceId: number().int().nonnegative() }),
6381
+ _void(),
6382
+ { kind: "mutation", auth: "admin" }
6383
+ ),
6384
+ trigger: method(
6385
+ object({
6386
+ deviceId: number().int().nonnegative(),
6387
+ /** When true, fires the action block while bypassing the
6388
+ * automation's condition evaluation. Gated by
6389
+ * `DeviceFeature.AutomationSkipCondition`. */
6390
+ skipCondition: boolean().optional()
6391
+ }),
6392
+ _void(),
6393
+ { kind: "mutation", auth: "admin" }
6394
+ )
6395
+ }
6396
+ });
5846
6397
  const BatteryStatusSchema = object({
5847
6398
  /** 0..100 inclusive. Firmware-reported. */
5848
6399
  percentage: number().min(0).max(100),
@@ -5860,10 +6411,48 @@ const BatteryStatusSchema = object({
5860
6411
  */
5861
6412
  sleeping: boolean(),
5862
6413
  /** Ms epoch of the last observation. Lets consumers reason about freshness. */
5863
- lastUpdated: number()
6414
+ lastUpdated: number(),
6415
+ /**
6416
+ * True when the source is a BINARY low-battery indicator (HA
6417
+ * `binary_sensor` device_class=battery / `LOW_BAT`) that has no real
6418
+ * charge level — `percentage` is then a coarse stand-in (100 = normal,
6419
+ * sub-threshold = low). UI MUST render "Normal"/"Low" instead of a
6420
+ * misleading exact percentage. Absent/false → genuine 0–100 % reading.
6421
+ */
6422
+ binary: boolean().optional()
5864
6423
  });
5865
6424
  ({
5866
6425
  deviceTypes: [DeviceType.Camera, DeviceType.Sensor, DeviceType.Button, DeviceType.Switch],
6426
+ methods: {
6427
+ /**
6428
+ * Explicitly wake the camera from low-power sleep ahead of a
6429
+ * streaming session start. Consumers that initiate a stream
6430
+ * against a sleeping battery cam (HomeKit Secure Video, Alexa
6431
+ * RTCSession, snapshot wrappers) call this with a short timeout
6432
+ * before establishing the media pipeline — the broker's own
6433
+ * passive wake-on-dial works but adds 5–7 seconds to first-frame,
6434
+ * during which the consumer renders a black screen. Pre-waking
6435
+ * compresses that gap.
6436
+ *
6437
+ * Returns `awoke: true` when the firmware acknowledged the wake
6438
+ * before `timeoutMs`. Returns `awoke: false` when it timed out OR
6439
+ * the cap surface is unavailable (no Baichuan / firmware
6440
+ * channel); the caller should still attempt the stream — the
6441
+ * passive broker wake remains as fallback.
6442
+ */
6443
+ wakeForStream: method(
6444
+ object({
6445
+ deviceId: number(),
6446
+ /** Bound on the wait. Sensible range 3000–10000ms. */
6447
+ timeoutMs: number().int().min(500).max(3e4).default(8e3)
6448
+ }),
6449
+ object({
6450
+ awoke: boolean(),
6451
+ durationMs: number()
6452
+ }),
6453
+ { kind: "mutation" }
6454
+ )
6455
+ },
5867
6456
  events: {
5868
6457
  /**
5869
6458
  * Emitted whenever the cached status changes (firmware push OR
@@ -5877,6 +6466,14 @@ const BatteryStatusSchema = object({
5877
6466
  }) }
5878
6467
  }
5879
6468
  });
6469
+ object({
6470
+ on: boolean(),
6471
+ /** Ms epoch of the last transition. 0 if never observed. */
6472
+ lastChangedAt: number()
6473
+ });
6474
+ ({
6475
+ deviceTypes: [DeviceType.Sensor]
6476
+ });
5880
6477
  object({
5881
6478
  /** Current level as 0..100 inclusive. Firmware-reported. */
5882
6479
  percentage: number().min(0).max(100),
@@ -5908,287 +6505,199 @@ object({
5908
6505
  }) }
5909
6506
  }
5910
6507
  });
5911
- const CamProfileSchema = _enum(["high", "mid", "low"]);
5912
- const CamStreamKindSchema = _enum([
5913
- "pull-rtsp",
5914
- "pull-rtmp",
5915
- "pull-http",
5916
- "pull-rfc4571",
5917
- "push-annexb"
5918
- ]);
5919
- const CamStreamResolutionSchema = object({
5920
- width: number().int().positive(),
5921
- height: number().int().positive()
6508
+ const StreamFormatSchema = _enum(["webrtc", "hls", "mjpeg", "rtsp"]);
6509
+ const StreamInfoSchema = object({
6510
+ streamId: string(),
6511
+ format: StreamFormatSchema,
6512
+ url: string().nullable(),
6513
+ active: boolean()
5922
6514
  });
5923
- const CameraStreamSchema = object({
5924
- /** Stable, provider-assigned id unique within the (deviceId) scope. */
5925
- camStreamId: string().min(1),
5926
- deviceId: number().int().nonnegative(),
5927
- kind: CamStreamKindSchema,
5928
- /** Required for pull-* kinds. Ignored for push-annexb. */
5929
- url: string().optional(),
6515
+ ({
6516
+ methods: {
6517
+ registerStream: method(
6518
+ object({ streamId: string(), sourceUrl: string(), codec: string().optional() }),
6519
+ _void(),
6520
+ { kind: "mutation" }
6521
+ ),
6522
+ unregisterStream: method(object({ streamId: string() }), _void(), { kind: "mutation" }),
6523
+ getStreamUrl: method(
6524
+ object({ streamId: string(), format: StreamFormatSchema }),
6525
+ string().nullable()
6526
+ ),
6527
+ listStreams: method(_void(), array(StreamInfoSchema))
6528
+ }
6529
+ });
6530
+ const RtspRestreamEntrySchema = object({
6531
+ brokerId: string(),
6532
+ url: string(),
6533
+ mutedUrl: string(),
6534
+ enabled: boolean(),
6535
+ /**
6536
+ * Source-stream codec / resolution for the camStream this entry serves
6537
+ * (the broker's "high"/"mid"/"low" profile slot for this device).
6538
+ * Used by exporter pickers (`pickPreferredRtspEntry`) to resolve
6539
+ * `streamPreference: 'auto'` to the slot whose source is closest to
6540
+ * the consumer's target — Alexa wants ~720p, HomeKit wants ~1080p,
6541
+ * and there's no point dialling the 4K slot for an Echo Show. Absent
6542
+ * when the source publisher never advertised the field; pickers fall
6543
+ * back to first-enabled in that case.
6544
+ */
5930
6545
  codec: string().optional(),
5931
- resolution: CamStreamResolutionSchema.optional(),
5932
- fps: number().positive().optional(),
5933
- /** Human label surfaced in the Admin UI "Camera Stream" dropdown. */
6546
+ resolution: object({
6547
+ width: number().int().positive(),
6548
+ height: number().int().positive()
6549
+ }).optional()
6550
+ });
6551
+ const BrokerRtspClientSchema = object({
6552
+ sessionId: string(),
6553
+ remoteAddr: string(),
6554
+ playing: boolean(),
6555
+ muted: boolean(),
6556
+ connectedAt: number(),
6557
+ lastRtpAt: number(),
6558
+ bytesSent: number()
6559
+ });
6560
+ const BrokerDecodedClientSchema = object({
6561
+ tag: string(),
6562
+ subscribedAt: number(),
6563
+ maxFps: number(),
6564
+ framesDelivered: number(),
6565
+ framesDropped: number()
6566
+ });
6567
+ const BrokerAudioClientSchema = object({
6568
+ tag: string(),
6569
+ subscribedAt: number(),
6570
+ chunksDelivered: number()
6571
+ });
6572
+ const BrokerConsumerKindSchema = _enum([
6573
+ "alexa",
6574
+ "homekit",
6575
+ "webrtc-browser",
6576
+ "webrtc-mobile",
6577
+ "webrtc-whep",
6578
+ "rtsp-listen",
6579
+ "derived-broker",
6580
+ "recording",
6581
+ "pipeline",
6582
+ "snapshot",
6583
+ "warmup",
6584
+ "unknown"
6585
+ ]);
6586
+ const BrokerConsumerAttributionSchema = object({
6587
+ kind: BrokerConsumerKindSchema,
6588
+ /**
6589
+ * Free-form label intended to disambiguate consumers OF THE SAME kind
6590
+ * on the same broker — e.g. user name, device alias. Should NOT repeat
6591
+ * the kind / cam / cam-stream / sessionId (those are surfaced by
6592
+ * dedicated fields). When empty the widget falls back to `${kind} ·
6593
+ * <sessionId tail>`.
6594
+ */
5934
6595
  label: string().optional(),
5935
6596
  /**
5936
- * Device-level features the publisher advertised (e.g. `battery-operated`).
5937
- * The broker, snapshot orchestrator, and prebuffer manager all consult
5938
- * this list to derive policy relaxed stall watchdog for battery
5939
- * cams, prebuffer off by default, longer snapshot rate-limit, etc.
6597
+ * Which part of the encoded plane this subscription consumes:
6598
+ * - `'audio'` audio packets only
6599
+ * - `'video'`video packets only
6600
+ * - `'both'` — both video + audio (typical for AnnexB push paths
6601
+ * that don't split the source RTP)
5940
6602
  *
5941
- * Single source of truth replacing per-stream flags like the
5942
- * historical `allowStall`: if the publisher knows the camera is
5943
- * battery-powered, every downstream service derives the right policy
5944
- * from this list.
6603
+ * Missing field means "unknown" older callers and the legacy
6604
+ * paths that haven't been migrated yet.
5945
6605
  */
5946
- deviceFeatures: array(string()).optional(),
6606
+ media: _enum(["audio", "video", "both"]).optional(),
5947
6607
  /**
5948
- * Whether this stream participates in the broker's automatic profile
5949
- * assignment (`computeInitialAssignment`). Defaults to `true`. Publishers
5950
- * use `false` when they want a stream to be SELECTABLE in the UI but not
5951
- * picked by default e.g. Reolink publishes its native Baichuan streams
5952
- * as `autoEligible: true` (the recommended path) and its RTSP / RTMP
5953
- * mirrors as `autoEligible: false` (still pickable per slot, just not
5954
- * the auto choice). Manual `assignProfile` calls remain valid for
5955
- * non-eligible streams.
6608
+ * Codec the consumer receives AFTER any per-session re-encoding. For
6609
+ * a WebRTC session this is the negotiated egress codec (e.g. `H264`,
6610
+ * `H265`, `Opus`, `Pcmu`); for an RTSP restreamer it mirrors the
6611
+ * source codec. Surfaced in the widget chip so operators see at a
6612
+ * glance whether a viewer is on H.264 (Echo) or H.265 (Safari).
5956
6613
  */
5957
- autoEligible: boolean().optional(),
6614
+ targetCodec: string().optional(),
5958
6615
  /**
5959
- * Transport-specific opaque metadata. The broker passes it through to
5960
- * the source reader without inspecting it. Currently used by
5961
- * `pull-rfc4571` streams to carry the upstream SDP (so the reader can
5962
- * route RTP packets to the right depacketizer without an in-band
5963
- * DESCRIBE phase). Other kinds typically leave it undefined.
6616
+ * Whether the broker is re-encoding for this consumer:
6617
+ * - `'passthrough'` bytes flow source→consumer without ffmpeg
6618
+ * - `'repacketize'` RTP payload is re-packetized but NOT re-encoded
6619
+ * (preserves frames, just rebuilds headers; e.g.
6620
+ * the H.265 repacketizer path)
6621
+ * - `'transcode'` — a full encode/decode round (ffmpeg, libx264,
6622
+ * libav…); the most expensive path
5964
6623
  */
5965
- metadata: record(string(), unknown()).optional()
6624
+ transport: _enum(["passthrough", "repacketize", "transcode"]).optional(),
6625
+ /** Remote peer IP if the consumer terminates a network socket. */
6626
+ remoteAddr: string().optional(),
6627
+ /**
6628
+ * Server-read User-Agent of the originating client; enriched by the hub
6629
+ * from the tRPC request context (browser sessions).
6630
+ */
6631
+ userAgent: string().optional(),
6632
+ /** Authenticated user id (CamStack OAuth subject) when known. */
6633
+ userId: string().optional(),
6634
+ /** Higher-level session identifier (Alexa Echo sessionId, HAP session, …). */
6635
+ sessionId: string().optional(),
6636
+ /** Free-form key/value extras (e.g. clientHints from a WebRTC offer). */
6637
+ extra: record(string(), string()).optional()
6638
+ }).readonly();
6639
+ const BrokerEncodedClientSchema = object({
6640
+ /** Stable id assigned on attach; used by `killClient`. */
6641
+ id: string(),
6642
+ /** Which broker fanout the subscriber rides — annexB encoded packets vs raw RTP. */
6643
+ channel: _enum(["annexb", "rtp"]),
6644
+ attribution: BrokerConsumerAttributionSchema,
6645
+ subscribedAt: number(),
6646
+ /** Total packets delivered (annex-B EncodedPackets or RTP byte-buffers). */
6647
+ packetsDelivered: number()
5966
6648
  });
5967
- const ProfileSlotStatusSchema = _enum([
5968
- "unassigned",
5969
- "idle",
5970
- "connecting",
5971
- "streaming",
5972
- "error"
6649
+ const BrokerClientsSchema = object({
6650
+ rtsp: array(BrokerRtspClientSchema).readonly(),
6651
+ decoded: array(BrokerDecodedClientSchema).readonly(),
6652
+ audio: array(BrokerAudioClientSchema).readonly(),
6653
+ encoded: array(BrokerEncodedClientSchema).readonly(),
6654
+ pipeClients: number(),
6655
+ /** Total encoded + raw-RTP callback subscriber count (sum of `encoded[].length`). */
6656
+ encodedSubscribers: number()
6657
+ });
6658
+ _enum([
6659
+ "reconnecting",
6660
+ "sleeping",
6661
+ "offline",
6662
+ "disabled",
6663
+ "waking"
5973
6664
  ]);
5974
- const ProfileSlotSchema = object({
6665
+ const VideoCodecTargetSchema = _enum(["h264", "h265", "copy"]);
6666
+ const AudioCodecTargetSchema = _enum(["aac", "opus", "pcmu", "copy", "none"]);
6667
+ const GetStreamWithCodecInputSchema = object({
5975
6668
  deviceId: number().int().nonnegative(),
5976
- profile: CamProfileSchema,
5977
- /** Broker id the rest of the system addresses: `${deviceId}/${profile}`. */
5978
- brokerId: string(),
5979
- /** `null` when the profile is unassigned. */
5980
- sourceCamStreamId: string().nullable(),
5981
- status: ProfileSlotStatusSchema,
5982
- resolution: CamStreamResolutionSchema.optional(),
5983
- codec: string().optional(),
5984
- preBufferSec: number().nonnegative().optional(),
5985
- errorMessage: string().optional()
5986
- });
5987
- const StreamSourceEntrySchema$1 = object({
5988
- id: string(),
5989
- label: string(),
5990
- protocol: _enum(["rtsp", "rtmp", "annexb", "http-mjpeg", "webrtc", "custom"]),
5991
- url: string().optional(),
5992
- resolution: object({ width: number(), height: number() }).readonly().optional(),
5993
- fps: number().optional(),
5994
- bitrate: number().optional(),
5995
- codec: string().optional(),
5996
- profileHint: _enum(["high", "mid", "low"]).optional()
6669
+ /** Target codec. `'copy'` = passthrough (no re-encode). */
6670
+ video: VideoCodecTargetSchema,
6671
+ /** Target audio codec. `'copy'` = passthrough. Defaults to `'aac'`. */
6672
+ audio: AudioCodecTargetSchema.optional(),
6673
+ /** Target a profile's assigned source camStream. Required to resolve a source. */
6674
+ profile: CamProfileSchema.optional(),
6675
+ /** Optional output resolution target. When set: drives the transcode
6676
+ * output scale (downscale toward WxH), and — when `profile` is absent —
6677
+ * selects the published source closest to this resolution. Ignored for
6678
+ * `video:'copy'` scaling (a copy can't be rescaled), but still used for
6679
+ * source selection. */
6680
+ targetResolution: object({ width: number().int().positive(), height: number().int().positive() }).optional(),
6681
+ /** Extra ffmpeg output flags appended verbatim by the consumer (in code).
6682
+ * Folded into the pipeline key so different args never share a child. */
6683
+ outputArgs: array(string()).optional(),
6684
+ /** Opt-in: carry transcoded audio over an RTP sidecar grafted into the
6685
+ * restreamer SDP (only for re-encoded audio targets — aac/opus/pcmu).
6686
+ * Default/absent = the live-verified video-only egress. */
6687
+ audioEgress: boolean().optional(),
6688
+ tag: string().optional()
5997
6689
  });
5998
- object({
5999
- type: string(),
6690
+ const RtpSourceSchema = object({
6000
6691
  url: string(),
6001
- videoCodec: string().optional(),
6002
- audioCodec: string().optional(),
6003
- metadata: record(string(), unknown()).readonly().optional()
6004
- });
6005
- const EncodedPacketSchema = object({
6006
- type: _enum(["video", "audio"]),
6007
- data: _instanceof(Uint8Array),
6008
- pts: number(),
6009
- dts: number(),
6010
- keyframe: boolean(),
6011
- codec: string()
6012
- });
6013
- const DecodedFrameSchema = object({
6014
- data: _instanceof(Uint8Array),
6015
- width: number(),
6016
- height: number(),
6017
- format: _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]),
6018
- timestamp: number()
6019
- });
6020
- const FrameHandleSchema = object({
6021
- shmId: string(),
6022
- slot: number().int().nonnegative(),
6023
- seq: number().int().nonnegative(),
6024
- width: number().int().positive(),
6025
- height: number().int().positive(),
6026
- format: _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]),
6027
- pts: number(),
6028
- byteLength: number().int().nonnegative(),
6029
- nodeId: string(),
6030
- slotCount: number().int().positive()
6031
- });
6032
- const FrameHandleFormatSchema = _enum(["rgb", "bgr", "yuv420", "gray"]);
6033
- const SubscribeFramesInputSchema = object({
6034
- brokerId: string(),
6035
- format: FrameHandleFormatSchema,
6036
- /**
6037
- * Optional reader-side cadence hint in frames per second. The broker does
6038
- * NOT throttle — latest-wins ring reads drop frames implicitly for a slow
6039
- * consumer. The value is echoed back in `SubscribeFramesResult.maxFps` so
6040
- * the consumer can pace its own `pullFrameHandles` polling.
6041
- */
6042
- maxFps: number().positive().optional(),
6043
- /** Short caller-identity tag (`motion`, `detection`, …) for diagnostics. */
6044
- tag: string().optional()
6045
- });
6046
- const SubscribeFramesResultSchema = object({
6047
- /** Opaque id the consumer passes to `pullFrameHandles` / `unsubscribeFrames`. */
6048
- subscriptionId: string(),
6049
- /** Reader-side cadence hint (frames/s) — echoes `SubscribeFramesInput.maxFps`. */
6050
- maxFps: number().nonnegative()
6051
- });
6052
- const DecodedAudioChunkSchema = object({
6053
- data: _instanceof(Uint8Array),
6054
- sampleRate: number().int().positive(),
6055
- channels: number().int().positive(),
6056
- timestamp: number()
6057
- });
6058
- const SubscribeAudioChunksInputSchema = object({
6059
- brokerId: string(),
6060
- /** Short caller-identity tag (`audio-analyzer`, …) for `listClients`. */
6061
- tag: string().optional()
6062
- });
6063
- const SubscribeAudioChunksResultSchema = object({
6064
- /** Opaque id passed to `pullAudioChunks` / `unsubscribeAudioChunks`. */
6065
- subscriptionId: string()
6066
- });
6067
- const BrokerStatusSchema$1 = _enum(["idle", "connecting", "streaming", "error", "stopped"]);
6068
- const BrokerStatsSchema = object({
6069
- status: BrokerStatusSchema$1,
6070
- inputFps: number(),
6071
- decodeFps: number(),
6072
- encodedSubscribers: number(),
6073
- decodedSubscribers: number(),
6074
- uptimeMs: number(),
6075
- bitrateKbps: number(),
6076
- idrIntervalMs: number(),
6077
- codec: string().optional(),
6078
- totalBytes: number(),
6079
- packetCount: number(),
6080
- rtspClients: number(),
6081
- pipeClients: number(),
6082
- preBufferSec: number(),
6083
- preBufferMs: number(),
6084
- preBufferPackets: number(),
6085
- /**
6086
- * Moleculer node id of the decoder provider currently servicing this
6087
- * stream's decoded subscribers. `null` until the deferred decoder is
6088
- * created (no decoded clients yet, or codec not detected). Surfaces the
6089
- * runtime decoder placement so the UI can show "Decoder: <agent>" without
6090
- * a separate cap call per broker.
6091
- */
6092
- decoderNodeId: string().nullable(),
6093
- /**
6094
- * Detected audio track parameters from the RTSP DESCRIBE / SDP. `null`
6095
- * when the stream has no audio track or the broker is in cold start.
6096
- * `supported = false` means the codec was detected but the local
6097
- * decoder pipeline cannot produce PCM chunks (e.g. AAC without the
6098
- * AAC pipeline wired). Surfaced in the UI device-overview so operators
6099
- * can pre-pick the audio-analysis model that matches the codec.
6100
- */
6101
- audio: object({
6102
- codec: string(),
6103
- sampleRate: number(),
6104
- channels: number(),
6105
- supported: boolean()
6106
- }).nullable().optional()
6107
- });
6108
- const StreamFormatSchema = _enum(["webrtc", "hls", "mjpeg", "rtsp"]);
6109
- const StreamInfoSchema = object({
6110
- streamId: string(),
6111
- format: StreamFormatSchema,
6112
- url: string().nullable(),
6113
- active: boolean()
6114
- });
6115
- ({
6116
- methods: {
6117
- registerStream: method(
6118
- object({ streamId: string(), sourceUrl: string(), codec: string().optional() }),
6119
- _void(),
6120
- { kind: "mutation" }
6121
- ),
6122
- unregisterStream: method(object({ streamId: string() }), _void(), { kind: "mutation" }),
6123
- getStreamUrl: method(
6124
- object({ streamId: string(), format: StreamFormatSchema }),
6125
- string().nullable()
6126
- ),
6127
- listStreams: method(_void(), array(StreamInfoSchema))
6128
- }
6129
- });
6130
- const RtspRestreamEntrySchema = object({
6131
- brokerId: string(),
6132
- url: string(),
6133
- mutedUrl: string(),
6134
- enabled: boolean()
6135
- });
6136
- const BrokerRtspClientSchema = object({
6137
- sessionId: string(),
6138
- remoteAddr: string(),
6139
- playing: boolean(),
6140
- muted: boolean(),
6141
- connectedAt: number(),
6142
- lastRtpAt: number(),
6143
- bytesSent: number()
6144
- });
6145
- const BrokerDecodedClientSchema = object({
6146
- tag: string(),
6147
- subscribedAt: number(),
6148
- maxFps: number(),
6149
- framesDelivered: number(),
6150
- framesDropped: number()
6151
- });
6152
- const BrokerAudioClientSchema = object({
6153
- tag: string(),
6154
- subscribedAt: number(),
6155
- chunksDelivered: number()
6156
- });
6157
- const BrokerClientsSchema = object({
6158
- rtsp: array(BrokerRtspClientSchema).readonly(),
6159
- decoded: array(BrokerDecodedClientSchema).readonly(),
6160
- audio: array(BrokerAudioClientSchema).readonly(),
6161
- pipeClients: number(),
6162
- encodedSubscribers: number()
6163
- });
6164
- _enum([
6165
- "reconnecting",
6166
- "sleeping",
6167
- "offline",
6168
- "disabled",
6169
- "waking"
6170
- ]);
6171
- const VideoCodecTargetSchema = _enum(["H264", "H265", "auto"]);
6172
- const AudioCodecTargetSchema = _enum(["AAC", "Opus", "PCMU", "none"]);
6173
- const MaxResolutionSchema = object({
6174
- width: number().int().positive(),
6175
- height: number().int().positive()
6176
- });
6177
- const GetStreamWithCodecInputSchema = object({
6178
- deviceId: number().int().nonnegative(),
6179
- videoCodec: VideoCodecTargetSchema,
6180
- audioCodec: AudioCodecTargetSchema.optional(),
6181
- maxResolution: MaxResolutionSchema.optional(),
6182
- tag: string().optional()
6183
- });
6184
- const RtpSourceSchema = object({
6185
- url: string(),
6186
- videoCodec: _enum(["H264", "H265"]),
6187
- audioCodec: string(),
6188
- resolution: MaxResolutionSchema,
6189
- transcoded: boolean(),
6190
- encoder: string(),
6191
- pipelineKey: string()
6692
+ videoCodec: _enum(["H264", "H265"]),
6693
+ audioCodec: string(),
6694
+ resolution: object({
6695
+ width: number().int().positive(),
6696
+ height: number().int().positive()
6697
+ }),
6698
+ transcoded: boolean(),
6699
+ encoder: string(),
6700
+ pipelineKey: string()
6192
6701
  });
6193
6702
  ({
6194
6703
  methods: {
@@ -6204,7 +6713,17 @@ const RtpSourceSchema = object({
6204
6713
  label: string().optional(),
6205
6714
  deviceFeatures: array(string()).optional(),
6206
6715
  autoEligible: boolean().optional(),
6207
- metadata: record(string(), unknown()).optional()
6716
+ metadata: record(string(), unknown()).optional(),
6717
+ /** Required when kind === 'derived' — the source camStreamId the
6718
+ * derived broker reads its encoded plane from.
6719
+ * The runtime guard (broker manager, Task 10) enforces presence for
6720
+ * kind === 'derived'; the schema stays permissive so ts-morph emits
6721
+ * full field types instead of collapsing to ZodEffects. */
6722
+ sourceCamStreamId: string().min(1).optional(),
6723
+ /** Required when kind === 'derived' — the ffmpeg params for the
6724
+ * spawn. Reuses the same brokerId for identical params (single-flight).
6725
+ * Runtime guard in broker manager; schema is permissive (see above). */
6726
+ encodeProfile: EncodeProfileSchema.optional()
6208
6727
  }),
6209
6728
  object({ success: literal(true) }),
6210
6729
  { kind: "mutation", auth: "admin" }
@@ -6267,12 +6786,21 @@ const RtpSourceSchema = object({
6267
6786
  object({ success: boolean() }),
6268
6787
  { kind: "mutation", auth: "admin" }
6269
6788
  ),
6789
+ /**
6790
+ * LEGACY / observability. Returns a raw per-broker restream URL for a
6791
+ * single stream. Prefer {@link getStreamWithCodec} for programmatic
6792
+ * CONSUMPTION — it is the single demand-counted entry that rides the
6793
+ * broker's one source dial and returns a codec-correct passthrough.
6794
+ */
6270
6795
  getStreamUrl: method(
6271
6796
  object({ streamId: string(), format: StreamFormatSchema }),
6272
6797
  object({ url: string() })
6273
6798
  ),
6274
6799
  /**
6275
- * Shared codec-targeted stream API — see Task #184.
6800
+ * Shared codec-targeted stream API — see Task #184. THE single public
6801
+ * stream-acquisition surface for programmatic consumers: single-dial
6802
+ * (rides the broker's one source pull) and passthrough-correct
6803
+ * (`videoCodec:'auto'` → `-c copy`, never a needless re-encode).
6276
6804
  * Resolution order: source-select → HW transcode → libx264/libx265.
6277
6805
  */
6278
6806
  getStreamWithCodec: method(
@@ -6366,10 +6894,20 @@ const RtpSourceSchema = object({
6366
6894
  object({ configuredSec: number(), bufferedMs: number(), packetCount: number() })
6367
6895
  ),
6368
6896
  getRtspPort: method(_void(), number()),
6897
+ /**
6898
+ * Enumeration surface — backs the admin RTSP-export picker and
6899
+ * `pickPreferredRtspEntry`. Lists every per-broker restream entry. NOT a
6900
+ * consumption API: programmatic readers use {@link getStreamWithCodec}.
6901
+ */
6369
6902
  getAllRtspEntries: method(
6370
6903
  object({ hostname: string().optional() }),
6371
6904
  array(RtspRestreamEntrySchema).readonly()
6372
6905
  ),
6906
+ /**
6907
+ * LEGACY / observability. Resolves one broker's restream entry. Prefer
6908
+ * {@link getStreamWithCodec} for programmatic CONSUMPTION so the read is
6909
+ * demand-counted and rides the single source dial.
6910
+ */
6373
6911
  getRtspEntry: method(
6374
6912
  object({ brokerId: string(), hostname: string().optional() }),
6375
6913
  RtspRestreamEntrySchema.nullable()
@@ -6406,203 +6944,1099 @@ const RtpSourceSchema = object({
6406
6944
  }))
6407
6945
  }
6408
6946
  });
6947
+ const STREAM_CODEC_VALUES = ["h264", "h265", "hevc", "av1", "mjpeg", "vp8", "vp9"];
6948
+ const StreamCodecSchema = _enum(STREAM_CODEC_VALUES);
6949
+ const PickStreamRequirementsSchema = object({
6950
+ /**
6951
+ * Codecs the consumer can decode without an intermediate transcode.
6952
+ * Match is case-insensitive against the camera stream's `codec`.
6953
+ * Default (omitted / empty array): no codec filter — useful when the
6954
+ * consumer just wants "the highest-quality stream of any codec".
6955
+ */
6956
+ acceptCodecs: array(StreamCodecSchema).readonly().optional(),
6957
+ /** Minimum vertical resolution. Streams shorter than this are dropped. */
6958
+ minHeight: number().int().positive().optional(),
6959
+ /** Minimum horizontal resolution. */
6960
+ minWidth: number().int().positive().optional(),
6961
+ /**
6962
+ * When true (default), `derived:*` streams (transcoded broker pipes)
6963
+ * are excluded — the caller wants a SOURCE stream the broker can
6964
+ * forward without a re-encode. Set false to allow derived in the
6965
+ * picker (useful for a "best playable" fallback).
6966
+ */
6967
+ excludeDerived: boolean().optional(),
6968
+ /**
6969
+ * Bypass gate. When set, the picker returns a hit ONLY IF the device
6970
+ * also exposes a non-derived stream in one of these (rejected) codecs.
6971
+ * Mirrors the Alexa "bypass only when the natural path WOULD have
6972
+ * transcoded" guard: if the device is already serving the consumer's
6973
+ * codec end-to-end, there's nothing to optimise.
6974
+ */
6975
+ requireSiblingCodec: array(StreamCodecSchema).readonly().optional()
6976
+ }).readonly();
6977
+ const PickStreamPreferencesSchema = object({
6978
+ /**
6979
+ * Ordered list of provider prefixes (e.g. `['native:', 'rtsp:']`) the
6980
+ * picker prefers — earlier entries win ties. Defaults to
6981
+ * `['native:']`, mirroring Alexa's "prefer the native dial path".
6982
+ */
6983
+ preferredProviders: array(string()).readonly().optional(),
6984
+ /**
6985
+ * Resolution preference within the surviving candidates. `'highest'`
6986
+ * picks the tallest stream; `'lowest'` picks the shortest (used by
6987
+ * memory-constrained consumers / Apple Home guest sessions).
6988
+ */
6989
+ resolutionPreference: _enum(["highest", "lowest"]).optional()
6990
+ }).readonly();
6991
+ const PickedCamStreamSchema = object({
6992
+ camStreamId: string(),
6993
+ codec: string().optional(),
6994
+ resolution: CamStreamResolutionSchema.optional(),
6995
+ /** One-line explanation of why this stream won — for logs / debug UI. */
6996
+ reason: string()
6997
+ });
6998
+ ({
6999
+ deviceTypes: [DeviceType.Camera],
7000
+ methods: {
7001
+ getCameraStreams: method(
7002
+ object({ deviceId: number().int().nonnegative() }),
7003
+ array(CameraStreamSchema).readonly()
7004
+ ),
7005
+ getBrokerStreams: method(
7006
+ object({ deviceId: number().int().nonnegative() }),
7007
+ array(ProfileSlotSchema).readonly()
7008
+ ),
7009
+ /**
7010
+ * Per-device RAW RTSP restream entries — one per published camStream
7011
+ * that has RTSP restream enabled (`native:main`, `rtsp:sub`, …).
7012
+ *
7013
+ * LIVE-VIEW ONLY. This is the surface the device-details stream
7014
+ * picker uses so an operator can hit each physical stream directly.
7015
+ * Programmatic / external consumers (HAP, Alexa, ha-mqtt, recording)
7016
+ * MUST use `getProfileRtspEntries` instead — picking from raw
7017
+ * variants makes two consumers of the same camera land on two
7018
+ * different physical pulls (e.g. Reolink `native:main` vs
7019
+ * `rtsp:main`) and trips the camera's concurrent-session limit.
7020
+ */
7021
+ getRtspEntries: method(
7022
+ object({
7023
+ deviceId: number().int().nonnegative(),
7024
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
7025
+ hostname: string().optional()
7026
+ }),
7027
+ array(RtspRestreamEntrySchema).readonly()
7028
+ ),
7029
+ /**
7030
+ * Per-device PROFILE RTSP restream entries — one per ASSIGNED
7031
+ * profile slot (high/mid/low). Each entry's `url` is a profile-keyed
7032
+ * broker restream that aliases the profile's assigned source broker,
7033
+ * so HAP / Alexa / recording / WebRTC all converge on the broker's
7034
+ * single on-demand pull for that profile. This is the supported
7035
+ * exporter-facing surface; the raw `getRtspEntries` is live-view
7036
+ * only. Returns `[]` for a device with no assigned profiles
7037
+ * (cold-start before first publish).
7038
+ */
7039
+ getProfileRtspEntries: method(
7040
+ object({
7041
+ deviceId: number().int().nonnegative(),
7042
+ /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
7043
+ hostname: string().optional()
7044
+ }),
7045
+ array(ProfileRtspEntrySchema).readonly()
7046
+ ),
7047
+ /**
7048
+ * "Best source stream for these decode constraints". Returns the
7049
+ * camStreamId the caller should dial (or null when no stream
7050
+ * matches). See `PickStreamRequirementsSchema` for the filter shape
7051
+ * and `PickStreamPreferencesSchema` for the ranking inputs.
7052
+ *
7053
+ * Returning null instructs the caller to fall back to its existing
7054
+ * path (derived-broker transcode, profile-slot pick, etc.) — the
7055
+ * picker NEVER ranks `derived:*` candidates as a "match" because
7056
+ * its whole job is to avoid the transcode.
7057
+ */
7058
+ pickStream: method(
7059
+ object({
7060
+ deviceId: number().int().nonnegative(),
7061
+ requirements: PickStreamRequirementsSchema,
7062
+ preferences: PickStreamPreferencesSchema.optional()
7063
+ }),
7064
+ PickedCamStreamSchema.nullable()
7065
+ )
7066
+ },
7067
+ events: {
7068
+ /** Fires on publishCameraStream / retractCameraStream. */
7069
+ onCamStreamsChanged: event(object({
7070
+ deviceId: number().int().nonnegative(),
7071
+ camStreams: array(CameraStreamSchema).readonly()
7072
+ })),
7073
+ /** Fires on assignProfile / unassignProfile / runtime status change. */
7074
+ onProfileSlotsChanged: event(object({
7075
+ deviceId: number().int().nonnegative(),
7076
+ profileSlots: array(ProfileSlotSchema).readonly()
7077
+ }))
7078
+ },
7079
+ /**
7080
+ * Per-device live stream-broker state. Persistent settings (RTSP
7081
+ * tokens, profile assignments, pre-buffer config, RTSP-enabled toggles,
7082
+ * streamingDebug) stay in the broker's addon store — they survive
7083
+ * restarts. The slice below carries ONLY what's truly runtime:
7084
+ *
7085
+ * - `online` — at least one profile slot is currently `'streaming'`.
7086
+ * Drivers without a firmware liveness signal (RTSP, ONVIF…) can
7087
+ * subscribe and mirror this into `state.deviceStatus.online`.
7088
+ * - `slotStatuses` — current `ProfileSlotStatus` per profile,
7089
+ * mirroring the runtime-mutable subset of `ProfileSlot`.
7090
+ * - `slotErrors` — last error message per profile (only set when the
7091
+ * corresponding slot is in `'error'`).
7092
+ * - `lastChangedAt` — freshness signal for consumers that want to
7093
+ * reason about how stale the slice is.
7094
+ *
7095
+ * Written by the stream-broker manager on every transition that
7096
+ * affects these aggregates. Read via `device.state.cameraStreams.<field>`
7097
+ * (BaseDevice proxy) or, cross-process, via
7098
+ * `device-state.getCapSlice({deviceId, capName: 'camera-streams'})`.
7099
+ * The cap's `onChanged` event fires automatically on each write so
7100
+ * subscribers get push semantics for free.
7101
+ */
7102
+ runtimeState: object({
7103
+ online: boolean(),
7104
+ slotStatuses: object({
7105
+ high: ProfileSlotStatusSchema.optional(),
7106
+ mid: ProfileSlotStatusSchema.optional(),
7107
+ low: ProfileSlotStatusSchema.optional()
7108
+ }),
7109
+ slotErrors: object({
7110
+ high: string().optional(),
7111
+ mid: string().optional(),
7112
+ low: string().optional()
7113
+ }),
7114
+ lastChangedAt: number()
7115
+ })
7116
+ });
7117
+ object({
7118
+ detected: boolean(),
7119
+ /** Ms epoch of the last transition. 0 if never observed. */
7120
+ lastChangedAt: number()
7121
+ });
7122
+ ({
7123
+ deviceTypes: [DeviceType.Sensor]
7124
+ });
7125
+ const HvacModeSchema = _enum([
7126
+ "off",
7127
+ "heat",
7128
+ "cool",
7129
+ "auto",
7130
+ "heat_cool",
7131
+ "fan_only",
7132
+ "dry"
7133
+ ]);
7134
+ object({
7135
+ /** Active HVAC mode. */
7136
+ mode: HvacModeSchema,
7137
+ /** Available HVAC modes the device accepts. Subset of HvacMode. */
7138
+ availableModes: array(HvacModeSchema),
7139
+ /** Active fan mode (`auto` / `low` / `medium` / `high` / `on` / `off`
7140
+ * / vendor-specific) — empty string when the device has no fan
7141
+ * surface. */
7142
+ fanMode: string(),
7143
+ /** Available fan modes the device accepts. */
7144
+ availableFanModes: array(string()),
7145
+ /** Active preset (`eco` / `away` / `sleep` / vendor-specific) —
7146
+ * empty string when no preset is active or the device has no
7147
+ * preset surface. */
7148
+ preset: string(),
7149
+ /** Available presets the device accepts. */
7150
+ availablePresets: array(string()),
7151
+ /** Single setpoint in Celsius. Used by single-target modes
7152
+ * (heat / cool / dry). Null when the device is in a range mode
7153
+ * (`heat_cool`) or has no setpoint at all (`fan_only`/`off`). */
7154
+ target: number().nullable(),
7155
+ /** Upper bound of the dual setpoint in Celsius. Populated only
7156
+ * in `heat_cool` mode. */
7157
+ targetHigh: number().nullable(),
7158
+ /** Lower bound of the dual setpoint in Celsius. Populated only
7159
+ * in `heat_cool` mode. */
7160
+ targetLow: number().nullable(),
7161
+ /** Target relative humidity (0..100). Null when the device has no
7162
+ * humidity control. */
7163
+ targetHumidity: number().min(0).max(100).nullable(),
7164
+ /** Current measured temperature in Celsius. Read-only — pushed by
7165
+ * the device's internal sensor. */
7166
+ currentTemp: number().nullable(),
7167
+ /** Current measured relative humidity (0..100). Read-only. */
7168
+ currentHumidity: number().min(0).max(100).nullable(),
7169
+ /** Ms epoch when the slice was last updated (push or command). */
7170
+ lastFetchedAt: number()
7171
+ });
7172
+ ({
7173
+ deviceTypes: [DeviceType.Thermostat],
7174
+ methods: {
7175
+ setMode: method(
7176
+ object({
7177
+ deviceId: number().int().nonnegative(),
7178
+ mode: HvacModeSchema
7179
+ }),
7180
+ _void(),
7181
+ { kind: "mutation", auth: "admin" }
7182
+ ),
7183
+ setFanMode: method(
7184
+ object({
7185
+ deviceId: number().int().nonnegative(),
7186
+ fanMode: string().min(1)
7187
+ }),
7188
+ _void(),
7189
+ { kind: "mutation", auth: "admin" }
7190
+ ),
7191
+ setPreset: method(
7192
+ object({
7193
+ deviceId: number().int().nonnegative(),
7194
+ preset: string().min(1)
7195
+ }),
7196
+ _void(),
7197
+ { kind: "mutation", auth: "admin" }
7198
+ ),
7199
+ setTarget: method(
7200
+ object({
7201
+ deviceId: number().int().nonnegative(),
7202
+ target: number()
7203
+ }),
7204
+ _void(),
7205
+ { kind: "mutation", auth: "admin" }
7206
+ ),
7207
+ setTargetRange: method(
7208
+ object({
7209
+ deviceId: number().int().nonnegative(),
7210
+ targetLow: number(),
7211
+ targetHigh: number()
7212
+ }),
7213
+ _void(),
7214
+ { kind: "mutation", auth: "admin" }
7215
+ ),
7216
+ setTargetHumidity: method(
7217
+ object({
7218
+ deviceId: number().int().nonnegative(),
7219
+ targetHumidity: number().min(0).max(100)
7220
+ }),
7221
+ _void(),
7222
+ { kind: "mutation", auth: "admin" }
7223
+ )
7224
+ }
7225
+ });
7226
+ const RgbTripletSchema = object({
7227
+ r: number().int().min(0).max(255),
7228
+ g: number().int().min(0).max(255),
7229
+ b: number().int().min(0).max(255)
7230
+ });
7231
+ const HsvTripletSchema = object({
7232
+ /** Hue in degrees, 0..360. */
7233
+ h: number().min(0).max(360),
7234
+ /** Saturation as 0..100 inclusive. */
7235
+ s: number().min(0).max(100),
7236
+ /** Value/brightness as 0..100 inclusive. */
7237
+ v: number().min(0).max(100)
7238
+ });
7239
+ const ColorInputSchema = discriminatedUnion("mode", [
7240
+ object({ mode: literal("rgb"), rgb: RgbTripletSchema }),
7241
+ object({ mode: literal("hsv"), hsv: HsvTripletSchema }),
7242
+ object({ mode: literal("mired"), mireds: number().int().min(50).max(1e3) })
7243
+ ]);
7244
+ object({
7245
+ /** Active color mode — which of `rgb` / `hsv` / `mireds` reflects
7246
+ * the bulb's current state. */
7247
+ mode: _enum(["rgb", "hsv", "mired"]),
7248
+ /** Populated when `mode === 'rgb'`. */
7249
+ rgb: RgbTripletSchema.optional(),
7250
+ /** Populated when `mode === 'hsv'`. */
7251
+ hsv: HsvTripletSchema.optional(),
7252
+ /** Populated when `mode === 'mired'`. */
7253
+ mireds: number().int().optional(),
7254
+ /** Ms epoch of the last operator-driven change. */
7255
+ lastChangedAt: number()
7256
+ });
7257
+ ({
7258
+ deviceTypes: [DeviceType.Light],
7259
+ methods: {
7260
+ setColor: method(
7261
+ object({
7262
+ deviceId: number().int().nonnegative(),
7263
+ color: ColorInputSchema
7264
+ }),
7265
+ _void(),
7266
+ { kind: "mutation", auth: "admin" }
7267
+ )
7268
+ },
7269
+ events: {
7270
+ /**
7271
+ * Emitted whenever the color changes — operator action OR firmware
7272
+ * push. Subscribers (UI color pickers, automation engines) react
7273
+ * without polling.
7274
+ */
7275
+ onColorChanged: { data: object({
7276
+ deviceId: number(),
7277
+ mode: _enum(["rgb", "hsv", "mired"]),
7278
+ rgb: RgbTripletSchema.optional(),
7279
+ hsv: HsvTripletSchema.optional(),
7280
+ mireds: number().int().optional(),
7281
+ lastChangedAt: number()
7282
+ }) }
7283
+ }
7284
+ });
7285
+ object({
7286
+ /** True when the upstream system considers the entity connected. */
7287
+ connected: boolean(),
7288
+ /** Ms epoch of the last transition. 0 if never observed. */
7289
+ lastChangedAt: number()
7290
+ });
7291
+ ({
7292
+ deviceTypes: [DeviceType.Sensor]
7293
+ });
7294
+ const ConsumableItemSchema = object({
7295
+ /** Stable id, e.g. 'main-brush'. */
7296
+ key: string().min(1),
7297
+ /** Display name. */
7298
+ label: string().min(1),
7299
+ /** Remaining life % when known (0..100). */
7300
+ level: number().min(0).max(100).nullable(),
7301
+ /** Discrete state when known (binary mode). */
7302
+ status: _enum(["ok", "replace"]).nullable(),
7303
+ /** Ms epoch of the last replace, when known. */
7304
+ lastResetAt: number().nullable(),
7305
+ /** Whether `reset()` is meaningful for this item. */
7306
+ resettable: boolean()
7307
+ });
7308
+ const ConsumablesStatusSchema = object({
7309
+ items: array(ConsumableItemSchema),
7310
+ lastChangedAt: number()
7311
+ });
7312
+ ({
7313
+ // Device-agnostic — any device may expose consumables, so the cap
7314
+ // binds on EVERY DeviceType. The list enumerates every enum member
7315
+ // (kept in sync with `device-type.ts`) rather than a subset, so a
7316
+ // provider on any device type — vacuums/mowers included — can register
7317
+ // it and no future device type silently fails to bind.
7318
+ deviceTypes: [
7319
+ DeviceType.Camera,
7320
+ DeviceType.Hub,
7321
+ DeviceType.Light,
7322
+ DeviceType.Siren,
7323
+ DeviceType.Switch,
7324
+ DeviceType.Sensor,
7325
+ DeviceType.Thermostat,
7326
+ DeviceType.Button,
7327
+ DeviceType.EventEmitter,
7328
+ DeviceType.Update,
7329
+ DeviceType.Generic,
7330
+ DeviceType.Notifier,
7331
+ DeviceType.Script,
7332
+ DeviceType.Automation,
7333
+ DeviceType.Lock,
7334
+ DeviceType.Cover,
7335
+ DeviceType.Valve,
7336
+ DeviceType.Humidifier,
7337
+ DeviceType.WaterHeater,
7338
+ DeviceType.Fan,
7339
+ DeviceType.MediaPlayer,
7340
+ DeviceType.AlarmPanel,
7341
+ DeviceType.Control,
7342
+ DeviceType.Presence,
7343
+ DeviceType.Weather,
7344
+ DeviceType.Vacuum,
7345
+ DeviceType.LawnMower,
7346
+ DeviceType.Container,
7347
+ DeviceType.Image
7348
+ ],
7349
+ methods: {
7350
+ /** Mark a consumable as replaced — resets its remaining life. Only
7351
+ * meaningful when the item's `resettable` is true. */
7352
+ reset: method(
7353
+ object({ deviceId: number().int().nonnegative(), key: string().min(1) }),
7354
+ _void(),
7355
+ { kind: "mutation", auth: "admin" }
7356
+ )
7357
+ },
7358
+ // Per the cap checklist, runtimeState carries the bridge helper's
7359
+ // freshness field. The status surface stays the plain schema; only the
7360
+ // persistent slice gains `lastFetchedAt` so future poll-backed
7361
+ // providers can use the runtime-state bridge unchanged.
7362
+ runtimeState: ConsumablesStatusSchema.extend({ lastFetchedAt: number() })
7363
+ });
7364
+ object({
7365
+ /** True when the entry is open; false when closed. */
7366
+ entryOpen: boolean(),
7367
+ /** Ms epoch of the last open↔closed transition. 0 if never observed. */
7368
+ lastChangedAt: number()
7369
+ });
7370
+ ({
7371
+ deviceTypes: [DeviceType.Sensor]
7372
+ });
7373
+ const ControlKindSchema = _enum(["numeric", "select", "text", "datetime"]);
7374
+ object({
7375
+ kind: ControlKindSchema,
7376
+ /** Current value. Numeric kind → number; select/text/datetime → string.
7377
+ * Datetime values are ISO 8601 strings (full timestamp, date-only,
7378
+ * or time-only — HA accepts all three forms). */
7379
+ value: union([number(), string()]),
7380
+ /** Acceptable values when `kind === 'select'`. Empty for other kinds. */
7381
+ options: array(string()),
7382
+ /** Ms epoch when the slice was last updated. */
7383
+ lastChangedAt: number(),
7384
+ /** Display unit-of-measurement (e.g. '°C', '%', 'lx').
7385
+ * Populated live from HA `attributes.unit_of_measurement` on each push.
7386
+ * Meaningful for `kind === 'numeric'`; absent for other kinds. */
7387
+ unit: string().optional(),
7388
+ /** Minimum allowed value. Populated live from HA `attributes.min`.
7389
+ * Meaningful for `kind === 'numeric'`; absent for other kinds. */
7390
+ min: number().optional(),
7391
+ /** Maximum allowed value. Populated live from HA `attributes.max`.
7392
+ * Meaningful for `kind === 'numeric'`; absent for other kinds. */
7393
+ max: number().optional(),
7394
+ /** Step increment. Populated live from HA `attributes.step`.
7395
+ * Meaningful for `kind === 'numeric'`; absent for other kinds. */
7396
+ step: number().optional(),
7397
+ /** Suggested decimal places for numeric display. Populated live from HA
7398
+ * `attributes.suggested_display_precision`, or derived from `step` when the
7399
+ * HA attribute is absent. Meaningful for `kind === 'numeric'`; absent for
7400
+ * other kinds. Falls back to auto-formatting when absent. */
7401
+ precision: number().int().min(0).max(10).optional(),
7402
+ /** Date-picker granularity for `kind === 'datetime'`. 'date' → date-only,
7403
+ * 'time' → time-only, 'datetime' → full timestamp. Absent for other kinds
7404
+ * (UI defaults to 'datetime'). Derived live from the device's HA domain
7405
+ * (date.* / time.* / datetime.*) or, for input_datetime, from the entity's
7406
+ * `has_date`/`has_time` attributes on each push. */
7407
+ format: _enum(["date", "time", "datetime"]).optional()
7408
+ });
7409
+ const ControlSetValueInputSchema = discriminatedUnion("kind", [
7410
+ object({ kind: literal("numeric"), value: number() }),
7411
+ object({ kind: literal("select"), value: string().min(1) }),
7412
+ object({ kind: literal("text"), value: string() }),
7413
+ object({ kind: literal("datetime"), value: string().min(1) })
7414
+ ]);
7415
+ ({
7416
+ deviceTypes: [DeviceType.Control],
7417
+ methods: {
7418
+ setValue: method(
7419
+ object({
7420
+ deviceId: number().int().nonnegative(),
7421
+ control: ControlSetValueInputSchema
7422
+ }),
7423
+ _void(),
7424
+ { kind: "mutation", auth: "admin" }
7425
+ )
7426
+ }
7427
+ });
7428
+ const CoverStateSchema = _enum([
7429
+ "open",
7430
+ "opening",
7431
+ "closing",
7432
+ "closed",
7433
+ "stopped"
7434
+ ]);
7435
+ object({
7436
+ /** Lifecycle state of the cover. */
7437
+ state: CoverStateSchema,
7438
+ /** 0 = fully closed, 100 = fully open. Null when the device has
7439
+ * no intermediate position surface. */
7440
+ position: number().min(0).max(100).nullable(),
7441
+ /** 0 = closed slats, 100 = open slats. Null when no tilt support. */
7442
+ tiltPosition: number().min(0).max(100).nullable(),
7443
+ /** Ms epoch when the slice was last updated. */
7444
+ lastChangedAt: number()
7445
+ });
7446
+ ({
7447
+ deviceTypes: [DeviceType.Cover],
7448
+ methods: {
7449
+ open: method(
7450
+ object({ deviceId: number().int().nonnegative() }),
7451
+ _void(),
7452
+ { kind: "mutation", auth: "admin" }
7453
+ ),
7454
+ close: method(
7455
+ object({ deviceId: number().int().nonnegative() }),
7456
+ _void(),
7457
+ { kind: "mutation", auth: "admin" }
7458
+ ),
7459
+ stop: method(
7460
+ object({ deviceId: number().int().nonnegative() }),
7461
+ _void(),
7462
+ { kind: "mutation", auth: "admin" }
7463
+ ),
7464
+ setPosition: method(
7465
+ object({
7466
+ deviceId: number().int().nonnegative(),
7467
+ position: number().min(0).max(100)
7468
+ }),
7469
+ _void(),
7470
+ { kind: "mutation", auth: "admin" }
7471
+ ),
7472
+ setTiltPosition: method(
7473
+ object({
7474
+ deviceId: number().int().nonnegative(),
7475
+ tiltPosition: number().min(0).max(100)
7476
+ }),
7477
+ _void(),
7478
+ { kind: "mutation", auth: "admin" }
7479
+ )
7480
+ }
7481
+ });
7482
+ const SourceInfoSchema = object({
7483
+ // ── Identity (required) ─────────────────────────────────────────
7484
+ /** Live dispatch key — mutable when the source system allows rename. */
7485
+ id: string(),
7486
+ /** Source system tag — e.g. 'homeassistant' | 'reolink' | 'frigate'. */
7487
+ system: string(),
7488
+ // ── Stability (optional) ────────────────────────────────────────
7489
+ /** Immutable upstream identifier when available (HA `unique_id`, … ).
7490
+ * Used to detect rename when `id` changes. */
7491
+ uniqueId: string().optional(),
7492
+ // ── Free-form passthrough ───────────────────────────────────────
7493
+ /** Provider-specific extras that don't fit the structured slots.
7494
+ * Whitelist what goes here per provider — do NOT dump entire HA
7495
+ * attribute blobs (would explode the meta JSON column). */
7496
+ raw: record(string(), unknown()).optional()
7497
+ });
7498
+ const DiscoveredChildStatusSchema = _enum(["online", "sleeping", "offline", "unknown"]);
7499
+ const DiscoveredChildDeviceSchema = lazy(
7500
+ () => object({
7501
+ /** Stable, integration-defined identifier. Must be unique per parent and persistent across reboots. */
7502
+ childNativeId: string(),
7503
+ /** Friendly name as reported by the source (Reolink camera name, ONVIF profile, ...). */
7504
+ name: string(),
7505
+ /** DeviceType the child should be created with on adopt. */
7506
+ type: _enum(DeviceType),
7507
+ status: DiscoveredChildStatusSchema,
7508
+ /** Free-form integration-specific metadata surfaced in the panel. */
7509
+ metadata: object({
7510
+ model: string().optional(),
7511
+ serialNumber: string().optional(),
7512
+ uid: string().optional(),
7513
+ /** Reolink: 0-based channel index inside the parent NVR/Hub. */
7514
+ rtspChannel: number().int().nonnegative().optional(),
7515
+ isBattery: boolean().optional(),
7516
+ isDoorbell: boolean().optional(),
7517
+ isMultifocal: boolean().optional(),
7518
+ externalLocation: string().optional(),
7519
+ /** Vendor / manufacturer name as reported by the source (HA device
7520
+ * `manufacturer`, …). Surfaced in the adoption modal's fuzzy search. */
7521
+ manufacturer: string().optional(),
7522
+ /** Integration / platform domain the candidate belongs to (HA
7523
+ * `entity_registry.platform`, e.g. `dreame_vacuum`). Surfaced in the
7524
+ * adoption modal's fuzzy search. */
7525
+ integration: string().optional()
7526
+ }).default({}),
7527
+ /**
7528
+ * `true` when the framework already created a child device for this
7529
+ * `childNativeId` under the current parent. The panel uses it to
7530
+ * gate the Add/Remove button and surface the existing `deviceId`.
7531
+ */
7532
+ alreadyAdopted: boolean(),
7533
+ /** When `alreadyAdopted=true`, the framework-assigned child device id. */
7534
+ adoptedDeviceId: number().int().nonnegative().nullable(),
7535
+ /** Capability names the candidate advertises — integrations populate, Hubs omit. */
7536
+ capabilities: array(string()).readonly().optional(),
7537
+ /** Feature flags the candidate advertises — integrations populate, Hubs omit. */
7538
+ features: array(string()).readonly().optional(),
7539
+ /** Upstream-system identity + rendering hints for the candidate. */
7540
+ sourceInfo: SourceInfoSchema.optional(),
7541
+ /** Nested entity-children for accordion preview — integrations populate, Hubs omit. */
7542
+ children: array(DiscoveredChildDeviceSchema).readonly().optional()
7543
+ })
7544
+ );
7545
+ const DeviceDiscoveryStatusSchema = object({
7546
+ discovered: array(DiscoveredChildDeviceSchema),
7547
+ /** Wall-clock ms of the last successful enumeration. */
7548
+ lastDiscoveryAt: number().int().nonnegative().nullable(),
7549
+ /** Last error surfaced from the source (rendered as a banner). */
7550
+ lastError: string().nullable()
7551
+ });
7552
+ ({
7553
+ // Hub is the canonical parent. Other integrations (gateway-style)
7554
+ // can register against the cap by also targeting their root type.
7555
+ deviceTypes: [DeviceType.Hub],
7556
+ // Mirror status into the per-device runtime-state slice so the panel
7557
+ // hydrates from the kernel cache without a round-trip on every open.
7558
+ runtimeState: DeviceDiscoveryStatusSchema.extend({
7559
+ lastFetchedAt: number().int().nonnegative()
7560
+ }),
7561
+ methods: {
7562
+ /**
7563
+ * Snapshot of the current `discovered` list. Returns the
7564
+ * runtime-state cache — call `refreshDiscovery` first if a
7565
+ * fresh round-trip to the source is required.
7566
+ */
7567
+ listDiscovered: method(
7568
+ object({ deviceId: number().int().nonnegative() }),
7569
+ array(DiscoveredChildDeviceSchema).readonly()
7570
+ ),
7571
+ /**
7572
+ * Force the integration to re-enumerate and update the
7573
+ * runtime-state slice. Returns the freshly-enumerated list (also
7574
+ * available via `listDiscovered` post-call).
7575
+ */
7576
+ refreshDiscovery: method(
7577
+ object({ deviceId: number().int().nonnegative() }),
7578
+ array(DiscoveredChildDeviceSchema).readonly(),
7579
+ { kind: "mutation", auth: "admin" }
7580
+ ),
7581
+ /**
7582
+ * Promote a discovered entry to a real child device. The framework
7583
+ * creates the child via `kernel.devices.create()` with
7584
+ * `parentDeviceId = parent.id` and seeds the child's config from
7585
+ * `childInitialConfig` (driver-defined; usually carries channel +
7586
+ * uid + parent reference). Returns the kernel-assigned numeric id.
7587
+ */
7588
+ adoptDevice: method(
7589
+ object({
7590
+ deviceId: number().int().nonnegative(),
7591
+ childNativeId: string(),
7592
+ /** Optional override for the child's display name. */
7593
+ name: string().optional()
7594
+ }),
7595
+ object({
7596
+ deviceId: number().int().nonnegative(),
7597
+ stableId: string()
7598
+ }),
7599
+ { kind: "mutation", auth: "admin" }
7600
+ ),
7601
+ /**
7602
+ * Inverse of `adoptDevice`: removes the child device from the
7603
+ * kernel registry. The discovered entry remains in the
7604
+ * enumeration (status updates resume) so the operator can re-adopt
7605
+ * it later without a fresh refresh.
7606
+ */
7607
+ releaseDevice: method(
7608
+ object({
7609
+ deviceId: number().int().nonnegative(),
7610
+ childDeviceId: number().int().nonnegative()
7611
+ }),
7612
+ _void(),
7613
+ { kind: "mutation", auth: "admin" }
7614
+ )
7615
+ }
7616
+ });
7617
+ object({
7618
+ /** Ms epoch of the last press. null = never observed since this provider started. */
7619
+ lastPressedAt: number().nullable(),
7620
+ /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
7621
+ pressCountSinceStart: number()
7622
+ });
7623
+ object({
7624
+ deviceId: number(),
7625
+ timestamp: number()
7626
+ });
7627
+ ({
7628
+ deviceTypes: [DeviceType.Button]
7629
+ });
7630
+ const EnumSensorDateTimeFormatSchema = _enum(["date", "time", "datetime"]);
7631
+ object({
7632
+ value: string(),
7633
+ /**
7634
+ * Set for `DateTimeSensor`-role sensors so the UI renders the ISO `value`
7635
+ * as a locale date/time/datetime; absent for plain enum sensors. HA
7636
+ * `device_class=timestamp` → `'datetime'`, `device_class=date` → `'date'`,
7637
+ * a time-only sensor → `'time'`.
7638
+ */
7639
+ format: EnumSensorDateTimeFormatSchema.optional(),
7640
+ /** Ms epoch when the slice was last updated. */
7641
+ lastFetchedAt: number()
7642
+ });
7643
+ ({
7644
+ deviceTypes: [DeviceType.Sensor]
7645
+ });
7646
+ const EventFireSchema = object({
7647
+ deviceId: number(),
7648
+ eventType: string(),
7649
+ data: record(string(), unknown()).nullable(),
7650
+ timestamp: number(),
7651
+ seq: number()
7652
+ });
7653
+ object({
7654
+ eventTypes: array(string()),
7655
+ lastEvent: EventFireSchema.nullable(),
7656
+ eventCountSinceStart: number()
7657
+ });
7658
+ ({
7659
+ deviceTypes: [DeviceType.EventEmitter]
7660
+ });
7661
+ const FanDirectionSchema = _enum(["forward", "reverse"]);
7662
+ object({
7663
+ /** Active speed as 0..100 inclusive. Null when the device has no
7664
+ * speed surface (single-speed fan). */
7665
+ percentage: number().min(0).max(100).nullable(),
7666
+ /** Speed granularity the device accepts (HA `percentage_step`, e.g. 25 for
7667
+ * a 4-speed fan). Absent when unknown — the UI then falls back to step 1. */
7668
+ percentageStep: number().positive().optional(),
7669
+ /** Active preset mode (`auto` / `quiet` / `turbo` / vendor-specific)
7670
+ * — empty string when no preset is active or supported. */
7671
+ preset: string(),
7672
+ /** Available preset modes the device accepts. */
7673
+ availablePresets: array(string()),
7674
+ /** Ceiling-fan blade direction. Null when the device has no
7675
+ * direction surface. */
7676
+ direction: FanDirectionSchema.nullable(),
7677
+ /** Oscillation toggle. Null when the device has no oscillation
7678
+ * surface. */
7679
+ oscillating: boolean().nullable(),
7680
+ /** Ms epoch when the slice was last updated. */
7681
+ lastChangedAt: number()
7682
+ });
7683
+ ({
7684
+ deviceTypes: [DeviceType.Fan],
7685
+ methods: {
7686
+ setPercentage: method(
7687
+ object({
7688
+ deviceId: number().int().nonnegative(),
7689
+ percentage: number().min(0).max(100)
7690
+ }),
7691
+ _void(),
7692
+ { kind: "mutation", auth: "admin" }
7693
+ ),
7694
+ setPreset: method(
7695
+ object({
7696
+ deviceId: number().int().nonnegative(),
7697
+ preset: string().min(1)
7698
+ }),
7699
+ _void(),
7700
+ { kind: "mutation", auth: "admin" }
7701
+ ),
7702
+ setDirection: method(
7703
+ object({
7704
+ deviceId: number().int().nonnegative(),
7705
+ direction: FanDirectionSchema
7706
+ }),
7707
+ _void(),
7708
+ { kind: "mutation", auth: "admin" }
7709
+ ),
7710
+ setOscillating: method(
7711
+ object({
7712
+ deviceId: number().int().nonnegative(),
7713
+ oscillating: boolean()
7714
+ }),
7715
+ _void(),
7716
+ { kind: "mutation", auth: "admin" }
7717
+ )
7718
+ }
7719
+ });
7720
+ object({
7721
+ /** True when leak is currently detected. */
7722
+ flooded: boolean(),
7723
+ /** Ms epoch of the last flooded↔dry transition. 0 if never observed. */
7724
+ lastChangedAt: number()
7725
+ });
7726
+ ({
7727
+ deviceTypes: [DeviceType.Sensor]
7728
+ });
7729
+ object({
7730
+ detected: boolean(),
7731
+ /** Ms epoch of the last transition. 0 if never observed. */
7732
+ lastChangedAt: number()
7733
+ });
7734
+ ({
7735
+ deviceTypes: [DeviceType.Sensor]
7736
+ });
7737
+ object({
7738
+ /** Whether the humidifier is currently on. */
7739
+ on: boolean(),
7740
+ /** Current measured relative humidity (0..100). Null when not reported. */
7741
+ currentHumidity: number().min(0).max(100).nullable(),
7742
+ /** Target relative humidity (0..100). Null when no setpoint surface. */
7743
+ targetHumidity: number().min(0).max(100).nullable(),
7744
+ /** Active mode (`auto` / `normal` / `baby` / vendor). Null when the
7745
+ * device has no mode surface. */
7746
+ mode: string().nullable(),
7747
+ /** Available modes the device accepts. */
7748
+ availableModes: array(string()),
7749
+ /** HA `action` attribute, verbatim (`humidifying` / `drying` /
7750
+ * `idle` / `off`). Null when the device doesn't report it. */
7751
+ action: string().nullable(),
7752
+ /** HA `min_humidity` attribute. Null → UI uses 0. */
7753
+ minHumidity: number().nullable(),
7754
+ /** HA `max_humidity` attribute. Null → UI uses 100. */
7755
+ maxHumidity: number().nullable(),
7756
+ /** Ms epoch when the slice was last updated. */
7757
+ lastChangedAt: number()
7758
+ });
7759
+ ({
7760
+ deviceTypes: [DeviceType.Humidifier],
7761
+ methods: {
7762
+ setOn: method(
7763
+ object({
7764
+ deviceId: number().int().nonnegative(),
7765
+ on: boolean()
7766
+ }),
7767
+ _void(),
7768
+ { kind: "mutation", auth: "admin" }
7769
+ ),
7770
+ setTargetHumidity: method(
7771
+ object({
7772
+ deviceId: number().int().nonnegative(),
7773
+ humidity: number().min(0).max(100)
7774
+ }),
7775
+ _void(),
7776
+ { kind: "mutation", auth: "admin" }
7777
+ ),
7778
+ setMode: method(
7779
+ object({
7780
+ deviceId: number().int().nonnegative(),
7781
+ mode: string().min(1)
7782
+ }),
7783
+ _void(),
7784
+ { kind: "mutation", auth: "admin" }
7785
+ )
7786
+ }
7787
+ });
7788
+ object({
7789
+ /** Current relative humidity, 0..100. */
7790
+ percent: number().min(0).max(100),
7791
+ /** Ms epoch when the slice was last updated. */
7792
+ lastFetchedAt: number(),
7793
+ /** Live display unit from the upstream source (e.g. HA
7794
+ * `attributes.unit_of_measurement`). The UI prefers this over the
7795
+ * role's canonical unit. Absent → fall back to the canonical unit. */
7796
+ unit: string().optional(),
7797
+ /** Suggested decimal places for numeric display.
7798
+ * Populated live from the upstream source when provided (e.g. HA
7799
+ * `attributes.suggested_display_precision`). Falls back to
7800
+ * auto-formatting when absent. */
7801
+ precision: number().int().min(0).max(10).optional()
7802
+ });
7803
+ ({
7804
+ deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat]
7805
+ });
7806
+ object({
7807
+ /** Absolute signed URL the browser loads directly. Null when the
7808
+ * entity exposes no `entity_picture` (yet). */
7809
+ url: string().nullable(),
7810
+ /** Ms epoch of the upstream last-updated timestamp. Null at cold-start. */
7811
+ lastUpdated: number().nullable()
7812
+ });
7813
+ ({
7814
+ deviceTypes: [DeviceType.Image]
7815
+ });
7816
+ const LawnMowerActivitySchema = _enum([
7817
+ "idle",
7818
+ "mowing",
7819
+ "paused",
7820
+ "docked",
7821
+ "error"
7822
+ ]);
7823
+ object({
7824
+ /** Lifecycle activity of the mower. */
7825
+ activity: LawnMowerActivitySchema,
7826
+ /** 0..100 battery percentage. Null when the device has no battery
7827
+ * reading. */
7828
+ batteryLevel: number().min(0).max(100).nullable(),
7829
+ /** Ms epoch when the slice was last updated. */
7830
+ lastChangedAt: number()
7831
+ });
6409
7832
  ({
6410
- deviceTypes: [DeviceType.Camera],
7833
+ deviceTypes: [DeviceType.LawnMower],
6411
7834
  methods: {
6412
- getCameraStreams: method(
7835
+ startMowing: method(
6413
7836
  object({ deviceId: number().int().nonnegative() }),
6414
- array(CameraStreamSchema).readonly()
7837
+ _void(),
7838
+ { kind: "mutation", auth: "admin" }
6415
7839
  ),
6416
- getBrokerStreams: method(
7840
+ pause: method(
6417
7841
  object({ deviceId: number().int().nonnegative() }),
6418
- array(ProfileSlotSchema).readonly()
7842
+ _void(),
7843
+ { kind: "mutation", auth: "admin" }
6419
7844
  ),
6420
- /**
6421
- * Per-device RTSP restream entries. Returns the broker's published
6422
- * RTSP URLs (one per `${deviceId}/${profile}`) for THIS device only,
6423
- * including the rendered `url` field with the RTSP token applied.
6424
- * Consumers (snapshot wrapper, recording, external probes) use this
6425
- * to pick a stream URL without scanning the whole cluster.
6426
- *
6427
- * The system `stream-broker.getAllRtspEntries({hostname?})` still
6428
- * exists for whole-cluster use cases (admin dashboard, settings
6429
- * exports). This device-scoped accessor is the supported handle for
6430
- * code that already has a `deviceId` in hand — keeps device-keyed
6431
- * filtering server-side and rides the DeviceProxy auto-injection.
6432
- */
6433
- getRtspEntries: method(
7845
+ dock: method(
7846
+ object({ deviceId: number().int().nonnegative() }),
7847
+ _void(),
7848
+ { kind: "mutation", auth: "admin" }
7849
+ )
7850
+ }
7851
+ });
7852
+ const LockStateSchema = _enum([
7853
+ "locked",
7854
+ "unlocked",
7855
+ "locking",
7856
+ "unlocking",
7857
+ "jammed"
7858
+ ]);
7859
+ object({
7860
+ /** Lifecycle state of the lock. `jammed` means the motor reported
7861
+ * failure to reach the target — operator intervention required. */
7862
+ state: LockStateSchema,
7863
+ /** Ms epoch when the slice was last updated. */
7864
+ lastChangedAt: number()
7865
+ });
7866
+ ({
7867
+ deviceTypes: [DeviceType.Lock],
7868
+ methods: {
7869
+ lock: method(
6434
7870
  object({
6435
7871
  deviceId: number().int().nonnegative(),
6436
- /** Override hostname embedded in returned URLs. Defaults to the broker's bound address. */
6437
- hostname: string().optional()
7872
+ /** Optional PIN code required by some keypad locks. NOT
7873
+ * persisted — passed directly to the upstream service. */
7874
+ code: string().min(1).optional()
6438
7875
  }),
6439
- array(RtspRestreamEntrySchema).readonly()
7876
+ _void(),
7877
+ { kind: "mutation", auth: "admin" }
7878
+ ),
7879
+ unlock: method(
7880
+ object({
7881
+ deviceId: number().int().nonnegative(),
7882
+ code: string().min(1).optional()
7883
+ }),
7884
+ _void(),
7885
+ { kind: "mutation", auth: "admin" }
7886
+ ),
7887
+ open: method(
7888
+ object({
7889
+ deviceId: number().int().nonnegative()
7890
+ }),
7891
+ _void(),
7892
+ { kind: "mutation", auth: "admin" }
6440
7893
  )
6441
- },
6442
- events: {
6443
- /** Fires on publishCameraStream / retractCameraStream. */
6444
- onCamStreamsChanged: event(object({
6445
- deviceId: number().int().nonnegative(),
6446
- camStreams: array(CameraStreamSchema).readonly()
6447
- })),
6448
- /** Fires on assignProfile / unassignProfile / runtime status change. */
6449
- onProfileSlotsChanged: event(object({
6450
- deviceId: number().int().nonnegative(),
6451
- profileSlots: array(ProfileSlotSchema).readonly()
6452
- }))
6453
- },
6454
- /**
6455
- * Per-device live stream-broker state. Persistent settings (RTSP
6456
- * tokens, profile assignments, pre-buffer config, RTSP-enabled toggles,
6457
- * streamingDebug) stay in the broker's addon store — they survive
6458
- * restarts. The slice below carries ONLY what's truly runtime:
6459
- *
6460
- * - `online` — at least one profile slot is currently `'streaming'`.
6461
- * Drivers without a firmware liveness signal (RTSP, ONVIF…) can
6462
- * subscribe and mirror this into `state.deviceStatus.online`.
6463
- * - `slotStatuses` — current `ProfileSlotStatus` per profile,
6464
- * mirroring the runtime-mutable subset of `ProfileSlot`.
6465
- * - `slotErrors` — last error message per profile (only set when the
6466
- * corresponding slot is in `'error'`).
6467
- * - `lastChangedAt` — freshness signal for consumers that want to
6468
- * reason about how stale the slice is.
6469
- *
6470
- * Written by the stream-broker manager on every transition that
6471
- * affects these aggregates. Read via `device.state.cameraStreams.<field>`
6472
- * (BaseDevice proxy) or, cross-process, via
6473
- * `device-state.getCapSlice({deviceId, capName: 'camera-streams'})`.
6474
- * The cap's `onChanged` event fires automatically on each write so
6475
- * subscribers get push semantics for free.
6476
- */
6477
- runtimeState: object({
6478
- online: boolean(),
6479
- slotStatuses: object({
6480
- high: ProfileSlotStatusSchema.optional(),
6481
- mid: ProfileSlotStatusSchema.optional(),
6482
- low: ProfileSlotStatusSchema.optional()
6483
- }),
6484
- slotErrors: object({
6485
- high: string().optional(),
6486
- mid: string().optional(),
6487
- low: string().optional()
6488
- }),
6489
- lastChangedAt: number()
6490
- })
7894
+ }
6491
7895
  });
6492
- const DiscoveredChildStatusSchema = _enum(["online", "sleeping", "offline", "unknown"]);
6493
- const DiscoveredChildDeviceSchema = object({
6494
- /** Stable, integration-defined identifier. Mirrors Scrypted's `nativeId`. */
6495
- childNativeId: string(),
6496
- /** Friendly name as reported by the source (Reolink camera name, ONVIF profile, ...). */
6497
- name: string(),
6498
- /** DeviceType the child should be created with on adopt. */
6499
- type: _enum(DeviceType),
6500
- status: DiscoveredChildStatusSchema,
6501
- /** Free-form integration-specific metadata surfaced in the panel. */
6502
- metadata: object({
6503
- model: string().optional(),
6504
- serialNumber: string().optional(),
6505
- uid: string().optional(),
6506
- /** Reolink: 0-based channel index inside the parent NVR/Hub. */
6507
- rtspChannel: number().int().nonnegative().optional(),
6508
- isBattery: boolean().optional(),
6509
- isDoorbell: boolean().optional(),
6510
- isMultifocal: boolean().optional()
6511
- }).default({}),
6512
- /**
6513
- * `true` when the framework already created a child device for this
6514
- * `childNativeId` under the current parent. The panel uses it to
6515
- * gate the Add/Remove button and surface the existing `deviceId`.
6516
- */
6517
- alreadyAdopted: boolean(),
6518
- /** When `alreadyAdopted=true`, the framework-assigned child device id. */
6519
- adoptedDeviceId: number().int().nonnegative().nullable()
7896
+ const MediaPlayerStateSchema = _enum([
7897
+ "off",
7898
+ "on",
7899
+ "idle",
7900
+ "playing",
7901
+ "paused",
7902
+ "buffering",
7903
+ "standby"
7904
+ ]);
7905
+ const MediaPlayerRepeatSchema = _enum(["off", "all", "one"]);
7906
+ const MediaInfoSchema = object({
7907
+ /** Free-form media kind (`music`, `tvshow`, `movie`, `app`, `channel`,
7908
+ * `podcast`, ). */
7909
+ type: string(),
7910
+ /** Human-readable title. */
7911
+ title: string(),
7912
+ /** Optional artist / channel / station label. */
7913
+ artist: string().optional(),
7914
+ /** Optional album / season / show name. */
7915
+ album: string().optional(),
7916
+ /** Optional cover-art / thumbnail URL. */
7917
+ imageUrl: string().optional()
6520
7918
  });
6521
- const DeviceDiscoveryStatusSchema = object({
6522
- discovered: array(DiscoveredChildDeviceSchema),
6523
- /** Wall-clock ms of the last successful enumeration. */
6524
- lastDiscoveryAt: number().int().nonnegative().nullable(),
6525
- /** Last error surfaced from the source (rendered as a banner). */
6526
- lastError: string().nullable()
7919
+ object({
7920
+ /** Playback lifecycle state. */
7921
+ state: MediaPlayerStateSchema,
7922
+ /** Volume as 0..100 inclusive. Null when the device has no volume
7923
+ * surface. Pair with `DeviceFeature.MediaPlayerVolume`. */
7924
+ volumeLevel: number().min(0).max(100).nullable(),
7925
+ /** Mute toggle distinct from volume=0. Null when no mute surface.
7926
+ * Pair with `DeviceFeature.MediaPlayerMute`. */
7927
+ isMuted: boolean().nullable(),
7928
+ /** Active source / input. Empty when no source surface. */
7929
+ source: string(),
7930
+ /** Selectable sources. Empty when no source surface.
7931
+ * Pair with `DeviceFeature.MediaPlayerSelectSource`. */
7932
+ availableSources: array(string()),
7933
+ /** Currently-playing media info. Null when nothing is playing. */
7934
+ currentMedia: MediaInfoSchema.nullable(),
7935
+ /** Current playback position in ms. Null when not seekable or
7936
+ * nothing is playing. Pair with `DeviceFeature.MediaPlayerSeek`. */
7937
+ positionMs: number().int().nonnegative().nullable(),
7938
+ /** Total duration of the current media in ms. Null when unknown
7939
+ * (live stream) or nothing is playing. */
7940
+ durationMs: number().int().nonnegative().nullable(),
7941
+ /** Shuffle toggle. Null when no shuffle surface.
7942
+ * Pair with `DeviceFeature.MediaPlayerShuffle`. */
7943
+ shuffle: boolean().nullable(),
7944
+ /** Repeat mode. Null when no repeat surface.
7945
+ * Pair with `DeviceFeature.MediaPlayerRepeat`. */
7946
+ repeat: MediaPlayerRepeatSchema.nullable(),
7947
+ /** Ms epoch when the slice was last updated. */
7948
+ lastChangedAt: number()
6527
7949
  });
6528
7950
  ({
6529
- // Hub is the canonical parent. Other integrations (gateway-style)
6530
- // can register against the cap by also targeting their root type.
6531
- deviceTypes: [DeviceType.Hub],
6532
- // Mirror status into the per-device runtime-state slice so the panel
6533
- // hydrates from the kernel cache without a round-trip on every open.
6534
- runtimeState: DeviceDiscoveryStatusSchema.extend({
6535
- lastFetchedAt: number().int().nonnegative()
6536
- }),
7951
+ deviceTypes: [DeviceType.MediaPlayer],
6537
7952
  methods: {
6538
- /**
6539
- * Snapshot of the current `discovered` list. Returns the
6540
- * runtime-state cache — call `refreshDiscovery` first if a
6541
- * fresh round-trip to the source is required.
6542
- */
6543
- listDiscovered: method(
7953
+ play: method(
6544
7954
  object({ deviceId: number().int().nonnegative() }),
6545
- array(DiscoveredChildDeviceSchema).readonly()
7955
+ _void(),
7956
+ { kind: "mutation", auth: "admin" }
6546
7957
  ),
6547
- /**
6548
- * Force the integration to re-enumerate and update the
6549
- * runtime-state slice. Returns the freshly-enumerated list (also
6550
- * available via `listDiscovered` post-call).
6551
- */
6552
- refreshDiscovery: method(
7958
+ pause: method(
6553
7959
  object({ deviceId: number().int().nonnegative() }),
6554
- array(DiscoveredChildDeviceSchema).readonly(),
7960
+ _void(),
6555
7961
  { kind: "mutation", auth: "admin" }
6556
7962
  ),
6557
- /**
6558
- * Promote a discovered entry to a real child device. The framework
6559
- * creates the child via `kernel.devices.create()` with
6560
- * `parentDeviceId = parent.id` and seeds the child's config from
6561
- * `childInitialConfig` (driver-defined; usually carries channel +
6562
- * uid + parent reference). Returns the kernel-assigned numeric id.
6563
- */
6564
- adoptDevice: method(
7963
+ stop: method(
7964
+ object({ deviceId: number().int().nonnegative() }),
7965
+ _void(),
7966
+ { kind: "mutation", auth: "admin" }
7967
+ ),
7968
+ next: method(
7969
+ object({ deviceId: number().int().nonnegative() }),
7970
+ _void(),
7971
+ { kind: "mutation", auth: "admin" }
7972
+ ),
7973
+ previous: method(
7974
+ object({ deviceId: number().int().nonnegative() }),
7975
+ _void(),
7976
+ { kind: "mutation", auth: "admin" }
7977
+ ),
7978
+ seek: method(
6565
7979
  object({
6566
7980
  deviceId: number().int().nonnegative(),
6567
- childNativeId: string(),
6568
- /** Optional override for the child's display name. */
6569
- name: string().optional()
7981
+ positionMs: number().int().nonnegative()
6570
7982
  }),
7983
+ _void(),
7984
+ { kind: "mutation", auth: "admin" }
7985
+ ),
7986
+ setVolume: method(
6571
7987
  object({
6572
7988
  deviceId: number().int().nonnegative(),
6573
- stableId: string()
7989
+ volumeLevel: number().min(0).max(100)
6574
7990
  }),
7991
+ _void(),
6575
7992
  { kind: "mutation", auth: "admin" }
6576
7993
  ),
6577
- /**
6578
- * Inverse of `adoptDevice`: removes the child device from the
6579
- * kernel registry. The discovered entry remains in the
6580
- * enumeration (status updates resume) so the operator can re-adopt
6581
- * it later without a fresh refresh.
6582
- */
6583
- releaseDevice: method(
7994
+ setMute: method(
6584
7995
  object({
6585
7996
  deviceId: number().int().nonnegative(),
6586
- childDeviceId: number().int().nonnegative()
7997
+ muted: boolean()
7998
+ }),
7999
+ _void(),
8000
+ { kind: "mutation", auth: "admin" }
8001
+ ),
8002
+ setShuffle: method(
8003
+ object({
8004
+ deviceId: number().int().nonnegative(),
8005
+ shuffle: boolean()
8006
+ }),
8007
+ _void(),
8008
+ { kind: "mutation", auth: "admin" }
8009
+ ),
8010
+ setRepeat: method(
8011
+ object({
8012
+ deviceId: number().int().nonnegative(),
8013
+ repeat: MediaPlayerRepeatSchema
8014
+ }),
8015
+ _void(),
8016
+ { kind: "mutation", auth: "admin" }
8017
+ ),
8018
+ selectSource: method(
8019
+ object({
8020
+ deviceId: number().int().nonnegative(),
8021
+ source: string().min(1)
8022
+ }),
8023
+ _void(),
8024
+ { kind: "mutation", auth: "admin" }
8025
+ ),
8026
+ playMedia: method(
8027
+ object({
8028
+ deviceId: number().int().nonnegative(),
8029
+ /** Media identifier / URL. */
8030
+ mediaId: string().min(1),
8031
+ /** Media kind (`music`, `tvshow`, `movie`, `app`, …) — provider
8032
+ * passes it through to the upstream service. */
8033
+ mediaType: string().min(1)
6587
8034
  }),
6588
8035
  _void(),
6589
8036
  { kind: "mutation", auth: "admin" }
6590
8037
  )
6591
8038
  }
6592
8039
  });
6593
- object({
6594
- /** Ms epoch of the last press. null = never observed since this provider started. */
6595
- lastPressedAt: number().nullable(),
6596
- /** Counter since provider start. Resets on reboot. Useful for metrics/debug. */
6597
- pressCountSinceStart: number()
6598
- });
6599
- object({
6600
- deviceId: number(),
6601
- timestamp: number()
6602
- });
6603
- ({
6604
- deviceTypes: [DeviceType.Button]
6605
- });
6606
8040
  const FrameFormatSchema = _enum(["jpeg", "rgb", "bgr", "yuv420", "gray"]);
6607
8041
  const FrameInputSchema = object({
6608
8042
  data: custom(),
@@ -7009,8 +8443,8 @@ const PipelineRunResultBridge = custom();
7009
8443
  * calls. Single root step + uniform model assumed; trees with crop
7010
8444
  * children fall back to sequential execution.
7011
8445
  *
7012
- * Used by `scripts/bench-scrypted-style.mts` to mirror Scrypted's
7013
- * `detectObjects(media, {batch})` semantics for fair comparison.
8446
+ * Used by `scripts/bench-batch-style.mts` for batch benchmarking —
8447
+ * N frames in one call to amortise per-call IPC overhead.
7014
8448
  */
7015
8449
  runPipelineBatch: method(
7016
8450
  object({
@@ -7391,8 +8825,8 @@ object({
7391
8825
  lastDetectedAt: number().nullable(),
7392
8826
  /**
7393
8827
  * Ms after which `detected` auto-reverts to false if no fresh push
7394
- * arrives. Mirrors the scrypted-reolink-native default. Null means
7395
- * the provider leaves detected state until a native "clear" event.
8828
+ * arrives. Null means the provider leaves detected state until a
8829
+ * native "clear" event.
7396
8830
  */
7397
8831
  autoClearAfterMs: number().nullable()
7398
8832
  });
@@ -7578,6 +9012,194 @@ NativeObjectDetectionStatusSchema.extend({
7578
9012
  }) }
7579
9013
  }
7580
9014
  });
9015
+ const NotifierPrioritySchema = _enum(["min", "low", "normal", "high"]);
9016
+ const NotifierActionSchema = object({
9017
+ /** Stable id used in the callback when the user taps the button. */
9018
+ id: string().min(1),
9019
+ /** User-visible button label. */
9020
+ title: string().min(1),
9021
+ /** Optional deep-link URI invoked on tap (rich notifiers only). */
9022
+ uri: url().optional(),
9023
+ /** Optional flag — when true, the action is destructive and the
9024
+ * client should render the button in a warning style. */
9025
+ destructive: boolean().optional()
9026
+ });
9027
+ const NotifierSupportsSchema = object({
9028
+ /** Inline / URL image attachment. Pair with `DeviceFeature.NotifierImage`. */
9029
+ image: boolean(),
9030
+ /** Priority hint. Pair with `DeviceFeature.NotifierPriority`. */
9031
+ priority: boolean(),
9032
+ /** Free-form platform-specific data block.
9033
+ * Pair with `DeviceFeature.NotifierData`. */
9034
+ data: boolean(),
9035
+ /** Interactive action buttons. Pair with `DeviceFeature.NotifierActions`. */
9036
+ actions: boolean(),
9037
+ /** Per-call recipient targeting (multi-user notifiers).
9038
+ * Pair with `DeviceFeature.NotifierRecipients`. */
9039
+ recipients: boolean()
9040
+ });
9041
+ object({
9042
+ /** Ms epoch of the most recent successful send. 0 if none yet. */
9043
+ lastSentAt: number(),
9044
+ /** Failure description from the most recent send attempt. Null on
9045
+ * success or when nothing has been sent yet. */
9046
+ lastError: string().nullable(),
9047
+ /** Number of deliveries currently buffered server-side (rate-limited
9048
+ * notifiers may queue). 0 when the provider sends synchronously. */
9049
+ queueDepth: number().int().nonnegative(),
9050
+ /** Per-feature capability matrix — authoritative source for the
9051
+ * compose-form field gating. */
9052
+ supports: NotifierSupportsSchema
9053
+ });
9054
+ const NotifierSendInputSchema = object({
9055
+ deviceId: number().int().nonnegative(),
9056
+ /** Optional title — many platforms render it bolder than the body. */
9057
+ title: string().optional(),
9058
+ /** Required message body. */
9059
+ body: string().min(1),
9060
+ /** Optional image URL or inline data URI (`data:image/...;base64,...`). */
9061
+ image: string().optional(),
9062
+ /** Priority hint — providers without priority support ignore the field. */
9063
+ priority: NotifierPrioritySchema.optional(),
9064
+ /** Channel / topic / tag the notifier should route through (Android
9065
+ * notification channel, Telegram chat id, ntfy topic, …). */
9066
+ channel: string().optional(),
9067
+ /** Optional list of recipient ids (multi-user notifiers). Empty /
9068
+ * omitted = the device's default recipient. */
9069
+ recipients: array(string()).optional(),
9070
+ /** Optional interactive action buttons. */
9071
+ actions: array(NotifierActionSchema).optional(),
9072
+ /** Free-form platform-specific payload — passed through to the
9073
+ * upstream service untouched. */
9074
+ data: record(string(), unknown()).optional()
9075
+ });
9076
+ const NotifierSendResultSchema = object({
9077
+ /** Provider-assigned id for the delivery. Used for `cancel`. */
9078
+ notificationId: string(),
9079
+ /** Ms epoch when the notifier accepted the send (not when delivered). */
9080
+ acceptedAt: number()
9081
+ });
9082
+ ({
9083
+ deviceTypes: [DeviceType.Notifier],
9084
+ methods: {
9085
+ send: method(
9086
+ NotifierSendInputSchema,
9087
+ NotifierSendResultSchema,
9088
+ { kind: "mutation", auth: "admin" }
9089
+ ),
9090
+ cancel: method(
9091
+ object({
9092
+ deviceId: number().int().nonnegative(),
9093
+ notificationId: string().min(1)
9094
+ }),
9095
+ _void(),
9096
+ { kind: "mutation", auth: "admin" }
9097
+ )
9098
+ },
9099
+ events: {
9100
+ /**
9101
+ * Emitted after every send attempt — success or failure. Subscribers
9102
+ * (admin UI history pane, automation engines, retry workers) react
9103
+ * without polling the provider's `lastSentAt`.
9104
+ */
9105
+ onSent: { data: object({
9106
+ deviceId: number(),
9107
+ notificationId: string(),
9108
+ success: boolean(),
9109
+ error: string().nullable(),
9110
+ acceptedAt: number()
9111
+ }) }
9112
+ }
9113
+ });
9114
+ object({
9115
+ value: number(),
9116
+ /** Display unit-of-measurement (e.g. 'dBm', 's', 'rpm', 'steps').
9117
+ * Populated live from the upstream source on each state push.
9118
+ * Absent for unitless sensors. */
9119
+ unit: string().optional(),
9120
+ /** Suggested decimal places for numeric display.
9121
+ * Populated live from the upstream source when provided.
9122
+ * Falls back to auto-formatting when absent. */
9123
+ precision: number().int().min(0).max(10).optional(),
9124
+ /** Ms epoch when the slice was last updated. */
9125
+ lastFetchedAt: number()
9126
+ });
9127
+ ({
9128
+ deviceTypes: [DeviceType.Sensor]
9129
+ });
9130
+ object({
9131
+ /** Instantaneous power draw in watts. */
9132
+ watts: number().optional(),
9133
+ /** Cumulative energy in kilowatt-hours since the meter was reset. */
9134
+ kwhTotal: number().optional(),
9135
+ /** Voltage in volts. */
9136
+ volts: number().optional(),
9137
+ /** Current in amperes. */
9138
+ amps: number().optional(),
9139
+ /** Apparent power in volt-amperes (W × power-factor reciprocal). */
9140
+ va: number().optional(),
9141
+ /** Power factor (0..1) if reported. */
9142
+ powerFactor: number().min(0).max(1).optional(),
9143
+ /** Ms epoch when the slice was last updated. */
9144
+ lastFetchedAt: number(),
9145
+ /** Live display unit of the single metric this slice carries (e.g. HA
9146
+ * `attributes.unit_of_measurement` → 'V' / 'A' / 'W' / 'kW' / 'kWh').
9147
+ * Each upstream `sensor.*` entity surfaces ONE device_class, so one
9148
+ * unit per slice is unambiguous. The UI prefers this over the role's
9149
+ * canonical unit so a 'kW' / 'Wh' feed renders verbatim. */
9150
+ unit: string().optional(),
9151
+ /** Suggested decimal places for numeric display.
9152
+ * Populated live from the upstream source when provided (e.g. HA
9153
+ * `attributes.suggested_display_precision`). Falls back to
9154
+ * auto-formatting when absent. */
9155
+ precision: number().int().min(0).max(10).optional()
9156
+ });
9157
+ ({
9158
+ deviceTypes: [DeviceType.Sensor]
9159
+ });
9160
+ const GpsLocationSchema = object({
9161
+ /** Latitude in decimal degrees, -90..90. */
9162
+ latitude: number().min(-90).max(90),
9163
+ /** Longitude in decimal degrees, -180..180. */
9164
+ longitude: number().min(-180).max(180),
9165
+ /** Reported accuracy in meters (lower = better). */
9166
+ accuracyMeters: number().nonnegative()
9167
+ });
9168
+ object({
9169
+ /** `home` / `not_home` / any user-defined zone name. */
9170
+ state: string(),
9171
+ /** Optional textual location label (zone name, city, address). Null
9172
+ * when only the binary state is known. */
9173
+ location: string().nullable(),
9174
+ /** GPS coordinates when available. Null otherwise. */
9175
+ gps: GpsLocationSchema.nullable(),
9176
+ /** Optional battery level of the tracking device (0..100). Null
9177
+ * when not reported. */
9178
+ batteryPercent: number().min(0).max(100).nullable(),
9179
+ /** Ms epoch when the slice was last updated. */
9180
+ lastChangedAt: number()
9181
+ });
9182
+ ({
9183
+ deviceTypes: [DeviceType.Presence]
9184
+ });
9185
+ object({
9186
+ /** Current pressure in hPa. */
9187
+ hpa: number(),
9188
+ /** Ms epoch when the slice was last updated. */
9189
+ lastFetchedAt: number(),
9190
+ /** Live display unit from the upstream source (e.g. HA
9191
+ * `attributes.unit_of_measurement`). The UI prefers this over the
9192
+ * role's canonical unit. Absent → fall back to the canonical unit. */
9193
+ unit: string().optional(),
9194
+ /** Suggested decimal places for numeric display.
9195
+ * Populated live from the upstream source when provided (e.g. HA
9196
+ * `attributes.suggested_display_precision`). Falls back to
9197
+ * auto-formatting when absent. */
9198
+ precision: number().int().min(0).max(10).optional()
9199
+ });
9200
+ ({
9201
+ deviceTypes: [DeviceType.Sensor]
9202
+ });
7581
9203
  const PrivacyMaskShapeSchema = discriminatedUnion("kind", [
7582
9204
  MaskRectShapeSchema,
7583
9205
  MaskPolygonShapeSchema
@@ -7706,6 +9328,51 @@ PtzAutotrackStatusSchema.extend({
7706
9328
  }) }
7707
9329
  }
7708
9330
  });
9331
+ object({
9332
+ /** Whether the script is currently executing. */
9333
+ isRunning: boolean(),
9334
+ /** Ms epoch of the last invocation start. 0 when never run. */
9335
+ lastRunAt: number(),
9336
+ /** Outcome of the last completed run. Null when never run or still
9337
+ * running. */
9338
+ lastRunSuccess: boolean().nullable(),
9339
+ /** Failure description from the last completed run. Null on success
9340
+ * or when never run. */
9341
+ lastError: string().nullable(),
9342
+ /** Ms epoch when the slice was last updated. */
9343
+ lastChangedAt: number()
9344
+ });
9345
+ ({
9346
+ deviceTypes: [DeviceType.Script],
9347
+ methods: {
9348
+ run: method(
9349
+ object({
9350
+ deviceId: number().int().nonnegative(),
9351
+ /** Optional variables map — passed through to the upstream
9352
+ * script. Provider rejects when the script doesn't declare
9353
+ * input fields (gated by `DeviceFeature.ScriptVariables`). */
9354
+ variables: record(string(), unknown()).optional()
9355
+ }),
9356
+ _void(),
9357
+ { kind: "mutation", auth: "admin" }
9358
+ ),
9359
+ /** Cancel a running script. Provider rejects when the script
9360
+ * isn't currently running. */
9361
+ stop: method(
9362
+ object({ deviceId: number().int().nonnegative() }),
9363
+ _void(),
9364
+ { kind: "mutation", auth: "admin" }
9365
+ )
9366
+ }
9367
+ });
9368
+ object({
9369
+ detected: boolean(),
9370
+ /** Ms epoch of the last transition. 0 if never observed. */
9371
+ lastChangedAt: number()
9372
+ });
9373
+ ({
9374
+ deviceTypes: [DeviceType.Sensor]
9375
+ });
7709
9376
  const StreamProfileSchema = _enum(["main", "sub", "ext"]);
7710
9377
  const StreamProfileConfigSchema = object({
7711
9378
  width: number(),
@@ -7822,6 +9489,257 @@ object({
7822
9489
  }) }
7823
9490
  }
7824
9491
  });
9492
+ object({
9493
+ /** True when the device's tamper switch / case-open contact is
9494
+ * currently triggered. */
9495
+ tampered: boolean(),
9496
+ /** Ms epoch of the last transition. 0 if never observed. */
9497
+ lastChangedAt: number()
9498
+ });
9499
+ ({
9500
+ deviceTypes: [DeviceType.Sensor]
9501
+ });
9502
+ object({
9503
+ /** Current temperature in Celsius. */
9504
+ celsius: number(),
9505
+ /** Ms epoch when the slice was last updated (push or poll). */
9506
+ lastFetchedAt: number(),
9507
+ /** Live display unit from the upstream source (e.g. HA
9508
+ * `attributes.unit_of_measurement`). The UI prefers this over the
9509
+ * role's canonical unit, so a Fahrenheit feed renders '°F' not '°C'.
9510
+ * Absent → the UI falls back to the role's canonical unit. */
9511
+ unit: string().optional(),
9512
+ /** Suggested decimal places for numeric display.
9513
+ * Populated live from the upstream source when provided (e.g. HA
9514
+ * `attributes.suggested_display_precision`). Falls back to
9515
+ * auto-formatting when absent. */
9516
+ precision: number().int().min(0).max(10).optional()
9517
+ });
9518
+ ({
9519
+ deviceTypes: [DeviceType.Sensor, DeviceType.Thermostat]
9520
+ });
9521
+ object({
9522
+ currentVersion: string().nullable(),
9523
+ availableVersion: string().nullable(),
9524
+ /**
9525
+ * DEVICE-CAPABILITY flag: the entity supports firmware updates at all — NOT
9526
+ * a per-state "an update is available right now" flag. The canonical
9527
+ * "update available" signal is `availableVersion !== currentVersion` (which
9528
+ * is what the UI uses); HA's `state === 'on'` mirrors that same condition.
9529
+ */
9530
+ updatable: boolean(),
9531
+ state: string().nullable(),
9532
+ // e.g. 'UP_TO_DATE' / 'DELIVER_FIRMWARE_IMAGE' (provider-verbatim)
9533
+ inProgress: boolean()
9534
+ });
9535
+ ({
9536
+ deviceTypes: [DeviceType.Update],
9537
+ methods: {
9538
+ installUpdate: method(_void(), _void(), { kind: "mutation", auth: "admin" })
9539
+ }
9540
+ });
9541
+ const VacuumStateSchema = _enum([
9542
+ "idle",
9543
+ "cleaning",
9544
+ "paused",
9545
+ "returning",
9546
+ "docked",
9547
+ "error"
9548
+ ]);
9549
+ const TankStatusSchema = object({
9550
+ /** Numeric fill 0..100 when the hardware reports a percentage; null otherwise. */
9551
+ level: number().min(0).max(100).nullable(),
9552
+ /** Discrete state when the hardware is binary-mode; null otherwise. */
9553
+ status: _enum(["ok", "low", "full"]).nullable()
9554
+ });
9555
+ object({
9556
+ /** Lifecycle state of the vacuum. */
9557
+ state: VacuumStateSchema,
9558
+ /** 0..100 battery percentage. Null when the device has no battery
9559
+ * reading. */
9560
+ batteryLevel: number().min(0).max(100).nullable(),
9561
+ /** Current fan-speed token (provider-verbatim). Null when unknown or
9562
+ * the vacuum has no speed control. */
9563
+ fanSpeed: string().nullable(),
9564
+ /** Speed tokens the hardware accepts — drives the UI selector. */
9565
+ availableFanSpeeds: array(string()),
9566
+ /** Clean-water (mop) tank. Null when the hardware has no clean-water tank. */
9567
+ cleanWater: TankStatusSchema.nullable(),
9568
+ /** Dirty-water (recovery) tank. Null when the hardware has no dirty-water tank. */
9569
+ dirtyWater: TankStatusSchema.nullable(),
9570
+ /** Detergent tank. Null when the hardware has no detergent tank. */
9571
+ detergent: TankStatusSchema.nullable(),
9572
+ /** Dust bin. Null when the hardware has no dust bin. */
9573
+ dustBin: TankStatusSchema.nullable(),
9574
+ /** Ms epoch when the slice was last updated. */
9575
+ lastChangedAt: number()
9576
+ });
9577
+ ({
9578
+ deviceTypes: [DeviceType.Vacuum],
9579
+ methods: {
9580
+ start: method(
9581
+ object({ deviceId: number().int().nonnegative() }),
9582
+ _void(),
9583
+ { kind: "mutation", auth: "admin" }
9584
+ ),
9585
+ pause: method(
9586
+ object({ deviceId: number().int().nonnegative() }),
9587
+ _void(),
9588
+ { kind: "mutation", auth: "admin" }
9589
+ ),
9590
+ stop: method(
9591
+ object({ deviceId: number().int().nonnegative() }),
9592
+ _void(),
9593
+ { kind: "mutation", auth: "admin" }
9594
+ ),
9595
+ returnToBase: method(
9596
+ object({ deviceId: number().int().nonnegative() }),
9597
+ _void(),
9598
+ { kind: "mutation", auth: "admin" }
9599
+ ),
9600
+ locate: method(
9601
+ object({ deviceId: number().int().nonnegative() }),
9602
+ _void(),
9603
+ { kind: "mutation", auth: "admin" }
9604
+ ),
9605
+ setFanSpeed: method(
9606
+ object({
9607
+ deviceId: number().int().nonnegative(),
9608
+ speed: string().min(1)
9609
+ }),
9610
+ _void(),
9611
+ { kind: "mutation", auth: "admin" }
9612
+ )
9613
+ }
9614
+ });
9615
+ const ValveStateSchema = _enum([
9616
+ "open",
9617
+ "opening",
9618
+ "closing",
9619
+ "closed",
9620
+ "stopped"
9621
+ ]);
9622
+ object({
9623
+ /** Lifecycle state of the valve. */
9624
+ state: ValveStateSchema,
9625
+ /** 0 = fully closed, 100 = fully open. Null when the device has no
9626
+ * intermediate position surface. */
9627
+ position: number().min(0).max(100).nullable(),
9628
+ /** Ms epoch when the slice was last updated. */
9629
+ lastChangedAt: number()
9630
+ });
9631
+ ({
9632
+ deviceTypes: [DeviceType.Valve],
9633
+ methods: {
9634
+ open: method(
9635
+ object({ deviceId: number().int().nonnegative() }),
9636
+ _void(),
9637
+ { kind: "mutation", auth: "admin" }
9638
+ ),
9639
+ close: method(
9640
+ object({ deviceId: number().int().nonnegative() }),
9641
+ _void(),
9642
+ { kind: "mutation", auth: "admin" }
9643
+ ),
9644
+ stop: method(
9645
+ object({ deviceId: number().int().nonnegative() }),
9646
+ _void(),
9647
+ { kind: "mutation", auth: "admin" }
9648
+ ),
9649
+ setPosition: method(
9650
+ object({
9651
+ deviceId: number().int().nonnegative(),
9652
+ position: number().min(0).max(100)
9653
+ }),
9654
+ _void(),
9655
+ { kind: "mutation", auth: "admin" }
9656
+ )
9657
+ }
9658
+ });
9659
+ object({
9660
+ detected: boolean(),
9661
+ /** Ms epoch of the last transition. 0 if never observed. */
9662
+ lastChangedAt: number()
9663
+ });
9664
+ ({
9665
+ deviceTypes: [DeviceType.Sensor]
9666
+ });
9667
+ object({
9668
+ /** Current measured temperature. Null when not reported. */
9669
+ currentTemp: number().nullable(),
9670
+ /** Target temperature setpoint. Null when no setpoint surface. */
9671
+ targetTemp: number().nullable(),
9672
+ /** Active operation mode = HA `state` (`eco` / `electric` / `gas` /
9673
+ * `heat_pump` / `high_demand` / `performance` / `off`). Null when the
9674
+ * device reports an unknown state. */
9675
+ operationMode: string().nullable(),
9676
+ /** Available operation modes = HA `operation_list`. */
9677
+ availableModes: array(string()),
9678
+ /** Away mode (HA `away_mode` 'on'/'off' → bool). Null when the device
9679
+ * has no away surface. */
9680
+ away: boolean().nullable(),
9681
+ /** HA `min_temp` attribute. Null when not reported. */
9682
+ minTemp: number().nullable(),
9683
+ /** HA `max_temp` attribute. Null when not reported. */
9684
+ maxTemp: number().nullable(),
9685
+ /** Ms epoch when the slice was last updated. */
9686
+ lastChangedAt: number()
9687
+ });
9688
+ ({
9689
+ deviceTypes: [DeviceType.WaterHeater],
9690
+ methods: {
9691
+ setTargetTemp: method(
9692
+ object({
9693
+ deviceId: number().int().nonnegative(),
9694
+ temp: number().finite()
9695
+ }),
9696
+ _void(),
9697
+ { kind: "mutation", auth: "admin" }
9698
+ ),
9699
+ setOperationMode: method(
9700
+ object({
9701
+ deviceId: number().int().nonnegative(),
9702
+ mode: string().min(1)
9703
+ }),
9704
+ _void(),
9705
+ { kind: "mutation", auth: "admin" }
9706
+ ),
9707
+ setAway: method(
9708
+ object({
9709
+ deviceId: number().int().nonnegative(),
9710
+ on: boolean()
9711
+ }),
9712
+ _void(),
9713
+ { kind: "mutation", auth: "admin" }
9714
+ )
9715
+ }
9716
+ });
9717
+ object({
9718
+ /** Verbatim HA condition state (`sunny`, `cloudy`, `rainy`, …). Null
9719
+ * when no condition has been reported yet. */
9720
+ condition: string().nullable(),
9721
+ /** Current temperature in the reported unit. Null when not provided. */
9722
+ temperature: number().nullable(),
9723
+ /** Temperature unit string (e.g. `°C` / `°F`). Null when not provided. */
9724
+ temperatureUnit: string().nullable(),
9725
+ /** Relative humidity (0..100). Null when not provided. */
9726
+ humidity: number().min(0).max(100).nullable(),
9727
+ /** Barometric pressure in the reported unit. Null when not provided. */
9728
+ pressure: number().nullable(),
9729
+ /** Pressure unit string (e.g. `hPa` / `inHg`). Null when not provided. */
9730
+ pressureUnit: string().nullable(),
9731
+ /** Wind speed in the reported unit. Null when not provided. */
9732
+ windSpeed: number().nullable(),
9733
+ /** Wind-speed unit string (e.g. `km/h` / `mph`). Null when not provided. */
9734
+ windSpeedUnit: string().nullable(),
9735
+ /** Wind bearing in degrees (0..360, meteorological). Null when not provided. */
9736
+ windBearing: number().nullable(),
9737
+ /** Ms epoch when the slice was last updated. */
9738
+ lastFetchedAt: number()
9739
+ });
9740
+ ({
9741
+ deviceTypes: [DeviceType.Weather]
9742
+ });
7825
9743
  const PerScopeBreakdownSchema = object({
7826
9744
  /** Total tracked objects in this scope (frame / zone / unzoned). */
7827
9745
  totalObjects: number().int().nonnegative(),
@@ -8017,7 +9935,14 @@ const DiscoveryCandidateSchema = object({
8017
9935
  stableId: string(),
8018
9936
  type: _enum(DeviceType),
8019
9937
  suggestedName: string(),
8020
- prefilledConfig: record(string(), unknown())
9938
+ prefilledConfig: record(string(), unknown()),
9939
+ /**
9940
+ * Optional upstream-system identity (HA entity_id, vendor MAC, …).
9941
+ * Discovery pre-populates this for systems that know the upstream
9942
+ * identity ahead of adoption. Rendering metadata (unit, precision)
9943
+ * flows live through the cap STATUS SLICE after adoption.
9944
+ */
9945
+ sourceInfo: SourceInfoSchema.optional()
8021
9946
  });
8022
9947
  const DeviceSummarySchema = object({
8023
9948
  id: number(),
@@ -8028,7 +9953,12 @@ const DeviceSummarySchema = object({
8028
9953
  parentDeviceId: number().nullable(),
8029
9954
  online: boolean(),
8030
9955
  features: array(string()),
8031
- config: record(string(), unknown())
9956
+ config: record(string(), unknown()),
9957
+ /** Optional upstream-system identity (dispatch key + system tag).
9958
+ * See `SourceInfo`. Present when the device has a non-synthetic
9959
+ * source identifier (HA entities, vendor MAC, …); omitted when the
9960
+ * synthetic backfill is in effect. */
9961
+ sourceInfo: SourceInfoSchema.optional()
8032
9962
  });
8033
9963
  const FieldProbeResultSchema = object({
8034
9964
  status: _enum(["ok", "error"]),
@@ -8207,6 +10137,22 @@ const AlertSchema = object({
8207
10137
  dismiss: method(object({ alertId: string() }), _void(), { kind: "mutation" })
8208
10138
  }
8209
10139
  });
10140
+ const ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [
10141
+ object({
10142
+ providerId: string().min(1),
10143
+ displayName: string().min(1),
10144
+ configSchema: unknown(),
10145
+ shouldSaveDiskSpace: literal(true),
10146
+ minFreePercent: number().min(0).max(100)
10147
+ }),
10148
+ object({
10149
+ providerId: string().min(1),
10150
+ displayName: string().min(1),
10151
+ configSchema: unknown(),
10152
+ shouldSaveDiskSpace: literal(false),
10153
+ minFreePercent: literal(null)
10154
+ })
10155
+ ]);
8210
10156
  ({
8211
10157
  methods: {
8212
10158
  // ── Small-file primitives ────────────────────────────────────────
@@ -8281,6 +10227,16 @@ const AlertSchema = object({
8281
10227
  object({ type: StorageLocationTypeSchema }),
8282
10228
  StorageLocationSchema.nullable()
8283
10229
  ),
10230
+ // The admin-UI Data screen renders one group per declared location
10231
+ // (header = `displayName`, "+ Add" shown only for `cardinality:
10232
+ // 'multi'`). Source: the kernel-aggregated `storageLocations`
10233
+ // declarations injected into the orchestrator via `setRegistry`.
10234
+ // No closed type enum in the UI — the screen is fully declaration-
10235
+ // driven.
10236
+ listLocationDeclarations: method(
10237
+ _void(),
10238
+ array(StorageLocationDeclarationSchema).readonly()
10239
+ ),
8284
10240
  upsertLocation: method(
8285
10241
  StorageLocationSchema.omit({ createdAt: true, updatedAt: true }),
8286
10242
  StorageLocationSchema,
@@ -8305,12 +10261,7 @@ const AlertSchema = object({
8305
10261
  // never changes after boot).
8306
10262
  listProviders: method(
8307
10263
  _void(),
8308
- array(object({
8309
- providerId: string(),
8310
- displayName: string(),
8311
- supportedLocationTypes: array(StorageLocationTypeSchema).readonly(),
8312
- configSchema: unknown()
8313
- })).readonly()
10264
+ array(ProviderListEntrySchema).readonly()
8314
10265
  ),
8315
10266
  // Validate a candidate config against a provider BEFORE persisting.
8316
10267
  // Used by the wizard so operators can preflight a connection
@@ -8326,12 +10277,24 @@ const AlertSchema = object({
8326
10277
  )
8327
10278
  }
8328
10279
  });
8329
- const ProviderInfoSchema = object({
8330
- providerId: string().min(1),
8331
- displayName: string().min(1),
8332
- supportedLocationTypes: array(StorageLocationTypeSchema).readonly(),
8333
- configSchema: unknown()
8334
- });
10280
+ const ProviderInfoSchema = discriminatedUnion("shouldSaveDiskSpace", [
10281
+ object({
10282
+ providerId: string().min(1),
10283
+ displayName: string().min(1),
10284
+ configSchema: unknown(),
10285
+ // Provider manages a finite volume → declares a default free-space threshold.
10286
+ shouldSaveDiskSpace: literal(true),
10287
+ minFreePercent: number().min(0).max(100)
10288
+ }),
10289
+ object({
10290
+ providerId: string().min(1),
10291
+ displayName: string().min(1),
10292
+ configSchema: unknown(),
10293
+ // No local free-space concept (remote/object store) → no threshold.
10294
+ shouldSaveDiskSpace: literal(false),
10295
+ minFreePercent: literal(null)
10296
+ })
10297
+ ]);
8335
10298
  const TestLocationResultSchema = object({
8336
10299
  ok: boolean(),
8337
10300
  error: string().optional()
@@ -8417,6 +10380,35 @@ const EndDownloadInputSchema = object({ downloadId: string() });
8417
10380
  endDownload: method(EndDownloadInputSchema, _void(), { kind: "mutation" })
8418
10381
  }
8419
10382
  });
10383
+ const EvictableUsageSchema = object({
10384
+ /** Bytes this provider currently holds on the location (0 if none). */
10385
+ bytes: number().int().nonnegative()
10386
+ });
10387
+ const EvictResultSchema = object({
10388
+ /** Bytes actually reclaimed (only successfully-deleted data counts). */
10389
+ reclaimedBytes: number().int().nonnegative(),
10390
+ /** True when the provider has nothing left it is willing to drop on this location. */
10391
+ exhausted: boolean()
10392
+ });
10393
+ ({
10394
+ methods: {
10395
+ /** Bytes this provider holds on the given location — drives proportional fan-out. */
10396
+ getEvictableUsage: method(
10397
+ object({ locationId: string() }),
10398
+ EvictableUsageSchema
10399
+ ),
10400
+ /**
10401
+ * Free approximately `targetBytes` of this provider's OWN least-valuable
10402
+ * data on the location (oldest footage, expired clips, …). Returns what it
10403
+ * actually reclaimed + whether it is now exhausted.
10404
+ */
10405
+ evict: method(
10406
+ object({ locationId: string(), targetBytes: number().int().positive() }),
10407
+ EvictResultSchema,
10408
+ { kind: "mutation" }
10409
+ )
10410
+ }
10411
+ });
8420
10412
  const BackupSubDestinationInfoSchema = object({
8421
10413
  /**
8422
10414
  * Sub-id within this addon. Convention `default` for single-
@@ -8627,7 +10619,7 @@ const QueryFilterSchema = object({
8627
10619
  limit: number().optional(),
8628
10620
  offset: number().optional()
8629
10621
  });
8630
- const SettingsRecordSchema = object({
10622
+ const SettingsRecordSchema$1 = object({
8631
10623
  id: string(),
8632
10624
  data: record(string(), unknown())
8633
10625
  });
@@ -8659,11 +10651,11 @@ const CollectionIndexSchema = object({
8659
10651
  /** Get all entries matching an optional filter. */
8660
10652
  query: method(
8661
10653
  object({ namespace: string().optional(), collection: string(), filter: QueryFilterSchema.optional() }),
8662
- array(SettingsRecordSchema).readonly()
10654
+ array(SettingsRecordSchema$1).readonly()
8663
10655
  ),
8664
10656
  /** Insert a new record. */
8665
10657
  insert: method(
8666
- object({ namespace: string().optional(), collection: string(), record: SettingsRecordSchema }),
10658
+ object({ namespace: string().optional(), collection: string(), record: SettingsRecordSchema$1 }),
8667
10659
  _void(),
8668
10660
  { kind: "mutation" }
8669
10661
  ),
@@ -8684,6 +10676,18 @@ const CollectionIndexSchema = object({
8684
10676
  object({ namespace: string().optional(), collection: string(), filter: QueryFilterSchema.optional() }),
8685
10677
  number()
8686
10678
  ),
10679
+ /** Grouped counts per ((field-origin)/bucketSize) bucket, filtered. */
10680
+ histogram: method(
10681
+ object({
10682
+ namespace: string().optional(),
10683
+ collection: string(),
10684
+ field: string(),
10685
+ bucketSize: number().int().positive(),
10686
+ origin: number().int(),
10687
+ filter: QueryFilterSchema.optional()
10688
+ }),
10689
+ array(object({ bucket: number().int(), count: number().int() })).readonly()
10690
+ ),
8687
10691
  /** Check if a collection is empty. */
8688
10692
  isEmpty: method(
8689
10693
  object({ namespace: string().optional(), collection: string() }),
@@ -9004,6 +11008,297 @@ const SmtpStatusSchema = object({
9004
11008
  )
9005
11009
  }
9006
11010
  });
11011
+ const AdoptionFilterSchema = object({
11012
+ id: string(),
11013
+ label: string(),
11014
+ isDefault: boolean().optional()
11015
+ });
11016
+ const CandidateQueryFilterSchema = object({
11017
+ /** Substring filter on name + manufacturer + model. */
11018
+ search: string().optional(),
11019
+ /** Area-name exact match. */
11020
+ area: string().optional(),
11021
+ /** Manufacturer exact match. */
11022
+ manufacturer: string().optional(),
11023
+ /** When true, only return candidates the operator already adopted. */
11024
+ adoptedOnly: boolean().optional(),
11025
+ /** When true, only return candidates the operator hasn't adopted yet. */
11026
+ unadoptedOnly: boolean().optional()
11027
+ });
11028
+ const ListCandidatesInputSchema = object({
11029
+ integrationId: string(),
11030
+ page: number().int().positive().default(1),
11031
+ // Pagination is client-side in the adoption UI: the modal fetches the whole
11032
+ // candidate set in one page and the shared list paginates locally. The cap
11033
+ // stays high enough to return every candidate at once. The large case is the
11034
+ // HA `entities` granularity — one HA device maps to many entities, so an
11035
+ // install can expose several thousand candidates; the ceiling is sized for it.
11036
+ pageSize: number().int().positive().max(2e4).default(50),
11037
+ /**
11038
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11039
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11040
+ * exactly the pre-existing behavior (fully back-compatible).
11041
+ */
11042
+ filter: string().optional(),
11043
+ /** Optional candidate-list text/query narrowing within the granularity. */
11044
+ filterText: CandidateQueryFilterSchema.optional()
11045
+ });
11046
+ const ListCandidatesOutputSchema = object({
11047
+ candidates: array(DiscoveredChildDeviceSchema).readonly(),
11048
+ totalCount: number().int().nonnegative(),
11049
+ page: number().int().positive(),
11050
+ pageSize: number().int().positive()
11051
+ });
11052
+ const GetCandidateInputSchema = object({
11053
+ integrationId: string(),
11054
+ childNativeId: string()
11055
+ });
11056
+ const AdoptionStatusSchema = object({
11057
+ /** Last refresh timestamp (ms epoch) — null when never refreshed. */
11058
+ lastDiscoveryAt: number().int().nonnegative().nullable(),
11059
+ /** Count of candidates in the discovery cache. */
11060
+ candidateCount: number().int().nonnegative(),
11061
+ /** Count of candidates the operator has already adopted. */
11062
+ adoptedCount: number().int().nonnegative(),
11063
+ /** Last error message from a refresh attempt. */
11064
+ lastError: string().nullable()
11065
+ });
11066
+ const PerCandidateSchema = object({
11067
+ /** Override the default display name for this candidate's parent. */
11068
+ name: string().min(1).optional(),
11069
+ /** Pre-hide a subset of entity-children — created (state still flows)
11070
+ * but listed in the parent's `accessories.hiddenChildIds`. */
11071
+ hiddenChildIds: array(string()).optional()
11072
+ });
11073
+ const AdoptInputSchema = object({
11074
+ integrationId: string(),
11075
+ /**
11076
+ * Candidate native ids to adopt. Their MEANING is filter-relative: under the
11077
+ * default `'devices'` granularity these are device-native ids; under a
11078
+ * provider-declared granularity (e.g. HA `'entities'`) they are that
11079
+ * granularity's native ids (e.g. entity ids). The field name is kept stable
11080
+ * to avoid a breaking rename.
11081
+ */
11082
+ childNativeIds: array(string()).min(1),
11083
+ /**
11084
+ * Optional provider-declared discovery GRANULARITY id (opaque; see
11085
+ * `AdoptionFilterSchema`). Omitted = the reserved `'devices'` granularity =
11086
+ * exactly the pre-existing behavior (fully back-compatible).
11087
+ */
11088
+ filter: string().optional(),
11089
+ /** When true, import each adopted device's source-system location (e.g. HA
11090
+ * area) into CamStack — fuzzy-match an existing location or create it, then
11091
+ * assign. Omitted/false = no location work (back-compat). */
11092
+ importLocations: boolean().optional(),
11093
+ perCandidate: record(string(), PerCandidateSchema).optional()
11094
+ });
11095
+ const AdoptResultSchema = object({
11096
+ adopted: array(
11097
+ object({
11098
+ childNativeId: string(),
11099
+ parentDeviceId: number().int().nonnegative(),
11100
+ accessoryDeviceIds: array(number().int().nonnegative()).readonly()
11101
+ })
11102
+ ).readonly()
11103
+ });
11104
+ const ReleaseInputSchema = object({
11105
+ integrationId: string(),
11106
+ /** Parent CamStack device id (NOT an accessory child id). Removing
11107
+ * the parent cascades into every accessory. */
11108
+ camDeviceId: number().int().nonnegative()
11109
+ });
11110
+ const ResyncInputSchema = object({
11111
+ /** Parent CamStack device id of an adopted device. The provider resolves its
11112
+ * source (integration/broker + native id) and re-aligns the device's
11113
+ * structural spec (type/role/capabilities/units) with the live mapping,
11114
+ * rebuilding any child whose class changed while preserving operator edits. */
11115
+ camDeviceId: number().int().nonnegative()
11116
+ });
11117
+ const ResyncResultSchema = object({
11118
+ /** True when the persisted spec actually changed (children may have been rebuilt). */
11119
+ changed: boolean(),
11120
+ /** Number of child devices rebuilt into a new class by this re-sync. */
11121
+ rebuiltChildren: number().int().nonnegative()
11122
+ });
11123
+ ({
11124
+ methods: {
11125
+ listCandidateFilters: method(
11126
+ object({ integrationId: string() }),
11127
+ object({ filters: array(AdoptionFilterSchema) }),
11128
+ { auth: "admin" }
11129
+ ),
11130
+ listCandidates: method(ListCandidatesInputSchema, ListCandidatesOutputSchema, { auth: "admin" }),
11131
+ getCandidate: method(GetCandidateInputSchema, DiscoveredChildDeviceSchema.nullable(), { auth: "admin" }),
11132
+ refresh: method(object({ integrationId: string() }), AdoptionStatusSchema, { kind: "mutation", auth: "admin" }),
11133
+ adopt: method(AdoptInputSchema, AdoptResultSchema, { kind: "mutation", auth: "admin" }),
11134
+ release: method(ReleaseInputSchema, _void(), { kind: "mutation", auth: "admin" }),
11135
+ resync: method(ResyncInputSchema, ResyncResultSchema, { kind: "mutation", auth: "admin" })
11136
+ }
11137
+ });
11138
+ const BrokerStatusEnum = _enum([
11139
+ "connected",
11140
+ "disconnected",
11141
+ "connecting",
11142
+ "auth-failed",
11143
+ "unreachable",
11144
+ "error"
11145
+ ]);
11146
+ const BrokerInfoSchema$1 = object({
11147
+ /** Stable broker id. Persisted; survives addon restarts. */
11148
+ id: string(),
11149
+ /** Addon id of the provider that OWNS this broker.
11150
+ *
11151
+ * The `broker` cap is a system-scoped collection: several addons
11152
+ * register a `broker` provider, each owning a DISJOINT set of
11153
+ * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
11154
+ * `ha_*`). A broker therefore belongs to exactly one addon — like a
11155
+ * device belongs to one integration. The admin UI threads this id
11156
+ * back as the `{ addonId }` system-collection selector on every
11157
+ * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
11158
+ * `testConnection`), so the call routes to the OWNING provider
11159
+ * instead of defaulting to the first-registered one. */
11160
+ addonId: string(),
11161
+ /** Human-readable name (operator-chosen at add-time). */
11162
+ name: string(),
11163
+ /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
11164
+ kind: string(),
11165
+ status: BrokerStatusEnum,
11166
+ /** Free-form provider-specific info (HA version, MQTT broker
11167
+ * flavour, latency, mTLS active, …). */
11168
+ info: record(string(), unknown()),
11169
+ /** Ms epoch of the last connection probe / status update. */
11170
+ lastCheckedAt: number().nullable(),
11171
+ /** Last error message, when `status` indicates failure. */
11172
+ error: string().nullable()
11173
+ });
11174
+ const RegistryStatusSchema = object({
11175
+ brokerCount: number().int().nonnegative(),
11176
+ connectedCount: number().int().nonnegative()
11177
+ });
11178
+ const BrokerProviderInfoSchema = object({
11179
+ /** Addon id of the `broker` provider this entry describes. */
11180
+ addonId: string(),
11181
+ /** Broker kinds this provider can create, with a display label. */
11182
+ kinds: array(object({ kind: string(), label: string() }))
11183
+ });
11184
+ const ListInputSchema = object({
11185
+ /** Optional kind filter — `list({kind:'home-assistant'})` returns
11186
+ * only HA brokers. Omit to list every broker. */
11187
+ kind: string().optional()
11188
+ });
11189
+ const GetInputSchema = object({ id: string() });
11190
+ const AddInputSchema = object({
11191
+ kind: string().min(1),
11192
+ name: string().min(1),
11193
+ /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
11194
+ * `{baseUrl,accessToken}`). Validated by the kind-specific provider
11195
+ * branch on receipt — invalid shape rejects the add. */
11196
+ settings: record(string(), unknown())
11197
+ });
11198
+ const AddResultSchema = object({ id: string() });
11199
+ const RemoveInputSchema = object({ id: string() });
11200
+ const TestConnectionResultSchema$1 = discriminatedUnion("ok", [
11201
+ object({ ok: literal(true), latencyMs: number().nonnegative() }),
11202
+ object({ ok: literal(false), error: string() })
11203
+ ]);
11204
+ const SettingsRecordSchema = record(string(), unknown());
11205
+ const SettingsSchemaInputSchema = object({ kind: string() });
11206
+ const TestSettingsInputSchema = object({
11207
+ kind: string(),
11208
+ settings: SettingsRecordSchema
11209
+ });
11210
+ const TestSettingsResultSchema = discriminatedUnion("ok", [
11211
+ // `.strict()` on the success branch so a stray `error` field is rejected,
11212
+ // not silently stripped — a success result must never carry an error.
11213
+ object({ ok: literal(true), latencyMs: number().nonnegative().optional() }).strict(),
11214
+ object({ ok: literal(false), error: string() })
11215
+ ]);
11216
+ const SettingsSchemaResultSchema = unknown().nullable();
11217
+ const PublishInputSchema = object({
11218
+ brokerId: string(),
11219
+ /** Kind-specific routing target.
11220
+ * MQTT: `{ topic: string, qos?: 0|1|2, retain?: boolean }`.
11221
+ * HA: `{ domain: string, service: string, entityId?: string, area?: string }`. */
11222
+ target: record(string(), unknown()),
11223
+ /** Kind-specific payload.
11224
+ * MQTT: raw string / number / object (provider serialises).
11225
+ * HA: service-call `data` object (transition, brightness, …). */
11226
+ payload: unknown().optional()
11227
+ });
11228
+ const SubscribeInputSchema = object({
11229
+ brokerId: string(),
11230
+ /** Kind-specific filter.
11231
+ * MQTT: `{ topic: string, qos?: 0|1|2 }` — topic can include wildcards.
11232
+ * HA: `{ entityIds: string[] }` or `{ domain: string }`. */
11233
+ filter: record(string(), unknown())
11234
+ });
11235
+ const SubscribeResultSchema = object({
11236
+ /** Stable subscription id. Used to unsubscribe and to filter the
11237
+ * matching `broker.message` events on the bus. */
11238
+ subscriptionId: string()
11239
+ });
11240
+ const UnsubscribeInputSchema = object({
11241
+ brokerId: string(),
11242
+ subscriptionId: string()
11243
+ });
11244
+ const GetStateInputSchema = object({
11245
+ brokerId: string(),
11246
+ /** Kind-specific lookup key.
11247
+ * MQTT: topic string (returns the last retained message).
11248
+ * HA: entity_id (returns the cached entity state). */
11249
+ key: string()
11250
+ });
11251
+ ({
11252
+ methods: {
11253
+ // ── Registry CRUD ────────────────────────────────────────────────
11254
+ list: method(ListInputSchema, array(BrokerInfoSchema$1)),
11255
+ get: method(GetInputSchema, BrokerInfoSchema$1.nullable()),
11256
+ /** Enumerate which addon provides which broker kind(s) for the
11257
+ * unified create picker. The auto-mount fans this array across
11258
+ * every registered `broker` provider (array-output method), so the
11259
+ * picker sees every kind from every provider in one call. */
11260
+ listProviders: method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }),
11261
+ add: method(AddInputSchema, AddResultSchema, { kind: "mutation", auth: "admin" }),
11262
+ remove: method(RemoveInputSchema, _void(), { kind: "mutation", auth: "admin" }),
11263
+ testConnection: method(GetInputSchema, TestConnectionResultSchema$1, { kind: "mutation", auth: "admin" }),
11264
+ // ── Settings ─────────────────────────────────────────────────────
11265
+ /** Read the persisted settings record for a broker (kind-specific
11266
+ * shape). Admin-only — settings may contain secrets. Returns `null`
11267
+ * when the broker id is unknown to the provider (the collection
11268
+ * fallback may route a foreign id to the first provider). */
11269
+ getSettings: method(GetInputSchema, SettingsRecordSchema.nullable(), { auth: "admin" }),
11270
+ /** Overwrite the persisted settings record. The kind-specific
11271
+ * provider validates the shape and applies the change (reconnects
11272
+ * if credentials changed). */
11273
+ setSettings: method(
11274
+ object({ id: string(), settings: SettingsRecordSchema }),
11275
+ _void(),
11276
+ { kind: "mutation", auth: "admin" }
11277
+ ),
11278
+ // ── Connection-config (for consumers that open their own client) ─
11279
+ /** Returns the kind-specific connection config the consumer needs
11280
+ * to open its own client (MQTT pattern: `{url, username, password,
11281
+ * clientIdPrefix}`). HA providers MAY return the auth envelope
11282
+ * but typical HA consumers use `publish` / `subscribe` instead.
11283
+ * Returns `null` when the broker id is unknown to the provider. */
11284
+ getBrokerConfig: method(GetInputSchema, SettingsRecordSchema.nullable(), { auth: "admin" }),
11285
+ // ── Per-kind creation schema + pre-creation test ─────────────────
11286
+ getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
11287
+ testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, { kind: "mutation", auth: "admin" }),
11288
+ // ── Pub/sub primitives ───────────────────────────────────────────
11289
+ publish: method(PublishInputSchema, unknown(), { kind: "mutation", auth: "admin" }),
11290
+ subscribe: method(SubscribeInputSchema, SubscribeResultSchema, { kind: "mutation", auth: "admin" }),
11291
+ unsubscribe: method(UnsubscribeInputSchema, _void(), { kind: "mutation", auth: "admin" }),
11292
+ /** Read the broker's cached state for a key. Returns `null` when
11293
+ * unknown to the broker (never published / unknown entity). */
11294
+ getState: method(GetStateInputSchema, unknown().nullable()),
11295
+ /** Status method — explicit registration with a `z.void()` input so
11296
+ * the codegen-generated tRPC router types its input as
11297
+ * `{addonId?: string, nodeId?: string}` (system-scoped collection
11298
+ * shape) instead of the device-scoped `{deviceId}` fallback. */
11299
+ getStatus: method(_void(), RegistryStatusSchema)
11300
+ }
11301
+ });
9007
11302
  const BrokerKindSchema = _enum(["external", "embedded"]);
9008
11303
  const BrokerStatusSchema = _enum([
9009
11304
  "connected",
@@ -9517,7 +11812,15 @@ const WebrtcStreamChoiceSchema = object({
9517
11812
  * direct (host/srflx) path. Clients MUST NOT send this — the
9518
11813
  * server overwrites it from the request context.
9519
11814
  */
9520
- relayOnly: boolean().optional()
11815
+ relayOnly: boolean().optional(),
11816
+ /**
11817
+ * Subscriber attribution surfaced in the stream-broker widget's
11818
+ * client list. Callers (Alexa addon, browser hooks, WHEP server)
11819
+ * populate `kind` and any of `label`/`userId`/`sessionId`/
11820
+ * `remoteAddr` they know. The hub layer enriches `remoteAddr`
11821
+ * from the tRPC request context.
11822
+ */
11823
+ consumerAttribution: BrokerConsumerAttributionSchema.optional()
9521
11824
  }),
9522
11825
  object({ sessionId: string(), sdpOffer: string() }),
9523
11826
  { kind: "mutation" }
@@ -9555,7 +11858,62 @@ const WebrtcStreamChoiceSchema = object({
9555
11858
  * Untrusted browser clients MUST NOT send it — the hub overwrites
9556
11859
  * it from the request context.
9557
11860
  */
9558
- relayOnly: boolean().optional()
11861
+ relayOnly: boolean().optional(),
11862
+ /**
11863
+ * Force a NON-TRICKLE answer: the server awaits ICE gathering to
11864
+ * COMPLETE and returns a full-candidate answer SDP (with `a=candidate`
11865
+ * lines) instead of the default bare/trickle answer.
11866
+ *
11867
+ * Set `true` by callers that DON'T support trickle ICE — notably
11868
+ * Alexa's RTCSessionController, which never polls `getIceCandidates`
11869
+ * nor trickles back via `addIceCandidate`, so without server
11870
+ * candidates in the answer SDP its ICE agent stalls (no nominated
11871
+ * pair → DTLS never connects → Echo shows black → SessionDisconnected).
11872
+ *
11873
+ * Browser viewers leave it absent/false: they poll `getIceCandidates`
11874
+ * and trickle, so the bare answer lets media start in ~0s instead of
11875
+ * blocking on full gathering.
11876
+ */
11877
+ nonTrickle: boolean().optional(),
11878
+ /**
11879
+ * Suppress TURN servers from the per-session iceConfig: the answer
11880
+ * will carry ONLY host + srflx candidates, never a relay one.
11881
+ *
11882
+ * Set `true` by peers that are themselves directly reachable on the
11883
+ * public internet (Alexa's RTCSessionController media gateway is
11884
+ * AWS-hosted with public host candidates). Including a TURN relay
11885
+ * for such a peer is counterproductive: (a) gathering blocks on the
11886
+ * TURN allocation round-trip and slows the answer; (b) werift
11887
+ * spends ICE-checking budget on a relay-pair that the peer cannot
11888
+ * use, sometimes stalling the host/srflx pair validation enough
11889
+ * that Echo's agent times out before nominating one — visible as
11890
+ * 'ICE checking → DTLS never connected'. Browser viewers leave it
11891
+ * absent/false so the relay candidate remains as a fallback for
11892
+ * symmetric-NAT clients.
11893
+ *
11894
+ * Independent from `relayOnly`. `relayOnly: true` forces ICE to a
11895
+ * relay-only pair set; `disableTurn: true` strips the relay
11896
+ * candidate entirely. They are mutually exclusive in practice but
11897
+ * not policed at the schema level — callers MUST NOT set both.
11898
+ */
11899
+ disableTurn: boolean().optional(),
11900
+ /**
11901
+ * Suppress IPv6 host candidates from gathering and from
11902
+ * `additionalHostAddresses`. The answer SDP will contain only IPv4
11903
+ * host/srflx (and TURN, unless `disableTurn` is also set).
11904
+ *
11905
+ * Set `true` by peers that document IPv4-only ICE — Amazon's
11906
+ * `Alexa.RTCSessionController` spec states: "For ICE candidates,
11907
+ * you can use either UDP or TCP but you must use IPv4." Echo's
11908
+ * media gateway ignores IPv6 candidates and they consume budget
11909
+ * against the gateway's documented 6-second SDP processing limit.
11910
+ *
11911
+ * Browser viewers leave it absent/false so IPv6-only paths
11912
+ * (Tailscale, native dual-stack) remain available.
11913
+ */
11914
+ disableIpv6: boolean().optional(),
11915
+ /** Subscriber attribution. See `createSession` for the contract. */
11916
+ consumerAttribution: BrokerConsumerAttributionSchema.optional()
9559
11917
  }),
9560
11918
  object({ sessionId: string(), sdpAnswer: string() }),
9561
11919
  { kind: "mutation" }
@@ -9574,7 +11932,7 @@ const WebrtcStreamChoiceSchema = object({
9574
11932
  * Lets the client send its SDP offer/answer IMMEDIATELY (before ICE
9575
11933
  * gathering finishes) and deliver candidates as they arrive, so the
9576
11934
  * connection establishes in ~0s instead of waiting for full gathering.
9577
- * The dual of `getIceCandidates`. Mirrors Scrypted's signaling.
11935
+ * The dual of `getIceCandidates` in the trickle-ICE signaling exchange.
9578
11936
  */
9579
11937
  addIceCandidate: method(
9580
11938
  object({
@@ -9624,6 +11982,18 @@ const WebrtcStreamChoiceSchema = object({
9624
11982
  profile: CamProfileSchema
9625
11983
  }),
9626
11984
  boolean()
11985
+ ),
11986
+ /** Poll the live signaling state of a session. `pendingRenegotiation` is
11987
+ * non-null after an adaptive tier switch — the client re-offers on the
11988
+ * same sessionId; `epoch` guards against acting on a stale poll twice. */
11989
+ getSessionState: method(
11990
+ object({
11991
+ deviceId: number().int().nonnegative(),
11992
+ sessionId: string()
11993
+ }),
11994
+ object({
11995
+ pendingRenegotiation: object({ target: WebrtcStreamTargetSchema, epoch: number() }).nullable()
11996
+ })
9627
11997
  )
9628
11998
  }
9629
11999
  });
@@ -10236,6 +12606,26 @@ const embeddingEncoderCapability = {
10236
12606
  getInfo: method(_void(), EmbeddingInfoSchema)
10237
12607
  }
10238
12608
  };
12609
+ const ChildLayoutEntrySchema = object({
12610
+ childKey: string(),
12611
+ section: string(),
12612
+ order: number().optional(),
12613
+ // Section-level default-collapsed hint: when true the accordion section this
12614
+ // entry belongs to renders closed by default. The section is treated as
12615
+ // collapsed if ANY of its entries sets collapsed:true; consistent values
12616
+ // across a section's entries are recommended but not enforced. Absent ⇒ open.
12617
+ collapsed: boolean().optional()
12618
+ });
12619
+ const DeviceLinkSchema = object({
12620
+ id: string(),
12621
+ source: object({ sourceKey: string(), cap: string(), fieldPath: string() }),
12622
+ target: object({ cap: string(), fieldPath: string(), itemKey: string().optional() }),
12623
+ transform: discriminatedUnion("kind", [
12624
+ object({ kind: literal("identity") }),
12625
+ object({ kind: literal("enum-map"), mapping: record(string(), union([string(), number(), boolean()])), fallback: union([string(), number(), boolean()]).optional() }),
12626
+ object({ kind: literal("linear"), scale: number(), offset: number(), clamp: tuple([number(), number()]).readonly().optional() })
12627
+ ]).optional()
12628
+ });
10239
12629
  const DeviceInfoSchema = object({
10240
12630
  /** Progressive, system-wide unique number. Allocated synchronously by
10241
12631
  * `device-manager.allocateDeviceId` BEFORE the owning `IDevice` is
@@ -10255,6 +12645,12 @@ const DeviceInfoSchema = object({
10255
12645
  /** Optional semantic role — `DeviceRole` string. null for top-level devices. */
10256
12646
  role: string().nullable().optional(),
10257
12647
  online: boolean(),
12648
+ /** True when the device's initial feature-probe has completed (or it has
12649
+ * no probe) — exported shape is stable; exporters gate advertise on this.
12650
+ * Optional for deploy-order resilience: a record produced by an older
12651
+ * device-manager build omits it, and consumers treat absent as "not ready"
12652
+ * (export gate carries forward, never advertises a partial shape). */
12653
+ probed: boolean().optional(),
10258
12654
  features: array(string()),
10259
12655
  /** true when the device has a getStreamSources() method (ICameraDevice) */
10260
12656
  isCamera: boolean(),
@@ -10262,7 +12658,25 @@ const DeviceInfoSchema = object({
10262
12658
  config: record(string(), unknown()),
10263
12659
  /** Hardware + identity blob (manufacturer / model / firmware / sn /
10264
12660
  * uid / mac / …). Populated by drivers; editable via setMetadata. */
10265
- metadata: record(string(), unknown()).nullable().optional()
12661
+ metadata: record(string(), unknown()).nullable().optional(),
12662
+ /** Optional upstream-system identity + rendering envelope. See
12663
+ * `SourceInfo` in `packages/types/src/device/source-info.ts`. */
12664
+ sourceInfo: SourceInfoSchema.optional(),
12665
+ /** Owning integration id, stamped at create — used by the core to
12666
+ * cascade-delete an integration's devices. Optional: only present
12667
+ * when the device was created through an integration (e.g. HA). */
12668
+ integrationId: string().optional(),
12669
+ linkDeviceId: number().nullable().optional(),
12670
+ /** Durable primary-child override for a CONTAINER device, keyed on the
12671
+ * chosen child's re-sync/rename-stable `entityId`. Survives a re-sync
12672
+ * that reallocates the child's numeric id. `null` clears the override. */
12673
+ primaryChildEntityId: string().nullable().optional(),
12674
+ /** Container-level layout hint: assigns specific child/accessory devices to
12675
+ * named accordion sections (with optional intra-section order). See
12676
+ * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12677
+ childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12678
+ /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12679
+ deviceLinks: array(DeviceLinkSchema).readonly().optional()
10266
12680
  });
10267
12681
  const ConfigEntrySchema$1 = object({
10268
12682
  key: string(),
@@ -10293,7 +12707,32 @@ const DeviceMetaSchema = object({
10293
12707
  location: string().nullable(),
10294
12708
  disabled: boolean(),
10295
12709
  parentDeviceId: number().nullable(),
10296
- metadata: record(string(), unknown()).nullable().optional()
12710
+ metadata: record(string(), unknown()).nullable().optional(),
12711
+ /** Optional upstream-system identity + rendering envelope. See
12712
+ * `SourceInfo`. Present when the device has been persisted with a
12713
+ * non-synthetic source identifier (HA entities, vendor MAC, …);
12714
+ * omitted when the synthetic `{ id: stableId, system: addonId }`
12715
+ * fallback is in effect. Persisted under
12716
+ * `DeviceMeta.metadata.sourceInfo` — the field on this schema is
12717
+ * the typed projection callers consume directly. */
12718
+ sourceInfo: SourceInfoSchema.optional(),
12719
+ /** Owning integration id, stamped at create — used by the core to
12720
+ * cascade-delete an integration's devices. Optional: only present
12721
+ * when the device was created through an integration (e.g. HA). */
12722
+ integrationId: string().optional(),
12723
+ linkDeviceId: number().nullable().optional(),
12724
+ /** Durable primary-child override for a CONTAINER device, keyed on the
12725
+ * chosen child's re-sync/rename-stable `entityId`. See `DeviceMeta`. */
12726
+ primaryChildEntityId: string().nullable().optional(),
12727
+ /** Container-level layout hint: assigns child/accessory devices to named
12728
+ * accordion sections (with optional intra-section order). See
12729
+ * `DeviceMeta.childLayout`. Absent ⇒ no layout declared. */
12730
+ childLayout: array(ChildLayoutEntrySchema).readonly().optional(),
12731
+ /** Operator-authored cross-device field wirings. See `DeviceMeta.deviceLinks`. */
12732
+ deviceLinks: array(DeviceLinkSchema).readonly().optional(),
12733
+ /** Semantic role string (`DeviceRole`) — propagated from the spawn pre-seed.
12734
+ * Optional: only present for accessory children that carry a known role. */
12735
+ role: string().nullable().optional()
10297
12736
  });
10298
12737
  const ConfigUISchemaOutput = unknown().nullable();
10299
12738
  const StreamProbeResultSchema = object({
@@ -10387,6 +12826,109 @@ const DevicePersistConfigPayloadSchema = object({
10387
12826
  _void(),
10388
12827
  { kind: "mutation", auth: "admin" }
10389
12828
  ),
12829
+ /** Update the device type. Writes the meta row, emits a
12830
+ * `DeviceMetaChanged` event. Used by the kernel to apply
12831
+ * `initialMeta.type` before device construction so the device
12832
+ * constructs with its real type instead of the allocateDeviceId
12833
+ * placeholder `'generic'`. */
12834
+ setType: method(
12835
+ object({ deviceId: number(), type: _enum(DeviceType) }),
12836
+ _void(),
12837
+ { kind: "mutation", auth: "admin" }
12838
+ ),
12839
+ /** Stamp (or update) the owning integration id on the device's meta
12840
+ * row. Called by the kernel's `create()` pre-seed path when
12841
+ * `initialMeta.integrationId` is set (analogous to `setName` /
12842
+ * `setType`). Idempotent. */
12843
+ setIntegrationId: method(
12844
+ object({ deviceId: number(), integrationId: string() }),
12845
+ _void(),
12846
+ { kind: "mutation", auth: "admin" }
12847
+ ),
12848
+ /** Stamp (or update) the soft-link device id on the device's meta
12849
+ * row. Called by the kernel `create()` pre-seed when
12850
+ * `initialMeta.linkDeviceId !== undefined`. Idempotent. */
12851
+ setLinkDeviceId: method(
12852
+ object({ deviceId: number(), linkDeviceId: number().nullable() }),
12853
+ _void(),
12854
+ { kind: "mutation", auth: "admin" }
12855
+ ),
12856
+ /** Set (or clear) the durable primary-child override on a CONTAINER
12857
+ * device's meta row, keyed on the chosen child's re-sync/rename-stable
12858
+ * `entityId`. Unlike `setLinkDeviceId` (numeric child id, goes stale on
12859
+ * re-sync), this survives a re-sync that reallocates the child's numeric
12860
+ * id. `null` clears the override → priority default. Idempotent. */
12861
+ setPrimaryChildEntityId: method(
12862
+ object({ deviceId: number(), primaryChildEntityId: string().nullable() }),
12863
+ _void(),
12864
+ { kind: "mutation", auth: "admin" }
12865
+ ),
12866
+ /** Set (or replace) the container-level `childLayout` on a device's meta
12867
+ * row — assigns specific child/accessory devices to named accordion
12868
+ * sections (with optional intra-section order). Mirrors
12869
+ * `setPrimaryChildEntityId`; the layout is parent-only. Persisted,
12870
+ * projected, and preserved across re-register/restore. Idempotent. */
12871
+ setChildLayout: method(
12872
+ object({ deviceId: number(), childLayout: array(ChildLayoutEntrySchema).readonly() }),
12873
+ _void(),
12874
+ { kind: "mutation", auth: "admin" }
12875
+ ),
12876
+ /** Set (or replace) the cross-device field wirings on a device's meta row.
12877
+ * Mirrors `setChildLayout`; persisted, projected, preserved across
12878
+ * re-register/restore. Idempotent. */
12879
+ setDeviceLinks: method(
12880
+ object({ deviceId: number(), deviceLinks: array(DeviceLinkSchema).readonly() }),
12881
+ _void(),
12882
+ { kind: "mutation", auth: "admin" }
12883
+ ),
12884
+ /** List the wireable status-schema fields per cap bound to a device.
12885
+ * Powers the Wiring tab's field pickers. Caps without a status schema are omitted. */
12886
+ getWireableFields: method(
12887
+ object({ deviceId: number() }),
12888
+ object({
12889
+ caps: array(object({
12890
+ cap: string(),
12891
+ fields: array(object({
12892
+ path: string(),
12893
+ kind: _enum(["string", "number", "boolean", "enum"]),
12894
+ enumValues: array(string()).optional()
12895
+ })).readonly()
12896
+ })).readonly()
12897
+ }),
12898
+ { kind: "query" }
12899
+ ),
12900
+ /** Stamp (or update) the semantic role on the device's meta row.
12901
+ * Called by the kernel's `create()` / `spawnAccessoryChild` pre-seed
12902
+ * path when `initialMeta.role` is set (analogous to `setIntegrationId`).
12903
+ * Idempotent. `null` explicitly clears a previous role. */
12904
+ setRole: method(
12905
+ object({ deviceId: number(), role: string().nullable() }),
12906
+ _void(),
12907
+ { kind: "mutation", auth: "admin" }
12908
+ ),
12909
+ /** Batched meta pre-seed — applies every provided field to the device's
12910
+ * meta row in ONE settings round-trip (one lock acquisition, one
12911
+ * `deviceMeta` write) and emits one `DeviceMetaChanged` event per field
12912
+ * that was supplied. Each field carries the SAME semantics as its
12913
+ * individual setter (`setName` / `setLocation` / `setType` /
12914
+ * `setIntegrationId` / `setLinkDeviceId` / `setRole`) — omitted fields are
12915
+ * left untouched; `null` clears `location` / `linkDeviceId` / `role`.
12916
+ * Idempotent. Used by the kernel's `spawnAccessoryChild` flow to collapse
12917
+ * the per-child meta pre-seed (4-6 individual setter calls) into one,
12918
+ * which is the dominant cost when reconciling a many-entity container. */
12919
+ applyInitialMeta: method(
12920
+ object({
12921
+ deviceId: number(),
12922
+ name: string().optional(),
12923
+ location: string().nullable().optional(),
12924
+ type: _enum(DeviceType).optional(),
12925
+ integrationId: string().optional(),
12926
+ linkDeviceId: number().nullable().optional(),
12927
+ role: string().nullable().optional()
12928
+ }),
12929
+ _void(),
12930
+ { kind: "mutation", auth: "admin" }
12931
+ ),
10390
12932
  /** Patch the device's hardware-identity metadata blob. Merges
10391
12933
  * `patch` over the current blob (shallow). Value `null` for a
10392
12934
  * given key removes that key. Drivers populate factual fields
@@ -10500,6 +13042,15 @@ const DevicePersistConfigPayloadSchema = object({
10500
13042
  object({ success: literal(true) }),
10501
13043
  { kind: "mutation", auth: "admin" }
10502
13044
  ),
13045
+ /** Cascade-delete every top-level device whose `integrationId` matches.
13046
+ * Children cascade automatically via the per-parent remove path.
13047
+ * Idempotent: a device whose `integrationId` was never set never matches.
13048
+ * Returns the count of top-level parents actually removed. */
13049
+ removeByIntegration: method(
13050
+ object({ integrationId: string() }),
13051
+ object({ removed: number() }),
13052
+ { kind: "mutation", auth: "admin" }
13053
+ ),
10503
13054
  // ── Stream profile map ────────────────────────────────────────────────────
10504
13055
  /** Get quality→streamId mapping for a camera. Derived from profileHint if not explicitly set. */
10505
13056
  getStreamProfileMap: method(
@@ -10643,6 +13194,15 @@ const DevicePersistConfigPayloadSchema = object({
10643
13194
  live: SettingsSchemaWithValuesSchema.nullable()
10644
13195
  })
10645
13196
  ),
13197
+ runDeviceAction: method(
13198
+ object({
13199
+ deviceId: number().int().nonnegative(),
13200
+ action: string().min(1),
13201
+ input: unknown()
13202
+ }),
13203
+ unknown(),
13204
+ { kind: "mutation" }
13205
+ ),
10646
13206
  updateDeviceField: method(
10647
13207
  object({
10648
13208
  deviceId: number(),
@@ -10697,7 +13257,11 @@ const DevicePersistConfigPayloadSchema = object({
10697
13257
  adoptDevice: method(
10698
13258
  object({
10699
13259
  addonId: string(),
10700
- candidate: DiscoveryCandidateSchema
13260
+ candidate: DiscoveryCandidateSchema,
13261
+ /** Owning integration id, stamped onto the new device's meta by the
13262
+ * device-manager forwarder so `removeByIntegration` can cascade it.
13263
+ * Optional for back-compat (omitted = no stamp = pre-existing behavior). */
13264
+ integrationId: string().optional()
10701
13265
  }),
10702
13266
  DeviceSummarySchema,
10703
13267
  { kind: "mutation", auth: "admin" }
@@ -10718,7 +13282,11 @@ const DevicePersistConfigPayloadSchema = object({
10718
13282
  object({
10719
13283
  addonId: string(),
10720
13284
  type: _enum(DeviceType),
10721
- config: record(string(), unknown())
13285
+ config: record(string(), unknown()),
13286
+ /** Owning integration id, stamped onto the new device's meta by the
13287
+ * device-manager forwarder so `removeByIntegration` can cascade it.
13288
+ * Optional for back-compat (omitted = no stamp = pre-existing behavior). */
13289
+ integrationId: string().optional()
10722
13290
  }),
10723
13291
  DeviceSummarySchema,
10724
13292
  { kind: "mutation", auth: "admin" }
@@ -10742,6 +13310,41 @@ const DevicePersistConfigPayloadSchema = object({
10742
13310
  FieldProbeResultSchema,
10743
13311
  { kind: "mutation", auth: "admin" }
10744
13312
  ),
13313
+ // ── Device-adoption operations (routed via CapabilityRegistry) ────────────
13314
+ //
13315
+ // These methods proxy to the `device-adoption` capability for an
13316
+ // integration-style addon (Home Assistant, …), routing by addonId —
13317
+ // the same hub-side proxy pattern as `discoverDevices`/`adoptDevice`
13318
+ // above. The admin UI's generic adopt modal calls them through this
13319
+ // singleton so it never needs a direct handle on the addon's
13320
+ // `device-adoption` provider.
13321
+ // getCandidate is not proxied — the adopt modal expands rows from
13322
+ // listCandidates' nested `children`, so a single-candidate fetch is
13323
+ // never needed at this layer.
13324
+ /** List adoption candidates for an integration via its device-adoption provider. */
13325
+ adoptionListCandidates: method(
13326
+ ListCandidatesInputSchema.extend({ addonId: string() }),
13327
+ ListCandidatesOutputSchema,
13328
+ { auth: "admin" }
13329
+ ),
13330
+ /** Trigger a discovery refresh on the integration's device-adoption provider. */
13331
+ adoptionRefresh: method(
13332
+ object({ addonId: string(), integrationId: string() }),
13333
+ AdoptionStatusSchema,
13334
+ { kind: "mutation", auth: "admin" }
13335
+ ),
13336
+ /** Adopt one or more discovered candidates via the device-adoption provider. */
13337
+ adoptionAdopt: method(
13338
+ AdoptInputSchema.extend({ addonId: string() }),
13339
+ AdoptResultSchema,
13340
+ { kind: "mutation", auth: "admin" }
13341
+ ),
13342
+ /** Release an adopted parent device via the device-adoption provider. */
13343
+ adoptionRelease: method(
13344
+ ReleaseInputSchema.extend({ addonId: string() }),
13345
+ _void(),
13346
+ { kind: "mutation", auth: "admin" }
13347
+ ),
10745
13348
  /**
10746
13349
  * Test a field value on an existing device (e.g. probe an RTSP URL).
10747
13350
  * Routes through the device-provider for the owning addon.
@@ -11029,7 +13632,11 @@ const NotificationRuleConditionsSchema = object({
11029
13632
  endHour: number()
11030
13633
  }).optional(),
11031
13634
  cooldownSeconds: number().optional(),
11032
- minDwellSeconds: number().optional()
13635
+ minDwellSeconds: number().optional(),
13636
+ /** Match against `event.data.eventType` token (e.g. `'press_long'`). When non-empty, only events
13637
+ * carrying a matching `data.eventType` string pass this condition. Rules without this field are
13638
+ * unaffected (back-compat). Distinct from `rule.eventTypes` which holds EventCategory strings. */
13639
+ eventTypeTokens: array(string()).readonly().optional()
11033
13640
  });
11034
13641
  const NotificationRuleTemplateSchema = object({
11035
13642
  title: string(),
@@ -11098,192 +13705,6 @@ const NotificationHistoryFilterSchema = object({
11098
13705
  )
11099
13706
  }
11100
13707
  });
11101
- const RecordingModeSchema = _enum(["continuous", "motion", "scheduled", "composite"]);
11102
- const StreamPolicySchema = object({
11103
- streamId: string(),
11104
- mode: _enum(["always", "inherit"])
11105
- });
11106
- const ScheduleRuleSchema = object({
11107
- days: array(number()).readonly(),
11108
- startTime: string(),
11109
- endTime: string(),
11110
- mode: _enum(["continuous", "motion"])
11111
- });
11112
- const RecordingPolicySchema = object({
11113
- deviceId: number(),
11114
- mode: RecordingModeSchema,
11115
- streams: array(StreamPolicySchema).readonly(),
11116
- enabled: boolean(),
11117
- preBufferSec: number(),
11118
- postBufferSec: number(),
11119
- scheduleRules: array(ScheduleRuleSchema).readonly().optional()
11120
- });
11121
- const DataCategorySchema = _enum([
11122
- "recording:main",
11123
- "recording:mid",
11124
- "recording:sub",
11125
- "thumbnail:scrub",
11126
- "thumbnail:event"
11127
- ]);
11128
- const RecordingStorageConfigSchema = object({
11129
- deviceId: number(),
11130
- dataCategory: DataCategorySchema,
11131
- storageName: string(),
11132
- subDirectory: string(),
11133
- retentionDays: number().nullable(),
11134
- retentionGb: number().nullable()
11135
- });
11136
- const RecordingSegmentSchema = object({
11137
- id: string(),
11138
- deviceId: number(),
11139
- streamId: string(),
11140
- startTime: number(),
11141
- endTime: number(),
11142
- duration: number(),
11143
- path: string(),
11144
- storageName: string(),
11145
- subDirectory: string(),
11146
- sizeBytes: number(),
11147
- codec: _enum(["h264", "h265"]),
11148
- hasAudio: boolean()
11149
- });
11150
- const RecordingThumbnailSchema = object({
11151
- deviceId: number(),
11152
- timestamp: number(),
11153
- path: string(),
11154
- storageName: string(),
11155
- subDirectory: string(),
11156
- sizeBytes: number(),
11157
- category: _enum(["scrub", "event"])
11158
- });
11159
- const AvailabilityRangeSchema = object({
11160
- startTime: number(),
11161
- endTime: number(),
11162
- streams: array(string()).readonly()
11163
- });
11164
- const StorageUsageSchema = object({
11165
- totalBytes: number(),
11166
- segmentCount: number()
11167
- });
11168
- const StreamEstimateSchema = object({
11169
- bitrateKbps: number(),
11170
- retentionDays: number().nullable(),
11171
- retentionGb: number().nullable(),
11172
- estimatedGb: number(),
11173
- estimatedDaysAtCapacity: number().nullable()
11174
- });
11175
- const StorageEstimateSchema = object({
11176
- perStream: record(string(), StreamEstimateSchema),
11177
- thumbnails: object({ estimatedGb: number() }),
11178
- totalEstimatedGb: number(),
11179
- motionEstimate: object({
11180
- avgEventsPerDay: number(),
11181
- avgDurationSec: number(),
11182
- dutyCyclePercent: number()
11183
- }).optional()
11184
- });
11185
- const MotionStatsSchema = object({
11186
- totalEvents: number(),
11187
- avgDurationSec: number(),
11188
- avgEventsPerDay: number(),
11189
- dutyCyclePercent: number()
11190
- });
11191
- const DeviceIdInput = object({ deviceId: number() });
11192
- const EnableInput = object({
11193
- deviceId: number(),
11194
- policy: RecordingPolicySchema.omit({ deviceId: true }),
11195
- storageOverrides: array(RecordingStorageConfigSchema.omit({ deviceId: true })).readonly().optional(),
11196
- ffmpegOverrides: record(string(), unknown()).optional()
11197
- });
11198
- const TimeRangeInput = object({
11199
- deviceId: number(),
11200
- startTime: number(),
11201
- endTime: number()
11202
- });
11203
- const StreamTimeRangeInput = object({
11204
- deviceId: number(),
11205
- streamId: string(),
11206
- startTime: number(),
11207
- endTime: number()
11208
- });
11209
- const PlaylistInput = object({
11210
- deviceId: number(),
11211
- streamId: string(),
11212
- startTime: number(),
11213
- endTime: number(),
11214
- live: boolean().optional()
11215
- });
11216
- const ThumbnailInput = object({
11217
- deviceId: number(),
11218
- timestamp: number(),
11219
- category: string().optional()
11220
- });
11221
- const DeviceStreamInput = object({
11222
- deviceId: number(),
11223
- streamId: string()
11224
- });
11225
- const StorageEstimateInput = object({
11226
- deviceId: number(),
11227
- motionInput: object({
11228
- avgEventsPerDay: number(),
11229
- avgDurationSec: number()
11230
- }).optional()
11231
- });
11232
- const RetentionConfigInput = object({
11233
- deviceId: number(),
11234
- dataCategory: DataCategorySchema
11235
- });
11236
- const SetPolicyInput = object({
11237
- deviceId: number(),
11238
- policy: RecordingPolicySchema.omit({ deviceId: true })
11239
- });
11240
- const UpdateConfigInput = object({
11241
- deviceId: number(),
11242
- policy: RecordingPolicySchema.omit({ deviceId: true }),
11243
- ffmpegOverrides: record(string(), unknown()).optional()
11244
- });
11245
- const recordingEngineCapability = {
11246
- name: "recording-engine",
11247
- scope: "system",
11248
- mode: "singleton",
11249
- methods: {
11250
- // ── Status ────────────────────────────────────────────────────────
11251
- getStatus: method(_void(), object({
11252
- activeRecordings: number(),
11253
- totalSegments: number(),
11254
- totalSizeMB: number()
11255
- })),
11256
- // ── Lifecycle ─────────────────────────────────────────────────────
11257
- enable: method(EnableInput, _void(), { kind: "mutation", auth: "admin" }),
11258
- disable: method(DeviceIdInput, _void(), { kind: "mutation", auth: "admin" }),
11259
- // ── Config ────────────────────────────────────────────────────────
11260
- getConfig: method(DeviceIdInput, RecordingPolicySchema.nullable()),
11261
- updateConfig: method(UpdateConfigInput, _void(), { kind: "mutation", auth: "admin" }),
11262
- // ── Playback ──────────────────────────────────────────────────────
11263
- getPlaylist: method(PlaylistInput, string()),
11264
- getThumbnail: method(ThumbnailInput, RecordingThumbnailSchema.nullable()),
11265
- getSegments: method(StreamTimeRangeInput, array(RecordingSegmentSchema).readonly()),
11266
- getAvailability: method(TimeRangeInput, array(AvailabilityRangeSchema).readonly()),
11267
- // ── Storage ───────────────────────────────────────────────────────
11268
- estimateStorage: method(StorageEstimateInput, StorageEstimateSchema),
11269
- estimateGlobalStorage: method(_void(), StorageEstimateSchema),
11270
- getStorageUsage: method(DeviceStreamInput, StorageUsageSchema),
11271
- // ── Policy ────────────────────────────────────────────────────────
11272
- setPolicy: method(SetPolicyInput, _void(), { kind: "mutation", auth: "admin" }),
11273
- getPolicy: method(DeviceIdInput, RecordingPolicySchema.nullable()),
11274
- getPolicyStatus: method(DeviceIdInput, object({
11275
- deviceId: number(),
11276
- enabled: boolean(),
11277
- mode: RecordingModeSchema,
11278
- activeStreams: number()
11279
- }).nullable()),
11280
- // ── Retention ─────────────────────────────────────────────────────
11281
- getRetentionConfig: method(RetentionConfigInput, RecordingStorageConfigSchema.nullable()),
11282
- updateRetentionConfig: method(RecordingStorageConfigSchema, _void(), { kind: "mutation", auth: "admin" }),
11283
- // ── Motion ────────────────────────────────────────────────────────
11284
- getMotionStats: method(TimeRangeInput, MotionStatsSchema)
11285
- }
11286
- };
11287
13708
  ({
11288
13709
  deviceTypes: [DeviceType.Camera]
11289
13710
  });
@@ -11332,26 +13753,38 @@ const MotionEventSchema = object({
11332
13753
  ...BaseEventFields,
11333
13754
  kind: literal("motion"),
11334
13755
  regionCount: number(),
13756
+ /** Heavy JSON array — omitted in slim projection. */
11335
13757
  regions: array(object({
11336
13758
  bbox: BoundingBoxSchema,
11337
13759
  pixelCount: number(),
11338
13760
  intensity: number()
11339
- })).readonly(),
11340
- frameWidth: number(),
11341
- frameHeight: number()
13761
+ })).readonly().optional(),
13762
+ /** Omitted in slim projection. */
13763
+ frameWidth: number().optional(),
13764
+ /** Omitted in slim projection. */
13765
+ frameHeight: number().optional(),
13766
+ /** Populated by B5 (recording playback URL for this event). */
13767
+ mediaUrl: string().optional()
11342
13768
  });
11343
13769
  const ObjectEventSchema = object({
11344
13770
  ...BaseEventFields,
11345
13771
  kind: literal("object"),
11346
- trackId: string(),
13772
+ /** Omitted in slim projection. */
13773
+ trackId: string().optional(),
11347
13774
  className: string(),
11348
13775
  label: string().optional(),
11349
- confidence: number(),
11350
- bbox: BoundingBoxSchema,
11351
- zones: array(string()).readonly(),
11352
- state: TrackStateSchema,
13776
+ /** Omitted in slim projection. */
13777
+ confidence: number().optional(),
13778
+ /** Heavy JSON — omitted in slim projection. */
13779
+ bbox: BoundingBoxSchema.optional(),
13780
+ /** Heavy JSON — omitted in slim projection. */
13781
+ zones: array(string()).readonly().optional(),
13782
+ /** Omitted in slim projection. */
13783
+ state: TrackStateSchema.optional(),
11353
13784
  /** MediaStore key for the crop attached to this event (if any). */
11354
- mediaKey: string().optional()
13785
+ mediaKey: string().optional(),
13786
+ /** Populated by B5 (recording playback URL for this event). */
13787
+ mediaUrl: string().optional()
11355
13788
  });
11356
13789
  const AudioEventSchema = object({
11357
13790
  ...BaseEventFields,
@@ -11362,7 +13795,9 @@ const AudioEventSchema = object({
11362
13795
  className: string(),
11363
13796
  originalClass: string().optional(),
11364
13797
  score: number()
11365
- }).optional()
13798
+ }).optional(),
13799
+ /** Populated by B5 (recording playback URL for this event). */
13800
+ mediaUrl: string().optional()
11366
13801
  });
11367
13802
  const MediaFileSchema = object({
11368
13803
  key: string(),
@@ -11377,7 +13812,12 @@ const DeviceEventQueryInput = object({
11377
13812
  deviceId: number(),
11378
13813
  since: number().optional(),
11379
13814
  until: number().optional(),
11380
- limit: number().int().min(1).max(MAX_EVENT_QUERY_LIMIT).default(DEFAULT_EVENT_QUERY_LIMIT)
13815
+ limit: number().int().min(1).max(MAX_EVENT_QUERY_LIMIT).default(DEFAULT_EVENT_QUERY_LIMIT),
13816
+ /** `slim` drops heavy JSON fields (regions/bbox/zones) and carries an
13817
+ * optional `mediaUrl` (populated by B5). `full` (default) keeps today's
13818
+ * exact behaviour. Callers may omit this field — the store defaults to
13819
+ * `full` when not provided. */
13820
+ projection: _enum(["full", "slim"]).optional()
11381
13821
  });
11382
13822
  const ObjectEventQueryInput = DeviceEventQueryInput.extend({
11383
13823
  classFilter: string().optional()
@@ -11438,6 +13878,37 @@ const pipelineAnalyticsCapability = {
11438
13878
  DeviceEventQueryInput,
11439
13879
  array(AudioEventSchema).readonly()
11440
13880
  ),
13881
+ // ── Density (timeline histogram) ──────────────────────────
13882
+ /** Server-side bucketed event counts for the 24-hour timeline.
13883
+ * Returns one entry per non-empty bucket; empty buckets are omitted. */
13884
+ getEventDensity: method(
13885
+ object({
13886
+ deviceId: number(),
13887
+ since: number(),
13888
+ until: number(),
13889
+ bucketMs: number().int().positive()
13890
+ }),
13891
+ array(object({
13892
+ bucketStart: number(),
13893
+ motion: number().int(),
13894
+ object: number().int(),
13895
+ audio: number().int()
13896
+ })).readonly()
13897
+ ),
13898
+ // ── Maintenance ───────────────────────────────────────────
13899
+ /**
13900
+ * Delete all events (motion + object + audio) for the given device
13901
+ * that are older than `cutoffMs` (exclusive), and delete their
13902
+ * thumbnails in lockstep. Returns per-kind deleted counts.
13903
+ *
13904
+ * Called by Phase B3 recorder orchestration to keep event history
13905
+ * aligned with available footage.
13906
+ */
13907
+ pruneEventsBefore: method(
13908
+ object({ deviceId: number(), cutoffMs: number() }),
13909
+ object({ motion: number().int(), object: number().int(), audio: number().int() }),
13910
+ { kind: "mutation", auth: "admin" }
13911
+ ),
11441
13912
  // ── Media ─────────────────────────────────────────────────
11442
13913
  getEventMedia: method(
11443
13914
  object({ eventId: string() }),
@@ -11791,32 +14262,102 @@ const EventItemSchema = object({
11791
14262
  )
11792
14263
  }
11793
14264
  });
11794
- const SegmentSchema = object({
11795
- id: string(),
11796
- startTs: number(),
11797
- // unix ms
11798
- endTs: number(),
11799
- durationSec: number(),
11800
- sizeBytes: number().optional()
14265
+ const RecordingStatusSchema = object({
14266
+ deviceId: number(),
14267
+ enabled: boolean(),
14268
+ activeMode: _enum(["off", "continuous"]),
14269
+ nodeId: string(),
14270
+ storageBytes: number()
14271
+ });
14272
+ const RecordingRangeSchema = object({
14273
+ profile: string(),
14274
+ startMs: number(),
14275
+ endMs: number()
14276
+ });
14277
+ const RecordingAvailabilitySchema = object({
14278
+ deviceId: number(),
14279
+ ranges: array(RecordingRangeSchema)
14280
+ });
14281
+ const RecordingManifestSchema = object({
14282
+ deviceId: number(),
14283
+ /** Local filesystem path to the master playlist; null when no recording exists for the requested range. */
14284
+ localMasterPath: string().nullable(),
14285
+ /** HTTP(S) URL to the master playlist on the recording node's playback server
14286
+ * (the PRIMARY candidate); null when no recording / server. Carries the
14287
+ * scoped playback token in its path. */
14288
+ playbackUrl: string().nullable(),
14289
+ /**
14290
+ * Candidate master-playlist URLs the client tries in order (LAN first, then
14291
+ * remote — Tailscale/Cloudflare if the operator configured extra hosts), each
14292
+ * carrying the same scoped token. `playbackUrl` is the first entry. Empty when
14293
+ * there is no recording / server.
14294
+ */
14295
+ playbackEndpoints: array(string())
14296
+ });
14297
+ const RecordingDeviceUsageSchema = object({
14298
+ deviceId: number(),
14299
+ usedBytes: number()
14300
+ });
14301
+ const RecordingLocationUsageSchema = object({
14302
+ /** StorageLocation id; null for the legacy/degraded single-root fallback. */
14303
+ locationId: string().nullable(),
14304
+ /** Bytes of recordings stored on this location. */
14305
+ usedBytes: number(),
14306
+ /** Free bytes on the location's volume; null when capacity is unknown (remote). */
14307
+ availableBytes: number().nullable(),
14308
+ /** Total bytes of the location's volume; null when unknown. */
14309
+ totalBytes: number().nullable()
14310
+ });
14311
+ const RecordingStorageUsageSchema = object({
14312
+ nodeId: string(),
14313
+ totalUsedBytes: number(),
14314
+ devices: array(RecordingDeviceUsageSchema),
14315
+ locations: array(RecordingLocationUsageSchema)
11801
14316
  });
11802
14317
  ({
11803
- deviceTypes: [DeviceType.Camera],
11804
14318
  methods: {
11805
- getSegments: method(
11806
- object({
11807
- deviceId: number(),
11808
- from: number().optional(),
11809
- to: number().optional()
11810
- }),
11811
- array(SegmentSchema)
14319
+ getAvailability: method(
14320
+ object({ deviceId: number(), fromMs: number(), toMs: number() }),
14321
+ RecordingAvailabilitySchema,
14322
+ { kind: "query", auth: "admin" }
11812
14323
  ),
11813
- getPlaybackUrl: method(
11814
- object({ deviceId: number(), segmentId: string() }),
11815
- string().nullable()
14324
+ getPlaybackManifest: method(
14325
+ object({ deviceId: number(), fromMs: number(), toMs: number() }),
14326
+ RecordingManifestSchema,
14327
+ { kind: "query", auth: "admin" }
11816
14328
  ),
11817
- getThumbnailAt: method(
11818
- object({ deviceId: number(), timestamp: number() }),
11819
- object({ base64: string(), contentType: string() }).nullable()
14329
+ getStorageUsage: method(
14330
+ object({}),
14331
+ RecordingStorageUsageSchema,
14332
+ { kind: "query", auth: "admin" }
14333
+ ),
14334
+ getDeviceConfig: method(
14335
+ object({ deviceId: number() }),
14336
+ RecordingConfigSchema,
14337
+ { kind: "query", auth: "admin" }
14338
+ ),
14339
+ setDeviceConfig: method(
14340
+ object({ deviceId: number(), config: RecordingConfigSchema }),
14341
+ RecordingConfigSchema,
14342
+ { kind: "mutation", auth: "admin" }
14343
+ ),
14344
+ /** Re-scan this device's footage from disk (stat sizes) and reseed the
14345
+ * index, then return the fresh status. */
14346
+ rescanStorage: method(
14347
+ object({ deviceId: number() }),
14348
+ RecordingStatusSchema,
14349
+ { kind: "mutation", auth: "admin" }
14350
+ ),
14351
+ /** Apply this device's retention policy to footage now; returns the oldest
14352
+ * surviving footage start (the retention floor) or null if no footage. */
14353
+ pruneFootage: method(
14354
+ object({ deviceId: number() }),
14355
+ object({
14356
+ floorMs: number().nullable(),
14357
+ deletedBuckets: number().int(),
14358
+ reclaimedBytes: number().int()
14359
+ }),
14360
+ { kind: "mutation", auth: "admin" }
11820
14361
  )
11821
14362
  }
11822
14363
  });
@@ -11829,13 +14370,19 @@ const StreamSourceEntrySchema = object({
11829
14370
  fps: number().optional(),
11830
14371
  bitrate: number().optional(),
11831
14372
  codec: string().optional(),
11832
- profileHint: _enum(["high", "mid", "low"]).optional(),
14373
+ profileHint: CamProfileSchema.optional(),
11833
14374
  sdp: string().optional()
11834
14375
  });
11835
14376
  const ConfigEntrySchema = object({
11836
14377
  key: string(),
11837
14378
  value: unknown()
11838
14379
  });
14380
+ const RawStateResultSchema = object({
14381
+ /** Originating provider id, e.g. 'homeassistant' | 'reolink' | 'hikvision'. */
14382
+ source: string(),
14383
+ /** Opaque, DISPLAY-SAFE upstream blob (no secrets/PII). */
14384
+ data: record(string(), unknown())
14385
+ });
11839
14386
  ({
11840
14387
  methods: {
11841
14388
  /**
@@ -11867,6 +14414,21 @@ const ConfigEntrySchema = object({
11867
14414
  _void(),
11868
14415
  { kind: "mutation" }
11869
14416
  ),
14417
+ /**
14418
+ * Invoke a device custom action on a forked/remote device (the
14419
+ * cross-process transport for `IDevice.runDeviceAction`). Mirrors
14420
+ * `setConfig` — the device-manager calls this when the device is not
14421
+ * hub-local.
14422
+ */
14423
+ runAction: method(
14424
+ object({
14425
+ deviceId: number(),
14426
+ action: string().min(1),
14427
+ input: unknown()
14428
+ }),
14429
+ unknown(),
14430
+ { kind: "mutation" }
14431
+ ),
11870
14432
  /**
11871
14433
  * Invoke `IDevice.removeDevice()` so the driver can release resources
11872
14434
  * (close sockets, stop background tasks, …). The device-manager still
@@ -11894,6 +14456,18 @@ const ConfigEntrySchema = object({
11894
14456
  getSettingsSchema: method(
11895
14457
  object({ deviceId: number() }),
11896
14458
  unknown().nullable()
14459
+ ),
14460
+ /**
14461
+ * Opt-in: return the device's RAW upstream state (the provider's
14462
+ * cached values) as a display-safe `{ source, data }` blob. Returns
14463
+ * `null` when the device exposes no raw state — the State panel hides
14464
+ * its Raw toggle in that case. One-shot (read the provider's existing
14465
+ * cache; no upstream round-trip).
14466
+ */
14467
+ getRawState: method(
14468
+ object({ deviceId: number() }),
14469
+ RawStateResultSchema.nullable(),
14470
+ { auth: "protected" }
11897
14471
  )
11898
14472
  }
11899
14473
  });
@@ -11951,6 +14525,16 @@ object({
11951
14525
  )
11952
14526
  }
11953
14527
  });
14528
+ ({
14529
+ deviceTypes: [DeviceType.Button],
14530
+ methods: {
14531
+ press: method(
14532
+ object({ deviceId: number().int().nonnegative() }),
14533
+ _void(),
14534
+ { kind: "mutation", auth: "admin" }
14535
+ )
14536
+ }
14537
+ });
11954
14538
  const OsdOverlayKindEnum = _enum(["text", "timestamp", "watermark"]);
11955
14539
  const OsdPositionEnum = _enum([
11956
14540
  "top-left",
@@ -12013,18 +14597,72 @@ const OsdOverlayPatchSchema = object({
12013
14597
  }
12014
14598
  });
12015
14599
  object({
12016
- childDeviceIds: array(number()).readonly()
14600
+ /** All accessory children of the parent. */
14601
+ childDeviceIds: array(number()).readonly(),
14602
+ /** Subset of `childDeviceIds` the operator hid from the UI. The order
14603
+ * is irrelevant; the array is treated as a set by consumers. */
14604
+ hiddenChildIds: array(number()).readonly()
12017
14605
  });
12018
14606
  ({
12019
- deviceTypes: [DeviceType.Camera, DeviceType.Hub],
14607
+ // Cameras + Hubs are the historical consumers; the HA integration
14608
+ // landed in Phase E adds Switch / Light / Sensor / Thermostat / Cover
14609
+ // / Lock / Fan / MediaPlayer / AlarmPanel / Generic as parent types
14610
+ // for adopted HA devices whose entity-children sit under accessories.
14611
+ deviceTypes: [
14612
+ DeviceType.Camera,
14613
+ DeviceType.Hub,
14614
+ DeviceType.Switch,
14615
+ DeviceType.Light,
14616
+ DeviceType.Sensor,
14617
+ DeviceType.Thermostat,
14618
+ DeviceType.Cover,
14619
+ DeviceType.Lock,
14620
+ DeviceType.Fan,
14621
+ DeviceType.MediaPlayer,
14622
+ DeviceType.AlarmPanel,
14623
+ DeviceType.Generic
14624
+ ],
14625
+ methods: {
14626
+ /**
14627
+ * Toggle the UI-visibility of a single accessory child. Hidden
14628
+ * children stay registered and continue to receive state updates;
14629
+ * they're omitted only from the parent's UI accessories panel.
14630
+ *
14631
+ * Idempotent — hiding an already-hidden child or unhiding a
14632
+ * non-hidden child is a no-op. Providers that don't support
14633
+ * per-child visibility may implement this as a no-op (the UI hide
14634
+ * toggle gracefully degrades).
14635
+ */
14636
+ setChildHidden: method(
14637
+ object({
14638
+ deviceId: number().int().nonnegative(),
14639
+ childDeviceId: number().int().nonnegative(),
14640
+ hidden: boolean()
14641
+ }),
14642
+ _void(),
14643
+ { kind: "mutation", auth: "admin" }
14644
+ )
14645
+ },
12020
14646
  events: {
12021
14647
  /**
12022
14648
  * Emitted when a child device is created, removed, or its parent
12023
- * assignment changes. Payload carries the fresh list.
14649
+ * assignment changes. Payload carries the fresh list and the
14650
+ * hidden subset.
12024
14651
  */
12025
14652
  onAccessoriesChanged: { data: object({
12026
14653
  deviceId: number(),
12027
- childDeviceIds: array(number()).readonly()
14654
+ childDeviceIds: array(number()).readonly(),
14655
+ hiddenChildIds: array(number()).readonly()
14656
+ }) },
14657
+ /**
14658
+ * Emitted when the operator toggles a child's UI-visibility.
14659
+ * Subscribers (admin UI accessories panel) re-render off this
14660
+ * signal without re-fetching the full status block.
14661
+ */
14662
+ onChildVisibilityChanged: { data: object({
14663
+ deviceId: number(),
14664
+ childDeviceId: number(),
14665
+ hidden: boolean()
12028
14666
  }) }
12029
14667
  }
12030
14668
  });
@@ -12045,6 +14683,12 @@ const IntercomStatusSchema = object({
12045
14683
  /** Firmware ability cached at first session creation. Null until probed. */
12046
14684
  ability: IntercomAbilitySchema.nullable()
12047
14685
  });
14686
+ const TalkAudioCodecSchema = _enum([
14687
+ "opus",
14688
+ "s16le",
14689
+ "g711ulaw",
14690
+ "g711alaw"
14691
+ ]);
12048
14692
  ({
12049
14693
  deviceTypes: [DeviceType.Camera],
12050
14694
  methods: {
@@ -12089,20 +14733,35 @@ const IntercomStatusSchema = object({
12089
14733
  { kind: "mutation", auth: "admin" }
12090
14734
  ),
12091
14735
  /**
12092
- * Push a chunk of PCM s16le mono onto the active raw-PCM talk
12093
- * session. Frames are encoded to the firmware's expected codec
12094
- * (Reolink: IMA ADPCM @ camera sample rate; Hikvision: G.711 @
12095
- * 8 kHz) inside the provider callers must resample upstream to
12096
- * the rate the camera negotiated (typical: 16 kHz Reolink,
12097
- * 8 kHz Hikvision). PCM is shipped base64-encoded so the payload
12098
- * survives JSON serialization across tRPC.
14736
+ * Push one chunk of talk-back audio onto the active talk session.
14737
+ * The cap is codec-agnostic: the caller declares (or omits) the
14738
+ * wire format via `codec`; the provider decides between passthrough
14739
+ * (when the wire codec matches the camera's native talk channel),
14740
+ * transcoding via the `audio-codec` cap, or rejecting the call.
14741
+ *
14742
+ * Callers do NOT need to know the camera's wire format or sample
14743
+ * rate — that information lives entirely inside the provider.
14744
+ *
14745
+ * Sequence numbers MUST be monotonic per talk session; older frames
14746
+ * arriving after newer ones are dropped to avoid smearing the
14747
+ * downstream encoder state (G.711 is stateless but IMA ADPCM's
14748
+ * predictor would corrupt with re-ordering).
12099
14749
  */
12100
- pushTalkPcm: method(
14750
+ pushTalkAudio: method(
12101
14751
  object({
12102
14752
  deviceId: number(),
12103
- /** PCM frames as little-endian s16, mono. Base64-encoded so
12104
- * the payload survives tRPC JSON serialization. */
12105
- pcmBase64: string(),
14753
+ /** Audio bytes for ONE frame, base64-encoded so the payload
14754
+ * survives tRPC JSON serialization. */
14755
+ audioBase64: string(),
14756
+ /** Wire codec of the payload. Omit to let the provider default
14757
+ * to its native expected format (s16le @ provider-native rate,
14758
+ * mono). See {@link TalkAudioCodecSchema} for the supported set. */
14759
+ codec: TalkAudioCodecSchema.optional(),
14760
+ /** Sample rate (Hz). REQUIRED for `s16le`; advisory for
14761
+ * `opus` (encoder clock); ignored for `g711*` (implied 8000). */
14762
+ sampleRate: number().int().positive().optional(),
14763
+ /** Channel count. Default 1. */
14764
+ channels: number().int().positive().optional(),
12106
14765
  /** Sequence number for ordering / dropping out-of-order frames. */
12107
14766
  sequenceNumber: number().int()
12108
14767
  }),
@@ -12196,6 +14855,10 @@ const HardwareEncodersSchema = object({
12196
14855
  defaultH265: HardwareEncoderIdSchema,
12197
14856
  probedAt: number()
12198
14857
  });
14858
+ const HardwareDecodeAccelsSchema = object({
14859
+ methods: array(string()).readonly(),
14860
+ probedAt: number()
14861
+ });
12199
14862
  const HardwarePlatformSchema = _enum(["darwin", "linux", "win32"]);
12200
14863
  const HardwareArchSchema = _enum(["arm64", "x64"]);
12201
14864
  const GpuInfoSchema = object({
@@ -12264,6 +14927,16 @@ const ResolvedInferenceConfigSchema = object({
12264
14927
  _void(),
12265
14928
  HardwareEncodersSchema,
12266
14929
  { kind: "mutation", auth: "admin" }
14930
+ ),
14931
+ /**
14932
+ * Decode-side hw-accel probe — the `-hwaccel` methods the configured ffmpeg
14933
+ * binary supports (via `ffmpeg -hwaccels`). Cached after first call.
14934
+ */
14935
+ getHardwareDecodeAccels: method(_void(), HardwareDecodeAccelsSchema),
14936
+ refreshHardwareDecodeAccels: method(
14937
+ _void(),
14938
+ HardwareDecodeAccelsSchema,
14939
+ { kind: "mutation", auth: "admin" }
12267
14940
  )
12268
14941
  }
12269
14942
  });
@@ -12953,6 +15626,7 @@ const ClientNetworkStatsSchema = object({
12953
15626
  rttMs: number(),
12954
15627
  jitterMs: number(),
12955
15628
  estimatedBandwidthKbps: number(),
15629
+ packetLossPercent: number().min(0).max(100),
12956
15630
  lastUpdated: number()
12957
15631
  });
12958
15632
  const DeviceNetworkStatsSchema = object({
@@ -12975,7 +15649,8 @@ const DeviceNetworkStatsSchema = object({
12975
15649
  deviceId: number(),
12976
15650
  rttMs: number().min(0).max(6e4),
12977
15651
  jitterMs: number().min(0).max(1e4),
12978
- estimatedBandwidthKbps: number().min(0).max(1e6)
15652
+ estimatedBandwidthKbps: number().min(0).max(1e6),
15653
+ packetLossPercent: number().min(0).max(100)
12979
15654
  }),
12980
15655
  _void(),
12981
15656
  { kind: "mutation" }
@@ -13200,6 +15875,24 @@ const AvailableIntegrationTypeSchema = object({
13200
15875
  color: string(),
13201
15876
  instanceMode: string(),
13202
15877
  discoveryMode: string(),
15878
+ /**
15879
+ * Which integration-marker cap the addon declared, so the wizard can
15880
+ * branch on CAP — never on addon name. `device-adoption` integrations
15881
+ * (Home Assistant, …) route through the broker step (Approach A);
15882
+ * `device-provider` integrations (Reolink/Frigate/ONVIF) keep the
15883
+ * legacy config → discovery flow.
15884
+ */
15885
+ kind: _enum(["device-adoption", "device-provider"]),
15886
+ /**
15887
+ * For `device-adoption` addons: the broker kind to create/link
15888
+ * (e.g. `home-assistant`), declared in the addon manifest. `null` for
15889
+ * `device-provider` addons, which carry no broker.
15890
+ */
15891
+ brokerKind: string().nullable(),
15892
+ /** True when the integration's source system exposes locations the adoption
15893
+ * flow can import (e.g. HA areas). Drives the adopt modal's "import
15894
+ * locations" checkbox. Provider-declared in the addon manifest. */
15895
+ supportsLocationImport: boolean(),
13203
15896
  existingInstances: array(object({ id: string(), name: string() })),
13204
15897
  canAdd: boolean()
13205
15898
  });
@@ -13806,10 +16499,15 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13806
16499
  EventCategory2["DeviceSettingsUpdated"] = "device.settings-updated";
13807
16500
  EventCategory2["DeviceBindingsChanged"] = "device.bindings-changed";
13808
16501
  EventCategory2["DeviceMetaChanged"] = "device.meta-changed";
16502
+ EventCategory2["DeviceSourceInfoChanged"] = "device.source-info-changed";
13809
16503
  EventCategory2["DeviceStreamsRegistered"] = "device.streams-registered";
16504
+ EventCategory2["DeviceProvisioned"] = "device.provisioned";
16505
+ EventCategory2["DeviceReady"] = "device.ready";
13810
16506
  EventCategory2["IntegrationEnabled"] = "integration.enabled";
13811
16507
  EventCategory2["IntegrationDisabled"] = "integration.disabled";
13812
16508
  EventCategory2["IntegrationDeleted"] = "integration.deleted";
16509
+ EventCategory2["BrokerStatusChanged"] = "broker.status-changed";
16510
+ EventCategory2["BrokerMessage"] = "broker.message";
13813
16511
  EventCategory2["ProviderStarted"] = "provider.started";
13814
16512
  EventCategory2["ProviderStopped"] = "provider.stopped";
13815
16513
  EventCategory2["ProcessCrashed"] = "process.crashed";
@@ -13843,7 +16541,9 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13843
16541
  EventCategory2["StreamParamsChanged"] = "stream-params.changed";
13844
16542
  EventCategory2["DeviceStateChanged"] = "device.state-changed";
13845
16543
  EventCategory2["BatteryOnStatusChanged"] = "battery.onStatusChanged";
16544
+ EventCategory2["BatteryOnWakeStarted"] = "battery.onWakeStarted";
13846
16545
  EventCategory2["DoorbellOnPressed"] = "doorbell.onPressed";
16546
+ EventCategory2["EventEmitted"] = "event-emitter.event";
13847
16547
  EventCategory2["PipelineEngineMetricsSnapshot"] = "pipeline.engine-metrics-snapshot";
13848
16548
  EventCategory2["ClusterTopologySnapshot"] = "cluster.topology-snapshot";
13849
16549
  EventCategory2["MetricsNodeResourcesSnapshot"] = "metrics.node-resources-snapshot";
@@ -13926,6 +16626,27 @@ function emitReadiness(bus, params) {
13926
16626
  }
13927
16627
  ));
13928
16628
  }
16629
+ function createDurableState(deps) {
16630
+ const get = async () => {
16631
+ const store = await deps.read();
16632
+ const raw = store[deps.key];
16633
+ if (raw === void 0) return deps.fallback;
16634
+ const parsed = deps.schema.safeParse(raw);
16635
+ if (!parsed.success) {
16636
+ deps.onParseError?.(deps.key, parsed.error);
16637
+ return deps.fallback;
16638
+ }
16639
+ return parsed.data;
16640
+ };
16641
+ const set = async (next) => {
16642
+ const validated = deps.schema.parse(next);
16643
+ await deps.write({ [deps.key]: validated });
16644
+ };
16645
+ const update = async (fn2) => {
16646
+ await set(fn2(await get()));
16647
+ };
16648
+ return { get, set, update };
16649
+ }
13929
16650
  class BaseAddon {
13930
16651
  _ctx = null;
13931
16652
  _config;
@@ -14169,12 +16890,18 @@ class BaseAddon {
14169
16890
  // ── Lifecycle event emission ────────────────────────────────────────────
14170
16891
  emitLifecycle(category, data) {
14171
16892
  try {
14172
- this._ctx?.eventBus.emit({
14173
- id: `${this._ctx.id}-${Date.now()}`,
16893
+ const ctx = this._ctx;
16894
+ if (!ctx) return;
16895
+ ctx.eventBus.emit({
16896
+ id: `${ctx.id}-${Date.now()}`,
14174
16897
  timestamp: /* @__PURE__ */ new Date(),
14175
- source: { type: "addon", id: this._ctx.id, nodeId: this._ctx.kernel.localNodeId ?? "hub" },
16898
+ source: { type: "addon", id: ctx.id, nodeId: ctx.kernel.localNodeId ?? "hub" },
14176
16899
  category,
14177
- data: data ?? {}
16900
+ // Always carry `addonId` in the payload (the lifecycle event
16901
+ // contract declares it) so consumers — e.g. the alerts builder —
16902
+ // can name the addon without reaching into `source`. An explicit
16903
+ // `data.addonId` still wins if a caller provides one.
16904
+ data: { addonId: ctx.id, ...data ?? {} }
14178
16905
  });
14179
16906
  } catch {
14180
16907
  }
@@ -14247,6 +16974,44 @@ class BaseAddon {
14247
16974
  }
14248
16975
  this._config = resolved;
14249
16976
  }
16977
+ /**
16978
+ * Typed durable handle over ONE key of this addon's store. The whole
16979
+ * Zod-validated value round-trips on every read/write — no hand-listed
16980
+ * fields, so a field can never be silently dropped on persist. Reads use
16981
+ * the same retry budget as config resolution; a corrupt/legacy blob logs
16982
+ * a warning and falls back rather than crashing boot.
16983
+ */
16984
+ state(key, schema, fallback) {
16985
+ return createDurableState({
16986
+ key,
16987
+ schema,
16988
+ fallback,
16989
+ read: () => this.readAddonStoreWithRetry(),
16990
+ write: async (patch) => {
16991
+ await this._ctx?.settings?.writeAddonStore(patch);
16992
+ },
16993
+ onParseError: (k, e) => this._ctx?.logger?.warn?.(
16994
+ `durable-state: stored "${k}" failed validation — using fallback`,
16995
+ { meta: { addonId: this._ctx?.id, key: k, error: String(e) } }
16996
+ )
16997
+ });
16998
+ }
16999
+ /** Per-device variant of {@link state}, backed by the per-device store. */
17000
+ deviceState(deviceId, key, schema, fallback) {
17001
+ return createDurableState({
17002
+ key,
17003
+ schema,
17004
+ fallback,
17005
+ read: async () => await this._ctx?.settings?.readDeviceStore(deviceId) ?? {},
17006
+ write: async (patch) => {
17007
+ await this._ctx?.settings?.writeDeviceStore(deviceId, patch);
17008
+ },
17009
+ onParseError: (k, e) => this._ctx?.logger?.warn?.(
17010
+ `durable-state: stored device ${deviceId} "${k}" failed validation — using fallback`,
17011
+ { meta: { addonId: this._ctx?.id, deviceId, key: k, error: String(e) } }
17012
+ )
17013
+ });
17014
+ }
14250
17015
  /**
14251
17016
  * Wrap `ctx.settings.readAddonStore()` with a short retry budget so a
14252
17017
  * transient settings-store outage (mid-restart of sqlite-settings, tsx-watch
@@ -14327,18 +17092,17 @@ export {
14327
17092
  boolean as b,
14328
17093
  createEvent as c,
14329
17094
  asJsonObject as d,
14330
- errMsg as e,
14331
- BACKEND_TO_FORMAT as f,
14332
- embeddingEncoderCapability as g,
17095
+ BACKEND_TO_FORMAT as e,
17096
+ embeddingEncoderCapability as f,
17097
+ errMsg as g,
14333
17098
  audioMetricsCapability as h,
14334
17099
  addonWidgetsSourceCapability as i,
14335
17100
  hydrateSchema as j,
14336
17101
  number as n,
14337
17102
  object as o,
14338
17103
  pipelineAnalyticsCapability as p,
14339
- recordingEngineCapability as r,
14340
17104
  string as s,
14341
17105
  tuple as t,
14342
17106
  zoneAnalyticsCapability as z
14343
17107
  };
14344
- //# sourceMappingURL=index-BrTlzsrE.mjs.map
17108
+ //# sourceMappingURL=index-ot5PeFg_.mjs.map