@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.mjs CHANGED
@@ -1,14 +1,14 @@
1
1
  import { a as __toCommonJS, i as __require, n as __esmMin, o as __toESM, r as __exportAll, t as __commonJSMin } from "./chunk-CNf5ZN-e.mjs";
2
2
  import { A as splitAnnexBToNalPayloads2, C as extractVpsFromAnnexB, D as normalizeDebugOptions, E as md5StrModern, M as traceLog, O as recordingsTraceLog, S as extractSpsFromAnnexB, T as isH265Irap, _ as convertToAnnexB2, a as BC_MAGIC, b as eventTraceLog, c as BaichuanVideoStream, d as aesDecrypt, f as aesEncrypt, g as convertToAnnexB, h as bcHeaderHasPayloadOffset, i as BC_CLASS_MODERN_24, j as talkTraceLog, k as splitAnnexBToNalPayloads, l as BcMediaAnnexBDecoder, m as bcEncrypt, n as BC_CLASS_LEGACY, o as BC_MAGIC_REV, p as bcDecrypt, r as BC_CLASS_MODERN_20, t as BC_CLASS_FILE_DOWNLOAD, u as __require$1, v as debugLog, w as getH265NalType, x as extractPpsFromAnnexB, y as deriveAesKey } from "./chunk-MZUSWKF3-yQgmMM4X.mjs";
3
+ import { createHash, randomBytes } from "node:crypto";
3
4
  import { EventEmitter } from "events";
4
5
  import * as fs2 from "fs";
5
6
  import fs, { existsSync, promises } from "fs";
6
7
  import * as path2 from "path";
7
8
  import path, { dirname, join } from "path";
8
9
  import * as crypto$7 from "crypto";
9
- import crypto$1, { createHash, randomUUID } from "crypto";
10
+ import crypto$1, { createHash as createHash$1, randomUUID } from "crypto";
10
11
  import { execFile, spawn } from "child_process";
11
- import { createHash as createHash$1, randomBytes } from "node:crypto";
12
12
  import { PassThrough } from "stream";
13
13
  import { format, promisify } from "util";
14
14
  import * as dgram2 from "dgram";
@@ -6994,6 +6994,14 @@ var EncodeProfileSchema = object({
6994
6994
  "main",
6995
6995
  "high"
6996
6996
  ]).optional(),
6997
+ /**
6998
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
6999
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7000
+ * it, or it ships a stream that does not match its own advertisement — the
7001
+ * defect class that kept HomeKit black for a year and that Alexa carried
7002
+ * silently. Optional because a browser negotiates the level itself.
7003
+ */
7004
+ level: string().optional(),
6997
7005
  width: number().int().positive().optional(),
6998
7006
  height: number().int().positive().optional(),
6999
7007
  fps: number().positive().optional(),
@@ -7041,6 +7049,29 @@ var EncodeProfileSchema = object({
7041
7049
  outputArgs: array(string()).optional()
7042
7050
  });
7043
7051
  /**
7052
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7053
+ * Baseline because it is the one profile every consumer in this repo decodes
7054
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7055
+ */
7056
+ var BASE_LIVE_EGRESS_PROFILE = {
7057
+ video: {
7058
+ codec: "h264",
7059
+ profile: "baseline",
7060
+ level: "3.1",
7061
+ width: 1280,
7062
+ height: 720,
7063
+ fps: 25,
7064
+ bitrateKbps: 2500,
7065
+ gopFrames: 25,
7066
+ bf: 0,
7067
+ preset: "veryfast",
7068
+ tune: "zerolatency"
7069
+ },
7070
+ audio: "passthrough"
7071
+ };
7072
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7073
+ ({ ...BASE_LIVE_EGRESS_PROFILE });
7074
+ /**
7044
7075
  * Deep wiring healthcheck — snapshot of active reachability probes across
7045
7076
  * every declared capability + widget of every installed plugin, on every
7046
7077
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7090,6 +7121,154 @@ object({
7090
7121
  })
7091
7122
  });
7092
7123
  /**
7124
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7125
+ * pipeline functions an operator thinks in terms of.
7126
+ *
7127
+ * ## This file adds no state
7128
+ *
7129
+ * Every switch here is a VIEW onto an authority that already existed
7130
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7131
+ * group is that there is exactly one place each function is turned off, and
7132
+ * the group routes to it:
7133
+ *
7134
+ * | Switch | Authority | Proven "off stops the work" gate |
7135
+ * | --- | --- | --- |
7136
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7137
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7138
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7139
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7140
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7141
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7142
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7143
+ *
7144
+ * ## The two switches whose authority is not on this server
7145
+ *
7146
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7147
+ * in "the group stores nothing" — it is the purest form of it: the camera
7148
+ * holds the fact, every read is a read-through, and there is no server-side
7149
+ * copy that could drift. Their availability therefore cannot come from
7150
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7151
+ * is filtered out there); it comes from the cap's own camera-probed
7152
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7153
+ * THIS camera rather than for the device type
7154
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7155
+ *
7156
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7157
+ *
7158
+ * Every other switch means *this camera's function is doing its job*, so
7159
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7160
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7161
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7162
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7163
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7164
+ * worse than either surface not having one.
7165
+ *
7166
+ * Two consequences follow and both are load-bearing:
7167
+ *
7168
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7169
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7170
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7171
+ * normal, healthy state of most cameras rendered as an operator disablement.
7172
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7173
+ * unconditionally by both clients, so for this row it has to read correctly
7174
+ * whichever way the switch is sitting.
7175
+ *
7176
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7177
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7178
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7179
+ * surface that decision never got.
7180
+ *
7181
+ * ## Two rules that are load-bearing
7182
+ *
7183
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7184
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7185
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7186
+ * turning the camera back on would then silently record nothing.
7187
+ * - **A switch that is off must be reported as off**, not merely produce
7188
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7189
+ * "disabled by an operator" instead of "broken" — see
7190
+ * `CameraStatus.switchedOff`.
7191
+ */
7192
+ /**
7193
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7194
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7195
+ * recognition and plate/LPR are per-step toggles on
7196
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7197
+ * editor, not in a safety group.
7198
+ */
7199
+ var CameraSwitchIdSchema = _enum([
7200
+ "stream-broker",
7201
+ "object-detection",
7202
+ "privacy-mask",
7203
+ "device-audio",
7204
+ "audio-analysis",
7205
+ "recording",
7206
+ "notifications"
7207
+ ]);
7208
+ /**
7209
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7210
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7211
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7212
+ * a compile error rather than a review comment.
7213
+ */
7214
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7215
+ object({ kind: literal("device-disabled") }),
7216
+ object({
7217
+ kind: literal("wrapper-binding"),
7218
+ capName: string()
7219
+ }),
7220
+ object({ kind: literal("recording-config") }),
7221
+ object({ kind: literal("notification-mute") }),
7222
+ object({
7223
+ kind: literal("camera-audio"),
7224
+ capName: string()
7225
+ }),
7226
+ object({
7227
+ kind: literal("camera-mask"),
7228
+ capName: string()
7229
+ })
7230
+ ]);
7231
+ /**
7232
+ * Why a switch is not offered for this camera. Rendered instead of the
7233
+ * control, never as a dead control — an absent function and a broken one must
7234
+ * not look the same.
7235
+ */
7236
+ var CameraSwitchUnavailableReasonSchema = _enum([
7237
+ "no-provider",
7238
+ "source-unreachable",
7239
+ "not-configured"
7240
+ ]);
7241
+ /**
7242
+ * One switch, resolved for one camera.
7243
+ *
7244
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7245
+ * client-side: the viewer is a separate repository that does not import
7246
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7247
+ * that will disagree with itself. Five rows per camera is nothing.
7248
+ */
7249
+ var CameraSwitchSchema = object({
7250
+ id: CameraSwitchIdSchema,
7251
+ label: string(),
7252
+ /**
7253
+ * What the operator LOSES while this is off, in one sentence. Required, not
7254
+ * optional: a switch that cannot say what it costs should not ship.
7255
+ */
7256
+ costWhenOff: string(),
7257
+ /** False = do not render a control. `unavailableReason` says why. */
7258
+ available: boolean(),
7259
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7260
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7261
+ enabled: boolean(),
7262
+ authority: CameraSwitchAuthoritySchema
7263
+ });
7264
+ /** The whole group for one camera. */
7265
+ var CameraSwitchGroupSchema = object({
7266
+ deviceId: number().int(),
7267
+ switches: array(CameraSwitchSchema).readonly(),
7268
+ /** Unix ms when the group was composed server-side. */
7269
+ fetchedAt: number()
7270
+ });
7271
+ /**
7093
7272
  * Ops-log — the durable, append-only operations audit shared by the
7094
7273
  * recordings and events management surfaces.
7095
7274
  *
@@ -7108,14 +7287,16 @@ var OpsLogOpSchema = _enum([
7108
7287
  "manual-delete",
7109
7288
  "rescan",
7110
7289
  "retention-run",
7111
- "relocate"
7290
+ "relocate",
7291
+ "orphan-audit"
7112
7292
  ]);
7113
7293
  /** Why the operation ran. */
7114
7294
  var OpsLogReasonSchema = _enum([
7115
7295
  "retention",
7116
7296
  "quota",
7117
7297
  "manual",
7118
- "operator"
7298
+ "operator",
7299
+ "maintenance"
7119
7300
  ]);
7120
7301
  /** One audit row, shared verbatim by both domains. */
7121
7302
  var OpsLogEntrySchema = object({
@@ -9151,6 +9332,126 @@ var RtpSourceSchema = object({
9151
9332
  encoder: string(),
9152
9333
  pipelineKey: string()
9153
9334
  });
9335
+ /**
9336
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9337
+ * hatch.** This is deliberate and it is the one lesson taken from
9338
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9339
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9340
+ * adding a flag silently forks the shared child, and two consumers that mean
9341
+ * the same thing but spell it differently never share. Here every knob is a
9342
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9343
+ * never an opaque array.
9344
+ *
9345
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9346
+ * The operator-facing derived-stream transform editor still has them — that is
9347
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9348
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9349
+ */
9350
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9351
+ inputArgs: true,
9352
+ outputArgs: true
9353
+ });
9354
+ /**
9355
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9356
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9357
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9358
+ * an Echo). Named rather than numeric so the INTENT survives.
9359
+ */
9360
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9361
+ var EgressTranscodeRequestSchema = object({
9362
+ deviceId: number().int().nonnegative(),
9363
+ /** Which published stream to read. */
9364
+ source: discriminatedUnion("kind", [object({
9365
+ kind: literal("profile"),
9366
+ profile: CamProfileSchema
9367
+ }), object({
9368
+ kind: literal("cam-stream"),
9369
+ camStreamId: string().min(1)
9370
+ })]),
9371
+ encode: EgressEncodeSchema,
9372
+ rateControl: EgressRateControlSchema.optional(),
9373
+ /**
9374
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9375
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9376
+ * branches. Enumerated, not free text.
9377
+ */
9378
+ bitstreamFilter: _enum([
9379
+ "dump_extra",
9380
+ "h264_mp4toannexb",
9381
+ "hevc_mp4toannexb"
9382
+ ]).optional(),
9383
+ /**
9384
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9385
+ * consumer to dial the returned url. The broker picks the id and returns it
9386
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9387
+ * sharing key is computed FROM this request.
9388
+ *
9389
+ * The url is still returned and still the contract for a transcode pinned to
9390
+ * another node. But dialling it locally costs an RTSP round trip that changes
9391
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9392
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9393
+ * repacketizer branch. The push branch — the one the derived mechanism has
9394
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9395
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9396
+ * other point.
9397
+ *
9398
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9399
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9400
+ * This is that rule for media.
9401
+ */
9402
+ publishLocally: boolean().optional(),
9403
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9404
+ /**
9405
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9406
+ * and the one that matters: the broker then resolves the backend from the
9407
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9408
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9409
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9410
+ */
9411
+ decodeHwAccel: _enum([
9412
+ "auto",
9413
+ "none",
9414
+ "videotoolbox",
9415
+ "vaapi",
9416
+ "qsv",
9417
+ "cuda"
9418
+ ]).optional(),
9419
+ /**
9420
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9421
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9422
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9423
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9424
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9425
+ */
9426
+ hostname: string().optional(),
9427
+ /** Attribution for the broker panel. Never part of the sharing key. */
9428
+ tag: string().optional()
9429
+ });
9430
+ var EgressTranscodeSchema = object({
9431
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9432
+ url: string(),
9433
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9434
+ pipelineKey: string(),
9435
+ videoCodec: _enum(["H264", "H265"]),
9436
+ resolution: object({
9437
+ width: number().int().positive(),
9438
+ height: number().int().positive()
9439
+ }),
9440
+ transcoded: boolean(),
9441
+ encoder: string(),
9442
+ /**
9443
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9444
+ * Returned rather than assumed: a consumer that asked for hardware and got
9445
+ * software needs to be able to see that without reading the broker's logs.
9446
+ */
9447
+ decodeHwAccel: string().nullable(),
9448
+ /**
9449
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9450
+ * `url`, and the session takes the push/deframe transport rather than the
9451
+ * RTP-passthrough one. `null` means the consumer must dial.
9452
+ */
9453
+ camStreamId: string().nullable()
9454
+ });
9154
9455
  method(object({
9155
9456
  deviceId: number().int().nonnegative(),
9156
9457
  camStreamId: string().min(1),
@@ -9260,6 +9561,15 @@ method(object({
9260
9561
  }), {
9261
9562
  kind: "mutation",
9262
9563
  auth: "admin"
9564
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9565
+ kind: "mutation",
9566
+ auth: "admin"
9567
+ }), method(object({ pipelineKey: string() }), object({
9568
+ released: boolean(),
9569
+ refcount: number().int().nonnegative()
9570
+ }), {
9571
+ kind: "mutation",
9572
+ auth: "admin"
9263
9573
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9264
9574
  subscriptionId: string(),
9265
9575
  maxCount: number().int().positive().default(8)
@@ -13876,12 +14186,13 @@ var NcConditionsSchema = object({
13876
14186
  * source; otherwise the subject's source must equal it. Legacy records
13877
14187
  * with no stamped source are treated as `pipeline`. The union spans both
13878
14188
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13879
- * tracks carry `sensor`.
14189
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13880
14190
  */
13881
14191
  source: _enum([
13882
14192
  "pipeline",
13883
14193
  "onboard",
13884
14194
  "sensor",
14195
+ "audio",
13885
14196
  "any"
13886
14197
  ]).optional(),
13887
14198
  /**
@@ -14457,6 +14768,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14457
14768
  }), object({ success: literal(true) }), {
14458
14769
  kind: "mutation",
14459
14770
  auth: "admin"
14771
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14772
+ deviceId: number().int(),
14773
+ muted: boolean()
14774
+ }), object({ success: literal(true) }), {
14775
+ kind: "mutation",
14776
+ auth: "admin"
14460
14777
  }), method(object({
14461
14778
  rule: NcRuleInputSchema,
14462
14779
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14795,12 +15112,60 @@ var TrackAudioLabelSchema = object({
14795
15112
  });
14796
15113
  /**
14797
15114
  * How a track was produced. `pipeline` (default / absent) = the spatial
14798
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14799
- * linked sensor/control state change (no positions; carries a snapshot). The
14800
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14801
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
15115
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
15116
+ * no positions, a single snapshot, and no bbox trajectory at all:
15117
+ *
15118
+ * - `sensor` — a linked sensor/control device state change.
15119
+ * - `audio` — an audio event on the camera itself that was anomalous for
15120
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
15121
+ *
15122
+ * The spatial subsystems (tracker association, occupancy count, re-id /
15123
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
15124
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
15125
+ * check silently readmits every source added after it was written.
14802
15126
  */
14803
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
15127
+ var TrackSourceSchema = _enum([
15128
+ "pipeline",
15129
+ "sensor",
15130
+ "audio"
15131
+ ]);
15132
+ /**
15133
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15134
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15135
+ * so the two surfaces cannot drift.
15136
+ *
15137
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
15138
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
15139
+ * columns existed read as absent, and a consumer that needs a boolean should say
15140
+ * `flag === true`, not `flag !== false`.
15141
+ *
15142
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15143
+ * operator curation, and the behaviour they drive will be specified separately.
15144
+ * In particular a `markForTrain` track is NOT pinned against retention — see
15145
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15146
+ */
15147
+ var TrackFlagFields = {
15148
+ /** Operator marked this track as training material. */
15149
+ markForTrain: boolean().optional(),
15150
+ /** Operator marked this track for diagnostic attention. */
15151
+ debug: boolean().optional()
15152
+ };
15153
+ /**
15154
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15155
+ * one flag can never clear the other — the toggles are independent and are
15156
+ * driven from three surfaces that do not know about each other.
15157
+ */
15158
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
15159
+ /**
15160
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
15161
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
15162
+ * mutation result without a re-fetch.
15163
+ */
15164
+ var TrackFlagsSchema = object({
15165
+ trackId: string(),
15166
+ markForTrain: boolean(),
15167
+ debug: boolean()
15168
+ });
14804
15169
  var TrackSchema = object({
14805
15170
  trackId: string(),
14806
15171
  deviceId: number(),
@@ -14843,7 +15208,8 @@ var TrackSchema = object({
14843
15208
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14844
15209
  * Populated from the persisted envelope columns on historical reads;
14845
15210
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14846
- envelope: TrackEnvelopeSchema.optional()
15211
+ envelope: TrackEnvelopeSchema.optional(),
15212
+ ...TrackFlagFields
14847
15213
  });
14848
15214
  var BaseEventFields = {
14849
15215
  id: string(),
@@ -15056,7 +15422,8 @@ var KeyEventSchema = object({
15056
15422
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15057
15423
  bestEventId: string(),
15058
15424
  /** Track lifetime in ms (lastSeen - firstSeen). */
15059
- windowMs: number().optional()
15425
+ windowMs: number().optional(),
15426
+ ...TrackFlagFields
15060
15427
  });
15061
15428
  object({
15062
15429
  trackId: string(),
@@ -15127,6 +15494,104 @@ var EventPruneCountsSchema = object({
15127
15494
  object: number().int(),
15128
15495
  audio: number().int()
15129
15496
  });
15497
+ /**
15498
+ * Re-embed stored tracks from their key frames.
15499
+ *
15500
+ * The reason this is an operator-callable method and not a migration script:
15501
+ * every knob that decides what a vector MEANS — encoder model, crop margin,
15502
+ * squaring — is only changeable if the existing vectors can be regenerated.
15503
+ * Mixing feature spaces in one index makes cosine scores incomparable, and the
15504
+ * symptom is a quality regression with no visible cause.
15505
+ */
15506
+ var RebuildObjectEmbeddingsInput = object({
15507
+ /** Restrict to one camera. Omit for the whole fleet. */
15508
+ deviceId: number().optional(),
15509
+ since: number().optional(),
15510
+ until: number().optional(),
15511
+ /** Stop after this many tracks; the result reports whether more remain. */
15512
+ maxTracks: number().int().positive().optional(),
15513
+ /**
15514
+ * Run every embedding on THIS node instead of round-robining the fleet.
15515
+ *
15516
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15517
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15518
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15519
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15520
+ * remotely. This field is data; the per-track pin is applied inside.
15521
+ *
15522
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15523
+ * pinned model.
15524
+ */
15525
+ executeOnNodeId: string().optional(),
15526
+ /**
15527
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15528
+ * run flat out.
15529
+ *
15530
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15531
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15532
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15533
+ * force is logged at start and finish so a deliberately slow pass reads
15534
+ * differently from a stalled one.
15535
+ */
15536
+ pacingMs: number().int().nonnegative().optional()
15537
+ });
15538
+ /**
15539
+ * Result of emptying the CLIP index.
15540
+ *
15541
+ * The clean slate before a policy change: a new crop margin or encoder model
15542
+ * leaves two feature spaces in one index whose cosine scores are not
15543
+ * comparable, so wiping and rebuilding is the only way to be sure every vector
15544
+ * means the same thing.
15545
+ */
15546
+ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
15547
+ /**
15548
+ * Acknowledgement that a rebuild STARTED.
15549
+ *
15550
+ * The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
15551
+ * runs detached and this returns immediately. Waiting for it made the client
15552
+ * time out while the work carried on server-side, which is the worst of both:
15553
+ * no result and no way to know it was still going. Poll
15554
+ * `getObjectEmbeddingRebuildStatus` for progress.
15555
+ */
15556
+ var RebuildObjectEmbeddingsResultSchema = object({
15557
+ started: boolean(),
15558
+ /** True when a pass was already running; the new request is ignored. */
15559
+ alreadyRunning: boolean()
15560
+ });
15561
+ var RebuildStatusSchema = object({
15562
+ running: boolean(),
15563
+ scanned: number(),
15564
+ rebuilt: number(),
15565
+ /** Tracks whose key frame is gone — nothing to re-embed from. */
15566
+ missingKeyFrame: number(),
15567
+ /** Tracks with no usable detection box. */
15568
+ missingBbox: number(),
15569
+ /**
15570
+ * Tracks an executing node REFUSED rather than broke on — an unreadable key
15571
+ * frame, a step that threw. Separate from `failed` because the remedy is
15572
+ * different, and because a whole camera silently contributing zero vectors
15573
+ * is the shape of failure a rebuild must never hide.
15574
+ */
15575
+ notRunnable: number(),
15576
+ /**
15577
+ * The pass stopped because NO node could serve the pinned model.
15578
+ *
15579
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15580
+ * refused", this one says "the cluster cannot do this work at all" — every
15581
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15582
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15583
+ * engine change, not a per-camera one. Non-zero here always comes with
15584
+ * `complete: false`.
15585
+ */
15586
+ noCapableNode: number(),
15587
+ failed: number(),
15588
+ /** Set once a pass ends: true only when EVERYTHING was covered. */
15589
+ complete: boolean().nullable(),
15590
+ startedAtMs: number().nullable(),
15591
+ finishedAtMs: number().nullable(),
15592
+ /** Present when the pass ended by throwing. */
15593
+ error: string().nullable()
15594
+ });
15130
15595
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
15131
15596
  deviceId: number(),
15132
15597
  trackId: string()
@@ -15190,7 +15655,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15190
15655
  }), {
15191
15656
  kind: "mutation",
15192
15657
  auth: "admin"
15193
- }), method(object({}), EventStoreFootprintSchema, {
15658
+ }), method(object({
15659
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15660
+ deviceId: number(),
15661
+ trackId: string(),
15662
+ flags: TrackFlagsPatchSchema
15663
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
15194
15664
  kind: "query",
15195
15665
  auth: "admin"
15196
15666
  }), method(object({
@@ -15220,7 +15690,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15220
15690
  }), array(MediaFileSchema).readonly()), method(object({
15221
15691
  trackId: string(),
15222
15692
  kinds: array(MediaFileKindEnum).optional()
15223
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
15693
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
15694
+ kind: "mutation",
15695
+ auth: "admin"
15696
+ }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
15697
+ kind: "mutation",
15698
+ auth: "admin"
15699
+ }), method(object({}), RebuildStatusSchema), object({
15224
15700
  deviceId: number(),
15225
15701
  timestamp: number(),
15226
15702
  frameWidth: number(),
@@ -15827,6 +16303,53 @@ var DetailResultSchema = object({
15827
16303
  nativeFaceShortSidePx: number().optional()
15828
16304
  });
15829
16305
  /**
16306
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
16307
+ *
16308
+ * A refusal is a first-class answer, not an error, because the caller's next
16309
+ * move depends on WHICH one it is — and because "the pass produced nothing"
16310
+ * must never be reachable without a named, counted cause. The two tiers:
16311
+ *
16312
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
16313
+ * never serve this (step, model) pair. The caller drops it from its rotation
16314
+ * and retries the same work elsewhere; nothing about the work changes.
16315
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
16316
+ * fine, this one request is not. Retrying it on another node would only
16317
+ * spread the same failure.
16318
+ */
16319
+ var StatelessStepRefusalSchema = _enum([
16320
+ "unknown-step",
16321
+ "model-not-servable",
16322
+ "unreadable-frame",
16323
+ "execution-failed"
16324
+ ]);
16325
+ /**
16326
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
16327
+ * result, because `null` is exactly what made the camera-bound detail path
16328
+ * unable to tell "refused" from "never asked".
16329
+ */
16330
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
16331
+ kind: literal("ran"),
16332
+ /** The node that actually executed it — the pin, echoed back for the log. */
16333
+ nodeId: string(),
16334
+ /**
16335
+ * The model the step ran with.
16336
+ *
16337
+ * The node verified this exact id has a build for the format it dispatched
16338
+ * on BEFORE running, so the executor's format resolution returns it
16339
+ * unchanged. A caller that pinned a model must compare this field and
16340
+ * treat a mismatch as a refusal — the whole point of the pin is that a
16341
+ * pass writes one feature space.
16342
+ */
16343
+ modelId: string(),
16344
+ details: array(DetailResultSchema)
16345
+ }), object({
16346
+ kind: literal("refused"),
16347
+ nodeId: string(),
16348
+ reason: StatelessStepRefusalSchema,
16349
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
16350
+ detail: string()
16351
+ })]);
16352
+ /**
15830
16353
  * Per-camera tunable ranges + defaults. Single source of truth used
15831
16354
  * by both the Zod data schema (validation + default fallback) and
15832
16355
  * the device settings UI (slider min/max/step). Touch one place and
@@ -16162,10 +16685,46 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
16162
16685
  }), NativeCropResultSchema.nullable()), method(object({
16163
16686
  deviceId: number(),
16164
16687
  frameHandle: FrameHandleSchema.optional(),
16688
+ /**
16689
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
16690
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
16691
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
16692
+ */
16693
+ frameJpeg: string().optional(),
16694
+ /**
16695
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
16696
+ * The fallback when the lease/session backing the frame is gone and the
16697
+ * caller already holds a crop.
16698
+ */
16165
16699
  cropJpeg: string().optional(),
16166
16700
  parent: DetailParentSchema,
16167
16701
  steps: array(string()).optional()
16168
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
16702
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
16703
+ /** Catalog step id, e.g. `clip-embedding`. */
16704
+ stepId: string(),
16705
+ /**
16706
+ * REQUIRED model pin. The node runs this exact model or refuses with
16707
+ * `model-not-servable` — it never substitutes a format default, because
16708
+ * a fleet pass that round-robins across nodes would then fill one index
16709
+ * from several encoders.
16710
+ */
16711
+ modelId: string(),
16712
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
16713
+ frameJpeg: string(),
16714
+ /**
16715
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
16716
+ * purpose: the caller stores boxes against a downscaled analysis frame
16717
+ * while the stored key frame is native-resolution, and the only side
16718
+ * that reliably knows the image's pixel dimensions is the side that
16719
+ * decodes it. Denormalising here removes a second reader of the
16720
+ * dimensions and the class of mismatch that comes with it.
16721
+ */
16722
+ bbox: NativeCropBboxSchema,
16723
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
16724
+ className: string(),
16725
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
16726
+ sourceDeviceId: number()
16727
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
16169
16728
  var CameraPipelineConfigSchema = object({
16170
16729
  engine: PipelineEngineChoiceSchema.optional(),
16171
16730
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16463,6 +17022,20 @@ var CameraStatusSchema = object({
16463
17022
  detection: CameraDetectionStatusSchema.nullable(),
16464
17023
  audio: CameraAudioStatusSchema.nullable(),
16465
17024
  recording: CameraRecordingStatusSchema.nullable(),
17025
+ /**
17026
+ * Per-camera function switches an OPERATOR has turned off
17027
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
17028
+ *
17029
+ * This is the difference between DISABLED and BROKEN. A camera whose
17030
+ * `detection` block reports zero fps and whose `switchedOff` contains
17031
+ * `'object-detection'` was switched off by a person; the same camera with an
17032
+ * empty list is failing. Every status surface must render the two
17033
+ * differently — a quiet camera that looks identical to a dead one is the
17034
+ * silence-reads-as-never-happened trap this repo keeps paying for.
17035
+ *
17036
+ * Empty when nothing is off. Never contains a switch no provider offers.
17037
+ */
17038
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16466
17039
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16467
17040
  fetchedAt: number()
16468
17041
  });
@@ -16631,7 +17204,14 @@ method(object({
16631
17204
  }), method(object({
16632
17205
  deviceId: number(),
16633
17206
  agentNodeId: string().optional()
16634
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
17207
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
17208
+ deviceId: number(),
17209
+ switchId: CameraSwitchIdSchema,
17210
+ enabled: boolean()
17211
+ }), CameraSwitchGroupSchema, {
17212
+ kind: "mutation",
17213
+ auth: "admin"
17214
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16635
17215
  name: string(),
16636
17216
  description: string().optional(),
16637
17217
  config: CameraPipelineConfigSchema
@@ -16990,9 +17570,15 @@ var snapshotCapability = {
16990
17570
  * Bypass the cache freshness check and fetch directly from the
16991
17571
  * native (or stream-broker fallback). Triggered by the UI's
16992
17572
  * "refresh" button so an operator can force a fresh frame
16993
- * even when the cache is well within `snapshotMaxAgeMs`.
16994
- * On battery cams this WILL wake the camera — accept the
16995
- * cost only when the user explicitly asks for it.
17573
+ * even when the cache is well within the device's
17574
+ * `snapshotMaxAgeS` window.
17575
+ *
17576
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
17577
+ * battery camera it is the one thing that walks past the wrapper's
17578
+ * sleep gate and wakes the camera, so a background caller — a poller,
17579
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
17580
+ * gets the cached frame, which on a sleeping battery camera is the
17581
+ * correct answer: stale but honest beats woken.
16996
17582
  */
16997
17583
  force: boolean().optional()
16998
17584
  }), SnapshotImageSchema.nullable()),
@@ -17538,6 +18124,24 @@ var VectorDeleteByFilterInputSchema = object({
17538
18124
  filter: VectorFilterSchema
17539
18125
  });
17540
18126
  var VectorDeleteResultSchema = object({ deleted: number() });
18127
+ var VectorGetInputSchema = object({
18128
+ index: string(),
18129
+ ids: array(string())
18130
+ });
18131
+ /**
18132
+ * Metadata for the requested ids, WITHOUT their vectors.
18133
+ *
18134
+ * The only caller is a best-of gate that compares a candidate's confidence
18135
+ * against the stored one, and shipping 512 floats back to answer "is 0.91 >
18136
+ * 0.87" would undo the point of the compact encoding. Ids with no row are
18137
+ * simply absent — a caller distinguishing "not stored" from "stored" reads the
18138
+ * length, and a null placeholder would invite a `?? 0` that treats a missing
18139
+ * row as confidence zero.
18140
+ */
18141
+ var VectorGetResultSchema = object({ items: array(object({
18142
+ id: string(),
18143
+ metadata: VectorMetadataSchema
18144
+ })) });
17541
18145
  var VectorStatsInputSchema = object({ index: string() });
17542
18146
  var VectorStatsResultSchema = object({
17543
18147
  /** Provider id, so an operator can tell brute force from an ANN index. */
@@ -17550,7 +18154,7 @@ var VectorStatsResultSchema = object({
17550
18154
  /** False when the backend ranks approximately. */
17551
18155
  exact: boolean()
17552
18156
  });
17553
- 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);
18157
+ 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);
17554
18158
  /**
17555
18159
  * `videoclips` — the unified, navigable-clip surface for a camera.
17556
18160
  *
@@ -22452,12 +23056,30 @@ var pressureSensorCapability = {
22452
23056
  runtimeState: PressureSensorStatusSchema
22453
23057
  };
22454
23058
  /**
22455
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
22456
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
22457
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
22458
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
22459
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
22460
- * normalized 0..1 (top-left origin).
23059
+ * PRIVACY what the camera deliberately does not capture. Two planes:
23060
+ *
23061
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
23062
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
23063
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
23064
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
23065
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
23066
+ * normalized 0..1 (top-left origin).
23067
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
23068
+ * camera encoding an audio track at all, so EVERY consumer — live view,
23069
+ * recording, the audio analyzer, an export — sees silent video. There is
23070
+ * no server-side copy of this fact; the camera is the store and every read
23071
+ * is a read-through, which is why a switch over it cannot drift
23072
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
23073
+ *
23074
+ * Both belong here for one reason: they are the two things an operator turns
23075
+ * off when the answer to "what is this camera allowed to record" changes, and
23076
+ * both are applied ON the device, before anything leaves it.
23077
+ *
23078
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
23079
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
23080
+ * by one provider — and it was removed when this landed. A second writer onto
23081
+ * one device register is the shape of every knob this repo has shipped that
23082
+ * disagreed with the one the reader read.
22461
23083
  */
22462
23084
  /** A privacy-mask region's geometry — rectangle or free polygon. */
22463
23085
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -22469,21 +23091,45 @@ var PrivacyMaskRegionSchema = object({
22469
23091
  enabled: boolean(),
22470
23092
  shape: PrivacyMaskShapeSchema
22471
23093
  });
22472
- /** Current on-camera privacy-mask state — master enable + zones. */
23094
+ /** Current on-camera privacy state — mask master enable + zones + microphone. */
22473
23095
  var PrivacyMaskStatusSchema = object({
22474
23096
  enabled: boolean(),
22475
23097
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
22476
23098
  regions: array(PrivacyMaskRegionSchema),
23099
+ /**
23100
+ * Is the camera capturing sound right now? Read from the camera, never from
23101
+ * a server-side mirror.
23102
+ *
23103
+ * `null` means "no answer" — either this camera exposes no controllable
23104
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
23105
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
23106
+ * "the microphone is off" and "we could not ask" look identical to an
23107
+ * operator only until one of them is wrong.
23108
+ *
23109
+ * On a camera whose profiles carry the flag independently (Reolink writes
23110
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
23111
+ * privacy is only satisfied when every one of them is silent.
23112
+ */
23113
+ audioEnabled: boolean().nullable(),
22477
23114
  lastFetchedAt: number()
22478
23115
  });
22479
- /** Per-camera availability. */
23116
+ /** Per-camera availability. Probed, never assumed from the model name. */
22480
23117
  var PrivacyMaskOptionsSchema = object({
22481
23118
  /** Maximum number of supported zones. */
22482
23119
  maxRegions: number(),
22483
23120
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
22484
23121
  supportedShapes: array(MaskShapeKindSchema),
22485
23122
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
22486
- polygonVertices: MaskPolygonVerticesSchema.optional()
23123
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
23124
+ /**
23125
+ * Does this camera expose a microphone switch we can actually write?
23126
+ *
23127
+ * Camera-probed: `true` only when the firmware answered with an audio flag
23128
+ * we know how to patch. A camera that never answered is `false` — a control
23129
+ * the operator can press that changes nothing is worse than no control, and
23130
+ * the switch group renders "not available" instead.
23131
+ */
23132
+ supportsAudioMute: boolean()
22487
23133
  });
22488
23134
  /** Partial change — every field optional. */
22489
23135
  var PrivacyMaskPatchSchema = object({
@@ -22510,6 +23156,27 @@ var privacyMaskCapability = {
22510
23156
  }), _void(), {
22511
23157
  kind: "mutation",
22512
23158
  auth: "admin"
23159
+ }),
23160
+ /**
23161
+ * Turn the camera's microphone on or off, at the camera.
23162
+ *
23163
+ * Deliberately its OWN mutation rather than a field on
23164
+ * {@link PrivacyMaskPatchSchema}: `patch.enabled` already means "the video
23165
+ * mask master switch", and overloading it would make one boolean mean two
23166
+ * unrelated things on the same call. It is also the only method here whose
23167
+ * write leaves the device in a state a later `getStatus` reads back
23168
+ * verbatim, which is what makes it safe as a switch authority.
23169
+ *
23170
+ * A camera whose `getOptions().supportsAudioMute` is false must REJECT
23171
+ * this rather than silently accept it — a write nothing applies is exactly
23172
+ * what the switch group exists to remove.
23173
+ */
23174
+ setAudioEnabled: method(object({
23175
+ deviceId: number(),
23176
+ enabled: boolean()
23177
+ }), _void(), {
23178
+ kind: "mutation",
23179
+ auth: "admin"
22513
23180
  })
22514
23181
  },
22515
23182
  status: {
@@ -22518,6 +23185,26 @@ var privacyMaskCapability = {
22518
23185
  },
22519
23186
  runtimeState: PrivacyMaskStatusSchema
22520
23187
  };
23188
+ /**
23189
+ * Collapse a camera's PER-PROFILE audio flags into the one answer
23190
+ * {@link PrivacyMaskStatusSchema.shape.audioEnabled} promises.
23191
+ *
23192
+ * Both firmwares this cap talks to store the flag per stream profile, and
23193
+ * both let those profiles disagree. The rule is `some`, not `every`: privacy
23194
+ * is only satisfied when NOTHING is carrying sound, so a camera whose sub
23195
+ * stream is still audible must read as `true` and be switchable off — not as
23196
+ * `false` because the main stream happens to be muted already.
23197
+ *
23198
+ * An empty list is `null` ("this camera reported no audio flag at all"),
23199
+ * never `false`.
23200
+ *
23201
+ * Lives here rather than in each provider so the rule the schema documents
23202
+ * and the rule the providers apply cannot drift apart.
23203
+ */
23204
+ function summarisePrivacyAudio(profiles) {
23205
+ if (profiles.length === 0) return null;
23206
+ return profiles.some((p) => p.audioEnabled);
23207
+ }
22521
23208
  var PtzPresetSchema = object({
22522
23209
  id: string(),
22523
23210
  name: string()
@@ -22859,6 +23546,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
22859
23546
  })]);
22860
23547
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
22861
23548
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
23549
+ /**
23550
+ * One GOP of a finalized segment, cut by byte range through the segment's own
23551
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
23552
+ * the single `moof`+`mdat` covering the requested instant — standalone-
23553
+ * demuxable, never the whole file. When the segment's index cannot be parsed
23554
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
23555
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
23556
+ */
23557
+ var ReadGopBytesResultSchema = object({
23558
+ data: _instanceof(Uint8Array),
23559
+ /** Absolute epoch ms of the returned fragment's first sample. */
23560
+ gopStartMs: number(),
23561
+ /** Media ms the returned fragment covers. */
23562
+ gopDurMs: number()
23563
+ });
22862
23564
  method(object({
22863
23565
  deviceId: number(),
22864
23566
  fromMs: number(),
@@ -22901,6 +23603,14 @@ method(object({
22901
23603
  }), ReadSegmentBytesResultSchema, {
22902
23604
  kind: "query",
22903
23605
  auth: "admin"
23606
+ }), method(object({
23607
+ deviceId: number(),
23608
+ profile: string(),
23609
+ startMs: number(),
23610
+ epochMs: number()
23611
+ }), ReadGopBytesResultSchema, {
23612
+ kind: "query",
23613
+ auth: "admin"
22904
23614
  }), method(object({
22905
23615
  deviceId: number(),
22906
23616
  config: RecordingConfigSchema
@@ -23473,6 +24183,16 @@ var StreamProfileConfigSchema = object({
23473
24183
  "baseline"
23474
24184
  ]).optional(),
23475
24185
  gop: number().optional(),
24186
+ /**
24187
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
24188
+ *
24189
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
24190
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
24191
+ * writes every profile at once so "audio off" means silent everywhere. A
24192
+ * per-profile writer beside it would let a camera be half-muted and would be
24193
+ * a second knob onto one device register — the failure D62 exists to
24194
+ * prevent. Absent when the firmware does not report the flag.
24195
+ */
23476
24196
  audio: boolean().optional()
23477
24197
  });
23478
24198
  var StreamParamsStatusSchema = object({
@@ -23513,7 +24233,13 @@ var StreamParamsOptionsSchema = object({
23513
24233
  ext: StreamProfileOptionsSchema.optional()
23514
24234
  });
23515
24235
  /** A partial change to one profile — every field optional; a provider
23516
- * ignores fields it doesn't support. */
24236
+ * ignores fields it doesn't support.
24237
+ *
24238
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
24239
+ * reachable from no form and honoured by exactly one provider, while the
24240
+ * camera's microphone is a whole-device fact. It now has one writer,
24241
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
24242
+ * `privacy-mask.cap.ts`. */
23517
24243
  var StreamProfilePatchSchema = object({
23518
24244
  width: number().optional(),
23519
24245
  height: number().optional(),
@@ -23526,8 +24252,7 @@ var StreamProfilePatchSchema = object({
23526
24252
  "main",
23527
24253
  "baseline"
23528
24254
  ]).optional(),
23529
- gop: number().optional(),
23530
- audio: boolean().optional()
24255
+ gop: number().optional()
23531
24256
  });
23532
24257
  var streamParamsCapability = {
23533
24258
  name: "stream-params",
@@ -28691,6 +29416,12 @@ Object.freeze({
28691
29416
  addonId: null,
28692
29417
  access: "view"
28693
29418
  },
29419
+ "notificationRules.listDeviceMutes": {
29420
+ capName: "notification-rules",
29421
+ capScope: "system",
29422
+ addonId: null,
29423
+ access: "view"
29424
+ },
28694
29425
  "notificationRules.listRules": {
28695
29426
  capName: "notification-rules",
28696
29427
  capScope: "system",
@@ -28709,6 +29440,12 @@ Object.freeze({
28709
29440
  addonId: null,
28710
29441
  access: "create"
28711
29442
  },
29443
+ "notificationRules.setDeviceMuted": {
29444
+ capName: "notification-rules",
29445
+ capScope: "system",
29446
+ addonId: null,
29447
+ access: "create"
29448
+ },
28712
29449
  "notificationRules.setRuleEnabled": {
28713
29450
  capName: "notification-rules",
28714
29451
  capScope: "system",
@@ -28883,6 +29620,12 @@ Object.freeze({
28883
29620
  addonId: null,
28884
29621
  access: "view"
28885
29622
  },
29623
+ "pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
29624
+ capName: "pipeline-analytics",
29625
+ capScope: "device",
29626
+ addonId: null,
29627
+ access: "view"
29628
+ },
28886
29629
  "pipelineAnalytics.getObjectEvents": {
28887
29630
  capName: "pipeline-analytics",
28888
29631
  capScope: "device",
@@ -28961,6 +29704,12 @@ Object.freeze({
28961
29704
  addonId: null,
28962
29705
  access: "create"
28963
29706
  },
29707
+ "pipelineAnalytics.rebuildObjectEmbeddings": {
29708
+ capName: "pipeline-analytics",
29709
+ capScope: "device",
29710
+ addonId: null,
29711
+ access: "create"
29712
+ },
28964
29713
  "pipelineAnalytics.relocateMedia": {
28965
29714
  capName: "pipeline-analytics",
28966
29715
  capScope: "device",
@@ -28973,12 +29722,24 @@ Object.freeze({
28973
29722
  addonId: null,
28974
29723
  access: "view"
28975
29724
  },
29725
+ "pipelineAnalytics.setTrackFlags": {
29726
+ capName: "pipeline-analytics",
29727
+ capScope: "device",
29728
+ addonId: null,
29729
+ access: "create"
29730
+ },
28976
29731
  "pipelineAnalytics.wipeAllAnalytics": {
28977
29732
  capName: "pipeline-analytics",
28978
29733
  capScope: "device",
28979
29734
  addonId: null,
28980
29735
  access: "delete"
28981
29736
  },
29737
+ "pipelineAnalytics.wipeObjectEmbeddings": {
29738
+ capName: "pipeline-analytics",
29739
+ capScope: "device",
29740
+ addonId: null,
29741
+ access: "delete"
29742
+ },
28982
29743
  "pipelineExecutor.cacheFrameInPool": {
28983
29744
  capName: "pipeline-executor",
28984
29745
  capScope: "system",
@@ -29273,6 +30034,12 @@ Object.freeze({
29273
30034
  addonId: null,
29274
30035
  access: "view"
29275
30036
  },
30037
+ "pipelineOrchestrator.getCameraSwitches": {
30038
+ capName: "pipeline-orchestrator",
30039
+ capScope: "system",
30040
+ addonId: null,
30041
+ access: "view"
30042
+ },
29276
30043
  "pipelineOrchestrator.getCapabilityBindings": {
29277
30044
  capName: "pipeline-orchestrator",
29278
30045
  capScope: "system",
@@ -29405,6 +30172,12 @@ Object.freeze({
29405
30172
  addonId: null,
29406
30173
  access: "create"
29407
30174
  },
30175
+ "pipelineOrchestrator.setCameraSwitch": {
30176
+ capName: "pipeline-orchestrator",
30177
+ capScope: "system",
30178
+ addonId: null,
30179
+ access: "create"
30180
+ },
29408
30181
  "pipelineOrchestrator.setCapabilityBinding": {
29409
30182
  capName: "pipeline-orchestrator",
29410
30183
  capScope: "system",
@@ -29495,6 +30268,12 @@ Object.freeze({
29495
30268
  addonId: null,
29496
30269
  access: "create"
29497
30270
  },
30271
+ "pipelineRunner.runStatelessStep": {
30272
+ capName: "pipeline-runner",
30273
+ capScope: "system",
30274
+ addonId: null,
30275
+ access: "create"
30276
+ },
29498
30277
  "plateGallery.assignPlate": {
29499
30278
  capName: "plate-gallery",
29500
30279
  capScope: "system",
@@ -29627,6 +30406,12 @@ Object.freeze({
29627
30406
  addonId: null,
29628
30407
  access: "view"
29629
30408
  },
30409
+ "privacyMask.setAudioEnabled": {
30410
+ capName: "privacy-mask",
30411
+ capScope: "device",
30412
+ addonId: null,
30413
+ access: "create"
30414
+ },
29630
30415
  "privacyMask.setMask": {
29631
30416
  capName: "privacy-mask",
29632
30417
  capScope: "device",
@@ -29795,6 +30580,12 @@ Object.freeze({
29795
30580
  addonId: null,
29796
30581
  access: "create"
29797
30582
  },
30583
+ "recording.readGopBytes": {
30584
+ capName: "recording",
30585
+ capScope: "system",
30586
+ addonId: null,
30587
+ access: "view"
30588
+ },
29798
30589
  "recording.readSegmentBytes": {
29799
30590
  capName: "recording",
29800
30591
  capScope: "system",
@@ -30311,6 +31102,12 @@ Object.freeze({
30311
31102
  addonId: null,
30312
31103
  access: "create"
30313
31104
  },
31105
+ "streamBroker.acquireEgressTranscode": {
31106
+ capName: "stream-broker",
31107
+ capScope: "system",
31108
+ addonId: null,
31109
+ access: "create"
31110
+ },
30314
31111
  "streamBroker.assignProfile": {
30315
31112
  capName: "stream-broker",
30316
31113
  capScope: "system",
@@ -30419,6 +31216,12 @@ Object.freeze({
30419
31216
  addonId: null,
30420
31217
  access: "create"
30421
31218
  },
31219
+ "streamBroker.releaseEgressTranscode": {
31220
+ capName: "stream-broker",
31221
+ capScope: "system",
31222
+ addonId: null,
31223
+ access: "create"
31224
+ },
30422
31225
  "streamBroker.releaseStreamWithCodec": {
30423
31226
  capName: "stream-broker",
30424
31227
  capScope: "system",
@@ -30899,6 +31702,12 @@ Object.freeze({
30899
31702
  addonId: null,
30900
31703
  access: "delete"
30901
31704
  },
31705
+ "vectorStore.getByIds": {
31706
+ capName: "vector-store",
31707
+ capScope: "system",
31708
+ addonId: null,
31709
+ access: "view"
31710
+ },
30902
31711
  "vectorStore.query": {
30903
31712
  capName: "vector-store",
30904
31713
  capScope: "system",
@@ -31173,6 +31982,112 @@ TimelapseRuleInputSchema.extend({
31173
31982
  createdAt: number(),
31174
31983
  updatedAt: number()
31175
31984
  });
31985
+ object({
31986
+ /**
31987
+ * Fraction of the box's own size added on EACH side before cutting.
31988
+ *
31989
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
31990
+ * removes exactly the context it is strongest on (a dog cut to its outline
31991
+ * is a dark blob). The right value is an empirical question, which is why it
31992
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
31993
+ */
31994
+ paddingRatio: number().min(0).max(4),
31995
+ /**
31996
+ * Square the window (in PIXELS) before cutting.
31997
+ *
31998
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
31999
+ * — a standing person becomes a shape the model never saw. Squaring costs
32000
+ * extra background, which is context the model wants anyway. Off by default
32001
+ * because the live path has never squared and the stored index reflects that.
32002
+ */
32003
+ square: boolean()
32004
+ });
32005
+ ({
32006
+ paddingRatio: .15,
32007
+ square: false
32008
+ }).paddingRatio;
32009
+ /**
32010
+ * WHICH delivered frames the decode worker retains a native copy of.
32011
+ *
32012
+ * - `all` — every frame the worker delivered to the runner. The shipped
32013
+ * behaviour, and the only correct one if something can ask for a crop of a
32014
+ * frame the runner never sent to inference.
32015
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
32016
+ * A native-crop request always names a `frameId` that rode an inference
32017
+ * result, so that is the only set a request can name. How much it drops is
32018
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
32019
+ * this cluster, not the ~80% the design sketch assumed, because the governor
32020
+ * was not throttling as hard as the sketch supposed. Read
32021
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
32022
+ * of you rather than quoting a number from here. The newest delivered frame is
32023
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
32024
+ * which covers the one-frame race between a mark and the supersede that
32025
+ * consumes it.
32026
+ */
32027
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
32028
+ object({
32029
+ /**
32030
+ * How long a retained native frame is served before it counts as a miss.
32031
+ *
32032
+ * Must cover the FULL late-crop horizon: detection inference + the
32033
+ * cross-process inference-result hop to hub post-analysis + tracking + the
32034
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
32035
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
32036
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
32037
+ */
32038
+ ttlMs: number().int().min(250).max(1e4),
32039
+ /**
32040
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
32041
+ *
32042
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
32043
+ * which one is actually binding before reasoning from that. At the shipped
32044
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
32045
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
32046
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
32047
+ * change that admits fewer frames buys retention WINDOW at constant RAM
32048
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
32049
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
32050
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
32051
+ * to replace).
32052
+ */
32053
+ budgetMb: number().int().min(0).max(4096),
32054
+ /**
32055
+ * Demand window: eager per-frame native retention runs only within this many
32056
+ * ms of the last native-crop request (or of the dial starting).
32057
+ *
32058
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
32059
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
32060
+ * per second on a camera with zero crop demand), so leave it non-zero unless
32061
+ * you are reproducing that.
32062
+ */
32063
+ activityMs: number().int().min(0).max(12e4),
32064
+ /**
32065
+ * Which delivered frames are retained at all — see
32066
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
32067
+ * changes WHAT is kept rather than for how long, so it is also the only one
32068
+ * that can turn a crop that used to hit into a miss. The worker counts every
32069
+ * crop request naming a frame it did NOT see marked
32070
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
32071
+ * there is the signal that some caller names frames outside the inference set
32072
+ * and that this must go back to `all`.
32073
+ */
32074
+ admission: NativeLeaseAdmissionSchema
32075
+ });
32076
+ /**
32077
+ * The values in force when the operator has set nothing — byte-for-byte the
32078
+ * constants the decode worker shipped with as env-var defaults, so making these
32079
+ * settings changed no behaviour on the day it landed.
32080
+ */
32081
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
32082
+ ttlMs: 1200,
32083
+ budgetMb: 1024,
32084
+ activityMs: 15e3,
32085
+ admission: "inferred"
32086
+ };
32087
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
32088
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
32089
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
32090
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
31176
32091
  //#endregion
31177
32092
  //#region ../../node_modules/undici/lib/core/symbols.js
31178
32093
  var require_symbols = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -57652,7 +58567,7 @@ async function runMultifocalDiagnosticsConsecutively(params) {
57652
58567
  if (u.isKeyframe && firstKeyframeAtMs == null) firstKeyframeAtMs = Date.now();
57653
58568
  if (u.isKeyframe && firstKeyframeSha === void 0) {
57654
58569
  firstKeyframeBytes = u.data.length;
57655
- firstKeyframeSha = createHash("sha256").update(u.data).digest("hex");
58570
+ firstKeyframeSha = createHash$1("sha256").update(u.data).digest("hex");
57656
58571
  }
57657
58572
  const nalTypes = nalTypesSummary(u.videoType, u.data);
57658
58573
  appendNdjson(eventsPath, {
@@ -217742,7 +218657,7 @@ var CompositeStream = class extends EventEmitter {
217742
218657
  if (!buf?.length) return;
217743
218658
  const head = buf.subarray(0, Math.min(12, buf.length)).toString("hex");
217744
218659
  const slice = buf.subarray(0, Math.min(256, buf.length));
217745
- const sha1 = createHash("sha1").update(slice).digest("hex");
218660
+ const sha1 = createHash$1("sha1").update(slice).digest("hex");
217746
218661
  return {
217747
218662
  len: buf.length,
217748
218663
  headHex: head,
@@ -219525,69 +220440,6 @@ function buildInitialStatus(config) {
219525
220440
  };
219526
220441
  }
219527
220442
  //#endregion
219528
- //#region src/raw-state.ts
219529
- /**
219530
- * Source tag for every raw-state blob this provider emits.
219531
- */
219532
- var RAW_STATE_SOURCE = "reolink";
219533
- /**
219534
- * Key fragments that mark a field as secret/credential bearing. Any
219535
- * object key matching this (case-insensitive) is dropped from the
219536
- * display-safe raw-state blob.
219537
- */
219538
- var SECRET_KEY_PATTERN = /password|token|secret|credential|passwd|auth/i;
219539
- /**
219540
- * Deep-copy `value`, dropping any object key that looks like a
219541
- * credential. Recurses into nested plain objects and arrays; leaves
219542
- * primitives untouched. Never mutates the input.
219543
- */
219544
- function redactValue(value) {
219545
- if (Array.isArray(value)) return value.map(redactValue);
219546
- if (value && typeof value === "object") {
219547
- const out = {};
219548
- for (const [key, child] of Object.entries(value)) {
219549
- if (SECRET_KEY_PATTERN.test(key)) continue;
219550
- out[key] = redactValue(child);
219551
- }
219552
- return out;
219553
- }
219554
- return value;
219555
- }
219556
- /**
219557
- * Return a deep copy of `obj` with every credential-bearing key
219558
- * removed at any depth. Display-safe.
219559
- */
219560
- function redactSecrets(obj) {
219561
- return redactValue(obj);
219562
- }
219563
- /**
219564
- * Snapshot every cached cap slice from `reader` and strip secrets.
219565
- * Returns a `Record<capName, redactedSlice>`; empty when no slice has
219566
- * been written yet.
219567
- */
219568
- function collectRedactedSnapshot(reader) {
219569
- const snap = reader.snapshot();
219570
- const out = {};
219571
- for (const [capName, slice] of Object.entries(snap)) out[capName] = redactSecrets({ ...slice });
219572
- return out;
219573
- }
219574
- /**
219575
- * Build the display-safe `{ source:'reolink', data }` raw-state blob
219576
- * from a device's cached runtime-state slices. Returns `null` when
219577
- * the device has no cached state at all (so the State panel hides the
219578
- * Raw toggle rather than showing an empty object).
219579
- *
219580
- * No camera round-trip — reads only the in-memory runtime-state cache.
219581
- */
219582
- function buildRawState(reader) {
219583
- const data = collectRedactedSnapshot(reader);
219584
- if (Object.keys(data).length === 0) return null;
219585
- return {
219586
- source: RAW_STATE_SOURCE,
219587
- data
219588
- };
219589
- }
219590
- //#endregion
219591
220443
  //#region src/day-night-mapping.ts
219592
220444
  /**
219593
220445
  * Maps between the vendor-neutral `day-night` cap's `DayNightMode` and
@@ -219700,44 +220552,68 @@ function overlayLiveNativeRfc4571Sdp(descriptors, liveServerFor) {
219700
220552
  });
219701
220553
  }
219702
220554
  //#endregion
219703
- //#region src/accessory-probe-flags.ts
219704
- var FLAG_KEYS = [
219705
- "hasBattery",
219706
- "hasPtz",
219707
- "hasIntercom",
219708
- "hasDoorbell",
219709
- "hasFloodlight",
219710
- "hasSiren",
219711
- "hasPirSensor",
219712
- "hasAutotrack"
219713
- ];
220555
+ //#region src/raw-state.ts
219714
220556
  /**
219715
- * True when EITHER the live `feature-probe` slice has completed a probe
219716
- * this session (`sliceProbed`) OR a prior successful probe is persisted
219717
- * in the `deviceCache` config blob (`probedAt` stamp). The latter
219718
- * survives restarts, so a camera probed in an earlier session is still
219719
- * "probed" for accessory-derivation purposes even if its live probe is
219720
- * currently slow/failing.
220557
+ * Source tag for every raw-state blob this provider emits.
219721
220558
  */
219722
- function hasEverProbed(sliceProbed, deviceCache) {
219723
- return sliceProbed || deviceCache?.probedAt !== void 0;
220559
+ var RAW_STATE_SOURCE = "reolink";
220560
+ /**
220561
+ * Key fragments that mark a field as secret/credential bearing. Any
220562
+ * object key matching this (case-insensitive) is dropped from the
220563
+ * display-safe raw-state blob.
220564
+ */
220565
+ var SECRET_KEY_PATTERN = /password|token|secret|credential|passwd|auth/i;
220566
+ /**
220567
+ * Deep-copy `value`, dropping any object key that looks like a
220568
+ * credential. Recurses into nested plain objects and arrays; leaves
220569
+ * primitives untouched. Never mutates the input.
220570
+ */
220571
+ function redactValue(value) {
220572
+ if (Array.isArray(value)) return value.map(redactValue);
220573
+ if (value && typeof value === "object") {
220574
+ const out = {};
220575
+ for (const [key, child] of Object.entries(value)) {
220576
+ if (SECRET_KEY_PATTERN.test(key)) continue;
220577
+ out[key] = redactValue(child);
220578
+ }
220579
+ return out;
220580
+ }
220581
+ return value;
219724
220582
  }
219725
220583
  /**
219726
- * Effective ability flags for accessory derivation: the live slice
219727
- * flags when the slice has probed this session, otherwise the boolean
219728
- * flags persisted in `deviceCache` from the last successful probe.
219729
- * Returns an empty bag when neither source carries a completed probe.
220584
+ * Return a deep copy of `obj` with every credential-bearing key
220585
+ * removed at any depth. Display-safe.
219730
220586
  */
219731
- function resolveAccessoryProbeFlags(sliceProbed, sliceFlags, deviceCache) {
219732
- if (sliceProbed) return sliceFlags;
219733
- if (deviceCache?.probedAt === void 0) return {};
220587
+ function redactSecrets(obj) {
220588
+ return redactValue(obj);
220589
+ }
220590
+ /**
220591
+ * Snapshot every cached cap slice from `reader` and strip secrets.
220592
+ * Returns a `Record<capName, redactedSlice>`; empty when no slice has
220593
+ * been written yet.
220594
+ */
220595
+ function collectRedactedSnapshot(reader) {
220596
+ const snap = reader.snapshot();
219734
220597
  const out = {};
219735
- for (const key of FLAG_KEYS) {
219736
- const value = deviceCache[key];
219737
- if (typeof value === "boolean") out[key] = value;
219738
- }
220598
+ for (const [capName, slice] of Object.entries(snap)) out[capName] = redactSecrets({ ...slice });
219739
220599
  return out;
219740
220600
  }
220601
+ /**
220602
+ * Build the display-safe `{ source:'reolink', data }` raw-state blob
220603
+ * from a device's cached runtime-state slices. Returns `null` when
220604
+ * the device has no cached state at all (so the State panel hides the
220605
+ * Raw toggle rather than showing an empty object).
220606
+ *
220607
+ * No camera round-trip — reads only the in-memory runtime-state cache.
220608
+ */
220609
+ function buildRawState(reader) {
220610
+ const data = collectRedactedSnapshot(reader);
220611
+ if (Object.keys(data).length === 0) return null;
220612
+ return {
220613
+ source: RAW_STATE_SOURCE,
220614
+ data
220615
+ };
220616
+ }
219741
220617
  //#endregion
219742
220618
  //#region src/accessories/base.ts
219743
220619
  /**
@@ -220865,6 +221741,45 @@ function createAccessoryDevice(kind, ctx, parent) {
220865
221741
  }
220866
221742
  }
220867
221743
  //#endregion
221744
+ //#region src/accessory-probe-flags.ts
221745
+ var FLAG_KEYS = [
221746
+ "hasBattery",
221747
+ "hasPtz",
221748
+ "hasIntercom",
221749
+ "hasDoorbell",
221750
+ "hasFloodlight",
221751
+ "hasSiren",
221752
+ "hasPirSensor",
221753
+ "hasAutotrack"
221754
+ ];
221755
+ /**
221756
+ * True when EITHER the live `feature-probe` slice has completed a probe
221757
+ * this session (`sliceProbed`) OR a prior successful probe is persisted
221758
+ * in the `deviceCache` config blob (`probedAt` stamp). The latter
221759
+ * survives restarts, so a camera probed in an earlier session is still
221760
+ * "probed" for accessory-derivation purposes even if its live probe is
221761
+ * currently slow/failing.
221762
+ */
221763
+ function hasEverProbed(sliceProbed, deviceCache) {
221764
+ return sliceProbed || deviceCache?.probedAt !== void 0;
221765
+ }
221766
+ /**
221767
+ * Effective ability flags for accessory derivation: the live slice
221768
+ * flags when the slice has probed this session, otherwise the boolean
221769
+ * flags persisted in `deviceCache` from the last successful probe.
221770
+ * Returns an empty bag when neither source carries a completed probe.
221771
+ */
221772
+ function resolveAccessoryProbeFlags(sliceProbed, sliceFlags, deviceCache) {
221773
+ if (sliceProbed) return sliceFlags;
221774
+ if (deviceCache?.probedAt === void 0) return {};
221775
+ const out = {};
221776
+ for (const key of FLAG_KEYS) {
221777
+ const value = deviceCache[key];
221778
+ if (typeof value === "boolean") out[key] = value;
221779
+ }
221780
+ return out;
221781
+ }
221782
+ //#endregion
220868
221783
  //#region src/metadata-populator.ts
220869
221784
  var EXTENDED_INFO_TAGS = [
220870
221785
  "type",
@@ -220968,6 +221883,760 @@ async function populateReolinkMetadata(api, channel, target) {
220968
221883
  }
220969
221884
  }
220970
221885
  //#endregion
221886
+ //#region src/error-classifier.ts
221887
+ /**
221888
+ * Recognise transient Baichuan/socket failures that are expected to
221889
+ * recover on the next reconnect cycle. Mirrors
221890
+ * `scrypted-reolink-native/src/camera.ts isRecoverableBaichuanError` —
221891
+ * keeping parity so the same wire-level conditions are treated the
221892
+ * same way across plugins.
221893
+ *
221894
+ * Ported categories:
221895
+ * - Baichuan-specific transport closes (`Baichuan socket closed`,
221896
+ * `Baichuan UDP stream closed`, `Baichuan TCP socket is not
221897
+ * connected`)
221898
+ * - Generic TCP errors that fire as part of the same disconnect
221899
+ * storm (`ECONNRESET`, `EPIPE`, `socket hang up`)
221900
+ * - D2C disconnects (`D2C_DISC`) — UDP relay path teardown
221901
+ *
221902
+ * Callers downgrade these to WARN-level logs and trigger a fresh
221903
+ * login on the next demand instead of bubbling a hard ERROR.
221904
+ */
221905
+ var RECOVERABLE_FRAGMENTS = [
221906
+ "Baichuan socket closed",
221907
+ "Baichuan UDP stream closed",
221908
+ "Baichuan TCP socket is not connected",
221909
+ "socket hang up",
221910
+ "ECONNRESET",
221911
+ "EPIPE",
221912
+ "D2C_DISC"
221913
+ ];
221914
+ function isRecoverableBaichuanError(err) {
221915
+ const message = err instanceof Error ? err.message : typeof err === "string" ? err : err?.toString?.() ?? "";
221916
+ return RECOVERABLE_FRAGMENTS.some((fragment) => message.includes(fragment));
221917
+ }
221918
+ //#endregion
221919
+ //#region src/intercom-encoder.ts
221920
+ /**
221921
+ * IMA ADPCM (DVI4) encoder — Reolink Baichuan talk-back wire format.
221922
+ *
221923
+ * Ported from `scrypted-reolink-native/src/intercom.ts` (BSD-2 like the
221924
+ * rest of the cross-cam Scrypted infra). Reolink cameras expect ADPCM
221925
+ * blocks of (4 + N) bytes:
221926
+ * - 2 bytes: little-endian Int16 predictor (first PCM sample of the
221927
+ * block, used to seed the decoder state on the camera side)
221928
+ * - 1 byte: index into the IMA step table (always 0 — we re-seed
221929
+ * the predictor on every block instead of carrying state forward)
221930
+ * - 1 byte: padding
221931
+ * - N bytes: 2 nibbles per byte, low nibble first, each nibble
221932
+ * encodes one PCM sample's delta as `sign | delta3` (4 bits)
221933
+ *
221934
+ * The block size is camera-firmware specific; the lib reports it as
221935
+ * `TalkSessionInfo.blockSize` per session.
221936
+ *
221937
+ * Why standalone?
221938
+ * - Pure function — no I/O, easy to unit-test
221939
+ * - Reusable from the (currently stub) WebRTC server-side intercom
221940
+ * path AND from any future direct-PCM caller
221941
+ * - Decoupled from werift / lib types
221942
+ */
221943
+ var IMA_INDEX_TABLE = Int8Array.from([
221944
+ -1,
221945
+ -1,
221946
+ -1,
221947
+ -1,
221948
+ 2,
221949
+ 4,
221950
+ 6,
221951
+ 8,
221952
+ -1,
221953
+ -1,
221954
+ -1,
221955
+ -1,
221956
+ 2,
221957
+ 4,
221958
+ 6,
221959
+ 8
221960
+ ]);
221961
+ var IMA_STEP_TABLE = Int16Array.from([
221962
+ 7,
221963
+ 8,
221964
+ 9,
221965
+ 10,
221966
+ 11,
221967
+ 12,
221968
+ 13,
221969
+ 14,
221970
+ 16,
221971
+ 17,
221972
+ 19,
221973
+ 21,
221974
+ 23,
221975
+ 25,
221976
+ 28,
221977
+ 31,
221978
+ 34,
221979
+ 37,
221980
+ 41,
221981
+ 45,
221982
+ 50,
221983
+ 55,
221984
+ 60,
221985
+ 66,
221986
+ 73,
221987
+ 80,
221988
+ 88,
221989
+ 97,
221990
+ 107,
221991
+ 118,
221992
+ 130,
221993
+ 143,
221994
+ 157,
221995
+ 173,
221996
+ 190,
221997
+ 209,
221998
+ 230,
221999
+ 253,
222000
+ 279,
222001
+ 307,
222002
+ 337,
222003
+ 371,
222004
+ 408,
222005
+ 449,
222006
+ 494,
222007
+ 544,
222008
+ 598,
222009
+ 658,
222010
+ 724,
222011
+ 796,
222012
+ 876,
222013
+ 963,
222014
+ 1060,
222015
+ 1166,
222016
+ 1282,
222017
+ 1411,
222018
+ 1552,
222019
+ 1707,
222020
+ 1878,
222021
+ 2066,
222022
+ 2272,
222023
+ 2499,
222024
+ 2749,
222025
+ 3024,
222026
+ 3327,
222027
+ 3660,
222028
+ 4026,
222029
+ 4428,
222030
+ 4871,
222031
+ 5358,
222032
+ 5894,
222033
+ 6484,
222034
+ 7132,
222035
+ 7845,
222036
+ 8630,
222037
+ 9493,
222038
+ 10442,
222039
+ 11487,
222040
+ 12635,
222041
+ 13899,
222042
+ 15289,
222043
+ 16818,
222044
+ 18500,
222045
+ 20350,
222046
+ 22385,
222047
+ 24623,
222048
+ 27086,
222049
+ 29794,
222050
+ 32767
222051
+ ]);
222052
+ function clamp16(x) {
222053
+ if (x > 32767) return 32767;
222054
+ if (x < -32768) return -32768;
222055
+ return x | 0;
222056
+ }
222057
+ /**
222058
+ * Encode a PCM s16le buffer into IMA ADPCM blocks of size
222059
+ * `(4 + blockSizeBytes)` each. The output length is a multiple of
222060
+ * `4 + blockSizeBytes`. PCM samples that don't fit a full block at
222061
+ * the end get folded into a final partial block (the camera tolerates
222062
+ * trailing zeros from the unpopulated nibbles).
222063
+ *
222064
+ * Block layout per Reolink's wire format:
222065
+ * bytes [0..1] = predictor (Int16 LE)
222066
+ * byte [2] = step index (always 0 — re-seed each block)
222067
+ * byte [3] = padding (0x00)
222068
+ * bytes [4..N+3] = packed nibbles (2 samples per byte, low first)
222069
+ *
222070
+ * Each block carries `blockSizeBytes * 2 + 1` PCM samples (the +1
222071
+ * is the predictor sample stored explicitly in the header).
222072
+ */
222073
+ function encodeImaAdpcm(pcm, blockSizeBytes) {
222074
+ const samplesPerBlock = blockSizeBytes * 2 + 1;
222075
+ const totalBlocks = Math.ceil(pcm.length / samplesPerBlock);
222076
+ const outBlocks = [];
222077
+ let sampleIndex = 0;
222078
+ for (let b = 0; b < totalBlocks; b++) {
222079
+ const block = Buffer.alloc(4 + blockSizeBytes);
222080
+ let predictor = pcm[sampleIndex] ?? 0;
222081
+ let index = 0;
222082
+ block.writeInt16LE(predictor, 0);
222083
+ block.writeUInt8(index, 2);
222084
+ block.writeUInt8(0, 3);
222085
+ sampleIndex++;
222086
+ const codes = new Uint8Array(blockSizeBytes * 2);
222087
+ for (let i = 0; i < codes.length; i++) {
222088
+ const sample = pcm[sampleIndex] ?? predictor;
222089
+ sampleIndex++;
222090
+ let diff = sample - predictor;
222091
+ let sign = 0;
222092
+ if (diff < 0) {
222093
+ sign = 8;
222094
+ diff = -diff;
222095
+ }
222096
+ let step = IMA_STEP_TABLE[index] ?? 7;
222097
+ let delta = 0;
222098
+ let vpdiff = step >> 3;
222099
+ if (diff >= step) {
222100
+ delta |= 4;
222101
+ diff -= step;
222102
+ vpdiff += step;
222103
+ }
222104
+ step >>= 1;
222105
+ if (diff >= step) {
222106
+ delta |= 2;
222107
+ diff -= step;
222108
+ vpdiff += step;
222109
+ }
222110
+ step >>= 1;
222111
+ if (diff >= step) {
222112
+ delta |= 1;
222113
+ vpdiff += step;
222114
+ }
222115
+ predictor = sign ? clamp16(predictor - vpdiff) : clamp16(predictor + vpdiff);
222116
+ index += IMA_INDEX_TABLE[delta] ?? 0;
222117
+ if (index < 0) index = 0;
222118
+ if (index > 88) index = 88;
222119
+ codes[i] = (delta | sign) & 15;
222120
+ }
222121
+ for (let i = 0; i < blockSizeBytes; i++) {
222122
+ const lo = codes[i * 2] ?? 0;
222123
+ const hi = codes[i * 2 + 1] ?? 0;
222124
+ block[4 + i] = lo & 15 | (hi & 15) << 4;
222125
+ }
222126
+ outBlocks.push(block);
222127
+ }
222128
+ return Buffer.concat(outBlocks);
222129
+ }
222130
+ //#endregion
222131
+ //#region src/intercom-session.ts
222132
+ var DEFAULT_BACKLOG_MS = 120;
222133
+ var MAX_BACKLOG_MS = 5e3;
222134
+ var MIN_BACKLOG_MS = 20;
222135
+ var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
222136
+ var DEFAULT_GAIN = 1;
222137
+ var MIN_GAIN = .1;
222138
+ var MAX_GAIN = 10;
222139
+ var ReolinkIntercomSession = class {
222140
+ opts;
222141
+ session = null;
222142
+ pcmBuffer = Buffer.alloc(0);
222143
+ pumping = false;
222144
+ pumpPromise = null;
222145
+ maxBacklogBytes = 0;
222146
+ bytesPerBlock = 0;
222147
+ blockSize = 0;
222148
+ lastBacklogClampLogAtMs = 0;
222149
+ outputGain = DEFAULT_GAIN;
222150
+ constructor(opts) {
222151
+ this.opts = opts;
222152
+ }
222153
+ /** True once `start()` has resolved and not yet been `stop()`'d. */
222154
+ get isOpen() {
222155
+ return this.session !== null;
222156
+ }
222157
+ /** Sample rate the camera negotiated. Throws when not started. */
222158
+ get sampleRate() {
222159
+ if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
222160
+ return this.session.info.audioConfig.sampleRate;
222161
+ }
222162
+ async start() {
222163
+ if (this.session) return;
222164
+ this.outputGain = clampGain(this.opts.outputGain);
222165
+ const session = await this.opts.api.createDedicatedTalkSession(this.opts.channel, {
222166
+ blocksPerPayload: clampBlocks(this.opts.blocksPerPayload),
222167
+ idleTimeoutMs: this.opts.idleTimeoutMs ?? 3e4,
222168
+ deviceId: this.opts.deviceTag,
222169
+ logger: { log: (msg, ...rest) => this.opts.logger.debug(`talk: ${msg}`, { meta: { rest } }) }
222170
+ });
222171
+ const { blockSize, fullBlockSize } = session.info;
222172
+ if (!Number.isFinite(blockSize) || blockSize <= 0 || fullBlockSize !== blockSize + 4) {
222173
+ try {
222174
+ await session.stop();
222175
+ } catch {}
222176
+ throw new Error(`Reolink talk session reported invalid block sizes: blockSize=${blockSize} fullBlockSize=${fullBlockSize}`);
222177
+ }
222178
+ const samplesPerBlock = blockSize * 2 + 1;
222179
+ this.bytesPerBlock = samplesPerBlock * 2;
222180
+ this.blockSize = blockSize;
222181
+ const sampleRate = session.info.audioConfig.sampleRate;
222182
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
222183
+ try {
222184
+ await session.stop();
222185
+ } catch {}
222186
+ throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
222187
+ }
222188
+ const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
222189
+ this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
222190
+ this.session = session;
222191
+ this.pcmBuffer = Buffer.alloc(0);
222192
+ this.opts.logger.info("intercom talk session opened", { meta: {
222193
+ channel: this.opts.channel,
222194
+ sampleRate,
222195
+ blockSize,
222196
+ bytesPerBlock: this.bytesPerBlock,
222197
+ backlogMs: wantedBacklogMs,
222198
+ maxBacklogBytes: this.maxBacklogBytes,
222199
+ blocksPerPayload: clampBlocks(this.opts.blocksPerPayload),
222200
+ outputGain: this.outputGain
222201
+ } });
222202
+ }
222203
+ /**
222204
+ * Feed a chunk of PCM s16le at `this.sampleRate` Hz. Returns
222205
+ * immediately after enqueueing — the actual encode + send happens
222206
+ * in a background pump. Calls before `start()` (or after `stop()`)
222207
+ * silently drop the chunk so callers don't have to gate every
222208
+ * push on `isOpen`.
222209
+ */
222210
+ feedPcm(pcm) {
222211
+ if (!this.session) return;
222212
+ if (pcm.length === 0) return;
222213
+ this.pcmBuffer = this.pcmBuffer.length ? Buffer.concat([this.pcmBuffer, pcm]) : pcm;
222214
+ if (this.pcmBuffer.length > this.maxBacklogBytes) {
222215
+ const keep = this.maxBacklogBytes - this.maxBacklogBytes % 2;
222216
+ const dropped = this.pcmBuffer.length - keep;
222217
+ this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
222218
+ const now = Date.now();
222219
+ if (now - this.lastBacklogClampLogAtMs > 2e3) {
222220
+ this.lastBacklogClampLogAtMs = now;
222221
+ this.opts.logger.warn("intercom backlog clamped (dropping PCM)", { meta: {
222222
+ droppedBytes: dropped,
222223
+ keptBytes: keep,
222224
+ maxBytes: this.maxBacklogBytes
222225
+ } });
222226
+ }
222227
+ }
222228
+ if (!this.pumping) this.startPump();
222229
+ }
222230
+ startPump() {
222231
+ const session = this.session;
222232
+ if (!session) return;
222233
+ this.pumping = true;
222234
+ this.pumpPromise = (async () => {
222235
+ try {
222236
+ while (true) {
222237
+ if (this.session !== session) return;
222238
+ if (this.pcmBuffer.length < this.bytesPerBlock) return;
222239
+ const chunk = this.pcmBuffer.subarray(0, this.bytesPerBlock);
222240
+ this.pcmBuffer = this.pcmBuffer.subarray(this.bytesPerBlock);
222241
+ const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
222242
+ const adpcm = encodeImaAdpcm(this.outputGain === 1 ? samples : applyGainInt16(samples, this.outputGain), this.blockSize);
222243
+ await session.sendAudio(adpcm);
222244
+ }
222245
+ } catch (err) {
222246
+ this.opts.logger.warn("intercom pump error — stopping", { meta: { error: err instanceof Error ? err.message : String(err) } });
222247
+ } finally {
222248
+ this.pumping = false;
222249
+ }
222250
+ })();
222251
+ }
222252
+ async stop() {
222253
+ const session = this.session;
222254
+ if (!session) return;
222255
+ this.session = null;
222256
+ this.pcmBuffer = Buffer.alloc(0);
222257
+ if (this.pumpPromise) {
222258
+ try {
222259
+ await Promise.race([this.pumpPromise, new Promise((r) => setTimeout(r, 250))]);
222260
+ } catch {}
222261
+ this.pumpPromise = null;
222262
+ }
222263
+ try {
222264
+ await Promise.race([session.stop(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("talk session stop timeout")), 2e3))]);
222265
+ } catch (err) {
222266
+ this.opts.logger.warn("intercom session stop error", { meta: { error: err instanceof Error ? err.message : String(err) } });
222267
+ }
222268
+ }
222269
+ };
222270
+ /** Clamp `blocksPerPayload` into the lib's accepted range. Mirrors
222271
+ * scrypted-reolink-native's `intercom-mixin.ts:96-101` clamp. */
222272
+ function clampBlocks(value) {
222273
+ if (value === void 0 || !Number.isFinite(value)) return DEFAULT_BLOCKS_PER_PAYLOAD;
222274
+ return Math.max(1, Math.min(8, Math.floor(value)));
222275
+ }
222276
+ /** Clamp `outputGain` into the operator-safe range. Same `[0.1, 10]`
222277
+ * band as Scrypted (`intercom-mixin.ts:104-107`). */
222278
+ function clampGain(value) {
222279
+ if (value === void 0 || !Number.isFinite(value)) return DEFAULT_GAIN;
222280
+ return Math.max(MIN_GAIN, Math.min(MAX_GAIN, value));
222281
+ }
222282
+ /** Apply a floating-point gain to each `Int16` sample in-place into
222283
+ * a freshly-allocated buffer. The result is hard-clipped to int16
222284
+ * bounds — soft saturation isn't worth the tradeoff for an intercom
222285
+ * channel where occasional clipping is preferable to a perceived
222286
+ * loudness mismatch. */
222287
+ function applyGainInt16(samples, gain) {
222288
+ const out = new Int16Array(samples.length);
222289
+ for (let i = 0; i < samples.length; i++) {
222290
+ const scaled = (samples[i] ?? 0) * gain;
222291
+ out[i] = scaled > 32767 ? 32767 : scaled < -32768 ? -32768 : scaled;
222292
+ }
222293
+ return out;
222294
+ }
222295
+ //#endregion
222296
+ //#region src/intercom-orchestrator.ts
222297
+ /** Default Opus parameters used when the orchestrator caller doesn't
222298
+ * override them. Browser Opus is canonically 48 kHz; mono is the only
222299
+ * practical choice for an intercom (the camera's talk channel is
222300
+ * mono — sending stereo would just cost bandwidth). */
222301
+ var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
222302
+ var DEFAULT_OPUS_CHANNELS = 1;
222303
+ var DEFAULT_CAMERA_SAMPLE_RATE = 16e3;
222304
+ var IntercomOrchestrator = class {
222305
+ opts;
222306
+ session = null;
222307
+ constructor(opts) {
222308
+ this.opts = opts;
222309
+ }
222310
+ /** True while a session is open (between `start()` resolve and
222311
+ * `stop()` resolve). */
222312
+ get isOpen() {
222313
+ return this.session !== null && !this.session.closed;
222314
+ }
222315
+ /**
222316
+ * Open a fresh WebRTC peer + audio-codec decode session + Reolink
222317
+ * talk session, wire them, return the SDP offer. Throws (and tears
222318
+ * down everything it had spun up) on any failure — the cap router
222319
+ * surfaces the error to the client unchanged.
222320
+ *
222321
+ * Single-active-session semantics: a second `start()` while the
222322
+ * first is still open closes the old session before opening the
222323
+ * new one. The cap router enforces this on the caller side via
222324
+ * `intercomSessions.size === 1` invariants.
222325
+ */
222326
+ async start() {
222327
+ if (this.session && !this.session.closed) await this.stop(this.session.sessionId, "superseded-by-new-start").catch(() => {});
222328
+ const sessionId = generateSessionId();
222329
+ const cameraRate = this.opts.cameraSampleRate ?? DEFAULT_CAMERA_SAMPLE_RATE;
222330
+ const opusRate = this.opts.opusSampleRate ?? DEFAULT_OPUS_SAMPLE_RATE;
222331
+ const opusChannels = this.opts.opusChannels ?? DEFAULT_OPUS_CHANNELS;
222332
+ this.opts.logger.info("intercom: negotiate (opening session)", { meta: {
222333
+ sessionId,
222334
+ channel: this.opts.channel,
222335
+ opusRate,
222336
+ opusChannels
222337
+ } });
222338
+ if (this.opts.wakeBeforeStart) try {
222339
+ await this.opts.wakeBeforeStart();
222340
+ } catch (err) {
222341
+ this.opts.logger.warn("intercom: pre-wake failed", { meta: { error: errMsg$1(err) } });
222342
+ throw err;
222343
+ }
222344
+ const talkSession = new ReolinkIntercomSession({
222345
+ channel: this.opts.channel,
222346
+ api: this.opts.api,
222347
+ logger: this.opts.logger.withTags?.({ sessionId }) ?? this.opts.logger,
222348
+ deviceTag: this.opts.deviceTag,
222349
+ ...this.opts.blocksPerPayload !== void 0 ? { blocksPerPayload: this.opts.blocksPerPayload } : {},
222350
+ ...this.opts.maxBacklogMs !== void 0 ? { maxBacklogMs: this.opts.maxBacklogMs } : {},
222351
+ ...this.opts.outputGain !== void 0 ? { outputGain: this.opts.outputGain } : {}
222352
+ });
222353
+ try {
222354
+ await talkSession.start();
222355
+ } catch (err) {
222356
+ this.opts.logger.warn("intercom: talk session open failed", { meta: { error: errMsg$1(err) } });
222357
+ throw err;
222358
+ }
222359
+ const realCameraRate = talkSession.sampleRate;
222360
+ const targetRate = Number.isFinite(realCameraRate) && realCameraRate > 0 ? realCameraRate : cameraRate;
222361
+ let codec;
222362
+ try {
222363
+ codec = await this.opts.audioCodec.createDecodeSession({
222364
+ codec: "opus",
222365
+ sourceSampleRate: opusRate,
222366
+ sourceChannels: opusChannels,
222367
+ targetSampleRate: targetRate,
222368
+ targetChannels: 1,
222369
+ targetFormat: "s16le",
222370
+ tag: `reolink-intercom:${this.opts.deviceTag}:${sessionId}`
222371
+ });
222372
+ } catch (err) {
222373
+ await talkSession.stop().catch(() => {});
222374
+ this.opts.logger.warn("intercom: audio-codec createDecodeSession failed", { meta: {
222375
+ error: errMsg$1(err),
222376
+ opusRate,
222377
+ opusChannels,
222378
+ targetRate
222379
+ } });
222380
+ throw err;
222381
+ }
222382
+ const peer = this.opts.peerFactory({ logger: this.opts.logger });
222383
+ const active = {
222384
+ sessionId,
222385
+ peer,
222386
+ talkSession,
222387
+ codec,
222388
+ closed: false,
222389
+ startedAtMs: Date.now(),
222390
+ framesPushed: 0,
222391
+ pcmBytesPushed: 0,
222392
+ answerApplied: false
222393
+ };
222394
+ this.session = active;
222395
+ peer.onOpusFrame((frame, pts) => {
222396
+ this.handleOpusFrame(active, frame, pts).catch((err) => {
222397
+ this.opts.logger.debug("intercom: opus frame pump error (dropped)", { meta: { error: errMsg$1(err) } });
222398
+ });
222399
+ });
222400
+ let offer;
222401
+ try {
222402
+ offer = await peer.createOffer();
222403
+ } catch (err) {
222404
+ await this.stop(sessionId, "start-failed-cleanup").catch(() => {});
222405
+ this.opts.logger.warn("intercom: webrtc createOffer failed", { meta: { error: errMsg$1(err) } });
222406
+ throw err;
222407
+ }
222408
+ this.opts.logger.info("intercom session opened", { meta: {
222409
+ sessionId,
222410
+ channel: this.opts.channel,
222411
+ targetRate,
222412
+ codecSessionId: codec.sessionId,
222413
+ codecNodeId: codec.nodeId
222414
+ } });
222415
+ return {
222416
+ sessionId,
222417
+ sdpOffer: offer.sdp
222418
+ };
222419
+ }
222420
+ /**
222421
+ * Apply the browser's SDP answer. The session is identified by id
222422
+ * (callers may have multiple cameras with overlapping intercom
222423
+ * sessions in flight — though this orchestrator only tracks one).
222424
+ * Mismatched session id throws.
222425
+ */
222426
+ async handleAnswer(sessionId, sdpAnswer) {
222427
+ const active = this.requireActive(sessionId);
222428
+ await active.peer.setAnswer(sdpAnswer);
222429
+ active.answerApplied = true;
222430
+ this.opts.logger.info("intercom: SDP answer accepted (handshake complete)", { meta: { sessionId } });
222431
+ }
222432
+ /**
222433
+ * Tear down the session in reverse-open order. Idempotent: a stop
222434
+ * for an unknown / already-closed session resolves silently. Best-
222435
+ * effort: a partial teardown (e.g. peer.close fails) does NOT
222436
+ * abort the rest — the next steps still try to release their own
222437
+ * resources.
222438
+ *
222439
+ * `reason` records WHY the session ended — surfaced in the close log so an
222440
+ * immediate-close is diagnosable from one line. The cap-router stop path
222441
+ * passes nothing and defaults to `'client-stop'`; internal teardown paths
222442
+ * (supersede, start-failure cleanup) pass their specific reason.
222443
+ */
222444
+ async stop(sessionId, reason = "client-stop") {
222445
+ const active = this.session;
222446
+ if (!active || active.sessionId !== sessionId || active.closed) return;
222447
+ active.closed = true;
222448
+ this.session = null;
222449
+ try {
222450
+ await active.peer.close();
222451
+ } catch (err) {
222452
+ this.opts.logger.debug("intercom: peer.close error (continuing)", { meta: {
222453
+ sessionId,
222454
+ error: errMsg$1(err)
222455
+ } });
222456
+ }
222457
+ try {
222458
+ await this.opts.audioCodec.closeSession({
222459
+ sessionId: active.codec.sessionId,
222460
+ nodeId: active.codec.nodeId
222461
+ });
222462
+ } catch (err) {
222463
+ this.opts.logger.debug("intercom: audio-codec.closeSession error (continuing)", { meta: {
222464
+ sessionId,
222465
+ error: errMsg$1(err)
222466
+ } });
222467
+ }
222468
+ try {
222469
+ await active.talkSession.stop();
222470
+ } catch (err) {
222471
+ this.opts.logger.debug("intercom: talk-session.stop error (continuing)", { meta: {
222472
+ sessionId,
222473
+ error: errMsg$1(err)
222474
+ } });
222475
+ }
222476
+ this.opts.logger.info("intercom session closed", { meta: {
222477
+ sessionId,
222478
+ reason,
222479
+ answerApplied: active.answerApplied,
222480
+ framesPushed: active.framesPushed,
222481
+ pcmBytesPushed: active.pcmBytesPushed,
222482
+ durationMs: Date.now() - active.startedAtMs
222483
+ } });
222484
+ }
222485
+ /**
222486
+ * Push one Opus frame into the audio-codec, immediately drain any
222487
+ * decoded PCM, and feed it to the talk session. Push-then-pull
222488
+ * keeps latency tight: each Opus frame produces ~20ms of PCM and
222489
+ * we surface it on the same async tick.
222490
+ *
222491
+ * Errors here are isolated to a single frame — the caller wraps in
222492
+ * a void-fire-and-forget so an audio-codec hiccup doesn't break
222493
+ * the RTP receive loop.
222494
+ */
222495
+ async handleOpusFrame(active, frame, pts) {
222496
+ if (active.closed) return;
222497
+ if (active.framesPushed === 0) this.opts.logger.info("intercom: first Opus frame received (feeding camera)", { meta: { sessionId: active.sessionId } });
222498
+ active.framesPushed += 1;
222499
+ await this.opts.audioCodec.pushEncodedFrame({
222500
+ sessionId: active.codec.sessionId,
222501
+ nodeId: active.codec.nodeId,
222502
+ data: frame,
222503
+ pts
222504
+ });
222505
+ if (active.closed) return;
222506
+ const chunks = await this.opts.audioCodec.pullPcm({
222507
+ sessionId: active.codec.sessionId,
222508
+ nodeId: active.codec.nodeId,
222509
+ maxCount: 8
222510
+ });
222511
+ if (active.closed) return;
222512
+ for (const chunk of chunks) {
222513
+ const buf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
222514
+ active.pcmBytesPushed += buf.length;
222515
+ active.talkSession.feedPcm(buf);
222516
+ }
222517
+ }
222518
+ requireActive(sessionId) {
222519
+ const active = this.session;
222520
+ if (!active || active.closed || active.sessionId !== sessionId) throw new Error(`Reolink intercom: unknown or closed sessionId ${sessionId}`);
222521
+ return active;
222522
+ }
222523
+ };
222524
+ /** Random-enough session id — no crypto requirement, just needs to be
222525
+ * unique-per-camera across reasonable timescales. */
222526
+ function generateSessionId() {
222527
+ return `intercom-${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
222528
+ }
222529
+ function errMsg$1(err) {
222530
+ return err instanceof Error ? err.message : String(err);
222531
+ }
222532
+ //#endregion
222533
+ //#region src/intercom-webrtc-peer.ts
222534
+ var _werift;
222535
+ /**
222536
+ * Lazy import — werift is an optional peer dep of this package
222537
+ * (declared via peerDependenciesMeta so npm install doesn't fail on
222538
+ * agents/clusters where the intercom is never used).
222539
+ */
222540
+ async function loadWerift() {
222541
+ if (_werift) return _werift;
222542
+ try {
222543
+ _werift = await Function("m", "return import(m)")("werift");
222544
+ return _werift;
222545
+ } catch {
222546
+ throw new Error("The 'werift' package is required for Reolink intercom support but is not installed. Install it with: npm install werift");
222547
+ }
222548
+ }
222549
+ var WeriftIntercomPeer = class {
222550
+ opts;
222551
+ pc = null;
222552
+ opusCallbacks = [];
222553
+ rtpUnsubscribe = null;
222554
+ trackUnsubscribe = null;
222555
+ closed = false;
222556
+ /** Anchor PTS to the first observed RTP timestamp so the audio-
222557
+ * codec receives monotonically-increasing PTS values from zero
222558
+ * rather than the camera's free-running 32-bit RTP clock. */
222559
+ firstRtpTimestamp = null;
222560
+ constructor(opts) {
222561
+ this.opts = opts;
222562
+ }
222563
+ onOpusFrame(cb) {
222564
+ if (this.closed) return;
222565
+ this.opusCallbacks.push(cb);
222566
+ }
222567
+ async createOffer() {
222568
+ if (this.pc) throw new Error("WeriftIntercomPeer: createOffer called twice");
222569
+ const werift = await loadWerift();
222570
+ const pcOptions = {};
222571
+ if (this.opts.iceServers && this.opts.iceServers.length > 0) pcOptions.iceServers = [...this.opts.iceServers];
222572
+ const pc = new werift.RTCPeerConnection(pcOptions);
222573
+ this.pc = pc;
222574
+ const localTrack = new werift.MediaStreamTrack({ kind: "audio" });
222575
+ const trackSub = pc.addTransceiver(localTrack, { direction: "sendrecv" }).onTrack.subscribe((track) => {
222576
+ if (track.kind !== "audio") return;
222577
+ const rtpSub = track.onReceiveRtp.subscribe((pkt) => {
222578
+ if (this.closed) return;
222579
+ const payload = pkt.payload;
222580
+ if (!payload || payload.length === 0) return;
222581
+ const ts = pkt.header.timestamp;
222582
+ if (this.firstRtpTimestamp === null) this.firstRtpTimestamp = ts;
222583
+ const relTs = ts - this.firstRtpTimestamp >>> 0;
222584
+ const ptsMs = Math.round(relTs / 48);
222585
+ for (const cb of this.opusCallbacks) try {
222586
+ cb(payload, ptsMs);
222587
+ } catch (err) {
222588
+ this.opts.logger.debug("intercom-peer: opus callback threw", { meta: { error: errMsg(err) } });
222589
+ }
222590
+ });
222591
+ if (rtpSub && typeof rtpSub.unsubscribe === "function") this.rtpUnsubscribe = () => rtpSub.unsubscribe?.();
222592
+ });
222593
+ if (trackSub && typeof trackSub.unsubscribe === "function") this.trackUnsubscribe = () => trackSub.unsubscribe?.();
222594
+ pc.iceConnectionStateChange.subscribe((state) => {
222595
+ this.opts.logger.info("intercom-peer: ICE state", { meta: { state } });
222596
+ });
222597
+ pc.iceGatheringStateChange.subscribe((state) => {
222598
+ this.opts.logger.debug("intercom-peer: ICE gathering", { meta: { state } });
222599
+ });
222600
+ const offer = await pc.createOffer();
222601
+ await pc.setLocalDescription(offer);
222602
+ return { sdp: pc.localDescription?.sdp ?? offer.sdp };
222603
+ }
222604
+ async setAnswer(sdp) {
222605
+ if (!this.pc) throw new Error("WeriftIntercomPeer: setAnswer called before createOffer");
222606
+ if (this.closed) throw new Error("WeriftIntercomPeer: setAnswer called on closed peer");
222607
+ await this.pc.setRemoteDescription({
222608
+ sdp,
222609
+ type: "answer"
222610
+ });
222611
+ }
222612
+ async close() {
222613
+ if (this.closed) return;
222614
+ this.closed = true;
222615
+ this.opusCallbacks = [];
222616
+ if (this.rtpUnsubscribe) {
222617
+ try {
222618
+ this.rtpUnsubscribe();
222619
+ } catch {}
222620
+ this.rtpUnsubscribe = null;
222621
+ }
222622
+ if (this.trackUnsubscribe) {
222623
+ try {
222624
+ this.trackUnsubscribe();
222625
+ } catch {}
222626
+ this.trackUnsubscribe = null;
222627
+ }
222628
+ if (this.pc) {
222629
+ try {
222630
+ await Promise.resolve(this.pc.close());
222631
+ } catch {}
222632
+ this.pc = null;
222633
+ }
222634
+ }
222635
+ };
222636
+ function errMsg(err) {
222637
+ return err instanceof Error ? err.message : String(err);
222638
+ }
222639
+ //#endregion
220971
222640
  //#region src/schema.ts
220972
222641
  /**
220973
222642
  * Single source of truth for the addon id used in event source tags +
@@ -221807,842 +223476,6 @@ function mapDetectionEvent(event, cameraId, nowMs) {
221807
223476
  function isNativeObjectForwardingEnabled(capState) {
221808
223477
  return capState?.enabled === true;
221809
223478
  }
221810
- //#endregion
221811
- //#region src/stream-routing.ts
221812
- var KIND_RE = /^(native|rtsp|rtmp|flv):(.+)$/;
221813
- var CH_PROFILE_RE = /^ch(\d+)-(main|sub|ext)$/;
221814
- var PROFILE_ONLY_RE = /^(main|sub|ext)$/;
221815
- /**
221816
- * Build a camStreamId from the (kind, channel, profile) tuple. Single-channel
221817
- * devices omit the `ch{N}-` infix; NVR / Hub include it so per-channel
221818
- * routing survives the round-trip through the broker.
221819
- */
221820
- function buildCamStreamId(kind, channel, profile, channelCount) {
221821
- if (channelCount > 1) return `${kind}:ch${channel}-${profile}`;
221822
- return `${kind}:${profile}`;
221823
- }
221824
- /**
221825
- * Parse a Reolink camStreamId back to its (kind, channel, profile) tuple.
221826
- * Returns `null` if the id does not match our format — the demand handler
221827
- * uses this to ignore non-native streams (broker pulls those directly).
221828
- */
221829
- function parseCamStreamId(camStreamId, defaultChannel) {
221830
- const m = KIND_RE.exec(camStreamId);
221831
- if (!m) return null;
221832
- const kind = m[1];
221833
- const rest = m[2];
221834
- const ch = CH_PROFILE_RE.exec(rest);
221835
- if (ch) return {
221836
- kind,
221837
- channel: parseInt(ch[1], 10),
221838
- profile: ch[2]
221839
- };
221840
- const p = PROFILE_ONLY_RE.exec(rest);
221841
- if (p) return {
221842
- kind,
221843
- channel: defaultChannel,
221844
- profile: p[1]
221845
- };
221846
- return null;
221847
- }
221848
- /** Render a human-readable label for the device-settings dropdown. */
221849
- function streamLabel(s, kind) {
221850
- const parts = [];
221851
- if (kind) parts.push(kindLabel(kind));
221852
- if (s.channel !== void 0) parts.push(`Ch${s.channel}`);
221853
- parts.push(s.profile.charAt(0).toUpperCase() + s.profile.slice(1));
221854
- if (s.lens && s.lens !== "wide") parts.push(`(${s.lens})`);
221855
- return parts.join(" ");
221856
- }
221857
- function kindLabel(kind) {
221858
- switch (kind) {
221859
- case "native": return "Native";
221860
- case "rtsp": return "RTSP";
221861
- case "rtmp": return "RTMP";
221862
- case "flv": return "FLV";
221863
- }
221864
- }
221865
- /**
221866
- * Synthetic native cam-stream id list used by `getStreamSources()` to
221867
- * advertise the device's expected stream shape before the lib has been
221868
- * called. `publishToBroker` ignores this — it always uses the live
221869
- * result of `buildVideoStreamOptions()` instead.
221870
- */
221871
- function buildStreamIds(channelCount) {
221872
- if (channelCount <= 1) return [{
221873
- id: "native:main",
221874
- label: "Native Main"
221875
- }, {
221876
- id: "native:sub",
221877
- label: "Native Sub"
221878
- }];
221879
- const out = [];
221880
- for (let ch = 0; ch < channelCount; ch++) {
221881
- out.push({
221882
- id: `native:ch${ch}-main`,
221883
- label: `Native Ch${ch} Main`
221884
- });
221885
- out.push({
221886
- id: `native:ch${ch}-sub`,
221887
- label: `Native Ch${ch} Sub`
221888
- });
221889
- }
221890
- return out;
221891
- }
221892
- //#endregion
221893
- //#region src/error-classifier.ts
221894
- /**
221895
- * Recognise transient Baichuan/socket failures that are expected to
221896
- * recover on the next reconnect cycle. Mirrors
221897
- * `scrypted-reolink-native/src/camera.ts isRecoverableBaichuanError` —
221898
- * keeping parity so the same wire-level conditions are treated the
221899
- * same way across plugins.
221900
- *
221901
- * Ported categories:
221902
- * - Baichuan-specific transport closes (`Baichuan socket closed`,
221903
- * `Baichuan UDP stream closed`, `Baichuan TCP socket is not
221904
- * connected`)
221905
- * - Generic TCP errors that fire as part of the same disconnect
221906
- * storm (`ECONNRESET`, `EPIPE`, `socket hang up`)
221907
- * - D2C disconnects (`D2C_DISC`) — UDP relay path teardown
221908
- *
221909
- * Callers downgrade these to WARN-level logs and trigger a fresh
221910
- * login on the next demand instead of bubbling a hard ERROR.
221911
- */
221912
- var RECOVERABLE_FRAGMENTS = [
221913
- "Baichuan socket closed",
221914
- "Baichuan UDP stream closed",
221915
- "Baichuan TCP socket is not connected",
221916
- "socket hang up",
221917
- "ECONNRESET",
221918
- "EPIPE",
221919
- "D2C_DISC"
221920
- ];
221921
- function isRecoverableBaichuanError(err) {
221922
- const message = err instanceof Error ? err.message : typeof err === "string" ? err : err?.toString?.() ?? "";
221923
- return RECOVERABLE_FRAGMENTS.some((fragment) => message.includes(fragment));
221924
- }
221925
- //#endregion
221926
- //#region src/intercom-encoder.ts
221927
- /**
221928
- * IMA ADPCM (DVI4) encoder — Reolink Baichuan talk-back wire format.
221929
- *
221930
- * Ported from `scrypted-reolink-native/src/intercom.ts` (BSD-2 like the
221931
- * rest of the cross-cam Scrypted infra). Reolink cameras expect ADPCM
221932
- * blocks of (4 + N) bytes:
221933
- * - 2 bytes: little-endian Int16 predictor (first PCM sample of the
221934
- * block, used to seed the decoder state on the camera side)
221935
- * - 1 byte: index into the IMA step table (always 0 — we re-seed
221936
- * the predictor on every block instead of carrying state forward)
221937
- * - 1 byte: padding
221938
- * - N bytes: 2 nibbles per byte, low nibble first, each nibble
221939
- * encodes one PCM sample's delta as `sign | delta3` (4 bits)
221940
- *
221941
- * The block size is camera-firmware specific; the lib reports it as
221942
- * `TalkSessionInfo.blockSize` per session.
221943
- *
221944
- * Why standalone?
221945
- * - Pure function — no I/O, easy to unit-test
221946
- * - Reusable from the (currently stub) WebRTC server-side intercom
221947
- * path AND from any future direct-PCM caller
221948
- * - Decoupled from werift / lib types
221949
- */
221950
- var IMA_INDEX_TABLE = Int8Array.from([
221951
- -1,
221952
- -1,
221953
- -1,
221954
- -1,
221955
- 2,
221956
- 4,
221957
- 6,
221958
- 8,
221959
- -1,
221960
- -1,
221961
- -1,
221962
- -1,
221963
- 2,
221964
- 4,
221965
- 6,
221966
- 8
221967
- ]);
221968
- var IMA_STEP_TABLE = Int16Array.from([
221969
- 7,
221970
- 8,
221971
- 9,
221972
- 10,
221973
- 11,
221974
- 12,
221975
- 13,
221976
- 14,
221977
- 16,
221978
- 17,
221979
- 19,
221980
- 21,
221981
- 23,
221982
- 25,
221983
- 28,
221984
- 31,
221985
- 34,
221986
- 37,
221987
- 41,
221988
- 45,
221989
- 50,
221990
- 55,
221991
- 60,
221992
- 66,
221993
- 73,
221994
- 80,
221995
- 88,
221996
- 97,
221997
- 107,
221998
- 118,
221999
- 130,
222000
- 143,
222001
- 157,
222002
- 173,
222003
- 190,
222004
- 209,
222005
- 230,
222006
- 253,
222007
- 279,
222008
- 307,
222009
- 337,
222010
- 371,
222011
- 408,
222012
- 449,
222013
- 494,
222014
- 544,
222015
- 598,
222016
- 658,
222017
- 724,
222018
- 796,
222019
- 876,
222020
- 963,
222021
- 1060,
222022
- 1166,
222023
- 1282,
222024
- 1411,
222025
- 1552,
222026
- 1707,
222027
- 1878,
222028
- 2066,
222029
- 2272,
222030
- 2499,
222031
- 2749,
222032
- 3024,
222033
- 3327,
222034
- 3660,
222035
- 4026,
222036
- 4428,
222037
- 4871,
222038
- 5358,
222039
- 5894,
222040
- 6484,
222041
- 7132,
222042
- 7845,
222043
- 8630,
222044
- 9493,
222045
- 10442,
222046
- 11487,
222047
- 12635,
222048
- 13899,
222049
- 15289,
222050
- 16818,
222051
- 18500,
222052
- 20350,
222053
- 22385,
222054
- 24623,
222055
- 27086,
222056
- 29794,
222057
- 32767
222058
- ]);
222059
- function clamp16(x) {
222060
- if (x > 32767) return 32767;
222061
- if (x < -32768) return -32768;
222062
- return x | 0;
222063
- }
222064
- /**
222065
- * Encode a PCM s16le buffer into IMA ADPCM blocks of size
222066
- * `(4 + blockSizeBytes)` each. The output length is a multiple of
222067
- * `4 + blockSizeBytes`. PCM samples that don't fit a full block at
222068
- * the end get folded into a final partial block (the camera tolerates
222069
- * trailing zeros from the unpopulated nibbles).
222070
- *
222071
- * Block layout per Reolink's wire format:
222072
- * bytes [0..1] = predictor (Int16 LE)
222073
- * byte [2] = step index (always 0 — re-seed each block)
222074
- * byte [3] = padding (0x00)
222075
- * bytes [4..N+3] = packed nibbles (2 samples per byte, low first)
222076
- *
222077
- * Each block carries `blockSizeBytes * 2 + 1` PCM samples (the +1
222078
- * is the predictor sample stored explicitly in the header).
222079
- */
222080
- function encodeImaAdpcm(pcm, blockSizeBytes) {
222081
- const samplesPerBlock = blockSizeBytes * 2 + 1;
222082
- const totalBlocks = Math.ceil(pcm.length / samplesPerBlock);
222083
- const outBlocks = [];
222084
- let sampleIndex = 0;
222085
- for (let b = 0; b < totalBlocks; b++) {
222086
- const block = Buffer.alloc(4 + blockSizeBytes);
222087
- let predictor = pcm[sampleIndex] ?? 0;
222088
- let index = 0;
222089
- block.writeInt16LE(predictor, 0);
222090
- block.writeUInt8(index, 2);
222091
- block.writeUInt8(0, 3);
222092
- sampleIndex++;
222093
- const codes = new Uint8Array(blockSizeBytes * 2);
222094
- for (let i = 0; i < codes.length; i++) {
222095
- const sample = pcm[sampleIndex] ?? predictor;
222096
- sampleIndex++;
222097
- let diff = sample - predictor;
222098
- let sign = 0;
222099
- if (diff < 0) {
222100
- sign = 8;
222101
- diff = -diff;
222102
- }
222103
- let step = IMA_STEP_TABLE[index] ?? 7;
222104
- let delta = 0;
222105
- let vpdiff = step >> 3;
222106
- if (diff >= step) {
222107
- delta |= 4;
222108
- diff -= step;
222109
- vpdiff += step;
222110
- }
222111
- step >>= 1;
222112
- if (diff >= step) {
222113
- delta |= 2;
222114
- diff -= step;
222115
- vpdiff += step;
222116
- }
222117
- step >>= 1;
222118
- if (diff >= step) {
222119
- delta |= 1;
222120
- vpdiff += step;
222121
- }
222122
- predictor = sign ? clamp16(predictor - vpdiff) : clamp16(predictor + vpdiff);
222123
- index += IMA_INDEX_TABLE[delta] ?? 0;
222124
- if (index < 0) index = 0;
222125
- if (index > 88) index = 88;
222126
- codes[i] = (delta | sign) & 15;
222127
- }
222128
- for (let i = 0; i < blockSizeBytes; i++) {
222129
- const lo = codes[i * 2] ?? 0;
222130
- const hi = codes[i * 2 + 1] ?? 0;
222131
- block[4 + i] = lo & 15 | (hi & 15) << 4;
222132
- }
222133
- outBlocks.push(block);
222134
- }
222135
- return Buffer.concat(outBlocks);
222136
- }
222137
- //#endregion
222138
- //#region src/intercom-session.ts
222139
- var DEFAULT_BACKLOG_MS = 120;
222140
- var MAX_BACKLOG_MS = 5e3;
222141
- var MIN_BACKLOG_MS = 20;
222142
- var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
222143
- var DEFAULT_GAIN = 1;
222144
- var MIN_GAIN = .1;
222145
- var MAX_GAIN = 10;
222146
- var ReolinkIntercomSession = class {
222147
- opts;
222148
- session = null;
222149
- pcmBuffer = Buffer.alloc(0);
222150
- pumping = false;
222151
- pumpPromise = null;
222152
- maxBacklogBytes = 0;
222153
- bytesPerBlock = 0;
222154
- blockSize = 0;
222155
- lastBacklogClampLogAtMs = 0;
222156
- outputGain = DEFAULT_GAIN;
222157
- constructor(opts) {
222158
- this.opts = opts;
222159
- }
222160
- /** True once `start()` has resolved and not yet been `stop()`'d. */
222161
- get isOpen() {
222162
- return this.session !== null;
222163
- }
222164
- /** Sample rate the camera negotiated. Throws when not started. */
222165
- get sampleRate() {
222166
- if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
222167
- return this.session.info.audioConfig.sampleRate;
222168
- }
222169
- async start() {
222170
- if (this.session) return;
222171
- this.outputGain = clampGain(this.opts.outputGain);
222172
- const session = await this.opts.api.createDedicatedTalkSession(this.opts.channel, {
222173
- blocksPerPayload: clampBlocks(this.opts.blocksPerPayload),
222174
- idleTimeoutMs: this.opts.idleTimeoutMs ?? 3e4,
222175
- deviceId: this.opts.deviceTag,
222176
- logger: { log: (msg, ...rest) => this.opts.logger.debug(`talk: ${msg}`, { meta: { rest } }) }
222177
- });
222178
- const { blockSize, fullBlockSize } = session.info;
222179
- if (!Number.isFinite(blockSize) || blockSize <= 0 || fullBlockSize !== blockSize + 4) {
222180
- try {
222181
- await session.stop();
222182
- } catch {}
222183
- throw new Error(`Reolink talk session reported invalid block sizes: blockSize=${blockSize} fullBlockSize=${fullBlockSize}`);
222184
- }
222185
- const samplesPerBlock = blockSize * 2 + 1;
222186
- this.bytesPerBlock = samplesPerBlock * 2;
222187
- this.blockSize = blockSize;
222188
- const sampleRate = session.info.audioConfig.sampleRate;
222189
- if (!Number.isFinite(sampleRate) || sampleRate <= 0) {
222190
- try {
222191
- await session.stop();
222192
- } catch {}
222193
- throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
222194
- }
222195
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
222196
- this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
222197
- this.session = session;
222198
- this.pcmBuffer = Buffer.alloc(0);
222199
- this.opts.logger.info("intercom talk session opened", { meta: {
222200
- channel: this.opts.channel,
222201
- sampleRate,
222202
- blockSize,
222203
- bytesPerBlock: this.bytesPerBlock,
222204
- backlogMs: wantedBacklogMs,
222205
- maxBacklogBytes: this.maxBacklogBytes,
222206
- blocksPerPayload: clampBlocks(this.opts.blocksPerPayload),
222207
- outputGain: this.outputGain
222208
- } });
222209
- }
222210
- /**
222211
- * Feed a chunk of PCM s16le at `this.sampleRate` Hz. Returns
222212
- * immediately after enqueueing — the actual encode + send happens
222213
- * in a background pump. Calls before `start()` (or after `stop()`)
222214
- * silently drop the chunk so callers don't have to gate every
222215
- * push on `isOpen`.
222216
- */
222217
- feedPcm(pcm) {
222218
- if (!this.session) return;
222219
- if (pcm.length === 0) return;
222220
- this.pcmBuffer = this.pcmBuffer.length ? Buffer.concat([this.pcmBuffer, pcm]) : pcm;
222221
- if (this.pcmBuffer.length > this.maxBacklogBytes) {
222222
- const keep = this.maxBacklogBytes - this.maxBacklogBytes % 2;
222223
- const dropped = this.pcmBuffer.length - keep;
222224
- this.pcmBuffer = this.pcmBuffer.subarray(this.pcmBuffer.length - keep);
222225
- const now = Date.now();
222226
- if (now - this.lastBacklogClampLogAtMs > 2e3) {
222227
- this.lastBacklogClampLogAtMs = now;
222228
- this.opts.logger.warn("intercom backlog clamped (dropping PCM)", { meta: {
222229
- droppedBytes: dropped,
222230
- keptBytes: keep,
222231
- maxBytes: this.maxBacklogBytes
222232
- } });
222233
- }
222234
- }
222235
- if (!this.pumping) this.startPump();
222236
- }
222237
- startPump() {
222238
- const session = this.session;
222239
- if (!session) return;
222240
- this.pumping = true;
222241
- this.pumpPromise = (async () => {
222242
- try {
222243
- while (true) {
222244
- if (this.session !== session) return;
222245
- if (this.pcmBuffer.length < this.bytesPerBlock) return;
222246
- const chunk = this.pcmBuffer.subarray(0, this.bytesPerBlock);
222247
- this.pcmBuffer = this.pcmBuffer.subarray(this.bytesPerBlock);
222248
- const samples = new Int16Array(chunk.buffer, chunk.byteOffset, chunk.length / 2);
222249
- const adpcm = encodeImaAdpcm(this.outputGain === 1 ? samples : applyGainInt16(samples, this.outputGain), this.blockSize);
222250
- await session.sendAudio(adpcm);
222251
- }
222252
- } catch (err) {
222253
- this.opts.logger.warn("intercom pump error — stopping", { meta: { error: err instanceof Error ? err.message : String(err) } });
222254
- } finally {
222255
- this.pumping = false;
222256
- }
222257
- })();
222258
- }
222259
- async stop() {
222260
- const session = this.session;
222261
- if (!session) return;
222262
- this.session = null;
222263
- this.pcmBuffer = Buffer.alloc(0);
222264
- if (this.pumpPromise) {
222265
- try {
222266
- await Promise.race([this.pumpPromise, new Promise((r) => setTimeout(r, 250))]);
222267
- } catch {}
222268
- this.pumpPromise = null;
222269
- }
222270
- try {
222271
- await Promise.race([session.stop(), new Promise((_, rej) => setTimeout(() => rej(/* @__PURE__ */ new Error("talk session stop timeout")), 2e3))]);
222272
- } catch (err) {
222273
- this.opts.logger.warn("intercom session stop error", { meta: { error: err instanceof Error ? err.message : String(err) } });
222274
- }
222275
- }
222276
- };
222277
- /** Clamp `blocksPerPayload` into the lib's accepted range. Mirrors
222278
- * scrypted-reolink-native's `intercom-mixin.ts:96-101` clamp. */
222279
- function clampBlocks(value) {
222280
- if (value === void 0 || !Number.isFinite(value)) return DEFAULT_BLOCKS_PER_PAYLOAD;
222281
- return Math.max(1, Math.min(8, Math.floor(value)));
222282
- }
222283
- /** Clamp `outputGain` into the operator-safe range. Same `[0.1, 10]`
222284
- * band as Scrypted (`intercom-mixin.ts:104-107`). */
222285
- function clampGain(value) {
222286
- if (value === void 0 || !Number.isFinite(value)) return DEFAULT_GAIN;
222287
- return Math.max(MIN_GAIN, Math.min(MAX_GAIN, value));
222288
- }
222289
- /** Apply a floating-point gain to each `Int16` sample in-place into
222290
- * a freshly-allocated buffer. The result is hard-clipped to int16
222291
- * bounds — soft saturation isn't worth the tradeoff for an intercom
222292
- * channel where occasional clipping is preferable to a perceived
222293
- * loudness mismatch. */
222294
- function applyGainInt16(samples, gain) {
222295
- const out = new Int16Array(samples.length);
222296
- for (let i = 0; i < samples.length; i++) {
222297
- const scaled = (samples[i] ?? 0) * gain;
222298
- out[i] = scaled > 32767 ? 32767 : scaled < -32768 ? -32768 : scaled;
222299
- }
222300
- return out;
222301
- }
222302
- //#endregion
222303
- //#region src/intercom-orchestrator.ts
222304
- /** Default Opus parameters used when the orchestrator caller doesn't
222305
- * override them. Browser Opus is canonically 48 kHz; mono is the only
222306
- * practical choice for an intercom (the camera's talk channel is
222307
- * mono — sending stereo would just cost bandwidth). */
222308
- var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
222309
- var DEFAULT_OPUS_CHANNELS = 1;
222310
- var DEFAULT_CAMERA_SAMPLE_RATE = 16e3;
222311
- var IntercomOrchestrator = class {
222312
- opts;
222313
- session = null;
222314
- constructor(opts) {
222315
- this.opts = opts;
222316
- }
222317
- /** True while a session is open (between `start()` resolve and
222318
- * `stop()` resolve). */
222319
- get isOpen() {
222320
- return this.session !== null && !this.session.closed;
222321
- }
222322
- /**
222323
- * Open a fresh WebRTC peer + audio-codec decode session + Reolink
222324
- * talk session, wire them, return the SDP offer. Throws (and tears
222325
- * down everything it had spun up) on any failure — the cap router
222326
- * surfaces the error to the client unchanged.
222327
- *
222328
- * Single-active-session semantics: a second `start()` while the
222329
- * first is still open closes the old session before opening the
222330
- * new one. The cap router enforces this on the caller side via
222331
- * `intercomSessions.size === 1` invariants.
222332
- */
222333
- async start() {
222334
- if (this.session && !this.session.closed) await this.stop(this.session.sessionId, "superseded-by-new-start").catch(() => {});
222335
- const sessionId = generateSessionId();
222336
- const cameraRate = this.opts.cameraSampleRate ?? DEFAULT_CAMERA_SAMPLE_RATE;
222337
- const opusRate = this.opts.opusSampleRate ?? DEFAULT_OPUS_SAMPLE_RATE;
222338
- const opusChannels = this.opts.opusChannels ?? DEFAULT_OPUS_CHANNELS;
222339
- this.opts.logger.info("intercom: negotiate (opening session)", { meta: {
222340
- sessionId,
222341
- channel: this.opts.channel,
222342
- opusRate,
222343
- opusChannels
222344
- } });
222345
- if (this.opts.wakeBeforeStart) try {
222346
- await this.opts.wakeBeforeStart();
222347
- } catch (err) {
222348
- this.opts.logger.warn("intercom: pre-wake failed", { meta: { error: errMsg$1(err) } });
222349
- throw err;
222350
- }
222351
- const talkSession = new ReolinkIntercomSession({
222352
- channel: this.opts.channel,
222353
- api: this.opts.api,
222354
- logger: this.opts.logger.withTags?.({ sessionId }) ?? this.opts.logger,
222355
- deviceTag: this.opts.deviceTag,
222356
- ...this.opts.blocksPerPayload !== void 0 ? { blocksPerPayload: this.opts.blocksPerPayload } : {},
222357
- ...this.opts.maxBacklogMs !== void 0 ? { maxBacklogMs: this.opts.maxBacklogMs } : {},
222358
- ...this.opts.outputGain !== void 0 ? { outputGain: this.opts.outputGain } : {}
222359
- });
222360
- try {
222361
- await talkSession.start();
222362
- } catch (err) {
222363
- this.opts.logger.warn("intercom: talk session open failed", { meta: { error: errMsg$1(err) } });
222364
- throw err;
222365
- }
222366
- const realCameraRate = talkSession.sampleRate;
222367
- const targetRate = Number.isFinite(realCameraRate) && realCameraRate > 0 ? realCameraRate : cameraRate;
222368
- let codec;
222369
- try {
222370
- codec = await this.opts.audioCodec.createDecodeSession({
222371
- codec: "opus",
222372
- sourceSampleRate: opusRate,
222373
- sourceChannels: opusChannels,
222374
- targetSampleRate: targetRate,
222375
- targetChannels: 1,
222376
- targetFormat: "s16le",
222377
- tag: `reolink-intercom:${this.opts.deviceTag}:${sessionId}`
222378
- });
222379
- } catch (err) {
222380
- await talkSession.stop().catch(() => {});
222381
- this.opts.logger.warn("intercom: audio-codec createDecodeSession failed", { meta: {
222382
- error: errMsg$1(err),
222383
- opusRate,
222384
- opusChannels,
222385
- targetRate
222386
- } });
222387
- throw err;
222388
- }
222389
- const peer = this.opts.peerFactory({ logger: this.opts.logger });
222390
- const active = {
222391
- sessionId,
222392
- peer,
222393
- talkSession,
222394
- codec,
222395
- closed: false,
222396
- startedAtMs: Date.now(),
222397
- framesPushed: 0,
222398
- pcmBytesPushed: 0,
222399
- answerApplied: false
222400
- };
222401
- this.session = active;
222402
- peer.onOpusFrame((frame, pts) => {
222403
- this.handleOpusFrame(active, frame, pts).catch((err) => {
222404
- this.opts.logger.debug("intercom: opus frame pump error (dropped)", { meta: { error: errMsg$1(err) } });
222405
- });
222406
- });
222407
- let offer;
222408
- try {
222409
- offer = await peer.createOffer();
222410
- } catch (err) {
222411
- await this.stop(sessionId, "start-failed-cleanup").catch(() => {});
222412
- this.opts.logger.warn("intercom: webrtc createOffer failed", { meta: { error: errMsg$1(err) } });
222413
- throw err;
222414
- }
222415
- this.opts.logger.info("intercom session opened", { meta: {
222416
- sessionId,
222417
- channel: this.opts.channel,
222418
- targetRate,
222419
- codecSessionId: codec.sessionId,
222420
- codecNodeId: codec.nodeId
222421
- } });
222422
- return {
222423
- sessionId,
222424
- sdpOffer: offer.sdp
222425
- };
222426
- }
222427
- /**
222428
- * Apply the browser's SDP answer. The session is identified by id
222429
- * (callers may have multiple cameras with overlapping intercom
222430
- * sessions in flight — though this orchestrator only tracks one).
222431
- * Mismatched session id throws.
222432
- */
222433
- async handleAnswer(sessionId, sdpAnswer) {
222434
- const active = this.requireActive(sessionId);
222435
- await active.peer.setAnswer(sdpAnswer);
222436
- active.answerApplied = true;
222437
- this.opts.logger.info("intercom: SDP answer accepted (handshake complete)", { meta: { sessionId } });
222438
- }
222439
- /**
222440
- * Tear down the session in reverse-open order. Idempotent: a stop
222441
- * for an unknown / already-closed session resolves silently. Best-
222442
- * effort: a partial teardown (e.g. peer.close fails) does NOT
222443
- * abort the rest — the next steps still try to release their own
222444
- * resources.
222445
- *
222446
- * `reason` records WHY the session ended — surfaced in the close log so an
222447
- * immediate-close is diagnosable from one line. The cap-router stop path
222448
- * passes nothing and defaults to `'client-stop'`; internal teardown paths
222449
- * (supersede, start-failure cleanup) pass their specific reason.
222450
- */
222451
- async stop(sessionId, reason = "client-stop") {
222452
- const active = this.session;
222453
- if (!active || active.sessionId !== sessionId || active.closed) return;
222454
- active.closed = true;
222455
- this.session = null;
222456
- try {
222457
- await active.peer.close();
222458
- } catch (err) {
222459
- this.opts.logger.debug("intercom: peer.close error (continuing)", { meta: {
222460
- sessionId,
222461
- error: errMsg$1(err)
222462
- } });
222463
- }
222464
- try {
222465
- await this.opts.audioCodec.closeSession({
222466
- sessionId: active.codec.sessionId,
222467
- nodeId: active.codec.nodeId
222468
- });
222469
- } catch (err) {
222470
- this.opts.logger.debug("intercom: audio-codec.closeSession error (continuing)", { meta: {
222471
- sessionId,
222472
- error: errMsg$1(err)
222473
- } });
222474
- }
222475
- try {
222476
- await active.talkSession.stop();
222477
- } catch (err) {
222478
- this.opts.logger.debug("intercom: talk-session.stop error (continuing)", { meta: {
222479
- sessionId,
222480
- error: errMsg$1(err)
222481
- } });
222482
- }
222483
- this.opts.logger.info("intercom session closed", { meta: {
222484
- sessionId,
222485
- reason,
222486
- answerApplied: active.answerApplied,
222487
- framesPushed: active.framesPushed,
222488
- pcmBytesPushed: active.pcmBytesPushed,
222489
- durationMs: Date.now() - active.startedAtMs
222490
- } });
222491
- }
222492
- /**
222493
- * Push one Opus frame into the audio-codec, immediately drain any
222494
- * decoded PCM, and feed it to the talk session. Push-then-pull
222495
- * keeps latency tight: each Opus frame produces ~20ms of PCM and
222496
- * we surface it on the same async tick.
222497
- *
222498
- * Errors here are isolated to a single frame — the caller wraps in
222499
- * a void-fire-and-forget so an audio-codec hiccup doesn't break
222500
- * the RTP receive loop.
222501
- */
222502
- async handleOpusFrame(active, frame, pts) {
222503
- if (active.closed) return;
222504
- if (active.framesPushed === 0) this.opts.logger.info("intercom: first Opus frame received (feeding camera)", { meta: { sessionId: active.sessionId } });
222505
- active.framesPushed += 1;
222506
- await this.opts.audioCodec.pushEncodedFrame({
222507
- sessionId: active.codec.sessionId,
222508
- nodeId: active.codec.nodeId,
222509
- data: frame,
222510
- pts
222511
- });
222512
- if (active.closed) return;
222513
- const chunks = await this.opts.audioCodec.pullPcm({
222514
- sessionId: active.codec.sessionId,
222515
- nodeId: active.codec.nodeId,
222516
- maxCount: 8
222517
- });
222518
- if (active.closed) return;
222519
- for (const chunk of chunks) {
222520
- const buf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
222521
- active.pcmBytesPushed += buf.length;
222522
- active.talkSession.feedPcm(buf);
222523
- }
222524
- }
222525
- requireActive(sessionId) {
222526
- const active = this.session;
222527
- if (!active || active.closed || active.sessionId !== sessionId) throw new Error(`Reolink intercom: unknown or closed sessionId ${sessionId}`);
222528
- return active;
222529
- }
222530
- };
222531
- /** Random-enough session id — no crypto requirement, just needs to be
222532
- * unique-per-camera across reasonable timescales. */
222533
- function generateSessionId() {
222534
- return `intercom-${Date.now().toString(36)}-${Math.floor(Math.random() * 16777215).toString(36)}`;
222535
- }
222536
- function errMsg$1(err) {
222537
- return err instanceof Error ? err.message : String(err);
222538
- }
222539
- //#endregion
222540
- //#region src/intercom-webrtc-peer.ts
222541
- var _werift;
222542
- /**
222543
- * Lazy import — werift is an optional peer dep of this package
222544
- * (declared via peerDependenciesMeta so npm install doesn't fail on
222545
- * agents/clusters where the intercom is never used).
222546
- */
222547
- async function loadWerift() {
222548
- if (_werift) return _werift;
222549
- try {
222550
- _werift = await Function("m", "return import(m)")("werift");
222551
- return _werift;
222552
- } catch {
222553
- throw new Error("The 'werift' package is required for Reolink intercom support but is not installed. Install it with: npm install werift");
222554
- }
222555
- }
222556
- var WeriftIntercomPeer = class {
222557
- opts;
222558
- pc = null;
222559
- opusCallbacks = [];
222560
- rtpUnsubscribe = null;
222561
- trackUnsubscribe = null;
222562
- closed = false;
222563
- /** Anchor PTS to the first observed RTP timestamp so the audio-
222564
- * codec receives monotonically-increasing PTS values from zero
222565
- * rather than the camera's free-running 32-bit RTP clock. */
222566
- firstRtpTimestamp = null;
222567
- constructor(opts) {
222568
- this.opts = opts;
222569
- }
222570
- onOpusFrame(cb) {
222571
- if (this.closed) return;
222572
- this.opusCallbacks.push(cb);
222573
- }
222574
- async createOffer() {
222575
- if (this.pc) throw new Error("WeriftIntercomPeer: createOffer called twice");
222576
- const werift = await loadWerift();
222577
- const pcOptions = {};
222578
- if (this.opts.iceServers && this.opts.iceServers.length > 0) pcOptions.iceServers = [...this.opts.iceServers];
222579
- const pc = new werift.RTCPeerConnection(pcOptions);
222580
- this.pc = pc;
222581
- const localTrack = new werift.MediaStreamTrack({ kind: "audio" });
222582
- const trackSub = pc.addTransceiver(localTrack, { direction: "sendrecv" }).onTrack.subscribe((track) => {
222583
- if (track.kind !== "audio") return;
222584
- const rtpSub = track.onReceiveRtp.subscribe((pkt) => {
222585
- if (this.closed) return;
222586
- const payload = pkt.payload;
222587
- if (!payload || payload.length === 0) return;
222588
- const ts = pkt.header.timestamp;
222589
- if (this.firstRtpTimestamp === null) this.firstRtpTimestamp = ts;
222590
- const relTs = ts - this.firstRtpTimestamp >>> 0;
222591
- const ptsMs = Math.round(relTs / 48);
222592
- for (const cb of this.opusCallbacks) try {
222593
- cb(payload, ptsMs);
222594
- } catch (err) {
222595
- this.opts.logger.debug("intercom-peer: opus callback threw", { meta: { error: errMsg(err) } });
222596
- }
222597
- });
222598
- if (rtpSub && typeof rtpSub.unsubscribe === "function") this.rtpUnsubscribe = () => rtpSub.unsubscribe?.();
222599
- });
222600
- if (trackSub && typeof trackSub.unsubscribe === "function") this.trackUnsubscribe = () => trackSub.unsubscribe?.();
222601
- pc.iceConnectionStateChange.subscribe((state) => {
222602
- this.opts.logger.info("intercom-peer: ICE state", { meta: { state } });
222603
- });
222604
- pc.iceGatheringStateChange.subscribe((state) => {
222605
- this.opts.logger.debug("intercom-peer: ICE gathering", { meta: { state } });
222606
- });
222607
- const offer = await pc.createOffer();
222608
- await pc.setLocalDescription(offer);
222609
- return { sdp: pc.localDescription?.sdp ?? offer.sdp };
222610
- }
222611
- async setAnswer(sdp) {
222612
- if (!this.pc) throw new Error("WeriftIntercomPeer: setAnswer called before createOffer");
222613
- if (this.closed) throw new Error("WeriftIntercomPeer: setAnswer called on closed peer");
222614
- await this.pc.setRemoteDescription({
222615
- sdp,
222616
- type: "answer"
222617
- });
222618
- }
222619
- async close() {
222620
- if (this.closed) return;
222621
- this.closed = true;
222622
- this.opusCallbacks = [];
222623
- if (this.rtpUnsubscribe) {
222624
- try {
222625
- this.rtpUnsubscribe();
222626
- } catch {}
222627
- this.rtpUnsubscribe = null;
222628
- }
222629
- if (this.trackUnsubscribe) {
222630
- try {
222631
- this.trackUnsubscribe();
222632
- } catch {}
222633
- this.trackUnsubscribe = null;
222634
- }
222635
- if (this.pc) {
222636
- try {
222637
- await Promise.resolve(this.pc.close());
222638
- } catch {}
222639
- this.pc = null;
222640
- }
222641
- }
222642
- };
222643
- function errMsg(err) {
222644
- return err instanceof Error ? err.message : String(err);
222645
- }
222646
223479
  function formatUserLevel(level) {
222647
223480
  if (level === void 0 || level === null) return "user";
222648
223481
  if (typeof level === "string" && level.length > 0) return level;
@@ -222846,6 +223679,88 @@ function buildSessionsTabSections(snap, opts) {
222846
223679
  }];
222847
223680
  }
222848
223681
  //#endregion
223682
+ //#region src/stream-routing.ts
223683
+ var KIND_RE = /^(native|rtsp|rtmp|flv):(.+)$/;
223684
+ var CH_PROFILE_RE = /^ch(\d+)-(main|sub|ext)$/;
223685
+ var PROFILE_ONLY_RE = /^(main|sub|ext)$/;
223686
+ /**
223687
+ * Build a camStreamId from the (kind, channel, profile) tuple. Single-channel
223688
+ * devices omit the `ch{N}-` infix; NVR / Hub include it so per-channel
223689
+ * routing survives the round-trip through the broker.
223690
+ */
223691
+ function buildCamStreamId(kind, channel, profile, channelCount) {
223692
+ if (channelCount > 1) return `${kind}:ch${channel}-${profile}`;
223693
+ return `${kind}:${profile}`;
223694
+ }
223695
+ /**
223696
+ * Parse a Reolink camStreamId back to its (kind, channel, profile) tuple.
223697
+ * Returns `null` if the id does not match our format — the demand handler
223698
+ * uses this to ignore non-native streams (broker pulls those directly).
223699
+ */
223700
+ function parseCamStreamId(camStreamId, defaultChannel) {
223701
+ const m = KIND_RE.exec(camStreamId);
223702
+ if (!m) return null;
223703
+ const kind = m[1];
223704
+ const rest = m[2];
223705
+ const ch = CH_PROFILE_RE.exec(rest);
223706
+ if (ch) return {
223707
+ kind,
223708
+ channel: parseInt(ch[1], 10),
223709
+ profile: ch[2]
223710
+ };
223711
+ const p = PROFILE_ONLY_RE.exec(rest);
223712
+ if (p) return {
223713
+ kind,
223714
+ channel: defaultChannel,
223715
+ profile: p[1]
223716
+ };
223717
+ return null;
223718
+ }
223719
+ /** Render a human-readable label for the device-settings dropdown. */
223720
+ function streamLabel(s, kind) {
223721
+ const parts = [];
223722
+ if (kind) parts.push(kindLabel(kind));
223723
+ if (s.channel !== void 0) parts.push(`Ch${s.channel}`);
223724
+ parts.push(s.profile.charAt(0).toUpperCase() + s.profile.slice(1));
223725
+ if (s.lens && s.lens !== "wide") parts.push(`(${s.lens})`);
223726
+ return parts.join(" ");
223727
+ }
223728
+ function kindLabel(kind) {
223729
+ switch (kind) {
223730
+ case "native": return "Native";
223731
+ case "rtsp": return "RTSP";
223732
+ case "rtmp": return "RTMP";
223733
+ case "flv": return "FLV";
223734
+ }
223735
+ }
223736
+ /**
223737
+ * Synthetic native cam-stream id list used by `getStreamSources()` to
223738
+ * advertise the device's expected stream shape before the lib has been
223739
+ * called. `publishToBroker` ignores this — it always uses the live
223740
+ * result of `buildVideoStreamOptions()` instead.
223741
+ */
223742
+ function buildStreamIds(channelCount) {
223743
+ if (channelCount <= 1) return [{
223744
+ id: "native:main",
223745
+ label: "Native Main"
223746
+ }, {
223747
+ id: "native:sub",
223748
+ label: "Native Sub"
223749
+ }];
223750
+ const out = [];
223751
+ for (let ch = 0; ch < channelCount; ch++) {
223752
+ out.push({
223753
+ id: `native:ch${ch}-main`,
223754
+ label: `Native Ch${ch} Main`
223755
+ });
223756
+ out.push({
223757
+ id: `native:ch${ch}-sub`,
223758
+ label: `Native Ch${ch} Sub`
223759
+ });
223760
+ }
223761
+ return out;
223762
+ }
223763
+ //#endregion
222849
223764
  //#region src/synthetic-sdp.ts
222850
223765
  /**
222851
223766
  * Build a synthetic SDP for `pull-rfc4571` entries from device-side
@@ -222926,6 +223841,18 @@ function buildLazyRfc4571Url(camStreamId) {
222926
223841
  }
222927
223842
  //#endregion
222928
223843
  //#region src/reolink-camera.ts
223844
+ /**
223845
+ * The `<Compression>` stream blocks that can carry an audio flag, in the
223846
+ * order the camera reports them. Named once so the read
223847
+ * (`readStreamAudioProfiles`) and the write (`setAudioEnabled`) cannot cover
223848
+ * different sets — a mute that skipped a stream would leave the camera
223849
+ * audible while reporting itself silent.
223850
+ */
223851
+ var REOLINK_AUDIO_STREAM_KEYS = [
223852
+ "mainStream",
223853
+ "subStream",
223854
+ "thirdStream"
223855
+ ];
222929
223856
  /** Generate a short random hex token for per-stream RTSP-style credentials. */
222930
223857
  function randomToken(bytes) {
222931
223858
  return randomBytes(bytes).toString("hex");
@@ -225508,6 +226435,30 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225508
226435
  * 0..1 rect into the firmware-specific packed-int `(pixel << 16) | canvas`
225509
226436
  * coords. The master `enable` flag rides the same call.
225510
226437
  */
226438
+ /**
226439
+ * Every `<Compression>` stream block of this camera that reports an audio
226440
+ * flag, with its current value.
226441
+ *
226442
+ * A block whose `audio` field is absent is OMITTED, not reported as `false`
226443
+ * — "this firmware does not expose an audio flag" and "the microphone is
226444
+ * off" are different answers, and only the second justifies offering the
226445
+ * operator a switch. An empty array is the honest "no controllable audio
226446
+ * input here".
226447
+ */
226448
+ async readStreamAudioProfiles() {
226449
+ const compression = (await (await this.ensureApi()).getEnc(this.getChannel()))?.body?.Compression;
226450
+ if (!compression) return [];
226451
+ const out = [];
226452
+ for (const streamKey of REOLINK_AUDIO_STREAM_KEYS) {
226453
+ const raw = compression[streamKey]?.audio;
226454
+ if (typeof raw !== "number") continue;
226455
+ out.push({
226456
+ streamKey,
226457
+ audioEnabled: raw !== 0
226458
+ });
226459
+ }
226460
+ return out;
226461
+ }
225511
226462
  registerPrivacyMaskCap() {
225512
226463
  const channel = this.getChannel();
225513
226464
  const CAP_NAME = "privacy-mask";
@@ -225516,7 +226467,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225516
226467
  if (this.privacyMaskRefreshInFlight) return this.privacyMaskRefreshInFlight;
225517
226468
  const promise = (async () => {
225518
226469
  try {
225519
- const zones = await (await this.ensureApi()).getMaskZones(channel);
226470
+ const api = await this.ensureApi();
226471
+ const [zones, audioProfiles] = await Promise.all([api.getMaskZones(channel), this.readStreamAudioProfiles().catch(() => [])]);
225520
226472
  this.ctx.logger.debug("reolink privacy-mask getMaskZones", {
225521
226473
  tags: { deviceId: this.id },
225522
226474
  meta: {
@@ -225539,6 +226491,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225539
226491
  const next = {
225540
226492
  enabled: zones.enable,
225541
226493
  regions,
226494
+ audioEnabled: summarisePrivacyAudio(audioProfiles),
225542
226495
  lastFetchedAt: Date.now()
225543
226496
  };
225544
226497
  this.runtimeState.setCapState(CAP_NAME, next);
@@ -225566,27 +226519,32 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225566
226519
  empty: () => ({
225567
226520
  enabled: false,
225568
226521
  regions: [],
226522
+ audioEnabled: null,
225569
226523
  lastFetchedAt: 0
225570
226524
  })
225571
226525
  }).getStatus,
225572
226526
  getOptions: async ({ deviceId }) => {
225573
226527
  if (deviceId !== this.id) return {
225574
226528
  maxRegions: 4,
225575
- supportedShapes: ["rect"]
226529
+ supportedShapes: ["rect"],
226530
+ supportsAudioMute: false
225576
226531
  };
225577
226532
  return this.resolveCapOptions({
225578
226533
  capName: CAP_NAME,
225579
226534
  schema: PrivacyMaskOptionsSchema,
225580
226535
  probe: async () => {
225581
- const zones = await (await this.ensureApi()).getMaskZones(channel);
226536
+ const api = await this.ensureApi();
226537
+ const [zones, audioProfiles] = await Promise.all([api.getMaskZones(channel), this.readStreamAudioProfiles().catch(() => [])]);
225582
226538
  return {
225583
226539
  maxRegions: zones.maxNum,
225584
- supportedShapes: zones.maxNum > 0 ? ["rect"] : []
226540
+ supportedShapes: zones.maxNum > 0 ? ["rect"] : [],
226541
+ supportsAudioMute: audioProfiles.length > 0
225585
226542
  };
225586
226543
  },
225587
226544
  fallback: () => ({
225588
226545
  maxRegions: 4,
225589
- supportedShapes: ["rect"]
226546
+ supportedShapes: ["rect"],
226547
+ supportsAudioMute: false
225590
226548
  })
225591
226549
  });
225592
226550
  },
@@ -225624,6 +226582,49 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
225624
226582
  tags: { deviceId: this.id },
225625
226583
  meta: { regions: JSON.stringify(this.runtimeState.getCapState(CAP_NAME)?.regions ?? []) }
225626
226584
  });
226585
+ },
226586
+ setAudioEnabled: async ({ deviceId, enabled }) => {
226587
+ if (deviceId !== this.id) return;
226588
+ const profiles = await this.readStreamAudioProfiles();
226589
+ if (profiles.length === 0) {
226590
+ this.ctx.logger.warn("reolink privacy audio: camera reports NO audio flag — refusing", { tags: { deviceId: this.id } });
226591
+ throw new Error(`device ${String(this.id)}: this camera exposes no controllable audio input`);
226592
+ }
226593
+ const value = enabled ? 1 : 0;
226594
+ const api = await this.ensureApi();
226595
+ for (const profile of profiles) {
226596
+ const patch = { [profile.streamKey]: { audio: value } };
226597
+ await api.setEnc(channel, patch);
226598
+ }
226599
+ const refused = (await this.readStreamAudioProfiles().catch(() => [])).filter((p) => p.audioEnabled !== enabled).map((p) => p.streamKey);
226600
+ if (refused.length > 0) {
226601
+ const revert = enabled ? 0 : 1;
226602
+ for (const profile of profiles) {
226603
+ if (refused.includes(profile.streamKey)) continue;
226604
+ await api.setEnc(channel, { [profile.streamKey]: { audio: revert } }).catch(() => void 0);
226605
+ }
226606
+ this.ctx.logger.warn("reolink privacy audio: stream REFUSED the write — reverted, camera still audible", {
226607
+ tags: { deviceId: this.id },
226608
+ meta: {
226609
+ enabled,
226610
+ refused: refused.join(","),
226611
+ asked: profiles.length
226612
+ }
226613
+ });
226614
+ this.cachedStreamDescriptors = void 0;
226615
+ await refreshFromCamera();
226616
+ 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`);
226617
+ }
226618
+ this.ctx.logger.info("reolink privacy audio: microphone written", {
226619
+ tags: { deviceId: this.id },
226620
+ meta: {
226621
+ enabled,
226622
+ streams: profiles.map((p) => p.streamKey).join(",")
226623
+ }
226624
+ });
226625
+ this.cachedStreamDescriptors = void 0;
226626
+ this.ctx.eventBus.emit(createEvent(EventCategory.StreamParamsChanged, this.eventSource(), { deviceId: this.id }));
226627
+ await refreshFromCamera();
225627
226628
  }
225628
226629
  };
225629
226630
  this.registerCapWarmer(CAP_NAME, async () => {
@@ -229324,8 +230325,13 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
229324
230325
  * cap `framerate` → `frameRate` (fps, same unit)
229325
230326
  * cap `gop` → `gop` (seconds, same unit)
229326
230327
  * cap `encoderProfile` → `encoderProfile` (direct passthrough)
229327
- * cap `audio` → `audio` (boolean → 0/1)
229328
230328
  * cap `width`/`height` → `width`/`height` (pixels, direct passthrough)
230329
+ *
230330
+ * There is deliberately NO audio branch. `StreamProfilePatch` carried one
230331
+ * until 2026-08-07, reachable from no form and no caller, while the camera's
230332
+ * microphone is a whole-device fact. It now has exactly one writer —
230333
+ * `privacyMask.setAudioEnabled`, which patches every stream block together so
230334
+ * the camera cannot end up half-muted.
229329
230335
  */
229330
230336
  function buildEncStreamPatch(patch) {
229331
230337
  const out = {};
@@ -229335,7 +230341,6 @@ function buildEncStreamPatch(patch) {
229335
230341
  if (patch.framerate !== void 0) out.frameRate = patch.framerate;
229336
230342
  if (patch.gop !== void 0) out.gop = patch.gop;
229337
230343
  if (patch.encoderProfile !== void 0) out.encoderProfile = patch.encoderProfile;
229338
- if (patch.audio !== void 0) out.audio = patch.audio ? 1 : 0;
229339
230344
  if (patch.width !== void 0) out.width = patch.width;
229340
230345
  if (patch.height !== void 0) out.height = patch.height;
229341
230346
  return out;
@@ -230403,7 +231408,7 @@ var AutodetectCache = class {
230403
231408
  * the same api instance.
230404
231409
  */
230405
231410
  static keyFor(input) {
230406
- const passHash = createHash$1("sha256").update(input.password).digest("hex").slice(0, 16);
231411
+ const passHash = createHash("sha256").update(input.password).digest("hex").slice(0, 16);
230407
231412
  return [
230408
231413
  input.host.trim().toLowerCase(),
230409
231414
  input.username,