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 +23 -0
- package/lib/camera.js +40 -6
- package/lib/config.js +116 -6
- package/lib/media-config.js +56 -3
- package/lib/ring-platform.js +37 -13
- package/package.json +2 -2
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
|
|
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
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
writeFileAtomic(filePath, JSON.stringify(ringContext));
|
|
51
161
|
}
|
|
52
162
|
catch (error) {
|
|
53
163
|
logError('Failed to rotate Ring system id');
|
package/lib/media-config.js
CHANGED
|
@@ -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
|
|
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;
|
package/lib/ring-platform.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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.
|
|
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
|
|
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",
|