homebridge-ring-secure-video 1.0.0-beta.3 → 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,57 @@
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
+
3
55
  ## 1.0.0-beta.3 (2026-11-13)
4
56
 
5
57
  Fixes from a full independent review of 1.0.0-beta.2.
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,12 +1,155 @@
1
1
  import { hap } from "./hap.js";
2
2
  import { PushNotificationAction } from 'ring-client-api';
3
3
  import { BaseDataAccessory } from "./base-data-accessory.js";
4
- 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';
5
5
  import { CameraSource } from "./camera-source.js";
6
6
  import { TargetValueTimer } from "./target-value-timer.js";
7
7
  import { delay, logError, logInfo } from 'ring-client-api/util';
8
- import { defer, EMPTY, firstValueFrom, from, merge, of, timer } from 'rxjs';
8
+ import { defer, EMPTY, firstValueFrom, merge, of, timer } from 'rxjs';
9
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
+ }
10
153
  /**
11
154
  * Motion from Ring's push notifications, as HomeKit needs it: true for every new motion alert.
12
155
  *
@@ -217,56 +360,12 @@ export class Camera extends BaseDataAccessory {
217
360
  }
218
361
  }
219
362
  getMotionDetectedObservable() {
220
- const pollSeconds = Math.max(this.config.cameraStatusPollingSeconds ?? 20, 10), startedAt = Date.now() - 5000, seenEventIds = new Set(), pushMotionEvents = getPushMotionEvents(this.device), enableMotionHistoryFallback = this.config.enableCameraMotionHistory !== false;
221
- let historyFailureCount = 0;
222
- let nextHistoryPollAt = 0;
223
- const baseHistoryRetryMs = Math.max(pollSeconds * 1000, 30_000), polledNewMotionEvents = timer(pollSeconds * 1000, pollSeconds * 1000).pipe(exhaustMap(() => {
224
- if (Date.now() < nextHistoryPollAt) {
225
- return EMPTY;
226
- }
227
- return from(this.device.getEvents({
228
- limit: 10,
229
- kind: 'motion',
230
- })).pipe(mergeMap(({ events }) => {
231
- historyFailureCount = 0;
232
- nextHistoryPollAt = 0;
233
- const newMotionEvents = events
234
- .filter((event) => {
235
- const eventId = event.ding_id_str || String(event.ding_id);
236
- return (event.kind === 'motion' &&
237
- Date.parse(event.created_at) >= startedAt &&
238
- !seenEventIds.has(eventId));
239
- })
240
- .sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
241
- newMotionEvents.forEach((event) => {
242
- seenEventIds.add(event.ding_id_str || String(event.ding_id));
243
- });
244
- if (!newMotionEvents.length)
245
- return EMPTY;
246
- logInfo(`${this.device.name} Detected Motion from Ring event history fallback`);
247
- return of(true);
248
- }), catchError(() => {
249
- historyFailureCount++;
250
- const retryDelayMs = Math.min(5 * 60 * 1000, baseHistoryRetryMs * 2 ** Math.min(historyFailureCount - 1, 4));
251
- nextHistoryPollAt = Date.now() + retryDelayMs;
252
- logError(`${this.device.name} failed to poll Ring event history for motion fallback; retrying in ${Math.ceil(retryDelayMs / 1000)} seconds`);
253
- return EMPTY;
254
- }));
255
- })),
256
- // No separate "motion off" for these: the watchdog below turns motion off 30 s after the
257
- // last event, and a 65 s one from here could cut a later push-started visit short.
258
- polledMotionEvents = polledNewMotionEvents;
259
- if (enableMotionHistoryFallback) {
260
- this.device.onNewNotification.subscribe((notification) => {
261
- const dingId = notification.data?.event?.ding?.id;
262
- if (dingId) {
263
- seenEventIds.add(String(dingId));
264
- }
265
- });
363
+ const pushMotionEvents = getPushMotionEvents(this.device);
364
+ if (this.config.enableCameraMotionHistory === false) {
365
+ return addMotionWatchdog(pushMotionEvents);
266
366
  }
267
- return addMotionWatchdog(enableMotionHistoryFallback
268
- ? merge(pushMotionEvents, polledMotionEvents)
269
- : pushMotionEvents);
367
+ const pollSeconds = Math.max(this.config.cameraStatusPollingSeconds ?? 20, 10);
368
+ return addMotionWatchdog(merge(pushMotionEvents, getHistoryMotionEvents(this.device, pollSeconds)));
270
369
  }
271
370
  async loadSnapshotForEvent(eventDescription, characteristicValue) {
272
371
  let imageUuid = this.device.latestNotificationSnapshotUuid;
@@ -37,6 +37,10 @@ export class ManagedFfmpegProcess {
37
37
  if (options.stdoutCallback) {
38
38
  this.child.stdout.on('data', options.stdoutCallback);
39
39
  }
40
+ // An unhandled 'error' event would crash the whole bridge (alarm included).
41
+ for (const stream of [this.child.stdout, this.child.stderr]) {
42
+ stream.on('error', (error) => options.logger.error(prefix + error.message));
43
+ }
40
44
  this.child.stderr.on('data', (data) => {
41
45
  options.logger.info(prefix + data);
42
46
  });
@@ -45,11 +49,12 @@ export class ManagedFfmpegProcess {
45
49
  options.logger.error(prefix + error.message);
46
50
  }
47
51
  });
48
- this.child.once('error', (error) => {
49
- if (this.didExit)
50
- return;
52
+ this.child.on('error', (error) => {
51
53
  options.logger.error(prefix + error.message);
52
- this.finish(null, null, false);
54
+ // Without a pid it never started, and no 'exit' will follow. A running process (say a
55
+ // failed kill) still exits on its own and must stay tracked until then.
56
+ if (this.child.pid === undefined)
57
+ this.finish(null, null, false);
53
58
  });
54
59
  this.child.once('exit', (code, signal) => {
55
60
  this.finish(code, signal, true);
@@ -182,6 +182,47 @@ export function createRefreshTokenSaver(api, tokenInConfig, clearRefreshFlags) {
182
182
  }
183
183
  };
184
184
  }
185
+ /** An error as a short text; ring-client-api's HTTP errors have no message, only a status. */
186
+ export function describeError(error) {
187
+ if (error instanceof Error && error.message)
188
+ return error.message;
189
+ const status = error?.response?.status;
190
+ if (status)
191
+ return `Ring returned HTTP ${status}`;
192
+ return error instanceof Error ? error.name : String(error);
193
+ }
194
+ /**
195
+ * A Ring error that retrying can't fix. Only an outdated Node.js: ring-client-api reports a Ring
196
+ * sign-in server outage (5xx, 429) as "Refresh token is not valid", the same as a revoked token,
197
+ * so that is retried (every 10 minutes at most, a single request each time).
198
+ */
199
+ export function isPermanentRingError(error) {
200
+ return describeError(error).includes('too old to support');
201
+ }
202
+ /**
203
+ * Keeps an accessory that couldn't be set up in HomeKit (its room, name and automations stay)
204
+ * but makes it show "No Response", rather than old values and controls that silently do nothing
205
+ * (an alarm that seems to arm).
206
+ */
207
+ function markNotResponding(accessory) {
208
+ const failure = () => {
209
+ throw new hap.HapStatusError(-70402 /* hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
210
+ };
211
+ for (const service of accessory.services) {
212
+ if (service.UUID === hap.Service.AccessoryInformation.UUID)
213
+ continue;
214
+ for (const characteristic of service.characteristics) {
215
+ characteristic.onGet(failure);
216
+ if (characteristic.props.perms.includes("pw" /* hap.Perms.PAIRED_WRITE */)) {
217
+ characteristic.onSet(failure);
218
+ }
219
+ }
220
+ }
221
+ }
222
+ /** 30 s, 1 min, 2 min, ... up to 10 min between attempts to reach Ring at startup. */
223
+ export function getRetryDelayMs(attempt) {
224
+ return Math.min(30_000 * 2 ** attempt, 600_000);
225
+ }
185
226
  export class RingPlatform {
186
227
  homebridgeAccessories = {};
187
228
  log;
@@ -224,19 +265,52 @@ export class RingPlatform {
224
265
  queueTimeoutMs: 30_000,
225
266
  });
226
267
  logInfo(`Media profile ${media.profile}: recordingConcurrency=${media.recording.maxConcurrentRecordings}, codec=${media.recording.codec}`);
268
+ this.api.on('shutdown', () => {
269
+ this.shuttingDown = true;
270
+ clearTimeout(this.retryTimer);
271
+ });
227
272
  this.api.on('didFinishLaunching', () => {
228
273
  this.log.debug('didFinishLaunching');
229
274
  if (config.refreshToken) {
230
- this.connectToApi().catch((e) => {
231
- this.log.error('Error connecting to API');
232
- this.log.error(e);
233
- });
275
+ void this.connectWithRetry();
234
276
  }
235
277
  else {
236
278
  this.log.warn('Plugin is not configured. See the README of homebridge-ring-secure-video for setup instructions.');
237
279
  }
238
280
  });
239
281
  }
282
+ retryTimer;
283
+ shuttingDown = false;
284
+ devicesSetUp = false;
285
+ /**
286
+ * Connects to Ring, retrying later if Ring can't be reached when Homebridge starts (an outage,
287
+ * a server error), with a fresh connection each time: until then the alarm and every other
288
+ * accessory would stay unconnected. Not retried: an outdated Node.js, a failure once devices
289
+ * were being set up (which would set them up twice), and anything after shutdown.
290
+ */
291
+ async connectWithRetry(attempt = 0) {
292
+ try {
293
+ await this.connectToApi();
294
+ }
295
+ catch (e) {
296
+ const message = describeError(e);
297
+ if (this.shuttingDown || this.devicesSetUp || isPermanentRingError(e)) {
298
+ this.log.error(`Error connecting to Ring: ${message}`);
299
+ return;
300
+ }
301
+ const delay = getRetryDelayMs(attempt);
302
+ this.log.error(`Could not connect to Ring (${message}). Trying again in ${Math.round(delay / 1000)} s.${message.includes('Refresh token is not valid')
303
+ ? ' If this keeps happening, sign in again in the plugin settings.'
304
+ : ''}`);
305
+ this.retryTimer = setTimeout(() => {
306
+ void this.connectWithRetry(attempt + 1);
307
+ }, delay);
308
+ }
309
+ }
310
+ // A separate method so tests can stand in for Ring.
311
+ createRingApi(options) {
312
+ return new RingApi(options);
313
+ }
240
314
  configureAccessory(accessory) {
241
315
  logInfo(`Configuring cached accessory ${accessory.UUID} ${accessory.displayName}`);
242
316
  this.log.debug('%j', accessory);
@@ -260,7 +334,7 @@ export class RingPlatform {
260
334
  if (refreshTokenWithoutPushCredentials) {
261
335
  logInfo('Forcing Ring push credential refresh because Ring push credentials were removed from the startup token');
262
336
  }
263
- const ringApi = new RingApi({
337
+ const ringApi = this.createRingApi({
264
338
  controlCenterDisplayName,
265
339
  ...config,
266
340
  ...(refreshTokenWithoutPushCredentials
@@ -269,13 +343,48 @@ export class RingPlatform {
269
343
  systemId,
270
344
  });
271
345
  ringApi.onRefreshTokenUpdated.subscribe(createRefreshTokenSaver(this.api, config.refreshToken, Boolean(refreshTokenWithoutPushCredentials)));
272
- const locations = await ringApi.getLocations(), cachedAccessoryIds = Object.keys(this.homebridgeAccessories), platformAccessories = [], externalAccessories = [], activeAccessoryIds = [];
346
+ // A retry must sign in with the newest token (Ring may have replaced the old one) and must
347
+ // not force a new identity or push registration again.
348
+ ringApi.onRefreshTokenUpdated.subscribe(({ newRefreshToken }) => {
349
+ config.refreshToken = newRefreshToken;
350
+ config.forceRefreshRingPushCredentials = false;
351
+ config.forceRefreshRingApiSession = false;
352
+ });
353
+ let locations, loadedDevices;
354
+ try {
355
+ locations = await ringApi.getLocations();
356
+ // Load the devices of every location now: if none can be loaded (a Ring error while
357
+ // connecting to the alarm, say), a retry with a new connection may do better, and nothing
358
+ // has been set up yet. A location that fails while others work is reported below.
359
+ // Loaded once: after a failed connection, ring-client-api's getDevices() waits forever.
360
+ loadedDevices = await Promise.allSettled(locations.map((location) => location.getDevices()));
361
+ const firstFailure = loadedDevices.find((result) => result.status === 'rejected');
362
+ if (firstFailure && loadedDevices.every((result) => result.status === 'rejected')) {
363
+ throw firstFailure.reason;
364
+ }
365
+ }
366
+ catch (e) {
367
+ // A retry starts over with a new connection.
368
+ ringApi.disconnect();
369
+ throw e;
370
+ }
371
+ if (this.shuttingDown) {
372
+ ringApi.disconnect();
373
+ return;
374
+ }
375
+ this.devicesSetUp = true;
376
+ const cachedAccessoryIds = Object.keys(this.homebridgeAccessories), platformAccessories = [], externalAccessories = [], activeAccessoryIds = [];
273
377
  logInfo('Found the following locations:');
274
378
  locations.forEach((location) => {
275
379
  logInfo(` locationId: ${location.id} - ${location.name}`);
276
380
  });
277
- await Promise.all(locations.map(async (location) => {
278
- const devices = await location.getDevices(), { cameras, chimes, intercoms } = location, allDevices = [...devices, ...cameras, ...chimes, ...intercoms], securityPanel = devices.find((x) => x.deviceType === RingDeviceType.SecurityPanel), debugPrefix = debug ? 'TEST ' : '', hapDevices = allDevices.map((device) => {
381
+ // One location (or device) that fails to set up must not stop the others, the alarm
382
+ // included; its cached accessories are kept rather than removed as stale.
383
+ const locationResults = await Promise.allSettled(locations.map(async (location, locationIndex) => {
384
+ const loaded = loadedDevices[locationIndex];
385
+ if (loaded.status === 'rejected')
386
+ throw loaded.reason;
387
+ const devices = loaded.value, { cameras, chimes, intercoms } = location, allDevices = [...devices, ...cameras, ...chimes, ...intercoms], securityPanel = devices.find((x) => x.deviceType === RingDeviceType.SecurityPanel), debugPrefix = debug ? 'TEST ' : '', hapDevices = allDevices.map((device) => {
279
388
  const isCamera = device instanceof RingCamera, isIntercomVideo = device instanceof RingIntercom &&
280
389
  device.deviceType === 'intercom_handset_video', cameraIdentitySalt = isCamera && config.externalCameraIdSalt
281
390
  ? `-${config.externalCameraIdSalt}`
@@ -320,7 +429,7 @@ export class RingPlatform {
320
429
  });
321
430
  }
322
431
  if (config.locationModePollingSeconds &&
323
- (await location.supportsLocationModeSwitching())) {
432
+ (await location.supportsLocationModeSwitching().catch(() => false))) {
324
433
  hapDevices.push({
325
434
  deviceType: 'location.mode',
326
435
  device: location,
@@ -334,73 +443,104 @@ export class RingPlatform {
334
443
  logInfo(`Configuring ${cameras.length} cameras and ${hapDevices.length} devices for location "${location.name}" - locationId: ${location.id}`);
335
444
  hapDevices.forEach(({ deviceType, device, isCamera, isIntercomVideo, id, name, AccessoryClass, }) => {
336
445
  const uuid = hap.uuid.generate(debugPrefix + id), displayName = debugPrefix + name;
337
- if (!AccessoryClass ||
338
- (config.hideLightGroups &&
339
- deviceType === RingDeviceType.BeamsLightGroupSwitch) ||
340
- hideDeviceIds.includes(uuid) ||
341
- (onlyDeviceTypes && !onlyDeviceTypes.includes(deviceType))) {
342
- if (!ignoreHiddenDeviceTypes.includes(deviceType)) {
343
- logInfo(`Hidden accessory ${uuid} ${deviceType} ${displayName}`);
446
+ try {
447
+ if (!AccessoryClass ||
448
+ (config.hideLightGroups &&
449
+ deviceType === RingDeviceType.BeamsLightGroupSwitch) ||
450
+ hideDeviceIds.includes(uuid) ||
451
+ (onlyDeviceTypes && !onlyDeviceTypes.includes(deviceType))) {
452
+ if (!ignoreHiddenDeviceTypes.includes(deviceType)) {
453
+ logInfo(`Hidden accessory ${uuid} ${deviceType} ${displayName}`);
454
+ }
455
+ return;
344
456
  }
345
- return;
346
- }
347
- if (isCamera && this.homebridgeAccessories[uuid]) {
348
- // Camera was previously bridged. Remove it from the platform so that it can be added as an external accessory
349
- this.log.warn(`Camera ${displayName} was previously bridged. You will need to manually pair it as a new accessory.`);
350
- this.api.unregisterPlatformAccessories(pluginName, platformName, [
351
- this.homebridgeAccessories[uuid],
352
- ]);
353
- delete this.homebridgeAccessories[uuid];
354
- }
355
- const createHomebridgeAccessory = () => {
356
- const category = isCamera
357
- ? 17 /* hap.Categories.CAMERA */
358
- : isIntercomVideo
359
- ? 18 /* hap.Categories.VIDEO_DOORBELL */
360
- : 11 /* hap.Categories.SECURITY_SYSTEM */;
361
- const accessory = new api.platformAccessory(displayName, uuid, category);
362
- if (isCamera) {
363
- logInfo(`Configured camera ${uuid} ${deviceType} ${displayName}`);
364
- externalAccessories.push(accessory);
365
- }
366
- else if (isIntercomVideo) {
367
- logInfo(`Configured Ring Intercom Video ${uuid} ${deviceType} ${displayName}`);
368
- externalAccessories.push(accessory);
369
- }
370
- else {
371
- logInfo(`Adding new accessory ${uuid} ${deviceType} ${displayName}`);
372
- platformAccessories.push(accessory);
457
+ if (isCamera && this.homebridgeAccessories[uuid]) {
458
+ // Camera was previously bridged. Remove it from the platform so that it can be added as an external accessory
459
+ this.log.warn(`Camera ${displayName} was previously bridged. You will need to manually pair it as a new accessory.`);
460
+ this.api.unregisterPlatformAccessories(pluginName, platformName, [
461
+ this.homebridgeAccessories[uuid],
462
+ ]);
463
+ delete this.homebridgeAccessories[uuid];
373
464
  }
374
- return accessory;
375
- }, homebridgeAccessory = this.homebridgeAccessories[uuid] || createHomebridgeAccessory(), accessory = new AccessoryClass(device, homebridgeAccessory, config);
376
- accessory.initBase();
377
- this.homebridgeAccessories[uuid] = homebridgeAccessory;
378
- activeAccessoryIds.push(uuid);
379
- if (config.cameraMotionNotificationSensor &&
380
- accessory instanceof Camera &&
381
- accessory.motionDetected) {
382
- const sensorUuid = getMotionNotificationSensorUuid(debugPrefix + id), sensorName = `${displayName} Motion`;
383
- let sensorAccessory = this.homebridgeAccessories[sensorUuid];
384
- if (!sensorAccessory) {
385
- sensorAccessory = new api.platformAccessory(sensorName, sensorUuid, 10 /* hap.Categories.SENSOR */);
386
- logInfo(`Adding motion notification sensor ${sensorUuid} ${sensorName}`);
387
- platformAccessories.push(sensorAccessory);
465
+ let addTo;
466
+ const createHomebridgeAccessory = () => {
467
+ const category = isCamera
468
+ ? 17 /* hap.Categories.CAMERA */
469
+ : isIntercomVideo
470
+ ? 18 /* hap.Categories.VIDEO_DOORBELL */
471
+ : 11 /* hap.Categories.SECURITY_SYSTEM */;
472
+ const accessory = new api.platformAccessory(displayName, uuid, category);
473
+ if (isCamera) {
474
+ logInfo(`Configured camera ${uuid} ${deviceType} ${displayName}`);
475
+ addTo = externalAccessories;
476
+ }
477
+ else if (isIntercomVideo) {
478
+ logInfo(`Configured Ring Intercom Video ${uuid} ${deviceType} ${displayName}`);
479
+ addTo = externalAccessories;
480
+ }
481
+ else {
482
+ logInfo(`Adding new accessory ${uuid} ${deviceType} ${displayName}`);
483
+ addTo = platformAccessories;
484
+ }
485
+ return accessory;
486
+ }, homebridgeAccessory = this.homebridgeAccessories[uuid] || createHomebridgeAccessory(), accessory = new AccessoryClass(device, homebridgeAccessory, config);
487
+ accessory.initBase();
488
+ // Registered only once set up: a half-built accessory isn't added to HomeKit.
489
+ addTo?.push(homebridgeAccessory);
490
+ this.homebridgeAccessories[uuid] = homebridgeAccessory;
491
+ activeAccessoryIds.push(uuid);
492
+ if (config.cameraMotionNotificationSensor &&
493
+ accessory instanceof Camera &&
494
+ accessory.motionDetected) {
495
+ const sensorUuid = getMotionNotificationSensorUuid(debugPrefix + id), sensorName = `${displayName} Motion`;
496
+ let sensorAccessory = this.homebridgeAccessories[sensorUuid];
497
+ if (!sensorAccessory) {
498
+ sensorAccessory = new api.platformAccessory(sensorName, sensorUuid, 10 /* hap.Categories.SENSOR */);
499
+ logInfo(`Adding motion notification sensor ${sensorUuid} ${sensorName}`);
500
+ }
501
+ setUpMotionNotificationSensor(sensorAccessory, sensorName, `${device.id}-motion`, accessory.motionDetected);
502
+ if (!this.homebridgeAccessories[sensorUuid]) {
503
+ platformAccessories.push(sensorAccessory);
504
+ }
505
+ this.homebridgeAccessories[sensorUuid] = sensorAccessory;
506
+ activeAccessoryIds.push(sensorUuid);
388
507
  }
389
- setUpMotionNotificationSensor(sensorAccessory, sensorName, `${device.id}-motion`, accessory.motionDetected);
390
- this.homebridgeAccessories[sensorUuid] = sensorAccessory;
391
- activeAccessoryIds.push(sensorUuid);
508
+ }
509
+ catch (e) {
510
+ this.log.error(`Could not set up ${displayName} (${deviceType}): ${describeError(e)}. The other accessories are unaffected; restart Homebridge to try again.`);
511
+ const cached = this.homebridgeAccessories[uuid];
512
+ if (cached)
513
+ markNotResponding(cached);
514
+ // Keep it (and a motion sensor it had) in HomeKit rather than removing it as stale.
515
+ activeAccessoryIds.push(uuid, getMotionNotificationSensorUuid(debugPrefix + id));
392
516
  }
393
517
  });
394
518
  }));
519
+ const failedLocations = locations.filter((_, i) => locationResults[i].status === 'rejected');
520
+ locationResults.forEach((result, i) => {
521
+ if (result.status === 'rejected') {
522
+ const reason = result.reason;
523
+ this.log.error(`Could not set up the devices of location "${locations[i].name}": ${describeError(reason)}. Restart Homebridge to try again.`);
524
+ }
525
+ });
395
526
  if (platformAccessories.length) {
396
527
  api.registerPlatformAccessories(pluginName, platformName, platformAccessories);
397
528
  }
398
529
  if (externalAccessories.length) {
399
530
  api.publishExternalAccessories(pluginName, externalAccessories);
400
531
  }
401
- const staleAccessories = cachedAccessoryIds
402
- .filter((cachedId) => !activeAccessoryIds.includes(cachedId))
403
- .map((id) => this.homebridgeAccessories[id]);
532
+ // With a location that failed, which cached accessories belong to it is unknown: keep all,
533
+ // showing "No Response" until a restart sets them up.
534
+ if (failedLocations.length) {
535
+ cachedAccessoryIds
536
+ .filter((cachedId) => !activeAccessoryIds.includes(cachedId))
537
+ .forEach((id) => markNotResponding(this.homebridgeAccessories[id]));
538
+ }
539
+ const staleAccessories = failedLocations.length
540
+ ? []
541
+ : cachedAccessoryIds
542
+ .filter((cachedId) => !activeAccessoryIds.includes(cachedId))
543
+ .map((id) => this.homebridgeAccessories[id]);
404
544
  staleAccessories.forEach((staleAccessory) => {
405
545
  logInfo(`Removing stale cached accessory ${staleAccessory.UUID} ${staleAccessory.displayName}`);
406
546
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-ring-secure-video",
3
3
  "displayName": "Ring Secure Video",
4
- "version": "1.0.0-beta.3",
4
+ "version": "1.0.0-beta.4",
5
5
  "description": "Homebridge plugin for Ring devices with HomeKit Secure Video recording, based on homebridge-ring and homebridge-ring-hksv",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -85,7 +85,7 @@
85
85
  "category-outdoor"
86
86
  ],
87
87
  "allowScripts": {
88
- "ffmpeg-for-homebridge@2.2.1": true,
89
- "protobufjs@7.6.4": true
88
+ "ffmpeg-for-homebridge@2.2.2": true,
89
+ "protobufjs@7.6.6": true
90
90
  }
91
91
  }