homebridge-ring-secure-video 1.0.0-beta.2 → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,80 @@
1
1
  # homebridge-ring-secure-video
2
2
 
3
+ ## 1.0.0-beta.4 (2026-11-13)
4
+
5
+ More fixes from the full independent review of 1.0.0-beta.2.
6
+
7
+ **Changes in behaviour to check after updating:**
8
+
9
+ - `enableHksv` now means on when it isn't set, as the settings form shows. Configs without it
10
+ (written by hand, or copied from homebridge-ring) now offer HKSV recording: live view allows
11
+ one viewer at a time, and recording starts once turned on for the camera in the Home app. Set
12
+ `"enableHksv": false` to keep the old behaviour.
13
+ - `disableHksvOnBattery` now means on when it isn't set, and looks at whether a camera actually
14
+ runs on battery: battery models that are wired (power mode wired in the Ring app), like many
15
+ doorbells, keep HKSV; battery-only cameras don't get it. Configs without the setting therefore
16
+ lose HKSV on battery-only cameras; set `"disableHksvOnBattery": false` to keep it. Configs
17
+ saved by the old settings form have it set to true, so their wired battery doorbells now get
18
+ HKSV.
19
+ - Values the old settings form saved stay until removed: a saved `"media": { "profile":
20
+ "adaptive" }` still overrides `"hksvPerformanceMode": "rpi"`, and a saved
21
+ `"hksvVideoBitrateKbps": 3000` still overrides the profile's bitrate. Remove them if you didn't
22
+ choose them.
23
+
24
+ **Fixes:**
25
+
26
+ - One device or location that fails to set up no longer stops the others, the alarm included.
27
+ A failed device keeps its HomeKit accessory (rooms and automations stay) but shows "No
28
+ Response"; a location whose devices can't be loaded is logged, and its accessories are kept and
29
+ show "No Response". If
30
+ Ring can't be reached when Homebridge starts (an outage, a server error, a sign-in server
31
+ outage), connecting is retried after 30 s, 1 min, 2 min, ... up to every 10 minutes, with the
32
+ newest sign-in token and without forcing a new Ring registration.
33
+ - The motion fallback no longer asks Ring for each camera's event history every 20 s all day
34
+ (about 4,300 requests a day). Once motion events turn out to arrive by push too, it checks every
35
+ 5 minutes as a safety net; a motion event no push reported switches back to frequent checks. A
36
+ history event counts as pushed if a motion push had its id or came within 90 s of it. If push
37
+ stops working, it is noticed at the next 5-minute check; a visit found then is only reported if
38
+ it is at most 3 minutes old, so it can be missed. Remembered events are forgotten an hour after
39
+ they leave Ring's history.
40
+ - ffmpeg errors no longer end the whole bridge (alarm included): further errors on a process and
41
+ errors on its output are logged, and a talk-back ffmpeg that fails to start is logged and the
42
+ live view is reported to HomeKit as not started.
43
+ - A snapshot that can't be refreshed is shown for at most Ring's snapshot lifetime plus 2
44
+ minutes, as in homebridge-ring, rather than indefinitely.
45
+ - The settings form no longer fills in a bitrate or media profile that overrode the media
46
+ profile and the older HKSV performance mode.
47
+ - README and settings text: the Raspberry Pi section shows the settings verified on a Pi 4
48
+ (`lowPower`, `libx264`, with a Ring Video Doorbell 3 Plus); `copy` and `h264_v4l2m2m` are
49
+ described as not yet verified. Upstream links point to dgreif/ring, and the install commands
50
+ no longer use `--unsafe-perm`.
51
+ - Development: GitHub Actions and the publishing npm are pinned to exact versions, the GitHub
52
+ token is kept out of dependency installs, Dependabot is set up, and locked dependencies include
53
+ the available security fixes.
54
+
55
+ ## 1.0.0-beta.3 (2026-11-13)
56
+
57
+ Fixes from a full independent review of 1.0.0-beta.2.
58
+
59
+ - A second visit 30–65 s after the first now turns HomeKit motion on again, so it is recorded and
60
+ notified. Ring keeps a motion alert active for 65 s and only reports changes, while the plugin
61
+ turns motion off after 30 s; a new alert in between gave nothing. A repeat of the same alert
62
+ (Ring's follow-up with the snapshot, a resend) is not a new visit, and the event-history
63
+ fallback no longer adds its own "no motion" after 65 s, which could cut a later visit short.
64
+ - A media setting the plugin can't use (for example a maximum bitrate below the bitrate, which
65
+ the settings form allows, or a stray key) no longer stops the plugin's bridge from starting
66
+ (which took the alarm down with it). It is ignored with a warning naming it, and the older
67
+ setting or the default is used. Default maximum bitrate and buffer size derived from a high
68
+ bitrate stay within their limits.
69
+ - After a forced Ring refresh ("Force Ring Push Credential Refresh" or "Force Ring API Session
70
+ Refresh"), every new Ring token is saved, not only the first; before, the next start
71
+ registered with Ring again. The force settings are cleared once, and a token that can't be
72
+ saved is logged.
73
+ - config.json and .ring.json are written to a temporary file and then renamed into place, so a
74
+ power cut or a full disk during a token renewal can't leave them cut short (Homebridge would not
75
+ start). Permissions, group and symlinks are kept; where that can't work (a bind-mounted file,
76
+ a file owned by another user, ...) they are written in place as before.
77
+
3
78
  ## 1.0.0-beta.2 (2026-11-13)
4
79
 
5
80
  - The Homebridge UI shows the plugin as "Ring Secure Video" (was "Homebridge Ring Secure Video"),
package/README.md CHANGED
@@ -13,22 +13,22 @@ It is meant to replace `homebridge-ring` on its own: one plugin, one Ring sign-i
13
13
 
14
14
  ## Origin and Attribution
15
15
 
16
- This project is based on Dustin Greif's original Ring Homebridge ecosystem:
16
+ This project is based on Dusty Greif's original Ring Homebridge ecosystem:
17
17
 
18
- - Upstream plugin: [dgreif/homebridge-ring](https://github.com/dgreif/homebridge-ring)
19
- - Upstream API library: [dgreif/ring-client-api](https://github.com/dgreif/ring-client-api)
18
+ - Upstream plugin and API library: [dgreif/ring](https://github.com/dgreif/ring)
19
+ (`homebridge-ring` and `ring-client-api`)
20
20
 
21
21
  - HKSV fork: [trinityhades/homebridge-ring-hksv](https://github.com/trinityhades/homebridge-ring-hksv), whose history this repository continues
22
22
 
23
- Big thanks to Dustin, trinityhades and all upstream contributors. This plugin reuses and extends that foundation.
23
+ Big thanks to Dusty, trinityhades and all upstream contributors. This plugin reuses and extends that foundation.
24
24
 
25
25
  ## Important Plugin Options
26
26
 
27
27
  | Option | Purpose |
28
28
  | --- | --- |
29
- | `enableHksv` | Enables experimental HKSV support for eligible cameras |
30
- | `enableCameraMotionHistory` | Allows camera motion to fall back to recent Ring event history if live notifications are stale; disable to only trust push-triggered motion |
31
- | `disableHksvOnBattery` | Disables HKSV on battery cameras to reduce battery/network usage |
29
+ | `enableHksv` | Enables experimental HKSV support for eligible cameras; on by default, set `false` to turn it off |
30
+ | `enableCameraMotionHistory` | Allows camera motion to fall back to recent Ring event history if push notifications don't arrive. The history is checked every `cameraStatusPollingSeconds` until motion events turn out to come by push too, then every 5 minutes as a safety net (and frequently again once a push is missed, so motion can go unreported for up to 5 minutes after push stops working); disable to only trust push-triggered motion |
31
+ | `disableHksvOnBattery` | Disables HKSV on cameras running on battery to reduce battery/network usage; wired battery models keep it. On by default |
32
32
  | `hksvPrebufferLengthMs` | HKSV prebuffer duration (minimum 4000ms) |
33
33
  | `hksvFragmentLengthMs` | HKSV fragment duration target |
34
34
  | `hksvMaxRecordingSeconds` | Optional safety cap for a recording session (default 180 s; 0 disables) |
@@ -61,13 +61,13 @@ normally ends a recording itself when motion stops), or to `0` to disable it.
61
61
  If Homebridge is installed globally:
62
62
 
63
63
  ```bash
64
- npm i -g --unsafe-perm homebridge-ring-secure-video
64
+ npm i -g homebridge-ring-secure-video
65
65
  ```
66
66
 
67
67
  To try a beta, install the `beta` tag instead:
68
68
 
69
69
  ```bash
70
- npm i -g --unsafe-perm homebridge-ring-secure-video@beta
70
+ npm i -g homebridge-ring-secure-video@beta
71
71
  ```
72
72
 
73
73
  If running from source:
@@ -115,7 +115,8 @@ Changing `homeKitAccessoryTag` updates both the exposed accessory name and the g
115
115
 
116
116
  HKSV support is experimental and actively evolving. Behavior may vary by camera model, wired vs battery power, Ring API changes, and FFmpeg environment.
117
117
 
118
- I currently am able to run 3 cameras with HKSV enabled on a Homebridge instance ran on a M4 Mac Mini 32GB of RAM.
118
+ Verified so far: complete HomeKit clips from a Ring Video Doorbell 3 Plus on a Raspberry Pi 4
119
+ (Node 22, Homebridge 2) with the settings below. The HKSV fork this continues reported 3 cameras on an M4 Mac mini.
119
120
  Please report your experience and setup details to help improve support.
120
121
 
121
122
  Recordings follow the frame rate and audio format HomeKit asks for, and include audio only when
@@ -133,24 +134,23 @@ For fast-motion pixelation or stuttering in HKSV recordings, try increasing
133
134
  `cameraVideoCodec: "h264_videotoolbox"` can reduce CPU load by using hardware
134
135
  encoding.
135
136
 
136
- For Raspberry Pi 4/5 and other small computers, start with:
137
+ For Raspberry Pi 4/5 and other small computers, start with what has been verified on a Pi 4:
137
138
 
138
139
  ```json
139
140
  {
140
141
  "enableHksv": true,
141
- "hksvPerformanceMode": "rpi",
142
- "cameraVideoCodec": "auto",
143
- "hksvMaxConcurrentRecordings": 1
142
+ "media": {
143
+ "profile": "lowPower",
144
+ "recording": { "codec": "libx264", "bitrateKbps": 800 }
145
+ }
144
146
  }
145
147
  ```
146
148
 
147
- The `rpi` profile favors remuxing Ring's H.264 video into HomeKit Secure Video
148
- fragmented MP4 instead of re-encoding it. This is much lighter than libx264, but
149
- it depends on Ring's current stream being compatible with HomeKit's H.264
150
- recording requirements. If recording fails in this mode, try
151
- `cameraVideoCodec: "h264_v4l2m2m"` when your FFmpeg build exposes that encoder,
152
- or fall back to `cameraVideoCodec: "libx264"` with `hksvVideoPreset:
153
- "ultrafast"`.
149
+ The `lowPower` profile (also chosen by `"hksvPerformanceMode": "rpi"`) records one clip at a
150
+ time with conservative settings. Lighter codecs exist but complete HomeKit clips with them are
151
+ not verified yet: `copy` (the `lowPower` default for `auto`) remuxes Ring's H.264 without
152
+ re-encoding, which only works when Ring's stream meets HomeKit's recording requirements, and
153
+ `h264_v4l2m2m` uses the Pi's hardware encoder when FFmpeg has it.
154
154
 
155
155
  ### Minimum specifications for HKSV:
156
156
  [TBD - will be added as more users test and report their setups]
@@ -36,7 +36,7 @@
36
36
  "profile": {
37
37
  "title": "Media profile",
38
38
  "type": "string",
39
- "default": "adaptive",
39
+ "description": "Defaults to the HKSV Performance Mode (rpi gives lowPower, quality gives quality), otherwise adaptive.",
40
40
  "oneOf": [
41
41
  { "title": "adaptive", "enum": ["adaptive"] },
42
42
  { "title": "lowPower", "enum": ["lowPower"] },
@@ -155,13 +155,13 @@
155
155
  "enableCameraMotionHistory": {
156
156
  "title": "Enable Camera Motion History Fallback",
157
157
  "type": "boolean",
158
- "description": "If enabled, cameras can synthesize motion from recent Ring event history when live push notifications are missing or stale. Disable this if you only want motion triggered by Ring notifications.",
158
+ "description": "If enabled, cameras can synthesize motion from recent Ring event history when push notifications don't arrive. The history is checked frequently until push is seen to work, then every 5 minutes as a safety net. Disable this if you only want motion triggered by Ring notifications.",
159
159
  "default": true
160
160
  },
161
161
  "disableHksvOnBattery": {
162
162
  "title": "Disable HKSV On Battery Cameras",
163
163
  "type": "boolean",
164
- "description": "If enabled, HKSV is disabled on battery-powered cameras to reduce battery and network impact.",
164
+ "description": "If enabled, HKSV is disabled on cameras running on battery, to reduce battery and network impact. Battery models that are wired (power mode wired in the Ring app) keep HKSV.",
165
165
  "default": true
166
166
  },
167
167
  "hksvPrebufferLengthMs": {
@@ -189,7 +189,7 @@
189
189
  "hksvPerformanceMode": {
190
190
  "title": "HKSV Performance Mode",
191
191
  "type": "string",
192
- "description": "High-level HKSV tuning profile. Use rpi for Raspberry Pi and other small computers; it favors remuxing, lower advertised recording tiers, and conservative concurrency.",
192
+ "description": "High-level HKSV tuning profile. Use rpi for Raspberry Pi and other small computers: lower advertised recording tiers, one recording at a time, and remuxing (copy) unless a codec other than auto is set. Verified on a Pi 4 with the codec set to libx264.",
193
193
  "default": "balanced",
194
194
  "oneOf": [
195
195
  {
@@ -225,8 +225,7 @@
225
225
  "type": "integer",
226
226
  "minimum": 256,
227
227
  "maximum": 12000,
228
- "default": 3000,
229
- "description": "Target video bitrate for HKSV recording transcodes. Increase this first if fast motion becomes blocky or pixelated. 4000-6000 is a good starting range for wired cameras."
228
+ "description": "Target video bitrate for HKSV recording transcodes. Defaults to the media profile's (1000 lowPower, 3000 adaptive, 5000 quality). Increase this first if fast motion becomes blocky or pixelated. 4000-6000 is a good starting range for wired cameras."
230
229
  },
231
230
  "hksvVideoMaxBitrateKbps": {
232
231
  "title": "HKSV Max Video Bitrate (kbps)",
@@ -308,7 +307,7 @@
308
307
  "cameraVideoCodec": {
309
308
  "title": "Camera Video Codec",
310
309
  "type": "string",
311
- "description": "Preferred FFmpeg H.264 handling for HKSV recordings. auto chooses the lowest-cost option for the configured performance mode; copy remuxes Ring video without re-encoding.",
310
+ "description": "Preferred FFmpeg H.264 handling for HKSV recordings. auto chooses the lowest-cost option for the configured performance mode; copy remuxes Ring video without re-encoding. Complete clips are verified with libx264; copy and h264_v4l2m2m are not verified yet.",
312
311
  "oneOf": [
313
312
  {
314
313
  "title": "auto",
@@ -225,46 +225,79 @@ class StreamingSessionWrapper {
225
225
  // deSerialize will sometimes fail, but the errors can be ignored
226
226
  }
227
227
  return null;
228
- }), releaseReturnAudioFfmpeg = hksvRecordingQueue.trackFfmpegProcess(), returnAudioTranscoder = new ReturnAudioTranscoder({
229
- prepareStreamRequest: getReturnAudioPrepareStreamRequest(this.prepareStreamRequest),
230
- startStreamRequest: request,
231
- incomingAudioOptions: {
232
- ssrc: this.audioSsrc,
233
- rtcpPort: 0, // we don't care about rtcp for incoming audio
234
- },
235
- outputArgs: [
236
- '-acodec',
237
- 'libopus',
238
- '-application',
239
- 'lowdelay',
240
- '-frame_duration',
241
- '60',
242
- '-flags',
243
- '+global_header',
244
- '-ar',
245
- '48k',
246
- '-b:a',
247
- '48k',
248
- '-bufsize',
249
- '192k',
250
- '-ac',
251
- '2',
252
- '-f',
253
- 'rtp',
254
- `rtp://127.0.0.1:${await returnAudioTranscodedSplitter.portPromise}`,
255
- ],
256
- ffmpegPath: getFfmpegPath(),
257
- exitCallback: () => {
258
- releaseReturnAudioFfmpeg();
259
- this.stop();
260
- },
261
- logger: {
262
- info: logDebug,
263
- error: logError,
264
- },
265
- logLabel: `Return Audio (${this.ringCamera.name})`,
266
- returnAudioSplitter: this.audioSplitter,
267
228
  });
229
+ // Port first, so a failure here leaves nothing counted or open.
230
+ let returnAudioPort;
231
+ try {
232
+ returnAudioPort = await returnAudioTranscodedSplitter.portPromise;
233
+ }
234
+ catch (e) {
235
+ returnAudioTranscodedSplitter.close();
236
+ throw e;
237
+ }
238
+ if (this.stopped) {
239
+ returnAudioTranscodedSplitter.close();
240
+ throw getLiveStreamAbortError();
241
+ }
242
+ const releaseReturnAudioFfmpeg = hksvRecordingQueue.trackFfmpegProcess();
243
+ let returnAudioTranscoder;
244
+ try {
245
+ returnAudioTranscoder = new ReturnAudioTranscoder({
246
+ prepareStreamRequest: getReturnAudioPrepareStreamRequest(this.prepareStreamRequest),
247
+ startStreamRequest: request,
248
+ incomingAudioOptions: {
249
+ ssrc: this.audioSsrc,
250
+ rtcpPort: 0, // we don't care about rtcp for incoming audio
251
+ },
252
+ outputArgs: [
253
+ '-acodec',
254
+ 'libopus',
255
+ '-application',
256
+ 'lowdelay',
257
+ '-frame_duration',
258
+ '60',
259
+ '-flags',
260
+ '+global_header',
261
+ '-ar',
262
+ '48k',
263
+ '-b:a',
264
+ '48k',
265
+ '-bufsize',
266
+ '192k',
267
+ '-ac',
268
+ '2',
269
+ '-f',
270
+ 'rtp',
271
+ `rtp://127.0.0.1:${returnAudioPort}`,
272
+ ],
273
+ ffmpegPath: getFfmpegPath(),
274
+ exitCallback: () => {
275
+ releaseReturnAudioFfmpeg();
276
+ this.stop();
277
+ },
278
+ logger: {
279
+ info: logDebug,
280
+ error: logError,
281
+ },
282
+ logLabel: `Return Audio (${this.ringCamera.name})`,
283
+ returnAudioSplitter: this.audioSplitter,
284
+ });
285
+ }
286
+ catch (e) {
287
+ releaseReturnAudioFfmpeg();
288
+ returnAudioTranscodedSplitter.close();
289
+ throw e;
290
+ }
291
+ // @homebridge/camera-utils' ffmpeg has no 'error' listener: a failed start (no ffmpeg, too
292
+ // many processes) would be an unhandled 'error' event and end the whole bridge, alarm
293
+ // included. Its spawn errors arrive on the next tick, so listening now is in time.
294
+ const returnAudioFfmpeg = returnAudioTranscoder.ffmpegProcess?.ff;
295
+ for (const emitter of [returnAudioFfmpeg, returnAudioFfmpeg?.stdout, returnAudioFfmpeg?.stderr]) {
296
+ emitter?.on('error', (error) => {
297
+ logError(`Return Audio (${this.ringCamera.name}): ${error.message}`);
298
+ this.stop();
299
+ });
300
+ }
268
301
  this.streamingSession.onCallEnded.pipe(take(1)).subscribe(() => {
269
302
  returnAudioTranscoder.stop();
270
303
  returnAudioTranscodedSplitter.close();
@@ -277,6 +310,9 @@ class StreamingSessionWrapper {
277
310
  });
278
311
  this.listenForAudioPackets(request);
279
312
  await returnAudioTranscoder.start();
313
+ // A talk-back ffmpeg that failed to start stopped the stream meanwhile: report it to HomeKit.
314
+ if (this.stopped)
315
+ throw getLiveStreamAbortError();
280
316
  }
281
317
  stop() {
282
318
  if (this.stopped) {
@@ -329,10 +365,20 @@ export function getHksvRecordingFormatArguments(configuration, videoArguments, c
329
365
  // Raspberry Pi's V4L2 encoder) otherwise leave an empty avcC, and HomeKit can't decode the
330
366
  // recording. The first media fragment arrives no later than before.
331
367
  export const HKSV_RECORDING_MOVFLAGS = 'frag_keyframe+empty_moov+default_base_moof+delay_moov';
368
+ /**
369
+ * Whether a camera gets HomeKit Secure Video. Unset settings mean what the settings form shows:
370
+ * HKSV on, except for cameras running on battery (each recording opens a Ring live call, which
371
+ * drains it). A battery model that is wired, like many doorbells, counts as wired.
372
+ */
373
+ export function isHksvEnabled(config, operatingOnBattery) {
374
+ return (config.enableHksv !== false &&
375
+ !(config.disableHksvOnBattery !== false && operatingOnBattery));
376
+ }
332
377
  export class CameraSource {
333
378
  controller;
334
379
  sessions = {};
335
380
  cachedSnapshot;
381
+ cachedSnapshotAt = 0;
336
382
  ringCamera;
337
383
  config;
338
384
  mediaIngress;
@@ -345,7 +391,7 @@ export class CameraSource {
345
391
  this.ringCamera = ringCamera;
346
392
  this.config = config;
347
393
  this.mediaIngress = new RingMediaIngress(ringCamera);
348
- const enableHksv = config.enableHksv && !(config.disableHksvOnBattery && ringCamera.hasBattery);
394
+ const enableHksv = isHksvEnabled(config, ringCamera.operatingOnBattery);
349
395
  const controllerOptions = {
350
396
  cameraStreamCount: enableHksv ? 1 : 10,
351
397
  delegate: this,
@@ -512,6 +558,7 @@ export class CameraSource {
512
558
  try {
513
559
  const previousSnapshot = this.cachedSnapshot, newSnapshot = await this.ringCamera.getSnapshot({ uuid: imageUuid });
514
560
  this.cachedSnapshot = newSnapshot;
561
+ this.cachedSnapshotAt = Date.now();
515
562
  logDebug(`Snapshot cached for ${this.ringCamera.name}${imageUuid ? ' by uuid' : ''} (${getDurationSeconds(start)}s)`);
516
563
  }
517
564
  catch (e) {
@@ -535,7 +582,13 @@ export class CameraSource {
535
582
  if (!this.ringCamera.hasSnapshotWithinLifetime) {
536
583
  this.loadSnapshot().catch(logError);
537
584
  }
538
- // may or may not have a snapshot cached
585
+ // May or may not have a snapshot cached. A failed load keeps the last one for a while, but
586
+ // not past Ring's snapshot lifetime plus 2 minutes (as upstream): an hours-old image would
587
+ // look current in the Home app.
588
+ if (this.cachedSnapshot &&
589
+ Date.now() - this.cachedSnapshotAt > this.ringCamera.snapshotLifeTime + 2 * 60_000) {
590
+ return undefined;
591
+ }
539
592
  return this.cachedSnapshot;
540
593
  }
541
594
  async handleSnapshotRequest(request, callback) {
package/lib/camera.js CHANGED
@@ -1,11 +1,188 @@
1
1
  import { hap } from "./hap.js";
2
+ import { PushNotificationAction } from 'ring-client-api';
2
3
  import { BaseDataAccessory } from "./base-data-accessory.js";
3
- import { catchError, exhaustMap, filter, map, mergeMap, share, switchMap, throttleTime, } from 'rxjs/operators';
4
+ import { catchError, exhaustMap, filter, finalize, map, mergeMap, share, switchMap, throttleTime, } from 'rxjs/operators';
4
5
  import { CameraSource } from "./camera-source.js";
5
6
  import { TargetValueTimer } from "./target-value-timer.js";
6
7
  import { delay, logError, logInfo } from 'ring-client-api/util';
7
- import { EMPTY, firstValueFrom, from, merge, of, timer } from 'rxjs';
8
+ import { defer, EMPTY, firstValueFrom, merge, of, timer } from 'rxjs';
8
9
  export const MOTION_WATCHDOG_MS = 30_000;
10
+ /** How often Ring's event history is checked once its push notifications are known to arrive. */
11
+ export const HISTORY_CHECK_WHILE_PUSH_WORKS_MS = 5 * 60_000;
12
+ /** A push motion this close to a history event's time is taken as that event's notification. */
13
+ export const PUSH_MATCH_WINDOW_MS = 90_000;
14
+ /** After a gap in checking, a history event older than this no longer gives motion. */
15
+ export const HISTORY_MOTION_MAX_AGE_MS = 180_000;
16
+ /** A history event first seen less than this ago may still get its push; judged at a later check. */
17
+ const PUSH_GRACE_MS = 30_000;
18
+ /** Checks in a row that found only pushed events (no misses in between) before push is trusted. */
19
+ const MATCHES_TO_TRUST_PUSH = 2;
20
+ /**
21
+ * Ids no longer in the history are forgotten after this. Ids still in it are kept, however old:
22
+ * on a quiet camera the 10 newest events can be days old, and must not count as new again.
23
+ */
24
+ const ID_MEMORY_MS = 60 * 60_000;
25
+ /**
26
+ * Motion from Ring's event history, for when its push notifications don't arrive.
27
+ *
28
+ * Checking every pollSeconds (20 s by default) costs some 4,300 Ring requests a day per camera,
29
+ * on the same session as everything else. So the history is checked that often only until motion
30
+ * events in it (two in a row) turn out to have come by push too; from then on push is trusted
31
+ * and the history is checked every 5 minutes as a safety net. A motion event no push reported
32
+ * switches back to frequent checks. While checking frequently every new event gives motion; after
33
+ * a gap (5-minute checks, a failure) only events from the last 3 minutes do, as one found later
34
+ * would only record an empty doorway.
35
+ */
36
+ export function getHistoryMotionEvents(device, pollSeconds) {
37
+ return defer(() => {
38
+ const startedAt = Date.now() - 5000, historySeen = new Map(),
39
+ // Events that already gave motion from here (a young one is judged again later).
40
+ shownAsMotion = new Map(),
41
+ // When an event not judged yet was first seen in the history (by the Pi's clock).
42
+ firstSeen = new Map(), pushIds = new Map(), pushMotionTimes = [], baseRetryMs = Math.max(pollSeconds * 1000, 30_000), forget = (ids, now) => {
43
+ for (const [id, at] of ids) {
44
+ if (now - at > ID_MEMORY_MS)
45
+ ids.delete(id);
46
+ }
47
+ };
48
+ let pushWorks = false, matchedInARow = 0, lastCheckAt = 0, lastSuccessAt = 0, failureCount = 0, nextCheckAt = 0;
49
+ const notifications = device.onNewNotification.subscribe((notification) => {
50
+ if (notification.android_config?.category !== PushNotificationAction.Motion)
51
+ return;
52
+ const id = notification.data?.event?.ding?.id, now = Date.now();
53
+ if (id !== undefined)
54
+ pushIds.set(String(id), now);
55
+ pushMotionTimes.push(now);
56
+ if (pushMotionTimes.length > 50)
57
+ pushMotionTimes.shift();
58
+ });
59
+ return timer(pollSeconds * 1000, pollSeconds * 1000).pipe(exhaustMap(() => {
60
+ const now = Date.now();
61
+ if (now < nextCheckAt)
62
+ return EMPTY;
63
+ if (pushWorks && now - lastCheckAt < HISTORY_CHECK_WHILE_PUSH_WORKS_MS) {
64
+ return EMPTY;
65
+ }
66
+ lastCheckAt = now;
67
+ // Checked recently and frequently: anything new is recent, whatever the clocks say
68
+ // (a Pi clock ahead of Ring's would otherwise make every event look too old).
69
+ const checkedJustBefore = !pushWorks && now - lastSuccessAt <= pollSeconds * 1000 + 30_000;
70
+ return defer(() => device.getEvents({ limit: 10, kind: 'motion' })).pipe(mergeMap(({ events }) => {
71
+ const checkedAt = Date.now();
72
+ failureCount = 0;
73
+ nextCheckAt = 0;
74
+ lastSuccessAt = checkedAt;
75
+ // Still in the history: remembered as long as it stays there.
76
+ for (const event of events) {
77
+ const id = event.ding_id_str || String(event.ding_id);
78
+ for (const ids of [historySeen, shownAsMotion, firstSeen, pushIds]) {
79
+ if (ids.has(id) && ids !== firstSeen)
80
+ ids.set(id, checkedAt);
81
+ }
82
+ }
83
+ forget(historySeen, checkedAt);
84
+ forget(shownAsMotion, checkedAt);
85
+ forget(firstSeen, checkedAt);
86
+ forget(pushIds, checkedAt);
87
+ const isPushed = (event) => {
88
+ const id = event.ding_id_str || String(event.ding_id), at = Date.parse(event.created_at);
89
+ return (pushIds.has(id) ||
90
+ pushMotionTimes.some((t) => Math.abs(t - at) <= PUSH_MATCH_WINDOW_MS));
91
+ };
92
+ const newEvents = events.filter((event) => {
93
+ const id = event.ding_id_str || String(event.ding_id);
94
+ return (event.kind === 'motion' &&
95
+ Date.parse(event.created_at) >= startedAt &&
96
+ !historySeen.has(id));
97
+ });
98
+ // Too young to know whether its push is still coming: judged at a later check, but
99
+ // it can still give motion now (once). Young by when it was first seen here, so a
100
+ // Pi clock behind Ring's doesn't keep it young.
101
+ const young = (event) => {
102
+ const id = event.ding_id_str || String(event.ding_id);
103
+ if (!firstSeen.has(id))
104
+ firstSeen.set(id, checkedAt);
105
+ return !isPushed(event) && checkedAt - (firstSeen.get(id) ?? checkedAt) < PUSH_GRACE_MS;
106
+ };
107
+ const judged = newEvents.filter((event) => !young(event)), missed = judged.filter((event) => !isPushed(event));
108
+ judged.forEach((event) => {
109
+ const id = event.ding_id_str || String(event.ding_id);
110
+ historySeen.set(id, checkedAt);
111
+ firstSeen.delete(id);
112
+ });
113
+ if (pushWorks && judged.length < newEvents.length) {
114
+ // An event to judge soon: check again shortly rather than in 5 minutes.
115
+ lastCheckAt = checkedAt - HISTORY_CHECK_WHILE_PUSH_WORKS_MS + PUSH_GRACE_MS + 10_000;
116
+ }
117
+ if (missed.length) {
118
+ matchedInARow = 0;
119
+ if (pushWorks) {
120
+ pushWorks = false;
121
+ logInfo(`${device.name}: Ring event history shows motion no push notification reported; checking it every ${pollSeconds} s again`);
122
+ }
123
+ }
124
+ else if (judged.length) {
125
+ // One per check: a single push can match several events.
126
+ matchedInARow += 1;
127
+ if (!pushWorks && matchedInARow >= MATCHES_TO_TRUST_PUSH) {
128
+ pushWorks = true;
129
+ logInfo(`${device.name}: Ring push notifications are arriving; checking Ring event history every ${HISTORY_CHECK_WHILE_PUSH_WORKS_MS / 60_000} minutes instead of every ${pollSeconds} s`);
130
+ }
131
+ }
132
+ const unpushedNow = newEvents.filter((event) => !isPushed(event)), givesMotion = unpushedNow.some((event) => !shownAsMotion.has(event.ding_id_str || String(event.ding_id)) &&
133
+ (checkedJustBefore ||
134
+ checkedAt - Date.parse(event.created_at) <= HISTORY_MOTION_MAX_AGE_MS));
135
+ unpushedNow.forEach((event) => shownAsMotion.set(event.ding_id_str || String(event.ding_id), checkedAt));
136
+ if (!givesMotion)
137
+ return EMPTY;
138
+ logInfo(`${device.name} Detected Motion from Ring event history fallback`);
139
+ return of(true);
140
+ }), catchError(() => {
141
+ failureCount++;
142
+ const retryDelayMs = Math.min(5 * 60 * 1000, baseRetryMs * 2 ** Math.min(failureCount - 1, 4));
143
+ nextCheckAt = Date.now() + retryDelayMs;
144
+ const nextInMs = pushWorks
145
+ ? Math.max(retryDelayMs, lastCheckAt + HISTORY_CHECK_WHILE_PUSH_WORKS_MS - Date.now())
146
+ : retryDelayMs;
147
+ logError(`${device.name} failed to poll Ring event history for motion fallback; retrying in ${Math.ceil(nextInMs / 1000)} seconds`);
148
+ return EMPTY;
149
+ }));
150
+ }), finalize(() => notifications.unsubscribe()));
151
+ });
152
+ }
153
+ /**
154
+ * Motion from Ring's push notifications, as HomeKit needs it: true for every new motion alert.
155
+ *
156
+ * ring-client-api's onMotionDetected is true while any motion alert is active (each lasts 65 s)
157
+ * and only reports changes, so a new alert while an earlier one is still active gives nothing.
158
+ * The watchdog below turns motion off after 30 s, so a second visit 30-65 s after the first
159
+ * would never turn it on again: no recording and no notification. So every new motion
160
+ * notification gives a true (restarting the watchdog), and onMotionDetected only adds its
161
+ * false once all alerts have expired. A notification repeating an alert seen in the last two
162
+ * minutes (Ring sends a second one with the snapshot; a reconnect can resend) is not a new visit.
163
+ */
164
+ export const REPEATED_ALERT_WINDOW_MS = 120_000;
165
+ export function getPushMotionEvents(camera) {
166
+ return defer(() => {
167
+ const seenAt = new Map();
168
+ return merge(camera.onMotionDetected.pipe(filter((motion) => !motion)), camera.onNewNotification.pipe(filter((notification) => {
169
+ if (notification.android_config?.category !== PushNotificationAction.Motion) {
170
+ return false;
171
+ }
172
+ const now = Date.now(), id = notification.data?.event?.ding?.id;
173
+ for (const [seenId, at] of seenAt) {
174
+ if (now - at > REPEATED_ALERT_WINDOW_MS)
175
+ seenAt.delete(seenId);
176
+ }
177
+ if (id === undefined)
178
+ return true;
179
+ if (seenAt.has(String(id)))
180
+ return false;
181
+ seenAt.set(String(id), now);
182
+ return true;
183
+ }), map(() => true)));
184
+ });
185
+ }
9
186
  export function addMotionWatchdog(motionEvents, watchdogMs = MOTION_WATCHDOG_MS) {
10
187
  return motionEvents.pipe(switchMap((motion) => {
11
188
  if (!motion) {
@@ -183,56 +360,12 @@ export class Camera extends BaseDataAccessory {
183
360
  }
184
361
  }
185
362
  getMotionDetectedObservable() {
186
- const pollSeconds = Math.max(this.config.cameraStatusPollingSeconds ?? 20, 10), startedAt = Date.now() - 5000, seenEventIds = new Set(), pushMotionEvents = this.device.onMotionDetected, enableMotionHistoryFallback = this.config.enableCameraMotionHistory !== false;
187
- let historyFailureCount = 0;
188
- let nextHistoryPollAt = 0;
189
- const baseHistoryRetryMs = Math.max(pollSeconds * 1000, 30_000), polledNewMotionEvents = timer(pollSeconds * 1000, pollSeconds * 1000).pipe(exhaustMap(() => {
190
- if (Date.now() < nextHistoryPollAt) {
191
- return EMPTY;
192
- }
193
- return from(this.device.getEvents({
194
- limit: 10,
195
- kind: 'motion',
196
- })).pipe(mergeMap(({ events }) => {
197
- historyFailureCount = 0;
198
- nextHistoryPollAt = 0;
199
- const newMotionEvents = events
200
- .filter((event) => {
201
- const eventId = event.ding_id_str || String(event.ding_id);
202
- return (event.kind === 'motion' &&
203
- Date.parse(event.created_at) >= startedAt &&
204
- !seenEventIds.has(eventId));
205
- })
206
- .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
207
- newMotionEvents.forEach((event) => {
208
- seenEventIds.add(event.ding_id_str || String(event.ding_id));
209
- });
210
- if (!newMotionEvents.length)
211
- return EMPTY;
212
- logInfo(`${this.device.name} Detected Motion from Ring event history fallback`);
213
- return of(true);
214
- }), catchError(() => {
215
- historyFailureCount++;
216
- const retryDelayMs = Math.min(5 * 60 * 1000, baseHistoryRetryMs * 2 ** Math.min(historyFailureCount - 1, 4));
217
- nextHistoryPollAt = Date.now() + retryDelayMs;
218
- logError(`${this.device.name} failed to poll Ring event history for motion fallback; retrying in ${Math.ceil(retryDelayMs / 1000)} seconds`);
219
- return EMPTY;
220
- }));
221
- }),
222
- // The false timer below has a second subscriber. Sharing keeps one
223
- // history request per poll instead of duplicating Ring API traffic.
224
- share()), polledMotionEvents = merge(polledNewMotionEvents, polledNewMotionEvents.pipe(switchMap(() => timer(65000).pipe(map(() => false)))));
225
- if (enableMotionHistoryFallback) {
226
- this.device.onNewNotification.subscribe((notification) => {
227
- const dingId = notification.data?.event?.ding?.id;
228
- if (dingId) {
229
- seenEventIds.add(String(dingId));
230
- }
231
- });
363
+ const pushMotionEvents = getPushMotionEvents(this.device);
364
+ if (this.config.enableCameraMotionHistory === false) {
365
+ return addMotionWatchdog(pushMotionEvents);
232
366
  }
233
- return addMotionWatchdog(enableMotionHistoryFallback
234
- ? merge(pushMotionEvents, polledMotionEvents)
235
- : pushMotionEvents);
367
+ const pollSeconds = Math.max(this.config.cameraStatusPollingSeconds ?? 20, 10);
368
+ return addMotionWatchdog(merge(pushMotionEvents, getHistoryMotionEvents(this.device, pollSeconds)));
236
369
  }
237
370
  async loadSnapshotForEvent(eventDescription, characteristicValue) {
238
371
  let imageUuid = this.device.latestNotificationSnapshotUuid;