@camstack/addon-provider-hikvision 1.2.12 → 1.2.13

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 +701 -14
  2. package/dist/addon.mjs +701 -14
  3. package/package.json +1 -1
package/dist/addon.mjs CHANGED
@@ -6469,7 +6469,20 @@ var BrokerStatsSchema = object({
6469
6469
  sampleRate: number(),
6470
6470
  channels: number(),
6471
6471
  supported: boolean()
6472
- }).nullable().optional()
6472
+ }).nullable().optional(),
6473
+ /**
6474
+ * BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
6475
+ * distributing none of the device's audio, on live or recording.
6476
+ *
6477
+ * Present so a silent camera can be told apart from a broken one on the
6478
+ * stream panel itself, without cross-referencing the switch group: a
6479
+ * broker holding an `audio` track descriptor while `audioMuted` is true is
6480
+ * working exactly as asked. `audioMutedDropped` counts the audio units
6481
+ * thrown away since the current dial — it is how you confirm from stats
6482
+ * alone that the mute is on the packet path and not merely persisted.
6483
+ */
6484
+ audioMuted: boolean().optional(),
6485
+ audioMutedDropped: number().optional()
6473
6486
  });
6474
6487
  /**
6475
6488
  * Exporter-facing "profile restream" entry. Returned by
@@ -7102,6 +7115,19 @@ object({
7102
7115
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7103
7116
  * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7104
7117
  * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7118
+ * | `broker-audio` | `streamBroker.setDeviceAudioMute` → `DeviceOverride.audioMuted` | `StreamBroker.setAudioMuted` drops the audio plane at the source: no `type:'audio'` packet leaves `fanOutEncoded`, no RTP reaches the restreamer, and the restreamer serves the video-only SDP |
7119
+ *
7120
+ * ## `device-audio` and `broker-audio` are two functions, not two knobs
7121
+ *
7122
+ * They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
7123
+ * `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
7124
+ * genuinely stops, it survives CamStack entirely, and it costs a multi-second
7125
+ * encoder restart on every flip. `broker-audio` writes THIS server, so it is
7126
+ * instant, vendor-independent and reversible without touching the camera, and
7127
+ * a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
7128
+ * D62 forbids a second switch that *disagrees* with the first; these two
7129
+ * cannot disagree, because neither reads the other's store — the camera holds
7130
+ * one, the broker holds the other, and each reports its own fact.
7105
7131
  *
7106
7132
  * ## The two switches whose authority is not on this server
7107
7133
  *
@@ -7163,6 +7189,7 @@ var CameraSwitchIdSchema = _enum([
7163
7189
  "object-detection",
7164
7190
  "privacy-mask",
7165
7191
  "device-audio",
7192
+ "broker-audio",
7166
7193
  "audio-analysis",
7167
7194
  "recording",
7168
7195
  "notifications"
@@ -7188,7 +7215,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7188
7215
  object({
7189
7216
  kind: literal("camera-mask"),
7190
7217
  capName: string()
7191
- })
7218
+ }),
7219
+ object({ kind: literal("broker-audio-mute") })
7192
7220
  ]);
7193
7221
  /**
7194
7222
  * Why a switch is not offered for this camera. Rendered instead of the
@@ -9560,7 +9588,25 @@ method(object({
9560
9588
  }), _void(), {
9561
9589
  kind: "mutation",
9562
9590
  auth: "admin"
9563
- }), method(object({ brokerId: string() }), boolean()), object({
9591
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
9592
+ muted: boolean(),
9593
+ /**
9594
+ * How many live non-derived brokers currently hold the mute. Purely
9595
+ * diagnostic: `muted` is the policy and is authoritative on its own
9596
+ * (it applies to brokers that do not exist yet), while this says
9597
+ * whether anything is presently being silenced.
9598
+ */
9599
+ appliedBrokers: number().int().nonnegative()
9600
+ })), method(object({
9601
+ deviceId: number().int(),
9602
+ muted: boolean()
9603
+ }), object({
9604
+ muted: boolean(),
9605
+ appliedBrokers: number().int().nonnegative()
9606
+ }), {
9607
+ kind: "mutation",
9608
+ auth: "admin"
9609
+ }), object({
9564
9610
  deviceId: number().int().nonnegative(),
9565
9611
  camStreamId: string(),
9566
9612
  profile: CamProfileSchema
@@ -15092,6 +15138,30 @@ var TrackSourceSchema = _enum([
15092
15138
  "audio"
15093
15139
  ]);
15094
15140
  /**
15141
+ * Where a track sits in the RETRAIN lifecycle (D81).
15142
+ *
15143
+ * - `none` — never marked, or un-marked. Evictable.
15144
+ * - `staging` — the operator wants this track as training material and has not
15145
+ * finished with it. **This is the only state retention holds**: the track and
15146
+ * everything it owns (object events, crops, keyframes, CLIP vector) survive
15147
+ * the device's age window.
15148
+ * - `trained` — the retrain page has taken what it needed. The frames it chose
15149
+ * were COPIED into the retrain dataset at selection time, so the dataset no
15150
+ * longer depends on the track's media and the track becomes EVICTABLE again.
15151
+ * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
15152
+ * a deliberate action of the retrain page, not a side effect of a checkbox.
15153
+ *
15154
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
15155
+ * the store's filter language has only positive equality and `whereIn` — no
15156
+ * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
15157
+ * would make the entire pre-column history immortal in one deploy.
15158
+ */
15159
+ var RetrainStatusSchema = _enum([
15160
+ "none",
15161
+ "staging",
15162
+ "trained"
15163
+ ]);
15164
+ /**
15095
15165
  * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15096
15166
  * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15097
15167
  * so the two surfaces cannot drift.
@@ -15101,18 +15171,31 @@ var TrackSourceSchema = _enum([
15101
15171
  * columns existed read as absent, and a consumer that needs a boolean should say
15102
15172
  * `flag === true`, not `flag !== false`.
15103
15173
  *
15104
- * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15105
- * operator curation, and the behaviour they drive will be specified separately.
15106
- * In particular a `markForTrain` track is NOT pinned against retention — see
15107
- * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15174
+ * `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
15175
+ * it is exactly `retrainStatus === 'staging'`, in both directions. Writing
15176
+ * `true` moves `none → staging`, writing `false` moves `staging none`, and a
15177
+ * `trained` track reports `false` while refusing both writes. The boolean is
15178
+ * kept because three surfaces drive a toggle off it; anything that needs to tell
15179
+ * "never marked" from "already trained" must read `retrainStatus`.
15180
+ *
15181
+ * `debug` does NOT pin; it is attention, not durability.
15108
15182
  */
15109
15183
  var TrackFlagFields = {
15110
- /** Operator marked this track as training material. */
15184
+ /** Operator marked this track as training material — i.e. `retrainStatus` is
15185
+ * `'staging'`. */
15111
15186
  markForTrain: boolean().optional(),
15112
15187
  /** Operator marked this track for diagnostic attention. */
15113
15188
  debug: boolean().optional()
15114
15189
  };
15115
15190
  /**
15191
+ * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
15192
+ * Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
15193
+ * write patch, and the status is not something the toggle sets — it is what the
15194
+ * toggle's boolean is derived from. Absent on an in-RAM track never touched;
15195
+ * always present on a persisted row (the column default materialises `'none'`).
15196
+ */
15197
+ var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
15198
+ /**
15116
15199
  * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15117
15200
  * one flag can never clear the other — the toggles are independent and are
15118
15201
  * driven from three surfaces that do not know about each other.
@@ -15126,7 +15209,32 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
15126
15209
  var TrackFlagsSchema = object({
15127
15210
  trackId: string(),
15128
15211
  markForTrain: boolean(),
15129
- debug: boolean()
15212
+ debug: boolean(),
15213
+ /** The lifecycle state the boolean was derived from. Required here (unlike on
15214
+ * a track row) because this shape is only ever produced by the write body,
15215
+ * which always knows it — and a surface that has just written needs to render
15216
+ * `trained` without a re-fetch. */
15217
+ retrainStatus: RetrainStatusSchema
15218
+ });
15219
+ /** Per-camera slice of a training-export estimate. */
15220
+ var TrainingExportDeviceTotalsSchema = object({
15221
+ deviceId: number(),
15222
+ tracks: number().int(),
15223
+ files: number().int(),
15224
+ bytes: number().int()
15225
+ });
15226
+ /**
15227
+ * What a training export WOULD contain. Computed from media index rows only —
15228
+ * no blob is read to produce this.
15229
+ */
15230
+ var TrainingExportSummarySchema = object({
15231
+ generatedAt: number(),
15232
+ trackCount: number().int(),
15233
+ fileCount: number().int(),
15234
+ byteCount: number().int(),
15235
+ /** More marked tracks exist than a single pass carries. */
15236
+ truncated: boolean(),
15237
+ devices: array(TrainingExportDeviceTotalsSchema).readonly()
15130
15238
  });
15131
15239
  var TrackSchema = object({
15132
15240
  trackId: string(),
@@ -15171,7 +15279,8 @@ var TrackSchema = object({
15171
15279
  * Populated from the persisted envelope columns on historical reads;
15172
15280
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15173
15281
  envelope: TrackEnvelopeSchema.optional(),
15174
- ...TrackFlagFields
15282
+ ...TrackFlagFields,
15283
+ ...TrackRetrainFields
15175
15284
  });
15176
15285
  var BaseEventFields = {
15177
15286
  id: string(),
@@ -15385,7 +15494,8 @@ var KeyEventSchema = object({
15385
15494
  bestEventId: string(),
15386
15495
  /** Track lifetime in ms (lastSeen - firstSeen). */
15387
15496
  windowMs: number().optional(),
15388
- ...TrackFlagFields
15497
+ ...TrackFlagFields,
15498
+ ...TrackRetrainFields
15389
15499
  });
15390
15500
  object({
15391
15501
  trackId: string(),
@@ -15646,6 +15756,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15646
15756
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
15647
15757
  kind: "query",
15648
15758
  auth: "admin"
15759
+ }), method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
15760
+ kind: "query",
15761
+ auth: "admin"
15762
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
15763
+ kind: "query",
15764
+ auth: "admin"
15649
15765
  }), method(object({
15650
15766
  eventId: string(),
15651
15767
  kind: MediaFileKindEnum.optional()
@@ -22508,6 +22624,173 @@ setOverlay: method(object({
22508
22624
  }] }
22509
22625
  };
22510
22626
  /**
22627
+ * `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
22628
+ *
22629
+ * The `osd` cap is the firmware contract: it probes a camera's overlay
22630
+ * SLOTS and writes literal text into one. It has no idea WHERE that text
22631
+ * comes from, and it must not — a driver that grew a "show the temperature
22632
+ * here" feature would grow it once per vendor.
22633
+ *
22634
+ * This cap owns the other half: a per-(camera, slot) BINDING that says
22635
+ * which value feeds the slot, how it is formatted, and under which
22636
+ * conditions it is shown at all. One addon renders every binding on every
22637
+ * camera, so a new source costs zero driver code.
22638
+ *
22639
+ * Three deliberate choices, each with a rejected alternative:
22640
+ *
22641
+ * 1. A source is `(capName, valuePath)` over the kernel's device
22642
+ * runtime-state mirror — NOT a closed enum of source kinds. Every
22643
+ * cap-keyed slice a device publishes is bindable the day the cap
22644
+ * ships. The rejected alternative (one enum member per source, with
22645
+ * a resolver branch each) is what makes "add the humidity too" a
22646
+ * code change.
22647
+ * 2. The display gate reuses `NcConditionsSchema` verbatim — the
22648
+ * notification centre's condition vocabulary — rather than a parallel
22649
+ * model. An operator who has learned one condition editor has learned
22650
+ * both.
22651
+ * 3. Because the renderer's facts are device STATE and not a detection
22652
+ * record, only a SUBSET of that vocabulary can be answered here.
22653
+ * `setSlotBinding` REJECTS the rest at write time (see
22654
+ * `getConditionSupport`). It does not accept-then-fail-closed: a
22655
+ * condition that can never be true renders a permanently blank
22656
+ * overlay, and a blank overlay looks exactly like a broken camera.
22657
+ */
22658
+ /** Where a slot's value comes from. */
22659
+ var OsdSourceSchema = discriminatedUnion("kind", [
22660
+ object({
22661
+ kind: literal("static"),
22662
+ text: string().max(64)
22663
+ }),
22664
+ object({
22665
+ kind: literal("clock"),
22666
+ /** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
22667
+ pattern: string().min(1).max(32).default("HH:mm"),
22668
+ /** IANA zone. Omitted = the server's zone. */
22669
+ timezone: string().min(1).max(64).optional()
22670
+ }),
22671
+ object({
22672
+ kind: literal("device-state"),
22673
+ deviceId: number().int().optional(),
22674
+ capName: string().min(1).max(64),
22675
+ /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
22676
+ valuePath: string().min(1).max(64)
22677
+ })
22678
+ ]);
22679
+ var OsdSlotBindingSchema = object({
22680
+ /** Off = the manager stops driving this slot. It does NOT clear it. */
22681
+ enabled: boolean().default(true),
22682
+ source: OsdSourceSchema,
22683
+ /** `${value}` and `${unit}` are substituted; every occurrence. */
22684
+ template: string().max(96).default("${value}"),
22685
+ /** Truncate with an ellipsis past this length. Absent = no limit. */
22686
+ maxCharacters: number().int().min(4).max(64).optional(),
22687
+ /**
22688
+ * Decimal places for a numeric value. `0` yields an integer — the
22689
+ * documented workaround for firmwares that reject `.` in overlay text.
22690
+ */
22691
+ maxDecimals: number().int().min(0).max(4).default(1),
22692
+ /** Appended via `${unit}`. The state mirror does not carry units. */
22693
+ unitLabel: string().max(8).optional(),
22694
+ /** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
22695
+ valueMap: record(string(), string()).optional(),
22696
+ /** Time windows in which the slot is shown. Absent = always. */
22697
+ schedule: NcScheduleSchema.optional(),
22698
+ /**
22699
+ * Display gate, in the notification centre's condition vocabulary.
22700
+ * Only the keys reported by `getConditionSupport` are accepted.
22701
+ */
22702
+ conditions: NcConditionsSchema.optional(),
22703
+ /** Rendered when the gate is closed or the value unreadable. Empty = hide. */
22704
+ fallbackText: string().max(64).default("")
22705
+ });
22706
+ /** One camera slot, as the operator sees it: firmware truth + our binding. */
22707
+ var OsdSlotViewSchema = object({
22708
+ slotId: string(),
22709
+ kind: OsdOverlayKindEnum,
22710
+ /** Firmware refuses text edits (a timestamp, the channel name). */
22711
+ readOnly: boolean(),
22712
+ cameraEnabled: boolean(),
22713
+ cameraText: string().optional(),
22714
+ binding: OsdSlotBindingSchema.nullable()
22715
+ });
22716
+ /**
22717
+ * What happened to one slot on one render pass. `unchanged` exists so the
22718
+ * operator can tell "we are driving this and the value is steady" from
22719
+ * "we never got there" — and so the loop can prove it is not rewriting
22720
+ * identical text to the camera every tick.
22721
+ */
22722
+ var OsdRenderOutcomeEnum = _enum([
22723
+ "written",
22724
+ "unchanged",
22725
+ "gated",
22726
+ "unreadable",
22727
+ "disabled",
22728
+ "unbound",
22729
+ "failed"
22730
+ ]);
22731
+ var OsdRenderResultSchema = object({
22732
+ slotId: string(),
22733
+ outcome: OsdRenderOutcomeEnum,
22734
+ /** The text the slot should carry. Empty = the slot is switched off. */
22735
+ text: string(),
22736
+ /** Why, whenever the outcome is not a plain write. Never silent. */
22737
+ reason: string().optional()
22738
+ });
22739
+ var OsdSourceValueTypeEnum = _enum([
22740
+ "number",
22741
+ "boolean",
22742
+ "string",
22743
+ "enum"
22744
+ ]);
22745
+ /**
22746
+ * One bindable value, derived from a cap's `runtimeState` schema — never
22747
+ * hand-listed. The editor renders from this, so a cap that ships a new
22748
+ * state field becomes bindable with no UI change.
22749
+ */
22750
+ var OsdSourceOptionSchema = object({
22751
+ deviceId: number().int(),
22752
+ deviceName: string(),
22753
+ capName: string(),
22754
+ valuePath: string(),
22755
+ label: string(),
22756
+ valueType: OsdSourceValueTypeEnum,
22757
+ /** Present for `enum`; the editor offers these as `valueMap` keys. */
22758
+ enumValues: array(string()).readonly().optional()
22759
+ });
22760
+ method(object({ deviceId: number().int() }), object({
22761
+ supported: boolean(),
22762
+ slots: array(OsdSlotViewSchema)
22763
+ }), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
22764
+ supported: array(string()),
22765
+ catalog: array(NcConditionDescriptorSchema)
22766
+ }), { auth: "admin" }), method(object({
22767
+ deviceId: number().int(),
22768
+ slotId: string().min(1),
22769
+ binding: OsdSlotBindingSchema
22770
+ }), object({
22771
+ slot: OsdSlotViewSchema,
22772
+ render: OsdRenderResultSchema
22773
+ }), {
22774
+ kind: "mutation",
22775
+ auth: "admin"
22776
+ }), method(object({
22777
+ deviceId: number().int(),
22778
+ slotId: string().min(1)
22779
+ }), object({ success: literal(true) }), {
22780
+ kind: "mutation",
22781
+ auth: "admin"
22782
+ }), method(object({
22783
+ deviceId: number().int(),
22784
+ slotId: string().min(1),
22785
+ binding: OsdSlotBindingSchema.optional()
22786
+ }), OsdRenderResultSchema, {
22787
+ kind: "mutation",
22788
+ auth: "admin"
22789
+ }), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
22790
+ kind: "mutation",
22791
+ auth: "admin"
22792
+ });
22793
+ /**
22511
22794
  * Feeder connectivity / power status — mirrors the HA petkit device-status
22512
22795
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
22513
22796
  * `on_batteries` (running on battery backup). `null` until first reported.
@@ -29562,6 +29845,48 @@ Object.freeze({
29562
29845
  addonId: null,
29563
29846
  access: "create"
29564
29847
  },
29848
+ "osdManager.clearSlotBinding": {
29849
+ capName: "osd-manager",
29850
+ capScope: "system",
29851
+ addonId: null,
29852
+ access: "delete"
29853
+ },
29854
+ "osdManager.getConditionSupport": {
29855
+ capName: "osd-manager",
29856
+ capScope: "system",
29857
+ addonId: null,
29858
+ access: "view"
29859
+ },
29860
+ "osdManager.getDeviceOsd": {
29861
+ capName: "osd-manager",
29862
+ capScope: "system",
29863
+ addonId: null,
29864
+ access: "view"
29865
+ },
29866
+ "osdManager.getSourceCatalog": {
29867
+ capName: "osd-manager",
29868
+ capScope: "system",
29869
+ addonId: null,
29870
+ access: "view"
29871
+ },
29872
+ "osdManager.previewSlot": {
29873
+ capName: "osd-manager",
29874
+ capScope: "system",
29875
+ addonId: null,
29876
+ access: "create"
29877
+ },
29878
+ "osdManager.renderDevice": {
29879
+ capName: "osd-manager",
29880
+ capScope: "system",
29881
+ addonId: null,
29882
+ access: "create"
29883
+ },
29884
+ "osdManager.setSlotBinding": {
29885
+ capName: "osd-manager",
29886
+ capScope: "system",
29887
+ addonId: null,
29888
+ access: "create"
29889
+ },
29565
29890
  "petFeeder.callPet": {
29566
29891
  capName: "pet-feeder",
29567
29892
  capScope: "device",
@@ -29724,6 +30049,18 @@ Object.freeze({
29724
30049
  addonId: null,
29725
30050
  access: "view"
29726
30051
  },
30052
+ "pipelineAnalytics.getTrainingExportSummary": {
30053
+ capName: "pipeline-analytics",
30054
+ capScope: "device",
30055
+ addonId: null,
30056
+ access: "view"
30057
+ },
30058
+ "pipelineAnalytics.getTrainingExportUrl": {
30059
+ capName: "pipeline-analytics",
30060
+ capScope: "device",
30061
+ addonId: null,
30062
+ access: "view"
30063
+ },
29727
30064
  "pipelineAnalytics.listEventKinds": {
29728
30065
  capName: "pipeline-analytics",
29729
30066
  capScope: "device",
@@ -31200,6 +31537,12 @@ Object.freeze({
31200
31537
  addonId: null,
31201
31538
  access: "view"
31202
31539
  },
31540
+ "streamBroker.getDeviceAudioMute": {
31541
+ capName: "stream-broker",
31542
+ capScope: "system",
31543
+ addonId: null,
31544
+ access: "view"
31545
+ },
31203
31546
  "streamBroker.getPreBufferInfo": {
31204
31547
  capName: "stream-broker",
31205
31548
  capScope: "system",
@@ -31320,6 +31663,12 @@ Object.freeze({
31320
31663
  addonId: null,
31321
31664
  access: "create"
31322
31665
  },
31666
+ "streamBroker.setDeviceAudioMute": {
31667
+ capName: "stream-broker",
31668
+ capScope: "system",
31669
+ addonId: null,
31670
+ access: "create"
31671
+ },
31323
31672
  "streamBroker.setPreBufferDuration": {
31324
31673
  capName: "stream-broker",
31325
31674
  capScope: "system",
@@ -32763,6 +33112,215 @@ function buildAuthHeader(input) {
32763
33112
  return `Digest ${parts.join(", ")}`;
32764
33113
  }
32765
33114
  //#endregion
33115
+ //#region src/hikvision-snapshot-channel.ts
33116
+ /**
33117
+ * Hikvision encodes a channel id as `{cameraNumber}{streamSlot}`; slot `1` is
33118
+ * the main stream. The provider has always taken its snapshot from the main
33119
+ * stream of the device's camera number, and that stays the default.
33120
+ */
33121
+ function defaultSnapshotChannelId(cameraNumber) {
33122
+ return `${cameraNumber}01`;
33123
+ }
33124
+ /** Human name for a stream slot, for the select label. */
33125
+ function streamSlotLabel(channelId) {
33126
+ const slot = channelId.slice(-1);
33127
+ if (slot === "1") return "Main stream";
33128
+ if (slot === "2") return "Sub stream";
33129
+ if (slot === "3") return "Third stream";
33130
+ return `Stream ${slot}`;
33131
+ }
33132
+ /**
33133
+ * Tag scanner. `<?xml …?>`, `<!-- … -->` and `<![CDATA[…]]>` never match —
33134
+ * `?`, `!` are outside the name class — so they are skipped as text.
33135
+ */
33136
+ function* scanTags(xml) {
33137
+ const re = /<(\/?)\s*([A-Za-z_][A-Za-z0-9_.:-]*)([^>]*)>/g;
33138
+ let m;
33139
+ while ((m = re.exec(xml)) !== null) {
33140
+ const rawName = m[2] ?? "";
33141
+ const colon = rawName.lastIndexOf(":");
33142
+ yield {
33143
+ name: (colon >= 0 ? rawName.slice(colon + 1) : rawName).toLowerCase(),
33144
+ closing: m[1] === "/",
33145
+ selfClosing: (m[3] ?? "").trimEnd().endsWith("/"),
33146
+ start: m.index,
33147
+ end: re.lastIndex
33148
+ };
33149
+ }
33150
+ }
33151
+ function decodeXmlEntities$1(s) {
33152
+ return s.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&apos;/g, "'").replace(/&amp;/g, "&");
33153
+ }
33154
+ /**
33155
+ * Text of the first `<tag>` that is a DIRECT child of `elementInner`.
33156
+ *
33157
+ * `null` when the element has no such direct child — a nested occurrence
33158
+ * (`<Video><enabled>`) is deliberately invisible here. Empty string when the
33159
+ * direct child exists but is empty or self-closing, which is NOT the same
33160
+ * answer and callers must keep the two apart.
33161
+ */
33162
+ function readDirectChildText(elementInner, tag) {
33163
+ const want = tag.toLowerCase();
33164
+ let depth = 0;
33165
+ let openedAt = -1;
33166
+ for (const token of scanTags(elementInner)) {
33167
+ if (token.selfClosing) {
33168
+ if (depth === 0 && !token.closing && token.name === want) return "";
33169
+ continue;
33170
+ }
33171
+ if (token.closing) {
33172
+ if (depth === 1 && openedAt >= 0 && token.name === want) return decodeXmlEntities$1(elementInner.slice(openedAt, token.start).trim());
33173
+ depth = Math.max(0, depth - 1);
33174
+ continue;
33175
+ }
33176
+ if (depth === 0 && token.name === want && openedAt < 0) openedAt = token.end;
33177
+ depth += 1;
33178
+ }
33179
+ return null;
33180
+ }
33181
+ /**
33182
+ * Inner content of the first `<tag>…</tag>` element in `xml`, or `null`.
33183
+ * Used to reach INTO a `<StreamingChannel>` before asking for a direct child.
33184
+ */
33185
+ function extractElementInner(xml, tag) {
33186
+ const want = tag.toLowerCase();
33187
+ let depth = 0;
33188
+ let openedAt = -1;
33189
+ for (const token of scanTags(xml)) {
33190
+ if (token.selfClosing) {
33191
+ if (depth === 0 && !token.closing && token.name === want) return "";
33192
+ continue;
33193
+ }
33194
+ if (token.closing) {
33195
+ if (depth === 1 && openedAt >= 0 && token.name === want) return xml.slice(openedAt, token.start);
33196
+ depth = Math.max(0, depth - 1);
33197
+ continue;
33198
+ }
33199
+ if (depth === 0 && token.name === want && openedAt < 0) openedAt = token.end;
33200
+ depth += 1;
33201
+ }
33202
+ return null;
33203
+ }
33204
+ /**
33205
+ * Every `<StreamingChannel>` element's inner content, in document order.
33206
+ * Depth-aware, so a `<StreamingChannelList>` wrapper is transparent and a
33207
+ * (hypothetical) nested repeat is not double-counted.
33208
+ */
33209
+ function streamingChannelBlocks(xml) {
33210
+ const out = [];
33211
+ let depth = 0;
33212
+ let openedAt = -1;
33213
+ let openDepth = -1;
33214
+ for (const token of scanTags(xml)) {
33215
+ if (token.selfClosing) continue;
33216
+ if (token.closing) {
33217
+ depth = Math.max(0, depth - 1);
33218
+ if (openedAt >= 0 && depth === openDepth && token.name === "streamingchannel") {
33219
+ out.push(xml.slice(openedAt, token.start));
33220
+ openedAt = -1;
33221
+ openDepth = -1;
33222
+ }
33223
+ continue;
33224
+ }
33225
+ if (openedAt < 0 && token.name === "streamingchannel") {
33226
+ openedAt = token.end;
33227
+ openDepth = depth;
33228
+ }
33229
+ depth += 1;
33230
+ }
33231
+ return out;
33232
+ }
33233
+ /**
33234
+ * The CHANNEL's own `<enabled>` in a `/ISAPI/Streaming/channels/{id}` document
33235
+ * (or in one `<StreamingChannel>` block). `null` when the firmware emits no
33236
+ * channel-level flag — distinct from `false`, which means the operator (or the
33237
+ * camera) switched the channel off and no picture will come out of it.
33238
+ *
33239
+ * Never returns `<Video><enabled>` or `<Audio><enabled>`.
33240
+ */
33241
+ function readStreamingChannelEnabled(xml) {
33242
+ const raw = readDirectChildText(extractElementInner(xml, "StreamingChannel") ?? xml, "enabled");
33243
+ if (raw === null || raw === "") return null;
33244
+ return raw.trim().toLowerCase() === "true";
33245
+ }
33246
+ /**
33247
+ * Parse `/ISAPI/Streaming/channels` into one summary per channel. Element-scoped
33248
+ * throughout: `enabled` is the channel's own flag even on a firmware that emits
33249
+ * `<Video>` before it.
33250
+ */
33251
+ function parseStreamingChannelSummaries(xml) {
33252
+ const out = [];
33253
+ for (const block of streamingChannelBlocks(xml)) {
33254
+ const channelId = readDirectChildText(block, "id");
33255
+ if (channelId === null || channelId === "") continue;
33256
+ const name = readDirectChildText(block, "channelName");
33257
+ const enabledRaw = readDirectChildText(block, "enabled");
33258
+ out.push({
33259
+ channelId,
33260
+ channelName: name === null || name === "" ? null : name,
33261
+ enabled: enabledRaw === null || enabledRaw === "" ? true : enabledRaw.toLowerCase() === "true"
33262
+ });
33263
+ }
33264
+ return out;
33265
+ }
33266
+ /**
33267
+ * Build the operator-facing select options from PROBED channels — never a
33268
+ * hardcoded main/sub pair. A channel the camera reports as disabled is still
33269
+ * listed (choosing it is a legitimate way to say "use this once I re-enable
33270
+ * it") but says so, so an operator does not pick a source that cannot produce
33271
+ * a picture without being told.
33272
+ *
33273
+ * Ordered by channel id so main/sub/third read in the natural order.
33274
+ */
33275
+ function buildSnapshotChannelOptions(channels) {
33276
+ return channels.toSorted((a, b) => a.channelId.localeCompare(b.channelId, "en")).map((c) => {
33277
+ const parts = [`${streamSlotLabel(c.channelId)} — ${c.channelId}`];
33278
+ const detail = [];
33279
+ if (c.channelName !== null && c.channelName !== void 0 && c.channelName !== "") detail.push(c.channelName);
33280
+ if (c.width !== null && c.width !== void 0 && c.height !== null && c.height !== void 0) detail.push(`${c.width}×${c.height}`);
33281
+ if (c.codec !== null && c.codec !== void 0 && c.codec !== "") detail.push(c.codec);
33282
+ if (c.enabled === false) detail.push("disabled on camera");
33283
+ if (detail.length > 0) parts.push(`(${detail.join(", ")})`);
33284
+ return {
33285
+ value: c.channelId,
33286
+ label: parts.join(" ")
33287
+ };
33288
+ });
33289
+ }
33290
+ /**
33291
+ * Resolve the channel a snapshot is taken from.
33292
+ *
33293
+ * - Nothing stored → the historical default. This is the whole
33294
+ * no-behaviour-change-on-upgrade guarantee: the setting starts at the channel
33295
+ * the provider already used and only an explicit operator save moves it.
33296
+ * - Stored choice present in `available` → the operator's channel.
33297
+ * - `available` EMPTY → honour the stored choice. An empty list means the probe
33298
+ * never ran (fresh boot, unreachable camera), not that the camera refused the
33299
+ * channel; discarding an operator's setting because we have not looked yet
33300
+ * would be destroying their work on a fallible read.
33301
+ * - Stored choice absent from a non-empty `available` → default, flagged
33302
+ * `fallback-unknown` so the caller can log it.
33303
+ */
33304
+ function resolveSnapshotChannelId(configured, available, fallbackChannelId) {
33305
+ const choice = (configured ?? "").trim();
33306
+ if (choice === "") return {
33307
+ channelId: fallbackChannelId,
33308
+ source: "default"
33309
+ };
33310
+ if (available.length === 0) return {
33311
+ channelId: choice,
33312
+ source: "operator"
33313
+ };
33314
+ if (available.includes(choice)) return {
33315
+ channelId: choice,
33316
+ source: "operator"
33317
+ };
33318
+ return {
33319
+ channelId: fallbackChannelId,
33320
+ source: "fallback-unknown"
33321
+ };
33322
+ }
33323
+ //#endregion
32766
33324
  //#region src/hikvision-stream-audio.ts
32767
33325
  /**
32768
33326
  * Compare a post-write read-back against what was asked for.
@@ -34230,7 +34788,7 @@ function parseStreamingChannelCaps(xml) {
34230
34788
  function parseStreamingChannelConfig(xml) {
34231
34789
  const id = extractTag(xml, "id") ?? "";
34232
34790
  const channelName = extractTag(xml, "channelName");
34233
- const enabled = (extractTag(xml, "enabled") ?? "false").toLowerCase() === "true";
34791
+ const enabled = readStreamingChannelEnabled(xml) ?? true;
34234
34792
  const videoCodecType = extractTag(xml, "videoCodecType");
34235
34793
  const width = parseIntSafe(extractTag(xml, "videoResolutionWidth"));
34236
34794
  const height = parseIntSafe(extractTag(xml, "videoResolutionHeight"));
@@ -34465,11 +35023,12 @@ function decodeXmlEntities(s) {
34465
35023
  }
34466
35024
  function parseChannelsList(xml) {
34467
35025
  const blocks = extractAllBlocks(xml, "StreamingChannel");
35026
+ const summaries = new Map(parseStreamingChannelSummaries(xml).map((s) => [s.channelId, s]));
34468
35027
  const out = [];
34469
35028
  for (const b of blocks) {
34470
35029
  const id = extractTag(b, "id");
34471
35030
  if (!id) continue;
34472
- const enabled = (extractTag(b, "enabled") ?? "true").toLowerCase() === "true";
35031
+ const enabled = summaries.get(id)?.enabled ?? true;
34473
35032
  const codec = extractTag(b, "videoCodecType");
34474
35033
  const width = parseIntSafe(extractTag(b, "videoResolutionWidth"));
34475
35034
  const height = parseIntSafe(extractTag(b, "videoResolutionHeight"));
@@ -35830,6 +36389,13 @@ var HikvisionDeviceCacheSchema = object({
35830
36389
  ntpSnapshot: HikvisionNtpSnapshotSchema.optional(),
35831
36390
  streamSnapshots: record(string(), object({
35832
36391
  channelName: string().nullable().optional(),
36392
+ /**
36393
+ * The CHANNEL's own `<enabled>` — read element-scoped via
36394
+ * `readStreamingChannelEnabled`, never the `<Video>` / `<Audio>`
36395
+ * sub-element flag that shares the tag name. Drives the
36396
+ * "disabled on camera" hint on the snapshot-source select.
36397
+ */
36398
+ enabled: boolean().optional(),
35833
36399
  videoCodecType: string().nullable().optional(),
35834
36400
  maxFrameRate: number().nullable().optional(),
35835
36401
  videoQualityControlType: _enum(["VBR", "CBR"]).nullable().optional(),
@@ -35894,6 +36460,21 @@ var hikvisionCameraSchema = object({
35894
36460
  "off"
35895
36461
  ]).default("auto").describe("Two-way audio source (auto/isapi/onvif/off)"),
35896
36462
  /**
36463
+ * ISAPI streaming channel the `snapshot` cap captures from
36464
+ * (`/ISAPI/Streaming/channels/{id}/picture`).
36465
+ *
36466
+ * Empty string = "not chosen" → the provider's historical channel
36467
+ * (`{cameraNumber}01`, the main stream). That sentinel is what makes an
36468
+ * upgrade a no-op: the setting starts at the channel already in use and only
36469
+ * an explicit operator save moves it.
36470
+ *
36471
+ * Options are PROBED from `/ISAPI/Streaming/channels` (persisted into
36472
+ * `deviceCache.streamSnapshots`), never hardcoded. A stored id the camera
36473
+ * stops enumerating falls back to the default and the provider logs it —
36474
+ * see `resolveSnapshotChannelId`.
36475
+ */
36476
+ snapshotChannelId: string().default("").describe("ISAPI channel used for snapshots (empty = main stream)"),
36477
+ /**
35897
36478
  * Motion detection master switch + sensitivity (0..100). Maps to
35898
36479
  * ISAPI `/ISAPI/System/Video/inputs/channels/{cam}/motionDetection`.
35899
36480
  * Disabling this stops the camera from emitting `<eventType>VMD</eventType>`
@@ -36178,6 +36759,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
36178
36759
  * plenty; the cap's own getStatus refreshes on demand for the
36179
36760
  * universal overlay UI. */
36180
36761
  osdPollTimer = null;
36762
+ /** Last snapshot channel we logged, so `resolveSnapshotChannel` emits one
36763
+ * line per CHANGE instead of one per capture. */
36764
+ lastSnapshotChannelLogged = null;
36181
36765
  static OSD_POLL_INTERVAL_MS = 3e5;
36182
36766
  /** Control-plane reachability poll — drives `device.online` from ISAPI
36183
36767
  * `getDeviceInfo` liveness, decoupled from stream-broker video health.
@@ -36197,7 +36781,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
36197
36781
  getSnapshot: async ({ deviceId }) => {
36198
36782
  if (deviceId !== this.id) throw new Error(`HikvisionCamera: deviceId mismatch, expected ${this.id}, got ${deviceId}`);
36199
36783
  const isapi = this.ensureClient();
36200
- const channelId = `101`;
36784
+ const channelId = this.resolveSnapshotChannel();
36201
36785
  try {
36202
36786
  const { buffer, contentType } = await isapi.getSnapshot(channelId);
36203
36787
  return {
@@ -36256,6 +36840,62 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
36256
36840
  });
36257
36841
  }
36258
36842
  /**
36843
+ * The probed streaming channels, as the snapshot-source select needs them.
36844
+ * Sourced from `deviceCache.streamSnapshots`, which the connect-time probe
36845
+ * fills from `/ISAPI/Streaming/channels` — never a hardcoded main/sub pair.
36846
+ * Empty until the first successful probe; the select is then not rendered
36847
+ * at all rather than offering a channel we have not confirmed exists.
36848
+ */
36849
+ probedSnapshotChannels() {
36850
+ const snapshots = this.config.get("deviceCache")?.streamSnapshots ?? {};
36851
+ return Object.entries(snapshots).map(([channelId, snap]) => ({
36852
+ channelId,
36853
+ channelName: snap.channelName ?? null,
36854
+ enabled: snap.enabled,
36855
+ codec: snap.videoCodecType ?? null,
36856
+ width: snap.resolutionWidth ?? null,
36857
+ height: snap.resolutionHeight ?? null
36858
+ }));
36859
+ }
36860
+ /**
36861
+ * The ISAPI channel the next snapshot is taken from.
36862
+ *
36863
+ * Unset setting → `{cam}01`, the channel the provider used before this
36864
+ * setting existed. A stored channel the camera no longer enumerates falls
36865
+ * back to that default and WARNS: a snapshot silently coming from a
36866
+ * different channel than the operator picked is precisely the kind of
36867
+ * substitution that reads as "the setting never worked".
36868
+ *
36869
+ * The info line fires only when the effective channel CHANGES, so a
36870
+ * per-second snapshot poll does not become a log stream.
36871
+ */
36872
+ resolveSnapshotChannel() {
36873
+ const fallback = defaultSnapshotChannelId(1);
36874
+ const configured = this.config.get("snapshotChannelId") ?? "";
36875
+ const available = this.probedSnapshotChannels().map((c) => c.channelId);
36876
+ const resolved = resolveSnapshotChannelId(configured, available, fallback);
36877
+ if (resolved.source === "fallback-unknown") this.ctx.logger.warn("hikvision snapshot channel not offered by camera — using default", {
36878
+ tags: { deviceId: this.id },
36879
+ meta: {
36880
+ configured,
36881
+ fallback,
36882
+ available
36883
+ }
36884
+ });
36885
+ if (this.lastSnapshotChannelLogged !== resolved.channelId) {
36886
+ this.lastSnapshotChannelLogged = resolved.channelId;
36887
+ this.ctx.logger.info("hikvision snapshot source channel resolved", {
36888
+ tags: { deviceId: this.id },
36889
+ meta: {
36890
+ channelId: resolved.channelId,
36891
+ source: resolved.source,
36892
+ configured
36893
+ }
36894
+ });
36895
+ }
36896
+ return resolved.channelId;
36897
+ }
36898
+ /**
36259
36899
  * Register the device-scoped `stream-catalog` cap — the broker pulls
36260
36900
  * this to (re)build its cam-stream registry. Sources its descriptors
36261
36901
  * from `buildStreamCatalog`, which also reconciles the local `published`
@@ -36993,6 +37633,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
36993
37633
  if (!cfg) continue;
36994
37634
  snapshots[channel.id] = {
36995
37635
  channelName: cfg.channelName,
37636
+ enabled: cfg.enabled,
36996
37637
  videoCodecType: cfg.videoCodecType,
36997
37638
  maxFrameRate: cfg.maxFrameRate,
36998
37639
  videoQualityControlType: cfg.videoQualityControlType,
@@ -38297,6 +38938,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
38297
38938
  }
38298
38939
  getSettingsUISchema() {
38299
38940
  const osdValues = this.osdSnapshotForUi();
38941
+ const snapshotChannelOptions = buildSnapshotChannelOptions(this.probedSnapshotChannels());
38300
38942
  return hydrateSchema({ sections: [
38301
38943
  {
38302
38944
  id: "connection",
@@ -38747,6 +39389,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
38747
39389
  }] : []
38748
39390
  ]
38749
39391
  }] : [],
39392
+ ...snapshotChannelOptions.length > 0 ? [{
39393
+ id: "snapshot-source",
39394
+ tab: "streaming",
39395
+ title: "Snapshot source",
39396
+ description: "Which ISAPI streaming channel the still image is captured from (`/ISAPI/Streaming/channels/{id}/picture`). The list is read off this camera. Default is the main stream — the channel the provider has always used — so leaving this alone changes nothing. Pick the sub stream when the main stream is 4K and the snapshot only feeds thumbnails.",
39397
+ columns: 1,
39398
+ fields: [{
39399
+ type: "select",
39400
+ key: "snapshotChannelId",
39401
+ label: "Snapshot channel",
39402
+ default: defaultSnapshotChannelId(1),
39403
+ options: [...snapshotChannelOptions]
39404
+ }]
39405
+ }] : [],
38750
39406
  {
38751
39407
  id: "alarms",
38752
39408
  tab: "alarms",
@@ -38903,6 +39559,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
38903
39559
  username: this.config.get("username"),
38904
39560
  password: this.config.get("password"),
38905
39561
  rtspTransport: this.config.get("rtspTransport") ?? "unicast",
39562
+ snapshotChannelId: this.resolveSnapshotChannel(),
38906
39563
  motionEnabled: this.config.get("motionEnabled") ?? true,
38907
39564
  motionSensitivity: this.config.get("motionSensitivity") ?? 60,
38908
39565
  vcaResource: this.config.get("vcaResource") ?? "smart",
@@ -38961,6 +39618,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
38961
39618
  }
38962
39619
  async applySettingsPatch(patch) {
38963
39620
  const cleaned = stripOsdShadowFields(patch);
39621
+ const previousSnapshotChannel = this.resolveSnapshotChannel();
38964
39622
  await this.config.setAll(cleaned);
38965
39623
  const typed = cleaned;
38966
39624
  if (typed.host !== void 0 || typed.port !== void 0 || typed.https !== void 0 || typed.username !== void 0 || typed.password !== void 0) {
@@ -38973,6 +39631,35 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
38973
39631
  });
38974
39632
  this.ensureAlarmSubscription();
38975
39633
  }
39634
+ if (typed.snapshotChannelId !== void 0) {
39635
+ const nextSnapshotChannel = this.resolveSnapshotChannel();
39636
+ if (nextSnapshotChannel !== previousSnapshotChannel) {
39637
+ this.ctx.logger.info("hikvision snapshot channel changed", {
39638
+ tags: { deviceId: this.id },
39639
+ meta: {
39640
+ from: previousSnapshotChannel,
39641
+ to: nextSnapshotChannel
39642
+ }
39643
+ });
39644
+ try {
39645
+ const proxy = await this.ctx.fetchDevice(this.id);
39646
+ if (proxy.snapshot === void 0) this.ctx.logger.info("no snapshot wrapper bound — nothing to flush", {
39647
+ tags: { deviceId: this.id },
39648
+ meta: { to: nextSnapshotChannel }
39649
+ });
39650
+ else await proxy.snapshot.invalidateCache({});
39651
+ } catch (err) {
39652
+ this.ctx.logger.warn("snapshot cache flush after channel change failed", {
39653
+ tags: { deviceId: this.id },
39654
+ meta: {
39655
+ from: previousSnapshotChannel,
39656
+ to: nextSnapshotChannel,
39657
+ error: err instanceof Error ? err.message : String(err)
39658
+ }
39659
+ });
39660
+ }
39661
+ }
39662
+ }
38976
39663
  if (typed.motionEnabled !== void 0 || typed.motionSensitivity !== void 0) try {
38977
39664
  await this.ensureClient().setMotionDetection(1, {
38978
39665
  enabled: typed.motionEnabled,