homebridge-ring-hksv 14.3.4 → 14.3.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
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ### Patch Changes
6
+
7
+ - Prevent lock accessories from reporting invalid `LockTargetState` values when Ring reports a jammed or unknown current state. This removes repeated Homebridge warnings while preserving the correct current lock state in HomeKit.
8
+
5
9
  ## 14.3.4
6
10
 
7
11
  ### Patch Changes
package/README.md CHANGED
@@ -31,6 +31,9 @@ Big thanks to Dustin and all upstream contributors. This fork reuses and extends
31
31
  | `hksvPrebufferLengthMs` | HKSV prebuffer duration (minimum 4000ms) |
32
32
  | `hksvFragmentLengthMs` | HKSV fragment duration target |
33
33
  | `hksvMaxRecordingSeconds` | Optional safety cap for a recording session |
34
+ | `hksvVideoBitrateKbps` / `hksvVideoMaxBitrateKbps` / `hksvVideoBufferSizeKbps` | HKSV recording bitrate controls for improving fast-motion quality |
35
+ | `hksvVideoCrf` / `hksvVideoPreset` | Optional libx264 quality and CPU tuning controls |
36
+ | `hksvVideoKeyframeInterval` | HKSV recording keyframe interval |
34
37
  | `homeKitAccessoryTag` | Appends a tag to accessory names and HomeKit IDs so the same Ring device can be exposed as a distinct HomeKit accessory for debugging/testing |
35
38
  | `cameraVideoCodec` | Preferred H.264 encoder (`h264_videotoolbox` or `libx264`) |
36
39
  | `hideDoorbellSwitch` / `hideCameraMotionSensor` / `hideCameraSirenSwitch` | Hides specific HomeKit-exposed services |
@@ -86,6 +89,12 @@ HKSV support is experimental and actively evolving. Behavior may vary by camera
86
89
  I currently am able to run 3 cameras with HKSV enabled on a Homebridge instance ran on a M4 Mac Mini 32GB of RAM.
87
90
  Please report your experience and setup details to help improve support.
88
91
 
92
+ For fast-motion pixelation or stuttering in HKSV recordings, try increasing
93
+ `hksvVideoBitrateKbps` first. On Apple Silicon Macs,
94
+ `cameraVideoCodec: "h264_videotoolbox"` can reduce CPU load by using hardware
95
+ encoding. A good starting point is 4000-6000 kbps target bitrate, 8000-12000
96
+ kbps max bitrate, and the default keyframe interval of 30.
97
+
89
98
  ### Minimum specifications for HKSV:
90
99
  [TBD - will be added as more users test and report their setups]
91
100
 
@@ -53,6 +53,86 @@
53
53
  "maximum": 300,
54
54
  "description": "Optional safety cap for a single HKSV recording stream duration."
55
55
  },
56
+ "hksvVideoBitrateKbps": {
57
+ "title": "HKSV Video Bitrate (kbps)",
58
+ "type": "integer",
59
+ "minimum": 256,
60
+ "maximum": 12000,
61
+ "default": 3000,
62
+ "description": "Target video bitrate for HKSV recording transcodes. Increase this first if fast motion becomes blocky or pixelated. 4000-6000 is a good starting range for wired cameras."
63
+ },
64
+ "hksvVideoMaxBitrateKbps": {
65
+ "title": "HKSV Max Video Bitrate (kbps)",
66
+ "type": "integer",
67
+ "minimum": 256,
68
+ "maximum": 20000,
69
+ "description": "Maximum burst bitrate for HKSV recording transcodes. Defaults to 2x the target bitrate. A practical starting range is 8000-12000 when target bitrate is 4000-6000."
70
+ },
71
+ "hksvVideoBufferSizeKbps": {
72
+ "title": "HKSV Video Buffer Size (kbps)",
73
+ "type": "integer",
74
+ "minimum": 256,
75
+ "maximum": 40000,
76
+ "description": "FFmpeg VBV buffer size for HKSV recording transcodes. Defaults to 2x the max video bitrate. Leave unset unless you are tuning bitrate stability."
77
+ },
78
+ "hksvVideoCrf": {
79
+ "title": "HKSV x264 Quality (CRF)",
80
+ "type": "integer",
81
+ "minimum": 18,
82
+ "maximum": 35,
83
+ "description": "Optional libx264 quality value. Lower values improve quality and use more bitrate/CPU. Try 20-23 if you stay on libx264. Ignored when using VideoToolbox."
84
+ },
85
+ "hksvVideoKeyframeInterval": {
86
+ "title": "HKSV Keyframe Interval",
87
+ "type": "integer",
88
+ "minimum": 5,
89
+ "maximum": 240,
90
+ "default": 30,
91
+ "description": "Maximum number of frames between video keyframes in HKSV recordings. 30 is the recommended default. Shorter intervals can improve fragmenting at the cost of bitrate."
92
+ },
93
+ "hksvVideoPreset": {
94
+ "title": "HKSV x264 Preset",
95
+ "type": "string",
96
+ "description": "libx264 speed/quality preset for HKSV recordings. veryfast is the recommended default. Move toward fast or medium for quality, or superfast for lower CPU. Ignored when using VideoToolbox.",
97
+ "oneOf": [
98
+ {
99
+ "title": "ultrafast",
100
+ "enum": ["ultrafast"]
101
+ },
102
+ {
103
+ "title": "superfast",
104
+ "enum": ["superfast"]
105
+ },
106
+ {
107
+ "title": "veryfast",
108
+ "enum": ["veryfast"]
109
+ },
110
+ {
111
+ "title": "faster",
112
+ "enum": ["faster"]
113
+ },
114
+ {
115
+ "title": "fast",
116
+ "enum": ["fast"]
117
+ },
118
+ {
119
+ "title": "medium",
120
+ "enum": ["medium"]
121
+ },
122
+ {
123
+ "title": "slow",
124
+ "enum": ["slow"]
125
+ },
126
+ {
127
+ "title": "slower",
128
+ "enum": ["slower"]
129
+ },
130
+ {
131
+ "title": "veryslow",
132
+ "enum": ["veryslow"]
133
+ }
134
+ ]
135
+ },
56
136
  "homeKitAccessoryTag": {
57
137
  "title": "HomeKit Accessory Tag",
58
138
  "type": "string",
@@ -61,7 +141,7 @@
61
141
  "cameraVideoCodec": {
62
142
  "title": "Camera Video Codec",
63
143
  "type": "string",
64
- "description": "Preferred ffmpeg H.264 encoder for camera paths.",
144
+ "description": "Preferred FFmpeg H.264 encoder for HKSV recording transcodes. On Apple Silicon or newer Intel Macs, h264_videotoolbox is the recommended first choice because it uses hardware acceleration.",
65
145
  "oneOf": [
66
146
  {
67
147
  "title": "h264_videotoolbox",
@@ -215,6 +295,12 @@
215
295
  "hksvPrebufferLengthMs",
216
296
  "hksvFragmentLengthMs",
217
297
  "hksvMaxRecordingSeconds",
298
+ "hksvVideoBitrateKbps",
299
+ "hksvVideoMaxBitrateKbps",
300
+ "hksvVideoBufferSizeKbps",
301
+ "hksvVideoCrf",
302
+ "hksvVideoKeyframeInterval",
303
+ "hksvVideoPreset",
218
304
  "homeKitAccessoryTag",
219
305
  "cameraVideoCodec",
220
306
  "hideLightGroups",
@@ -23,6 +23,12 @@ function getSessionConfig(srtpOptions) {
23
23
  profile: 1,
24
24
  };
25
25
  }
26
+ function getIntegerConfigValue(value, defaultValue, min, max) {
27
+ if (!Number.isFinite(value)) {
28
+ return defaultValue;
29
+ }
30
+ return Math.min(Math.max(Math.round(value), min), max);
31
+ }
26
32
  function* parseMp4Boxes(data) {
27
33
  let offset = 0;
28
34
  while (offset + 8 <= data.length) {
@@ -254,6 +260,7 @@ export class CameraSource {
254
260
  sessions = {};
255
261
  cachedSnapshot;
256
262
  ringCamera;
263
+ config;
257
264
  recordingActive = false;
258
265
  recordingConfiguration;
259
266
  closedRecordingStreams = new Set();
@@ -261,6 +268,7 @@ export class CameraSource {
261
268
  activeRecordingSessions = new Map();
262
269
  constructor(ringCamera, config) {
263
270
  this.ringCamera = ringCamera;
271
+ this.config = config;
264
272
  const enableHksv = config.enableHksv && !(config.disableHksvOnBattery && ringCamera.hasBattery);
265
273
  const controllerOptions = {
266
274
  cameraStreamCount: enableHksv ? 1 : 10,
@@ -358,6 +366,33 @@ export class CameraSource {
358
366
  }
359
367
  this.controller = new hap.CameraController(controllerOptions);
360
368
  }
369
+ getHksvVideoArguments() {
370
+ const { cameraVideoCodec = 'libx264', hksvVideoCrf, hksvVideoPreset = 'veryfast', } = this.config, bitrateKbps = getIntegerConfigValue(this.config.hksvVideoBitrateKbps, 3000, 256, 12000), maxBitrateKbps = getIntegerConfigValue(this.config.hksvVideoMaxBitrateKbps, bitrateKbps * 2, bitrateKbps, 20000), bufferSizeKbps = getIntegerConfigValue(this.config.hksvVideoBufferSizeKbps, maxBitrateKbps * 2, maxBitrateKbps, 40000), keyframeInterval = getIntegerConfigValue(this.config.hksvVideoKeyframeInterval, 30, 5, 240), videoArguments = [
371
+ '-vcodec',
372
+ cameraVideoCodec,
373
+ '-b:v',
374
+ `${bitrateKbps}k`,
375
+ '-maxrate',
376
+ `${maxBitrateKbps}k`,
377
+ '-bufsize',
378
+ `${bufferSizeKbps}k`,
379
+ '-pix_fmt',
380
+ 'yuv420p',
381
+ '-profile:v',
382
+ 'baseline',
383
+ '-level:v',
384
+ '3.1',
385
+ '-g',
386
+ `${keyframeInterval}`,
387
+ ];
388
+ if (cameraVideoCodec === 'libx264') {
389
+ videoArguments.push('-preset', hksvVideoPreset, '-tune', 'zerolatency', '-keyint_min', `${keyframeInterval}`, '-sc_threshold', '0');
390
+ if (Number.isFinite(hksvVideoCrf)) {
391
+ videoArguments.push('-crf', `${getIntegerConfigValue(hksvVideoCrf, 23, 18, 35)}`);
392
+ }
393
+ }
394
+ return videoArguments;
395
+ }
361
396
  previousLoadSnapshotPromise;
362
397
  async loadSnapshot(imageUuid) {
363
398
  // cache a promise of the snapshot load
@@ -399,7 +434,7 @@ export class CameraSource {
399
434
  this.cachedSnapshot = undefined;
400
435
  logDebug(`Failed to cache snapshot for ${this.ringCamera.name} (${getDurationSeconds(start)}s), The camera currently reports that it is ${this.ringCamera.isOffline ? 'offline' : 'online'}`);
401
436
  // log additioanl snapshot error message if one is present
402
- if (e.message.includes('Snapshot')) {
437
+ if (e.message && e.message.includes('Snapshot')) {
403
438
  logDebug(e.message);
404
439
  }
405
440
  }
@@ -533,32 +568,21 @@ export class CameraSource {
533
568
  this.activeRecordingSessions.set(streamId, closeSession);
534
569
  let liveCall;
535
570
  let keyFrameTimer;
571
+ let maxRecordingTimer;
536
572
  try {
537
573
  liveCall = await this.ringCamera.startLiveCall();
538
574
  liveCall.onCallEnded.pipe(take(1)).subscribe(() => {
539
575
  closeSession();
540
576
  });
577
+ const maxRecordingSeconds = getIntegerConfigValue(this.config.hksvMaxRecordingSeconds, 0, 0, 300);
578
+ if (maxRecordingSeconds) {
579
+ maxRecordingTimer = setTimeout(() => {
580
+ logInfo(`HKSV recording stream reached max duration for ${this.ringCamera.name} (streamId=${streamId}, maxSeconds=${maxRecordingSeconds})`);
581
+ closeSession();
582
+ }, maxRecordingSeconds * 1000);
583
+ }
541
584
  await liveCall.startTranscoding({
542
- video: [
543
- '-vcodec',
544
- 'libx264',
545
- '-preset',
546
- 'veryfast',
547
- '-tune',
548
- 'zerolatency',
549
- '-pix_fmt',
550
- 'yuv420p',
551
- '-profile:v',
552
- 'baseline',
553
- '-level:v',
554
- '3.1',
555
- '-g',
556
- '30',
557
- '-keyint_min',
558
- '30',
559
- '-sc_threshold',
560
- '0',
561
- ],
585
+ video: this.getHksvVideoArguments(),
562
586
  output: [
563
587
  '-movflags',
564
588
  'frag_keyframe+empty_moov+default_base_moof',
@@ -650,6 +674,9 @@ export class CameraSource {
650
674
  if (keyFrameTimer) {
651
675
  clearInterval(keyFrameTimer);
652
676
  }
677
+ if (maxRecordingTimer) {
678
+ clearTimeout(maxRecordingTimer);
679
+ }
653
680
  if (liveCall) {
654
681
  liveCall.stop();
655
682
  }
package/lib/intercom.js CHANGED
@@ -1,7 +1,125 @@
1
+ import { RingCamera } from 'ring-client-api';
1
2
  import { hap } from "./hap.js";
2
3
  import { BaseDataAccessory } from "./base-data-accessory.js";
3
4
  import { logError, logInfo } from 'ring-client-api/util';
4
- import { map, throttleTime } from 'rxjs/operators';
5
+ import { filter, map, share, switchMap, throttleTime } from 'rxjs/operators';
6
+ import { interval, merge, Subject } from 'rxjs';
7
+ import { CameraSource } from "./camera-source.js";
8
+ /**
9
+ * Creates a RingCamera-compatible proxy for a Ring Intercom Video device.
10
+ * Ring Intercom Video is classified as RingIntercom by ring-client-api but has a
11
+ * real camera. We use Object.create(RingCamera.prototype) so that startLiveCall()
12
+ * and createStreamingConnection() are inherited and work with the intercom's
13
+ * restClient and device id (Ring's signalling server uses the same doorbot_id
14
+ * scheme for video intercoms as for cameras).
15
+ */
16
+ function createIntercomVideoProxy(device) {
17
+ const proxy = Object.create(RingCamera.prototype);
18
+ const defineValue = (key, value) => Object.defineProperty(proxy, key, {
19
+ value,
20
+ writable: true,
21
+ configurable: true,
22
+ enumerable: true,
23
+ });
24
+ const defineGetter = (key, get) => Object.defineProperty(proxy, key, {
25
+ get,
26
+ configurable: true,
27
+ enumerable: true,
28
+ });
29
+ defineValue('id', device.id);
30
+ defineGetter('name', () => device.name);
31
+ defineGetter('deviceType', () => device.deviceType);
32
+ defineGetter('data', () => ({
33
+ ...device.data,
34
+ kind: device.deviceType,
35
+ device_id: String(device.id),
36
+ metadata: device.data.metadata ?? {},
37
+ settings: {
38
+ live_view_disabled: false,
39
+ motion_detection_enabled: true,
40
+ ...device.data.settings,
41
+ },
42
+ }));
43
+ // restClient is technically private in TypeScript but public in compiled JS
44
+ defineValue('restClient', device.restClient);
45
+ defineGetter('isRingEdgeEnabled', () => false);
46
+ defineGetter('hasBattery', () => device.batteryLevel !== null);
47
+ defineGetter('isOffline', () => device.isOffline);
48
+ defineGetter('snapshotsAreBlocked', () => false);
49
+ defineGetter('canTakeSnapshotWhileRecording', () => false);
50
+ defineGetter('latestNotificationSnapshotUuid', () => undefined);
51
+ defineValue('onNewNotification', new Subject());
52
+ defineValue('onMotionDetected', new Subject());
53
+ defineValue('onDoorbellPressed', device.onDing);
54
+ defineValue('onBatteryLevel', device.onBatteryLevel);
55
+ defineValue('onInHomeDoorbellStatus', new Subject());
56
+ defineGetter('hasLowBattery', () => Boolean(device.data.alerts?.battery === 'low'));
57
+ defineGetter('isCharging', () => false);
58
+ defineValue('isDoorbot', true);
59
+ defineValue('hasLight', false);
60
+ defineValue('hasSiren', false);
61
+ defineValue('hasInHomeDoorbell', false);
62
+ defineValue('model', 'Ring Intercom Video');
63
+ defineValue('snapshotLifeTime', 55000); // camera stays active ~1 min after a ring
64
+ // Pre-warm support: start the WebRTC call the moment a ding is detected so
65
+ // the camera is already streaming when HKSV requests the recording.
66
+ let preWarmedCall = null;
67
+ let preWarmCleanup = null;
68
+ let lastSnapshotAt = 0;
69
+ const realStartLiveCall = () => RingCamera.prototype.startLiveCall.call(proxy);
70
+ defineValue('startLiveCall', () => {
71
+ if (preWarmedCall) {
72
+ const call = preWarmedCall;
73
+ preWarmedCall = null;
74
+ if (preWarmCleanup) {
75
+ clearTimeout(preWarmCleanup);
76
+ preWarmCleanup = null;
77
+ }
78
+ logInfo(`${device.name}: using pre-warmed camera session for recording`);
79
+ return call;
80
+ }
81
+ return realStartLiveCall();
82
+ });
83
+ defineValue('preWarmCamera', () => {
84
+ if (preWarmedCall)
85
+ return;
86
+ logInfo(`${device.name}: pre-warming camera connection`);
87
+ preWarmedCall = realStartLiveCall();
88
+ // Clean up if HKSV doesn't claim it within 20 seconds
89
+ preWarmCleanup = setTimeout(async () => {
90
+ if (preWarmedCall) {
91
+ try {
92
+ const session = await preWarmedCall;
93
+ session.stop();
94
+ }
95
+ catch (e) {
96
+ logError(`Failed to stop pre-warmed camera session for ${device.name}`);
97
+ logError(e);
98
+ }
99
+ finally {
100
+ preWarmedCall = null;
101
+ preWarmCleanup = null;
102
+ }
103
+ }
104
+ }, 20000);
105
+ });
106
+ // Override getSnapshot: intercom uses the same doorbots snapshot endpoint as cameras.
107
+ defineGetter('hasSnapshotWithinLifetime', () => Date.now() - lastSnapshotAt < proxy.snapshotLifeTime);
108
+ defineValue('getSnapshot', async (_options) => {
109
+ try {
110
+ const snapshot = await device.restClient.request({
111
+ url: device.doorbotUrl('snapshot'),
112
+ responseType: 'buffer',
113
+ });
114
+ lastSnapshotAt = Date.now();
115
+ return snapshot;
116
+ }
117
+ catch {
118
+ throw new Error(`Snapshot not available for ${device.name}`);
119
+ }
120
+ });
121
+ return proxy;
122
+ }
5
123
  export class Intercom extends BaseDataAccessory {
6
124
  unlocking = false;
7
125
  unlockTimeout;
@@ -13,7 +131,64 @@ export class Intercom extends BaseDataAccessory {
13
131
  this.device = device;
14
132
  this.accessory = accessory;
15
133
  this.config = config;
16
- const { Characteristic, Service } = hap, lockService = this.getService(Service.LockMechanism), { LockCurrentState, LockTargetState, ProgrammableSwitchEvent } = Characteristic, programableSwitchService = this.getService(Service.StatelessProgrammableSwitch), onDoorbellPressed = device.onDing.pipe(throttleTime(15000), map(() => ProgrammableSwitchEvent.SINGLE_PRESS)), syncLockState = () => {
134
+ const { Characteristic, Service } = hap, isIntercomVideo = device.deviceType === 'intercom_handset_video';
135
+ // Set up camera streaming for Ring Intercom Video before other services
136
+ let intercomVideoProxy = null;
137
+ if (isIntercomVideo) {
138
+ const cameraProxy = createIntercomVideoProxy(device);
139
+ intercomVideoProxy = cameraProxy;
140
+ const cameraSource = new CameraSource(cameraProxy, config);
141
+ accessory.configureController(cameraSource.controller);
142
+ this.registerCharacteristic({
143
+ characteristicType: Characteristic.Mute,
144
+ serviceType: Service.Microphone,
145
+ getValue: () => false,
146
+ });
147
+ this.registerCharacteristic({
148
+ characteristicType: Characteristic.Mute,
149
+ serviceType: Service.Speaker,
150
+ getValue: () => false,
151
+ });
152
+ logInfo(`Camera streaming enabled for Ring Intercom Video: ${device.name}`);
153
+ }
154
+ const lockService = this.getService(Service.LockMechanism), { LockCurrentState, LockTargetState, ProgrammableSwitchEvent } = Characteristic,
155
+ // Ring Intercom Video ding events may arrive as camera-style FCM push
156
+ // notifications (different category than RingIntercom.onDing expects),
157
+ // so onDing never fires. Poll Ring's active-dings endpoint as a reliable
158
+ // fallback — it reflects the current ring in real time (unlike the history
159
+ // endpoint which can lag by ~60 seconds).
160
+ onDingDetected = (() => {
161
+ if (!isIntercomVideo) {
162
+ return device.onDing.pipe(share());
163
+ }
164
+ let lastDingId;
165
+ const polled = interval(3000).pipe(switchMap(async () => {
166
+ try {
167
+ const active = await device.restClient.request({
168
+ url: 'https://api.ring.com/clients_api/dings/active',
169
+ });
170
+ return Array.isArray(active) ? active : [];
171
+ }
172
+ catch {
173
+ return [];
174
+ }
175
+ }), filter((active) => {
176
+ const ding = active.find((d) => String(d.doorbot_id) === String(device.id) &&
177
+ d.kind === 'ding');
178
+ if (!ding)
179
+ return false;
180
+ if (String(ding.id) === lastDingId)
181
+ return false;
182
+ lastDingId = String(ding.id);
183
+ return true;
184
+ }), map(() => undefined));
185
+ // share() makes this hot: one polling interval, one lastDingId,
186
+ // emission fans out to all subscribers that rely on ding events.
187
+ return merge(device.onDing, polled).pipe(throttleTime(3000), share());
188
+ })(), onDoorbellPressed = onDingDetected.pipe(throttleTime(15000), map(() => {
189
+ logInfo(`Doorbell pressed on ${device.name} — sending HomeKit event`);
190
+ return ProgrammableSwitchEvent.SINGLE_PRESS;
191
+ })), syncLockState = () => {
17
192
  const state = this.getLockState();
18
193
  lockService
19
194
  .getCharacteristic(Characteristic.LockCurrentState)
@@ -68,25 +243,42 @@ export class Intercom extends BaseDataAccessory {
68
243
  }
69
244
  },
70
245
  });
71
- lockService.setPrimaryService(true);
246
+ // For audio-only intercom the lock is the main function; for video intercom
247
+ // the camera controller owns the primary-service role so HomeKit routes
248
+ // doorbell events (and HomePod chimes) correctly.
249
+ if (!isIntercomVideo) {
250
+ lockService.setPrimaryService(true);
251
+ }
252
+ // Pre-warm the camera WebRTC connection on every ding so HKSV recording
253
+ // gets live video instead of black frames.
254
+ if (intercomVideoProxy) {
255
+ const proxy = intercomVideoProxy;
256
+ onDingDetected.subscribe(() => {
257
+ ;
258
+ proxy.preWarmCamera?.();
259
+ });
260
+ }
72
261
  // Doorbell Service
73
262
  this.registerObservableCharacteristic({
74
263
  characteristicType: ProgrammableSwitchEvent,
75
264
  serviceType: Service.Doorbell,
76
265
  onValue: onDoorbellPressed,
77
266
  });
78
- // Programmable Switch Service
79
- this.registerObservableCharacteristic({
80
- characteristicType: ProgrammableSwitchEvent,
81
- serviceType: programableSwitchService,
82
- onValue: onDoorbellPressed,
83
- });
84
- // Hide long and double press events by setting max value
85
- programableSwitchService
86
- .getCharacteristic(ProgrammableSwitchEvent)
87
- .setProps({
88
- maxValue: ProgrammableSwitchEvent.SINGLE_PRESS,
89
- });
267
+ // Programmable Switch Service — only for audio-only intercom; the video
268
+ // variant already exposes Service.Doorbell which covers this.
269
+ if (!isIntercomVideo) {
270
+ const programableSwitchService = this.getService(Service.StatelessProgrammableSwitch);
271
+ this.registerObservableCharacteristic({
272
+ characteristicType: ProgrammableSwitchEvent,
273
+ serviceType: programableSwitchService,
274
+ onValue: onDoorbellPressed,
275
+ });
276
+ programableSwitchService
277
+ .getCharacteristic(ProgrammableSwitchEvent)
278
+ .setProps({
279
+ maxValue: ProgrammableSwitchEvent.SINGLE_PRESS,
280
+ });
281
+ }
90
282
  // Battery Service
91
283
  if (device.batteryLevel !== null) {
92
284
  this.registerObservableCharacteristic({
@@ -107,7 +299,7 @@ export class Intercom extends BaseDataAccessory {
107
299
  this.registerCharacteristic({
108
300
  characteristicType: Characteristic.Model,
109
301
  serviceType: Service.AccessoryInformation,
110
- getValue: () => 'Intercom Handset Audio',
302
+ getValue: () => isIntercomVideo ? 'Ring Intercom Video' : 'Ring Intercom',
111
303
  });
112
304
  this.registerCharacteristic({
113
305
  characteristicType: Characteristic.SerialNumber,
package/lib/lock.js CHANGED
@@ -55,6 +55,15 @@ export class Lock extends BaseDeviceAccessory {
55
55
  return this.device.sendCommand(`lock.${command}`);
56
56
  }
57
57
  getTargetState(data) {
58
- return this.targetState || getCurrentState(data);
58
+ if (this.targetState !== undefined) {
59
+ return this.targetState;
60
+ }
61
+ const { Characteristic: { LockCurrentState, LockTargetState }, } = hap;
62
+ const currentState = getCurrentState(data);
63
+ if (currentState === LockCurrentState.JAMMED ||
64
+ currentState === LockCurrentState.UNKNOWN) {
65
+ return LockTargetState.SECURED;
66
+ }
67
+ return currentState;
59
68
  }
60
69
  }
@@ -170,10 +170,17 @@ export class RingPlatform {
170
170
  });
171
171
  await Promise.all(locations.map(async (location) => {
172
172
  const devices = await location.getDevices(), { cameras, chimes, intercoms } = location, allDevices = [...devices, ...cameras, ...chimes, ...intercoms], securityPanel = devices.find((x) => x.deviceType === RingDeviceType.SecurityPanel), debugPrefix = debug ? 'TEST ' : '', hapDevices = allDevices.map((device) => {
173
- const isCamera = device instanceof RingCamera, cameraIdentitySalt = isCamera && config.externalCameraIdSalt
173
+ const isCamera = device instanceof RingCamera, isIntercomVideo = device instanceof RingIntercom &&
174
+ device.deviceType === 'intercom_handset_video', cameraIdentitySalt = isCamera && config.externalCameraIdSalt
174
175
  ? `-${config.externalCameraIdSalt}`
175
- : '', cameraIdDifferentiator = isCamera ? 'camera' : '', // this forces bridged cameras from old version of the plugin to be seen as "stale"
176
- AccessoryClass = (device instanceof RingCamera
176
+ : '',
177
+ // 'camera' forces bridged cameras from old plugin version to be seen as "stale"
178
+ // 'intercam' gives Ring Intercom Video a distinct external-accessory UUID
179
+ cameraIdDifferentiator = isCamera
180
+ ? 'camera'
181
+ : isIntercomVideo
182
+ ? 'intercam'
183
+ : '', AccessoryClass = (device instanceof RingCamera
177
184
  ? Camera
178
185
  : device instanceof RingChime
179
186
  ? Chime
@@ -184,6 +191,7 @@ export class RingPlatform {
184
191
  deviceType: device.deviceType,
185
192
  device: device,
186
193
  isCamera,
194
+ isIntercomVideo,
187
195
  id: device.id.toString() +
188
196
  cameraIdDifferentiator +
189
197
  cameraIdentitySalt +
@@ -199,6 +207,7 @@ export class RingPlatform {
199
207
  deviceType: securityPanel.deviceType,
200
208
  device: securityPanel,
201
209
  isCamera: false,
210
+ isIntercomVideo: false,
202
211
  id: securityPanel.id.toString() + 'panic' + accessoryIdSuffix,
203
212
  name: 'Panic Buttons' + accessoryNameSuffix,
204
213
  AccessoryClass: PanicButtons,
@@ -210,13 +219,14 @@ export class RingPlatform {
210
219
  deviceType: 'location.mode',
211
220
  device: location,
212
221
  isCamera: false,
222
+ isIntercomVideo: false,
213
223
  id: location.id + 'mode' + accessoryIdSuffix,
214
224
  name: location.name + ' Mode' + accessoryNameSuffix,
215
225
  AccessoryClass: LocationModeSwitch,
216
226
  });
217
227
  }
218
228
  logInfo(`Configuring ${cameras.length} cameras and ${hapDevices.length} devices for location "${location.name}" - locationId: ${location.id}`);
219
- hapDevices.forEach(({ deviceType, device, isCamera, id, name, AccessoryClass }) => {
229
+ hapDevices.forEach(({ deviceType, device, isCamera, isIntercomVideo, id, name, AccessoryClass, }) => {
220
230
  const uuid = hap.uuid.generate(debugPrefix + id), displayName = debugPrefix + name;
221
231
  if (!AccessoryClass ||
222
232
  (config.hideLightGroups &&
@@ -237,13 +247,20 @@ export class RingPlatform {
237
247
  delete this.homebridgeAccessories[uuid];
238
248
  }
239
249
  const createHomebridgeAccessory = () => {
240
- const accessory = new api.platformAccessory(displayName, uuid, isCamera
250
+ const category = isCamera
241
251
  ? 17 /* hap.Categories.CAMERA */
242
- : 11 /* hap.Categories.SECURITY_SYSTEM */);
252
+ : isIntercomVideo
253
+ ? 18 /* hap.Categories.VIDEO_DOORBELL */
254
+ : 11 /* hap.Categories.SECURITY_SYSTEM */;
255
+ const accessory = new api.platformAccessory(displayName, uuid, category);
243
256
  if (isCamera) {
244
257
  logInfo(`Configured camera ${uuid} ${deviceType} ${displayName}`);
245
258
  externalAccessories.push(accessory);
246
259
  }
260
+ else if (isIntercomVideo) {
261
+ logInfo(`Configured Ring Intercom Video ${uuid} ${deviceType} ${displayName}`);
262
+ externalAccessories.push(accessory);
263
+ }
247
264
  else {
248
265
  logInfo(`Adding new accessory ${uuid} ${deviceType} ${displayName}`);
249
266
  platformAccessories.push(accessory);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-ring-hksv",
3
3
  "displayName": "Homebridge Ring HKSV",
4
- "version": "14.3.4",
4
+ "version": "14.3.6",
5
5
  "description": "Homebridge plugin for Ring devices with HomeKit Secure Video support",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",