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

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,85 @@
1
1
  # homebridge-ring-secure-video
2
2
 
3
+ ## 1.0.0-beta.6 (2026-11-13)
4
+
5
+ **Changes in behaviour to check after updating:**
6
+
7
+ - Requires Node.js 22 or 24 (Node 20 reached end of life in April 2026) and Homebridge 1.8 or
8
+ later (2.x recommended). Homebridge warns about a plugin whose requirements don't match.
9
+
10
+ **New:**
11
+
12
+ - **A doorbell press can start an HKSV recording** (`doorbellPressMotion`, "Doorbell Press Starts
13
+ a Recording", off by default). HomeKit only records when the camera's motion sensor turns on,
14
+ and Ring reports no motion while a live view is open, or before someone who presses and leaves
15
+ at once is noticed. With the setting on, on a Ring doorbell with HKSV on in the plugin
16
+ (`enableHksv`; by default not for doorbells on battery, see `disableHksvOnBattery`), a press
17
+ also turns on the camera's motion for 30 seconds (a press during a live view is not tested
18
+ yet). A press while motion is already on keeps it on, so it stays one recording per visit.
19
+ Notifications and automations on the camera's motion sensor also see it; the separate
20
+ notification sensor doesn't, as a press already gives a doorbell notification. No effect with
21
+ `hideCameraMotionSensor` on.
22
+
23
+ **Docs:**
24
+
25
+ - Recording codecs: what each one does and costs, which settings apply to which, and what `auto`
26
+ picks per profile and machine. `copy`, `h264_v4l2m2m` and `libx264` all gave complete,
27
+ fully playing clips on a Raspberry Pi 4 with a Ring Video Doorbell 3 Plus; the exact settings
28
+ are listed, and the Pi recommendation is now the `lowPower` profile with its default, `copy`.
29
+ On a Pi 5 (no hardware encoder), set `copy` or `libx264` rather than relying on `auto` in the
30
+ `adaptive` and `quality` profiles.
31
+ - While a live view is open, Ring reports no motion, so no motion recording or notification
32
+ starts then (a running recording carries on until its motion ends).
33
+ - "What it adds to homebridge-ring", supported Node.js and Homebridge versions, and a correction
34
+ to the automatic session refresh description: events without a person are left out only when
35
+ the camera alerts for people only.
36
+
37
+ ## 1.0.0-beta.5 (2026-11-13)
38
+
39
+ **Changes in behaviour to check after updating:**
40
+
41
+ - Automatic Ring session refresh is on by default. When it runs, a child bridge restarts itself,
42
+ a new `homebridge-ring-secure-video` entry appears in Authorized Client Devices in the Ring app
43
+ (remove the older one), and the identity in `.ring.json` changes, which other Ring plugins in
44
+ the same Homebridge (such as homebridge-ring) also use the next time they sign in from scratch.
45
+ If Ring is set to send no motion alerts in some Modes or on a schedule, set
46
+ `"automaticSessionRefresh": false`.
47
+
48
+ **New:**
49
+
50
+ - **Automatic Ring session refresh** (`automaticSessionRefresh`, on by default). Ring can stop
51
+ sending push notifications to the plugin's device identity while the connection to Google's
52
+ push service stays healthy: motion then only arrives late from the event history, and doorbell
53
+ presses not at all. When Ring's event history shows 3 motion events over at least 30 minutes
54
+ that Ring sent no push notification for, and no push notification of any kind arrived in
55
+ between, the plugin does what "Force Ring API Session Refresh" does: a new device identity and
56
+ push registration. A child bridge restarts itself for it; on the main bridge it happens at the
57
+ next Homebridge restart (dropped if a push notification arrives first). At most once a day,
58
+ and no more after 2 refreshes that brought no motion push. Not counted:
59
+ motion while snoozed, and motion without a person when the camera alerts for people only.
60
+ Needs the motion history fallback (on by default). After a refresh the log says "Automatic Ring
61
+ session refresh done", naming the Authorized Client Devices entry to remove.
62
+ - **"Ring Session Refreshed" sensor** (`sessionRefreshSensor`, off by default): a contact sensor
63
+ that opens after an automatic session refresh and closes a day later, so the Home app can
64
+ notify you (turn on its notifications; needs a home hub). Needs Automatic Ring Session Refresh.
65
+
66
+ **Fixes:**
67
+
68
+ - A child bridge that Homebridge restarted on its own (after a crash, or for the automatic
69
+ refresh) signed in with the Ring token from when Homebridge started, and couldn't save the
70
+ next one; a "Force Ring ..." setting cleared since was on again. It now takes the token and
71
+ the force settings from its block in config.json.
72
+ - New Ring tokens are also saved when the platform block in config.json is named
73
+ `"homebridge-ring-secure-video.Ring Secure Video"`; before, they weren't, and the next start
74
+ signed in with an old token.
75
+ - README: what to do when Ring's push notifications stop arriving (Troubleshooting).
76
+
77
+ **Maintenance:** dependency updates (`@homebridge/plugin-ui-utils` 2.3, ajv, globals,
78
+ typescript-eslint, concurrently 10), Homebridge 2.5 for development, and newer GitHub Actions for
79
+ CI and publishing. `@eneris/push-receiver` stays at 4.3.1 and ring-client-api at 14.3.0;
80
+ Dependabot no longer bundles them with other updates. werift stays at 0.22.4 (the version
81
+ ring-client-api pins), and Dependabot now ignores it.
82
+
3
83
  ## 1.0.0-beta.4 (2026-11-13)
4
84
 
5
85
  More fixes from the full independent review of 1.0.0-beta.2.
package/README.md CHANGED
@@ -11,6 +11,30 @@
11
11
  locks, lights, cameras and doorbells), with HomeKit Secure Video (HKSV) recording for Ring cameras.
12
12
  It is meant to replace `homebridge-ring` on its own: one plugin, one Ring sign-in.
13
13
 
14
+ ## What it adds to homebridge-ring
15
+
16
+ Everything `homebridge-ring` exposes is here too (it is not a drop-in swap, though: the plugin and
17
+ platform names differ, so its accessories are new to HomeKit and are set up again), plus:
18
+
19
+ - **HomeKit Secure Video recording** for Ring cameras and doorbells, with media profiles for small
20
+ machines such as a Raspberry Pi (`media`, `hksvPerformanceMode`).
21
+ - **Instant motion notifications** with HKSV on: an optional separate motion sensor per camera
22
+ (`cameraMotionNotificationSensor`), so the Home app notifies without waiting for clip analysis.
23
+ - **Motion when Ring's push notifications fail**: motion from Ring's event history
24
+ (`enableCameraMotionHistory`), checked often only until push is seen to work.
25
+ - **Automatic recovery when Ring stops sending push notifications** (`automaticSessionRefresh`),
26
+ with an optional "Ring Session Refreshed" sensor (`sessionRefreshSensor`) to tell you.
27
+ - **A fix for dropped push notifications**: the push library ring-client-api ships (4.3.0) can drop
28
+ them; 4.3.1 is used instead.
29
+ - **A second visit is not missed**: a new Ring motion alert while the last one is still active
30
+ (ring-client-api keeps each active for 65 s) is reported to HomeKit as new motion, so it is
31
+ recorded and notified.
32
+ - **Ring Intercom Video** as a video doorbell camera, and `homeKitAccessoryTag` to expose the same
33
+ Ring device as a separate HomeKit accessory for testing.
34
+ - **Robustness**: one device or location that fails to set up doesn't stop the others; starting
35
+ while Ring can't be reached is retried; a restarted child bridge uses the newest Ring token;
36
+ config.json and the Ring identity are written so a power cut can't cut them short.
37
+
14
38
  ## Origin and Attribution
15
39
 
16
40
  This project is based on Dusty Greif's original Ring Homebridge ecosystem:
@@ -28,6 +52,8 @@ Big thanks to Dusty, trinityhades and all upstream contributors. This plugin reu
28
52
  | --- | --- |
29
53
  | `enableHksv` | Enables experimental HKSV support for eligible cameras; on by default, set `false` to turn it off |
30
54
  | `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 |
55
+ | `automaticSessionRefresh` | When Ring's event history shows motion that Ring sent no push notification for (3 events over at least 30 minutes, none pushed), refreshes the Ring API session by itself, as **Force Ring API Session Refresh** does (see Troubleshooting): a child bridge restarts for it, the main bridge does it at the next Homebridge restart. Events while motion is snoozed are not counted, nor, when the camera alerts for people only, events without a person. At most once a day, and no more after 2 refreshes that brought no motion push notification. Needs `enableCameraMotionHistory`. On by default; set `false` to turn it off, for example if Ring is set to send no motion alerts in some Modes or on a schedule |
56
+ | `sessionRefreshSensor` | Adds a "Ring Session Refreshed" contact sensor that opens after an automatic session refresh (10 s after the Home app is connected to it, within 3 minutes) and stays open for a day, also across restarts (which don't notify again). Turn on its notifications in the Home app (needs a home hub) to be told when to remove the older `homebridge-ring-secure-video` entry from Authorized Client Devices. Needs `automaticSessionRefresh`. Off by default |
31
57
  | `disableHksvOnBattery` | Disables HKSV on cameras running on battery to reduce battery/network usage; wired battery models keep it. On by default |
32
58
  | `hksvPrebufferLengthMs` | HKSV prebuffer duration (minimum 4000ms) |
33
59
  | `hksvFragmentLengthMs` | HKSV fragment duration target |
@@ -41,6 +67,7 @@ Big thanks to Dusty, trinityhades and all upstream contributors. This plugin reu
41
67
  | `cameraVideoCodec` | Preferred H.264 handling (`auto`, `copy`, `h264_v4l2m2m`, `h264_videotoolbox`, or `libx264`) |
42
68
  | `hideDoorbellSwitch` / `hideCameraMotionSensor` / `hideCameraSirenSwitch` | Hides specific HomeKit-exposed services |
43
69
  | `cameraMotionNotificationSensor` | Adds a separate "<camera> Motion" sensor per camera for instant motion notifications (see below) |
70
+ | `doorbellPressMotion` | For Ring doorbells with HKSV recording on: a press also turns on the camera's motion for 30 seconds, so HKSV records the visitor even when Ring reported no motion (during a live view, or a quick press-and-leave). One recording per visit, not two. Notifications and automations on the camera's motion sensor also see it; the separate notification sensor does not. No effect with `hideCameraMotionSensor`. Off by default |
44
71
  | `showPanicButtons` | Adds panic switches (use with caution) |
45
72
  | `ffmpegPath` | Override FFmpeg binary path |
46
73
  | `debug` | Enables additional logging |
@@ -58,7 +85,8 @@ normally ends a recording itself when motion stops), or to `0` to disable it.
58
85
 
59
86
  ## Installation
60
87
 
61
- If Homebridge is installed globally:
88
+ Requires Node.js 22 or 24 and Homebridge 1.8 or later (2.x recommended). If Homebridge is installed
89
+ globally:
62
90
 
63
91
  ```bash
64
92
  npm i -g homebridge-ring-secure-video
@@ -116,12 +144,21 @@ Changing `homeKitAccessoryTag` updates both the exposed accessory name and the g
116
144
  HKSV support is experimental and actively evolving. Behavior may vary by camera model, wired vs battery power, Ring API changes, and FFmpeg environment.
117
145
 
118
146
  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.
147
+ (Node 22, Homebridge 2) with each of `copy`, `h264_v4l2m2m` and `libx264` (see below). The HKSV fork this continues reported 3 cameras on an M4 Mac mini.
120
148
  Please report your experience and setup details to help improve support.
121
149
 
122
150
  Recordings follow the frame rate and audio format HomeKit asks for, and include audio only when
123
151
  **Record Audio** is on in the camera's settings in the Home app.
124
152
 
153
+ While a live view of the camera is open in the Home app, Ring reports no motion for it: its Event
154
+ History shows only the live view (probably also with a live view in the Ring app; not tested). So
155
+ no motion event, motion notification or HKSV recording starts during a live view (with Ring
156
+ Protect, Ring may still save the live view itself). Once the live view is closed, motion is
157
+ reported again. Opening a live view doesn't cut off an HKSV recording that is already running
158
+ (both share one Ring connection), but that recording still ends as usual once its motion ends.
159
+ With `doorbellPressMotion` on, a doorbell press during a live view still starts a recording, as
160
+ long as Ring delivers the press during the live view (not tested yet).
161
+
125
162
  With recording on, the Home app notifies about a camera's motion only after its home hub has
126
163
  analysed the clip, which can be late or never. For a notification as soon as Ring reports
127
164
  motion, turn on `cameraMotionNotificationSensor`: each camera gets a separate "<camera> Motion"
@@ -129,29 +166,69 @@ sensor on this plugin's bridge. If the bridge isn't in the Home app yet, add it
129
166
  code, then turn on notifications in the sensor's settings (and, if you like, turn off the
130
167
  camera's own Activity Notifications to avoid two alerts).
131
168
 
132
- For fast-motion pixelation or stuttering in HKSV recordings, try increasing
133
- `hksvVideoBitrateKbps` first if you are transcoding. On Apple Silicon Macs,
134
- `cameraVideoCodec: "h264_videotoolbox"` can reduce CPU load by using hardware
135
- encoding.
169
+ For fast-motion pixelation or stuttering in HKSV recordings with an encoder (not `copy`), try
170
+ increasing `media.recording.bitrateKbps` first. On Apple Silicon Macs, `"codec":
171
+ "h264_videotoolbox"` should reduce CPU load by using the hardware encoder (not verified in this
172
+ fork).
173
+
174
+ ### Recording codecs
175
+
176
+ Only HKSV recordings are encoded; live view passes Ring's video through. `media.recording.codec`
177
+ (or the older `cameraVideoCodec`) chooses how:
178
+
179
+ | Codec | What it does | CPU |
180
+ | --- | --- | --- |
181
+ | `copy` | Records Ring's H.264 as received, without re-encoding (Ring's own picture) | Least |
182
+ | `h264_v4l2m2m` | Raspberry Pi 4 hardware encoder (the Pi 5 has none) | Low to medium: Ring's video is still decoded and scaled in software |
183
+ | `h264_videotoolbox` | Mac hardware encoder (not verified in this fork) | Low |
184
+ | `libx264` | Software encoder, with the most control over quality | Most |
185
+ | `auto` | Picks one of the above, see below | |
186
+
187
+ `copy` works when Ring's stream meets HomeKit's recording requirements, as it does for the
188
+ Doorbell 3 Plus; if clips from another camera don't appear or don't play, choose an encoder.
189
+ `bitrateKbps`, `maxBitrateKbps`, `bufferSizeKbps`, `rateControl` and `keyframeInterval` apply to
190
+ all encoders; `preset` and `crf` (the latter only with `vbr`) apply only to `libx264`. `copy`
191
+ ignores all of them. The Pi's hardware encoder uses `bitrateKbps` and `keyframeInterval`;
192
+ `rateControl` likely makes little difference there.
193
+
194
+ #### What `auto` picks
195
+
196
+ `auto` is the default, except in the `lowPower` profile, whose default is `copy`.
136
197
 
137
- For Raspberry Pi 4/5 and other small computers, start with what has been verified on a Pi 4:
198
+ | Profile (`media.profile`) | `auto` picks |
199
+ | --- | --- |
200
+ | `lowPower` (also `"hksvPerformanceMode": "rpi"`) | `copy` |
201
+ | `adaptive` (default) or `quality` | `h264_v4l2m2m` if FFmpeg has it (Raspberry Pi 4), else `h264_videotoolbox` if FFmpeg has it (Mac), else `libx264` |
202
+
203
+ "FFmpeg has it" means the encoder is built into FFmpeg, not that the hardware is there: on a
204
+ Raspberry Pi 5 (no hardware encoder), set `codec` to `copy` or `libx264` rather than relying on
205
+ `auto` in the `adaptive` and `quality` profiles. A valid legacy `cameraVideoCodec` in the config
206
+ counts as the codec when `media.recording.codec` isn't set. The log shows the choice for each recording: `Starting HKSV recording pipeline ...
207
+ video=<codec>`.
208
+
209
+ #### Settings verified on a Raspberry Pi 4
210
+
211
+ Raspberry Pi 4, Ring Video Doorbell 3 Plus (wired), Homebridge 2.5, Node 22, FFmpeg from
212
+ ffmpeg-for-homebridge. HomeKit asked for 1280x720 at 30 fps with 4-second fragments; every
213
+ setting below gave complete clips that play fully in the Home app.
214
+
215
+ | Codec | `media` settings | Notes |
216
+ | --- | --- | --- |
217
+ | `copy` | `{ "profile": "lowPower", "recording": { "codec": "copy" } }` | Least CPU, most natural picture; the recommended start on a Pi |
218
+ | `h264_v4l2m2m` | `{ "profile": "lowPower", "recording": { "codec": "h264_v4l2m2m", "bitrateKbps": 1500, "keyframeInterval": 30 } }` | FFmpeg used about 75% of one of the Pi's four cores while recording |
219
+ | `libx264` | `{ "profile": "lowPower", "recording": { "codec": "libx264", "bitrateKbps": 800, "keyframeInterval": 112 } }` | The `lowPower` preset is `ultrafast` |
220
+
221
+ For a Raspberry Pi, start with:
138
222
 
139
223
  ```json
140
224
  {
141
225
  "enableHksv": true,
142
226
  "media": {
143
- "profile": "lowPower",
144
- "recording": { "codec": "libx264", "bitrateKbps": 800 }
227
+ "profile": "lowPower"
145
228
  }
146
229
  }
147
230
  ```
148
231
 
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
-
155
232
  ### Minimum specifications for HKSV:
156
233
  [TBD - will be added as more users test and report their setups]
157
234
 
@@ -163,16 +240,47 @@ For support and debugging:
163
240
 
164
241
  - Upstream Ring wiki (token/auth/camera references): <https://github.com/dgreif/ring/wiki>
165
242
 
166
- If motion and doorbell events arrive late (the log shows "Detected Motion from Ring event history
167
- fallback" rather than right away), Ring's instant notifications aren't reaching the plugin. At start the
168
- log says which `@eneris/push-receiver` the Ring push client uses ("Ring push notifications: ..."), or
169
- warns "Could not fix Ring push notifications" if the plugin couldn't give it the fixed 4.3.1. See also the upstream [notification troubleshooting](https://github.com/dgreif/ring/wiki/Notification-Troubleshooting).
170
-
171
- Use the "Force Ring Push Credential Refresh" and "Force Ring API Session Refresh" settings only as a
172
- last resort, once. Each refresh adds a registration with Ring, and repeated ones can make Ring stop
173
- sending notifications to the plugin. If that happens, remove `homebridge-ring-secure-video` from
174
- Authorized Client Devices in the Ring app's Control Center, create a new refresh token, and paste it
175
- into the plugin's settings.
243
+ ### Motion arrives late or doorbell presses are missed
244
+
245
+ If motion arrives 20–30 seconds late with "Detected Motion from Ring event history fallback" in the
246
+ log (or "Ring event history shows motion no push notification reported"), and doorbell presses don't
247
+ reach HomeKit at all, Ring's instant push notifications aren't reaching the plugin. Once they do, the
248
+ "...from Ring event history fallback" lines stop, and (with the motion history fallback on, the
249
+ default) the log says "Ring push notifications are arriving".
250
+
251
+ 1. Check that the Homebridge machine can reach Google's push service: `mtalk.google.com` must
252
+ resolve to a real address (not `0.0.0.0`; DNS ad blockers such as AdGuard or Pi-hole can block it)
253
+ and TCP port 5228 must be open. See the upstream
254
+ [notification troubleshooting](https://github.com/dgreif/ring/wiki/Notification-Troubleshooting).
255
+ 2. If the connection is fine, Ring may have stopped sending to the plugin's device identity, even
256
+ though the Ring app on your phone still gets alerts. Removing the plugin from Authorized Client
257
+ Devices and signing in again keeps the same identity (it is stored in `.ring.json`), so it may not
258
+ help. What does: turn on **Force Ring API Session Refresh** in the plugin's settings once, and
259
+ restart. The log then shows "Forcing Ring API session refresh" (and usually "Session hardware_id
260
+ not found" as Ring meets the new identity), and the setting turns itself off once the new sign-in
261
+ is saved. Then, in
262
+ the Ring app's Control Center, remove the older `homebridge-ring-secure-video` entry from
263
+ Authorized Client Devices.
264
+
265
+ With **Automatic Ring Session Refresh** on (the default) and the motion history fallback on, the
266
+ plugin does step 2 by itself when Ring's event history shows 3 motion events over at least 30
267
+ minutes that Ring sent no push notification for, and no push arrived in between. Events while
268
+ motion is snoozed, and events Ring saw no person in (which Smart Alerts may keep quiet), are not
269
+ counted. The log then says "Ring event history shows motion that Ring sent no push notification
270
+ for", and as a child bridge it restarts (on the main bridge, the refresh waits for the next
271
+ Homebridge restart, and is dropped if a push arrives first). It does this at most once a day, and
272
+ stops after 2 refreshes that brought no motion push notification, logging "Ring still sends no
273
+ push notifications after 2 automatic Ring session refreshes": then check step 1 and Authorized
274
+ Client Devices. If you have Ring send no motion alerts on purpose (for example in some Modes, or on
275
+ a schedule), turn the setting off, or the plugin will take the missing alerts for a fault.
276
+
277
+ Use the "Force Ring ..." settings only for this, and only once. Each API session refresh, automatic
278
+ or not, adds an entry to Authorized Client Devices, and many entries can themselves make Ring stop
279
+ sending notifications, so after one remove the older `homebridge-ring-secure-video` entry (after
280
+ an automatic refresh the log says "Automatic Ring session refresh done", and the optional
281
+ `sessionRefreshSensor` opens in the Home app). It also
282
+ changes the identity that other Ring plugins in the same Homebridge (such as homebridge-ring) will
283
+ use the next time they sign in from scratch, since they share `.ring.json`.
176
284
 
177
285
  ## Disclaimer
178
286
 
@@ -152,6 +152,18 @@
152
152
  "type": "boolean",
153
153
  "description": "Advanced recovery option: rotates the Ring API hardware/session identity and forces fresh push credentials on next startup. Try this when refreshing only Ring push credentials still leaves motion/button events delayed or missing. The option clears itself after the updated refresh token is saved. The identity is stored in .ring.json in the Homebridge storage folder, which other Ring plugins in the same Homebridge (such as homebridge-ring) also use, so rotating it can affect their sign-in."
154
154
  },
155
+ "automaticSessionRefresh": {
156
+ "title": "Automatic Ring Session Refresh",
157
+ "type": "boolean",
158
+ "description": "When Ring's event history shows motion but Ring has sent the plugin no push notification for it (3 events over at least 30 minutes, none pushed), the plugin does a Ring API session refresh by itself, as Force Ring API Session Refresh does: as a child bridge it restarts, otherwise at the next restart of Homebridge. Events while motion is snoozed are not counted, nor, when the camera alerts for people only, events without a person. At most once a day, and no more after 2 that brought no motion push notification. Needs the camera motion history fallback. Turn this off if Ring is set to send no motion alerts in some Modes or on a schedule. Each refresh adds an entry to Authorized Client Devices in the Ring app (remove the older homebridge-ring-secure-video entries) and changes the identity in .ring.json, which other Ring plugins in the same Homebridge also use.",
159
+ "default": true
160
+ },
161
+ "sessionRefreshSensor": {
162
+ "title": "Session Refresh Sensor",
163
+ "type": "boolean",
164
+ "description": "Adds a \"Ring Session Refreshed\" contact sensor that opens after an automatic Ring session refresh (10 s after the Home app is connected to it, within 3 minutes) and stays open for a day, so the Home app can notify you (turn on its notifications; needs a home hub): then remove the older homebridge-ring-secure-video entry from Authorized Client Devices in the Ring app. Needs Automatic Ring Session Refresh.",
165
+ "default": false
166
+ },
155
167
  "enableCameraMotionHistory": {
156
168
  "title": "Enable Camera Motion History Fallback",
157
169
  "type": "boolean",
@@ -189,7 +201,7 @@
189
201
  "hksvPerformanceMode": {
190
202
  "title": "HKSV Performance Mode",
191
203
  "type": "string",
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.",
204
+ "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 copy, h264_v4l2m2m and libx264.",
193
205
  "default": "balanced",
194
206
  "oneOf": [
195
207
  {
@@ -307,7 +319,7 @@
307
319
  "cameraVideoCodec": {
308
320
  "title": "Camera Video Codec",
309
321
  "type": "string",
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.",
322
+ "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 on a Raspberry Pi 4 with a Ring Video Doorbell 3 Plus for copy, h264_v4l2m2m and libx264; if clips from another camera don't play with copy, try h264_v4l2m2m (Raspberry Pi 4; the Pi 5 has no hardware H.264 encoder) or libx264.",
311
323
  "oneOf": [
312
324
  {
313
325
  "title": "auto",
@@ -354,6 +366,12 @@
354
366
  "type": "boolean",
355
367
  "description": "Adds a \"<camera> Motion\" sensor for each camera, driven by the same motion events but not tied to the camera. With HomeKit Secure Video recording on, the Home app only notifies about the camera's own motion after analysing the clip; turn on notifications for this sensor instead to be told at once. It appears on this plugin's bridge."
356
368
  },
369
+ "doorbellPressMotion": {
370
+ "title": "Doorbell Press Starts a Recording",
371
+ "type": "boolean",
372
+ "description": "For Ring doorbells with HomeKit Secure Video recording on: a doorbell press also turns on the camera's motion in HomeKit for 30 seconds, so HomeKit records the visitor even when Ring reported no motion (for example while a live view is open, or for someone who presses and leaves at once). A press while motion is already on keeps it on: one recording, not two. Notifications and automations on the camera's own motion sensor also see it; the separate notification sensor does not, as a press already gives a doorbell notification. No effect when the camera's motion sensor is hidden.",
373
+ "default": false
374
+ },
357
375
  "hideCameraSirenSwitch": {
358
376
  "title": "Hide Camera Siren Switch",
359
377
  "type": "boolean"
@@ -484,6 +502,8 @@
484
502
  "media",
485
503
  "forceRefreshRingPushCredentials",
486
504
  "forceRefreshRingApiSession",
505
+ "automaticSessionRefresh",
506
+ "sessionRefreshSensor",
487
507
  "enableCameraMotionHistory",
488
508
  "disableHksvOnBattery",
489
509
  "hksvVideoPreset",
@@ -512,6 +532,7 @@
512
532
  "hideCameraLight",
513
533
  "hideCameraMotionSensor",
514
534
  "cameraMotionNotificationSensor",
535
+ "doorbellPressMotion",
515
536
  "hideCameraSirenSwitch",
516
537
  "hideInHomeDoorbellSwitch",
517
538
  "hideAlarmSirenSwitch",
package/lib/camera.js CHANGED
@@ -1,11 +1,11 @@
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, finalize, map, mergeMap, share, switchMap, throttleTime, } from 'rxjs/operators';
5
- import { CameraSource } from "./camera-source.js";
4
+ import { catchError, distinctUntilChanged, exhaustMap, filter, finalize, map, mergeMap, share, startWith, switchMap, throttleTime, } from 'rxjs/operators';
5
+ import { CameraSource, isHksvEnabled } 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, merge, of, timer } from 'rxjs';
8
+ import { combineLatest, defer, EMPTY, firstValueFrom, merge, of, timer } from 'rxjs';
9
9
  export const MOTION_WATCHDOG_MS = 30_000;
10
10
  /** How often Ring's event history is checked once its push notifications are known to arrive. */
11
11
  export const HISTORY_CHECK_WHILE_PUSH_WORKS_MS = 5 * 60_000;
@@ -33,7 +33,7 @@ const ID_MEMORY_MS = 60 * 60_000;
33
33
  * a gap (5-minute checks, a failure) only events from the last 3 minutes do, as one found later
34
34
  * would only record an empty doorway.
35
35
  */
36
- export function getHistoryMotionEvents(device, pollSeconds) {
36
+ export function getHistoryMotionEvents(device, pollSeconds, pushHealth) {
37
37
  return defer(() => {
38
38
  const startedAt = Date.now() - 5000, historySeen = new Map(),
39
39
  // Events that already gave motion from here (a young one is judged again later).
@@ -47,6 +47,7 @@ export function getHistoryMotionEvents(device, pollSeconds) {
47
47
  };
48
48
  let pushWorks = false, matchedInARow = 0, lastCheckAt = 0, lastSuccessAt = 0, failureCount = 0, nextCheckAt = 0;
49
49
  const notifications = device.onNewNotification.subscribe((notification) => {
50
+ pushHealth?.recordPush(notification.android_config?.category === PushNotificationAction.Motion);
50
51
  if (notification.android_config?.category !== PushNotificationAction.Motion)
51
52
  return;
52
53
  const id = notification.data?.event?.ding?.id, now = Date.now();
@@ -115,6 +116,14 @@ export function getHistoryMotionEvents(device, pollSeconds) {
115
116
  lastCheckAt = checkedAt - HISTORY_CHECK_WHILE_PUSH_WORKS_MS + PUSH_GRACE_MS + 10_000;
116
117
  }
117
118
  if (missed.length) {
119
+ // Ring sends no alert while motion is snoozed, nor, when set to alert for people
120
+ // only, for motion it saw no person in: those say nothing about whether its
121
+ // pushes reach the plugin.
122
+ const peopleOnly = device.data?.settings?.advanced_motion_detection_human_only_mode === true, unexplained = device.data?.motion_snooze
123
+ ? []
124
+ : missed.filter((event) => !peopleOnly || event.cv_properties?.person_detected !== false);
125
+ if (unexplained.length)
126
+ pushHealth?.recordMissed(unexplained.length);
118
127
  matchedInARow = 0;
119
128
  if (pushWorks) {
120
129
  pushWorks = false;
@@ -191,6 +200,32 @@ export function addMotionWatchdog(motionEvents, watchdogMs = MOTION_WATCHDOG_MS)
191
200
  return merge(of(true), timer(watchdogMs).pipe(map(() => false)));
192
201
  }));
193
202
  }
203
+ /**
204
+ * The camera's motion, also on for a while after each doorbell press (doorbellPressMotion), so
205
+ * HomeKit records a visitor even when Ring reported no motion: while a live view is open Ring
206
+ * reports none, and someone may press and leave before it does. A press while motion is already
207
+ * on keeps it on (no off and on again, which would be a second recording); motion goes off once
208
+ * neither the camera nor a press in the last watchdogMs keeps it on.
209
+ */
210
+ export function addDoorbellPressMotion(motion, presses, onPressMotion, watchdogMs = MOTION_WATCHDOG_MS) {
211
+ return combineLatest([
212
+ motion.pipe(startWith(false)),
213
+ addMotionWatchdog(presses.pipe(
214
+ // A failing press stream must not stop the camera's own motion.
215
+ catchError((error) => {
216
+ logError(error);
217
+ return EMPTY;
218
+ }), map(() => true)), watchdogMs).pipe(startWith(false)),
219
+ ]).pipe(
220
+ // Repeated trues are dropped (HomeKit sees no change from them anyway), so a second motion
221
+ // alert while motion is on no longer reloads the snapshot; a press reloads it itself.
222
+ distinctUntilChanged((a, b) => (a[0] || a[1]) === (b[0] || b[1])), map(([cameraMotion, pressMotion]) => {
223
+ const on = cameraMotion || pressMotion;
224
+ if (on && !cameraMotion)
225
+ onPressMotion?.();
226
+ return on;
227
+ }));
228
+ }
194
229
  export class Camera extends BaseDataAccessory {
195
230
  inHomeDoorbellStatus;
196
231
  cameraSource;
@@ -198,11 +233,13 @@ export class Camera extends BaseDataAccessory {
198
233
  accessory;
199
234
  config;
200
235
  motionDetected;
201
- constructor(device, accessory, config) {
236
+ pushHealth;
237
+ constructor(device, accessory, config, pushHealth) {
202
238
  super();
203
239
  this.device = device;
204
240
  this.accessory = accessory;
205
241
  this.config = config;
242
+ this.pushHealth = pushHealth;
206
243
  this.cameraSource = new CameraSource(this.device, this.config);
207
244
  if (!hap.CameraController) {
208
245
  const error = 'HAP CameraController not found. Please make sure you are on homebridge version 1.0.0 or newer';
@@ -226,11 +263,27 @@ export class Camera extends BaseDataAccessory {
226
263
  if (!config.hideCameraMotionSensor || config.cameraMotionNotificationSensor) {
227
264
  this.motionDetected = this.getMotionDetectedObservable().pipe(share());
228
265
  }
266
+ if (config.doorbellPressMotion && device.isDoorbot) {
267
+ if (config.hideCameraMotionSensor) {
268
+ logInfo(`${device.name}: doorbellPressMotion has no effect while hideCameraMotionSensor is on (HomeKit records on the camera's motion sensor)`);
269
+ }
270
+ else if (!isHksvEnabled(config, device.operatingOnBattery)) {
271
+ logInfo(`${device.name}: doorbellPressMotion has no effect without HKSV recording`);
272
+ }
273
+ }
229
274
  if (!config.hideCameraMotionSensor && this.motionDetected) {
275
+ // Only the camera's own motion sensor (which starts HKSV recordings): a press already
276
+ // gives a doorbell notification, so the separate notification sensor doesn't get it too.
277
+ // Only with HKSV recording: without it a press would just add a motion notification.
278
+ const cameraMotion = config.doorbellPressMotion &&
279
+ device.isDoorbot &&
280
+ isHksvEnabled(config, device.operatingOnBattery)
281
+ ? addDoorbellPressMotion(this.motionDetected, device.onDoorbellPressed, () => logInfo(`${device.name}: doorbell press sent to HomeKit as motion (doorbellPressMotion)`))
282
+ : this.motionDetected;
230
283
  this.registerObservableCharacteristic({
231
284
  characteristicType: Characteristic.MotionDetected,
232
285
  serviceType: Service.MotionSensor,
233
- onValue: this.motionDetected.pipe(switchMap((motion) => {
286
+ onValue: cameraMotion.pipe(switchMap((motion) => {
234
287
  if (!motion) {
235
288
  return Promise.resolve(false);
236
289
  }
@@ -365,7 +418,7 @@ export class Camera extends BaseDataAccessory {
365
418
  return addMotionWatchdog(pushMotionEvents);
366
419
  }
367
420
  const pollSeconds = Math.max(this.config.cameraStatusPollingSeconds ?? 20, 10);
368
- return addMotionWatchdog(merge(pushMotionEvents, getHistoryMotionEvents(this.device, pollSeconds)));
421
+ return addMotionWatchdog(merge(pushMotionEvents, getHistoryMotionEvents(this.device, pollSeconds, this.pushHealth)));
369
422
  }
370
423
  async loadSnapshotForEvent(eventDescription, characteristicValue) {
371
424
  let imageUuid = this.device.latestNotificationSnapshotUuid;
@@ -0,0 +1,136 @@
1
+ import { readFileSync } from 'fs';
2
+ import { performance } from 'perf_hooks';
3
+ import { join } from 'path';
4
+ import { logError, logInfo } from 'ring-client-api/util';
5
+ import { writeFileAtomic } from "./config.js";
6
+ const stateFileName = '.ring-secure-video-push.json';
7
+ /** Motion events in Ring's history with no push notification, since the last push... */
8
+ export const MISSES_BEFORE_REFRESH = 3;
9
+ /** ...spread over at least this long (one visit can make several events)... */
10
+ export const MISS_SPAN_BEFORE_REFRESH_MS = 30 * 60_000;
11
+ /** ...give an automatic session refresh, at most once in this long... */
12
+ export const REFRESH_INTERVAL_MS = 24 * 60 * 60_000;
13
+ /** ...and no more after this many in a row that brought no push. */
14
+ export const MAX_REFRESHES_WITHOUT_PUSH = 2;
15
+ export function readPushRecoveryState(storagePath) {
16
+ try {
17
+ const state = JSON.parse(readFileSync(join(storagePath, stateFileName)).toString());
18
+ return state && typeof state === 'object' ? state : {};
19
+ }
20
+ catch {
21
+ return {};
22
+ }
23
+ }
24
+ export function writePushRecoveryState(storagePath, state) {
25
+ try {
26
+ writeFileAtomic(join(storagePath, stateFileName), JSON.stringify(state));
27
+ return true;
28
+ }
29
+ catch (error) {
30
+ logError(`Failed to save ${stateFileName}`);
31
+ logError(error);
32
+ return false;
33
+ }
34
+ }
35
+ /**
36
+ * Notices when Ring has stopped sending push notifications to the plugin while its event history
37
+ * still shows motion, and then asks for a Ring API session refresh (a new device identity and push
38
+ * registration, as "Force Ring API Session Refresh" does), which is what brings them back.
39
+ *
40
+ * Only when no push at all arrived while the misses piled up: Ring sometimes drops a single
41
+ * notification, and then a refresh would only add an entry to Authorized Client Devices.
42
+ */
43
+ export class PushHealthMonitor {
44
+ missTimes = [];
45
+ pushSeen = false;
46
+ motionPushSeen = false;
47
+ /** A refresh was asked for in this run, and no push has arrived since. */
48
+ requested = false;
49
+ /** This run's refresh has not saved its new token yet: it must stay pending. */
50
+ holdPending = false;
51
+ done = false;
52
+ loggedWaitUntil = 0;
53
+ storagePath;
54
+ refresh;
55
+ now;
56
+ elapsed;
57
+ /**
58
+ * now: the clock saved with a refresh (it must survive restarts); elapsed: the clock the
59
+ * misses are timed with, which a clock set by NTP after a boot (a Pi has no clock of its own)
60
+ * can't make jump.
61
+ */
62
+ constructor(storagePath, refresh, now = Date.now, elapsed = () => performance.now()) {
63
+ this.storagePath = storagePath;
64
+ this.refresh = refresh;
65
+ this.now = now;
66
+ this.elapsed = elapsed;
67
+ }
68
+ /** Set while this run's own refresh has not saved its new token (see connectToApi). */
69
+ setHoldPending(hold) {
70
+ this.holdPending = hold;
71
+ }
72
+ /** Any push shows they reach the plugin; only a motion push ends the refresh count. */
73
+ recordPush(isMotion) {
74
+ this.missTimes = [];
75
+ const checkPending = !this.pushSeen || this.requested, firstMotionPush = isMotion && !this.motionPushSeen;
76
+ this.pushSeen = true;
77
+ this.requested = false;
78
+ this.motionPushSeen ||= isMotion;
79
+ if (!checkPending && !firstMotionPush)
80
+ return;
81
+ const state = readPushRecoveryState(this.storagePath), update = {};
82
+ // A refresh still waiting for a restart (main bridge) is no longer needed.
83
+ if (checkPending && state.refreshPending && !this.holdPending) {
84
+ update.refreshPending = false;
85
+ }
86
+ if (firstMotionPush && state.refreshesWithoutPush)
87
+ update.refreshesWithoutPush = 0;
88
+ if (!Object.keys(update).length)
89
+ return;
90
+ writePushRecoveryState(this.storagePath, { ...state, ...update });
91
+ if (update.refreshesWithoutPush === 0) {
92
+ logInfo('Ring push notifications arrive again after the automatic Ring session refresh');
93
+ }
94
+ }
95
+ recordMissed(count) {
96
+ if (this.done || count < 1)
97
+ return;
98
+ const at = this.elapsed();
99
+ for (let i = 0; i < count; i++)
100
+ this.missTimes.push(at);
101
+ if (this.missTimes.length < MISSES_BEFORE_REFRESH ||
102
+ at - this.missTimes[0] < MISS_SPAN_BEFORE_REFRESH_MS) {
103
+ return;
104
+ }
105
+ const now = this.now();
106
+ const state = readPushRecoveryState(this.storagePath), refreshes = state.refreshesWithoutPush ?? 0;
107
+ if (refreshes >= MAX_REFRESHES_WITHOUT_PUSH) {
108
+ this.done = true;
109
+ logError(`Ring still sends no push notifications after ${refreshes} automatic Ring session refreshes; not trying again until one arrives. Check that mtalk.google.com resolves and port 5228 is open, and remove old homebridge-ring-secure-video entries from Authorized Client Devices in the Ring app (see the README, Troubleshooting).`);
110
+ return;
111
+ }
112
+ // A refresh saved later than now (one of the clocks was wrong) counts as done now, so the
113
+ // next one waits at most a day.
114
+ if ((state.lastRefreshAt ?? 0) > now) {
115
+ state.lastRefreshAt = now;
116
+ writePushRecoveryState(this.storagePath, state);
117
+ }
118
+ const nextAllowedAt = (state.lastRefreshAt ?? 0) + REFRESH_INTERVAL_MS;
119
+ if (now < nextAllowedAt) {
120
+ if (this.loggedWaitUntil !== nextAllowedAt) {
121
+ this.loggedWaitUntil = nextAllowedAt;
122
+ logInfo(`Ring sends no push notifications for motion; the last automatic Ring session refresh was less than a day ago, so the next one is due after ${new Date(nextAllowedAt).toLocaleString()}`);
123
+ }
124
+ return;
125
+ }
126
+ this.done = true;
127
+ if (writePushRecoveryState(this.storagePath, {
128
+ refreshPending: true,
129
+ lastRefreshAt: now,
130
+ refreshesWithoutPush: refreshes + 1,
131
+ })) {
132
+ this.requested = true;
133
+ this.refresh();
134
+ }
135
+ }
136
+ }
@@ -19,6 +19,9 @@ import { Fan } from "./fan.js";
19
19
  import { Outlet } from "./outlet.js";
20
20
  import { Switch } from "./switch.js";
21
21
  import { Camera } from "./camera.js";
22
+ import { PushHealthMonitor, readPushRecoveryState, writePushRecoveryState, } from "./push-health.js";
23
+ import { readFileSync } from 'fs';
24
+ import { getSessionRefreshSensorUuid, SessionRefreshSensor, } from "./session-refresh-sensor.js";
22
25
  import { PanicButtons } from "./panic-buttons.js";
23
26
  import { logInfo, useLogger } from 'ring-client-api/util';
24
27
  import { FloodFreezeSensor } from "./flood-freeze-sensor.js";
@@ -135,7 +138,8 @@ function removePushCredentialsFromRefreshToken(refreshToken, removeHardwareId =
135
138
  }
136
139
  function updateRefreshTokenInConfig(configContents, oldRefreshToken, newRefreshToken, clearRefreshFlags) {
137
140
  try {
138
- const homebridgeConfig = JSON.parse(configContents), platformConfig = homebridgeConfig.platforms?.find((platform) => platform.platform === platformName &&
141
+ const homebridgeConfig = JSON.parse(configContents), platformConfig = homebridgeConfig.platforms?.find((platform) => (platform.platform === platformName ||
142
+ platform.platform === `${pluginName}.${platformName}`) &&
139
143
  platform.refreshToken === oldRefreshToken);
140
144
  if (!platformConfig) {
141
145
  throw new Error('Ring platform config not found');
@@ -151,15 +155,46 @@ function updateRefreshTokenInConfig(configContents, oldRefreshToken, newRefreshT
151
155
  return configContents.replace(oldRefreshToken, newRefreshToken);
152
156
  }
153
157
  }
158
+ /**
159
+ * This platform's block as saved in config.json now, if exactly one block matches and it has a
160
+ * Ring token.
161
+ *
162
+ * Homebridge restarts a child bridge with the config it read when it started, not the one in
163
+ * config.json now: after a renewal the token it hands over is out of date (and replacing it in
164
+ * config.json would then fail), and a force setting cleared since would be on again. The block is
165
+ * this child bridge's, or the one on the main bridge.
166
+ */
167
+ export function getSavedPlatformConfig(api, config) {
168
+ try {
169
+ // Homebridge upper-cases the username it hands over; config.json may have it either way.
170
+ const username = config._bridge?.username?.toUpperCase(), blocks = (JSON.parse(readFileSync(api.user.configPath()).toString()).platforms ?? []).filter((block) => (block.platform === platformName ||
171
+ block.platform === `${pluginName}.${platformName}`) &&
172
+ (username
173
+ ? String(block._bridge?.username ?? '').toUpperCase() === username
174
+ : !block._bridge));
175
+ const block = blocks.length === 1 ? blocks[0] : undefined;
176
+ return typeof block?.refreshToken === 'string' && block.refreshToken ? block : undefined;
177
+ }
178
+ catch {
179
+ return undefined;
180
+ }
181
+ }
182
+ function clearRefreshPending(storagePath, update = {}) {
183
+ const state = readPushRecoveryState(storagePath);
184
+ if (state.refreshPending) {
185
+ writePushRecoveryState(storagePath, { ...state, ...update, refreshPending: false });
186
+ }
187
+ }
154
188
  /**
155
189
  * Saves each new Ring refresh token into config.json, replacing the token that is there now.
156
190
  *
157
191
  * The token in config.json is tracked here rather than taken from ring-client-api's
158
192
  * "oldRefreshToken": after a forced refresh, ring-client-api starts from a modified token that
159
193
  * was never written to config.json, and following its old token would save only the first new
160
- * token and none after it (so the next start would register with Ring again).
194
+ * token and none after it (so the next start would register with Ring again). onSaved is called
195
+ * after the first save.
161
196
  */
162
- export function createRefreshTokenSaver(api, tokenInConfig, clearRefreshFlags) {
197
+ export function createRefreshTokenSaver(api, tokenInConfig, clearRefreshFlags, onSaved) {
163
198
  let current = tokenInConfig,
164
199
  // Only with the first save: turning a force setting back on later must survive renewals.
165
200
  clearFlags = clearRefreshFlags, warned = false;
@@ -174,6 +209,9 @@ export function createRefreshTokenSaver(api, tokenInConfig, clearRefreshFlags) {
174
209
  if (saved) {
175
210
  current = newRefreshToken;
176
211
  clearFlags = false;
212
+ const firstSave = onSaved;
213
+ onSaved = undefined;
214
+ firstSave?.();
177
215
  }
178
216
  else if (!warned) {
179
217
  // Once per run: after a new sign-in while running, every renewal would end up here.
@@ -206,7 +244,7 @@ export function isPermanentRingError(error) {
206
244
  */
207
245
  function markNotResponding(accessory) {
208
246
  const failure = () => {
209
- throw new hap.HapStatusError(-70402 /* hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
247
+ throw new hap.HapStatusError(hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
210
248
  };
211
249
  for (const service of accessory.services) {
212
250
  if (service.UUID === hap.Service.AccessoryInformation.UUID)
@@ -268,9 +306,22 @@ export class RingPlatform {
268
306
  this.api.on('shutdown', () => {
269
307
  this.shuttingDown = true;
270
308
  clearTimeout(this.retryTimer);
309
+ this.sessionRefreshSensor?.stop();
271
310
  });
311
+ if (config.automaticSessionRefresh !== false) {
312
+ this.pushHealth = new PushHealthMonitor(api.user.storagePath(), () => this.refreshSessionAutomatically());
313
+ }
272
314
  this.api.on('didFinishLaunching', () => {
273
315
  this.log.debug('didFinishLaunching');
316
+ const saved = config.refreshToken ? getSavedPlatformConfig(api, config) : undefined;
317
+ if (saved) {
318
+ if (saved.refreshToken !== config.refreshToken) {
319
+ this.log.info('Using the newer Ring token saved in config.json');
320
+ config.refreshToken = saved.refreshToken;
321
+ }
322
+ config.forceRefreshRingPushCredentials = Boolean(saved.forceRefreshRingPushCredentials);
323
+ config.forceRefreshRingApiSession = Boolean(saved.forceRefreshRingApiSession);
324
+ }
274
325
  if (config.refreshToken) {
275
326
  void this.connectWithRetry();
276
327
  }
@@ -282,6 +333,31 @@ export class RingPlatform {
282
333
  retryTimer;
283
334
  shuttingDown = false;
284
335
  devicesSetUp = false;
336
+ pushHealth;
337
+ gotNewToken = false;
338
+ sessionRefreshSensor;
339
+ startedAt = Date.now();
340
+ /**
341
+ * Ring has stopped sending push notifications: the session is refreshed at the next start (see
342
+ * connectToApi). A child bridge restarts itself for that; Homebridge starts it again at once.
343
+ */
344
+ refreshSessionAutomatically() {
345
+ if (this.shuttingDown)
346
+ return;
347
+ const reason = 'Ring event history shows motion that Ring sent no push notification for (3 events over at least 30 minutes, none pushed)';
348
+ if (this.config._bridge && typeof process.send === 'function') {
349
+ this.log.warn(`${reason}; restarting this child bridge to refresh the Ring API session (turn off "Automatic Ring Session Refresh" to stop this)`);
350
+ this.restartChildBridge();
351
+ }
352
+ else {
353
+ this.log.warn(`${reason}; the Ring API session will be refreshed the next time Homebridge restarts (run the plugin as a child bridge to have this done automatically)`);
354
+ }
355
+ }
356
+ // A separate method so tests can stand in for it. Homebridge's own shutdown (SIGTERM) stops
357
+ // the accessories cleanly, and a child bridge that ends that way is started again at once.
358
+ restartChildBridge() {
359
+ process.kill(process.pid, 'SIGTERM');
360
+ }
285
361
  /**
286
362
  * Connects to Ring, retrying later if Ring can't be reached when Homebridge starts (an outage,
287
363
  * a server error), with a fresh connection each time: until then the alarm and every other
@@ -317,9 +393,13 @@ export class RingPlatform {
317
393
  this.homebridgeAccessories[accessory.UUID] = accessory;
318
394
  }
319
395
  async connectToApi() {
320
- const { api, config } = this, shouldRotateRingApiSession = config.forceRefreshRingApiSession && config.refreshToken, systemId = shouldRotateRingApiSession
321
- ? rotateSystemId(api.user.storagePath())
322
- : getSystemId(api.user.storagePath()), configuredHomeKitAccessoryTag = config.homeKitAccessoryTag?.trim(), accessoryIdSuffix = configuredHomeKitAccessoryTag
396
+ const { api, config } = this, storagePath = api.user.storagePath(),
397
+ // Asked for by the push check (refreshSessionAutomatically) before the last restart.
398
+ refreshPendingInState = Boolean(readPushRecoveryState(storagePath).refreshPending),
399
+ // Like the force settings, not again on a retry once Ring has given a new token.
400
+ automaticRefresh = Boolean(this.pushHealth) && refreshPendingInState && !this.gotNewToken, shouldRotateRingApiSession = (config.forceRefreshRingApiSession || automaticRefresh) && config.refreshToken, systemId = shouldRotateRingApiSession
401
+ ? rotateSystemId(storagePath)
402
+ : getSystemId(storagePath), configuredHomeKitAccessoryTag = config.homeKitAccessoryTag?.trim(), accessoryIdSuffix = configuredHomeKitAccessoryTag
323
403
  ? `-${configuredHomeKitAccessoryTag}`
324
404
  : '', accessoryNameSuffix = configuredHomeKitAccessoryTag
325
405
  ? ` (${configuredHomeKitAccessoryTag})`
@@ -329,7 +409,17 @@ export class RingPlatform {
329
409
  ? removePushCredentialsFromRefreshToken(config.refreshToken, Boolean(shouldRotateRingApiSession))
330
410
  : undefined;
331
411
  if (shouldRotateRingApiSession) {
332
- logInfo('Forcing Ring API session refresh because forceRefreshRingApiSession is enabled');
412
+ logInfo(automaticRefresh && !config.forceRefreshRingApiSession
413
+ ? 'Forcing Ring API session refresh because Ring stopped sending push notifications (automatic session refresh)'
414
+ : 'Forcing Ring API session refresh because forceRefreshRingApiSession is enabled');
415
+ }
416
+ // Until its new token is saved, a push must not drop this run's refresh: the next start
417
+ // would sign in with the old token again.
418
+ if (automaticRefresh)
419
+ this.pushHealth?.setHoldPending(true);
420
+ // Asked for while the setting was on: not done later on old evidence once it is back on.
421
+ if (refreshPendingInState && !this.pushHealth) {
422
+ clearRefreshPending(storagePath);
333
423
  }
334
424
  if (refreshTokenWithoutPushCredentials) {
335
425
  logInfo('Forcing Ring push credential refresh because Ring push credentials were removed from the startup token');
@@ -342,13 +432,27 @@ export class RingPlatform {
342
432
  : {}),
343
433
  systemId,
344
434
  });
345
- ringApi.onRefreshTokenUpdated.subscribe(createRefreshTokenSaver(this.api, config.refreshToken, Boolean(refreshTokenWithoutPushCredentials)));
435
+ ringApi.onRefreshTokenUpdated.subscribe(createRefreshTokenSaver(this.api, config.refreshToken, Boolean(refreshTokenWithoutPushCredentials),
436
+ // Cleared once the new identity's token is saved: until then the next start would
437
+ // sign in with the old one.
438
+ automaticRefresh
439
+ ? () => {
440
+ const doneAt = Date.now();
441
+ this.pushHealth?.setHoldPending(false);
442
+ clearRefreshPending(storagePath, { refreshDoneAt: doneAt });
443
+ // Usually saved while connecting, before the sensor is set up (which then reads
444
+ // refreshDoneAt); this covers a save after that.
445
+ this.sessionRefreshSensor?.show(doneAt);
446
+ this.log.warn('Automatic Ring session refresh done: in the Ring app, Control Center > Authorized Client Devices now lists homebridge-ring-secure-video twice. Remove the older entry (keep the newest).');
447
+ }
448
+ : undefined));
346
449
  // A retry must sign in with the newest token (Ring may have replaced the old one) and must
347
450
  // not force a new identity or push registration again.
348
451
  ringApi.onRefreshTokenUpdated.subscribe(({ newRefreshToken }) => {
349
452
  config.refreshToken = newRefreshToken;
350
453
  config.forceRefreshRingPushCredentials = false;
351
454
  config.forceRefreshRingApiSession = false;
455
+ this.gotNewToken = true;
352
456
  });
353
457
  let locations, loadedDevices;
354
458
  try {
@@ -483,7 +587,7 @@ export class RingPlatform {
483
587
  addTo = platformAccessories;
484
588
  }
485
589
  return accessory;
486
- }, homebridgeAccessory = this.homebridgeAccessories[uuid] || createHomebridgeAccessory(), accessory = new AccessoryClass(device, homebridgeAccessory, config);
590
+ }, homebridgeAccessory = this.homebridgeAccessories[uuid] || createHomebridgeAccessory(), accessory = new AccessoryClass(device, homebridgeAccessory, config, this.pushHealth);
487
591
  accessory.initBase();
488
592
  // Registered only once set up: a half-built accessory isn't added to HomeKit.
489
593
  addTo?.push(homebridgeAccessory);
@@ -516,6 +620,20 @@ export class RingPlatform {
516
620
  }
517
621
  });
518
622
  }));
623
+ // Opens after an automatic session refresh, so the Home app can notify.
624
+ if (this.pushHealth && config.sessionRefreshSensor) {
625
+ const sensorUuid = getSessionRefreshSensorUuid(`${debug ? 'TEST ' : ''}ring-secure-video${accessoryIdSuffix}`), sensorName = `Ring Session Refreshed${accessoryNameSuffix}`;
626
+ let sensorAccessory = this.homebridgeAccessories[sensorUuid];
627
+ if (!sensorAccessory) {
628
+ sensorAccessory = new api.platformAccessory(sensorName, sensorUuid, 10 /* hap.Categories.SENSOR */);
629
+ logInfo(`Adding session refresh sensor ${sensorUuid} ${sensorName}`);
630
+ platformAccessories.push(sensorAccessory);
631
+ this.homebridgeAccessories[sensorUuid] = sensorAccessory;
632
+ }
633
+ this.sessionRefreshSensor = new SessionRefreshSensor(sensorAccessory, sensorName, this.startedAt);
634
+ this.sessionRefreshSensor.show(readPushRecoveryState(storagePath).refreshDoneAt);
635
+ activeAccessoryIds.push(sensorUuid);
636
+ }
519
637
  const failedLocations = locations.filter((_, i) => locationResults[i].status === 'rejected');
520
638
  locationResults.forEach((result, i) => {
521
639
  if (result.status === 'rejected') {
@@ -0,0 +1,106 @@
1
+ import { logInfo } from 'ring-client-api/util';
2
+ import { hap } from "./hap.js";
3
+ /** How long the sensor stays open after an automatic Ring session refresh. */
4
+ export const SESSION_REFRESH_SENSOR_OPEN_MS = 24 * 60 * 60_000;
5
+ /** After a home hub subscribes again, the sensor opens this much later... */
6
+ export const SESSION_REFRESH_SENSOR_SUBSCRIBED_DELAY_MS = 10_000;
7
+ /** ...or after this long at most. */
8
+ export const SESSION_REFRESH_SENSOR_MAX_DELAY_MS = 3 * 60_000;
9
+ // Kept stable so the accessory, and its room and notification settings, survive restarts.
10
+ export function getSessionRefreshSensorUuid(id) {
11
+ return hap.uuid.generate(`${id}-zm-session-refresh`);
12
+ }
13
+ /**
14
+ * A contact sensor that opens after an automatic Ring session refresh, so the Home app can
15
+ * notify: the older homebridge-ring-secure-video entry in Authorized Client Devices is then to
16
+ * be removed. It closes again a day later.
17
+ *
18
+ * The refresh happens right after a restart, and the Home app notifies for a change it sees, not
19
+ * for a value it finds: so after a refresh in this run the sensor opens 10 s after a Home app
20
+ * or home hub is subscribed to it (or after 3 minutes). After a restart later that day it is
21
+ * simply open, without a second notification.
22
+ */
23
+ export class SessionRefreshSensor {
24
+ characteristic;
25
+ runStartedAt;
26
+ openTimer;
27
+ closeTimer;
28
+ onSubscribe;
29
+ constructor(accessory, name, runStartedAt) {
30
+ const { Characteristic, Service } = hap;
31
+ accessory
32
+ .getService(Service.AccessoryInformation)
33
+ .setCharacteristic(Characteristic.Manufacturer, 'Ring')
34
+ .setCharacteristic(Characteristic.Model, 'Session refresh sensor')
35
+ .setCharacteristic(Characteristic.SerialNumber, 'session-refresh');
36
+ const service = accessory.getService(Service.ContactSensor) ??
37
+ accessory.addService(Service.ContactSensor, name);
38
+ // Its value is kept from the last run (Homebridge caches it) until show() decides: every
39
+ // update is sent to the Home app, so closing it here and opening it again in show() would
40
+ // notify a second time.
41
+ this.characteristic = service.getCharacteristic(Characteristic.ContactSensorState);
42
+ this.runStartedAt = runStartedAt;
43
+ }
44
+ set(value) {
45
+ if (this.characteristic.value !== value)
46
+ this.characteristic.updateValue(value);
47
+ }
48
+ /** Shows a refresh done at refreshDoneAt (if it is less than a day ago); closed otherwise. */
49
+ show(refreshDoneAt) {
50
+ const now = Date.now(), { ContactSensorState } = hap.Characteristic, closeAt = (refreshDoneAt ?? 0) + SESSION_REFRESH_SENSOR_OPEN_MS;
51
+ this.stop();
52
+ if (!refreshDoneAt || refreshDoneAt > now || closeAt <= now) {
53
+ this.set(ContactSensorState.CONTACT_DETECTED);
54
+ return;
55
+ }
56
+ const open = () => {
57
+ this.stopWaiting();
58
+ if (Date.now() < closeAt)
59
+ this.set(ContactSensorState.CONTACT_NOT_DETECTED);
60
+ };
61
+ if (refreshDoneAt < this.runStartedAt) {
62
+ open();
63
+ }
64
+ else {
65
+ // A refresh in this run: closed until the Home app can see it open.
66
+ this.set(ContactSensorState.CONTACT_DETECTED);
67
+ this.openTimer = setTimeout(() => {
68
+ open();
69
+ logInfo('Ring Session Refreshed sensor opened (remove the older entry in Authorized Client Devices)');
70
+ }, Math.min(SESSION_REFRESH_SENSOR_MAX_DELAY_MS, closeAt - now));
71
+ this.onSubscribe = () => {
72
+ clearTimeout(this.openTimer);
73
+ this.openTimer = setTimeout(() => {
74
+ open();
75
+ logInfo('Ring Session Refreshed sensor opened (remove the older entry in Authorized Client Devices)');
76
+ }, SESSION_REFRESH_SENSOR_SUBSCRIBED_DELAY_MS);
77
+ this.openTimer.unref?.();
78
+ this.stopWaiting();
79
+ };
80
+ this.openTimer.unref?.();
81
+ // hap-nodejs only reports the first subscriber; a home hub that came back while the
82
+ // plugin was connecting to Ring has subscribed already (the count is private).
83
+ if (this.characteristic.subscriptions) {
84
+ this.onSubscribe();
85
+ }
86
+ else {
87
+ this.characteristic.on("subscribe" /* hap.CharacteristicEventTypes.SUBSCRIBE */, this.onSubscribe);
88
+ }
89
+ }
90
+ this.closeTimer = setTimeout(() => {
91
+ this.set(ContactSensorState.CONTACT_DETECTED);
92
+ }, closeAt - now);
93
+ this.closeTimer.unref?.();
94
+ }
95
+ stopWaiting() {
96
+ if (this.onSubscribe) {
97
+ this.characteristic.removeListener("subscribe" /* hap.CharacteristicEventTypes.SUBSCRIBE */, this.onSubscribe);
98
+ this.onSubscribe = undefined;
99
+ }
100
+ }
101
+ stop() {
102
+ this.stopWaiting();
103
+ clearTimeout(this.openTimer);
104
+ clearTimeout(this.closeTimer);
105
+ }
106
+ }
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.4",
4
+ "version": "1.0.0-beta.6",
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",
@@ -18,7 +18,7 @@
18
18
  "dependencies": {
19
19
  "@eneris/push-receiver": "4.3.1",
20
20
  "@homebridge/camera-utils": "^3.0.0",
21
- "@homebridge/plugin-ui-utils": "^2.1.2",
21
+ "@homebridge/plugin-ui-utils": "^2.3.0",
22
22
  "ring-client-api": "14.3.0",
23
23
  "rxjs": "^7.8.2",
24
24
  "werift": "0.22.4"
@@ -26,18 +26,18 @@
26
26
  "devDependencies": {
27
27
  "@eslint/js": "9.39.2",
28
28
  "@typescript-eslint/parser": "^8.54.0",
29
- "ajv": "8.17.1",
30
- "concurrently": "^9.2.1",
29
+ "ajv": "8.20.0",
30
+ "concurrently": "^10.0.7",
31
31
  "eslint": "9.39.2",
32
- "globals": "17.3.0",
33
- "homebridge": "^2.1.0",
32
+ "globals": "17.14.0",
33
+ "homebridge": "^2.5.0",
34
34
  "homebridge-config-ui-x": "^5.24.0",
35
35
  "typescript": "5.9.3",
36
- "typescript-eslint": "8.54.0"
36
+ "typescript-eslint": "8.72.2"
37
37
  },
38
38
  "engines": {
39
- "node": "^20 || ^22 || ^24",
40
- "homebridge": ">=1.4.0 || ^2.0.0-beta.0"
39
+ "node": "^22 || ^24",
40
+ "homebridge": "^1.8.0 || ^2.0.0-beta.0"
41
41
  },
42
42
  "homebridge": {
43
43
  "pluginAlias": "Ring Secure Video",