@camstack/addon-provider-amcrest 0.2.111 → 0.2.113

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 +250 -15
  2. package/dist/addon.mjs +250 -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-B1y0Fo1U.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,25 @@ 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
+ }),
31133
31283
  /**
31134
31284
  * Pull the current PTZ position. Redundant with the auto-injected
31135
31285
  * `getStatus` method (see `status` below); kept for callers that
@@ -40803,6 +40953,12 @@ Object.freeze({
40803
40953
  addonId: null,
40804
40954
  access: "delete"
40805
40955
  },
40956
+ "ptz.getHomePreset": {
40957
+ capName: "ptz",
40958
+ capScope: "device",
40959
+ addonId: null,
40960
+ access: "view"
40961
+ },
40806
40962
  "ptz.getOptions": {
40807
40963
  capName: "ptz",
40808
40964
  capScope: "device",
@@ -40851,6 +41007,12 @@ Object.freeze({
40851
41007
  addonId: null,
40852
41008
  access: "create"
40853
41009
  },
41010
+ "ptz.setHomePreset": {
41011
+ capName: "ptz",
41012
+ capScope: "device",
41013
+ addonId: null,
41014
+ access: "create"
41015
+ },
40854
41016
  "ptz.stop": {
40855
41017
  capName: "ptz",
40856
41018
  capScope: "device",
@@ -44024,6 +44186,11 @@ Object.freeze({
44024
44186
  form: "single",
44025
44187
  optional: false
44026
44188
  }],
44189
+ "ptz.getHomePreset": [{
44190
+ name: "deviceId",
44191
+ form: "single",
44192
+ optional: false
44193
+ }],
44027
44194
  "ptz.getOptions": [{
44028
44195
  name: "deviceId",
44029
44196
  form: "single",
@@ -44064,6 +44231,11 @@ Object.freeze({
44064
44231
  form: "single",
44065
44232
  optional: false
44066
44233
  }],
44234
+ "ptz.setHomePreset": [{
44235
+ name: "deviceId",
44236
+ form: "single",
44237
+ optional: false
44238
+ }],
44067
44239
  "ptz.stop": [{
44068
44240
  name: "deviceId",
44069
44241
  form: "single",
@@ -46139,6 +46311,17 @@ var amcrestDeviceCacheSchema = object({
46139
46311
  * carries only the technical connection knobs.
46140
46312
  */
46141
46313
  var amcrestCameraSchema = object({
46314
+ /**
46315
+ * Which preset `ptz.goHome` targets on THIS camera.
46316
+ *
46317
+ * Empty = nothing configured, and `goHome` then REFUSES. It used to go to a
46318
+ * hardcoded slot on a vendor "convention" this fleet contradicts: reolink
46319
+ * went to preset 0, which on device 592 holds "stanza"; amcrest went to
46320
+ * preset 1, which on device 3836 holds "Preset1". Both swallowed the failure
46321
+ * in a bare catch, so a head that never moved was indistinguishable from a
46322
+ * head that went home.
46323
+ */
46324
+ homePresetId: string().default("").describe("Preset id ptz.goHome targets (empty = goHome refuses)"),
46142
46325
  host: string().describe("Camera IP / hostname"),
46143
46326
  port: number().int().min(1).max(65535).default(80).describe("HTTP CGI port"),
46144
46327
  https: boolean().default(false).describe("Use HTTPS for CGI"),
@@ -46246,6 +46429,24 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46246
46429
  get channel() {
46247
46430
  return this.config.get("channel") ?? 1;
46248
46431
  }
46432
+ /**
46433
+ * Which preset `goHome` targets, and who decided it.
46434
+ *
46435
+ * Dahua names no home of its own that we have probed, so the operator's pick
46436
+ * is the only answer. With none, `none` -- and `goHome` refuses rather than
46437
+ * moving the head to whatever happens to sit in slot 1.
46438
+ */
46439
+ async resolveHomePreset() {
46440
+ const chosen = this.config.get("homePresetId") ?? "";
46441
+ if (chosen === "") return {
46442
+ presetId: null,
46443
+ source: "none"
46444
+ };
46445
+ return {
46446
+ presetId: chosen,
46447
+ source: "operator"
46448
+ };
46449
+ }
46249
46450
  ensureClient() {
46250
46451
  if (this.client) return this.client;
46251
46452
  this.client = this.buildClient();
@@ -46751,6 +46952,19 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46751
46952
  * auto-stop fires. The `usePTZ` UI hook already issues its own paired
46752
46953
  * `stop` after a client-side pulse window; this timer is a backstop so
46753
46954
  * a lone `move` (no following `stop`) can't leave the head gliding. */
46955
+ /**
46956
+ * Dahua's native PTZ speed scale is 1..8; `resolvePtzCodes` maps the cap's
46957
+ * normalized 0..1 onto it. Eight steps is COARSE -- three neighbouring
46958
+ * normalized requests round to the same one -- which is exactly why the
46959
+ * granularity is reported through `getOptions` instead of being hidden
46960
+ * behind a 0..1 float.
46961
+ */
46962
+ static DAHUA_SPEED_STEPS = 8;
46963
+ /**
46964
+ * How long one `ptz.move` pulse runs, ms. The DENOMINATOR of "how far did
46965
+ * the head travel per pulse", so it leaves the provider through
46966
+ * `getOptions` rather than staying a private literal.
46967
+ */
46754
46968
  static PTZ_PULSE_MS = 350;
46755
46969
  /** Short per-call deadline (ms) for the PTZ start/stop CGI so a slow or
46756
46970
  * blocked camera round-trip fails fast instead of exceeding the
@@ -46876,7 +47090,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46876
47090
  if (deviceId !== this.id) return [];
46877
47091
  return (await this.ensureClient().getPtzPresets(this.channel)).map((p) => ({
46878
47092
  id: String(p.id),
46879
- name: p.name
47093
+ name: p.name,
47094
+ fn: null,
47095
+ writable: true
46880
47096
  }));
46881
47097
  },
46882
47098
  goToPreset: async ({ deviceId, presetId }) => {
@@ -46912,7 +47128,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46912
47128
  hasTilt: false,
46913
47129
  hasZoom: false,
46914
47130
  supportsPresets: false,
46915
- hasAutofocus: false
47131
+ hasAutofocus: false,
47132
+ speedSteps: AmcrestCamera.DAHUA_SPEED_STEPS,
47133
+ moveImpulseMs: AmcrestCamera.PTZ_PULSE_MS
46916
47134
  };
46917
47135
  if (deviceId !== this.id) return cold;
46918
47136
  const caps = await this.ensureClient().getPtzCaps(this.channel);
@@ -46923,26 +47141,43 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46923
47141
  hasZoom: caps.hasZoom,
46924
47142
  supportsPresets: caps.supportsPreset,
46925
47143
  hasAutofocus: false,
47144
+ speedSteps: AmcrestCamera.DAHUA_SPEED_STEPS,
47145
+ moveImpulseMs: AmcrestCamera.PTZ_PULSE_MS,
46926
47146
  ...caps.maxPresets !== null ? { maxPresets: caps.maxPresets } : {}
46927
47147
  };
46928
47148
  },
46929
47149
  goHome: async ({ deviceId }) => {
46930
47150
  if (deviceId !== this.id) return;
47151
+ const home = await this.resolveHomePreset();
47152
+ if (home.source === "none" || home.presetId === null) throw new Error("No home preset configured for this camera — set one in the PTZ settings");
47153
+ const id = Number.parseInt(home.presetId, 10);
47154
+ if (!Number.isFinite(id) || id < 1) throw new Error(`Configured home preset is not a Dahua preset id: ${home.presetId}`);
46931
47155
  const client = this.ensureClient();
46932
- this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, 1, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
47156
+ this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, id, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
47157
+ },
47158
+ getHomePreset: async ({ deviceId }) => {
47159
+ if (deviceId !== this.id) return {
47160
+ presetId: null,
47161
+ source: "none"
47162
+ };
47163
+ return this.resolveHomePreset();
47164
+ },
47165
+ setHomePreset: async ({ deviceId, presetId }) => {
47166
+ if (deviceId !== this.id) return;
47167
+ await this.config.setAll({ homePresetId: presetId ?? "" });
46933
47168
  },
46934
47169
  getPosition: async () => {
46935
47170
  return {
46936
- pan: 0,
46937
- tilt: 0,
46938
- zoom: 0
47171
+ pan: null,
47172
+ tilt: null,
47173
+ zoom: null
46939
47174
  };
46940
47175
  },
46941
47176
  getStatus: async () => {
46942
47177
  return {
46943
- pan: 0,
46944
- tilt: 0,
46945
- zoom: 0,
47178
+ pan: null,
47179
+ tilt: null,
47180
+ zoom: null,
46946
47181
  autofocus: false
46947
47182
  };
46948
47183
  },
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-B1y0Fo1U.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,25 @@ 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
+ }),
31134
31284
  /**
31135
31285
  * Pull the current PTZ position. Redundant with the auto-injected
31136
31286
  * `getStatus` method (see `status` below); kept for callers that
@@ -40804,6 +40954,12 @@ Object.freeze({
40804
40954
  addonId: null,
40805
40955
  access: "delete"
40806
40956
  },
40957
+ "ptz.getHomePreset": {
40958
+ capName: "ptz",
40959
+ capScope: "device",
40960
+ addonId: null,
40961
+ access: "view"
40962
+ },
40807
40963
  "ptz.getOptions": {
40808
40964
  capName: "ptz",
40809
40965
  capScope: "device",
@@ -40852,6 +41008,12 @@ Object.freeze({
40852
41008
  addonId: null,
40853
41009
  access: "create"
40854
41010
  },
41011
+ "ptz.setHomePreset": {
41012
+ capName: "ptz",
41013
+ capScope: "device",
41014
+ addonId: null,
41015
+ access: "create"
41016
+ },
40855
41017
  "ptz.stop": {
40856
41018
  capName: "ptz",
40857
41019
  capScope: "device",
@@ -44025,6 +44187,11 @@ Object.freeze({
44025
44187
  form: "single",
44026
44188
  optional: false
44027
44189
  }],
44190
+ "ptz.getHomePreset": [{
44191
+ name: "deviceId",
44192
+ form: "single",
44193
+ optional: false
44194
+ }],
44028
44195
  "ptz.getOptions": [{
44029
44196
  name: "deviceId",
44030
44197
  form: "single",
@@ -44065,6 +44232,11 @@ Object.freeze({
44065
44232
  form: "single",
44066
44233
  optional: false
44067
44234
  }],
44235
+ "ptz.setHomePreset": [{
44236
+ name: "deviceId",
44237
+ form: "single",
44238
+ optional: false
44239
+ }],
44068
44240
  "ptz.stop": [{
44069
44241
  name: "deviceId",
44070
44242
  form: "single",
@@ -46140,6 +46312,17 @@ var amcrestDeviceCacheSchema = object({
46140
46312
  * carries only the technical connection knobs.
46141
46313
  */
46142
46314
  var amcrestCameraSchema = object({
46315
+ /**
46316
+ * Which preset `ptz.goHome` targets on THIS camera.
46317
+ *
46318
+ * Empty = nothing configured, and `goHome` then REFUSES. It used to go to a
46319
+ * hardcoded slot on a vendor "convention" this fleet contradicts: reolink
46320
+ * went to preset 0, which on device 592 holds "stanza"; amcrest went to
46321
+ * preset 1, which on device 3836 holds "Preset1". Both swallowed the failure
46322
+ * in a bare catch, so a head that never moved was indistinguishable from a
46323
+ * head that went home.
46324
+ */
46325
+ homePresetId: string().default("").describe("Preset id ptz.goHome targets (empty = goHome refuses)"),
46143
46326
  host: string().describe("Camera IP / hostname"),
46144
46327
  port: number().int().min(1).max(65535).default(80).describe("HTTP CGI port"),
46145
46328
  https: boolean().default(false).describe("Use HTTPS for CGI"),
@@ -46247,6 +46430,24 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46247
46430
  get channel() {
46248
46431
  return this.config.get("channel") ?? 1;
46249
46432
  }
46433
+ /**
46434
+ * Which preset `goHome` targets, and who decided it.
46435
+ *
46436
+ * Dahua names no home of its own that we have probed, so the operator's pick
46437
+ * is the only answer. With none, `none` -- and `goHome` refuses rather than
46438
+ * moving the head to whatever happens to sit in slot 1.
46439
+ */
46440
+ async resolveHomePreset() {
46441
+ const chosen = this.config.get("homePresetId") ?? "";
46442
+ if (chosen === "") return {
46443
+ presetId: null,
46444
+ source: "none"
46445
+ };
46446
+ return {
46447
+ presetId: chosen,
46448
+ source: "operator"
46449
+ };
46450
+ }
46250
46451
  ensureClient() {
46251
46452
  if (this.client) return this.client;
46252
46453
  this.client = this.buildClient();
@@ -46752,6 +46953,19 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46752
46953
  * auto-stop fires. The `usePTZ` UI hook already issues its own paired
46753
46954
  * `stop` after a client-side pulse window; this timer is a backstop so
46754
46955
  * a lone `move` (no following `stop`) can't leave the head gliding. */
46956
+ /**
46957
+ * Dahua's native PTZ speed scale is 1..8; `resolvePtzCodes` maps the cap's
46958
+ * normalized 0..1 onto it. Eight steps is COARSE -- three neighbouring
46959
+ * normalized requests round to the same one -- which is exactly why the
46960
+ * granularity is reported through `getOptions` instead of being hidden
46961
+ * behind a 0..1 float.
46962
+ */
46963
+ static DAHUA_SPEED_STEPS = 8;
46964
+ /**
46965
+ * How long one `ptz.move` pulse runs, ms. The DENOMINATOR of "how far did
46966
+ * the head travel per pulse", so it leaves the provider through
46967
+ * `getOptions` rather than staying a private literal.
46968
+ */
46755
46969
  static PTZ_PULSE_MS = 350;
46756
46970
  /** Short per-call deadline (ms) for the PTZ start/stop CGI so a slow or
46757
46971
  * blocked camera round-trip fails fast instead of exceeding the
@@ -46877,7 +47091,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46877
47091
  if (deviceId !== this.id) return [];
46878
47092
  return (await this.ensureClient().getPtzPresets(this.channel)).map((p) => ({
46879
47093
  id: String(p.id),
46880
- name: p.name
47094
+ name: p.name,
47095
+ fn: null,
47096
+ writable: true
46881
47097
  }));
46882
47098
  },
46883
47099
  goToPreset: async ({ deviceId, presetId }) => {
@@ -46913,7 +47129,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46913
47129
  hasTilt: false,
46914
47130
  hasZoom: false,
46915
47131
  supportsPresets: false,
46916
- hasAutofocus: false
47132
+ hasAutofocus: false,
47133
+ speedSteps: AmcrestCamera.DAHUA_SPEED_STEPS,
47134
+ moveImpulseMs: AmcrestCamera.PTZ_PULSE_MS
46917
47135
  };
46918
47136
  if (deviceId !== this.id) return cold;
46919
47137
  const caps = await this.ensureClient().getPtzCaps(this.channel);
@@ -46924,26 +47142,43 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
46924
47142
  hasZoom: caps.hasZoom,
46925
47143
  supportsPresets: caps.supportsPreset,
46926
47144
  hasAutofocus: false,
47145
+ speedSteps: AmcrestCamera.DAHUA_SPEED_STEPS,
47146
+ moveImpulseMs: AmcrestCamera.PTZ_PULSE_MS,
46927
47147
  ...caps.maxPresets !== null ? { maxPresets: caps.maxPresets } : {}
46928
47148
  };
46929
47149
  },
46930
47150
  goHome: async ({ deviceId }) => {
46931
47151
  if (deviceId !== this.id) return;
47152
+ const home = await this.resolveHomePreset();
47153
+ if (home.source === "none" || home.presetId === null) throw new Error("No home preset configured for this camera — set one in the PTZ settings");
47154
+ const id = Number.parseInt(home.presetId, 10);
47155
+ if (!Number.isFinite(id) || id < 1) throw new Error(`Configured home preset is not a Dahua preset id: ${home.presetId}`);
46932
47156
  const client = this.ensureClient();
46933
- this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, 1, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
47157
+ this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, id, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
47158
+ },
47159
+ getHomePreset: async ({ deviceId }) => {
47160
+ if (deviceId !== this.id) return {
47161
+ presetId: null,
47162
+ source: "none"
47163
+ };
47164
+ return this.resolveHomePreset();
47165
+ },
47166
+ setHomePreset: async ({ deviceId, presetId }) => {
47167
+ if (deviceId !== this.id) return;
47168
+ await this.config.setAll({ homePresetId: presetId ?? "" });
46934
47169
  },
46935
47170
  getPosition: async () => {
46936
47171
  return {
46937
- pan: 0,
46938
- tilt: 0,
46939
- zoom: 0
47172
+ pan: null,
47173
+ tilt: null,
47174
+ zoom: null
46940
47175
  };
46941
47176
  },
46942
47177
  getStatus: async () => {
46943
47178
  return {
46944
- pan: 0,
46945
- tilt: 0,
46946
- zoom: 0,
47179
+ pan: null,
47180
+ tilt: null,
47181
+ zoom: null,
46947
47182
  autofocus: false
46948
47183
  };
46949
47184
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-amcrest",
3
- "version": "0.2.111",
3
+ "version": "0.2.113",
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",