@camstack/addon-provider-reolink 1.2.18 → 1.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +1972 -967
  2. package/dist/addon.mjs +1976 -971
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -1,6 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_chunk = require("./chunk-Cek0wNdY.js");
3
3
  const require_chunk_MZUSWKF3 = require("./chunk-MZUSWKF3-CwIAby8V.js");
4
+ let node_crypto = require("node:crypto");
4
5
  let events = require("events");
5
6
  let fs = require("fs");
6
7
  fs = require_chunk.__toESM(fs, 1);
@@ -9,7 +10,6 @@ path = require_chunk.__toESM(path, 1);
9
10
  let crypto$1 = require("crypto");
10
11
  crypto$1 = require_chunk.__toESM(crypto$1, 1);
11
12
  let child_process = require("child_process");
12
- let node_crypto = require("node:crypto");
13
13
  let stream = require("stream");
14
14
  let util = require("util");
15
15
  let dgram = require("dgram");
@@ -6999,6 +6999,14 @@ var EncodeProfileSchema = object({
6999
6999
  "main",
7000
7000
  "high"
7001
7001
  ]).optional(),
7002
+ /**
7003
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7004
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7005
+ * it, or it ships a stream that does not match its own advertisement — the
7006
+ * defect class that kept HomeKit black for a year and that Alexa carried
7007
+ * silently. Optional because a browser negotiates the level itself.
7008
+ */
7009
+ level: string().optional(),
7002
7010
  width: number().int().positive().optional(),
7003
7011
  height: number().int().positive().optional(),
7004
7012
  fps: number().positive().optional(),
@@ -7046,6 +7054,29 @@ var EncodeProfileSchema = object({
7046
7054
  outputArgs: array(string()).optional()
7047
7055
  });
7048
7056
  /**
7057
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7058
+ * Baseline because it is the one profile every consumer in this repo decodes
7059
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7060
+ */
7061
+ var BASE_LIVE_EGRESS_PROFILE = {
7062
+ video: {
7063
+ codec: "h264",
7064
+ profile: "baseline",
7065
+ level: "3.1",
7066
+ width: 1280,
7067
+ height: 720,
7068
+ fps: 25,
7069
+ bitrateKbps: 2500,
7070
+ gopFrames: 25,
7071
+ bf: 0,
7072
+ preset: "veryfast",
7073
+ tune: "zerolatency"
7074
+ },
7075
+ audio: "passthrough"
7076
+ };
7077
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7078
+ ({ ...BASE_LIVE_EGRESS_PROFILE });
7079
+ /**
7049
7080
  * Deep wiring healthcheck — snapshot of active reachability probes across
7050
7081
  * every declared capability + widget of every installed plugin, on every
7051
7082
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7095,6 +7126,154 @@ object({
7095
7126
  })
7096
7127
  });
7097
7128
  /**
7129
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7130
+ * pipeline functions an operator thinks in terms of.
7131
+ *
7132
+ * ## This file adds no state
7133
+ *
7134
+ * Every switch here is a VIEW onto an authority that already existed
7135
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7136
+ * group is that there is exactly one place each function is turned off, and
7137
+ * the group routes to it:
7138
+ *
7139
+ * | Switch | Authority | Proven "off stops the work" gate |
7140
+ * | --- | --- | --- |
7141
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7142
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7143
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7144
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7145
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7146
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7147
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7148
+ *
7149
+ * ## The two switches whose authority is not on this server
7150
+ *
7151
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7152
+ * in "the group stores nothing" — it is the purest form of it: the camera
7153
+ * holds the fact, every read is a read-through, and there is no server-side
7154
+ * copy that could drift. Their availability therefore cannot come from
7155
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7156
+ * is filtered out there); it comes from the cap's own camera-probed
7157
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7158
+ * THIS camera rather than for the device type
7159
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7160
+ *
7161
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7162
+ *
7163
+ * Every other switch means *this camera's function is doing its job*, so
7164
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7165
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7166
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7167
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7168
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7169
+ * worse than either surface not having one.
7170
+ *
7171
+ * Two consequences follow and both are load-bearing:
7172
+ *
7173
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7174
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7175
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7176
+ * normal, healthy state of most cameras rendered as an operator disablement.
7177
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7178
+ * unconditionally by both clients, so for this row it has to read correctly
7179
+ * whichever way the switch is sitting.
7180
+ *
7181
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7182
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7183
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7184
+ * surface that decision never got.
7185
+ *
7186
+ * ## Two rules that are load-bearing
7187
+ *
7188
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7189
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7190
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7191
+ * turning the camera back on would then silently record nothing.
7192
+ * - **A switch that is off must be reported as off**, not merely produce
7193
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7194
+ * "disabled by an operator" instead of "broken" — see
7195
+ * `CameraStatus.switchedOff`.
7196
+ */
7197
+ /**
7198
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7199
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7200
+ * recognition and plate/LPR are per-step toggles on
7201
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7202
+ * editor, not in a safety group.
7203
+ */
7204
+ var CameraSwitchIdSchema = _enum([
7205
+ "stream-broker",
7206
+ "object-detection",
7207
+ "privacy-mask",
7208
+ "device-audio",
7209
+ "audio-analysis",
7210
+ "recording",
7211
+ "notifications"
7212
+ ]);
7213
+ /**
7214
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7215
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7216
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7217
+ * a compile error rather than a review comment.
7218
+ */
7219
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7220
+ object({ kind: literal("device-disabled") }),
7221
+ object({
7222
+ kind: literal("wrapper-binding"),
7223
+ capName: string()
7224
+ }),
7225
+ object({ kind: literal("recording-config") }),
7226
+ object({ kind: literal("notification-mute") }),
7227
+ object({
7228
+ kind: literal("camera-audio"),
7229
+ capName: string()
7230
+ }),
7231
+ object({
7232
+ kind: literal("camera-mask"),
7233
+ capName: string()
7234
+ })
7235
+ ]);
7236
+ /**
7237
+ * Why a switch is not offered for this camera. Rendered instead of the
7238
+ * control, never as a dead control — an absent function and a broken one must
7239
+ * not look the same.
7240
+ */
7241
+ var CameraSwitchUnavailableReasonSchema = _enum([
7242
+ "no-provider",
7243
+ "source-unreachable",
7244
+ "not-configured"
7245
+ ]);
7246
+ /**
7247
+ * One switch, resolved for one camera.
7248
+ *
7249
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7250
+ * client-side: the viewer is a separate repository that does not import
7251
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7252
+ * that will disagree with itself. Five rows per camera is nothing.
7253
+ */
7254
+ var CameraSwitchSchema = object({
7255
+ id: CameraSwitchIdSchema,
7256
+ label: string(),
7257
+ /**
7258
+ * What the operator LOSES while this is off, in one sentence. Required, not
7259
+ * optional: a switch that cannot say what it costs should not ship.
7260
+ */
7261
+ costWhenOff: string(),
7262
+ /** False = do not render a control. `unavailableReason` says why. */
7263
+ available: boolean(),
7264
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7265
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7266
+ enabled: boolean(),
7267
+ authority: CameraSwitchAuthoritySchema
7268
+ });
7269
+ /** The whole group for one camera. */
7270
+ var CameraSwitchGroupSchema = object({
7271
+ deviceId: number().int(),
7272
+ switches: array(CameraSwitchSchema).readonly(),
7273
+ /** Unix ms when the group was composed server-side. */
7274
+ fetchedAt: number()
7275
+ });
7276
+ /**
7098
7277
  * Ops-log — the durable, append-only operations audit shared by the
7099
7278
  * recordings and events management surfaces.
7100
7279
  *
@@ -7113,14 +7292,16 @@ var OpsLogOpSchema = _enum([
7113
7292
  "manual-delete",
7114
7293
  "rescan",
7115
7294
  "retention-run",
7116
- "relocate"
7295
+ "relocate",
7296
+ "orphan-audit"
7117
7297
  ]);
7118
7298
  /** Why the operation ran. */
7119
7299
  var OpsLogReasonSchema = _enum([
7120
7300
  "retention",
7121
7301
  "quota",
7122
7302
  "manual",
7123
- "operator"
7303
+ "operator",
7304
+ "maintenance"
7124
7305
  ]);
7125
7306
  /** One audit row, shared verbatim by both domains. */
7126
7307
  var OpsLogEntrySchema = object({
@@ -9156,6 +9337,126 @@ var RtpSourceSchema = object({
9156
9337
  encoder: string(),
9157
9338
  pipelineKey: string()
9158
9339
  });
9340
+ /**
9341
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9342
+ * hatch.** This is deliberate and it is the one lesson taken from
9343
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9344
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9345
+ * adding a flag silently forks the shared child, and two consumers that mean
9346
+ * the same thing but spell it differently never share. Here every knob is a
9347
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9348
+ * never an opaque array.
9349
+ *
9350
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9351
+ * The operator-facing derived-stream transform editor still has them — that is
9352
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9353
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9354
+ */
9355
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9356
+ inputArgs: true,
9357
+ outputArgs: true
9358
+ });
9359
+ /**
9360
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9361
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9362
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9363
+ * an Echo). Named rather than numeric so the INTENT survives.
9364
+ */
9365
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9366
+ var EgressTranscodeRequestSchema = object({
9367
+ deviceId: number().int().nonnegative(),
9368
+ /** Which published stream to read. */
9369
+ source: discriminatedUnion("kind", [object({
9370
+ kind: literal("profile"),
9371
+ profile: CamProfileSchema
9372
+ }), object({
9373
+ kind: literal("cam-stream"),
9374
+ camStreamId: string().min(1)
9375
+ })]),
9376
+ encode: EgressEncodeSchema,
9377
+ rateControl: EgressRateControlSchema.optional(),
9378
+ /**
9379
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9380
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9381
+ * branches. Enumerated, not free text.
9382
+ */
9383
+ bitstreamFilter: _enum([
9384
+ "dump_extra",
9385
+ "h264_mp4toannexb",
9386
+ "hevc_mp4toannexb"
9387
+ ]).optional(),
9388
+ /**
9389
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9390
+ * consumer to dial the returned url. The broker picks the id and returns it
9391
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9392
+ * sharing key is computed FROM this request.
9393
+ *
9394
+ * The url is still returned and still the contract for a transcode pinned to
9395
+ * another node. But dialling it locally costs an RTSP round trip that changes
9396
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9397
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9398
+ * repacketizer branch. The push branch — the one the derived mechanism has
9399
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9400
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9401
+ * other point.
9402
+ *
9403
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9404
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9405
+ * This is that rule for media.
9406
+ */
9407
+ publishLocally: boolean().optional(),
9408
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9409
+ /**
9410
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9411
+ * and the one that matters: the broker then resolves the backend from the
9412
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9413
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9414
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9415
+ */
9416
+ decodeHwAccel: _enum([
9417
+ "auto",
9418
+ "none",
9419
+ "videotoolbox",
9420
+ "vaapi",
9421
+ "qsv",
9422
+ "cuda"
9423
+ ]).optional(),
9424
+ /**
9425
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9426
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9427
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9428
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9429
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9430
+ */
9431
+ hostname: string().optional(),
9432
+ /** Attribution for the broker panel. Never part of the sharing key. */
9433
+ tag: string().optional()
9434
+ });
9435
+ var EgressTranscodeSchema = object({
9436
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9437
+ url: string(),
9438
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9439
+ pipelineKey: string(),
9440
+ videoCodec: _enum(["H264", "H265"]),
9441
+ resolution: object({
9442
+ width: number().int().positive(),
9443
+ height: number().int().positive()
9444
+ }),
9445
+ transcoded: boolean(),
9446
+ encoder: string(),
9447
+ /**
9448
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9449
+ * Returned rather than assumed: a consumer that asked for hardware and got
9450
+ * software needs to be able to see that without reading the broker's logs.
9451
+ */
9452
+ decodeHwAccel: string().nullable(),
9453
+ /**
9454
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9455
+ * `url`, and the session takes the push/deframe transport rather than the
9456
+ * RTP-passthrough one. `null` means the consumer must dial.
9457
+ */
9458
+ camStreamId: string().nullable()
9459
+ });
9159
9460
  method(object({
9160
9461
  deviceId: number().int().nonnegative(),
9161
9462
  camStreamId: string().min(1),
@@ -9265,6 +9566,15 @@ method(object({
9265
9566
  }), {
9266
9567
  kind: "mutation",
9267
9568
  auth: "admin"
9569
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9570
+ kind: "mutation",
9571
+ auth: "admin"
9572
+ }), method(object({ pipelineKey: string() }), object({
9573
+ released: boolean(),
9574
+ refcount: number().int().nonnegative()
9575
+ }), {
9576
+ kind: "mutation",
9577
+ auth: "admin"
9268
9578
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9269
9579
  subscriptionId: string(),
9270
9580
  maxCount: number().int().positive().default(8)
@@ -13881,12 +14191,13 @@ var NcConditionsSchema = object({
13881
14191
  * source; otherwise the subject's source must equal it. Legacy records
13882
14192
  * with no stamped source are treated as `pipeline`. The union spans both
13883
14193
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13884
- * tracks carry `sensor`.
14194
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13885
14195
  */
13886
14196
  source: _enum([
13887
14197
  "pipeline",
13888
14198
  "onboard",
13889
14199
  "sensor",
14200
+ "audio",
13890
14201
  "any"
13891
14202
  ]).optional(),
13892
14203
  /**
@@ -14462,6 +14773,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14462
14773
  }), object({ success: literal(true) }), {
14463
14774
  kind: "mutation",
14464
14775
  auth: "admin"
14776
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14777
+ deviceId: number().int(),
14778
+ muted: boolean()
14779
+ }), object({ success: literal(true) }), {
14780
+ kind: "mutation",
14781
+ auth: "admin"
14465
14782
  }), method(object({
14466
14783
  rule: NcRuleInputSchema,
14467
14784
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14800,12 +15117,60 @@ var TrackAudioLabelSchema = object({
14800
15117
  });
14801
15118
  /**
14802
15119
  * How a track was produced. `pipeline` (default / absent) = the spatial
14803
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14804
- * linked sensor/control state change (no positions; carries a snapshot). The
14805
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14806
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
15120
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
15121
+ * no positions, a single snapshot, and no bbox trajectory at all:
15122
+ *
15123
+ * - `sensor` — a linked sensor/control device state change.
15124
+ * - `audio` — an audio event on the camera itself that was anomalous for
15125
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
15126
+ *
15127
+ * The spatial subsystems (tracker association, occupancy count, re-id /
15128
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
15129
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
15130
+ * check silently readmits every source added after it was written.
14807
15131
  */
14808
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
15132
+ var TrackSourceSchema = _enum([
15133
+ "pipeline",
15134
+ "sensor",
15135
+ "audio"
15136
+ ]);
15137
+ /**
15138
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15139
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15140
+ * so the two surfaces cannot drift.
15141
+ *
15142
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
15143
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
15144
+ * columns existed read as absent, and a consumer that needs a boolean should say
15145
+ * `flag === true`, not `flag !== false`.
15146
+ *
15147
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15148
+ * operator curation, and the behaviour they drive will be specified separately.
15149
+ * In particular a `markForTrain` track is NOT pinned against retention — see
15150
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15151
+ */
15152
+ var TrackFlagFields = {
15153
+ /** Operator marked this track as training material. */
15154
+ markForTrain: boolean().optional(),
15155
+ /** Operator marked this track for diagnostic attention. */
15156
+ debug: boolean().optional()
15157
+ };
15158
+ /**
15159
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15160
+ * one flag can never clear the other — the toggles are independent and are
15161
+ * driven from three surfaces that do not know about each other.
15162
+ */
15163
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
15164
+ /**
15165
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
15166
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
15167
+ * mutation result without a re-fetch.
15168
+ */
15169
+ var TrackFlagsSchema = object({
15170
+ trackId: string(),
15171
+ markForTrain: boolean(),
15172
+ debug: boolean()
15173
+ });
14809
15174
  var TrackSchema = object({
14810
15175
  trackId: string(),
14811
15176
  deviceId: number(),
@@ -14848,7 +15213,8 @@ var TrackSchema = object({
14848
15213
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14849
15214
  * Populated from the persisted envelope columns on historical reads;
14850
15215
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14851
- envelope: TrackEnvelopeSchema.optional()
15216
+ envelope: TrackEnvelopeSchema.optional(),
15217
+ ...TrackFlagFields
14852
15218
  });
14853
15219
  var BaseEventFields = {
14854
15220
  id: string(),
@@ -15061,7 +15427,8 @@ var KeyEventSchema = object({
15061
15427
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15062
15428
  bestEventId: string(),
15063
15429
  /** Track lifetime in ms (lastSeen - firstSeen). */
15064
- windowMs: number().optional()
15430
+ windowMs: number().optional(),
15431
+ ...TrackFlagFields
15065
15432
  });
15066
15433
  object({
15067
15434
  trackId: string(),
@@ -15132,6 +15499,104 @@ var EventPruneCountsSchema = object({
15132
15499
  object: number().int(),
15133
15500
  audio: number().int()
15134
15501
  });
15502
+ /**
15503
+ * Re-embed stored tracks from their key frames.
15504
+ *
15505
+ * The reason this is an operator-callable method and not a migration script:
15506
+ * every knob that decides what a vector MEANS — encoder model, crop margin,
15507
+ * squaring — is only changeable if the existing vectors can be regenerated.
15508
+ * Mixing feature spaces in one index makes cosine scores incomparable, and the
15509
+ * symptom is a quality regression with no visible cause.
15510
+ */
15511
+ var RebuildObjectEmbeddingsInput = object({
15512
+ /** Restrict to one camera. Omit for the whole fleet. */
15513
+ deviceId: number().optional(),
15514
+ since: number().optional(),
15515
+ until: number().optional(),
15516
+ /** Stop after this many tracks; the result reports whether more remain. */
15517
+ maxTracks: number().int().positive().optional(),
15518
+ /**
15519
+ * Run every embedding on THIS node instead of round-robining the fleet.
15520
+ *
15521
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15522
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15523
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15524
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15525
+ * remotely. This field is data; the per-track pin is applied inside.
15526
+ *
15527
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15528
+ * pinned model.
15529
+ */
15530
+ executeOnNodeId: string().optional(),
15531
+ /**
15532
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15533
+ * run flat out.
15534
+ *
15535
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15536
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15537
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15538
+ * force is logged at start and finish so a deliberately slow pass reads
15539
+ * differently from a stalled one.
15540
+ */
15541
+ pacingMs: number().int().nonnegative().optional()
15542
+ });
15543
+ /**
15544
+ * Result of emptying the CLIP index.
15545
+ *
15546
+ * The clean slate before a policy change: a new crop margin or encoder model
15547
+ * leaves two feature spaces in one index whose cosine scores are not
15548
+ * comparable, so wiping and rebuilding is the only way to be sure every vector
15549
+ * means the same thing.
15550
+ */
15551
+ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
15552
+ /**
15553
+ * Acknowledgement that a rebuild STARTED.
15554
+ *
15555
+ * The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
15556
+ * runs detached and this returns immediately. Waiting for it made the client
15557
+ * time out while the work carried on server-side, which is the worst of both:
15558
+ * no result and no way to know it was still going. Poll
15559
+ * `getObjectEmbeddingRebuildStatus` for progress.
15560
+ */
15561
+ var RebuildObjectEmbeddingsResultSchema = object({
15562
+ started: boolean(),
15563
+ /** True when a pass was already running; the new request is ignored. */
15564
+ alreadyRunning: boolean()
15565
+ });
15566
+ var RebuildStatusSchema = object({
15567
+ running: boolean(),
15568
+ scanned: number(),
15569
+ rebuilt: number(),
15570
+ /** Tracks whose key frame is gone — nothing to re-embed from. */
15571
+ missingKeyFrame: number(),
15572
+ /** Tracks with no usable detection box. */
15573
+ missingBbox: number(),
15574
+ /**
15575
+ * Tracks an executing node REFUSED rather than broke on — an unreadable key
15576
+ * frame, a step that threw. Separate from `failed` because the remedy is
15577
+ * different, and because a whole camera silently contributing zero vectors
15578
+ * is the shape of failure a rebuild must never hide.
15579
+ */
15580
+ notRunnable: number(),
15581
+ /**
15582
+ * The pass stopped because NO node could serve the pinned model.
15583
+ *
15584
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15585
+ * refused", this one says "the cluster cannot do this work at all" — every
15586
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15587
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15588
+ * engine change, not a per-camera one. Non-zero here always comes with
15589
+ * `complete: false`.
15590
+ */
15591
+ noCapableNode: number(),
15592
+ failed: number(),
15593
+ /** Set once a pass ends: true only when EVERYTHING was covered. */
15594
+ complete: boolean().nullable(),
15595
+ startedAtMs: number().nullable(),
15596
+ finishedAtMs: number().nullable(),
15597
+ /** Present when the pass ended by throwing. */
15598
+ error: string().nullable()
15599
+ });
15135
15600
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
15136
15601
  deviceId: number(),
15137
15602
  trackId: string()
@@ -15195,7 +15660,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15195
15660
  }), {
15196
15661
  kind: "mutation",
15197
15662
  auth: "admin"
15198
- }), method(object({}), EventStoreFootprintSchema, {
15663
+ }), method(object({
15664
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15665
+ deviceId: number(),
15666
+ trackId: string(),
15667
+ flags: TrackFlagsPatchSchema
15668
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
15199
15669
  kind: "query",
15200
15670
  auth: "admin"
15201
15671
  }), method(object({
@@ -15225,7 +15695,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15225
15695
  }), array(MediaFileSchema).readonly()), method(object({
15226
15696
  trackId: string(),
15227
15697
  kinds: array(MediaFileKindEnum).optional()
15228
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
15698
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
15699
+ kind: "mutation",
15700
+ auth: "admin"
15701
+ }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
15702
+ kind: "mutation",
15703
+ auth: "admin"
15704
+ }), method(object({}), RebuildStatusSchema), object({
15229
15705
  deviceId: number(),
15230
15706
  timestamp: number(),
15231
15707
  frameWidth: number(),
@@ -15832,6 +16308,53 @@ var DetailResultSchema = object({
15832
16308
  nativeFaceShortSidePx: number().optional()
15833
16309
  });
15834
16310
  /**
16311
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
16312
+ *
16313
+ * A refusal is a first-class answer, not an error, because the caller's next
16314
+ * move depends on WHICH one it is — and because "the pass produced nothing"
16315
+ * must never be reachable without a named, counted cause. The two tiers:
16316
+ *
16317
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
16318
+ * never serve this (step, model) pair. The caller drops it from its rotation
16319
+ * and retries the same work elsewhere; nothing about the work changes.
16320
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
16321
+ * fine, this one request is not. Retrying it on another node would only
16322
+ * spread the same failure.
16323
+ */
16324
+ var StatelessStepRefusalSchema = _enum([
16325
+ "unknown-step",
16326
+ "model-not-servable",
16327
+ "unreadable-frame",
16328
+ "execution-failed"
16329
+ ]);
16330
+ /**
16331
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
16332
+ * result, because `null` is exactly what made the camera-bound detail path
16333
+ * unable to tell "refused" from "never asked".
16334
+ */
16335
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
16336
+ kind: literal("ran"),
16337
+ /** The node that actually executed it — the pin, echoed back for the log. */
16338
+ nodeId: string(),
16339
+ /**
16340
+ * The model the step ran with.
16341
+ *
16342
+ * The node verified this exact id has a build for the format it dispatched
16343
+ * on BEFORE running, so the executor's format resolution returns it
16344
+ * unchanged. A caller that pinned a model must compare this field and
16345
+ * treat a mismatch as a refusal — the whole point of the pin is that a
16346
+ * pass writes one feature space.
16347
+ */
16348
+ modelId: string(),
16349
+ details: array(DetailResultSchema)
16350
+ }), object({
16351
+ kind: literal("refused"),
16352
+ nodeId: string(),
16353
+ reason: StatelessStepRefusalSchema,
16354
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
16355
+ detail: string()
16356
+ })]);
16357
+ /**
15835
16358
  * Per-camera tunable ranges + defaults. Single source of truth used
15836
16359
  * by both the Zod data schema (validation + default fallback) and
15837
16360
  * the device settings UI (slider min/max/step). Touch one place and
@@ -16167,10 +16690,46 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
16167
16690
  }), NativeCropResultSchema.nullable()), method(object({
16168
16691
  deviceId: number(),
16169
16692
  frameHandle: FrameHandleSchema.optional(),
16693
+ /**
16694
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
16695
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
16696
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
16697
+ */
16698
+ frameJpeg: string().optional(),
16699
+ /**
16700
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
16701
+ * The fallback when the lease/session backing the frame is gone and the
16702
+ * caller already holds a crop.
16703
+ */
16170
16704
  cropJpeg: string().optional(),
16171
16705
  parent: DetailParentSchema,
16172
16706
  steps: array(string()).optional()
16173
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
16707
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
16708
+ /** Catalog step id, e.g. `clip-embedding`. */
16709
+ stepId: string(),
16710
+ /**
16711
+ * REQUIRED model pin. The node runs this exact model or refuses with
16712
+ * `model-not-servable` — it never substitutes a format default, because
16713
+ * a fleet pass that round-robins across nodes would then fill one index
16714
+ * from several encoders.
16715
+ */
16716
+ modelId: string(),
16717
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
16718
+ frameJpeg: string(),
16719
+ /**
16720
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
16721
+ * purpose: the caller stores boxes against a downscaled analysis frame
16722
+ * while the stored key frame is native-resolution, and the only side
16723
+ * that reliably knows the image's pixel dimensions is the side that
16724
+ * decodes it. Denormalising here removes a second reader of the
16725
+ * dimensions and the class of mismatch that comes with it.
16726
+ */
16727
+ bbox: NativeCropBboxSchema,
16728
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
16729
+ className: string(),
16730
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
16731
+ sourceDeviceId: number()
16732
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
16174
16733
  var CameraPipelineConfigSchema = object({
16175
16734
  engine: PipelineEngineChoiceSchema.optional(),
16176
16735
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16468,6 +17027,20 @@ var CameraStatusSchema = object({
16468
17027
  detection: CameraDetectionStatusSchema.nullable(),
16469
17028
  audio: CameraAudioStatusSchema.nullable(),
16470
17029
  recording: CameraRecordingStatusSchema.nullable(),
17030
+ /**
17031
+ * Per-camera function switches an OPERATOR has turned off
17032
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
17033
+ *
17034
+ * This is the difference between DISABLED and BROKEN. A camera whose
17035
+ * `detection` block reports zero fps and whose `switchedOff` contains
17036
+ * `'object-detection'` was switched off by a person; the same camera with an
17037
+ * empty list is failing. Every status surface must render the two
17038
+ * differently — a quiet camera that looks identical to a dead one is the
17039
+ * silence-reads-as-never-happened trap this repo keeps paying for.
17040
+ *
17041
+ * Empty when nothing is off. Never contains a switch no provider offers.
17042
+ */
17043
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16471
17044
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16472
17045
  fetchedAt: number()
16473
17046
  });
@@ -16636,7 +17209,14 @@ method(object({
16636
17209
  }), method(object({
16637
17210
  deviceId: number(),
16638
17211
  agentNodeId: string().optional()
16639
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
17212
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
17213
+ deviceId: number(),
17214
+ switchId: CameraSwitchIdSchema,
17215
+ enabled: boolean()
17216
+ }), CameraSwitchGroupSchema, {
17217
+ kind: "mutation",
17218
+ auth: "admin"
17219
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16640
17220
  name: string(),
16641
17221
  description: string().optional(),
16642
17222
  config: CameraPipelineConfigSchema
@@ -16995,9 +17575,15 @@ var snapshotCapability = {
16995
17575
  * Bypass the cache freshness check and fetch directly from the
16996
17576
  * native (or stream-broker fallback). Triggered by the UI's
16997
17577
  * "refresh" button so an operator can force a fresh frame
16998
- * even when the cache is well within `snapshotMaxAgeMs`.
16999
- * On battery cams this WILL wake the camera — accept the
17000
- * cost only when the user explicitly asks for it.
17578
+ * even when the cache is well within the device's
17579
+ * `snapshotMaxAgeS` window.
17580
+ *
17581
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
17582
+ * battery camera it is the one thing that walks past the wrapper's
17583
+ * sleep gate and wakes the camera, so a background caller — a poller,
17584
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
17585
+ * gets the cached frame, which on a sleeping battery camera is the
17586
+ * correct answer: stale but honest beats woken.
17001
17587
  */
17002
17588
  force: boolean().optional()
17003
17589
  }), SnapshotImageSchema.nullable()),
@@ -17543,6 +18129,24 @@ var VectorDeleteByFilterInputSchema = object({
17543
18129
  filter: VectorFilterSchema
17544
18130
  });
17545
18131
  var VectorDeleteResultSchema = object({ deleted: number() });
18132
+ var VectorGetInputSchema = object({
18133
+ index: string(),
18134
+ ids: array(string())
18135
+ });
18136
+ /**
18137
+ * Metadata for the requested ids, WITHOUT their vectors.
18138
+ *
18139
+ * The only caller is a best-of gate that compares a candidate's confidence
18140
+ * against the stored one, and shipping 512 floats back to answer "is 0.91 >
18141
+ * 0.87" would undo the point of the compact encoding. Ids with no row are
18142
+ * simply absent — a caller distinguishing "not stored" from "stored" reads the
18143
+ * length, and a null placeholder would invite a `?? 0` that treats a missing
18144
+ * row as confidence zero.
18145
+ */
18146
+ var VectorGetResultSchema = object({ items: array(object({
18147
+ id: string(),
18148
+ metadata: VectorMetadataSchema
18149
+ })) });
17546
18150
  var VectorStatsInputSchema = object({ index: string() });
17547
18151
  var VectorStatsResultSchema = object({
17548
18152
  /** Provider id, so an operator can tell brute force from an ANN index. */
@@ -17555,7 +18159,7 @@ var VectorStatsResultSchema = object({
17555
18159
  /** False when the backend ranks approximately. */
17556
18160
  exact: boolean()
17557
18161
  });
17558
- method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
18162
+ method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorGetInputSchema, VectorGetResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
17559
18163
  /**
17560
18164
  * `videoclips` — the unified, navigable-clip surface for a camera.
17561
18165
  *
@@ -22457,12 +23061,30 @@ var pressureSensorCapability = {
22457
23061
  runtimeState: PressureSensorStatusSchema
22458
23062
  };
22459
23063
  /**
22460
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
22461
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
22462
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
22463
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
22464
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
22465
- * normalized 0..1 (top-left origin).
23064
+ * PRIVACY what the camera deliberately does not capture. Two planes:
23065
+ *
23066
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
23067
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
23068
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
23069
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
23070
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
23071
+ * normalized 0..1 (top-left origin).
23072
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
23073
+ * camera encoding an audio track at all, so EVERY consumer — live view,
23074
+ * recording, the audio analyzer, an export — sees silent video. There is
23075
+ * no server-side copy of this fact; the camera is the store and every read
23076
+ * is a read-through, which is why a switch over it cannot drift
23077
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
23078
+ *
23079
+ * Both belong here for one reason: they are the two things an operator turns
23080
+ * off when the answer to "what is this camera allowed to record" changes, and
23081
+ * both are applied ON the device, before anything leaves it.
23082
+ *
23083
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
23084
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
23085
+ * by one provider — and it was removed when this landed. A second writer onto
23086
+ * one device register is the shape of every knob this repo has shipped that
23087
+ * disagreed with the one the reader read.
22466
23088
  */
22467
23089
  /** A privacy-mask region's geometry — rectangle or free polygon. */
22468
23090
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -22474,21 +23096,45 @@ var PrivacyMaskRegionSchema = object({
22474
23096
  enabled: boolean(),
22475
23097
  shape: PrivacyMaskShapeSchema
22476
23098
  });
22477
- /** Current on-camera privacy-mask state — master enable + zones. */
23099
+ /** Current on-camera privacy state — mask master enable + zones + microphone. */
22478
23100
  var PrivacyMaskStatusSchema = object({
22479
23101
  enabled: boolean(),
22480
23102
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
22481
23103
  regions: array(PrivacyMaskRegionSchema),
23104
+ /**
23105
+ * Is the camera capturing sound right now? Read from the camera, never from
23106
+ * a server-side mirror.
23107
+ *
23108
+ * `null` means "no answer" — either this camera exposes no controllable
23109
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
23110
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
23111
+ * "the microphone is off" and "we could not ask" look identical to an
23112
+ * operator only until one of them is wrong.
23113
+ *
23114
+ * On a camera whose profiles carry the flag independently (Reolink writes
23115
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
23116
+ * privacy is only satisfied when every one of them is silent.
23117
+ */
23118
+ audioEnabled: boolean().nullable(),
22482
23119
  lastFetchedAt: number()
22483
23120
  });
22484
- /** Per-camera availability. */
23121
+ /** Per-camera availability. Probed, never assumed from the model name. */
22485
23122
  var PrivacyMaskOptionsSchema = object({
22486
23123
  /** Maximum number of supported zones. */
22487
23124
  maxRegions: number(),
22488
23125
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
22489
23126
  supportedShapes: array(MaskShapeKindSchema),
22490
23127
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
22491
- polygonVertices: MaskPolygonVerticesSchema.optional()
23128
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
23129
+ /**
23130
+ * Does this camera expose a microphone switch we can actually write?
23131
+ *
23132
+ * Camera-probed: `true` only when the firmware answered with an audio flag
23133
+ * we know how to patch. A camera that never answered is `false` — a control
23134
+ * the operator can press that changes nothing is worse than no control, and
23135
+ * the switch group renders "not available" instead.
23136
+ */
23137
+ supportsAudioMute: boolean()
22492
23138
  });
22493
23139
  /** Partial change — every field optional. */
22494
23140
  var PrivacyMaskPatchSchema = object({
@@ -22515,6 +23161,27 @@ var privacyMaskCapability = {
22515
23161
  }), _void(), {
22516
23162
  kind: "mutation",
22517
23163
  auth: "admin"
23164
+ }),
23165
+ /**
23166
+ * Turn the camera's microphone on or off, at the camera.
23167
+ *
23168
+ * Deliberately its OWN mutation rather than a field on
23169
+ * {@link PrivacyMaskPatchSchema}: `patch.enabled` already means "the video
23170
+ * mask master switch", and overloading it would make one boolean mean two
23171
+ * unrelated things on the same call. It is also the only method here whose
23172
+ * write leaves the device in a state a later `getStatus` reads back
23173
+ * verbatim, which is what makes it safe as a switch authority.
23174
+ *
23175
+ * A camera whose `getOptions().supportsAudioMute` is false must REJECT
23176
+ * this rather than silently accept it — a write nothing applies is exactly
23177
+ * what the switch group exists to remove.
23178
+ */
23179
+ setAudioEnabled: method(object({
23180
+ deviceId: number(),
23181
+ enabled: boolean()
23182
+ }), _void(), {
23183
+ kind: "mutation",
23184
+ auth: "admin"
22518
23185
  })
22519
23186
  },
22520
23187
  status: {
@@ -22523,6 +23190,26 @@ var privacyMaskCapability = {
22523
23190
  },
22524
23191
  runtimeState: PrivacyMaskStatusSchema
22525
23192
  };
23193
+ /**
23194
+ * Collapse a camera's PER-PROFILE audio flags into the one answer
23195
+ * {@link PrivacyMaskStatusSchema.shape.audioEnabled} promises.
23196
+ *
23197
+ * Both firmwares this cap talks to store the flag per stream profile, and
23198
+ * both let those profiles disagree. The rule is `some`, not `every`: privacy
23199
+ * is only satisfied when NOTHING is carrying sound, so a camera whose sub
23200
+ * stream is still audible must read as `true` and be switchable off — not as
23201
+ * `false` because the main stream happens to be muted already.
23202
+ *
23203
+ * An empty list is `null` ("this camera reported no audio flag at all"),
23204
+ * never `false`.
23205
+ *
23206
+ * Lives here rather than in each provider so the rule the schema documents
23207
+ * and the rule the providers apply cannot drift apart.
23208
+ */
23209
+ function summarisePrivacyAudio(profiles) {
23210
+ if (profiles.length === 0) return null;
23211
+ return profiles.some((p) => p.audioEnabled);
23212
+ }
22526
23213
  var PtzPresetSchema = object({
22527
23214
  id: string(),
22528
23215
  name: string()
@@ -22864,6 +23551,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
22864
23551
  })]);
22865
23552
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
22866
23553
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
23554
+ /**
23555
+ * One GOP of a finalized segment, cut by byte range through the segment's own
23556
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
23557
+ * the single `moof`+`mdat` covering the requested instant — standalone-
23558
+ * demuxable, never the whole file. When the segment's index cannot be parsed
23559
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
23560
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
23561
+ */
23562
+ var ReadGopBytesResultSchema = object({
23563
+ data: _instanceof(Uint8Array),
23564
+ /** Absolute epoch ms of the returned fragment's first sample. */
23565
+ gopStartMs: number(),
23566
+ /** Media ms the returned fragment covers. */
23567
+ gopDurMs: number()
23568
+ });
22867
23569
  method(object({
22868
23570
  deviceId: number(),
22869
23571
  fromMs: number(),
@@ -22906,6 +23608,14 @@ method(object({
22906
23608
  }), ReadSegmentBytesResultSchema, {
22907
23609
  kind: "query",
22908
23610
  auth: "admin"
23611
+ }), method(object({
23612
+ deviceId: number(),
23613
+ profile: string(),
23614
+ startMs: number(),
23615
+ epochMs: number()
23616
+ }), ReadGopBytesResultSchema, {
23617
+ kind: "query",
23618
+ auth: "admin"
22909
23619
  }), method(object({
22910
23620
  deviceId: number(),
22911
23621
  config: RecordingConfigSchema
@@ -23478,6 +24188,16 @@ var StreamProfileConfigSchema = object({
23478
24188
  "baseline"
23479
24189
  ]).optional(),
23480
24190
  gop: number().optional(),
24191
+ /**
24192
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
24193
+ *
24194
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
24195
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
24196
+ * writes every profile at once so "audio off" means silent everywhere. A
24197
+ * per-profile writer beside it would let a camera be half-muted and would be
24198
+ * a second knob onto one device register — the failure D62 exists to
24199
+ * prevent. Absent when the firmware does not report the flag.
24200
+ */
23481
24201
  audio: boolean().optional()
23482
24202
  });
23483
24203
  var StreamParamsStatusSchema = object({
@@ -23518,7 +24238,13 @@ var StreamParamsOptionsSchema = object({
23518
24238
  ext: StreamProfileOptionsSchema.optional()
23519
24239
  });
23520
24240
  /** A partial change to one profile — every field optional; a provider
23521
- * ignores fields it doesn't support. */
24241
+ * ignores fields it doesn't support.
24242
+ *
24243
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
24244
+ * reachable from no form and honoured by exactly one provider, while the
24245
+ * camera's microphone is a whole-device fact. It now has one writer,
24246
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
24247
+ * `privacy-mask.cap.ts`. */
23522
24248
  var StreamProfilePatchSchema = object({
23523
24249
  width: number().optional(),
23524
24250
  height: number().optional(),
@@ -23531,8 +24257,7 @@ var StreamProfilePatchSchema = object({
23531
24257
  "main",
23532
24258
  "baseline"
23533
24259
  ]).optional(),
23534
- gop: number().optional(),
23535
- audio: boolean().optional()
24260
+ gop: number().optional()
23536
24261
  });
23537
24262
  var streamParamsCapability = {
23538
24263
  name: "stream-params",
@@ -28696,6 +29421,12 @@ Object.freeze({
28696
29421
  addonId: null,
28697
29422
  access: "view"
28698
29423
  },
29424
+ "notificationRules.listDeviceMutes": {
29425
+ capName: "notification-rules",
29426
+ capScope: "system",
29427
+ addonId: null,
29428
+ access: "view"
29429
+ },
28699
29430
  "notificationRules.listRules": {
28700
29431
  capName: "notification-rules",
28701
29432
  capScope: "system",
@@ -28714,6 +29445,12 @@ Object.freeze({
28714
29445
  addonId: null,
28715
29446
  access: "create"
28716
29447
  },
29448
+ "notificationRules.setDeviceMuted": {
29449
+ capName: "notification-rules",
29450
+ capScope: "system",
29451
+ addonId: null,
29452
+ access: "create"
29453
+ },
28717
29454
  "notificationRules.setRuleEnabled": {
28718
29455
  capName: "notification-rules",
28719
29456
  capScope: "system",
@@ -28888,6 +29625,12 @@ Object.freeze({
28888
29625
  addonId: null,
28889
29626
  access: "view"
28890
29627
  },
29628
+ "pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
29629
+ capName: "pipeline-analytics",
29630
+ capScope: "device",
29631
+ addonId: null,
29632
+ access: "view"
29633
+ },
28891
29634
  "pipelineAnalytics.getObjectEvents": {
28892
29635
  capName: "pipeline-analytics",
28893
29636
  capScope: "device",
@@ -28966,6 +29709,12 @@ Object.freeze({
28966
29709
  addonId: null,
28967
29710
  access: "create"
28968
29711
  },
29712
+ "pipelineAnalytics.rebuildObjectEmbeddings": {
29713
+ capName: "pipeline-analytics",
29714
+ capScope: "device",
29715
+ addonId: null,
29716
+ access: "create"
29717
+ },
28969
29718
  "pipelineAnalytics.relocateMedia": {
28970
29719
  capName: "pipeline-analytics",
28971
29720
  capScope: "device",
@@ -28978,12 +29727,24 @@ Object.freeze({
28978
29727
  addonId: null,
28979
29728
  access: "view"
28980
29729
  },
29730
+ "pipelineAnalytics.setTrackFlags": {
29731
+ capName: "pipeline-analytics",
29732
+ capScope: "device",
29733
+ addonId: null,
29734
+ access: "create"
29735
+ },
28981
29736
  "pipelineAnalytics.wipeAllAnalytics": {
28982
29737
  capName: "pipeline-analytics",
28983
29738
  capScope: "device",
28984
29739
  addonId: null,
28985
29740
  access: "delete"
28986
29741
  },
29742
+ "pipelineAnalytics.wipeObjectEmbeddings": {
29743
+ capName: "pipeline-analytics",
29744
+ capScope: "device",
29745
+ addonId: null,
29746
+ access: "delete"
29747
+ },
28987
29748
  "pipelineExecutor.cacheFrameInPool": {
28988
29749
  capName: "pipeline-executor",
28989
29750
  capScope: "system",
@@ -29278,6 +30039,12 @@ Object.freeze({
29278
30039
  addonId: null,
29279
30040
  access: "view"
29280
30041
  },
30042
+ "pipelineOrchestrator.getCameraSwitches": {
30043
+ capName: "pipeline-orchestrator",
30044
+ capScope: "system",
30045
+ addonId: null,
30046
+ access: "view"
30047
+ },
29281
30048
  "pipelineOrchestrator.getCapabilityBindings": {
29282
30049
  capName: "pipeline-orchestrator",
29283
30050
  capScope: "system",
@@ -29410,6 +30177,12 @@ Object.freeze({
29410
30177
  addonId: null,
29411
30178
  access: "create"
29412
30179
  },
30180
+ "pipelineOrchestrator.setCameraSwitch": {
30181
+ capName: "pipeline-orchestrator",
30182
+ capScope: "system",
30183
+ addonId: null,
30184
+ access: "create"
30185
+ },
29413
30186
  "pipelineOrchestrator.setCapabilityBinding": {
29414
30187
  capName: "pipeline-orchestrator",
29415
30188
  capScope: "system",
@@ -29500,6 +30273,12 @@ Object.freeze({
29500
30273
  addonId: null,
29501
30274
  access: "create"
29502
30275
  },
30276
+ "pipelineRunner.runStatelessStep": {
30277
+ capName: "pipeline-runner",
30278
+ capScope: "system",
30279
+ addonId: null,
30280
+ access: "create"
30281
+ },
29503
30282
  "plateGallery.assignPlate": {
29504
30283
  capName: "plate-gallery",
29505
30284
  capScope: "system",
@@ -29632,6 +30411,12 @@ Object.freeze({
29632
30411
  addonId: null,
29633
30412
  access: "view"
29634
30413
  },
30414
+ "privacyMask.setAudioEnabled": {
30415
+ capName: "privacy-mask",
30416
+ capScope: "device",
30417
+ addonId: null,
30418
+ access: "create"
30419
+ },
29635
30420
  "privacyMask.setMask": {
29636
30421
  capName: "privacy-mask",
29637
30422
  capScope: "device",
@@ -29800,6 +30585,12 @@ Object.freeze({
29800
30585
  addonId: null,
29801
30586
  access: "create"
29802
30587
  },
30588
+ "recording.readGopBytes": {
30589
+ capName: "recording",
30590
+ capScope: "system",
30591
+ addonId: null,
30592
+ access: "view"
30593
+ },
29803
30594
  "recording.readSegmentBytes": {
29804
30595
  capName: "recording",
29805
30596
  capScope: "system",
@@ -30316,6 +31107,12 @@ Object.freeze({
30316
31107
  addonId: null,
30317
31108
  access: "create"
30318
31109
  },
31110
+ "streamBroker.acquireEgressTranscode": {
31111
+ capName: "stream-broker",
31112
+ capScope: "system",
31113
+ addonId: null,
31114
+ access: "create"
31115
+ },
30319
31116
  "streamBroker.assignProfile": {
30320
31117
  capName: "stream-broker",
30321
31118
  capScope: "system",
@@ -30424,6 +31221,12 @@ Object.freeze({
30424
31221
  addonId: null,
30425
31222
  access: "create"
30426
31223
  },
31224
+ "streamBroker.releaseEgressTranscode": {
31225
+ capName: "stream-broker",
31226
+ capScope: "system",
31227
+ addonId: null,
31228
+ access: "create"
31229
+ },
30427
31230
  "streamBroker.releaseStreamWithCodec": {
30428
31231
  capName: "stream-broker",
30429
31232
  capScope: "system",
@@ -30904,6 +31707,12 @@ Object.freeze({
30904
31707
  addonId: null,
30905
31708
  access: "delete"
30906
31709
  },
31710
+ "vectorStore.getByIds": {
31711
+ capName: "vector-store",
31712
+ capScope: "system",
31713
+ addonId: null,
31714
+ access: "view"
31715
+ },
30907
31716
  "vectorStore.query": {
30908
31717
  capName: "vector-store",
30909
31718
  capScope: "system",
@@ -31178,6 +31987,112 @@ TimelapseRuleInputSchema.extend({
31178
31987
  createdAt: number(),
31179
31988
  updatedAt: number()
31180
31989
  });
31990
+ object({
31991
+ /**
31992
+ * Fraction of the box's own size added on EACH side before cutting.
31993
+ *
31994
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
31995
+ * removes exactly the context it is strongest on (a dog cut to its outline
31996
+ * is a dark blob). The right value is an empirical question, which is why it
31997
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
31998
+ */
31999
+ paddingRatio: number().min(0).max(4),
32000
+ /**
32001
+ * Square the window (in PIXELS) before cutting.
32002
+ *
32003
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
32004
+ * — a standing person becomes a shape the model never saw. Squaring costs
32005
+ * extra background, which is context the model wants anyway. Off by default
32006
+ * because the live path has never squared and the stored index reflects that.
32007
+ */
32008
+ square: boolean()
32009
+ });
32010
+ ({
32011
+ paddingRatio: .15,
32012
+ square: false
32013
+ }).paddingRatio;
32014
+ /**
32015
+ * WHICH delivered frames the decode worker retains a native copy of.
32016
+ *
32017
+ * - `all` — every frame the worker delivered to the runner. The shipped
32018
+ * behaviour, and the only correct one if something can ask for a crop of a
32019
+ * frame the runner never sent to inference.
32020
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
32021
+ * A native-crop request always names a `frameId` that rode an inference
32022
+ * result, so that is the only set a request can name. How much it drops is
32023
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
32024
+ * this cluster, not the ~80% the design sketch assumed, because the governor
32025
+ * was not throttling as hard as the sketch supposed. Read
32026
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
32027
+ * of you rather than quoting a number from here. The newest delivered frame is
32028
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
32029
+ * which covers the one-frame race between a mark and the supersede that
32030
+ * consumes it.
32031
+ */
32032
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
32033
+ object({
32034
+ /**
32035
+ * How long a retained native frame is served before it counts as a miss.
32036
+ *
32037
+ * Must cover the FULL late-crop horizon: detection inference + the
32038
+ * cross-process inference-result hop to hub post-analysis + tracking + the
32039
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
32040
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
32041
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
32042
+ */
32043
+ ttlMs: number().int().min(250).max(1e4),
32044
+ /**
32045
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
32046
+ *
32047
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
32048
+ * which one is actually binding before reasoning from that. At the shipped
32049
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
32050
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
32051
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
32052
+ * change that admits fewer frames buys retention WINDOW at constant RAM
32053
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
32054
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
32055
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
32056
+ * to replace).
32057
+ */
32058
+ budgetMb: number().int().min(0).max(4096),
32059
+ /**
32060
+ * Demand window: eager per-frame native retention runs only within this many
32061
+ * ms of the last native-crop request (or of the dial starting).
32062
+ *
32063
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
32064
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
32065
+ * per second on a camera with zero crop demand), so leave it non-zero unless
32066
+ * you are reproducing that.
32067
+ */
32068
+ activityMs: number().int().min(0).max(12e4),
32069
+ /**
32070
+ * Which delivered frames are retained at all — see
32071
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
32072
+ * changes WHAT is kept rather than for how long, so it is also the only one
32073
+ * that can turn a crop that used to hit into a miss. The worker counts every
32074
+ * crop request naming a frame it did NOT see marked
32075
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
32076
+ * there is the signal that some caller names frames outside the inference set
32077
+ * and that this must go back to `all`.
32078
+ */
32079
+ admission: NativeLeaseAdmissionSchema
32080
+ });
32081
+ /**
32082
+ * The values in force when the operator has set nothing — byte-for-byte the
32083
+ * constants the decode worker shipped with as env-var defaults, so making these
32084
+ * settings changed no behaviour on the day it landed.
32085
+ */
32086
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
32087
+ ttlMs: 1200,
32088
+ budgetMb: 1024,
32089
+ activityMs: 15e3,
32090
+ admission: "inferred"
32091
+ };
32092
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
32093
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
32094
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
32095
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
31181
32096
  //#endregion
31182
32097
  //#region ../../node_modules/undici/lib/core/symbols.js
31183
32098
  var require_symbols = /* @__PURE__ */ require_chunk.__commonJSMin(((exports, module) => {
@@ -219545,69 +220460,6 @@ function buildInitialStatus(config) {
219545
220460
  };
219546
220461
  }
219547
220462
  //#endregion
219548
- //#region src/raw-state.ts
219549
- /**
219550
- * Source tag for every raw-state blob this provider emits.
219551
- */
219552
- var RAW_STATE_SOURCE = "reolink";
219553
- /**
219554
- * Key fragments that mark a field as secret/credential bearing. Any
219555
- * object key matching this (case-insensitive) is dropped from the
219556
- * display-safe raw-state blob.
219557
- */
219558
- var SECRET_KEY_PATTERN = /password|token|secret|credential|passwd|auth/i;
219559
- /**
219560
- * Deep-copy `value`, dropping any object key that looks like a
219561
- * credential. Recurses into nested plain objects and arrays; leaves
219562
- * primitives untouched. Never mutates the input.
219563
- */
219564
- function redactValue(value) {
219565
- if (Array.isArray(value)) return value.map(redactValue);
219566
- if (value && typeof value === "object") {
219567
- const out = {};
219568
- for (const [key, child] of Object.entries(value)) {
219569
- if (SECRET_KEY_PATTERN.test(key)) continue;
219570
- out[key] = redactValue(child);
219571
- }
219572
- return out;
219573
- }
219574
- return value;
219575
- }
219576
- /**
219577
- * Return a deep copy of `obj` with every credential-bearing key
219578
- * removed at any depth. Display-safe.
219579
- */
219580
- function redactSecrets(obj) {
219581
- return redactValue(obj);
219582
- }
219583
- /**
219584
- * Snapshot every cached cap slice from `reader` and strip secrets.
219585
- * Returns a `Record<capName, redactedSlice>`; empty when no slice has
219586
- * been written yet.
219587
- */
219588
- function collectRedactedSnapshot(reader) {
219589
- const snap = reader.snapshot();
219590
- const out = {};
219591
- for (const [capName, slice] of Object.entries(snap)) out[capName] = redactSecrets({ ...slice });
219592
- return out;
219593
- }
219594
- /**
219595
- * Build the display-safe `{ source:'reolink', data }` raw-state blob
219596
- * from a device's cached runtime-state slices. Returns `null` when
219597
- * the device has no cached state at all (so the State panel hides the
219598
- * Raw toggle rather than showing an empty object).
219599
- *
219600
- * No camera round-trip — reads only the in-memory runtime-state cache.
219601
- */
219602
- function buildRawState(reader) {
219603
- const data = collectRedactedSnapshot(reader);
219604
- if (Object.keys(data).length === 0) return null;
219605
- return {
219606
- source: RAW_STATE_SOURCE,
219607
- data
219608
- };
219609
- }
219610
- //#endregion
219611
220463
  //#region src/day-night-mapping.ts
219612
220464
  /**
219613
220465
  * Maps between the vendor-neutral `day-night` cap's `DayNightMode` and
@@ -219720,44 +220572,68 @@ function overlayLiveNativeRfc4571Sdp(descriptors, liveServerFor) {
219720
220572
  });
219721
220573
  }
219722
220574
  //#endregion
219723
- //#region src/accessory-probe-flags.ts
219724
- var FLAG_KEYS = [
219725
- "hasBattery",
219726
- "hasPtz",
219727
- "hasIntercom",
219728
- "hasDoorbell",
219729
- "hasFloodlight",
219730
- "hasSiren",
219731
- "hasPirSensor",
219732
- "hasAutotrack"
219733
- ];
220575
+ //#region src/raw-state.ts
219734
220576
  /**
219735
- * True when EITHER the live `feature-probe` slice has completed a probe
219736
- * this session (`sliceProbed`) OR a prior successful probe is persisted
219737
- * in the `deviceCache` config blob (`probedAt` stamp). The latter
219738
- * survives restarts, so a camera probed in an earlier session is still
219739
- * "probed" for accessory-derivation purposes even if its live probe is
219740
- * currently slow/failing.
220577
+ * Source tag for every raw-state blob this provider emits.
219741
220578
  */
219742
- function hasEverProbed(sliceProbed, deviceCache) {
219743
- return sliceProbed || deviceCache?.probedAt !== void 0;
220579
+ var RAW_STATE_SOURCE = "reolink";
220580
+ /**
220581
+ * Key fragments that mark a field as secret/credential bearing. Any
220582
+ * object key matching this (case-insensitive) is dropped from the
220583
+ * display-safe raw-state blob.
220584
+ */
220585
+ var SECRET_KEY_PATTERN = /password|token|secret|credential|passwd|auth/i;
220586
+ /**
220587
+ * Deep-copy `value`, dropping any object key that looks like a
220588
+ * credential. Recurses into nested plain objects and arrays; leaves
220589
+ * primitives untouched. Never mutates the input.
220590
+ */
220591
+ function redactValue(value) {
220592
+ if (Array.isArray(value)) return value.map(redactValue);
220593
+ if (value && typeof value === "object") {
220594
+ const out = {};
220595
+ for (const [key, child] of Object.entries(value)) {
220596
+ if (SECRET_KEY_PATTERN.test(key)) continue;
220597
+ out[key] = redactValue(child);
220598
+ }
220599
+ return out;
220600
+ }
220601
+ return value;
219744
220602
  }
219745
220603
  /**
219746
- * Effective ability flags for accessory derivation: the live slice
219747
- * flags when the slice has probed this session, otherwise the boolean
219748
- * flags persisted in `deviceCache` from the last successful probe.
219749
- * Returns an empty bag when neither source carries a completed probe.
220604
+ * Return a deep copy of `obj` with every credential-bearing key
220605
+ * removed at any depth. Display-safe.
219750
220606
  */
219751
- function resolveAccessoryProbeFlags(sliceProbed, sliceFlags, deviceCache) {
219752
- if (sliceProbed) return sliceFlags;
219753
- if (deviceCache?.probedAt === void 0) return {};
220607
+ function redactSecrets(obj) {
220608
+ return redactValue(obj);
220609
+ }
220610
+ /**
220611
+ * Snapshot every cached cap slice from `reader` and strip secrets.
220612
+ * Returns a `Record<capName, redactedSlice>`; empty when no slice has
220613
+ * been written yet.
220614
+ */
220615
+ function collectRedactedSnapshot(reader) {
220616
+ const snap = reader.snapshot();
219754
220617
  const out = {};
219755
- for (const key of FLAG_KEYS) {
219756
- const value = deviceCache[key];
219757
- if (typeof value === "boolean") out[key] = value;
219758
- }
220618
+ for (const [capName, slice] of Object.entries(snap)) out[capName] = redactSecrets({ ...slice });
219759
220619
  return out;
219760
220620
  }
220621
+ /**
220622
+ * Build the display-safe `{ source:'reolink', data }` raw-state blob
220623
+ * from a device's cached runtime-state slices. Returns `null` when
220624
+ * the device has no cached state at all (so the State panel hides the
220625
+ * Raw toggle rather than showing an empty object).
220626
+ *
220627
+ * No camera round-trip — reads only the in-memory runtime-state cache.
220628
+ */
220629
+ function buildRawState(reader) {
220630
+ const data = collectRedactedSnapshot(reader);
220631
+ if (Object.keys(data).length === 0) return null;
220632
+ return {
220633
+ source: RAW_STATE_SOURCE,
220634
+ data
220635
+ };
220636
+ }
219761
220637
  //#endregion
219762
220638
  //#region src/accessories/base.ts
219763
220639
  /**
@@ -220885,6 +221761,45 @@ function createAccessoryDevice(kind, ctx, parent) {
220885
221761
  }
220886
221762
  }
220887
221763
  //#endregion
221764
+ //#region src/accessory-probe-flags.ts
221765
+ var FLAG_KEYS = [
221766
+ "hasBattery",
221767
+ "hasPtz",
221768
+ "hasIntercom",
221769
+ "hasDoorbell",
221770
+ "hasFloodlight",
221771
+ "hasSiren",
221772
+ "hasPirSensor",
221773
+ "hasAutotrack"
221774
+ ];
221775
+ /**
221776
+ * True when EITHER the live `feature-probe` slice has completed a probe
221777
+ * this session (`sliceProbed`) OR a prior successful probe is persisted
221778
+ * in the `deviceCache` config blob (`probedAt` stamp). The latter
221779
+ * survives restarts, so a camera probed in an earlier session is still
221780
+ * "probed" for accessory-derivation purposes even if its live probe is
221781
+ * currently slow/failing.
221782
+ */
221783
+ function hasEverProbed(sliceProbed, deviceCache) {
221784
+ return sliceProbed || deviceCache?.probedAt !== void 0;
221785
+ }
221786
+ /**
221787
+ * Effective ability flags for accessory derivation: the live slice
221788
+ * flags when the slice has probed this session, otherwise the boolean
221789
+ * flags persisted in `deviceCache` from the last successful probe.
221790
+ * Returns an empty bag when neither source carries a completed probe.
221791
+ */
221792
+ function resolveAccessoryProbeFlags(sliceProbed, sliceFlags, deviceCache) {
221793
+ if (sliceProbed) return sliceFlags;
221794
+ if (deviceCache?.probedAt === void 0) return {};
221795
+ const out = {};
221796
+ for (const key of FLAG_KEYS) {
221797
+ const value = deviceCache[key];
221798
+ if (typeof value === "boolean") out[key] = value;
221799
+ }
221800
+ return out;
221801
+ }
221802
+ //#endregion
220888
221803
  //#region src/metadata-populator.ts
220889
221804
  var EXTENDED_INFO_TAGS = [
220890
221805
  "type",
@@ -220988,6 +221903,760 @@ async function populateReolinkMetadata(api, channel, target) {
220988
221903
  }
220989
221904
  }
220990
221905
  //#endregion
221906
+ //#region src/error-classifier.ts
221907
+ /**
221908
+ * Recognise transient Baichuan/socket failures that are expected to
221909
+ * recover on the next reconnect cycle. Mirrors
221910
+ * `scrypted-reolink-native/src/camera.ts isRecoverableBaichuanError` —
221911
+ * keeping parity so the same wire-level conditions are treated the
221912
+ * same way across plugins.
221913
+ *
221914
+ * Ported categories:
221915
+ * - Baichuan-specific transport closes (`Baichuan socket closed`,
221916
+ * `Baichuan UDP stream closed`, `Baichuan TCP socket is not
221917
+ * connected`)
221918
+ * - Generic TCP errors that fire as part of the same disconnect
221919
+ * storm (`ECONNRESET`, `EPIPE`, `socket hang up`)
221920
+ * - D2C disconnects (`D2C_DISC`) — UDP relay path teardown
221921
+ *
221922
+ * Callers downgrade these to WARN-level logs and trigger a fresh
221923
+ * login on the next demand instead of bubbling a hard ERROR.
221924
+ */
221925
+ var RECOVERABLE_FRAGMENTS = [
221926
+ "Baichuan socket closed",
221927
+ "Baichuan UDP stream closed",
221928
+ "Baichuan TCP socket is not connected",
221929
+ "socket hang up",
221930
+ "ECONNRESET",
221931
+ "EPIPE",
221932
+ "D2C_DISC"
221933
+ ];
221934
+ function isRecoverableBaichuanError(err) {
221935
+ const message = err instanceof Error ? err.message : typeof err === "string" ? err : err?.toString?.() ?? "";
221936
+ return RECOVERABLE_FRAGMENTS.some((fragment) => message.includes(fragment));
221937
+ }
221938
+ //#endregion
221939
+ //#region src/intercom-encoder.ts
221940
+ /**
221941
+ * IMA ADPCM (DVI4) encoder — Reolink Baichuan talk-back wire format.
221942
+ *
221943
+ * Ported from `scrypted-reolink-native/src/intercom.ts` (BSD-2 like the
221944
+ * rest of the cross-cam Scrypted infra). Reolink cameras expect ADPCM
221945
+ * blocks of (4 + N) bytes:
221946
+ * - 2 bytes: little-endian Int16 predictor (first PCM sample of the
221947
+ * block, used to seed the decoder state on the camera side)
221948
+ * - 1 byte: index into the IMA step table (always 0 — we re-seed
221949
+ * the predictor on every block instead of carrying state forward)
221950
+ * - 1 byte: padding
221951
+ * - N bytes: 2 nibbles per byte, low nibble first, each nibble
221952
+ * encodes one PCM sample's delta as `sign | delta3` (4 bits)
221953
+ *
221954
+ * The block size is camera-firmware specific; the lib reports it as
221955
+ * `TalkSessionInfo.blockSize` per session.
221956
+ *
221957
+ * Why standalone?
221958
+ * - Pure function — no I/O, easy to unit-test
221959
+ * - Reusable from the (currently stub) WebRTC server-side intercom
221960
+ * path AND from any future direct-PCM caller
221961
+ * - Decoupled from werift / lib types
221962
+ */
221963
+ var IMA_INDEX_TABLE = Int8Array.from([
221964
+ -1,
221965
+ -1,
221966
+ -1,
221967
+ -1,
221968
+ 2,
221969
+ 4,
221970
+ 6,
221971
+ 8,
221972
+ -1,
221973
+ -1,
221974
+ -1,
221975
+ -1,
221976
+ 2,
221977
+ 4,
221978
+ 6,
221979
+ 8
221980
+ ]);
221981
+ var IMA_STEP_TABLE = Int16Array.from([
221982
+ 7,
221983
+ 8,
221984
+ 9,
221985
+ 10,
221986
+ 11,
221987
+ 12,
221988
+ 13,
221989
+ 14,
221990
+ 16,
221991
+ 17,
221992
+ 19,
221993
+ 21,
221994
+ 23,
221995
+ 25,
221996
+ 28,
221997
+ 31,
221998
+ 34,
221999
+ 37,
222000
+ 41,
222001
+ 45,
222002
+ 50,
222003
+ 55,
222004
+ 60,
222005
+ 66,
222006
+ 73,
222007
+ 80,
222008
+ 88,
222009
+ 97,
222010
+ 107,
222011
+ 118,
222012
+ 130,
222013
+ 143,
222014
+ 157,
222015
+ 173,
222016
+ 190,
222017
+ 209,
222018
+ 230,
222019
+ 253,
222020
+ 279,
222021
+ 307,
222022
+ 337,
222023
+ 371,
222024
+ 408,
222025
+ 449,
222026
+ 494,
222027
+ 544,
222028
+ 598,
222029
+ 658,
222030
+ 724,
222031
+ 796,
222032
+ 876,
222033
+ 963,
222034
+ 1060,
222035
+ 1166,
222036
+ 1282,
222037
+ 1411,
222038
+ 1552,
222039
+ 1707,
222040
+ 1878,
222041
+ 2066,
222042
+ 2272,
222043
+ 2499,
222044
+ 2749,
222045
+ 3024,
222046
+ 3327,
222047
+ 3660,
222048
+ 4026,
222049
+ 4428,
222050
+ 4871,
222051
+ 5358,
222052
+ 5894,
222053
+ 6484,
222054
+ 7132,
222055
+ 7845,
222056
+ 8630,
222057
+ 9493,
222058
+ 10442,
222059
+ 11487,
222060
+ 12635,
222061
+ 13899,
222062
+ 15289,
222063
+ 16818,
222064
+ 18500,
222065
+ 20350,
222066
+ 22385,
222067
+ 24623,
222068
+ 27086,
222069
+ 29794,
222070
+ 32767
222071
+ ]);
222072
+ function clamp16(x) {
222073
+ if (x > 32767) return 32767;
222074
+ if (x < -32768) return -32768;
222075
+ return x | 0;
222076
+ }
222077
+ /**
222078
+ * Encode a PCM s16le buffer into IMA ADPCM blocks of size
222079
+ * `(4 + blockSizeBytes)` each. The output length is a multiple of
222080
+ * `4 + blockSizeBytes`. PCM samples that don't fit a full block at
222081
+ * the end get folded into a final partial block (the camera tolerates
222082
+ * trailing zeros from the unpopulated nibbles).
222083
+ *
222084
+ * Block layout per Reolink's wire format:
222085
+ * bytes [0..1] = predictor (Int16 LE)
222086
+ * byte [2] = step index (always 0 — re-seed each block)
222087
+ * byte [3] = padding (0x00)
222088
+ * bytes [4..N+3] = packed nibbles (2 samples per byte, low first)
222089
+ *
222090
+ * Each block carries `blockSizeBytes * 2 + 1` PCM samples (the +1
222091
+ * is the predictor sample stored explicitly in the header).
222092
+ */
222093
+ function encodeImaAdpcm(pcm, blockSizeBytes) {
222094
+ const samplesPerBlock = blockSizeBytes * 2 + 1;
222095
+ const totalBlocks = Math.ceil(pcm.length / samplesPerBlock);
222096
+ const outBlocks = [];
222097
+ let sampleIndex = 0;
222098
+ for (let b = 0; b < totalBlocks; b++) {
222099
+ const block = Buffer.alloc(4 + blockSizeBytes);
222100
+ let predictor = pcm[sampleIndex] ?? 0;
222101
+ let index = 0;
222102
+ block.writeInt16LE(predictor, 0);
222103
+ block.writeUInt8(index, 2);
222104
+ block.writeUInt8(0, 3);
222105
+ sampleIndex++;
222106
+ const codes = new Uint8Array(blockSizeBytes * 2);
222107
+ for (let i = 0; i < codes.length; i++) {
222108
+ const sample = pcm[sampleIndex] ?? predictor;
222109
+ sampleIndex++;
222110
+ let diff = sample - predictor;
222111
+ let sign = 0;
222112
+ if (diff < 0) {
222113
+ sign = 8;
222114
+ diff = -diff;
222115
+ }
222116
+ let step = IMA_STEP_TABLE[index] ?? 7;
222117
+ let delta = 0;
222118
+ let vpdiff = step >> 3;
222119
+ if (diff >= step) {
222120
+ delta |= 4;
222121
+ diff -= step;
222122
+ vpdiff += step;
222123
+ }
222124
+ step >>= 1;
222125
+ if (diff >= step) {
222126
+ delta |= 2;
222127
+ diff -= step;
222128
+ vpdiff += step;
222129
+ }
222130
+ step >>= 1;
222131
+ if (diff >= step) {
222132
+ delta |= 1;
222133
+ vpdiff += step;
222134
+ }
222135
+ predictor = sign ? clamp16(predictor - vpdiff) : clamp16(predictor + vpdiff);
222136
+ index += IMA_INDEX_TABLE[delta] ?? 0;
222137
+ if (index < 0) index = 0;
222138
+ if (index > 88) index = 88;
222139
+ codes[i] = (delta | sign) & 15;
222140
+ }
222141
+ for (let i = 0; i < blockSizeBytes; i++) {
222142
+ const lo = codes[i * 2] ?? 0;
222143
+ const hi = codes[i * 2 + 1] ?? 0;
222144
+ block[4 + i] = lo & 15 | (hi & 15) << 4;
222145
+ }
222146
+ outBlocks.push(block);
222147
+ }
222148
+ return Buffer.concat(outBlocks);
222149
+ }
222150
+ //#endregion
222151
+ //#region src/intercom-session.ts
222152
+ var DEFAULT_BACKLOG_MS = 120;
222153
+ var MAX_BACKLOG_MS = 5e3;
222154
+ var MIN_BACKLOG_MS = 20;
222155
+ var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
222156
+ var DEFAULT_GAIN = 1;
222157
+ var MIN_GAIN = .1;
222158
+ var MAX_GAIN = 10;
222159
+ var ReolinkIntercomSession = class {
222160
+ opts;
222161
+ session = null;
222162
+ pcmBuffer = Buffer.alloc(0);
222163
+ pumping = false;
222164
+ pumpPromise = null;
222165
+ maxBacklogBytes = 0;
222166
+ bytesPerBlock = 0;
222167
+ blockSize = 0;
222168
+ lastBacklogClampLogAtMs = 0;
222169
+ outputGain = DEFAULT_GAIN;
222170
+ constructor(opts) {
222171
+ this.opts = opts;
222172
+ }
222173
+ /** True once `start()` has resolved and not yet been `stop()`'d. */
222174
+ get isOpen() {
222175
+ return this.session !== null;
222176
+ }
222177
+ /** Sample rate the camera negotiated. Throws when not started. */
222178
+ get sampleRate() {
222179
+ if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
222180
+ return this.session.info.audioConfig.sampleRate;
222181
+ }
222182
+ async start() {
222183
+ if (this.session) return;
222184
+ this.outputGain = clampGain(this.opts.outputGain);
222185
+ const session = await this.opts.api.createDedicatedTalkSession(this.opts.channel, {
222186
+ blocksPerPayload: clampBlocks(this.opts.blocksPerPayload),
222187
+ idleTimeoutMs: this.opts.idleTimeoutMs ?? 3e4,
222188
+ deviceId: this.opts.deviceTag,
222189
+ logger: { log: (msg, ...rest) => this.opts.logger.debug(`talk: ${msg}`, { meta: { rest } }) }
222190
+ });
222191
+ const { blockSize, fullBlockSize } = session.info;
222192
+ if (!Number.isFinite(blockSize) || blockSize <= 0 || fullBlockSize !== blockSize + 4) {
222193
+ try {
222194
+ await session.stop();
222195
+ } catch {}
222196
+ throw new Error(`Reolink talk session reported invalid block sizes: blockSize=${blockSize} fullBlockSize=${fullBlockSize}`);
222197
+ }
222198
+ const samplesPerBlock = blockSize * 2 + 1;
222199
+ this.bytesPerBlock = samplesPerBlock * 2;
222200
+ this.blockSize = blockSize;
222201
+ const sampleRate = session.info.audioConfig.sampleRate;
222202
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
222203
+ try {
222204
+ await session.stop();
222205
+ } catch {}
222206
+ throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
222207
+ }
222208
+ const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
222209
+ this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
222210
+ this.session = session;
222211
+ this.pcmBuffer = Buffer.alloc(0);
222212
+ this.opts.logger.info("intercom talk session opened", { meta: {
222213
+ channel: this.opts.channel,
222214
+ sampleRate,
222215
+ blockSize,
222216
+ bytesPerBlock: this.bytesPerBlock,
222217
+ backlogMs: wantedBacklogMs,
222218
+ maxBacklogBytes: this.maxBacklogBytes,
222219
+ blocksPerPayload: clampBlocks(this.opts.blocksPerPayload),
222220
+ outputGain: this.outputGain
222221
+ } });
222222
+ }
222223
+ /**
222224
+ * Feed a chunk of PCM s16le at `this.sampleRate` Hz. Returns
222225
+ * immediately after enqueueing — the actual encode + send happens
222226
+ * in a background pump. Calls before `start()` (or after `stop()`)
222227
+ * silently drop the chunk so callers don't have to gate every
222228
+ * push on `isOpen`.
222229
+ */
222230
+ feedPcm(pcm) {
222231
+ if (!this.session) return;
222232
+ if (pcm.length === 0) return;
222233
+ this.pcmBuffer = this.pcmBuffer.length ? Buffer.concat([this.pcmBuffer, pcm]) : pcm;
222234
+ if (this.pcmBuffer.length > this.maxBacklogBytes) {
222235
+ const keep = this.maxBacklogBytes - this.maxBacklogBytes % 2;
222236
+ const dropped = this.pcmBuffer.length - keep;
222237
+ this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
222238
+ const now = Date.now();
222239
+ if (now - this.lastBacklogClampLogAtMs > 2e3) {
222240
+ this.lastBacklogClampLogAtMs = now;
222241
+ this.opts.logger.warn("intercom backlog clamped (dropping PCM)", { meta: {
222242
+ droppedBytes: dropped,
222243
+ keptBytes: keep,
222244
+ maxBytes: this.maxBacklogBytes
222245
+ } });
222246
+ }
222247
+ }
222248
+ if (!this.pumping) this.startPump();
222249
+ }
222250
+ startPump() {
222251
+ const session = this.session;
222252
+ if (!session) return;
222253
+ this.pumping = true;
222254
+ this.pumpPromise = (async () => {
222255
+ try {
222256
+ while (true) {
222257
+ if (this.session !== session) return;
222258
+ if (this.pcmBuffer.length < this.bytesPerBlock) return;
222259
+ const chunk = this.pcmBuffer.subarray(0, this.bytesPerBlock);
222260
+ this.pcmBuffer = this.pcmBuffer.subarray(this.bytesPerBlock);
222261
+ const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
222262
+ const adpcm = encodeImaAdpcm(this.outputGain === 1 ? samples : applyGainInt16(samples, this.outputGain), this.blockSize);
222263
+ await session.sendAudio(adpcm);
222264
+ }
222265
+ } catch (err) {
222266
+ this.opts.logger.warn("intercom pump error — stopping", { meta: { error: err instanceof Error ? err.message : String(err) } });
222267
+ } finally {
222268
+ this.pumping = false;
222269
+ }
222270
+ })();
222271
+ }
222272
+ async stop() {
222273
+ const session = this.session;
222274
+ if (!session) return;
222275
+ this.session = null;
222276
+ this.pcmBuffer = Buffer.alloc(0);
222277
+ if (this.pumpPromise) {
222278
+ try {
222279
+ await Promise.race([this.pumpPromise, new Promise((r) => setTimeout(r, 250))]);
222280
+ } catch {}
222281
+ this.pumpPromise = null;
222282
+ }
222283
+ try {
222284
+ await Promise.race([session.stop(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("talk session stop timeout")), 2e3))]);
222285
+ } catch (err) {
222286
+ this.opts.logger.warn("intercom session stop error", { meta: { error: err instanceof Error ? err.message : String(err) } });
222287
+ }
222288
+ }
222289
+ };
222290
+ /** Clamp `blocksPerPayload` into the lib's accepted range. Mirrors
222291
+ * scrypted-reolink-native's `intercom-mixin.ts:96-101` clamp. */
222292
+ function clampBlocks(value) {
222293
+ if (value === void 0 || !Number.isFinite(value)) return DEFAULT_BLOCKS_PER_PAYLOAD;
222294
+ return Math.max(1, Math.min(8, Math.floor(value)));
222295
+ }
222296
+ /** Clamp `outputGain` into the operator-safe range. Same `[0.1, 10]`
222297
+ * band as Scrypted (`intercom-mixin.ts:104-107`). */
222298
+ function clampGain(value) {
222299
+ if (value === void 0 || !Number.isFinite(value)) return DEFAULT_GAIN;
222300
+ return Math.max(MIN_GAIN, Math.min(MAX_GAIN, value));
222301
+ }
222302
+ /** Apply a floating-point gain to each `Int16` sample in-place into
222303
+ * a freshly-allocated buffer. The result is hard-clipped to int16
222304
+ * bounds — soft saturation isn't worth the tradeoff for an intercom
222305
+ * channel where occasional clipping is preferable to a perceived
222306
+ * loudness mismatch. */
222307
+ function applyGainInt16(samples, gain) {
222308
+ const out = new Int16Array(samples.length);
222309
+ for (let i = 0; i < samples.length; i++) {
222310
+ const scaled = (samples[i] ?? 0) * gain;
222311
+ out[i] = scaled > 32767 ? 32767 : scaled < -32768 ? -32768 : scaled;
222312
+ }
222313
+ return out;
222314
+ }
222315
+ //#endregion
222316
+ //#region src/intercom-orchestrator.ts
222317
+ /** Default Opus parameters used when the orchestrator caller doesn't
222318
+ * override them. Browser Opus is canonically 48 kHz; mono is the only
222319
+ * practical choice for an intercom (the camera's talk channel is
222320
+ * mono — sending stereo would just cost bandwidth). */
222321
+ var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
222322
+ var DEFAULT_OPUS_CHANNELS = 1;
222323
+ var DEFAULT_CAMERA_SAMPLE_RATE = 16e3;
222324
+ var IntercomOrchestrator = class {
222325
+ opts;
222326
+ session = null;
222327
+ constructor(opts) {
222328
+ this.opts = opts;
222329
+ }
222330
+ /** True while a session is open (between `start()` resolve and
222331
+ * `stop()` resolve). */
222332
+ get isOpen() {
222333
+ return this.session !== null && !this.session.closed;
222334
+ }
222335
+ /**
222336
+ * Open a fresh WebRTC peer + audio-codec decode session + Reolink
222337
+ * talk session, wire them, return the SDP offer. Throws (and tears
222338
+ * down everything it had spun up) on any failure — the cap router
222339
+ * surfaces the error to the client unchanged.
222340
+ *
222341
+ * Single-active-session semantics: a second `start()` while the
222342
+ * first is still open closes the old session before opening the
222343
+ * new one. The cap router enforces this on the caller side via
222344
+ * `intercomSessions.size === 1` invariants.
222345
+ */
222346
+ async start() {
222347
+ if (this.session && !this.session.closed) await this.stop(this.session.sessionId, "superseded-by-new-start").catch(() => {});
222348
+ const sessionId = generateSessionId();
222349
+ const cameraRate = this.opts.cameraSampleRate ?? DEFAULT_CAMERA_SAMPLE_RATE;
222350
+ const opusRate = this.opts.opusSampleRate ?? DEFAULT_OPUS_SAMPLE_RATE;
222351
+ const opusChannels = this.opts.opusChannels ?? DEFAULT_OPUS_CHANNELS;
222352
+ this.opts.logger.info("intercom: negotiate (opening session)", { meta: {
222353
+ sessionId,
222354
+ channel: this.opts.channel,
222355
+ opusRate,
222356
+ opusChannels
222357
+ } });
222358
+ if (this.opts.wakeBeforeStart) try {
222359
+ await this.opts.wakeBeforeStart();
222360
+ } catch (err) {
222361
+ this.opts.logger.warn("intercom: pre-wake failed", { meta: { error: errMsg$1(err) } });
222362
+ throw err;
222363
+ }
222364
+ const talkSession = new ReolinkIntercomSession({
222365
+ channel: this.opts.channel,
222366
+ api: this.opts.api,
222367
+ logger: this.opts.logger.withTags?.({ sessionId }) ?? this.opts.logger,
222368
+ deviceTag: this.opts.deviceTag,
222369
+ ...this.opts.blocksPerPayload !== void 0 ? { blocksPerPayload: this.opts.blocksPerPayload } : {},
222370
+ ...this.opts.maxBacklogMs !== void 0 ? { maxBacklogMs: this.opts.maxBacklogMs } : {},
222371
+ ...this.opts.outputGain !== void 0 ? { outputGain: this.opts.outputGain } : {}
222372
+ });
222373
+ try {
222374
+ await talkSession.start();
222375
+ } catch (err) {
222376
+ this.opts.logger.warn("intercom: talk session open failed", { meta: { error: errMsg$1(err) } });
222377
+ throw err;
222378
+ }
222379
+ const realCameraRate = talkSession.sampleRate;
222380
+ const targetRate = Number.isFinite(realCameraRate) && realCameraRate > 0 ? realCameraRate : cameraRate;
222381
+ let codec;
222382
+ try {
222383
+ codec = await this.opts.audioCodec.createDecodeSession({
222384
+ codec: "opus",
222385
+ sourceSampleRate: opusRate,
222386
+ sourceChannels: opusChannels,
222387
+ targetSampleRate: targetRate,
222388
+ targetChannels: 1,
222389
+ targetFormat: "s16le",
222390
+ tag: `reolink-intercom:${this.opts.deviceTag}:${sessionId}`
222391
+ });
222392
+ } catch (err) {
222393
+ await talkSession.stop().catch(() => {});
222394
+ this.opts.logger.warn("intercom: audio-codec createDecodeSession failed", { meta: {
222395
+ error: errMsg$1(err),
222396
+ opusRate,
222397
+ opusChannels,
222398
+ targetRate
222399
+ } });
222400
+ throw err;
222401
+ }
222402
+ const peer = this.opts.peerFactory({ logger: this.opts.logger });
222403
+ const active = {
222404
+ sessionId,
222405
+ peer,
222406
+ talkSession,
222407
+ codec,
222408
+ closed: false,
222409
+ startedAtMs: Date.now(),
222410
+ framesPushed: 0,
222411
+ pcmBytesPushed: 0,
222412
+ answerApplied: false
222413
+ };
222414
+ this.session = active;
222415
+ peer.onOpusFrame((frame, pts) => {
222416
+ this.handleOpusFrame(active, frame, pts).catch((err) => {
222417
+ this.opts.logger.debug("intercom: opus frame pump error (dropped)", { meta: { error: errMsg$1(err) } });
222418
+ });
222419
+ });
222420
+ let offer;
222421
+ try {
222422
+ offer = await peer.createOffer();
222423
+ } catch (err) {
222424
+ await this.stop(sessionId, "start-failed-cleanup").catch(() => {});
222425
+ this.opts.logger.warn("intercom: webrtc createOffer failed", { meta: { error: errMsg$1(err) } });
222426
+ throw err;
222427
+ }
222428
+ this.opts.logger.info("intercom session opened", { meta: {
222429
+ sessionId,
222430
+ channel: this.opts.channel,
222431
+ targetRate,
222432
+ codecSessionId: codec.sessionId,
222433
+ codecNodeId: codec.nodeId
222434
+ } });
222435
+ return {
222436
+ sessionId,
222437
+ sdpOffer: offer.sdp
222438
+ };
222439
+ }
222440
+ /**
222441
+ * Apply the browser's SDP answer. The session is identified by id
222442
+ * (callers may have multiple cameras with overlapping intercom
222443
+ * sessions in flight — though this orchestrator only tracks one).
222444
+ * Mismatched session id throws.
222445
+ */
222446
+ async handleAnswer(sessionId, sdpAnswer) {
222447
+ const active = this.requireActive(sessionId);
222448
+ await active.peer.setAnswer(sdpAnswer);
222449
+ active.answerApplied = true;
222450
+ this.opts.logger.info("intercom: SDP answer accepted (handshake complete)", { meta: { sessionId } });
222451
+ }
222452
+ /**
222453
+ * Tear down the session in reverse-open order. Idempotent: a stop
222454
+ * for an unknown / already-closed session resolves silently. Best-
222455
+ * effort: a partial teardown (e.g. peer.close fails) does NOT
222456
+ * abort the rest — the next steps still try to release their own
222457
+ * resources.
222458
+ *
222459
+ * `reason` records WHY the session ended — surfaced in the close log so an
222460
+ * immediate-close is diagnosable from one line. The cap-router stop path
222461
+ * passes nothing and defaults to `'client-stop'`; internal teardown paths
222462
+ * (supersede, start-failure cleanup) pass their specific reason.
222463
+ */
222464
+ async stop(sessionId, reason = "client-stop") {
222465
+ const active = this.session;
222466
+ if (!active || active.sessionId !== sessionId || active.closed) return;
222467
+ active.closed = true;
222468
+ this.session = null;
222469
+ try {
222470
+ await active.peer.close();
222471
+ } catch (err) {
222472
+ this.opts.logger.debug("intercom: peer.close error (continuing)", { meta: {
222473
+ sessionId,
222474
+ error: errMsg$1(err)
222475
+ } });
222476
+ }
222477
+ try {
222478
+ await this.opts.audioCodec.closeSession({
222479
+ sessionId: active.codec.sessionId,
222480
+ nodeId: active.codec.nodeId
222481
+ });
222482
+ } catch (err) {
222483
+ this.opts.logger.debug("intercom: audio-codec.closeSession error (continuing)", { meta: {
222484
+ sessionId,
222485
+ error: errMsg$1(err)
222486
+ } });
222487
+ }
222488
+ try {
222489
+ await active.talkSession.stop();
222490
+ } catch (err) {
222491
+ this.opts.logger.debug("intercom: talk-session.stop error (continuing)", { meta: {
222492
+ sessionId,
222493
+ error: errMsg$1(err)
222494
+ } });
222495
+ }
222496
+ this.opts.logger.info("intercom session closed", { meta: {
222497
+ sessionId,
222498
+ reason,
222499
+ answerApplied: active.answerApplied,
222500
+ framesPushed: active.framesPushed,
222501
+ pcmBytesPushed: active.pcmBytesPushed,
222502
+ durationMs: Date.now() - active.startedAtMs
222503
+ } });
222504
+ }
222505
+ /**
222506
+ * Push one Opus frame into the audio-codec, immediately drain any
222507
+ * decoded PCM, and feed it to the talk session. Push-then-pull
222508
+ * keeps latency tight: each Opus frame produces ~20ms of PCM and
222509
+ * we surface it on the same async tick.
222510
+ *
222511
+ * Errors here are isolated to a single frame — the caller wraps in
222512
+ * a void-fire-and-forget so an audio-codec hiccup doesn't break
222513
+ * the RTP receive loop.
222514
+ */
222515
+ async handleOpusFrame(active, frame, pts) {
222516
+ if (active.closed) return;
222517
+ if (active.framesPushed === 0) this.opts.logger.info("intercom: first Opus frame received (feeding camera)", { meta: { sessionId: active.sessionId } });
222518
+ active.framesPushed += 1;
222519
+ await this.opts.audioCodec.pushEncodedFrame({
222520
+ sessionId: active.codec.sessionId,
222521
+ nodeId: active.codec.nodeId,
222522
+ data: frame,
222523
+ pts
222524
+ });
222525
+ if (active.closed) return;
222526
+ const chunks = await this.opts.audioCodec.pullPcm({
222527
+ sessionId: active.codec.sessionId,
222528
+ nodeId: active.codec.nodeId,
222529
+ maxCount: 8
222530
+ });
222531
+ if (active.closed) return;
222532
+ for (const chunk of chunks) {
222533
+ const buf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
222534
+ active.pcmBytesPushed += buf.length;
222535
+ active.talkSession.feedPcm(buf);
222536
+ }
222537
+ }
222538
+ requireActive(sessionId) {
222539
+ const active = this.session;
222540
+ if (!active || active.closed || active.sessionId !== sessionId) throw new Error(`Reolink intercom: unknown or closed sessionId ${sessionId}`);
222541
+ return active;
222542
+ }
222543
+ };
222544
+ /** Random-enough session id — no crypto requirement, just needs to be
222545
+ * unique-per-camera across reasonable timescales. */
222546
+ function generateSessionId() {
222547
+ return `intercom-${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
222548
+ }
222549
+ function errMsg$1(err) {
222550
+ return err instanceof Error ? err.message : String(err);
222551
+ }
222552
+ //#endregion
222553
+ //#region src/intercom-webrtc-peer.ts
222554
+ var _werift;
222555
+ /**
222556
+ * Lazy import — werift is an optional peer dep of this package
222557
+ * (declared via peerDependenciesMeta so npm install doesn't fail on
222558
+ * agents/clusters where the intercom is never used).
222559
+ */
222560
+ async function loadWerift() {
222561
+ if (_werift) return _werift;
222562
+ try {
222563
+ _werift = await Function("m", "return import(m)")("werift");
222564
+ return _werift;
222565
+ } catch {
222566
+ throw new Error("The 'werift' package is required for Reolink intercom support but is not installed. Install it with: npm install werift");
222567
+ }
222568
+ }
222569
+ var WeriftIntercomPeer = class {
222570
+ opts;
222571
+ pc = null;
222572
+ opusCallbacks = [];
222573
+ rtpUnsubscribe = null;
222574
+ trackUnsubscribe = null;
222575
+ closed = false;
222576
+ /** Anchor PTS to the first observed RTP timestamp so the audio-
222577
+ * codec receives monotonically-increasing PTS values from zero
222578
+ * rather than the camera's free-running 32-bit RTP clock. */
222579
+ firstRtpTimestamp = null;
222580
+ constructor(opts) {
222581
+ this.opts = opts;
222582
+ }
222583
+ onOpusFrame(cb) {
222584
+ if (this.closed) return;
222585
+ this.opusCallbacks.push(cb);
222586
+ }
222587
+ async createOffer() {
222588
+ if (this.pc) throw new Error("WeriftIntercomPeer: createOffer called twice");
222589
+ const werift = await loadWerift();
222590
+ const pcOptions = {};
222591
+ if (this.opts.iceServers && this.opts.iceServers.length > 0) pcOptions.iceServers = [...this.opts.iceServers];
222592
+ const pc = new werift.RTCPeerConnection(pcOptions);
222593
+ this.pc = pc;
222594
+ const localTrack = new werift.MediaStreamTrack({ kind: "audio" });
222595
+ const trackSub = pc.addTransceiver(localTrack, { direction: "sendrecv" }).onTrack.subscribe((track) => {
222596
+ if (track.kind !== "audio") return;
222597
+ const rtpSub = track.onReceiveRtp.subscribe((pkt) => {
222598
+ if (this.closed) return;
222599
+ const payload = pkt.payload;
222600
+ if (!payload || payload.length === 0) return;
222601
+ const ts = pkt.header.timestamp;
222602
+ if (this.firstRtpTimestamp === null) this.firstRtpTimestamp = ts;
222603
+ const relTs = ts - this.firstRtpTimestamp >>> 0;
222604
+ const ptsMs = Math.round(relTs / 48);
222605
+ for (const cb of this.opusCallbacks) try {
222606
+ cb(payload, ptsMs);
222607
+ } catch (err) {
222608
+ this.opts.logger.debug("intercom-peer: opus callback threw", { meta: { error: errMsg(err) } });
222609
+ }
222610
+ });
222611
+ if (rtpSub && typeof rtpSub.unsubscribe === "function") this.rtpUnsubscribe = () => rtpSub.unsubscribe?.();
222612
+ });
222613
+ if (trackSub && typeof trackSub.unsubscribe === "function") this.trackUnsubscribe = () => trackSub.unsubscribe?.();
222614
+ pc.iceConnectionStateChange.subscribe((state) => {
222615
+ this.opts.logger.info("intercom-peer: ICE state", { meta: { state } });
222616
+ });
222617
+ pc.iceGatheringStateChange.subscribe((state) => {
222618
+ this.opts.logger.debug("intercom-peer: ICE gathering", { meta: { state } });
222619
+ });
222620
+ const offer = await pc.createOffer();
222621
+ await pc.setLocalDescription(offer);
222622
+ return { sdp: pc.localDescription?.sdp ?? offer.sdp };
222623
+ }
222624
+ async setAnswer(sdp) {
222625
+ if (!this.pc) throw new Error("WeriftIntercomPeer: setAnswer called before createOffer");
222626
+ if (this.closed) throw new Error("WeriftIntercomPeer: setAnswer called on closed peer");
222627
+ await this.pc.setRemoteDescription({
222628
+ sdp,
222629
+ type: "answer"
222630
+ });
222631
+ }
222632
+ async close() {
222633
+ if (this.closed) return;
222634
+ this.closed = true;
222635
+ this.opusCallbacks = [];
222636
+ if (this.rtpUnsubscribe) {
222637
+ try {
222638
+ this.rtpUnsubscribe();
222639
+ } catch {}
222640
+ this.rtpUnsubscribe = null;
222641
+ }
222642
+ if (this.trackUnsubscribe) {
222643
+ try {
222644
+ this.trackUnsubscribe();
222645
+ } catch {}
222646
+ this.trackUnsubscribe = null;
222647
+ }
222648
+ if (this.pc) {
222649
+ try {
222650
+ await Promise.resolve(this.pc.close());
222651
+ } catch {}
222652
+ this.pc = null;
222653
+ }
222654
+ }
222655
+ };
222656
+ function errMsg(err) {
222657
+ return err instanceof Error ? err.message : String(err);
222658
+ }
222659
+ //#endregion
220991
222660
  //#region src/schema.ts
220992
222661
  /**
220993
222662
  * Single source of truth for the addon id used in event source tags +
@@ -221827,842 +223496,6 @@ function mapDetectionEvent(event, cameraId, nowMs) {
221827
223496
  function isNativeObjectForwardingEnabled(capState) {
221828
223497
  return capState?.enabled === true;
221829
223498
  }
221830
- //#endregion
221831
- //#region src/stream-routing.ts
221832
- var KIND_RE = /^(native|rtsp|rtmp|flv):(.+)$/;
221833
- var CH_PROFILE_RE = /^ch(\d+)-(main|sub|ext)$/;
221834
- var PROFILE_ONLY_RE = /^(main|sub|ext)$/;
221835
- /**
221836
- * Build a camStreamId from the (kind, channel, profile) tuple. Single-channel
221837
- * devices omit the `ch{N}-` infix; NVR / Hub include it so per-channel
221838
- * routing survives the round-trip through the broker.
221839
- */
221840
- function buildCamStreamId(kind, channel, profile, channelCount) {
221841
- if (channelCount > 1) return `${kind}:ch${channel}-${profile}`;
221842
- return `${kind}:${profile}`;
221843
- }
221844
- /**
221845
- * Parse a Reolink camStreamId back to its (kind, channel, profile) tuple.
221846
- * Returns `null` if the id does not match our format — the demand handler
221847
- * uses this to ignore non-native streams (broker pulls those directly).
221848
- */
221849
- function parseCamStreamId(camStreamId, defaultChannel) {
221850
- const m = KIND_RE.exec(camStreamId);
221851
- if (!m) return null;
221852
- const kind = m[1];
221853
- const rest = m[2];
221854
- const ch = CH_PROFILE_RE.exec(rest);
221855
- if (ch) return {
221856
- kind,
221857
- channel: parseInt(ch[1], 10),
221858
- profile: ch[2]
221859
- };
221860
- const p = PROFILE_ONLY_RE.exec(rest);
221861
- if (p) return {
221862
- kind,
221863
- channel: defaultChannel,
221864
- profile: p[1]
221865
- };
221866
- return null;
221867
- }
221868
- /** Render a human-readable label for the device-settings dropdown. */
221869
- function streamLabel(s, kind) {
221870
- const parts = [];
221871
- if (kind) parts.push(kindLabel(kind));
221872
- if (s.channel !== void 0) parts.push(`Ch${s.channel}`);
221873
- parts.push(s.profile.charAt(0).toUpperCase() + s.profile.slice(1));
221874
- if (s.lens && s.lens !== "wide") parts.push(`(${s.lens})`);
221875
- return parts.join(" ");
221876
- }
221877
- function kindLabel(kind) {
221878
- switch (kind) {
221879
- case "native": return "Native";
221880
- case "rtsp": return "RTSP";
221881
- case "rtmp": return "RTMP";
221882
- case "flv": return "FLV";
221883
- }
221884
- }
221885
- /**
221886
- * Synthetic native cam-stream id list used by `getStreamSources()` to
221887
- * advertise the device's expected stream shape before the lib has been
221888
- * called. `publishToBroker` ignores this — it always uses the live
221889
- * result of `buildVideoStreamOptions()` instead.
221890
- */
221891
- function buildStreamIds(channelCount) {
221892
- if (channelCount <= 1) return [{
221893
- id: "native:main",
221894
- label: "Native Main"
221895
- }, {
221896
- id: "native:sub",
221897
- label: "Native Sub"
221898
- }];
221899
- const out = [];
221900
- for (let ch = 0; ch < channelCount; ch++) {
221901
- out.push({
221902
- id: `native:ch${ch}-main`,
221903
- label: `Native Ch${ch} Main`
221904
- });
221905
- out.push({
221906
- id: `native:ch${ch}-sub`,
221907
- label: `Native Ch${ch} Sub`
221908
- });
221909
- }
221910
- return out;
221911
- }
221912
- //#endregion
221913
- //#region src/error-classifier.ts
221914
- /**
221915
- * Recognise transient Baichuan/socket failures that are expected to
221916
- * recover on the next reconnect cycle. Mirrors
221917
- * `scrypted-reolink-native/src/camera.ts isRecoverableBaichuanError` —
221918
- * keeping parity so the same wire-level conditions are treated the
221919
- * same way across plugins.
221920
- *
221921
- * Ported categories:
221922
- * - Baichuan-specific transport closes (`Baichuan socket closed`,
221923
- * `Baichuan UDP stream closed`, `Baichuan TCP socket is not
221924
- * connected`)
221925
- * - Generic TCP errors that fire as part of the same disconnect
221926
- * storm (`ECONNRESET`, `EPIPE`, `socket hang up`)
221927
- * - D2C disconnects (`D2C_DISC`) — UDP relay path teardown
221928
- *
221929
- * Callers downgrade these to WARN-level logs and trigger a fresh
221930
- * login on the next demand instead of bubbling a hard ERROR.
221931
- */
221932
- var RECOVERABLE_FRAGMENTS = [
221933
- "Baichuan socket closed",
221934
- "Baichuan UDP stream closed",
221935
- "Baichuan TCP socket is not connected",
221936
- "socket hang up",
221937
- "ECONNRESET",
221938
- "EPIPE",
221939
- "D2C_DISC"
221940
- ];
221941
- function isRecoverableBaichuanError(err) {
221942
- const message = err instanceof Error ? err.message : typeof err === "string" ? err : err?.toString?.() ?? "";
221943
- return RECOVERABLE_FRAGMENTS.some((fragment) => message.includes(fragment));
221944
- }
221945
- //#endregion
221946
- //#region src/intercom-encoder.ts
221947
- /**
221948
- * IMA ADPCM (DVI4) encoder — Reolink Baichuan talk-back wire format.
221949
- *
221950
- * Ported from `scrypted-reolink-native/src/intercom.ts` (BSD-2 like the
221951
- * rest of the cross-cam Scrypted infra). Reolink cameras expect ADPCM
221952
- * blocks of (4 + N) bytes:
221953
- * - 2 bytes: little-endian Int16 predictor (first PCM sample of the
221954
- * block, used to seed the decoder state on the camera side)
221955
- * - 1 byte: index into the IMA step table (always 0 — we re-seed
221956
- * the predictor on every block instead of carrying state forward)
221957
- * - 1 byte: padding
221958
- * - N bytes: 2 nibbles per byte, low nibble first, each nibble
221959
- * encodes one PCM sample's delta as `sign | delta3` (4 bits)
221960
- *
221961
- * The block size is camera-firmware specific; the lib reports it as
221962
- * `TalkSessionInfo.blockSize` per session.
221963
- *
221964
- * Why standalone?
221965
- * - Pure function — no I/O, easy to unit-test
221966
- * - Reusable from the (currently stub) WebRTC server-side intercom
221967
- * path AND from any future direct-PCM caller
221968
- * - Decoupled from werift / lib types
221969
- */
221970
- var IMA_INDEX_TABLE = Int8Array.from([
221971
- -1,
221972
- -1,
221973
- -1,
221974
- -1,
221975
- 2,
221976
- 4,
221977
- 6,
221978
- 8,
221979
- -1,
221980
- -1,
221981
- -1,
221982
- -1,
221983
- 2,
221984
- 4,
221985
- 6,
221986
- 8
221987
- ]);
221988
- var IMA_STEP_TABLE = Int16Array.from([
221989
- 7,
221990
- 8,
221991
- 9,
221992
- 10,
221993
- 11,
221994
- 12,
221995
- 13,
221996
- 14,
221997
- 16,
221998
- 17,
221999
- 19,
222000
- 21,
222001
- 23,
222002
- 25,
222003
- 28,
222004
- 31,
222005
- 34,
222006
- 37,
222007
- 41,
222008
- 45,
222009
- 50,
222010
- 55,
222011
- 60,
222012
- 66,
222013
- 73,
222014
- 80,
222015
- 88,
222016
- 97,
222017
- 107,
222018
- 118,
222019
- 130,
222020
- 143,
222021
- 157,
222022
- 173,
222023
- 190,
222024
- 209,
222025
- 230,
222026
- 253,
222027
- 279,
222028
- 307,
222029
- 337,
222030
- 371,
222031
- 408,
222032
- 449,
222033
- 494,
222034
- 544,
222035
- 598,
222036
- 658,
222037
- 724,
222038
- 796,
222039
- 876,
222040
- 963,
222041
- 1060,
222042
- 1166,
222043
- 1282,
222044
- 1411,
222045
- 1552,
222046
- 1707,
222047
- 1878,
222048
- 2066,
222049
- 2272,
222050
- 2499,
222051
- 2749,
222052
- 3024,
222053
- 3327,
222054
- 3660,
222055
- 4026,
222056
- 4428,
222057
- 4871,
222058
- 5358,
222059
- 5894,
222060
- 6484,
222061
- 7132,
222062
- 7845,
222063
- 8630,
222064
- 9493,
222065
- 10442,
222066
- 11487,
222067
- 12635,
222068
- 13899,
222069
- 15289,
222070
- 16818,
222071
- 18500,
222072
- 20350,
222073
- 22385,
222074
- 24623,
222075
- 27086,
222076
- 29794,
222077
- 32767
222078
- ]);
222079
- function clamp16(x) {
222080
- if (x > 32767) return 32767;
222081
- if (x < -32768) return -32768;
222082
- return x | 0;
222083
- }
222084
- /**
222085
- * Encode a PCM s16le buffer into IMA ADPCM blocks of size
222086
- * `(4 + blockSizeBytes)` each. The output length is a multiple of
222087
- * `4 + blockSizeBytes`. PCM samples that don't fit a full block at
222088
- * the end get folded into a final partial block (the camera tolerates
222089
- * trailing zeros from the unpopulated nibbles).
222090
- *
222091
- * Block layout per Reolink's wire format:
222092
- * bytes [0..1] = predictor (Int16 LE)
222093
- * byte [2] = step index (always 0 — re-seed each block)
222094
- * byte [3] = padding (0x00)
222095
- * bytes [4..N+3] = packed nibbles (2 samples per byte, low first)
222096
- *
222097
- * Each block carries `blockSizeBytes * 2 + 1` PCM samples (the +1
222098
- * is the predictor sample stored explicitly in the header).
222099
- */
222100
- function encodeImaAdpcm(pcm, blockSizeBytes) {
222101
- const samplesPerBlock = blockSizeBytes * 2 + 1;
222102
- const totalBlocks = Math.ceil(pcm.length / samplesPerBlock);
222103
- const outBlocks = [];
222104
- let sampleIndex = 0;
222105
- for (let b = 0; b < totalBlocks; b++) {
222106
- const block = Buffer.alloc(4 + blockSizeBytes);
222107
- let predictor = pcm[sampleIndex] ?? 0;
222108
- let index = 0;
222109
- block.writeInt16LE(predictor, 0);
222110
- block.writeUInt8(index, 2);
222111
- block.writeUInt8(0, 3);
222112
- sampleIndex++;
222113
- const codes = new Uint8Array(blockSizeBytes * 2);
222114
- for (let i = 0; i < codes.length; i++) {
222115
- const sample = pcm[sampleIndex] ?? predictor;
222116
- sampleIndex++;
222117
- let diff = sample - predictor;
222118
- let sign = 0;
222119
- if (diff < 0) {
222120
- sign = 8;
222121
- diff = -diff;
222122
- }
222123
- let step = IMA_STEP_TABLE[index] ?? 7;
222124
- let delta = 0;
222125
- let vpdiff = step >> 3;
222126
- if (diff >= step) {
222127
- delta |= 4;
222128
- diff -= step;
222129
- vpdiff += step;
222130
- }
222131
- step >>= 1;
222132
- if (diff >= step) {
222133
- delta |= 2;
222134
- diff -= step;
222135
- vpdiff += step;
222136
- }
222137
- step >>= 1;
222138
- if (diff >= step) {
222139
- delta |= 1;
222140
- vpdiff += step;
222141
- }
222142
- predictor = sign ? clamp16(predictor - vpdiff) : clamp16(predictor + vpdiff);
222143
- index += IMA_INDEX_TABLE[delta] ?? 0;
222144
- if (index < 0) index = 0;
222145
- if (index > 88) index = 88;
222146
- codes[i] = (delta | sign) & 15;
222147
- }
222148
- for (let i = 0; i < blockSizeBytes; i++) {
222149
- const lo = codes[i * 2] ?? 0;
222150
- const hi = codes[i * 2 + 1] ?? 0;
222151
- block[4 + i] = lo & 15 | (hi & 15) << 4;
222152
- }
222153
- outBlocks.push(block);
222154
- }
222155
- return Buffer.concat(outBlocks);
222156
- }
222157
- //#endregion
222158
- //#region src/intercom-session.ts
222159
- var DEFAULT_BACKLOG_MS = 120;
222160
- var MAX_BACKLOG_MS = 5e3;
222161
- var MIN_BACKLOG_MS = 20;
222162
- var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
222163
- var DEFAULT_GAIN = 1;
222164
- var MIN_GAIN = .1;
222165
- var MAX_GAIN = 10;
222166
- var ReolinkIntercomSession = class {
222167
- opts;
222168
- session = null;
222169
- pcmBuffer = Buffer.alloc(0);
222170
- pumping = false;
222171
- pumpPromise = null;
222172
- maxBacklogBytes = 0;
222173
- bytesPerBlock = 0;
222174
- blockSize = 0;
222175
- lastBacklogClampLogAtMs = 0;
222176
- outputGain = DEFAULT_GAIN;
222177
- constructor(opts) {
222178
- this.opts = opts;
222179
- }
222180
- /** True once `start()` has resolved and not yet been `stop()`'d. */
222181
- get isOpen() {
222182
- return this.session !== null;
222183
- }
222184
- /** Sample rate the camera negotiated. Throws when not started. */
222185
- get sampleRate() {
222186
- if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
222187
- return this.session.info.audioConfig.sampleRate;
222188
- }
222189
- async start() {
222190
- if (this.session) return;
222191
- this.outputGain = clampGain(this.opts.outputGain);
222192
- const session = await this.opts.api.createDedicatedTalkSession(this.opts.channel, {
222193
- blocksPerPayload: clampBlocks(this.opts.blocksPerPayload),
222194
- idleTimeoutMs: this.opts.idleTimeoutMs ?? 3e4,
222195
- deviceId: this.opts.deviceTag,
222196
- logger: { log: (msg, ...rest) => this.opts.logger.debug(`talk: ${msg}`, { meta: { rest } }) }
222197
- });
222198
- const { blockSize, fullBlockSize } = session.info;
222199
- if (!Number.isFinite(blockSize) || blockSize <= 0 || fullBlockSize !== blockSize + 4) {
222200
- try {
222201
- await session.stop();
222202
- } catch {}
222203
- throw new Error(`Reolink talk session reported invalid block sizes: blockSize=${blockSize} fullBlockSize=${fullBlockSize}`);
222204
- }
222205
- const samplesPerBlock = blockSize * 2 + 1;
222206
- this.bytesPerBlock = samplesPerBlock * 2;
222207
- this.blockSize = blockSize;
222208
- const sampleRate = session.info.audioConfig.sampleRate;
222209
- if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
222210
- try {
222211
- await session.stop();
222212
- } catch {}
222213
- throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
222214
- }
222215
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
222216
- this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
222217
- this.session = session;
222218
- this.pcmBuffer = Buffer.alloc(0);
222219
- this.opts.logger.info("intercom talk session opened", { meta: {
222220
- channel: this.opts.channel,
222221
- sampleRate,
222222
- blockSize,
222223
- bytesPerBlock: this.bytesPerBlock,
222224
- backlogMs: wantedBacklogMs,
222225
- maxBacklogBytes: this.maxBacklogBytes,
222226
- blocksPerPayload: clampBlocks(this.opts.blocksPerPayload),
222227
- outputGain: this.outputGain
222228
- } });
222229
- }
222230
- /**
222231
- * Feed a chunk of PCM s16le at `this.sampleRate` Hz. Returns
222232
- * immediately after enqueueing — the actual encode + send happens
222233
- * in a background pump. Calls before `start()` (or after `stop()`)
222234
- * silently drop the chunk so callers don't have to gate every
222235
- * push on `isOpen`.
222236
- */
222237
- feedPcm(pcm) {
222238
- if (!this.session) return;
222239
- if (pcm.length === 0) return;
222240
- this.pcmBuffer = this.pcmBuffer.length ? Buffer.concat([this.pcmBuffer, pcm]) : pcm;
222241
- if (this.pcmBuffer.length > this.maxBacklogBytes) {
222242
- const keep = this.maxBacklogBytes - this.maxBacklogBytes % 2;
222243
- const dropped = this.pcmBuffer.length - keep;
222244
- this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
222245
- const now = Date.now();
222246
- if (now - this.lastBacklogClampLogAtMs > 2e3) {
222247
- this.lastBacklogClampLogAtMs = now;
222248
- this.opts.logger.warn("intercom backlog clamped (dropping PCM)", { meta: {
222249
- droppedBytes: dropped,
222250
- keptBytes: keep,
222251
- maxBytes: this.maxBacklogBytes
222252
- } });
222253
- }
222254
- }
222255
- if (!this.pumping) this.startPump();
222256
- }
222257
- startPump() {
222258
- const session = this.session;
222259
- if (!session) return;
222260
- this.pumping = true;
222261
- this.pumpPromise = (async () => {
222262
- try {
222263
- while (true) {
222264
- if (this.session !== session) return;
222265
- if (this.pcmBuffer.length < this.bytesPerBlock) return;
222266
- const chunk = this.pcmBuffer.subarray(0, this.bytesPerBlock);
222267
- this.pcmBuffer = this.pcmBuffer.subarray(this.bytesPerBlock);
222268
- const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
222269
- const adpcm = encodeImaAdpcm(this.outputGain === 1 ? samples : applyGainInt16(samples, this.outputGain), this.blockSize);
222270
- await session.sendAudio(adpcm);
222271
- }
222272
- } catch (err) {
222273
- this.opts.logger.warn("intercom pump error — stopping", { meta: { error: err instanceof Error ? err.message : String(err) } });
222274
- } finally {
222275
- this.pumping = false;
222276
- }
222277
- })();
222278
- }
222279
- async stop() {
222280
- const session = this.session;
222281
- if (!session) return;
222282
- this.session = null;
222283
- this.pcmBuffer = Buffer.alloc(0);
222284
- if (this.pumpPromise) {
222285
- try {
222286
- await Promise.race([this.pumpPromise, new Promise((r) => setTimeout(r, 250))]);
222287
- } catch {}
222288
- this.pumpPromise = null;
222289
- }
222290
- try {
222291
- await Promise.race([session.stop(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("talk session stop timeout")), 2e3))]);
222292
- } catch (err) {
222293
- this.opts.logger.warn("intercom session stop error", { meta: { error: err instanceof Error ? err.message : String(err) } });
222294
- }
222295
- }
222296
- };
222297
- /** Clamp `blocksPerPayload` into the lib's accepted range. Mirrors
222298
- * scrypted-reolink-native's `intercom-mixin.ts:96-101` clamp. */
222299
- function clampBlocks(value) {
222300
- if (value === void 0 || !Number.isFinite(value)) return DEFAULT_BLOCKS_PER_PAYLOAD;
222301
- return Math.max(1, Math.min(8, Math.floor(value)));
222302
- }
222303
- /** Clamp `outputGain` into the operator-safe range. Same `[0.1, 10]`
222304
- * band as Scrypted (`intercom-mixin.ts:104-107`). */
222305
- function clampGain(value) {
222306
- if (value === void 0 || !Number.isFinite(value)) return DEFAULT_GAIN;
222307
- return Math.max(MIN_GAIN, Math.min(MAX_GAIN, value));
222308
- }
222309
- /** Apply a floating-point gain to each `Int16` sample in-place into
222310
- * a freshly-allocated buffer. The result is hard-clipped to int16
222311
- * bounds — soft saturation isn't worth the tradeoff for an intercom
222312
- * channel where occasional clipping is preferable to a perceived
222313
- * loudness mismatch. */
222314
- function applyGainInt16(samples, gain) {
222315
- const out = new Int16Array(samples.length);
222316
- for (let i = 0; i < samples.length; i++) {
222317
- const scaled = (samples[i] ?? 0) * gain;
222318
- out[i] = scaled > 32767 ? 32767 : scaled < -32768 ? -32768 : scaled;
222319
- }
222320
- return out;
222321
- }
222322
- //#endregion
222323
- //#region src/intercom-orchestrator.ts
222324
- /** Default Opus parameters used when the orchestrator caller doesn't
222325
- * override them. Browser Opus is canonically 48 kHz; mono is the only
222326
- * practical choice for an intercom (the camera's talk channel is
222327
- * mono — sending stereo would just cost bandwidth). */
222328
- var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
222329
- var DEFAULT_OPUS_CHANNELS = 1;
222330
- var DEFAULT_CAMERA_SAMPLE_RATE = 16e3;
222331
- var IntercomOrchestrator = class {
222332
- opts;
222333
- session = null;
222334
- constructor(opts) {
222335
- this.opts = opts;
222336
- }
222337
- /** True while a session is open (between `start()` resolve and
222338
- * `stop()` resolve). */
222339
- get isOpen() {
222340
- return this.session !== null && !this.session.closed;
222341
- }
222342
- /**
222343
- * Open a fresh WebRTC peer + audio-codec decode session + Reolink
222344
- * talk session, wire them, return the SDP offer. Throws (and tears
222345
- * down everything it had spun up) on any failure — the cap router
222346
- * surfaces the error to the client unchanged.
222347
- *
222348
- * Single-active-session semantics: a second `start()` while the
222349
- * first is still open closes the old session before opening the
222350
- * new one. The cap router enforces this on the caller side via
222351
- * `intercomSessions.size === 1` invariants.
222352
- */
222353
- async start() {
222354
- if (this.session && !this.session.closed) await this.stop(this.session.sessionId, "superseded-by-new-start").catch(() => {});
222355
- const sessionId = generateSessionId();
222356
- const cameraRate = this.opts.cameraSampleRate ?? DEFAULT_CAMERA_SAMPLE_RATE;
222357
- const opusRate = this.opts.opusSampleRate ?? DEFAULT_OPUS_SAMPLE_RATE;
222358
- const opusChannels = this.opts.opusChannels ?? DEFAULT_OPUS_CHANNELS;
222359
- this.opts.logger.info("intercom: negotiate (opening session)", { meta: {
222360
- sessionId,
222361
- channel: this.opts.channel,
222362
- opusRate,
222363
- opusChannels
222364
- } });
222365
- if (this.opts.wakeBeforeStart) try {
222366
- await this.opts.wakeBeforeStart();
222367
- } catch (err) {
222368
- this.opts.logger.warn("intercom: pre-wake failed", { meta: { error: errMsg$1(err) } });
222369
- throw err;
222370
- }
222371
- const talkSession = new ReolinkIntercomSession({
222372
- channel: this.opts.channel,
222373
- api: this.opts.api,
222374
- logger: this.opts.logger.withTags?.({ sessionId }) ?? this.opts.logger,
222375
- deviceTag: this.opts.deviceTag,
222376
- ...this.opts.blocksPerPayload !== void 0 ? { blocksPerPayload: this.opts.blocksPerPayload } : {},
222377
- ...this.opts.maxBacklogMs !== void 0 ? { maxBacklogMs: this.opts.maxBacklogMs } : {},
222378
- ...this.opts.outputGain !== void 0 ? { outputGain: this.opts.outputGain } : {}
222379
- });
222380
- try {
222381
- await talkSession.start();
222382
- } catch (err) {
222383
- this.opts.logger.warn("intercom: talk session open failed", { meta: { error: errMsg$1(err) } });
222384
- throw err;
222385
- }
222386
- const realCameraRate = talkSession.sampleRate;
222387
- const targetRate = Number.isFinite(realCameraRate) && realCameraRate > 0 ? realCameraRate : cameraRate;
222388
- let codec;
222389
- try {
222390
- codec = await this.opts.audioCodec.createDecodeSession({
222391
- codec: "opus",
222392
- sourceSampleRate: opusRate,
222393
- sourceChannels: opusChannels,
222394
- targetSampleRate: targetRate,
222395
- targetChannels: 1,
222396
- targetFormat: "s16le",
222397
- tag: `reolink-intercom:${this.opts.deviceTag}:${sessionId}`
222398
- });
222399
- } catch (err) {
222400
- await talkSession.stop().catch(() => {});
222401
- this.opts.logger.warn("intercom: audio-codec createDecodeSession failed", { meta: {
222402
- error: errMsg$1(err),
222403
- opusRate,
222404
- opusChannels,
222405
- targetRate
222406
- } });
222407
- throw err;
222408
- }
222409
- const peer = this.opts.peerFactory({ logger: this.opts.logger });
222410
- const active = {
222411
- sessionId,
222412
- peer,
222413
- talkSession,
222414
- codec,
222415
- closed: false,
222416
- startedAtMs: Date.now(),
222417
- framesPushed: 0,
222418
- pcmBytesPushed: 0,
222419
- answerApplied: false
222420
- };
222421
- this.session = active;
222422
- peer.onOpusFrame((frame, pts) => {
222423
- this.handleOpusFrame(active, frame, pts).catch((err) => {
222424
- this.opts.logger.debug("intercom: opus frame pump error (dropped)", { meta: { error: errMsg$1(err) } });
222425
- });
222426
- });
222427
- let offer;
222428
- try {
222429
- offer = await peer.createOffer();
222430
- } catch (err) {
222431
- await this.stop(sessionId, "start-failed-cleanup").catch(() => {});
222432
- this.opts.logger.warn("intercom: webrtc createOffer failed", { meta: { error: errMsg$1(err) } });
222433
- throw err;
222434
- }
222435
- this.opts.logger.info("intercom session opened", { meta: {
222436
- sessionId,
222437
- channel: this.opts.channel,
222438
- targetRate,
222439
- codecSessionId: codec.sessionId,
222440
- codecNodeId: codec.nodeId
222441
- } });
222442
- return {
222443
- sessionId,
222444
- sdpOffer: offer.sdp
222445
- };
222446
- }
222447
- /**
222448
- * Apply the browser's SDP answer. The session is identified by id
222449
- * (callers may have multiple cameras with overlapping intercom
222450
- * sessions in flight — though this orchestrator only tracks one).
222451
- * Mismatched session id throws.
222452
- */
222453
- async handleAnswer(sessionId, sdpAnswer) {
222454
- const active = this.requireActive(sessionId);
222455
- await active.peer.setAnswer(sdpAnswer);
222456
- active.answerApplied = true;
222457
- this.opts.logger.info("intercom: SDP answer accepted (handshake complete)", { meta: { sessionId } });
222458
- }
222459
- /**
222460
- * Tear down the session in reverse-open order. Idempotent: a stop
222461
- * for an unknown / already-closed session resolves silently. Best-
222462
- * effort: a partial teardown (e.g. peer.close fails) does NOT
222463
- * abort the rest — the next steps still try to release their own
222464
- * resources.
222465
- *
222466
- * `reason` records WHY the session ended — surfaced in the close log so an
222467
- * immediate-close is diagnosable from one line. The cap-router stop path
222468
- * passes nothing and defaults to `'client-stop'`; internal teardown paths
222469
- * (supersede, start-failure cleanup) pass their specific reason.
222470
- */
222471
- async stop(sessionId, reason = "client-stop") {
222472
- const active = this.session;
222473
- if (!active || active.sessionId !== sessionId || active.closed) return;
222474
- active.closed = true;
222475
- this.session = null;
222476
- try {
222477
- await active.peer.close();
222478
- } catch (err) {
222479
- this.opts.logger.debug("intercom: peer.close error (continuing)", { meta: {
222480
- sessionId,
222481
- error: errMsg$1(err)
222482
- } });
222483
- }
222484
- try {
222485
- await this.opts.audioCodec.closeSession({
222486
- sessionId: active.codec.sessionId,
222487
- nodeId: active.codec.nodeId
222488
- });
222489
- } catch (err) {
222490
- this.opts.logger.debug("intercom: audio-codec.closeSession error (continuing)", { meta: {
222491
- sessionId,
222492
- error: errMsg$1(err)
222493
- } });
222494
- }
222495
- try {
222496
- await active.talkSession.stop();
222497
- } catch (err) {
222498
- this.opts.logger.debug("intercom: talk-session.stop error (continuing)", { meta: {
222499
- sessionId,
222500
- error: errMsg$1(err)
222501
- } });
222502
- }
222503
- this.opts.logger.info("intercom session closed", { meta: {
222504
- sessionId,
222505
- reason,
222506
- answerApplied: active.answerApplied,
222507
- framesPushed: active.framesPushed,
222508
- pcmBytesPushed: active.pcmBytesPushed,
222509
- durationMs: Date.now() - active.startedAtMs
222510
- } });
222511
- }
222512
- /**
222513
- * Push one Opus frame into the audio-codec, immediately drain any
222514
- * decoded PCM, and feed it to the talk session. Push-then-pull
222515
- * keeps latency tight: each Opus frame produces ~20ms of PCM and
222516
- * we surface it on the same async tick.
222517
- *
222518
- * Errors here are isolated to a single frame — the caller wraps in
222519
- * a void-fire-and-forget so an audio-codec hiccup doesn't break
222520
- * the RTP receive loop.
222521
- */
222522
- async handleOpusFrame(active, frame, pts) {
222523
- if (active.closed) return;
222524
- if (active.framesPushed === 0) this.opts.logger.info("intercom: first Opus frame received (feeding camera)", { meta: { sessionId: active.sessionId } });
222525
- active.framesPushed += 1;
222526
- await this.opts.audioCodec.pushEncodedFrame({
222527
- sessionId: active.codec.sessionId,
222528
- nodeId: active.codec.nodeId,
222529
- data: frame,
222530
- pts
222531
- });
222532
- if (active.closed) return;
222533
- const chunks = await this.opts.audioCodec.pullPcm({
222534
- sessionId: active.codec.sessionId,
222535
- nodeId: active.codec.nodeId,
222536
- maxCount: 8
222537
- });
222538
- if (active.closed) return;
222539
- for (const chunk of chunks) {
222540
- const buf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
222541
- active.pcmBytesPushed += buf.length;
222542
- active.talkSession.feedPcm(buf);
222543
- }
222544
- }
222545
- requireActive(sessionId) {
222546
- const active = this.session;
222547
- if (!active || active.closed || active.sessionId !== sessionId) throw new Error(`Reolink intercom: unknown or closed sessionId ${sessionId}`);
222548
- return active;
222549
- }
222550
- };
222551
- /** Random-enough session id — no crypto requirement, just needs to be
222552
- * unique-per-camera across reasonable timescales. */
222553
- function generateSessionId() {
222554
- return `intercom-${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
222555
- }
222556
- function errMsg$1(err) {
222557
- return err instanceof Error ? err.message : String(err);
222558
- }
222559
- //#endregion
222560
- //#region src/intercom-webrtc-peer.ts
222561
- var _werift;
222562
- /**
222563
- * Lazy import — werift is an optional peer dep of this package
222564
- * (declared via peerDependenciesMeta so npm install doesn't fail on
222565
- * agents/clusters where the intercom is never used).
222566
- */
222567
- async function loadWerift() {
222568
- if (_werift) return _werift;
222569
- try {
222570
- _werift = await Function("m", "return import(m)")("werift");
222571
- return _werift;
222572
- } catch {
222573
- throw new Error("The 'werift' package is required for Reolink intercom support but is not installed. Install it with: npm install werift");
222574
- }
222575
- }
222576
- var WeriftIntercomPeer = class {
222577
- opts;
222578
- pc = null;
222579
- opusCallbacks = [];
222580
- rtpUnsubscribe = null;
222581
- trackUnsubscribe = null;
222582
- closed = false;
222583
- /** Anchor PTS to the first observed RTP timestamp so the audio-
222584
- * codec receives monotonically-increasing PTS values from zero
222585
- * rather than the camera's free-running 32-bit RTP clock. */
222586
- firstRtpTimestamp = null;
222587
- constructor(opts) {
222588
- this.opts = opts;
222589
- }
222590
- onOpusFrame(cb) {
222591
- if (this.closed) return;
222592
- this.opusCallbacks.push(cb);
222593
- }
222594
- async createOffer() {
222595
- if (this.pc) throw new Error("WeriftIntercomPeer: createOffer called twice");
222596
- const werift = await loadWerift();
222597
- const pcOptions = {};
222598
- if (this.opts.iceServers && this.opts.iceServers.length > 0) pcOptions.iceServers = [...this.opts.iceServers];
222599
- const pc = new werift.RTCPeerConnection(pcOptions);
222600
- this.pc = pc;
222601
- const localTrack = new werift.MediaStreamTrack({ kind: "audio" });
222602
- const trackSub = pc.addTransceiver(localTrack, { direction: "sendrecv" }).onTrack.subscribe((track) => {
222603
- if (track.kind !== "audio") return;
222604
- const rtpSub = track.onReceiveRtp.subscribe((pkt) => {
222605
- if (this.closed) return;
222606
- const payload = pkt.payload;
222607
- if (!payload || payload.length === 0) return;
222608
- const ts = pkt.header.timestamp;
222609
- if (this.firstRtpTimestamp === null) this.firstRtpTimestamp = ts;
222610
- const relTs = ts - this.firstRtpTimestamp >>> 0;
222611
- const ptsMs = Math.round(relTs / 48);
222612
- for (const cb of this.opusCallbacks) try {
222613
- cb(payload, ptsMs);
222614
- } catch (err) {
222615
- this.opts.logger.debug("intercom-peer: opus callback threw", { meta: { error: errMsg(err) } });
222616
- }
222617
- });
222618
- if (rtpSub && typeof rtpSub.unsubscribe === "function") this.rtpUnsubscribe = () => rtpSub.unsubscribe?.();
222619
- });
222620
- if (trackSub && typeof trackSub.unsubscribe === "function") this.trackUnsubscribe = () => trackSub.unsubscribe?.();
222621
- pc.iceConnectionStateChange.subscribe((state) => {
222622
- this.opts.logger.info("intercom-peer: ICE state", { meta: { state } });
222623
- });
222624
- pc.iceGatheringStateChange.subscribe((state) => {
222625
- this.opts.logger.debug("intercom-peer: ICE gathering", { meta: { state } });
222626
- });
222627
- const offer = await pc.createOffer();
222628
- await pc.setLocalDescription(offer);
222629
- return { sdp: pc.localDescription?.sdp ?? offer.sdp };
222630
- }
222631
- async setAnswer(sdp) {
222632
- if (!this.pc) throw new Error("WeriftIntercomPeer: setAnswer called before createOffer");
222633
- if (this.closed) throw new Error("WeriftIntercomPeer: setAnswer called on closed peer");
222634
- await this.pc.setRemoteDescription({
222635
- sdp,
222636
- type: "answer"
222637
- });
222638
- }
222639
- async close() {
222640
- if (this.closed) return;
222641
- this.closed = true;
222642
- this.opusCallbacks = [];
222643
- if (this.rtpUnsubscribe) {
222644
- try {
222645
- this.rtpUnsubscribe();
222646
- } catch {}
222647
- this.rtpUnsubscribe = null;
222648
- }
222649
- if (this.trackUnsubscribe) {
222650
- try {
222651
- this.trackUnsubscribe();
222652
- } catch {}
222653
- this.trackUnsubscribe = null;
222654
- }
222655
- if (this.pc) {
222656
- try {
222657
- await Promise.resolve(this.pc.close());
222658
- } catch {}
222659
- this.pc = null;
222660
- }
222661
- }
222662
- };
222663
- function errMsg(err) {
222664
- return err instanceof Error ? err.message : String(err);
222665
- }
222666
223499
  function formatUserLevel(level) {
222667
223500
  if (level === void 0 || level === null) return "user";
222668
223501
  if (typeof level === "string" && level.length > 0) return level;
@@ -222866,6 +223699,88 @@ function buildSessionsTabSections(snap, opts) {
222866
223699
  }];
222867
223700
  }
222868
223701
  //#endregion
223702
+ //#region src/stream-routing.ts
223703
+ var KIND_RE = /^(native|rtsp|rtmp|flv):(.+)$/;
223704
+ var CH_PROFILE_RE = /^ch(\d+)-(main|sub|ext)$/;
223705
+ var PROFILE_ONLY_RE = /^(main|sub|ext)$/;
223706
+ /**
223707
+ * Build a camStreamId from the (kind, channel, profile) tuple. Single-channel
223708
+ * devices omit the `ch{N}-` infix; NVR / Hub include it so per-channel
223709
+ * routing survives the round-trip through the broker.
223710
+ */
223711
+ function buildCamStreamId(kind, channel, profile, channelCount) {
223712
+ if (channelCount > 1) return `${kind}:ch${channel}-${profile}`;
223713
+ return `${kind}:${profile}`;
223714
+ }
223715
+ /**
223716
+ * Parse a Reolink camStreamId back to its (kind, channel, profile) tuple.
223717
+ * Returns `null` if the id does not match our format — the demand handler
223718
+ * uses this to ignore non-native streams (broker pulls those directly).
223719
+ */
223720
+ function parseCamStreamId(camStreamId, defaultChannel) {
223721
+ const m = KIND_RE.exec(camStreamId);
223722
+ if (!m) return null;
223723
+ const kind = m[1];
223724
+ const rest = m[2];
223725
+ const ch = CH_PROFILE_RE.exec(rest);
223726
+ if (ch) return {
223727
+ kind,
223728
+ channel: parseInt(ch[1], 10),
223729
+ profile: ch[2]
223730
+ };
223731
+ const p = PROFILE_ONLY_RE.exec(rest);
223732
+ if (p) return {
223733
+ kind,
223734
+ channel: defaultChannel,
223735
+ profile: p[1]
223736
+ };
223737
+ return null;
223738
+ }
223739
+ /** Render a human-readable label for the device-settings dropdown. */
223740
+ function streamLabel(s, kind) {
223741
+ const parts = [];
223742
+ if (kind) parts.push(kindLabel(kind));
223743
+ if (s.channel !== void 0) parts.push(`Ch${s.channel}`);
223744
+ parts.push(s.profile.charAt(0).toUpperCase() + s.profile.slice(1));
223745
+ if (s.lens && s.lens !== "wide") parts.push(`(${s.lens})`);
223746
+ return parts.join(" ");
223747
+ }
223748
+ function kindLabel(kind) {
223749
+ switch (kind) {
223750
+ case "native": return "Native";
223751
+ case "rtsp": return "RTSP";
223752
+ case "rtmp": return "RTMP";
223753
+ case "flv": return "FLV";
223754
+ }
223755
+ }
223756
+ /**
223757
+ * Synthetic native cam-stream id list used by `getStreamSources()` to
223758
+ * advertise the device's expected stream shape before the lib has been
223759
+ * called. `publishToBroker` ignores this — it always uses the live
223760
+ * result of `buildVideoStreamOptions()` instead.
223761
+ */
223762
+ function buildStreamIds(channelCount) {
223763
+ if (channelCount <= 1) return [{
223764
+ id: "native:main",
223765
+ label: "Native Main"
223766
+ }, {
223767
+ id: "native:sub",
223768
+ label: "Native Sub"
223769
+ }];
223770
+ const out = [];
223771
+ for (let ch = 0; ch < channelCount; ch++) {
223772
+ out.push({
223773
+ id: `native:ch${ch}-main`,
223774
+ label: `Native Ch${ch} Main`
223775
+ });
223776
+ out.push({
223777
+ id: `native:ch${ch}-sub`,
223778
+ label: `Native Ch${ch} Sub`
223779
+ });
223780
+ }
223781
+ return out;
223782
+ }
223783
+ //#endregion
222869
223784
  //#region src/synthetic-sdp.ts
222870
223785
  /**
222871
223786
  * Build a synthetic SDP for `pull-rfc4571` entries from device-side
@@ -222946,6 +223861,18 @@ function buildLazyRfc4571Url(camStreamId) {
222946
223861
  }
222947
223862
  //#endregion
222948
223863
  //#region src/reolink-camera.ts
223864
+ /**
223865
+ * The `<Compression>` stream blocks that can carry an audio flag, in the
223866
+ * order the camera reports them. Named once so the read
223867
+ * (`readStreamAudioProfiles`) and the write (`setAudioEnabled`) cannot cover
223868
+ * different sets — a mute that skipped a stream would leave the camera
223869
+ * audible while reporting itself silent.
223870
+ */
223871
+ var REOLINK_AUDIO_STREAM_KEYS = [
223872
+ "mainStream",
223873
+ "subStream",
223874
+ "thirdStream"
223875
+ ];
222949
223876
  /** Generate a short random hex token for per-stream RTSP-style credentials. */
222950
223877
  function randomToken(bytes) {
222951
223878
  return (0, node_crypto.randomBytes)(bytes).toString("hex");
@@ -225528,6 +226455,30 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225528
226455
  * 0..1 rect into the firmware-specific packed-int `(pixel << 16) | canvas`
225529
226456
  * coords. The master `enable` flag rides the same call.
225530
226457
  */
226458
+ /**
226459
+ * Every `<Compression>` stream block of this camera that reports an audio
226460
+ * flag, with its current value.
226461
+ *
226462
+ * A block whose `audio` field is absent is OMITTED, not reported as `false`
226463
+ * — "this firmware does not expose an audio flag" and "the microphone is
226464
+ * off" are different answers, and only the second justifies offering the
226465
+ * operator a switch. An empty array is the honest "no controllable audio
226466
+ * input here".
226467
+ */
226468
+ async readStreamAudioProfiles() {
226469
+ const compression = (await (await this.ensureApi()).getEnc(this.getChannel()))?.body?.Compression;
226470
+ if (!compression) return [];
226471
+ const out = [];
226472
+ for (const streamKey of REOLINK_AUDIO_STREAM_KEYS) {
226473
+ const raw = compression[streamKey]?.audio;
226474
+ if (typeof raw !== "number") continue;
226475
+ out.push({
226476
+ streamKey,
226477
+ audioEnabled: raw !== 0
226478
+ });
226479
+ }
226480
+ return out;
226481
+ }
225531
226482
  registerPrivacyMaskCap() {
225532
226483
  const channel = this.getChannel();
225533
226484
  const CAP_NAME = "privacy-mask";
@@ -225536,7 +226487,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225536
226487
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
225537
226488
  const promise = (async () => {
225538
226489
  try {
225539
- const zones = await (await this.ensureApi()).getMaskZones(channel);
226490
+ const api = await this.ensureApi();
226491
+ const [zones, audioProfiles] = await Promise.all([api.getMaskZones(channel), this.readStreamAudioProfiles().catch(() => [])]);
225540
226492
  this.ctx.logger.debug("reolink privacy-mask getMaskZones", {
225541
226493
  tags: { deviceId: this.id },
225542
226494
  meta: {
@@ -225559,6 +226511,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225559
226511
  const next = {
225560
226512
  enabled: zones.enable,
225561
226513
  regions,
226514
+ audioEnabled: summarisePrivacyAudio(audioProfiles),
225562
226515
  lastFetchedAt: Date.now()
225563
226516
  };
225564
226517
  this.runtimeState.setCapState(CAP_NAME, next);
@@ -225586,27 +226539,32 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225586
226539
  empty: () => ({
225587
226540
  enabled: false,
225588
226541
  regions: [],
226542
+ audioEnabled: null,
225589
226543
  lastFetchedAt: 0
225590
226544
  })
225591
226545
  }).getStatus,
225592
226546
  getOptions: async ({ deviceId }) => {
225593
226547
  if (deviceId !== this.id) return {
225594
226548
  maxRegions: 4,
225595
- supportedShapes: ["rect"]
226549
+ supportedShapes: ["rect"],
226550
+ supportsAudioMute: false
225596
226551
  };
225597
226552
  return this.resolveCapOptions({
225598
226553
  capName: CAP_NAME,
225599
226554
  schema: PrivacyMaskOptionsSchema,
225600
226555
  probe: async () => {
225601
- const zones = await (await this.ensureApi()).getMaskZones(channel);
226556
+ const api = await this.ensureApi();
226557
+ const [zones, audioProfiles] = await Promise.all([api.getMaskZones(channel), this.readStreamAudioProfiles().catch(() => [])]);
225602
226558
  return {
225603
226559
  maxRegions: zones.maxNum,
225604
- supportedShapes: zones.maxNum > 0 ? ["rect"] : []
226560
+ supportedShapes: zones.maxNum > 0 ? ["rect"] : [],
226561
+ supportsAudioMute: audioProfiles.length > 0
225605
226562
  };
225606
226563
  },
225607
226564
  fallback: () => ({
225608
226565
  maxRegions: 4,
225609
- supportedShapes: ["rect"]
226566
+ supportedShapes: ["rect"],
226567
+ supportsAudioMute: false
225610
226568
  })
225611
226569
  });
225612
226570
  },
@@ -225644,6 +226602,49 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225644
226602
  tags: { deviceId: this.id },
225645
226603
  meta: { regions: JSON.stringify(this.runtimeState.getCapState(CAP_NAME)?.regions ?? []) }
225646
226604
  });
226605
+ },
226606
+ setAudioEnabled: async ({ deviceId, enabled }) => {
226607
+ if (deviceId !== this.id) return;
226608
+ const profiles = await this.readStreamAudioProfiles();
226609
+ if (profiles.length === 0) {
226610
+ this.ctx.logger.warn("reolink privacy audio: camera reports NO audio flag — refusing", { tags: { deviceId: this.id } });
226611
+ throw new Error(`device ${String(this.id)}: this camera exposes no controllable audio input`);
226612
+ }
226613
+ const value = enabled ? 1 : 0;
226614
+ const api = await this.ensureApi();
226615
+ for (const profile of profiles) {
226616
+ const patch = { [profile.streamKey]: { audio: value } };
226617
+ await api.setEnc(channel, patch);
226618
+ }
226619
+ const refused = (await this.readStreamAudioProfiles().catch(() => [])).filter((p) => p.audioEnabled !== enabled).map((p) => p.streamKey);
226620
+ if (refused.length > 0) {
226621
+ const revert = enabled ? 0 : 1;
226622
+ for (const profile of profiles) {
226623
+ if (refused.includes(profile.streamKey)) continue;
226624
+ await api.setEnc(channel, { [profile.streamKey]: { audio: revert } }).catch(() => void 0);
226625
+ }
226626
+ this.ctx.logger.warn("reolink privacy audio: stream REFUSED the write — reverted, camera still audible", {
226627
+ tags: { deviceId: this.id },
226628
+ meta: {
226629
+ enabled,
226630
+ refused: refused.join(","),
226631
+ asked: profiles.length
226632
+ }
226633
+ });
226634
+ this.cachedStreamDescriptors = void 0;
226635
+ await refreshFromCamera();
226636
+ throw new Error(`device ${String(this.id)}: the camera refused to change audio on ${refused.join(", ")} — it would still carry sound, so the change was reverted`);
226637
+ }
226638
+ this.ctx.logger.info("reolink privacy audio: microphone written", {
226639
+ tags: { deviceId: this.id },
226640
+ meta: {
226641
+ enabled,
226642
+ streams: profiles.map((p) => p.streamKey).join(",")
226643
+ }
226644
+ });
226645
+ this.cachedStreamDescriptors = void 0;
226646
+ this.ctx.eventBus.emit(createEvent(EventCategory.StreamParamsChanged, this.eventSource(), { deviceId: this.id }));
226647
+ await refreshFromCamera();
225647
226648
  }
225648
226649
  };
225649
226650
  this.registerCapWarmer(CAP_NAME, async () => {
@@ -229344,8 +230345,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
229344
230345
  * cap `framerate` → `frameRate` (fps, same unit)
229345
230346
  * cap `gop` → `gop` (seconds, same unit)
229346
230347
  * cap `encoderProfile` → `encoderProfile` (direct passthrough)
229347
- * cap `audio` → `audio` (boolean → 0/1)
229348
230348
  * cap `width`/`height` → `width`/`height` (pixels, direct passthrough)
230349
+ *
230350
+ * There is deliberately NO audio branch. `StreamProfilePatch` carried one
230351
+ * until 2026-08-07, reachable from no form and no caller, while the camera's
230352
+ * microphone is a whole-device fact. It now has exactly one writer —
230353
+ * `privacyMask.setAudioEnabled`, which patches every stream block together so
230354
+ * the camera cannot end up half-muted.
229349
230355
  */
229350
230356
  function buildEncStreamPatch(patch) {
229351
230357
  const out = {};
@@ -229355,7 +230361,6 @@ function buildEncStreamPatch(patch) {
229355
230361
  if (patch.framerate !== void 0) out.frameRate = patch.framerate;
229356
230362
  if (patch.gop !== void 0) out.gop = patch.gop;
229357
230363
  if (patch.encoderProfile !== void 0) out.encoderProfile = patch.encoderProfile;
229358
- if (patch.audio !== void 0) out.audio = patch.audio ? 1 : 0;
229359
230364
  if (patch.width !== void 0) out.width = patch.width;
229360
230365
  if (patch.height !== void 0) out.height = patch.height;
229361
230366
  return out;