@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.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
|
-
*
|
|
15104
|
-
*
|
|
15105
|
-
*
|
|
15106
|
-
* `
|
|
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(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/&/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 = (
|
|
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 = (
|
|
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 =
|
|
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,
|