@camstack/addon-provider-hikvision 1.2.118 → 1.2.120

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/addon.js +464 -24
  2. package/dist/addon.mjs +464 -24
  3. package/package.json +1 -1
package/dist/addon.js CHANGED
@@ -5367,7 +5367,7 @@ var ZodIssueCode = {
5367
5367
  var ZodFirstPartyTypeKind;
5368
5368
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5369
5369
  //#endregion
5370
- //#region ../types/dist/sleep-BDR76Ykr.mjs
5370
+ //#region ../types/dist/sleep-vl6nBE8d.mjs
5371
5371
  /**
5372
5372
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5373
5373
  * window to float samples (D455).
@@ -31371,14 +31371,81 @@ function summarisePrivacyAudio(profiles) {
31371
31371
  if (profiles.length === 0) return null;
31372
31372
  return profiles.some((p) => p.audioEnabled);
31373
31373
  }
31374
+ /**
31375
+ * What a preset id DOES on this camera.
31376
+ *
31377
+ * On several PTZ firmwares the preset namespace is shared: low ids are stored
31378
+ * positions, a reserved band triggers camera FUNCTIONS. The dispensa camera
31379
+ * (Hikvision) returns its own reserved band in the preset list, named by the
31380
+ * firmware:
31381
+ *
31382
+ * 34 Back to origin · 39 Day mode · 40 Night mode · 46 Day/Night Auto Mode
31383
+ * 92 Set manual limits · 93 Save manual limits · 94 Remote reboot
31384
+ * 95 Call OSD menu
31385
+ *
31386
+ * We used to hand all of these to the UI as ordinary presets. Saving over one
31387
+ * is what produced `PUT /ISAPI/PTZCtrl/channels/1/presets/34 -> HTTP 500`: the
31388
+ * camera refusing to let "Back to origin" be overwritten, surfaced to the
31389
+ * operator as a transport failure.
31390
+ *
31391
+ * A function preset stays fully usable as a DESTINATION -- `goToPreset('34')`
31392
+ * is the single most useful thing that camera can do. Only `savePreset` and
31393
+ * `deletePreset` are gated.
31394
+ */
31395
+ var PtzPresetFunctionSchema = _enum([
31396
+ "home",
31397
+ "day-mode",
31398
+ "night-mode",
31399
+ "day-night-auto",
31400
+ "set-limits",
31401
+ "save-limits",
31402
+ "reboot",
31403
+ "osd-menu"
31404
+ ]);
31374
31405
  var PtzPresetSchema = object({
31375
31406
  id: string(),
31376
- name: string()
31407
+ name: string(),
31408
+ /**
31409
+ * The firmware function this id performs, when the provider KNOWS the id is
31410
+ * reserved. `null` = an ordinary stored position, OR a reserved id this
31411
+ * provider does not recognise -- the two are told apart by `writable`.
31412
+ */
31413
+ fn: PtzPresetFunctionSchema.nullable(),
31414
+ /**
31415
+ * Whether `savePreset` / `deletePreset` may target this id. A provider sets
31416
+ * it false only for a band it KNOWS is reserved; where the firmware is not
31417
+ * documented it stays true and a refusal comes from the camera, named.
31418
+ */
31419
+ writable: boolean()
31377
31420
  });
31421
+ /**
31422
+ * Where the head is pointing -- per axis, and `null` when the driver cannot
31423
+ * read it.
31424
+ *
31425
+ * It used to be three REQUIRED numbers, which left a provider that cannot read
31426
+ * position no way to say so. All four said `0`:
31427
+ *
31428
+ * reolink `return { pan: 0, tilt: 0, zoom: 0 }` "until a position-query
31429
+ * path lands upstream"
31430
+ * hikvision stub, "firmware-dependent and frequently absent"
31431
+ * amcrest stub, "no generic absolute-position read for this model family"
31432
+ * onvif throws
31433
+ *
31434
+ * So every consumer asking where a camera points was told "perfectly centred"
31435
+ * by four cameras that had never looked. That is D393 -- a measurement that
31436
+ * FAILED is `null`, never `0`, and the type says so all the way to the
31437
+ * decision -- the same shape as the unreadable `statfs` folded into "0 bytes of
31438
+ * headroom", which evacuated a healthy disk.
31439
+ *
31440
+ * D393 also says to check for a narrower structural TWIN of the result type.
31441
+ * There is one: `PtzStatusSchema` extends this, so the lie had already
31442
+ * propagated into `getStatus`, which is the surface `getPosition`'s own comment
31443
+ * tells callers to migrate to.
31444
+ */
31378
31445
  var PtzPositionSchema = object({
31379
- pan: number(),
31380
- tilt: number(),
31381
- zoom: number()
31446
+ pan: number().nullable(),
31447
+ tilt: number().nullable(),
31448
+ zoom: number().nullable()
31382
31449
  });
31383
31450
  var PtzMoveCommandSchema = object({
31384
31451
  pan: number().optional(),
@@ -31400,7 +31467,72 @@ var PtzOptionsSchema = object({
31400
31467
  maxPresets: number().optional(),
31401
31468
  /** Whether the camera exposes a controllable autofocus toggle
31402
31469
  * (boolean `hasX` per the getOptions availability convention). */
31403
- hasAutofocus: boolean()
31470
+ hasAutofocus: boolean(),
31471
+ /**
31472
+ * How many distinct speeds this camera's NATIVE scale offers.
31473
+ *
31474
+ * The cap's `speed` is normalized 0..1 and each provider maps it down --
31475
+ * Baichuan 1..63, Dahua 1..8, ISAPI's percentage -100..100. The normalized
31476
+ * value hides how coarse that really is: asking an amcrest for `0.37` is
31477
+ * meaningless, because it has eight steps and three of them round to the
31478
+ * same one. A loop that tunes its own gain has to know the granularity of
31479
+ * the knob it is turning.
31480
+ *
31481
+ * `null` = the driver does not know, or the scale is continuous (ONVIF takes
31482
+ * a float). Never a made-up number.
31483
+ */
31484
+ speedSteps: number().int().positive().nullable(),
31485
+ /**
31486
+ * How long ONE `move` pulse runs on this driver, ms.
31487
+ *
31488
+ * `move` is a self-terminating burst everywhere, but every provider hardcoded
31489
+ * its own duration in private -- 200 reolink, 350 amcrest, 500 hikvision,
31490
+ * 1000 onvif -- so no caller could read it. It is the DENOMINATOR of "how far
31491
+ * did the head travel per pulse": without it a measured displacement has no
31492
+ * gain to be divided into.
31493
+ *
31494
+ * `null` = the driver cannot say.
31495
+ */
31496
+ moveImpulseMs: number().int().positive().nullable()
31497
+ });
31498
+ /**
31499
+ * Which preset `goHome` goes to, and WHO decided.
31500
+ *
31501
+ * `goHome` used to be three vendor "conventions" written in three comments --
31502
+ * Reolink preset 0, Hikvision preset '1', Dahua preset 1 -- and on this fleet
31503
+ * not one of the three cameras honours its own:
31504
+ *
31505
+ * 592 Videocamera camera Daniel (reolink) preset 0 holds "stanza"
31506
+ * 1438 Videocamera dispensa (hikvision) preset 1 holds "Credenza"
31507
+ * 3836 Videocamera studio (amcrest) preset 1 holds "Preset1"
31508
+ *
31509
+ * So `goHome` meant "go wherever the operator happened to save in slot 0 or 1",
31510
+ * and two of the three swallowed the failure in a bare `catch`. For autotrack
31511
+ * this is the HOTTEST path -- it runs every time a subject is released -- so it
31512
+ * needs an answer that is true per camera and audible when it is missing.
31513
+ *
31514
+ * `source` is what makes it honest:
31515
+ * - `operator` -- picked in the UI, stored by the provider.
31516
+ * - `firmware` -- the camera itself named a preset `fn: 'home'` (Hikvision's
31517
+ * "Back to origin"), adopted as the default with nothing to configure.
31518
+ * - `native` -- the driver homes WITHOUT a preset at all. ONVIF does:
31519
+ * `goHome` is `ptzAbsoluteMove({x:0, y:0, zoom:0})`. `presetId` is null and
31520
+ * that is not a failure -- there is nothing to pick, and the UI must offer
31521
+ * no picker.
31522
+ * - `none` -- nothing is configured and the camera names nothing.
31523
+ * `goHome` REFUSES rather than moving the head somewhere arbitrary.
31524
+ *
31525
+ * `source === 'none'` is the refusal condition, NOT `presetId === null`: the
31526
+ * native case has no preset and homes perfectly well.
31527
+ */
31528
+ var PtzHomePresetSchema = object({
31529
+ presetId: string().nullable(),
31530
+ source: _enum([
31531
+ "operator",
31532
+ "firmware",
31533
+ "native",
31534
+ "none"
31535
+ ])
31404
31536
  });
31405
31537
  var ptzCapability = {
31406
31538
  name: "ptz",
@@ -31441,7 +31573,66 @@ var ptzCapability = {
31441
31573
  auth: "admin"
31442
31574
  }),
31443
31575
  getOptions: method(object({ deviceId: number() }), PtzOptionsSchema),
31576
+ /**
31577
+ * Move to the camera's configured home preset. THROWS when
31578
+ * `getHomePreset()` resolves to `none` -- a head that did not move must
31579
+ * never look like a head that went home.
31580
+ */
31444
31581
  goHome: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
31582
+ /** Which preset `goHome` targets, and who decided it. */
31583
+ getHomePreset: method(object({ deviceId: number() }), PtzHomePresetSchema),
31584
+ /**
31585
+ * Pin the home preset for this camera. `null` clears the operator's choice
31586
+ * and falls back to whatever the firmware names, or to `none`.
31587
+ */
31588
+ setHomePreset: method(object({
31589
+ deviceId: number(),
31590
+ presetId: string().nullable()
31591
+ }), _void(), {
31592
+ kind: "mutation",
31593
+ auth: "admin"
31594
+ }),
31595
+ /**
31596
+ * Make WHERE THE HEAD IS POINTING RIGHT NOW this camera's home.
31597
+ *
31598
+ * `setHomePreset` takes a preset id, and a preset id is the wrong shape for
31599
+ * the most natural thing an operator does: aim the camera, then say "here".
31600
+ * It is also the only shape a camera whose home is NOT a preset can accept
31601
+ * -- Reolink's home is a guard point, a stored position with no id at all,
31602
+ * and hiding the control for that case (as the UI briefly did) left the one
31603
+ * vendor with a native home unable to set it.
31604
+ *
31605
+ * Each provider does it its own way:
31606
+ * reolink `setGrd` with `needSetPos`, pinning the guard point here
31607
+ * hikvision / amcrest save a preset, then pin it as home
31608
+ * onvif REFUSES -- its home is the PTZ origin and cannot be moved
31609
+ *
31610
+ * A provider that cannot honour it throws rather than silently doing
31611
+ * nothing: an operator who pressed "set home here" and saw no error would
31612
+ * believe it took.
31613
+ */
31614
+ captureHomeHere: method(object({ deviceId: number() }), _void(), {
31615
+ kind: "mutation",
31616
+ auth: "admin"
31617
+ }),
31618
+ /**
31619
+ * How long the camera waits, idle, before returning home on its own --
31620
+ * `null` when it has no such behaviour.
31621
+ *
31622
+ * Reolink's guard point carries one (`timeout`, freely settable in
31623
+ * seconds). It is a real function of the camera that we did not expose at
31624
+ * all, and it belongs next to the home it governs rather than in a vendor
31625
+ * settings page nobody associates with Home.
31626
+ */
31627
+ getHomeReturnSeconds: method(object({ deviceId: number() }), number().int().nullable()),
31628
+ /** Set the idle-return delay. Refused by a camera that has no such timer. */
31629
+ setHomeReturnSeconds: method(object({
31630
+ deviceId: number(),
31631
+ seconds: number().int().min(0).max(3600)
31632
+ }), _void(), {
31633
+ kind: "mutation",
31634
+ auth: "admin"
31635
+ }),
31445
31636
  /**
31446
31637
  * Pull the current PTZ position. Redundant with the auto-injected
31447
31638
  * `getStatus` method (see `status` below); kept for callers that
@@ -41155,6 +41346,12 @@ Object.freeze({
41155
41346
  addonId: null,
41156
41347
  access: "create"
41157
41348
  },
41349
+ "ptz.captureHomeHere": {
41350
+ capName: "ptz",
41351
+ capScope: "device",
41352
+ addonId: null,
41353
+ access: "create"
41354
+ },
41158
41355
  "ptz.continuousMove": {
41159
41356
  capName: "ptz",
41160
41357
  capScope: "device",
@@ -41167,6 +41364,18 @@ Object.freeze({
41167
41364
  addonId: null,
41168
41365
  access: "delete"
41169
41366
  },
41367
+ "ptz.getHomePreset": {
41368
+ capName: "ptz",
41369
+ capScope: "device",
41370
+ addonId: null,
41371
+ access: "view"
41372
+ },
41373
+ "ptz.getHomeReturnSeconds": {
41374
+ capName: "ptz",
41375
+ capScope: "device",
41376
+ addonId: null,
41377
+ access: "view"
41378
+ },
41170
41379
  "ptz.getOptions": {
41171
41380
  capName: "ptz",
41172
41381
  capScope: "device",
@@ -41215,6 +41424,18 @@ Object.freeze({
41215
41424
  addonId: null,
41216
41425
  access: "create"
41217
41426
  },
41427
+ "ptz.setHomePreset": {
41428
+ capName: "ptz",
41429
+ capScope: "device",
41430
+ addonId: null,
41431
+ access: "create"
41432
+ },
41433
+ "ptz.setHomeReturnSeconds": {
41434
+ capName: "ptz",
41435
+ capScope: "device",
41436
+ addonId: null,
41437
+ access: "create"
41438
+ },
41218
41439
  "ptz.stop": {
41219
41440
  capName: "ptz",
41220
41441
  capScope: "device",
@@ -44378,6 +44599,11 @@ Object.freeze({
44378
44599
  form: "single",
44379
44600
  optional: false
44380
44601
  }],
44602
+ "ptz.captureHomeHere": [{
44603
+ name: "deviceId",
44604
+ form: "single",
44605
+ optional: false
44606
+ }],
44381
44607
  "ptz.continuousMove": [{
44382
44608
  name: "deviceId",
44383
44609
  form: "single",
@@ -44388,6 +44614,16 @@ Object.freeze({
44388
44614
  form: "single",
44389
44615
  optional: false
44390
44616
  }],
44617
+ "ptz.getHomePreset": [{
44618
+ name: "deviceId",
44619
+ form: "single",
44620
+ optional: false
44621
+ }],
44622
+ "ptz.getHomeReturnSeconds": [{
44623
+ name: "deviceId",
44624
+ form: "single",
44625
+ optional: false
44626
+ }],
44391
44627
  "ptz.getOptions": [{
44392
44628
  name: "deviceId",
44393
44629
  form: "single",
@@ -44428,6 +44664,16 @@ Object.freeze({
44428
44664
  form: "single",
44429
44665
  optional: false
44430
44666
  }],
44667
+ "ptz.setHomePreset": [{
44668
+ name: "deviceId",
44669
+ form: "single",
44670
+ optional: false
44671
+ }],
44672
+ "ptz.setHomeReturnSeconds": [{
44673
+ name: "deviceId",
44674
+ form: "single",
44675
+ optional: false
44676
+ }],
44431
44677
  "ptz.stop": [{
44432
44678
  name: "deviceId",
44433
44679
  form: "single",
@@ -49736,6 +49982,110 @@ function buildRawState(reader) {
49736
49982
  };
49737
49983
  }
49738
49984
  //#endregion
49985
+ //#region src/isapi-ptz-speed.ts
49986
+ /**
49987
+ * Turn the cap's normalized direction + magnitude into an ISAPI continuous
49988
+ * speed.
49989
+ *
49990
+ * The `ptz` cap splits the two: `pan`/`tilt`/`zoom` are a DIRECTION in -1..1,
49991
+ * and `speed` is the magnitude in 0..1. reolink maps them to Baichuan 1..63,
49992
+ * amcrest to Dahua 1..8 — and hikvision used to map them to nothing at all. It
49993
+ * dropped `speed` before it left the provider and handed the raw direction to
49994
+ * `/ISAPI/PTZCtrl/channels/{cam}/continuous`, whose `<pan>` is a speed
49995
+ * PERCENTAGE in -100..100.
49996
+ *
49997
+ * So `usePTZ` sending its d-pad default (`pan: 1, speed: 0.5`) reached the
49998
+ * dispensa camera as `<pan>1</pan>`: one percent of the camera's speed. A
49999
+ * request at less than half magnitude rounded to `0` and produced no motion at
50000
+ * all.
50001
+ *
50002
+ * An absent `speed` is mid-scale, never zero — reolink defaults to 32 of 63 for
50003
+ * the same reason. A caller who did not state a magnitude asked to move, not to
50004
+ * stand still.
50005
+ */
50006
+ var MID_SCALE = .5;
50007
+ function isapiSpeedFor(direction, speed) {
50008
+ const d = direction ?? 0;
50009
+ if (!Number.isFinite(d) || d === 0) return 0;
50010
+ const magnitude = speed === void 0 || !Number.isFinite(speed) ? MID_SCALE : Math.abs(speed);
50011
+ const percent = Math.round(Math.sign(d) * Math.min(1, Math.abs(d)) * Math.min(1, magnitude) * 100);
50012
+ return Math.max(-100, Math.min(100, percent));
50013
+ }
50014
+ //#endregion
50015
+ //#region src/ptz-preset-functions.ts
50016
+ var RESERVED = new Map([
50017
+ ["34", "home"],
50018
+ ["39", "day-mode"],
50019
+ ["40", "night-mode"],
50020
+ ["46", "day-night-auto"],
50021
+ ["92", "set-limits"],
50022
+ ["93", "save-limits"],
50023
+ ["94", "reboot"],
50024
+ ["95", "osd-menu"]
50025
+ ]);
50026
+ /** The firmware function this id performs, or `null` for a stored position. */
50027
+ function presetFunctionFor(presetId) {
50028
+ return RESERVED.get(presetId) ?? null;
50029
+ }
50030
+ /**
50031
+ * Whether `savePreset` / `deletePreset` may target this id.
50032
+ *
50033
+ * Anything outside the known band stays writable: an undocumented firmware
50034
+ * must be allowed to refuse for itself rather than be guessed at here.
50035
+ */
50036
+ function isPresetWritable(presetId) {
50037
+ return !RESERVED.has(presetId);
50038
+ }
50039
+ /**
50040
+ * Refuse a write to a reserved id, naming the function that owns it.
50041
+ *
50042
+ * Extracted so the REFUSAL itself is assertable. With the check inline in
50043
+ * `savePreset` / `deletePreset`, deleting it left every test green -- the table
50044
+ * spec proves what the band IS, not that anything consults it.
50045
+ */
50046
+ function assertPresetWritable(presetId, action) {
50047
+ const fn = presetFunctionFor(presetId);
50048
+ if (fn === null) return;
50049
+ throw new Error(`Preset ${presetId} is a firmware function on this camera (${fn}) and cannot be ${action}` + (action === "overwritten" ? " — pick an unreserved id" : ""));
50050
+ }
50051
+ //#endregion
50052
+ //#region src/free-preset-slot.ts
50053
+ /**
50054
+ * The lowest preset id this camera will actually accept a write on.
50055
+ *
50056
+ * "Set home here" has to land somewhere, and on a camera whose home IS a preset
50057
+ * that means claiming a slot. Two things make the choice non-obvious:
50058
+ *
50059
+ * - the reserved FUNCTION band shares the preset namespace (34 "Back to
50060
+ * origin", 94 "Remote reboot", 95 "Call OSD menu" and friends), and writing
50061
+ * one answers HTTP 500;
50062
+ * - the operator's own presets must not be overwritten, ever. Losing
50063
+ * "Credenza" to make room for a home is a worse trade than refusing.
50064
+ *
50065
+ * So: the first id that is neither taken nor reserved, within the camera's
50066
+ * advertised budget. `null` when there is no room, and the caller must then
50067
+ * REFUSE rather than pick a victim.
50068
+ */
50069
+ function findFreePresetSlot(taken, maxPresets) {
50070
+ const used = new Set(taken.map((p) => p.id));
50071
+ const ceiling = maxPresets === void 0 ? 32 : Math.min(maxPresets, 300);
50072
+ for (let id = 1; id <= ceiling; id += 1) {
50073
+ const candidate = String(id);
50074
+ if (used.has(candidate)) continue;
50075
+ if (!isPresetWritable(candidate)) continue;
50076
+ return candidate;
50077
+ }
50078
+ return null;
50079
+ }
50080
+ /**
50081
+ * The name written on a preset claimed by "set home here".
50082
+ *
50083
+ * Named rather than left a literal so the slot is recognisable in the camera's
50084
+ * own web UI — an operator who finds an unexplained preset has no way to know
50085
+ * it is ours.
50086
+ */
50087
+ var HOME_PRESET_NAME = "CamStack Home";
50088
+ //#endregion
49739
50089
  //#region src/schema.ts
49740
50090
  /**
49741
50091
  * Single source of truth for the addon id used in event-source tags +
@@ -50054,6 +50404,17 @@ var hikvisionCameraSchema = object({
50054
50404
  */
50055
50405
  snapshotChannelId: string().default("").describe("ISAPI channel used for snapshots (empty = main stream)"),
50056
50406
  /**
50407
+ * Which preset `ptz.goHome` targets on THIS camera.
50408
+ *
50409
+ * Empty = no operator choice. The provider then falls back to the preset the
50410
+ * FIRMWARE names as home (Hikvision returns id 34 "Back to origin" in its
50411
+ * reserved band), and only if that is absent too does `goHome` refuse.
50412
+ *
50413
+ * It has to be stored because the vendor "convention" is wrong here: the code
50414
+ * went to preset '1', and on the dispensa camera preset 1 is "Credenza".
50415
+ */
50416
+ homePresetId: string().default("").describe("Preset id ptz.goHome targets (empty = use the firmware home)"),
50417
+ /**
50057
50418
  * Motion detection master switch + sensitivity (0..100). Maps to
50058
50419
  * ISAPI `/ISAPI/System/Video/inputs/channels/{cam}/motionDetection`.
50059
50420
  * Disabling this stops the camera from emitting `<eventType>VMD</eventType>`
@@ -51091,6 +51452,41 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51091
51452
  ...slice
51092
51453
  } });
51093
51454
  }
51455
+ /**
51456
+ * Which preset `goHome` targets, and who decided it.
51457
+ *
51458
+ * The operator's pick wins. With none, the FIRMWARE's own home is adopted --
51459
+ * Hikvision names id 34 "Back to origin" in its reserved band -- so a camera
51460
+ * that already knows where home is needs nothing configured. Only when both
51461
+ * are absent is the answer `none`, and `goHome` then refuses rather than
51462
+ * moving the head to an arbitrary slot.
51463
+ *
51464
+ * A failed preset read yields `none`, never a guess: an unreadable camera is
51465
+ * not evidence that preset 34 exists (D393).
51466
+ */
51467
+ async resolveHomePreset() {
51468
+ const chosen = this.config.get("homePresetId") ?? "";
51469
+ if (chosen !== "") return {
51470
+ presetId: chosen,
51471
+ source: "operator"
51472
+ };
51473
+ try {
51474
+ const firmwareHome = (await this.ensureClient().listPtzPresets(1)).find((preset) => presetFunctionFor(preset.id) === "home");
51475
+ if (firmwareHome !== void 0) return {
51476
+ presetId: firmwareHome.id,
51477
+ source: "firmware"
51478
+ };
51479
+ } catch (err) {
51480
+ this.ctx.logger.warn("hikvision home preset lookup failed — goHome will refuse", {
51481
+ tags: { deviceId: this.id },
51482
+ meta: { error: err instanceof Error ? err.message : String(err) }
51483
+ });
51484
+ }
51485
+ return {
51486
+ presetId: null,
51487
+ source: "none"
51488
+ };
51489
+ }
51094
51490
  /** Re-read the image-tuning snapshot from the camera and persist it.
51095
51491
  * Best-effort: a refresh failure is logged and swallowed so a save
51096
51492
  * never throws. Called after an Image-section push in
@@ -51367,12 +51763,12 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51367
51763
  registerPtzCap(cameraNumber) {
51368
51764
  const isapi = this.ensureClient();
51369
51765
  this.ctx.registerNativeCap(ptzCapability, {
51370
- move: async ({ deviceId, pan, tilt, zoom }) => {
51766
+ move: async ({ deviceId, pan, tilt, zoom, speed }) => {
51371
51767
  if (deviceId !== this.id) return;
51372
51768
  await isapi.ptzContinuous(cameraNumber, {
51373
- pan,
51374
- tilt,
51375
- zoom
51769
+ pan: isapiSpeedFor(pan, speed),
51770
+ tilt: isapiSpeedFor(tilt, speed),
51771
+ zoom: isapiSpeedFor(zoom, speed)
51376
51772
  });
51377
51773
  await new Promise((r) => setTimeout(r, 500));
51378
51774
  await isapi.ptzContinuous(cameraNumber, {
@@ -51381,12 +51777,12 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51381
51777
  zoom: 0
51382
51778
  });
51383
51779
  },
51384
- continuousMove: async ({ deviceId, pan, tilt, zoom }) => {
51780
+ continuousMove: async ({ deviceId, pan, tilt, zoom, speed }) => {
51385
51781
  if (deviceId !== this.id) return;
51386
51782
  await isapi.ptzContinuous(cameraNumber, {
51387
- pan,
51388
- tilt,
51389
- zoom
51783
+ pan: isapiSpeedFor(pan, speed),
51784
+ tilt: isapiSpeedFor(tilt, speed),
51785
+ zoom: isapiSpeedFor(zoom, speed)
51390
51786
  });
51391
51787
  },
51392
51788
  stop: async ({ deviceId }) => {
@@ -51401,7 +51797,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51401
51797
  if (deviceId !== this.id) return [];
51402
51798
  return (await isapi.listPtzPresets(cameraNumber)).map((p) => ({
51403
51799
  id: p.id,
51404
- name: p.name ?? `Preset ${p.id}`
51800
+ name: p.name ?? `Preset ${p.id}`,
51801
+ fn: presetFunctionFor(p.id),
51802
+ writable: isPresetWritable(p.id)
51405
51803
  }));
51406
51804
  },
51407
51805
  goToPreset: async ({ deviceId, presetId }) => {
@@ -51410,10 +51808,12 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51410
51808
  },
51411
51809
  savePreset: async ({ deviceId, presetId, name }) => {
51412
51810
  if (deviceId !== this.id) return;
51811
+ assertPresetWritable(presetId, "overwritten");
51413
51812
  await isapi.savePtzPreset(cameraNumber, presetId, name);
51414
51813
  },
51415
51814
  deletePreset: async ({ deviceId, presetId }) => {
51416
51815
  if (deviceId !== this.id) return;
51816
+ assertPresetWritable(presetId, "deleted");
51417
51817
  await isapi.deletePtzPreset(cameraNumber, presetId);
51418
51818
  },
51419
51819
  getOptions: async ({ deviceId }) => {
@@ -51422,7 +51822,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51422
51822
  hasTilt: false,
51423
51823
  hasZoom: false,
51424
51824
  supportsPresets: false,
51425
- hasAutofocus: false
51825
+ hasAutofocus: false,
51826
+ speedSteps: 100,
51827
+ moveImpulseMs: 500
51426
51828
  };
51427
51829
  const caps = await isapi.getPtzCapabilities(cameraNumber);
51428
51830
  if (!caps) return {
@@ -51430,7 +51832,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51430
51832
  hasTilt: false,
51431
51833
  hasZoom: false,
51432
51834
  supportsPresets: false,
51433
- hasAutofocus: false
51835
+ hasAutofocus: false,
51836
+ speedSteps: 100,
51837
+ moveImpulseMs: 500
51434
51838
  };
51435
51839
  const maxPresets = caps.maxPresetCount ?? void 0;
51436
51840
  return {
@@ -51439,25 +51843,61 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51439
51843
  hasZoom: caps.hasZoom,
51440
51844
  supportsPresets: maxPresets === void 0 ? true : maxPresets > 0,
51441
51845
  hasAutofocus: false,
51846
+ speedSteps: 100,
51847
+ moveImpulseMs: 500,
51442
51848
  ...maxPresets !== void 0 ? { maxPresets } : {}
51443
51849
  };
51444
51850
  },
51445
51851
  goHome: async ({ deviceId }) => {
51446
51852
  if (deviceId !== this.id) return;
51447
- await isapi.ptzGoToPreset(cameraNumber, "1");
51853
+ const home = await this.resolveHomePreset();
51854
+ if (home.source === "none" || home.presetId === null) throw new Error("No home preset configured for this camera — set one in the PTZ settings");
51855
+ await isapi.ptzGoToPreset(cameraNumber, home.presetId);
51856
+ },
51857
+ getHomePreset: async ({ deviceId }) => {
51858
+ if (deviceId !== this.id) return {
51859
+ presetId: null,
51860
+ source: "none"
51861
+ };
51862
+ return this.resolveHomePreset();
51863
+ },
51864
+ captureHomeHere: async ({ deviceId }) => {
51865
+ if (deviceId !== this.id) return;
51866
+ const [presets, caps] = await Promise.all([isapi.listPtzPresets(cameraNumber), isapi.getPtzCapabilities(cameraNumber)]);
51867
+ const slot = findFreePresetSlot(presets.map((p) => ({
51868
+ id: p.id,
51869
+ writable: isPresetWritable(p.id)
51870
+ })), caps?.maxPresetCount ?? void 0);
51871
+ if (slot === null) throw new Error("No free preset slot on this camera to store a home position — free one first");
51872
+ await isapi.savePtzPreset(cameraNumber, slot, HOME_PRESET_NAME);
51873
+ await this.config.setAll({ homePresetId: slot });
51874
+ this.ctx.logger.info("hikvision: home pinned to the current position", {
51875
+ tags: { deviceId: this.id },
51876
+ meta: { presetId: slot }
51877
+ });
51878
+ },
51879
+ getHomeReturnSeconds: async () => {
51880
+ return null;
51881
+ },
51882
+ setHomeReturnSeconds: async () => {
51883
+ throw new Error("Hikvision: the idle-return timer is not wired on this driver");
51884
+ },
51885
+ setHomePreset: async ({ deviceId, presetId }) => {
51886
+ if (deviceId !== this.id) return;
51887
+ await this.config.setAll({ homePresetId: presetId ?? "" });
51448
51888
  },
51449
51889
  getPosition: async () => {
51450
51890
  return {
51451
- pan: 0,
51452
- tilt: 0,
51453
- zoom: 0
51891
+ pan: null,
51892
+ tilt: null,
51893
+ zoom: null
51454
51894
  };
51455
51895
  },
51456
51896
  getStatus: async () => {
51457
51897
  return {
51458
- pan: 0,
51459
- tilt: 0,
51460
- zoom: 0,
51898
+ pan: null,
51899
+ tilt: null,
51900
+ zoom: null,
51461
51901
  autofocus: false
51462
51902
  };
51463
51903
  },
package/dist/addon.mjs CHANGED
@@ -5368,7 +5368,7 @@ var ZodIssueCode = {
5368
5368
  var ZodFirstPartyTypeKind;
5369
5369
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5370
5370
  //#endregion
5371
- //#region ../types/dist/sleep-BDR76Ykr.mjs
5371
+ //#region ../types/dist/sleep-vl6nBE8d.mjs
5372
5372
  /**
5373
5373
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5374
5374
  * window to float samples (D455).
@@ -31372,14 +31372,81 @@ function summarisePrivacyAudio(profiles) {
31372
31372
  if (profiles.length === 0) return null;
31373
31373
  return profiles.some((p) => p.audioEnabled);
31374
31374
  }
31375
+ /**
31376
+ * What a preset id DOES on this camera.
31377
+ *
31378
+ * On several PTZ firmwares the preset namespace is shared: low ids are stored
31379
+ * positions, a reserved band triggers camera FUNCTIONS. The dispensa camera
31380
+ * (Hikvision) returns its own reserved band in the preset list, named by the
31381
+ * firmware:
31382
+ *
31383
+ * 34 Back to origin · 39 Day mode · 40 Night mode · 46 Day/Night Auto Mode
31384
+ * 92 Set manual limits · 93 Save manual limits · 94 Remote reboot
31385
+ * 95 Call OSD menu
31386
+ *
31387
+ * We used to hand all of these to the UI as ordinary presets. Saving over one
31388
+ * is what produced `PUT /ISAPI/PTZCtrl/channels/1/presets/34 -> HTTP 500`: the
31389
+ * camera refusing to let "Back to origin" be overwritten, surfaced to the
31390
+ * operator as a transport failure.
31391
+ *
31392
+ * A function preset stays fully usable as a DESTINATION -- `goToPreset('34')`
31393
+ * is the single most useful thing that camera can do. Only `savePreset` and
31394
+ * `deletePreset` are gated.
31395
+ */
31396
+ var PtzPresetFunctionSchema = _enum([
31397
+ "home",
31398
+ "day-mode",
31399
+ "night-mode",
31400
+ "day-night-auto",
31401
+ "set-limits",
31402
+ "save-limits",
31403
+ "reboot",
31404
+ "osd-menu"
31405
+ ]);
31375
31406
  var PtzPresetSchema = object({
31376
31407
  id: string(),
31377
- name: string()
31408
+ name: string(),
31409
+ /**
31410
+ * The firmware function this id performs, when the provider KNOWS the id is
31411
+ * reserved. `null` = an ordinary stored position, OR a reserved id this
31412
+ * provider does not recognise -- the two are told apart by `writable`.
31413
+ */
31414
+ fn: PtzPresetFunctionSchema.nullable(),
31415
+ /**
31416
+ * Whether `savePreset` / `deletePreset` may target this id. A provider sets
31417
+ * it false only for a band it KNOWS is reserved; where the firmware is not
31418
+ * documented it stays true and a refusal comes from the camera, named.
31419
+ */
31420
+ writable: boolean()
31378
31421
  });
31422
+ /**
31423
+ * Where the head is pointing -- per axis, and `null` when the driver cannot
31424
+ * read it.
31425
+ *
31426
+ * It used to be three REQUIRED numbers, which left a provider that cannot read
31427
+ * position no way to say so. All four said `0`:
31428
+ *
31429
+ * reolink `return { pan: 0, tilt: 0, zoom: 0 }` "until a position-query
31430
+ * path lands upstream"
31431
+ * hikvision stub, "firmware-dependent and frequently absent"
31432
+ * amcrest stub, "no generic absolute-position read for this model family"
31433
+ * onvif throws
31434
+ *
31435
+ * So every consumer asking where a camera points was told "perfectly centred"
31436
+ * by four cameras that had never looked. That is D393 -- a measurement that
31437
+ * FAILED is `null`, never `0`, and the type says so all the way to the
31438
+ * decision -- the same shape as the unreadable `statfs` folded into "0 bytes of
31439
+ * headroom", which evacuated a healthy disk.
31440
+ *
31441
+ * D393 also says to check for a narrower structural TWIN of the result type.
31442
+ * There is one: `PtzStatusSchema` extends this, so the lie had already
31443
+ * propagated into `getStatus`, which is the surface `getPosition`'s own comment
31444
+ * tells callers to migrate to.
31445
+ */
31379
31446
  var PtzPositionSchema = object({
31380
- pan: number(),
31381
- tilt: number(),
31382
- zoom: number()
31447
+ pan: number().nullable(),
31448
+ tilt: number().nullable(),
31449
+ zoom: number().nullable()
31383
31450
  });
31384
31451
  var PtzMoveCommandSchema = object({
31385
31452
  pan: number().optional(),
@@ -31401,7 +31468,72 @@ var PtzOptionsSchema = object({
31401
31468
  maxPresets: number().optional(),
31402
31469
  /** Whether the camera exposes a controllable autofocus toggle
31403
31470
  * (boolean `hasX` per the getOptions availability convention). */
31404
- hasAutofocus: boolean()
31471
+ hasAutofocus: boolean(),
31472
+ /**
31473
+ * How many distinct speeds this camera's NATIVE scale offers.
31474
+ *
31475
+ * The cap's `speed` is normalized 0..1 and each provider maps it down --
31476
+ * Baichuan 1..63, Dahua 1..8, ISAPI's percentage -100..100. The normalized
31477
+ * value hides how coarse that really is: asking an amcrest for `0.37` is
31478
+ * meaningless, because it has eight steps and three of them round to the
31479
+ * same one. A loop that tunes its own gain has to know the granularity of
31480
+ * the knob it is turning.
31481
+ *
31482
+ * `null` = the driver does not know, or the scale is continuous (ONVIF takes
31483
+ * a float). Never a made-up number.
31484
+ */
31485
+ speedSteps: number().int().positive().nullable(),
31486
+ /**
31487
+ * How long ONE `move` pulse runs on this driver, ms.
31488
+ *
31489
+ * `move` is a self-terminating burst everywhere, but every provider hardcoded
31490
+ * its own duration in private -- 200 reolink, 350 amcrest, 500 hikvision,
31491
+ * 1000 onvif -- so no caller could read it. It is the DENOMINATOR of "how far
31492
+ * did the head travel per pulse": without it a measured displacement has no
31493
+ * gain to be divided into.
31494
+ *
31495
+ * `null` = the driver cannot say.
31496
+ */
31497
+ moveImpulseMs: number().int().positive().nullable()
31498
+ });
31499
+ /**
31500
+ * Which preset `goHome` goes to, and WHO decided.
31501
+ *
31502
+ * `goHome` used to be three vendor "conventions" written in three comments --
31503
+ * Reolink preset 0, Hikvision preset '1', Dahua preset 1 -- and on this fleet
31504
+ * not one of the three cameras honours its own:
31505
+ *
31506
+ * 592 Videocamera camera Daniel (reolink) preset 0 holds "stanza"
31507
+ * 1438 Videocamera dispensa (hikvision) preset 1 holds "Credenza"
31508
+ * 3836 Videocamera studio (amcrest) preset 1 holds "Preset1"
31509
+ *
31510
+ * So `goHome` meant "go wherever the operator happened to save in slot 0 or 1",
31511
+ * and two of the three swallowed the failure in a bare `catch`. For autotrack
31512
+ * this is the HOTTEST path -- it runs every time a subject is released -- so it
31513
+ * needs an answer that is true per camera and audible when it is missing.
31514
+ *
31515
+ * `source` is what makes it honest:
31516
+ * - `operator` -- picked in the UI, stored by the provider.
31517
+ * - `firmware` -- the camera itself named a preset `fn: 'home'` (Hikvision's
31518
+ * "Back to origin"), adopted as the default with nothing to configure.
31519
+ * - `native` -- the driver homes WITHOUT a preset at all. ONVIF does:
31520
+ * `goHome` is `ptzAbsoluteMove({x:0, y:0, zoom:0})`. `presetId` is null and
31521
+ * that is not a failure -- there is nothing to pick, and the UI must offer
31522
+ * no picker.
31523
+ * - `none` -- nothing is configured and the camera names nothing.
31524
+ * `goHome` REFUSES rather than moving the head somewhere arbitrary.
31525
+ *
31526
+ * `source === 'none'` is the refusal condition, NOT `presetId === null`: the
31527
+ * native case has no preset and homes perfectly well.
31528
+ */
31529
+ var PtzHomePresetSchema = object({
31530
+ presetId: string().nullable(),
31531
+ source: _enum([
31532
+ "operator",
31533
+ "firmware",
31534
+ "native",
31535
+ "none"
31536
+ ])
31405
31537
  });
31406
31538
  var ptzCapability = {
31407
31539
  name: "ptz",
@@ -31442,7 +31574,66 @@ var ptzCapability = {
31442
31574
  auth: "admin"
31443
31575
  }),
31444
31576
  getOptions: method(object({ deviceId: number() }), PtzOptionsSchema),
31577
+ /**
31578
+ * Move to the camera's configured home preset. THROWS when
31579
+ * `getHomePreset()` resolves to `none` -- a head that did not move must
31580
+ * never look like a head that went home.
31581
+ */
31445
31582
  goHome: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
31583
+ /** Which preset `goHome` targets, and who decided it. */
31584
+ getHomePreset: method(object({ deviceId: number() }), PtzHomePresetSchema),
31585
+ /**
31586
+ * Pin the home preset for this camera. `null` clears the operator's choice
31587
+ * and falls back to whatever the firmware names, or to `none`.
31588
+ */
31589
+ setHomePreset: method(object({
31590
+ deviceId: number(),
31591
+ presetId: string().nullable()
31592
+ }), _void(), {
31593
+ kind: "mutation",
31594
+ auth: "admin"
31595
+ }),
31596
+ /**
31597
+ * Make WHERE THE HEAD IS POINTING RIGHT NOW this camera's home.
31598
+ *
31599
+ * `setHomePreset` takes a preset id, and a preset id is the wrong shape for
31600
+ * the most natural thing an operator does: aim the camera, then say "here".
31601
+ * It is also the only shape a camera whose home is NOT a preset can accept
31602
+ * -- Reolink's home is a guard point, a stored position with no id at all,
31603
+ * and hiding the control for that case (as the UI briefly did) left the one
31604
+ * vendor with a native home unable to set it.
31605
+ *
31606
+ * Each provider does it its own way:
31607
+ * reolink `setGrd` with `needSetPos`, pinning the guard point here
31608
+ * hikvision / amcrest save a preset, then pin it as home
31609
+ * onvif REFUSES -- its home is the PTZ origin and cannot be moved
31610
+ *
31611
+ * A provider that cannot honour it throws rather than silently doing
31612
+ * nothing: an operator who pressed "set home here" and saw no error would
31613
+ * believe it took.
31614
+ */
31615
+ captureHomeHere: method(object({ deviceId: number() }), _void(), {
31616
+ kind: "mutation",
31617
+ auth: "admin"
31618
+ }),
31619
+ /**
31620
+ * How long the camera waits, idle, before returning home on its own --
31621
+ * `null` when it has no such behaviour.
31622
+ *
31623
+ * Reolink's guard point carries one (`timeout`, freely settable in
31624
+ * seconds). It is a real function of the camera that we did not expose at
31625
+ * all, and it belongs next to the home it governs rather than in a vendor
31626
+ * settings page nobody associates with Home.
31627
+ */
31628
+ getHomeReturnSeconds: method(object({ deviceId: number() }), number().int().nullable()),
31629
+ /** Set the idle-return delay. Refused by a camera that has no such timer. */
31630
+ setHomeReturnSeconds: method(object({
31631
+ deviceId: number(),
31632
+ seconds: number().int().min(0).max(3600)
31633
+ }), _void(), {
31634
+ kind: "mutation",
31635
+ auth: "admin"
31636
+ }),
31446
31637
  /**
31447
31638
  * Pull the current PTZ position. Redundant with the auto-injected
31448
31639
  * `getStatus` method (see `status` below); kept for callers that
@@ -41156,6 +41347,12 @@ Object.freeze({
41156
41347
  addonId: null,
41157
41348
  access: "create"
41158
41349
  },
41350
+ "ptz.captureHomeHere": {
41351
+ capName: "ptz",
41352
+ capScope: "device",
41353
+ addonId: null,
41354
+ access: "create"
41355
+ },
41159
41356
  "ptz.continuousMove": {
41160
41357
  capName: "ptz",
41161
41358
  capScope: "device",
@@ -41168,6 +41365,18 @@ Object.freeze({
41168
41365
  addonId: null,
41169
41366
  access: "delete"
41170
41367
  },
41368
+ "ptz.getHomePreset": {
41369
+ capName: "ptz",
41370
+ capScope: "device",
41371
+ addonId: null,
41372
+ access: "view"
41373
+ },
41374
+ "ptz.getHomeReturnSeconds": {
41375
+ capName: "ptz",
41376
+ capScope: "device",
41377
+ addonId: null,
41378
+ access: "view"
41379
+ },
41171
41380
  "ptz.getOptions": {
41172
41381
  capName: "ptz",
41173
41382
  capScope: "device",
@@ -41216,6 +41425,18 @@ Object.freeze({
41216
41425
  addonId: null,
41217
41426
  access: "create"
41218
41427
  },
41428
+ "ptz.setHomePreset": {
41429
+ capName: "ptz",
41430
+ capScope: "device",
41431
+ addonId: null,
41432
+ access: "create"
41433
+ },
41434
+ "ptz.setHomeReturnSeconds": {
41435
+ capName: "ptz",
41436
+ capScope: "device",
41437
+ addonId: null,
41438
+ access: "create"
41439
+ },
41219
41440
  "ptz.stop": {
41220
41441
  capName: "ptz",
41221
41442
  capScope: "device",
@@ -44379,6 +44600,11 @@ Object.freeze({
44379
44600
  form: "single",
44380
44601
  optional: false
44381
44602
  }],
44603
+ "ptz.captureHomeHere": [{
44604
+ name: "deviceId",
44605
+ form: "single",
44606
+ optional: false
44607
+ }],
44382
44608
  "ptz.continuousMove": [{
44383
44609
  name: "deviceId",
44384
44610
  form: "single",
@@ -44389,6 +44615,16 @@ Object.freeze({
44389
44615
  form: "single",
44390
44616
  optional: false
44391
44617
  }],
44618
+ "ptz.getHomePreset": [{
44619
+ name: "deviceId",
44620
+ form: "single",
44621
+ optional: false
44622
+ }],
44623
+ "ptz.getHomeReturnSeconds": [{
44624
+ name: "deviceId",
44625
+ form: "single",
44626
+ optional: false
44627
+ }],
44392
44628
  "ptz.getOptions": [{
44393
44629
  name: "deviceId",
44394
44630
  form: "single",
@@ -44429,6 +44665,16 @@ Object.freeze({
44429
44665
  form: "single",
44430
44666
  optional: false
44431
44667
  }],
44668
+ "ptz.setHomePreset": [{
44669
+ name: "deviceId",
44670
+ form: "single",
44671
+ optional: false
44672
+ }],
44673
+ "ptz.setHomeReturnSeconds": [{
44674
+ name: "deviceId",
44675
+ form: "single",
44676
+ optional: false
44677
+ }],
44432
44678
  "ptz.stop": [{
44433
44679
  name: "deviceId",
44434
44680
  form: "single",
@@ -49737,6 +49983,110 @@ function buildRawState(reader) {
49737
49983
  };
49738
49984
  }
49739
49985
  //#endregion
49986
+ //#region src/isapi-ptz-speed.ts
49987
+ /**
49988
+ * Turn the cap's normalized direction + magnitude into an ISAPI continuous
49989
+ * speed.
49990
+ *
49991
+ * The `ptz` cap splits the two: `pan`/`tilt`/`zoom` are a DIRECTION in -1..1,
49992
+ * and `speed` is the magnitude in 0..1. reolink maps them to Baichuan 1..63,
49993
+ * amcrest to Dahua 1..8 — and hikvision used to map them to nothing at all. It
49994
+ * dropped `speed` before it left the provider and handed the raw direction to
49995
+ * `/ISAPI/PTZCtrl/channels/{cam}/continuous`, whose `<pan>` is a speed
49996
+ * PERCENTAGE in -100..100.
49997
+ *
49998
+ * So `usePTZ` sending its d-pad default (`pan: 1, speed: 0.5`) reached the
49999
+ * dispensa camera as `<pan>1</pan>`: one percent of the camera's speed. A
50000
+ * request at less than half magnitude rounded to `0` and produced no motion at
50001
+ * all.
50002
+ *
50003
+ * An absent `speed` is mid-scale, never zero — reolink defaults to 32 of 63 for
50004
+ * the same reason. A caller who did not state a magnitude asked to move, not to
50005
+ * stand still.
50006
+ */
50007
+ var MID_SCALE = .5;
50008
+ function isapiSpeedFor(direction, speed) {
50009
+ const d = direction ?? 0;
50010
+ if (!Number.isFinite(d) || d === 0) return 0;
50011
+ const magnitude = speed === void 0 || !Number.isFinite(speed) ? MID_SCALE : Math.abs(speed);
50012
+ const percent = Math.round(Math.sign(d) * Math.min(1, Math.abs(d)) * Math.min(1, magnitude) * 100);
50013
+ return Math.max(-100, Math.min(100, percent));
50014
+ }
50015
+ //#endregion
50016
+ //#region src/ptz-preset-functions.ts
50017
+ var RESERVED = new Map([
50018
+ ["34", "home"],
50019
+ ["39", "day-mode"],
50020
+ ["40", "night-mode"],
50021
+ ["46", "day-night-auto"],
50022
+ ["92", "set-limits"],
50023
+ ["93", "save-limits"],
50024
+ ["94", "reboot"],
50025
+ ["95", "osd-menu"]
50026
+ ]);
50027
+ /** The firmware function this id performs, or `null` for a stored position. */
50028
+ function presetFunctionFor(presetId) {
50029
+ return RESERVED.get(presetId) ?? null;
50030
+ }
50031
+ /**
50032
+ * Whether `savePreset` / `deletePreset` may target this id.
50033
+ *
50034
+ * Anything outside the known band stays writable: an undocumented firmware
50035
+ * must be allowed to refuse for itself rather than be guessed at here.
50036
+ */
50037
+ function isPresetWritable(presetId) {
50038
+ return !RESERVED.has(presetId);
50039
+ }
50040
+ /**
50041
+ * Refuse a write to a reserved id, naming the function that owns it.
50042
+ *
50043
+ * Extracted so the REFUSAL itself is assertable. With the check inline in
50044
+ * `savePreset` / `deletePreset`, deleting it left every test green -- the table
50045
+ * spec proves what the band IS, not that anything consults it.
50046
+ */
50047
+ function assertPresetWritable(presetId, action) {
50048
+ const fn = presetFunctionFor(presetId);
50049
+ if (fn === null) return;
50050
+ throw new Error(`Preset ${presetId} is a firmware function on this camera (${fn}) and cannot be ${action}` + (action === "overwritten" ? " — pick an unreserved id" : ""));
50051
+ }
50052
+ //#endregion
50053
+ //#region src/free-preset-slot.ts
50054
+ /**
50055
+ * The lowest preset id this camera will actually accept a write on.
50056
+ *
50057
+ * "Set home here" has to land somewhere, and on a camera whose home IS a preset
50058
+ * that means claiming a slot. Two things make the choice non-obvious:
50059
+ *
50060
+ * - the reserved FUNCTION band shares the preset namespace (34 "Back to
50061
+ * origin", 94 "Remote reboot", 95 "Call OSD menu" and friends), and writing
50062
+ * one answers HTTP 500;
50063
+ * - the operator's own presets must not be overwritten, ever. Losing
50064
+ * "Credenza" to make room for a home is a worse trade than refusing.
50065
+ *
50066
+ * So: the first id that is neither taken nor reserved, within the camera's
50067
+ * advertised budget. `null` when there is no room, and the caller must then
50068
+ * REFUSE rather than pick a victim.
50069
+ */
50070
+ function findFreePresetSlot(taken, maxPresets) {
50071
+ const used = new Set(taken.map((p) => p.id));
50072
+ const ceiling = maxPresets === void 0 ? 32 : Math.min(maxPresets, 300);
50073
+ for (let id = 1; id <= ceiling; id += 1) {
50074
+ const candidate = String(id);
50075
+ if (used.has(candidate)) continue;
50076
+ if (!isPresetWritable(candidate)) continue;
50077
+ return candidate;
50078
+ }
50079
+ return null;
50080
+ }
50081
+ /**
50082
+ * The name written on a preset claimed by "set home here".
50083
+ *
50084
+ * Named rather than left a literal so the slot is recognisable in the camera's
50085
+ * own web UI — an operator who finds an unexplained preset has no way to know
50086
+ * it is ours.
50087
+ */
50088
+ var HOME_PRESET_NAME = "CamStack Home";
50089
+ //#endregion
49740
50090
  //#region src/schema.ts
49741
50091
  /**
49742
50092
  * Single source of truth for the addon id used in event-source tags +
@@ -50055,6 +50405,17 @@ var hikvisionCameraSchema = object({
50055
50405
  */
50056
50406
  snapshotChannelId: string().default("").describe("ISAPI channel used for snapshots (empty = main stream)"),
50057
50407
  /**
50408
+ * Which preset `ptz.goHome` targets on THIS camera.
50409
+ *
50410
+ * Empty = no operator choice. The provider then falls back to the preset the
50411
+ * FIRMWARE names as home (Hikvision returns id 34 "Back to origin" in its
50412
+ * reserved band), and only if that is absent too does `goHome` refuse.
50413
+ *
50414
+ * It has to be stored because the vendor "convention" is wrong here: the code
50415
+ * went to preset '1', and on the dispensa camera preset 1 is "Credenza".
50416
+ */
50417
+ homePresetId: string().default("").describe("Preset id ptz.goHome targets (empty = use the firmware home)"),
50418
+ /**
50058
50419
  * Motion detection master switch + sensitivity (0..100). Maps to
50059
50420
  * ISAPI `/ISAPI/System/Video/inputs/channels/{cam}/motionDetection`.
50060
50421
  * Disabling this stops the camera from emitting `<eventType>VMD</eventType>`
@@ -51092,6 +51453,41 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51092
51453
  ...slice
51093
51454
  } });
51094
51455
  }
51456
+ /**
51457
+ * Which preset `goHome` targets, and who decided it.
51458
+ *
51459
+ * The operator's pick wins. With none, the FIRMWARE's own home is adopted --
51460
+ * Hikvision names id 34 "Back to origin" in its reserved band -- so a camera
51461
+ * that already knows where home is needs nothing configured. Only when both
51462
+ * are absent is the answer `none`, and `goHome` then refuses rather than
51463
+ * moving the head to an arbitrary slot.
51464
+ *
51465
+ * A failed preset read yields `none`, never a guess: an unreadable camera is
51466
+ * not evidence that preset 34 exists (D393).
51467
+ */
51468
+ async resolveHomePreset() {
51469
+ const chosen = this.config.get("homePresetId") ?? "";
51470
+ if (chosen !== "") return {
51471
+ presetId: chosen,
51472
+ source: "operator"
51473
+ };
51474
+ try {
51475
+ const firmwareHome = (await this.ensureClient().listPtzPresets(1)).find((preset) => presetFunctionFor(preset.id) === "home");
51476
+ if (firmwareHome !== void 0) return {
51477
+ presetId: firmwareHome.id,
51478
+ source: "firmware"
51479
+ };
51480
+ } catch (err) {
51481
+ this.ctx.logger.warn("hikvision home preset lookup failed — goHome will refuse", {
51482
+ tags: { deviceId: this.id },
51483
+ meta: { error: err instanceof Error ? err.message : String(err) }
51484
+ });
51485
+ }
51486
+ return {
51487
+ presetId: null,
51488
+ source: "none"
51489
+ };
51490
+ }
51095
51491
  /** Re-read the image-tuning snapshot from the camera and persist it.
51096
51492
  * Best-effort: a refresh failure is logged and swallowed so a save
51097
51493
  * never throws. Called after an Image-section push in
@@ -51368,12 +51764,12 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51368
51764
  registerPtzCap(cameraNumber) {
51369
51765
  const isapi = this.ensureClient();
51370
51766
  this.ctx.registerNativeCap(ptzCapability, {
51371
- move: async ({ deviceId, pan, tilt, zoom }) => {
51767
+ move: async ({ deviceId, pan, tilt, zoom, speed }) => {
51372
51768
  if (deviceId !== this.id) return;
51373
51769
  await isapi.ptzContinuous(cameraNumber, {
51374
- pan,
51375
- tilt,
51376
- zoom
51770
+ pan: isapiSpeedFor(pan, speed),
51771
+ tilt: isapiSpeedFor(tilt, speed),
51772
+ zoom: isapiSpeedFor(zoom, speed)
51377
51773
  });
51378
51774
  await new Promise((r) => setTimeout(r, 500));
51379
51775
  await isapi.ptzContinuous(cameraNumber, {
@@ -51382,12 +51778,12 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51382
51778
  zoom: 0
51383
51779
  });
51384
51780
  },
51385
- continuousMove: async ({ deviceId, pan, tilt, zoom }) => {
51781
+ continuousMove: async ({ deviceId, pan, tilt, zoom, speed }) => {
51386
51782
  if (deviceId !== this.id) return;
51387
51783
  await isapi.ptzContinuous(cameraNumber, {
51388
- pan,
51389
- tilt,
51390
- zoom
51784
+ pan: isapiSpeedFor(pan, speed),
51785
+ tilt: isapiSpeedFor(tilt, speed),
51786
+ zoom: isapiSpeedFor(zoom, speed)
51391
51787
  });
51392
51788
  },
51393
51789
  stop: async ({ deviceId }) => {
@@ -51402,7 +51798,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51402
51798
  if (deviceId !== this.id) return [];
51403
51799
  return (await isapi.listPtzPresets(cameraNumber)).map((p) => ({
51404
51800
  id: p.id,
51405
- name: p.name ?? `Preset ${p.id}`
51801
+ name: p.name ?? `Preset ${p.id}`,
51802
+ fn: presetFunctionFor(p.id),
51803
+ writable: isPresetWritable(p.id)
51406
51804
  }));
51407
51805
  },
51408
51806
  goToPreset: async ({ deviceId, presetId }) => {
@@ -51411,10 +51809,12 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51411
51809
  },
51412
51810
  savePreset: async ({ deviceId, presetId, name }) => {
51413
51811
  if (deviceId !== this.id) return;
51812
+ assertPresetWritable(presetId, "overwritten");
51414
51813
  await isapi.savePtzPreset(cameraNumber, presetId, name);
51415
51814
  },
51416
51815
  deletePreset: async ({ deviceId, presetId }) => {
51417
51816
  if (deviceId !== this.id) return;
51817
+ assertPresetWritable(presetId, "deleted");
51418
51818
  await isapi.deletePtzPreset(cameraNumber, presetId);
51419
51819
  },
51420
51820
  getOptions: async ({ deviceId }) => {
@@ -51423,7 +51823,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51423
51823
  hasTilt: false,
51424
51824
  hasZoom: false,
51425
51825
  supportsPresets: false,
51426
- hasAutofocus: false
51826
+ hasAutofocus: false,
51827
+ speedSteps: 100,
51828
+ moveImpulseMs: 500
51427
51829
  };
51428
51830
  const caps = await isapi.getPtzCapabilities(cameraNumber);
51429
51831
  if (!caps) return {
@@ -51431,7 +51833,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51431
51833
  hasTilt: false,
51432
51834
  hasZoom: false,
51433
51835
  supportsPresets: false,
51434
- hasAutofocus: false
51836
+ hasAutofocus: false,
51837
+ speedSteps: 100,
51838
+ moveImpulseMs: 500
51435
51839
  };
51436
51840
  const maxPresets = caps.maxPresetCount ?? void 0;
51437
51841
  return {
@@ -51440,25 +51844,61 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
51440
51844
  hasZoom: caps.hasZoom,
51441
51845
  supportsPresets: maxPresets === void 0 ? true : maxPresets > 0,
51442
51846
  hasAutofocus: false,
51847
+ speedSteps: 100,
51848
+ moveImpulseMs: 500,
51443
51849
  ...maxPresets !== void 0 ? { maxPresets } : {}
51444
51850
  };
51445
51851
  },
51446
51852
  goHome: async ({ deviceId }) => {
51447
51853
  if (deviceId !== this.id) return;
51448
- await isapi.ptzGoToPreset(cameraNumber, "1");
51854
+ const home = await this.resolveHomePreset();
51855
+ if (home.source === "none" || home.presetId === null) throw new Error("No home preset configured for this camera — set one in the PTZ settings");
51856
+ await isapi.ptzGoToPreset(cameraNumber, home.presetId);
51857
+ },
51858
+ getHomePreset: async ({ deviceId }) => {
51859
+ if (deviceId !== this.id) return {
51860
+ presetId: null,
51861
+ source: "none"
51862
+ };
51863
+ return this.resolveHomePreset();
51864
+ },
51865
+ captureHomeHere: async ({ deviceId }) => {
51866
+ if (deviceId !== this.id) return;
51867
+ const [presets, caps] = await Promise.all([isapi.listPtzPresets(cameraNumber), isapi.getPtzCapabilities(cameraNumber)]);
51868
+ const slot = findFreePresetSlot(presets.map((p) => ({
51869
+ id: p.id,
51870
+ writable: isPresetWritable(p.id)
51871
+ })), caps?.maxPresetCount ?? void 0);
51872
+ if (slot === null) throw new Error("No free preset slot on this camera to store a home position — free one first");
51873
+ await isapi.savePtzPreset(cameraNumber, slot, HOME_PRESET_NAME);
51874
+ await this.config.setAll({ homePresetId: slot });
51875
+ this.ctx.logger.info("hikvision: home pinned to the current position", {
51876
+ tags: { deviceId: this.id },
51877
+ meta: { presetId: slot }
51878
+ });
51879
+ },
51880
+ getHomeReturnSeconds: async () => {
51881
+ return null;
51882
+ },
51883
+ setHomeReturnSeconds: async () => {
51884
+ throw new Error("Hikvision: the idle-return timer is not wired on this driver");
51885
+ },
51886
+ setHomePreset: async ({ deviceId, presetId }) => {
51887
+ if (deviceId !== this.id) return;
51888
+ await this.config.setAll({ homePresetId: presetId ?? "" });
51449
51889
  },
51450
51890
  getPosition: async () => {
51451
51891
  return {
51452
- pan: 0,
51453
- tilt: 0,
51454
- zoom: 0
51892
+ pan: null,
51893
+ tilt: null,
51894
+ zoom: null
51455
51895
  };
51456
51896
  },
51457
51897
  getStatus: async () => {
51458
51898
  return {
51459
- pan: 0,
51460
- tilt: 0,
51461
- zoom: 0,
51899
+ pan: null,
51900
+ tilt: null,
51901
+ zoom: null,
51462
51902
  autofocus: false
51463
51903
  };
51464
51904
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-hikvision",
3
- "version": "1.2.118",
3
+ "version": "1.2.120",
4
4
  "description": "Hikvision camera device provider addon for CamStack — ISAPI over HTTP(S) with digest auth (snapshot, alarm stream, RTSP discovery)",
5
5
  "keywords": [
6
6
  "camstack",