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