@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.
- package/dist/addon.js +701 -14
- package/dist/addon.mjs +701 -14
- 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
|
-
*
|
|
15105
|
-
*
|
|
15106
|
-
*
|
|
15107
|
-
* `
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/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 = (
|
|
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 = (
|
|
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 =
|
|
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,
|