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

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,28 @@
1
1
  # homebridge-ring-secure-video
2
2
 
3
+ ## 1.0.0-beta.3 (2026-11-13)
4
+
5
+ Fixes from a full independent review of 1.0.0-beta.2.
6
+
7
+ - A second visit 30–65 s after the first now turns HomeKit motion on again, so it is recorded and
8
+ notified. Ring keeps a motion alert active for 65 s and only reports changes, while the plugin
9
+ turns motion off after 30 s; a new alert in between gave nothing. A repeat of the same alert
10
+ (Ring's follow-up with the snapshot, a resend) is not a new visit, and the event-history
11
+ fallback no longer adds its own "no motion" after 65 s, which could cut a later visit short.
12
+ - A media setting the plugin can't use (for example a maximum bitrate below the bitrate, which
13
+ the settings form allows, or a stray key) no longer stops the plugin's bridge from starting
14
+ (which took the alarm down with it). It is ignored with a warning naming it, and the older
15
+ setting or the default is used. Default maximum bitrate and buffer size derived from a high
16
+ bitrate stay within their limits.
17
+ - After a forced Ring refresh ("Force Ring Push Credential Refresh" or "Force Ring API Session
18
+ Refresh"), every new Ring token is saved, not only the first; before, the next start
19
+ registered with Ring again. The force settings are cleared once, and a token that can't be
20
+ saved is logged.
21
+ - config.json and .ring.json are written to a temporary file and then renamed into place, so a
22
+ power cut or a full disk during a token renewal can't leave them cut short (Homebridge would not
23
+ start). Permissions, group and symlinks are kept; where that can't work (a bind-mounted file,
24
+ a file owned by another user, ...) they are written in place as before.
25
+
3
26
  ## 1.0.0-beta.2 (2026-11-13)
4
27
 
5
28
  - The Homebridge UI shows the plugin as "Ring Secure Video" (was "Homebridge Ring Secure Video"),
package/lib/camera.js CHANGED
@@ -1,11 +1,45 @@
1
1
  import { hap } from "./hap.js";
2
+ import { PushNotificationAction } from 'ring-client-api';
2
3
  import { BaseDataAccessory } from "./base-data-accessory.js";
3
4
  import { catchError, exhaustMap, filter, map, mergeMap, share, switchMap, throttleTime, } from 'rxjs/operators';
4
5
  import { CameraSource } from "./camera-source.js";
5
6
  import { TargetValueTimer } from "./target-value-timer.js";
6
7
  import { delay, logError, logInfo } from 'ring-client-api/util';
7
- import { EMPTY, firstValueFrom, from, merge, of, timer } from 'rxjs';
8
+ import { defer, EMPTY, firstValueFrom, from, merge, of, timer } from 'rxjs';
8
9
  export const MOTION_WATCHDOG_MS = 30_000;
10
+ /**
11
+ * Motion from Ring's push notifications, as HomeKit needs it: true for every new motion alert.
12
+ *
13
+ * ring-client-api's onMotionDetected is true while any motion alert is active (each lasts 65 s)
14
+ * and only reports changes, so a new alert while an earlier one is still active gives nothing.
15
+ * The watchdog below turns motion off after 30 s, so a second visit 30-65 s after the first
16
+ * would never turn it on again: no recording and no notification. So every new motion
17
+ * notification gives a true (restarting the watchdog), and onMotionDetected only adds its
18
+ * false once all alerts have expired. A notification repeating an alert seen in the last two
19
+ * minutes (Ring sends a second one with the snapshot; a reconnect can resend) is not a new visit.
20
+ */
21
+ export const REPEATED_ALERT_WINDOW_MS = 120_000;
22
+ export function getPushMotionEvents(camera) {
23
+ return defer(() => {
24
+ const seenAt = new Map();
25
+ return merge(camera.onMotionDetected.pipe(filter((motion) => !motion)), camera.onNewNotification.pipe(filter((notification) => {
26
+ if (notification.android_config?.category !== PushNotificationAction.Motion) {
27
+ return false;
28
+ }
29
+ const now = Date.now(), id = notification.data?.event?.ding?.id;
30
+ for (const [seenId, at] of seenAt) {
31
+ if (now - at > REPEATED_ALERT_WINDOW_MS)
32
+ seenAt.delete(seenId);
33
+ }
34
+ if (id === undefined)
35
+ return true;
36
+ if (seenAt.has(String(id)))
37
+ return false;
38
+ seenAt.set(String(id), now);
39
+ return true;
40
+ }), map(() => true)));
41
+ });
42
+ }
9
43
  export function addMotionWatchdog(motionEvents, watchdogMs = MOTION_WATCHDOG_MS) {
10
44
  return motionEvents.pipe(switchMap((motion) => {
11
45
  if (!motion) {
@@ -183,7 +217,7 @@ export class Camera extends BaseDataAccessory {
183
217
  }
184
218
  }
185
219
  getMotionDetectedObservable() {
186
- const pollSeconds = Math.max(this.config.cameraStatusPollingSeconds ?? 20, 10), startedAt = Date.now() - 5000, seenEventIds = new Set(), pushMotionEvents = this.device.onMotionDetected, enableMotionHistoryFallback = this.config.enableCameraMotionHistory !== false;
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;
187
221
  let historyFailureCount = 0;
188
222
  let nextHistoryPollAt = 0;
189
223
  const baseHistoryRetryMs = Math.max(pollSeconds * 1000, 30_000), polledNewMotionEvents = timer(pollSeconds * 1000, pollSeconds * 1000).pipe(exhaustMap(() => {
@@ -218,10 +252,10 @@ export class Camera extends BaseDataAccessory {
218
252
  logError(`${this.device.name} failed to poll Ring event history for motion fallback; retrying in ${Math.ceil(retryDelayMs / 1000)} seconds`);
219
253
  return EMPTY;
220
254
  }));
221
- }),
222
- // The false timer below has a second subscriber. Sharing keeps one
223
- // history request per poll instead of duplicating Ring API traffic.
224
- share()), polledMotionEvents = merge(polledNewMotionEvents, polledNewMotionEvents.pipe(switchMap(() => timer(65000).pipe(map(() => false)))));
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;
225
259
  if (enableMotionHistoryFallback) {
226
260
  this.device.onNewNotification.subscribe((notification) => {
227
261
  const dingId = notification.data?.event?.ding?.id;
package/lib/config.js CHANGED
@@ -1,14 +1,124 @@
1
- import { logError } from 'ring-client-api/util';
2
- import { mkdirSync, readFileSync, writeFileSync } from 'fs';
1
+ import { logError, logInfo } from 'ring-client-api/util';
2
+ import { closeSync, fchmodSync, fchownSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'fs';
3
3
  import { createHash, randomBytes } from 'crypto';
4
- import { join } from 'path';
4
+ import { basename, dirname, join } from 'path';
5
5
  const systemIdFileName = '.ring.json';
6
6
  export const controlCenterDisplayName = 'homebridge-ring-secure-video';
7
+ // Errors where a temporary file next to the target can't be created or can't replace it, but
8
+ // writing the file in place may still work: a folder you can't write to, a file bind-mounted on
9
+ // its own (Docker), a file another program has open on Windows, another file system, an owner or
10
+ // group a container can't map (EINVAL from fchown).
11
+ const writeInPlaceOn = new Set(['EACCES', 'EPERM', 'EBUSY', 'EXDEV', 'EROFS', 'EINVAL']);
12
+ const reportedInPlace = new Set();
13
+ function writeInPlace(filePath, contents, reason) {
14
+ // Once per file: a bind-mounted config.json would otherwise log this with every token renewal.
15
+ if (!reportedInPlace.has(filePath)) {
16
+ reportedInPlace.add(filePath);
17
+ logInfo(`Writing ${filePath} in place (${reason})`);
18
+ }
19
+ writeFileSync(filePath, contents);
20
+ }
21
+ function removeStaleTemporaryFiles(folder, name) {
22
+ try {
23
+ for (const entry of readdirSync(folder)) {
24
+ if (!entry.startsWith(`.${name}.`) || !entry.endsWith('.tmp'))
25
+ continue;
26
+ const file = join(folder, entry);
27
+ // Older than a minute: left behind by a crash, not another writer's work in progress.
28
+ if (Date.now() - statSync(file).mtimeMs > 60_000)
29
+ rmSync(file, { force: true });
30
+ }
31
+ }
32
+ catch {
33
+ // best effort
34
+ }
35
+ }
36
+ /**
37
+ * Replaces a file's contents so that it is never left half-written: the new contents go to a
38
+ * temporary file next to it, are flushed to disk, and then take the file's place in one step. A
39
+ * power cut or a full disk on a Raspberry Pi would otherwise leave config.json cut short, and
40
+ * Homebridge would not start. A symlinked file is written where its link points, and the file
41
+ * keeps its permissions. Where that can't work (the file belongs to someone else and this isn't
42
+ * root, a folder you can't write to, a bind-mounted file, ...) it is written in place as before.
43
+ */
44
+ export function writeFileAtomic(filePath, contents) {
45
+ let target = filePath, previous;
46
+ try {
47
+ target = realpathSync(filePath);
48
+ previous = statSync(target);
49
+ }
50
+ catch {
51
+ let dangling = false;
52
+ try {
53
+ dangling = lstatSync(filePath).isSymbolicLink();
54
+ }
55
+ catch {
56
+ // a new file
57
+ }
58
+ if (dangling) {
59
+ return writeInPlace(filePath, contents, 'its link points nowhere yet');
60
+ }
61
+ }
62
+ const uid = process.getuid?.();
63
+ if (previous && uid !== undefined && uid !== 0 && previous.uid !== uid) {
64
+ // A new file would be owned by this user instead of the file's owner.
65
+ return writeInPlace(target, contents, 'it belongs to another user');
66
+ }
67
+ const folder = dirname(target), name = basename(target), temporary = join(folder, `.${name}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
68
+ try {
69
+ const fd = openSync(temporary, 'wx', 0o600);
70
+ try {
71
+ // Unlike writeSync, this writes everything or throws (e.g. ENOSPC on a full disk).
72
+ writeFileSync(fd, contents);
73
+ // Owner and group first: changing them clears setuid/setgid bits set by a chmod.
74
+ if (previous && uid === 0) {
75
+ fchownSync(fd, previous.uid, previous.gid);
76
+ }
77
+ else if (previous && previous.gid !== process.getgid?.()) {
78
+ // A shared group (e.g. for the Homebridge UI); EPERM writes in place, keeping it.
79
+ fchownSync(fd, -1, previous.gid);
80
+ }
81
+ fchmodSync(fd, previous ? previous.mode & 0o7777 : 0o644 & ~process.umask());
82
+ fsyncSync(fd);
83
+ }
84
+ finally {
85
+ closeSync(fd);
86
+ }
87
+ renameSync(temporary, target);
88
+ }
89
+ catch (error) {
90
+ try {
91
+ rmSync(temporary, { force: true });
92
+ }
93
+ catch {
94
+ // the original error matters
95
+ }
96
+ const code = error.code;
97
+ if (code && writeInPlaceOn.has(code)) {
98
+ return writeInPlace(target, contents, code);
99
+ }
100
+ throw error;
101
+ }
102
+ // Make the rename itself survive a power cut (best effort; not possible on every system).
103
+ try {
104
+ const dir = openSync(folder, 'r');
105
+ try {
106
+ fsyncSync(dir);
107
+ }
108
+ finally {
109
+ closeSync(dir);
110
+ }
111
+ }
112
+ catch {
113
+ // ignore
114
+ }
115
+ removeStaleTemporaryFiles(folder, name);
116
+ }
7
117
  export function updateHomebridgeConfig(homebridge, update) {
8
118
  try {
9
119
  const configPath = homebridge.user.configPath(), config = readFileSync(configPath).toString(), updatedConfig = update(config);
10
120
  if (config !== updatedConfig) {
11
- writeFileSync(configPath, updatedConfig);
121
+ writeFileAtomic(configPath, updatedConfig);
12
122
  return true;
13
123
  }
14
124
  }
@@ -35,7 +145,7 @@ export function getSystemId(homebridgeStoragePath) {
35
145
  const systemId = createSystemId(), ringContext = { systemId };
36
146
  try {
37
147
  mkdirSync(homebridgeStoragePath, { recursive: true });
38
- writeFileSync(filePath, JSON.stringify(ringContext));
148
+ writeFileAtomic(filePath, JSON.stringify(ringContext));
39
149
  }
40
150
  catch (error) {
41
151
  logError('Failed to persist Ring system id');
@@ -47,7 +157,7 @@ export function rotateSystemId(homebridgeStoragePath) {
47
157
  const filePath = join(homebridgeStoragePath, systemIdFileName), systemId = createSystemId(), ringContext = { systemId };
48
158
  try {
49
159
  mkdirSync(homebridgeStoragePath, { recursive: true });
50
- writeFileSync(filePath, JSON.stringify(ringContext));
160
+ writeFileAtomic(filePath, JSON.stringify(ringContext));
51
161
  }
52
162
  catch (error) {
53
163
  logError('Failed to rotate Ring system id');
@@ -25,8 +25,18 @@ const recordingKeys = new Set([
25
25
  'maxQueuedBytes',
26
26
  'maxConcurrentRecordings',
27
27
  ]);
28
+ /** A media setting that can't be used; path is where it is, e.g. "media.recording.crf". */
29
+ export class MediaConfigError extends Error {
30
+ path;
31
+ expectation;
32
+ constructor(path, expectation) {
33
+ super(`Invalid media configuration at ${path}: expected ${expectation}`);
34
+ this.path = path;
35
+ this.expectation = expectation;
36
+ }
37
+ }
28
38
  function invalid(path, expectation) {
29
- throw new Error(`Invalid media configuration at ${path}: expected ${expectation}`);
39
+ throw new MediaConfigError(path, expectation);
30
40
  }
31
41
  function isRecord(value) {
32
42
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -126,8 +136,8 @@ export function normalizeMediaConfig(config) {
126
136
  const input = media.recording;
127
137
  const lowPower = profile === 'lowPower';
128
138
  const bitrateKbps = preferInteger(input.bitrateKbps, config.hksvVideoBitrateKbps, 'media.recording.bitrateKbps', lowPower ? 1000 : profile === 'quality' ? 5000 : 3000, 256, 12000);
129
- const maxBitrateKbps = preferInteger(input.maxBitrateKbps, config.hksvVideoMaxBitrateKbps, 'media.recording.maxBitrateKbps', bitrateKbps * 2, bitrateKbps, 20000);
130
- const bufferSizeKbps = preferInteger(input.bufferSizeKbps, config.hksvVideoBufferSizeKbps, 'media.recording.bufferSizeKbps', maxBitrateKbps * 2, maxBitrateKbps, 40000);
139
+ const maxBitrateKbps = preferInteger(input.maxBitrateKbps, config.hksvVideoMaxBitrateKbps, 'media.recording.maxBitrateKbps', Math.min(bitrateKbps * 2, 20000), bitrateKbps, 20000);
140
+ const bufferSizeKbps = preferInteger(input.bufferSizeKbps, config.hksvVideoBufferSizeKbps, 'media.recording.bufferSizeKbps', Math.min(maxBitrateKbps * 2, 40000), maxBitrateKbps, 40000);
131
141
  return {
132
142
  profile,
133
143
  recording: {
@@ -147,6 +157,49 @@ export function normalizeMediaConfig(config) {
147
157
  },
148
158
  };
149
159
  }
160
+ // Error paths are "media", "media.<key>", "media.recording" or "media.recording.<key>", where
161
+ // <key> is the user's own key and may itself contain dots.
162
+ function removeSetting(config, path) {
163
+ const media = config.media;
164
+ const recordingKey = path.slice('media.recording.'.length);
165
+ if (path.startsWith('media.recording.') &&
166
+ isRecord(media) &&
167
+ isRecord(media.recording) &&
168
+ Object.hasOwn(media.recording, recordingKey)) {
169
+ delete media.recording[recordingKey];
170
+ }
171
+ else if (path.startsWith('media.') && isRecord(media)) {
172
+ delete media[path.slice('media.'.length)];
173
+ }
174
+ else {
175
+ delete config.media;
176
+ }
177
+ }
178
+ /**
179
+ * Like normalizeMediaConfig, but never throws: each media setting that can't be used is taken out
180
+ * of config (in memory only, so every later normalizeMediaConfig call agrees) and reported, and the
181
+ * older flat setting or the default is used instead. A bad value, for example a maximum bitrate below the bitrate, would
182
+ * otherwise stop the plugin from starting and take everything on its bridge down with it.
183
+ */
184
+ export function sanitizeMediaConfig(config) {
185
+ const ignored = [];
186
+ // Each round removes one setting. After 64 (only possible with many unknown keys), all media
187
+ // settings are dropped below.
188
+ for (let round = 0; round < 64; round++) {
189
+ try {
190
+ return { media: normalizeMediaConfig(config), ignored };
191
+ }
192
+ catch (e) {
193
+ if (!(e instanceof MediaConfigError))
194
+ throw e;
195
+ ignored.push(e);
196
+ removeSetting(config, e.path);
197
+ }
198
+ }
199
+ delete config.media;
200
+ ignored.push(new MediaConfigError('media', 'fewer than 64 unusable settings, so all of them are ignored'));
201
+ return { media: normalizeMediaConfig(config), ignored };
202
+ }
150
203
  /** Maps v15 profiles to the legacy names used by existing camera code. */
151
204
  export function getLegacyHksvPerformanceMode(config) {
152
205
  const profile = normalizeMediaConfig(config).profile;
@@ -30,7 +30,7 @@ import { Thermostat } from "./thermostat.js";
30
30
  import { UnknownZWaveSwitchSwitch } from "./unknown-zwave-switch.js";
31
31
  import { Intercom } from "./intercom.js";
32
32
  import { Valve } from "./valve.js";
33
- import { normalizeMediaConfig } from "./media-config.js";
33
+ import { sanitizeMediaConfig } from "./media-config.js";
34
34
  import { configureHksvResourceGovernor } from "./hksv-work-queue.js";
35
35
  import { getMotionNotificationSensorUuid, setUpMotionNotificationSensor, } from "./motion-notification-sensor.js";
36
36
  const ignoreHiddenDeviceTypes = [
@@ -151,6 +151,37 @@ function updateRefreshTokenInConfig(configContents, oldRefreshToken, newRefreshT
151
151
  return configContents.replace(oldRefreshToken, newRefreshToken);
152
152
  }
153
153
  }
154
+ /**
155
+ * Saves each new Ring refresh token into config.json, replacing the token that is there now.
156
+ *
157
+ * The token in config.json is tracked here rather than taken from ring-client-api's
158
+ * "oldRefreshToken": after a forced refresh, ring-client-api starts from a modified token that
159
+ * 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).
161
+ */
162
+ export function createRefreshTokenSaver(api, tokenInConfig, clearRefreshFlags) {
163
+ let current = tokenInConfig,
164
+ // Only with the first save: turning a force setting back on later must survive renewals.
165
+ clearFlags = clearRefreshFlags, warned = false;
166
+ return ({ oldRefreshToken, newRefreshToken, }) => {
167
+ const replace = current ?? oldRefreshToken;
168
+ if (!replace || newRefreshToken === replace) {
169
+ return;
170
+ }
171
+ const saved = updateHomebridgeConfig(api, (configContents) => {
172
+ return updateRefreshTokenInConfig(configContents, replace, newRefreshToken, clearFlags);
173
+ });
174
+ if (saved) {
175
+ current = newRefreshToken;
176
+ clearFlags = false;
177
+ }
178
+ else if (!warned) {
179
+ // Once per run: after a new sign-in while running, every renewal would end up here.
180
+ warned = true;
181
+ logInfo('Could not save a new Ring token to config.json (it may have been changed or signed in again while running). If you did not sign in again, the next start may register with Ring again.');
182
+ }
183
+ };
184
+ }
154
185
  export class RingPlatform {
155
186
  homebridgeAccessories = {};
156
187
  log;
@@ -184,7 +215,10 @@ export class RingPlatform {
184
215
  config.locationModePollingSeconds = config.locationModePollingSeconds ?? 20;
185
216
  // Media resources are platform-wide. Configure the singleton once before
186
217
  // cameras are constructed; individual cameras must never overwrite it.
187
- const media = normalizeMediaConfig(config);
218
+ const { media, ignored } = sanitizeMediaConfig(config);
219
+ for (const setting of ignored) {
220
+ this.log.warn(`Ignoring the media setting ${setting.path} (expected ${setting.expectation}); the older setting or the default is used instead. Correct it in the plugin settings.`);
221
+ }
188
222
  configureHksvResourceGovernor({
189
223
  recordingConcurrency: media.recording.maxConcurrentRecordings,
190
224
  queueTimeoutMs: 30_000,
@@ -233,18 +267,8 @@ export class RingPlatform {
233
267
  ? { refreshToken: refreshTokenWithoutPushCredentials }
234
268
  : {}),
235
269
  systemId,
236
- }), refreshTokenToReplace = refreshTokenWithoutPushCredentials && config.refreshToken
237
- ? config.refreshToken
238
- : undefined;
239
- ringApi.onRefreshTokenUpdated.subscribe(({ oldRefreshToken, newRefreshToken }) => {
240
- const configRefreshToken = refreshTokenToReplace ?? oldRefreshToken;
241
- if (!configRefreshToken) {
242
- return;
243
- }
244
- updateHomebridgeConfig(this.api, (configContents) => {
245
- return updateRefreshTokenInConfig(configContents, configRefreshToken, newRefreshToken, Boolean(refreshTokenWithoutPushCredentials));
246
- });
247
270
  });
271
+ ringApi.onRefreshTokenUpdated.subscribe(createRefreshTokenSaver(this.api, config.refreshToken, Boolean(refreshTokenWithoutPushCredentials)));
248
272
  const locations = await ringApi.getLocations(), cachedAccessoryIds = Object.keys(this.homebridgeAccessories), platformAccessories = [], externalAccessories = [], activeAccessoryIds = [];
249
273
  logInfo('Found the following locations:');
250
274
  locations.forEach((location) => {
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.2",
4
+ "version": "1.0.0-beta.3",
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",
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "build": "node -e \"require('fs').rmSync('lib', { recursive: true, force: true })\" && tsc && node -e \"require('fs').cpSync('./homebridge-ui/public', './lib/homebridge-ui/public', { recursive: true })\"",
11
11
  "lint": "eslint .",
12
- "test": "npm run build && node --test test/v15-media.test.mjs",
12
+ "test": "npm run build && node --test test/*.test.mjs",
13
13
  "prepack": "npm run build",
14
14
  "dev": "concurrently -c yellow,blue --kill-others \"npm:dev:build\" \"npm:dev:run\" ",
15
15
  "dev:build": "tsc --watch --preserveWatchOutput",