@camstack/addon-provider-amcrest 0.2.112 → 0.2.114

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 +347 -15
  2. package/dist/addon.mjs +347 -15
  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).
@@ -31059,14 +31059,81 @@ function summarisePrivacyAudio(profiles) {
31059
31059
  if (profiles.length === 0) return null;
31060
31060
  return profiles.some((p) => p.audioEnabled);
31061
31061
  }
31062
+ /**
31063
+ * What a preset id DOES on this camera.
31064
+ *
31065
+ * On several PTZ firmwares the preset namespace is shared: low ids are stored
31066
+ * positions, a reserved band triggers camera FUNCTIONS. The dispensa camera
31067
+ * (Hikvision) returns its own reserved band in the preset list, named by the
31068
+ * firmware:
31069
+ *
31070
+ * 34 Back to origin · 39 Day mode · 40 Night mode · 46 Day/Night Auto Mode
31071
+ * 92 Set manual limits · 93 Save manual limits · 94 Remote reboot
31072
+ * 95 Call OSD menu
31073
+ *
31074
+ * We used to hand all of these to the UI as ordinary presets. Saving over one
31075
+ * is what produced `PUT /ISAPI/PTZCtrl/channels/1/presets/34 -> HTTP 500`: the
31076
+ * camera refusing to let "Back to origin" be overwritten, surfaced to the
31077
+ * operator as a transport failure.
31078
+ *
31079
+ * A function preset stays fully usable as a DESTINATION -- `goToPreset('34')`
31080
+ * is the single most useful thing that camera can do. Only `savePreset` and
31081
+ * `deletePreset` are gated.
31082
+ */
31083
+ var PtzPresetFunctionSchema = _enum([
31084
+ "home",
31085
+ "day-mode",
31086
+ "night-mode",
31087
+ "day-night-auto",
31088
+ "set-limits",
31089
+ "save-limits",
31090
+ "reboot",
31091
+ "osd-menu"
31092
+ ]);
31062
31093
  var PtzPresetSchema = object({
31063
31094
  id: string(),
31064
- name: string()
31095
+ name: string(),
31096
+ /**
31097
+ * The firmware function this id performs, when the provider KNOWS the id is
31098
+ * reserved. `null` = an ordinary stored position, OR a reserved id this
31099
+ * provider does not recognise -- the two are told apart by `writable`.
31100
+ */
31101
+ fn: PtzPresetFunctionSchema.nullable(),
31102
+ /**
31103
+ * Whether `savePreset` / `deletePreset` may target this id. A provider sets
31104
+ * it false only for a band it KNOWS is reserved; where the firmware is not
31105
+ * documented it stays true and a refusal comes from the camera, named.
31106
+ */
31107
+ writable: boolean()
31065
31108
  });
31109
+ /**
31110
+ * Where the head is pointing -- per axis, and `null` when the driver cannot
31111
+ * read it.
31112
+ *
31113
+ * It used to be three REQUIRED numbers, which left a provider that cannot read
31114
+ * position no way to say so. All four said `0`:
31115
+ *
31116
+ * reolink `return { pan: 0, tilt: 0, zoom: 0 }` "until a position-query
31117
+ * path lands upstream"
31118
+ * hikvision stub, "firmware-dependent and frequently absent"
31119
+ * amcrest stub, "no generic absolute-position read for this model family"
31120
+ * onvif throws
31121
+ *
31122
+ * So every consumer asking where a camera points was told "perfectly centred"
31123
+ * by four cameras that had never looked. That is D393 -- a measurement that
31124
+ * FAILED is `null`, never `0`, and the type says so all the way to the
31125
+ * decision -- the same shape as the unreadable `statfs` folded into "0 bytes of
31126
+ * headroom", which evacuated a healthy disk.
31127
+ *
31128
+ * D393 also says to check for a narrower structural TWIN of the result type.
31129
+ * There is one: `PtzStatusSchema` extends this, so the lie had already
31130
+ * propagated into `getStatus`, which is the surface `getPosition`'s own comment
31131
+ * tells callers to migrate to.
31132
+ */
31066
31133
  var PtzPositionSchema = object({
31067
- pan: number(),
31068
- tilt: number(),
31069
- zoom: number()
31134
+ pan: number().nullable(),
31135
+ tilt: number().nullable(),
31136
+ zoom: number().nullable()
31070
31137
  });
31071
31138
  var PtzMoveCommandSchema = object({
31072
31139
  pan: number().optional(),
@@ -31088,7 +31155,72 @@ var PtzOptionsSchema = object({
31088
31155
  maxPresets: number().optional(),
31089
31156
  /** Whether the camera exposes a controllable autofocus toggle
31090
31157
  * (boolean `hasX` per the getOptions availability convention). */
31091
- hasAutofocus: boolean()
31158
+ hasAutofocus: boolean(),
31159
+ /**
31160
+ * How many distinct speeds this camera's NATIVE scale offers.
31161
+ *
31162
+ * The cap's `speed` is normalized 0..1 and each provider maps it down --
31163
+ * Baichuan 1..63, Dahua 1..8, ISAPI's percentage -100..100. The normalized
31164
+ * value hides how coarse that really is: asking an amcrest for `0.37` is
31165
+ * meaningless, because it has eight steps and three of them round to the
31166
+ * same one. A loop that tunes its own gain has to know the granularity of
31167
+ * the knob it is turning.
31168
+ *
31169
+ * `null` = the driver does not know, or the scale is continuous (ONVIF takes
31170
+ * a float). Never a made-up number.
31171
+ */
31172
+ speedSteps: number().int().positive().nullable(),
31173
+ /**
31174
+ * How long ONE `move` pulse runs on this driver, ms.
31175
+ *
31176
+ * `move` is a self-terminating burst everywhere, but every provider hardcoded
31177
+ * its own duration in private -- 200 reolink, 350 amcrest, 500 hikvision,
31178
+ * 1000 onvif -- so no caller could read it. It is the DENOMINATOR of "how far
31179
+ * did the head travel per pulse": without it a measured displacement has no
31180
+ * gain to be divided into.
31181
+ *
31182
+ * `null` = the driver cannot say.
31183
+ */
31184
+ moveImpulseMs: number().int().positive().nullable()
31185
+ });
31186
+ /**
31187
+ * Which preset `goHome` goes to, and WHO decided.
31188
+ *
31189
+ * `goHome` used to be three vendor "conventions" written in three comments --
31190
+ * Reolink preset 0, Hikvision preset '1', Dahua preset 1 -- and on this fleet
31191
+ * not one of the three cameras honours its own:
31192
+ *
31193
+ * 592 Videocamera camera Daniel (reolink) preset 0 holds "stanza"
31194
+ * 1438 Videocamera dispensa (hikvision) preset 1 holds "Credenza"
31195
+ * 3836 Videocamera studio (amcrest) preset 1 holds "Preset1"
31196
+ *
31197
+ * So `goHome` meant "go wherever the operator happened to save in slot 0 or 1",
31198
+ * and two of the three swallowed the failure in a bare `catch`. For autotrack
31199
+ * this is the HOTTEST path -- it runs every time a subject is released -- so it
31200
+ * needs an answer that is true per camera and audible when it is missing.
31201
+ *
31202
+ * `source` is what makes it honest:
31203
+ * - `operator` -- picked in the UI, stored by the provider.
31204
+ * - `firmware` -- the camera itself named a preset `fn: 'home'` (Hikvision's
31205
+ * "Back to origin"), adopted as the default with nothing to configure.
31206
+ * - `native` -- the driver homes WITHOUT a preset at all. ONVIF does:
31207
+ * `goHome` is `ptzAbsoluteMove({x:0, y:0, zoom:0})`. `presetId` is null and
31208
+ * that is not a failure -- there is nothing to pick, and the UI must offer
31209
+ * no picker.
31210
+ * - `none` -- nothing is configured and the camera names nothing.
31211
+ * `goHome` REFUSES rather than moving the head somewhere arbitrary.
31212
+ *
31213
+ * `source === 'none'` is the refusal condition, NOT `presetId === null`: the
31214
+ * native case has no preset and homes perfectly well.
31215
+ */
31216
+ var PtzHomePresetSchema = object({
31217
+ presetId: string().nullable(),
31218
+ source: _enum([
31219
+ "operator",
31220
+ "firmware",
31221
+ "native",
31222
+ "none"
31223
+ ])
31092
31224
  });
31093
31225
  var ptzCapability = {
31094
31226
  name: "ptz",
@@ -31129,7 +31261,66 @@ var ptzCapability = {
31129
31261
  auth: "admin"
31130
31262
  }),
31131
31263
  getOptions: method(object({ deviceId: number() }), PtzOptionsSchema),
31264
+ /**
31265
+ * Move to the camera's configured home preset. THROWS when
31266
+ * `getHomePreset()` resolves to `none` -- a head that did not move must
31267
+ * never look like a head that went home.
31268
+ */
31132
31269
  goHome: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
31270
+ /** Which preset `goHome` targets, and who decided it. */
31271
+ getHomePreset: method(object({ deviceId: number() }), PtzHomePresetSchema),
31272
+ /**
31273
+ * Pin the home preset for this camera. `null` clears the operator's choice
31274
+ * and falls back to whatever the firmware names, or to `none`.
31275
+ */
31276
+ setHomePreset: method(object({
31277
+ deviceId: number(),
31278
+ presetId: string().nullable()
31279
+ }), _void(), {
31280
+ kind: "mutation",
31281
+ auth: "admin"
31282
+ }),
31283
+ /**
31284
+ * Make WHERE THE HEAD IS POINTING RIGHT NOW this camera's home.
31285
+ *
31286
+ * `setHomePreset` takes a preset id, and a preset id is the wrong shape for
31287
+ * the most natural thing an operator does: aim the camera, then say "here".
31288
+ * It is also the only shape a camera whose home is NOT a preset can accept
31289
+ * -- Reolink's home is a guard point, a stored position with no id at all,
31290
+ * and hiding the control for that case (as the UI briefly did) left the one
31291
+ * vendor with a native home unable to set it.
31292
+ *
31293
+ * Each provider does it its own way:
31294
+ * reolink `setGrd` with `needSetPos`, pinning the guard point here
31295
+ * hikvision / amcrest save a preset, then pin it as home
31296
+ * onvif REFUSES -- its home is the PTZ origin and cannot be moved
31297
+ *
31298
+ * A provider that cannot honour it throws rather than silently doing
31299
+ * nothing: an operator who pressed "set home here" and saw no error would
31300
+ * believe it took.
31301
+ */
31302
+ captureHomeHere: method(object({ deviceId: number() }), _void(), {
31303
+ kind: "mutation",
31304
+ auth: "admin"
31305
+ }),
31306
+ /**
31307
+ * How long the camera waits, idle, before returning home on its own --
31308
+ * `null` when it has no such behaviour.
31309
+ *
31310
+ * Reolink's guard point carries one (`timeout`, freely settable in
31311
+ * seconds). It is a real function of the camera that we did not expose at
31312
+ * all, and it belongs next to the home it governs rather than in a vendor
31313
+ * settings page nobody associates with Home.
31314
+ */
31315
+ getHomeReturnSeconds: method(object({ deviceId: number() }), number().int().nullable()),
31316
+ /** Set the idle-return delay. Refused by a camera that has no such timer. */
31317
+ setHomeReturnSeconds: method(object({
31318
+ deviceId: number(),
31319
+ seconds: number().int().min(0).max(3600)
31320
+ }), _void(), {
31321
+ kind: "mutation",
31322
+ auth: "admin"
31323
+ }),
31133
31324
  /**
31134
31325
  * Pull the current PTZ position. Redundant with the auto-injected
31135
31326
  * `getStatus` method (see `status` below); kept for callers that
@@ -40791,6 +40982,12 @@ Object.freeze({
40791
40982
  addonId: null,
40792
40983
  access: "create"
40793
40984
  },
40985
+ "ptz.captureHomeHere": {
40986
+ capName: "ptz",
40987
+ capScope: "device",
40988
+ addonId: null,
40989
+ access: "create"
40990
+ },
40794
40991
  "ptz.continuousMove": {
40795
40992
  capName: "ptz",
40796
40993
  capScope: "device",
@@ -40803,6 +41000,18 @@ Object.freeze({
40803
41000
  addonId: null,
40804
41001
  access: "delete"
40805
41002
  },
41003
+ "ptz.getHomePreset": {
41004
+ capName: "ptz",
41005
+ capScope: "device",
41006
+ addonId: null,
41007
+ access: "view"
41008
+ },
41009
+ "ptz.getHomeReturnSeconds": {
41010
+ capName: "ptz",
41011
+ capScope: "device",
41012
+ addonId: null,
41013
+ access: "view"
41014
+ },
40806
41015
  "ptz.getOptions": {
40807
41016
  capName: "ptz",
40808
41017
  capScope: "device",
@@ -40851,6 +41060,18 @@ Object.freeze({
40851
41060
  addonId: null,
40852
41061
  access: "create"
40853
41062
  },
41063
+ "ptz.setHomePreset": {
41064
+ capName: "ptz",
41065
+ capScope: "device",
41066
+ addonId: null,
41067
+ access: "create"
41068
+ },
41069
+ "ptz.setHomeReturnSeconds": {
41070
+ capName: "ptz",
41071
+ capScope: "device",
41072
+ addonId: null,
41073
+ access: "create"
41074
+ },
40854
41075
  "ptz.stop": {
40855
41076
  capName: "ptz",
40856
41077
  capScope: "device",
@@ -44014,6 +44235,11 @@ Object.freeze({
44014
44235
  form: "single",
44015
44236
  optional: false
44016
44237
  }],
44238
+ "ptz.captureHomeHere": [{
44239
+ name: "deviceId",
44240
+ form: "single",
44241
+ optional: false
44242
+ }],
44017
44243
  "ptz.continuousMove": [{
44018
44244
  name: "deviceId",
44019
44245
  form: "single",
@@ -44024,6 +44250,16 @@ Object.freeze({
44024
44250
  form: "single",
44025
44251
  optional: false
44026
44252
  }],
44253
+ "ptz.getHomePreset": [{
44254
+ name: "deviceId",
44255
+ form: "single",
44256
+ optional: false
44257
+ }],
44258
+ "ptz.getHomeReturnSeconds": [{
44259
+ name: "deviceId",
44260
+ form: "single",
44261
+ optional: false
44262
+ }],
44027
44263
  "ptz.getOptions": [{
44028
44264
  name: "deviceId",
44029
44265
  form: "single",
@@ -44064,6 +44300,16 @@ Object.freeze({
44064
44300
  form: "single",
44065
44301
  optional: false
44066
44302
  }],
44303
+ "ptz.setHomePreset": [{
44304
+ name: "deviceId",
44305
+ form: "single",
44306
+ optional: false
44307
+ }],
44308
+ "ptz.setHomeReturnSeconds": [{
44309
+ name: "deviceId",
44310
+ form: "single",
44311
+ optional: false
44312
+ }],
44067
44313
  "ptz.stop": [{
44068
44314
  name: "deviceId",
44069
44315
  form: "single",
@@ -46139,6 +46385,17 @@ var amcrestDeviceCacheSchema = object({
46139
46385
  * carries only the technical connection knobs.
46140
46386
  */
46141
46387
  var amcrestCameraSchema = object({
46388
+ /**
46389
+ * Which preset `ptz.goHome` targets on THIS camera.
46390
+ *
46391
+ * Empty = nothing configured, and `goHome` then REFUSES. It used to go to a
46392
+ * hardcoded slot on a vendor "convention" this fleet contradicts: reolink
46393
+ * went to preset 0, which on device 592 holds "stanza"; amcrest went to
46394
+ * preset 1, which on device 3836 holds "Preset1". Both swallowed the failure
46395
+ * in a bare catch, so a head that never moved was indistinguishable from a
46396
+ * head that went home.
46397
+ */
46398
+ homePresetId: string().default("").describe("Preset id ptz.goHome targets (empty = goHome refuses)"),
46142
46399
  host: string().describe("Camera IP / hostname"),
46143
46400
  port: number().int().min(1).max(65535).default(80).describe("HTTP CGI port"),
46144
46401
  https: boolean().default(false).describe("Use HTTPS for CGI"),
@@ -46246,6 +46503,24 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46246
46503
  get channel() {
46247
46504
  return this.config.get("channel") ?? 1;
46248
46505
  }
46506
+ /**
46507
+ * Which preset `goHome` targets, and who decided it.
46508
+ *
46509
+ * Dahua names no home of its own that we have probed, so the operator's pick
46510
+ * is the only answer. With none, `none` -- and `goHome` refuses rather than
46511
+ * moving the head to whatever happens to sit in slot 1.
46512
+ */
46513
+ async resolveHomePreset() {
46514
+ const chosen = this.config.get("homePresetId") ?? "";
46515
+ if (chosen === "") return {
46516
+ presetId: null,
46517
+ source: "none"
46518
+ };
46519
+ return {
46520
+ presetId: chosen,
46521
+ source: "operator"
46522
+ };
46523
+ }
46249
46524
  ensureClient() {
46250
46525
  if (this.client) return this.client;
46251
46526
  this.client = this.buildClient();
@@ -46751,6 +47026,21 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46751
47026
  * auto-stop fires. The `usePTZ` UI hook already issues its own paired
46752
47027
  * `stop` after a client-side pulse window; this timer is a backstop so
46753
47028
  * a lone `move` (no following `stop`) can't leave the head gliding. */
47029
+ /**
47030
+ * Dahua's native PTZ speed scale is 1..8; `resolvePtzCodes` maps the cap's
47031
+ * normalized 0..1 onto it. Eight steps is COARSE -- three neighbouring
47032
+ * normalized requests round to the same one -- which is exactly why the
47033
+ * granularity is reported through `getOptions` instead of being hidden
47034
+ * behind a 0..1 float.
47035
+ */
47036
+ /** Dahua exposes 25 fixed preset slots, 1-based. */
47037
+ static DAHUA_MAX_PRESETS = 25;
47038
+ static DAHUA_SPEED_STEPS = 8;
47039
+ /**
47040
+ * How long one `ptz.move` pulse runs, ms. The DENOMINATOR of "how far did
47041
+ * the head travel per pulse", so it leaves the provider through
47042
+ * `getOptions` rather than staying a private literal.
47043
+ */
46754
47044
  static PTZ_PULSE_MS = 350;
46755
47045
  /** Short per-call deadline (ms) for the PTZ start/stop CGI so a slow or
46756
47046
  * blocked camera round-trip fails fast instead of exceeding the
@@ -46876,7 +47166,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46876
47166
  if (deviceId !== this.id) return [];
46877
47167
  return (await this.ensureClient().getPtzPresets(this.channel)).map((p) => ({
46878
47168
  id: String(p.id),
46879
- name: p.name
47169
+ name: p.name,
47170
+ fn: null,
47171
+ writable: true
46880
47172
  }));
46881
47173
  },
46882
47174
  goToPreset: async ({ deviceId, presetId }) => {
@@ -46912,7 +47204,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46912
47204
  hasTilt: false,
46913
47205
  hasZoom: false,
46914
47206
  supportsPresets: false,
46915
- hasAutofocus: false
47207
+ hasAutofocus: false,
47208
+ speedSteps: AmcrestCamera.DAHUA_SPEED_STEPS,
47209
+ moveImpulseMs: AmcrestCamera.PTZ_PULSE_MS
46916
47210
  };
46917
47211
  if (deviceId !== this.id) return cold;
46918
47212
  const caps = await this.ensureClient().getPtzCaps(this.channel);
@@ -46923,26 +47217,64 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46923
47217
  hasZoom: caps.hasZoom,
46924
47218
  supportsPresets: caps.supportsPreset,
46925
47219
  hasAutofocus: false,
47220
+ speedSteps: AmcrestCamera.DAHUA_SPEED_STEPS,
47221
+ moveImpulseMs: AmcrestCamera.PTZ_PULSE_MS,
46926
47222
  ...caps.maxPresets !== null ? { maxPresets: caps.maxPresets } : {}
46927
47223
  };
46928
47224
  },
46929
47225
  goHome: async ({ deviceId }) => {
46930
47226
  if (deviceId !== this.id) return;
47227
+ const home = await this.resolveHomePreset();
47228
+ if (home.source === "none" || home.presetId === null) throw new Error("No home preset configured for this camera — set one in the PTZ settings");
47229
+ const id = Number.parseInt(home.presetId, 10);
47230
+ if (!Number.isFinite(id) || id < 1) throw new Error(`Configured home preset is not a Dahua preset id: ${home.presetId}`);
46931
47231
  const client = this.ensureClient();
46932
- this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, 1, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
47232
+ this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, id, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
47233
+ },
47234
+ getHomePreset: async ({ deviceId }) => {
47235
+ if (deviceId !== this.id) return {
47236
+ presetId: null,
47237
+ source: "none"
47238
+ };
47239
+ return this.resolveHomePreset();
47240
+ },
47241
+ captureHomeHere: async ({ deviceId }) => {
47242
+ if (deviceId !== this.id) return;
47243
+ const client = this.ensureClient();
47244
+ const ceiling = (await client.getPtzCaps(this.channel))?.maxPresets ?? AmcrestCamera.DAHUA_MAX_PRESETS;
47245
+ const used = new Set((await client.getPtzPresets(this.channel)).map((p) => p.id));
47246
+ let slot = 1;
47247
+ while (used.has(slot) && slot <= ceiling) slot += 1;
47248
+ if (slot > ceiling) throw new Error("No free preset slot on this camera to store a home position — free one first");
47249
+ await client.ptzSetPreset(this.channel, slot, AmcrestCamera.PTZ_CGI_TIMEOUT_MS);
47250
+ await this.config.setAll({ homePresetId: String(slot) });
47251
+ this.ctx.logger.info("amcrest: home pinned to the current position", {
47252
+ tags: { deviceId: this.id },
47253
+ meta: { presetId: slot }
47254
+ });
47255
+ },
47256
+ getHomeReturnSeconds: async () => {
47257
+ return null;
47258
+ },
47259
+ setHomeReturnSeconds: async () => {
47260
+ throw new Error("Amcrest: the idle-return timer is not wired on this driver");
47261
+ },
47262
+ setHomePreset: async ({ deviceId, presetId }) => {
47263
+ if (deviceId !== this.id) return;
47264
+ await this.config.setAll({ homePresetId: presetId ?? "" });
46933
47265
  },
46934
47266
  getPosition: async () => {
46935
47267
  return {
46936
- pan: 0,
46937
- tilt: 0,
46938
- zoom: 0
47268
+ pan: null,
47269
+ tilt: null,
47270
+ zoom: null
46939
47271
  };
46940
47272
  },
46941
47273
  getStatus: async () => {
46942
47274
  return {
46943
- pan: 0,
46944
- tilt: 0,
46945
- zoom: 0,
47275
+ pan: null,
47276
+ tilt: null,
47277
+ zoom: null,
46946
47278
  autofocus: false
46947
47279
  };
46948
47280
  },
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).
@@ -31060,14 +31060,81 @@ function summarisePrivacyAudio(profiles) {
31060
31060
  if (profiles.length === 0) return null;
31061
31061
  return profiles.some((p) => p.audioEnabled);
31062
31062
  }
31063
+ /**
31064
+ * What a preset id DOES on this camera.
31065
+ *
31066
+ * On several PTZ firmwares the preset namespace is shared: low ids are stored
31067
+ * positions, a reserved band triggers camera FUNCTIONS. The dispensa camera
31068
+ * (Hikvision) returns its own reserved band in the preset list, named by the
31069
+ * firmware:
31070
+ *
31071
+ * 34 Back to origin · 39 Day mode · 40 Night mode · 46 Day/Night Auto Mode
31072
+ * 92 Set manual limits · 93 Save manual limits · 94 Remote reboot
31073
+ * 95 Call OSD menu
31074
+ *
31075
+ * We used to hand all of these to the UI as ordinary presets. Saving over one
31076
+ * is what produced `PUT /ISAPI/PTZCtrl/channels/1/presets/34 -> HTTP 500`: the
31077
+ * camera refusing to let "Back to origin" be overwritten, surfaced to the
31078
+ * operator as a transport failure.
31079
+ *
31080
+ * A function preset stays fully usable as a DESTINATION -- `goToPreset('34')`
31081
+ * is the single most useful thing that camera can do. Only `savePreset` and
31082
+ * `deletePreset` are gated.
31083
+ */
31084
+ var PtzPresetFunctionSchema = _enum([
31085
+ "home",
31086
+ "day-mode",
31087
+ "night-mode",
31088
+ "day-night-auto",
31089
+ "set-limits",
31090
+ "save-limits",
31091
+ "reboot",
31092
+ "osd-menu"
31093
+ ]);
31063
31094
  var PtzPresetSchema = object({
31064
31095
  id: string(),
31065
- name: string()
31096
+ name: string(),
31097
+ /**
31098
+ * The firmware function this id performs, when the provider KNOWS the id is
31099
+ * reserved. `null` = an ordinary stored position, OR a reserved id this
31100
+ * provider does not recognise -- the two are told apart by `writable`.
31101
+ */
31102
+ fn: PtzPresetFunctionSchema.nullable(),
31103
+ /**
31104
+ * Whether `savePreset` / `deletePreset` may target this id. A provider sets
31105
+ * it false only for a band it KNOWS is reserved; where the firmware is not
31106
+ * documented it stays true and a refusal comes from the camera, named.
31107
+ */
31108
+ writable: boolean()
31066
31109
  });
31110
+ /**
31111
+ * Where the head is pointing -- per axis, and `null` when the driver cannot
31112
+ * read it.
31113
+ *
31114
+ * It used to be three REQUIRED numbers, which left a provider that cannot read
31115
+ * position no way to say so. All four said `0`:
31116
+ *
31117
+ * reolink `return { pan: 0, tilt: 0, zoom: 0 }` "until a position-query
31118
+ * path lands upstream"
31119
+ * hikvision stub, "firmware-dependent and frequently absent"
31120
+ * amcrest stub, "no generic absolute-position read for this model family"
31121
+ * onvif throws
31122
+ *
31123
+ * So every consumer asking where a camera points was told "perfectly centred"
31124
+ * by four cameras that had never looked. That is D393 -- a measurement that
31125
+ * FAILED is `null`, never `0`, and the type says so all the way to the
31126
+ * decision -- the same shape as the unreadable `statfs` folded into "0 bytes of
31127
+ * headroom", which evacuated a healthy disk.
31128
+ *
31129
+ * D393 also says to check for a narrower structural TWIN of the result type.
31130
+ * There is one: `PtzStatusSchema` extends this, so the lie had already
31131
+ * propagated into `getStatus`, which is the surface `getPosition`'s own comment
31132
+ * tells callers to migrate to.
31133
+ */
31067
31134
  var PtzPositionSchema = object({
31068
- pan: number(),
31069
- tilt: number(),
31070
- zoom: number()
31135
+ pan: number().nullable(),
31136
+ tilt: number().nullable(),
31137
+ zoom: number().nullable()
31071
31138
  });
31072
31139
  var PtzMoveCommandSchema = object({
31073
31140
  pan: number().optional(),
@@ -31089,7 +31156,72 @@ var PtzOptionsSchema = object({
31089
31156
  maxPresets: number().optional(),
31090
31157
  /** Whether the camera exposes a controllable autofocus toggle
31091
31158
  * (boolean `hasX` per the getOptions availability convention). */
31092
- hasAutofocus: boolean()
31159
+ hasAutofocus: boolean(),
31160
+ /**
31161
+ * How many distinct speeds this camera's NATIVE scale offers.
31162
+ *
31163
+ * The cap's `speed` is normalized 0..1 and each provider maps it down --
31164
+ * Baichuan 1..63, Dahua 1..8, ISAPI's percentage -100..100. The normalized
31165
+ * value hides how coarse that really is: asking an amcrest for `0.37` is
31166
+ * meaningless, because it has eight steps and three of them round to the
31167
+ * same one. A loop that tunes its own gain has to know the granularity of
31168
+ * the knob it is turning.
31169
+ *
31170
+ * `null` = the driver does not know, or the scale is continuous (ONVIF takes
31171
+ * a float). Never a made-up number.
31172
+ */
31173
+ speedSteps: number().int().positive().nullable(),
31174
+ /**
31175
+ * How long ONE `move` pulse runs on this driver, ms.
31176
+ *
31177
+ * `move` is a self-terminating burst everywhere, but every provider hardcoded
31178
+ * its own duration in private -- 200 reolink, 350 amcrest, 500 hikvision,
31179
+ * 1000 onvif -- so no caller could read it. It is the DENOMINATOR of "how far
31180
+ * did the head travel per pulse": without it a measured displacement has no
31181
+ * gain to be divided into.
31182
+ *
31183
+ * `null` = the driver cannot say.
31184
+ */
31185
+ moveImpulseMs: number().int().positive().nullable()
31186
+ });
31187
+ /**
31188
+ * Which preset `goHome` goes to, and WHO decided.
31189
+ *
31190
+ * `goHome` used to be three vendor "conventions" written in three comments --
31191
+ * Reolink preset 0, Hikvision preset '1', Dahua preset 1 -- and on this fleet
31192
+ * not one of the three cameras honours its own:
31193
+ *
31194
+ * 592 Videocamera camera Daniel (reolink) preset 0 holds "stanza"
31195
+ * 1438 Videocamera dispensa (hikvision) preset 1 holds "Credenza"
31196
+ * 3836 Videocamera studio (amcrest) preset 1 holds "Preset1"
31197
+ *
31198
+ * So `goHome` meant "go wherever the operator happened to save in slot 0 or 1",
31199
+ * and two of the three swallowed the failure in a bare `catch`. For autotrack
31200
+ * this is the HOTTEST path -- it runs every time a subject is released -- so it
31201
+ * needs an answer that is true per camera and audible when it is missing.
31202
+ *
31203
+ * `source` is what makes it honest:
31204
+ * - `operator` -- picked in the UI, stored by the provider.
31205
+ * - `firmware` -- the camera itself named a preset `fn: 'home'` (Hikvision's
31206
+ * "Back to origin"), adopted as the default with nothing to configure.
31207
+ * - `native` -- the driver homes WITHOUT a preset at all. ONVIF does:
31208
+ * `goHome` is `ptzAbsoluteMove({x:0, y:0, zoom:0})`. `presetId` is null and
31209
+ * that is not a failure -- there is nothing to pick, and the UI must offer
31210
+ * no picker.
31211
+ * - `none` -- nothing is configured and the camera names nothing.
31212
+ * `goHome` REFUSES rather than moving the head somewhere arbitrary.
31213
+ *
31214
+ * `source === 'none'` is the refusal condition, NOT `presetId === null`: the
31215
+ * native case has no preset and homes perfectly well.
31216
+ */
31217
+ var PtzHomePresetSchema = object({
31218
+ presetId: string().nullable(),
31219
+ source: _enum([
31220
+ "operator",
31221
+ "firmware",
31222
+ "native",
31223
+ "none"
31224
+ ])
31093
31225
  });
31094
31226
  var ptzCapability = {
31095
31227
  name: "ptz",
@@ -31130,7 +31262,66 @@ var ptzCapability = {
31130
31262
  auth: "admin"
31131
31263
  }),
31132
31264
  getOptions: method(object({ deviceId: number() }), PtzOptionsSchema),
31265
+ /**
31266
+ * Move to the camera's configured home preset. THROWS when
31267
+ * `getHomePreset()` resolves to `none` -- a head that did not move must
31268
+ * never look like a head that went home.
31269
+ */
31133
31270
  goHome: method(object({ deviceId: number() }), _void(), { kind: "mutation" }),
31271
+ /** Which preset `goHome` targets, and who decided it. */
31272
+ getHomePreset: method(object({ deviceId: number() }), PtzHomePresetSchema),
31273
+ /**
31274
+ * Pin the home preset for this camera. `null` clears the operator's choice
31275
+ * and falls back to whatever the firmware names, or to `none`.
31276
+ */
31277
+ setHomePreset: method(object({
31278
+ deviceId: number(),
31279
+ presetId: string().nullable()
31280
+ }), _void(), {
31281
+ kind: "mutation",
31282
+ auth: "admin"
31283
+ }),
31284
+ /**
31285
+ * Make WHERE THE HEAD IS POINTING RIGHT NOW this camera's home.
31286
+ *
31287
+ * `setHomePreset` takes a preset id, and a preset id is the wrong shape for
31288
+ * the most natural thing an operator does: aim the camera, then say "here".
31289
+ * It is also the only shape a camera whose home is NOT a preset can accept
31290
+ * -- Reolink's home is a guard point, a stored position with no id at all,
31291
+ * and hiding the control for that case (as the UI briefly did) left the one
31292
+ * vendor with a native home unable to set it.
31293
+ *
31294
+ * Each provider does it its own way:
31295
+ * reolink `setGrd` with `needSetPos`, pinning the guard point here
31296
+ * hikvision / amcrest save a preset, then pin it as home
31297
+ * onvif REFUSES -- its home is the PTZ origin and cannot be moved
31298
+ *
31299
+ * A provider that cannot honour it throws rather than silently doing
31300
+ * nothing: an operator who pressed "set home here" and saw no error would
31301
+ * believe it took.
31302
+ */
31303
+ captureHomeHere: method(object({ deviceId: number() }), _void(), {
31304
+ kind: "mutation",
31305
+ auth: "admin"
31306
+ }),
31307
+ /**
31308
+ * How long the camera waits, idle, before returning home on its own --
31309
+ * `null` when it has no such behaviour.
31310
+ *
31311
+ * Reolink's guard point carries one (`timeout`, freely settable in
31312
+ * seconds). It is a real function of the camera that we did not expose at
31313
+ * all, and it belongs next to the home it governs rather than in a vendor
31314
+ * settings page nobody associates with Home.
31315
+ */
31316
+ getHomeReturnSeconds: method(object({ deviceId: number() }), number().int().nullable()),
31317
+ /** Set the idle-return delay. Refused by a camera that has no such timer. */
31318
+ setHomeReturnSeconds: method(object({
31319
+ deviceId: number(),
31320
+ seconds: number().int().min(0).max(3600)
31321
+ }), _void(), {
31322
+ kind: "mutation",
31323
+ auth: "admin"
31324
+ }),
31134
31325
  /**
31135
31326
  * Pull the current PTZ position. Redundant with the auto-injected
31136
31327
  * `getStatus` method (see `status` below); kept for callers that
@@ -40792,6 +40983,12 @@ Object.freeze({
40792
40983
  addonId: null,
40793
40984
  access: "create"
40794
40985
  },
40986
+ "ptz.captureHomeHere": {
40987
+ capName: "ptz",
40988
+ capScope: "device",
40989
+ addonId: null,
40990
+ access: "create"
40991
+ },
40795
40992
  "ptz.continuousMove": {
40796
40993
  capName: "ptz",
40797
40994
  capScope: "device",
@@ -40804,6 +41001,18 @@ Object.freeze({
40804
41001
  addonId: null,
40805
41002
  access: "delete"
40806
41003
  },
41004
+ "ptz.getHomePreset": {
41005
+ capName: "ptz",
41006
+ capScope: "device",
41007
+ addonId: null,
41008
+ access: "view"
41009
+ },
41010
+ "ptz.getHomeReturnSeconds": {
41011
+ capName: "ptz",
41012
+ capScope: "device",
41013
+ addonId: null,
41014
+ access: "view"
41015
+ },
40807
41016
  "ptz.getOptions": {
40808
41017
  capName: "ptz",
40809
41018
  capScope: "device",
@@ -40852,6 +41061,18 @@ Object.freeze({
40852
41061
  addonId: null,
40853
41062
  access: "create"
40854
41063
  },
41064
+ "ptz.setHomePreset": {
41065
+ capName: "ptz",
41066
+ capScope: "device",
41067
+ addonId: null,
41068
+ access: "create"
41069
+ },
41070
+ "ptz.setHomeReturnSeconds": {
41071
+ capName: "ptz",
41072
+ capScope: "device",
41073
+ addonId: null,
41074
+ access: "create"
41075
+ },
40855
41076
  "ptz.stop": {
40856
41077
  capName: "ptz",
40857
41078
  capScope: "device",
@@ -44015,6 +44236,11 @@ Object.freeze({
44015
44236
  form: "single",
44016
44237
  optional: false
44017
44238
  }],
44239
+ "ptz.captureHomeHere": [{
44240
+ name: "deviceId",
44241
+ form: "single",
44242
+ optional: false
44243
+ }],
44018
44244
  "ptz.continuousMove": [{
44019
44245
  name: "deviceId",
44020
44246
  form: "single",
@@ -44025,6 +44251,16 @@ Object.freeze({
44025
44251
  form: "single",
44026
44252
  optional: false
44027
44253
  }],
44254
+ "ptz.getHomePreset": [{
44255
+ name: "deviceId",
44256
+ form: "single",
44257
+ optional: false
44258
+ }],
44259
+ "ptz.getHomeReturnSeconds": [{
44260
+ name: "deviceId",
44261
+ form: "single",
44262
+ optional: false
44263
+ }],
44028
44264
  "ptz.getOptions": [{
44029
44265
  name: "deviceId",
44030
44266
  form: "single",
@@ -44065,6 +44301,16 @@ Object.freeze({
44065
44301
  form: "single",
44066
44302
  optional: false
44067
44303
  }],
44304
+ "ptz.setHomePreset": [{
44305
+ name: "deviceId",
44306
+ form: "single",
44307
+ optional: false
44308
+ }],
44309
+ "ptz.setHomeReturnSeconds": [{
44310
+ name: "deviceId",
44311
+ form: "single",
44312
+ optional: false
44313
+ }],
44068
44314
  "ptz.stop": [{
44069
44315
  name: "deviceId",
44070
44316
  form: "single",
@@ -46140,6 +46386,17 @@ var amcrestDeviceCacheSchema = object({
46140
46386
  * carries only the technical connection knobs.
46141
46387
  */
46142
46388
  var amcrestCameraSchema = object({
46389
+ /**
46390
+ * Which preset `ptz.goHome` targets on THIS camera.
46391
+ *
46392
+ * Empty = nothing configured, and `goHome` then REFUSES. It used to go to a
46393
+ * hardcoded slot on a vendor "convention" this fleet contradicts: reolink
46394
+ * went to preset 0, which on device 592 holds "stanza"; amcrest went to
46395
+ * preset 1, which on device 3836 holds "Preset1". Both swallowed the failure
46396
+ * in a bare catch, so a head that never moved was indistinguishable from a
46397
+ * head that went home.
46398
+ */
46399
+ homePresetId: string().default("").describe("Preset id ptz.goHome targets (empty = goHome refuses)"),
46143
46400
  host: string().describe("Camera IP / hostname"),
46144
46401
  port: number().int().min(1).max(65535).default(80).describe("HTTP CGI port"),
46145
46402
  https: boolean().default(false).describe("Use HTTPS for CGI"),
@@ -46247,6 +46504,24 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46247
46504
  get channel() {
46248
46505
  return this.config.get("channel") ?? 1;
46249
46506
  }
46507
+ /**
46508
+ * Which preset `goHome` targets, and who decided it.
46509
+ *
46510
+ * Dahua names no home of its own that we have probed, so the operator's pick
46511
+ * is the only answer. With none, `none` -- and `goHome` refuses rather than
46512
+ * moving the head to whatever happens to sit in slot 1.
46513
+ */
46514
+ async resolveHomePreset() {
46515
+ const chosen = this.config.get("homePresetId") ?? "";
46516
+ if (chosen === "") return {
46517
+ presetId: null,
46518
+ source: "none"
46519
+ };
46520
+ return {
46521
+ presetId: chosen,
46522
+ source: "operator"
46523
+ };
46524
+ }
46250
46525
  ensureClient() {
46251
46526
  if (this.client) return this.client;
46252
46527
  this.client = this.buildClient();
@@ -46752,6 +47027,21 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46752
47027
  * auto-stop fires. The `usePTZ` UI hook already issues its own paired
46753
47028
  * `stop` after a client-side pulse window; this timer is a backstop so
46754
47029
  * a lone `move` (no following `stop`) can't leave the head gliding. */
47030
+ /**
47031
+ * Dahua's native PTZ speed scale is 1..8; `resolvePtzCodes` maps the cap's
47032
+ * normalized 0..1 onto it. Eight steps is COARSE -- three neighbouring
47033
+ * normalized requests round to the same one -- which is exactly why the
47034
+ * granularity is reported through `getOptions` instead of being hidden
47035
+ * behind a 0..1 float.
47036
+ */
47037
+ /** Dahua exposes 25 fixed preset slots, 1-based. */
47038
+ static DAHUA_MAX_PRESETS = 25;
47039
+ static DAHUA_SPEED_STEPS = 8;
47040
+ /**
47041
+ * How long one `ptz.move` pulse runs, ms. The DENOMINATOR of "how far did
47042
+ * the head travel per pulse", so it leaves the provider through
47043
+ * `getOptions` rather than staying a private literal.
47044
+ */
46755
47045
  static PTZ_PULSE_MS = 350;
46756
47046
  /** Short per-call deadline (ms) for the PTZ start/stop CGI so a slow or
46757
47047
  * blocked camera round-trip fails fast instead of exceeding the
@@ -46877,7 +47167,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46877
47167
  if (deviceId !== this.id) return [];
46878
47168
  return (await this.ensureClient().getPtzPresets(this.channel)).map((p) => ({
46879
47169
  id: String(p.id),
46880
- name: p.name
47170
+ name: p.name,
47171
+ fn: null,
47172
+ writable: true
46881
47173
  }));
46882
47174
  },
46883
47175
  goToPreset: async ({ deviceId, presetId }) => {
@@ -46913,7 +47205,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46913
47205
  hasTilt: false,
46914
47206
  hasZoom: false,
46915
47207
  supportsPresets: false,
46916
- hasAutofocus: false
47208
+ hasAutofocus: false,
47209
+ speedSteps: AmcrestCamera.DAHUA_SPEED_STEPS,
47210
+ moveImpulseMs: AmcrestCamera.PTZ_PULSE_MS
46917
47211
  };
46918
47212
  if (deviceId !== this.id) return cold;
46919
47213
  const caps = await this.ensureClient().getPtzCaps(this.channel);
@@ -46924,26 +47218,64 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46924
47218
  hasZoom: caps.hasZoom,
46925
47219
  supportsPresets: caps.supportsPreset,
46926
47220
  hasAutofocus: false,
47221
+ speedSteps: AmcrestCamera.DAHUA_SPEED_STEPS,
47222
+ moveImpulseMs: AmcrestCamera.PTZ_PULSE_MS,
46927
47223
  ...caps.maxPresets !== null ? { maxPresets: caps.maxPresets } : {}
46928
47224
  };
46929
47225
  },
46930
47226
  goHome: async ({ deviceId }) => {
46931
47227
  if (deviceId !== this.id) return;
47228
+ const home = await this.resolveHomePreset();
47229
+ if (home.source === "none" || home.presetId === null) throw new Error("No home preset configured for this camera — set one in the PTZ settings");
47230
+ const id = Number.parseInt(home.presetId, 10);
47231
+ if (!Number.isFinite(id) || id < 1) throw new Error(`Configured home preset is not a Dahua preset id: ${home.presetId}`);
46932
47232
  const client = this.ensureClient();
46933
- this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, 1, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
47233
+ this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, id, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
47234
+ },
47235
+ getHomePreset: async ({ deviceId }) => {
47236
+ if (deviceId !== this.id) return {
47237
+ presetId: null,
47238
+ source: "none"
47239
+ };
47240
+ return this.resolveHomePreset();
47241
+ },
47242
+ captureHomeHere: async ({ deviceId }) => {
47243
+ if (deviceId !== this.id) return;
47244
+ const client = this.ensureClient();
47245
+ const ceiling = (await client.getPtzCaps(this.channel))?.maxPresets ?? AmcrestCamera.DAHUA_MAX_PRESETS;
47246
+ const used = new Set((await client.getPtzPresets(this.channel)).map((p) => p.id));
47247
+ let slot = 1;
47248
+ while (used.has(slot) && slot <= ceiling) slot += 1;
47249
+ if (slot > ceiling) throw new Error("No free preset slot on this camera to store a home position — free one first");
47250
+ await client.ptzSetPreset(this.channel, slot, AmcrestCamera.PTZ_CGI_TIMEOUT_MS);
47251
+ await this.config.setAll({ homePresetId: String(slot) });
47252
+ this.ctx.logger.info("amcrest: home pinned to the current position", {
47253
+ tags: { deviceId: this.id },
47254
+ meta: { presetId: slot }
47255
+ });
47256
+ },
47257
+ getHomeReturnSeconds: async () => {
47258
+ return null;
47259
+ },
47260
+ setHomeReturnSeconds: async () => {
47261
+ throw new Error("Amcrest: the idle-return timer is not wired on this driver");
47262
+ },
47263
+ setHomePreset: async ({ deviceId, presetId }) => {
47264
+ if (deviceId !== this.id) return;
47265
+ await this.config.setAll({ homePresetId: presetId ?? "" });
46934
47266
  },
46935
47267
  getPosition: async () => {
46936
47268
  return {
46937
- pan: 0,
46938
- tilt: 0,
46939
- zoom: 0
47269
+ pan: null,
47270
+ tilt: null,
47271
+ zoom: null
46940
47272
  };
46941
47273
  },
46942
47274
  getStatus: async () => {
46943
47275
  return {
46944
- pan: 0,
46945
- tilt: 0,
46946
- zoom: 0,
47276
+ pan: null,
47277
+ tilt: null,
47278
+ zoom: null,
46947
47279
  autofocus: false
46948
47280
  };
46949
47281
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-amcrest",
3
- "version": "0.2.112",
3
+ "version": "0.2.114",
4
4
  "description": "Amcrest/Dahua camera device provider addon for CamStack — Dahua CGI over HTTP(S) with digest auth (snapshot, RTSP catalog, PTZ, image/day-night config)",
5
5
  "keywords": [
6
6
  "camstack",