@camstack/addon-advanced-notifier 0.1.32 → 0.1.33

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