homebridge-ring 11.0.5 → 11.1.0-beta.0
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 +28 -0
- package/README.md +46 -45
- package/config.schema.json +7 -6
- package/lib/api/rest-client.js +1 -1
- package/lib/api/ring-camera.js +1 -0
- package/lib/homebridge/camera-source.d.ts +2 -1
- package/lib/homebridge/camera-source.js +103 -29
- package/lib/homebridge/camera.js +1 -1
- package/lib/homebridge/config.d.ts +3 -2
- package/lib/homebridge/opus-repacketizer.d.ts +7 -0
- package/lib/homebridge/opus-repacketizer.js +87 -0
- package/lib/homebridge/ring-platform.js +23 -5
- package/package.json +21 -21
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
|
|
4
4
|
|
|
5
|
+
## [11.1.0-beta.0](https://github.com/dgreif/ring/compare/v11.0.7...v11.1.0-beta.0) (2022-08-01)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **homebridge:** allow cameras to be unbridged ([7302298](https://github.com/dgreif/ring/commit/73022983eed59e6af4bfdf0d8f1ee9d8ee8f6953))
|
|
11
|
+
* **homebridge:** use opus transcoding for unbridge cameras ([e9eb7a4](https://github.com/dgreif/ring/commit/e9eb7a4940ce7d01dee920593af3bdd12e25fa1a))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
### Bug Fixes
|
|
15
|
+
|
|
16
|
+
* update dependencies ([6f98cdf](https://github.com/dgreif/ring/commit/6f98cdf48895daf3217890b6631a6dbb2ccd340f))
|
|
17
|
+
|
|
18
|
+
### [11.0.7](https://github.com/dgreif/ring/compare/v11.0.6...v11.0.7) (2022-07-10)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* hide errors from snapshot request timeouts ([f997e34](https://github.com/dgreif/ring/commit/f997e340ad3c579d059ae07c11ad82ddc5ceb181)), closes [#932](https://github.com/dgreif/ring/issues/932)
|
|
24
|
+
* update dependencies ([ce0bf2a](https://github.com/dgreif/ring/commit/ce0bf2aa81d55e629931f38ba2c6ca16a79fa0d2))
|
|
25
|
+
|
|
26
|
+
### [11.0.6](https://github.com/dgreif/ring/compare/v11.0.5...v11.0.6) (2022-06-29)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Bug Fixes
|
|
30
|
+
|
|
31
|
+
* update dependencies ([0c91c3e](https://github.com/dgreif/ring/commit/0c91c3e6e9c93883cde6275960be0adf930297cf))
|
|
32
|
+
|
|
5
33
|
### [11.0.5](https://github.com/dgreif/ring/compare/v11.0.4...v11.0.5) (2022-06-12)
|
|
6
34
|
|
|
7
35
|
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/homebridge-ring)
|
|
9
9
|
[](https://github.com/homebridge/homebridge/wiki/Verified-Plugins)
|
|
10
10
|
[](https://plugins.hoobs.org/plugin/homebridge-ring)
|
|
11
|
-
[](https://discord.gg/
|
|
11
|
+
[](https://discord.gg/CXryEzNC)
|
|
12
12
|
[](https://www.paypal.me/dustingreif)
|
|
13
13
|
|
|
14
14
|
This [Homebridge](https://github.com/nfarina/homebridge) plugin provides a platform for
|
|
@@ -70,28 +70,29 @@ Only include an optional parameter if you actually need it. Default behavior wi
|
|
|
70
70
|
}
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
Option
|
|
74
|
-
|
|
75
|
-
`
|
|
76
|
-
`
|
|
77
|
-
`
|
|
78
|
-
`
|
|
79
|
-
`
|
|
80
|
-
`
|
|
81
|
-
`
|
|
82
|
-
`
|
|
83
|
-
`
|
|
84
|
-
`
|
|
85
|
-
`
|
|
86
|
-
`
|
|
87
|
-
`
|
|
88
|
-
`
|
|
89
|
-
|
|
90
|
-
`
|
|
91
|
-
`
|
|
92
|
-
`
|
|
93
|
-
`
|
|
94
|
-
`
|
|
73
|
+
| Option | Default | Explanation |
|
|
74
|
+
| ---------------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
75
|
+
| `unbridgeCameras` | `false` | if `true`, all Ring Cameras we be treated as external accessories, which generally leads to better performance. This means they each need to be individually added to HomeKit. WARNING: If your cameras are already bridged, they will be deleted from HomeKit when you enable this option, and you will need to reconfigure any associated automations or HomeKit settings |
|
|
76
|
+
| `alarmOnEntryDelay` | `false` | if `true`, HomeKit will register a delayed entry event as a triggered alarm. There are some households where this is a nice feature as a heads up if you have other people who enter your house and you want an alert so that you can disable the alarm for them before it actually goes off. This works well if you automatically arm/disarm on leave/arrive (see setup instructions below) |
|
|
77
|
+
| `beamDurationSeconds` | `60` for light groups, previous from Ring app for individual lights | Ring smart lighting has a default behavior of only staying on for 60 seconds when you turn on a light via the Ring app. To force a duration when the light is turned on from HomeKit, set this option to a specific number of seconds. If this option is not set, the lights will use the duration from the previous time the light was turned on in the Ring app. For light groups, this will default to 60 seconds. The maximum value is `32767`, which is ~9.1 hours. |
|
|
78
|
+
| `hideDeviceIds` | `[]` | Allows you to hide specific devices by an array of ids. The id for each device is logged when homebridge starts. |
|
|
79
|
+
| `hideLightGroups` | `false` | Ring smart lighting allows you to create lighting groups within the Ring app. These groups are convenient for detecting motion in an area of your yard and turning on/off all lights in the group. However, you may wish to group the lights differently in HomeKit and ignore the groups you have configured in Ring. If this option is `true`, your Ring groups (and their associated motion sensor) will be ignored and will not show up in HomeKit. |
|
|
80
|
+
| `hideDoorbellSwitch` | `false` | If you have a Ring video doorbell, you will see a Programmable Switch associated with it. This switch can be used to perform actions on when the doorbell is pressed using "Single Press" actions. If you do not care to perform actions when the doorbell is pressed, you can hide the Programmable Switch by setting this option to `true`. You will still be able to receive _notifications_ from the doorbell even if the Programmable Switch is hidden (notifications can be configured in the settings for the doorbell camera in the Home app) |
|
|
81
|
+
| `hideCameraLight` | `false` | If `true`, hides the light for Ring cameras in HomeKit. |
|
|
82
|
+
| `hideCameraMotionSensor` | `false` | If `true`, hides the motion sensor for Ring cameras in HomeKit. |
|
|
83
|
+
| `hideCameraSirenSwitch` | `false` | If `true`, hides the siren switch for Ring cameras in HomeKit. |
|
|
84
|
+
| `hideInHomeDoorbellSwitch` | `false` | If `true`, hides the switch for in-home doorbells in HomeKit. |
|
|
85
|
+
| `hideAlarmSirenSwitch` | `false` | If you have a Ring Alarm, you will see both the alarm and a "Siren" switch in HomeKit. The siren switch can sometimes get triggered by Siri commands by accident, which is loud and annoying. Set this option to `true` to hide the siren switch. |
|
|
86
|
+
| `hideUnsupportedServices` | `false` | If `true`, hides the alarm Base Station and Keypad devices in HomeKit. These are only supported by third-party HomeKit apps, not the native Home app. |
|
|
87
|
+
| `showPanicButtons` | `false` | Creates a new `Panic Buttons` device in HomeKit with `Burglar Alarm` and `Fire Alarm` switches. **Use these at your own risk. I do not guarantee functionality in case of emergency, nor do I take responsibility for any false alarms**. These function just like the SOS sliders in the Ring app. |
|
|
88
|
+
| `nightModeBypassFor` | no Night Mode | Allows you to use Night mode to "Bypass and Arm" a Ring Alarm. Can be set to `all` for Away or `some` for Home. If set and Night mode is activated from HomeKit, any open contact sensors will automatically be bypassed. |
|
|
89
|
+
| `avoidSnapshotBatteryDrain` | false | Causes snapshots for battery cameras to be fetched at a minimum 10 minute interval to avoid draining the battery. |
|
|
90
|
+
| `cameraStatusPollingSeconds` | `20` | How frequently to poll for updates to your cameras and chimes (in seconds). Information like light/siren/volume/snooze status do not update in real time and need to be requested periodically. |
|
|
91
|
+
| `locationModePollingSeconds` | `20` | How frequently to poll for location mode updates (in seconds). This is only useful if you are using location modes to control camera settings and want to keep an up-to-date reference of the current mode for each location. Polling is automatically disabled for locations equipped with a Ring Alarm. Will hide Location Mode switch if set to `0` |
|
|
92
|
+
| `locationIds` | All Locations | Use this option if you only want a subset of your locations to appear in HomeKit. If this option is not included, all of your locations will be added to HomeKit (which is what most users will want to do). |
|
|
93
|
+
| `onlyDeviceTypes` | All Device Types | A subset of device types to be shown. If set, all other device types will be hidden in HomeKit. |
|
|
94
|
+
| `ffmpegPath` | Uses `ffmpeg-for-homebridge` | A custom path to the `ffmpeg` executable. By default, the static binaries built in [ffmpeg-for-homebridge](https://github.com/oznu/ffmpeg-for-homebridge) will be used. If you prefer to use your own version of ffmpeg, you can pass a complete path, or simply `"ffmpeg"` to use ffmpeg from your `PATH`. |
|
|
95
|
+
| `debug` | false | Turns on additional logging. In particular, ffmpeg logging. |
|
|
95
96
|
|
|
96
97
|
### Cameras
|
|
97
98
|
|
|
@@ -202,28 +203,28 @@ Note: Although homebridge has support for HomeKit Secure Video (HKSV), I will no
|
|
|
202
203
|
|
|
203
204
|
### Alarm/Location Modes
|
|
204
205
|
|
|
205
|
-
HomeKit Mode | Ring Mode
|
|
206
|
-
|
|
207
|
-
Off
|
|
208
|
-
Home
|
|
209
|
-
Away
|
|
210
|
-
Night
|
|
206
|
+
| HomeKit Mode | Ring Mode |
|
|
207
|
+
| ------------ | ----------------------------------------------------------- |
|
|
208
|
+
| Off | Disarmed |
|
|
209
|
+
| Home | Home |
|
|
210
|
+
| Away | Away |
|
|
211
|
+
| Night | Hidden by default. Configurable with `nightModeBypassFor` |
|
|
211
212
|
|
|
212
213
|
Entry delays and bypassed sensors (ex. for Home mode) are all controlled in the Ring app.
|
|
213
214
|
These settings will automatically be used by HomeKit.
|
|
214
215
|
|
|
215
216
|
### Siri Commands for Alarm/Location Modes
|
|
216
217
|
|
|
217
|
-
Siri Command
|
|
218
|
-
|
|
219
|
-
`Set Ring Alarm to Away`
|
|
220
|
-
`Arm my security system`
|
|
221
|
-
`Set Ring Alarm to Stay`
|
|
222
|
-
`Arm my security system for stay` | Home mode activated
|
|
223
|
-
`Disarm Ring Alarm`
|
|
224
|
-
`Disarm my security system`
|
|
225
|
-
`Turn on Ring Alarm`
|
|
226
|
-
`Turn off Ring Alarm`
|
|
218
|
+
| Siri Command | Outcome |
|
|
219
|
+
| --------------------------------- | --------------------------------------------------------------- |
|
|
220
|
+
| `Set Ring Alarm to Away` | Away mode activated |
|
|
221
|
+
| `Arm my security system` | Away mode activated |
|
|
222
|
+
| `Set Ring Alarm to Stay` | Home mode activated |
|
|
223
|
+
| `Arm my security system for stay` | Home mode activated |
|
|
224
|
+
| `Disarm Ring Alarm` | Disarmed |
|
|
225
|
+
| `Disarm my security system` | Disarmed |
|
|
226
|
+
| `Turn on Ring Alarm` | Turns on Ring Alarm Siren (unless hideAlarmSirenSwitch is set) |
|
|
227
|
+
| `Turn off Ring Alarm` | Turns off Ring Alarm Siren (unless hideAlarmSirenSwitch is set) |
|
|
227
228
|
|
|
228
229
|
### Changes Modes on Arrive/Leave Home
|
|
229
230
|
|
|
@@ -252,9 +253,9 @@ can then use these dummy switches to trigger your other automation (e.g. Arm/Dis
|
|
|
252
253
|
|
|
253
254
|
#### Example Home Automations
|
|
254
255
|
|
|
255
|
-
Trigger
|
|
256
|
-
|
|
257
|
-
People Arrive
|
|
258
|
-
People Leave
|
|
259
|
-
"Arrived Home" turns on | Set Ring Alarm mode to Off
|
|
260
|
-
"Left Home" turns on
|
|
256
|
+
| Trigger | Action |
|
|
257
|
+
| ----------------------- | --------------------------- |
|
|
258
|
+
| People Arrive | Turn on "Arrived Home" |
|
|
259
|
+
| People Leave | Turn on "Left Home" |
|
|
260
|
+
| "Arrived Home" turns on | Set Ring Alarm mode to Off |
|
|
261
|
+
| "Left Home" turns on | Set Ring Alarm mode to Away |
|
package/config.schema.json
CHANGED
|
@@ -16,6 +16,11 @@
|
|
|
16
16
|
"description": "Click \"Generate New Refresh Token\" above to change accounts or if your refresh token has expired",
|
|
17
17
|
"placeholder": "Refresh Token"
|
|
18
18
|
},
|
|
19
|
+
"unbridgeCameras": {
|
|
20
|
+
"title": "Unbridge Cameras",
|
|
21
|
+
"type": "boolean",
|
|
22
|
+
"description": "If enabled, all Ring Cameras we be treated as external accessories, which generally leads to better performance. This means they each need to be individually added to HomeKit. WARNING: If your cameras are already bridged, they will be deleted from HomeKit when you enable this option, and you will need to reconfigure any associated automations or HomeKit settings"
|
|
23
|
+
},
|
|
19
24
|
"alarmOnEntryDelay": {
|
|
20
25
|
"title": "Alarm on Entry Delay",
|
|
21
26
|
"type": "boolean",
|
|
@@ -74,15 +79,11 @@
|
|
|
74
79
|
"oneOf": [
|
|
75
80
|
{
|
|
76
81
|
"title": "Home",
|
|
77
|
-
"enum": [
|
|
78
|
-
"some"
|
|
79
|
-
]
|
|
82
|
+
"enum": ["some"]
|
|
80
83
|
},
|
|
81
84
|
{
|
|
82
85
|
"title": "Away",
|
|
83
|
-
"enum": [
|
|
84
|
-
"all"
|
|
85
|
-
]
|
|
86
|
+
"enum": ["all"]
|
|
86
87
|
}
|
|
87
88
|
]
|
|
88
89
|
},
|
package/lib/api/rest-client.js
CHANGED
|
@@ -282,7 +282,7 @@ class RingRestClient {
|
|
|
282
282
|
if (response.statusCode) {
|
|
283
283
|
(0, util_1.logError)(`Request to ${url} failed with status ${response.statusCode}. Response body: ${(0, util_1.stringify)(response.body)}`);
|
|
284
284
|
}
|
|
285
|
-
else {
|
|
285
|
+
else if (!options.allowNoResponse) {
|
|
286
286
|
(0, util_1.logError)(`Request to ${url} failed:`);
|
|
287
287
|
(0, util_1.logError)(e);
|
|
288
288
|
}
|
package/lib/api/ring-camera.js
CHANGED
|
@@ -356,6 +356,7 @@ class RingCamera extends subscribed_1.Subscribed {
|
|
|
356
356
|
headers: {
|
|
357
357
|
accept: 'image/jpeg',
|
|
358
358
|
},
|
|
359
|
+
allowNoResponse: true,
|
|
359
360
|
}), { responseTimestamp, timeMillis } = response, timestampAge = Math.abs(responseTimestamp - timeMillis);
|
|
360
361
|
this.lastSnapshotTimestamp = timeMillis;
|
|
361
362
|
this.lastSnapshotTimestampLocal = Date.now() - timestampAge;
|
|
@@ -2,10 +2,11 @@ import { RingCamera } from '../api';
|
|
|
2
2
|
import { CameraStreamingDelegate, PrepareStreamCallback, PrepareStreamRequest, SnapshotRequest, SnapshotRequestCallback, StreamingRequest, StreamRequestCallback } from 'homebridge';
|
|
3
3
|
export declare class CameraSource implements CameraStreamingDelegate {
|
|
4
4
|
private ringCamera;
|
|
5
|
+
private useOpus;
|
|
5
6
|
controller: import("homebridge").CameraController;
|
|
6
7
|
private sessions;
|
|
7
8
|
private cachedSnapshot?;
|
|
8
|
-
constructor(ringCamera: RingCamera);
|
|
9
|
+
constructor(ringCamera: RingCamera, useOpus?: boolean);
|
|
9
10
|
private previousLoadSnapshotPromise?;
|
|
10
11
|
loadSnapshot(imageUuid?: string): Promise<any>;
|
|
11
12
|
fn: number;
|
|
@@ -10,6 +10,7 @@ const fs_1 = require("fs");
|
|
|
10
10
|
const util_2 = require("util");
|
|
11
11
|
const ffmpeg_1 = require("../api/ffmpeg");
|
|
12
12
|
const werift_1 = require("werift");
|
|
13
|
+
const opus_repacketizer_1 = require("./opus-repacketizer");
|
|
13
14
|
const readFileAsync = (0, util_2.promisify)(fs_1.readFile), cameraOfflinePath = require.resolve('../../media/camera-offline.jpg'), snapshotsBlockedPath = require.resolve('../../media/snapshots-blocked.jpg');
|
|
14
15
|
function getDurationSeconds(start) {
|
|
15
16
|
return (Date.now() - start) / 1000;
|
|
@@ -37,6 +38,7 @@ class StreamingSessionWrapper {
|
|
|
37
38
|
this.videoSrtp = (0, camera_utils_1.generateSrtpOptions)();
|
|
38
39
|
this.audioSplitter = new camera_utils_1.RtpSplitter();
|
|
39
40
|
this.videoSplitter = new camera_utils_1.RtpSplitter();
|
|
41
|
+
this.repacketizeAudioSplitter = new camera_utils_1.RtpSplitter();
|
|
40
42
|
this.libfdkAacInstalledPromise = (0, camera_utils_1.doesFfmpegSupportCodec)('libfdk_aac', (0, ffmpeg_1.getFfmpegPath)())
|
|
41
43
|
.then((supported) => {
|
|
42
44
|
if (!supported) {
|
|
@@ -89,9 +91,57 @@ class StreamingSessionWrapper {
|
|
|
89
91
|
.catch(util_1.logError);
|
|
90
92
|
}));
|
|
91
93
|
}
|
|
94
|
+
listenForAudioPackets(startStreamRequest) {
|
|
95
|
+
const { targetAddress, audio: { port: audioPort }, } = this.prepareStreamRequest, { audio: { codec: audioCodec, sample_rate: audioSampleRate }, } = startStreamRequest,
|
|
96
|
+
// Repacketize the audio stream after it's been transcoded
|
|
97
|
+
opusRepacketizer = new opus_repacketizer_1.OpusRepacketizer(1), audioIntervalScale = audioSampleRate / 8, audioSrtpSession = new werift_1.SrtpSession(getSessionConfig(this.audioSrtp));
|
|
98
|
+
let firstTimestamp, audioPacketCount = 0;
|
|
99
|
+
this.repacketizeAudioSplitter.addMessageHandler(({ message }) => {
|
|
100
|
+
let rtp = werift_1.RtpPacket.deSerialize(message);
|
|
101
|
+
if (audioCodec === "OPUS" /* AudioStreamingCodecType.OPUS */) {
|
|
102
|
+
// borrowed from scrypted
|
|
103
|
+
// Original source: https://github.com/koush/scrypted/blob/c13ba09889c3e0d9d3724cb7d49253c9d787fb97/plugins/homekit/src/types/camera/camera-streaming-srtp-sender.ts#L124-L143
|
|
104
|
+
rtp = opusRepacketizer.repacketize(werift_1.RtpPacket.deSerialize(message));
|
|
105
|
+
if (!rtp) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
if (!firstTimestamp) {
|
|
109
|
+
firstTimestamp = rtp.header.timestamp;
|
|
110
|
+
}
|
|
111
|
+
// from HAP spec:
|
|
112
|
+
// RTP Payload Format for Opus Speech and Audio Codec RFC 7587 with an exception
|
|
113
|
+
// that Opus audio RTP Timestamp shall be based on RFC 3550.
|
|
114
|
+
// RFC 3550 indicates that PCM audio based with a sample rate of 8k and a packet
|
|
115
|
+
// time of 20ms would have a monotonic interval of 8k / (1000 / 20) = 160.
|
|
116
|
+
// So 24k audio would have a monotonic interval of (24k / 8k) * 160 = 480.
|
|
117
|
+
// HAP spec also states that it may request packet times of 20, 30, 40, or 60.
|
|
118
|
+
// In practice, HAP has been seen to request 20 on LAN and 60 over LTE.
|
|
119
|
+
// So the RTP timestamp must scale accordingly.
|
|
120
|
+
// Further investigation indicates that HAP doesn't care about the actual sample rate at all,
|
|
121
|
+
// that's merely a suggestion. When encoding Opus, it can seemingly be an arbitrary sample rate,
|
|
122
|
+
// audio will work so long as the rtp timestamps are created properly: which is a construct of the sample rate
|
|
123
|
+
// HAP requests, and the packet time is respected,
|
|
124
|
+
// opus 48khz will work just fine.
|
|
125
|
+
rtp.header.timestamp =
|
|
126
|
+
(firstTimestamp + audioPacketCount * 180 * audioIntervalScale) %
|
|
127
|
+
0xffffffff;
|
|
128
|
+
audioPacketCount++;
|
|
129
|
+
}
|
|
130
|
+
// encrypt the packet
|
|
131
|
+
const encryptedPacket = audioSrtpSession.encrypt(rtp.payload, rtp.header);
|
|
132
|
+
// send the encrypted packet to HomeKit
|
|
133
|
+
this.audioSplitter
|
|
134
|
+
.send(encryptedPacket, {
|
|
135
|
+
port: audioPort,
|
|
136
|
+
address: targetAddress,
|
|
137
|
+
})
|
|
138
|
+
.catch(util_1.logError);
|
|
139
|
+
return null;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
92
142
|
async activate(request) {
|
|
93
143
|
let sentVideo = false;
|
|
94
|
-
const { targetAddress, audio: {
|
|
144
|
+
const { targetAddress, audio: { srtp_key: remoteAudioSrtpKey, srtp_salt: remoteAudioSrtpSalt }, video: { port: videoPort }, } = this.prepareStreamRequest,
|
|
95
145
|
// use to encrypt Ring video to HomeKit
|
|
96
146
|
videoSrtpSession = new werift_1.SrtpSession(getSessionConfig(this.videoSrtp));
|
|
97
147
|
// Set up packet forwarding for video stream
|
|
@@ -119,20 +169,23 @@ class StreamingSessionWrapper {
|
|
|
119
169
|
audio: [
|
|
120
170
|
'-map',
|
|
121
171
|
'0:a',
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
172
|
+
...(request.audio.codec === "OPUS" /* AudioStreamingCodecType.OPUS */
|
|
173
|
+
? [
|
|
174
|
+
// OPUS specific - it works, but audio is very choppy
|
|
175
|
+
'-acodec',
|
|
176
|
+
'libopus',
|
|
177
|
+
'-frame_duration',
|
|
178
|
+
request.audio.packet_time,
|
|
179
|
+
'-application',
|
|
180
|
+
'lowdelay',
|
|
181
|
+
]
|
|
182
|
+
: [
|
|
183
|
+
// AAC-eld specific
|
|
184
|
+
'-acodec',
|
|
185
|
+
'libfdk_aac',
|
|
186
|
+
'-profile:a',
|
|
187
|
+
'aac_eld',
|
|
188
|
+
]),
|
|
136
189
|
// Shared options
|
|
137
190
|
'-flags',
|
|
138
191
|
'+global_header',
|
|
@@ -150,11 +203,8 @@ class StreamingSessionWrapper {
|
|
|
150
203
|
this.audioSsrc,
|
|
151
204
|
'-f',
|
|
152
205
|
'rtp',
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
'-srtp_out_params',
|
|
156
|
-
(0, camera_utils_1.encodeSrtpOptions)(this.audioSrtp),
|
|
157
|
-
`srtp://${targetAddress}:${audioPort}?pkt_size=188`,
|
|
206
|
+
`rtp://127.0.0.1:${await this.repacketizeAudioSplitter
|
|
207
|
+
.portPromise}?pkt_size=188`,
|
|
158
208
|
],
|
|
159
209
|
video: false,
|
|
160
210
|
output: [],
|
|
@@ -180,15 +230,16 @@ class StreamingSessionWrapper {
|
|
|
180
230
|
// deSerialize will sometimes fail, but the errors can be ignored
|
|
181
231
|
}
|
|
182
232
|
return null;
|
|
183
|
-
}),
|
|
233
|
+
}), isRingUsingOpus = await this.streamingSession.isUsingOpus, returnAudioTranscoder = new camera_utils_1.ReturnAudioTranscoder({
|
|
184
234
|
prepareStreamRequest: this.prepareStreamRequest,
|
|
235
|
+
startStreamRequest: request,
|
|
185
236
|
incomingAudioOptions: {
|
|
186
237
|
ssrc: this.audioSsrc,
|
|
187
238
|
rtcpPort: 0, // we don't care about rtcp for incoming audio
|
|
188
239
|
},
|
|
189
240
|
outputArgs: [
|
|
190
241
|
'-acodec',
|
|
191
|
-
...(
|
|
242
|
+
...(isRingUsingOpus
|
|
192
243
|
? ['libopus', '-ac', 2, '-ar', '48k']
|
|
193
244
|
: ['pcm_mulaw', '-ac', 1, '-ar', '8k']),
|
|
194
245
|
'-flags',
|
|
@@ -209,18 +260,21 @@ class StreamingSessionWrapper {
|
|
|
209
260
|
returnAudioTranscoder.stop();
|
|
210
261
|
returnAudioTranscodedSplitter.close();
|
|
211
262
|
});
|
|
263
|
+
this.listenForAudioPackets(request);
|
|
212
264
|
await returnAudioTranscoder.start();
|
|
213
265
|
await transcodingPromise;
|
|
214
266
|
}
|
|
215
267
|
stop() {
|
|
216
268
|
this.audioSplitter.close();
|
|
269
|
+
this.repacketizeAudioSplitter.close();
|
|
217
270
|
this.videoSplitter.close();
|
|
218
271
|
this.streamingSession.stop();
|
|
219
272
|
}
|
|
220
273
|
}
|
|
221
274
|
class CameraSource {
|
|
222
|
-
constructor(ringCamera) {
|
|
275
|
+
constructor(ringCamera, useOpus = false) {
|
|
223
276
|
this.ringCamera = ringCamera;
|
|
277
|
+
this.useOpus = useOpus;
|
|
224
278
|
this.controller = new hap_1.hap.CameraController({
|
|
225
279
|
cameraStreamCount: 10,
|
|
226
280
|
delegate: this,
|
|
@@ -244,12 +298,28 @@ class CameraSource {
|
|
|
244
298
|
},
|
|
245
299
|
},
|
|
246
300
|
audio: {
|
|
247
|
-
codecs:
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
301
|
+
codecs: this.useOpus
|
|
302
|
+
? [
|
|
303
|
+
{
|
|
304
|
+
type: "OPUS" /* AudioStreamingCodecType.OPUS */,
|
|
305
|
+
// required by watch
|
|
306
|
+
samplerate: 8 /* AudioStreamingSamplerate.KHZ_8 */,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
type: "OPUS" /* AudioStreamingCodecType.OPUS */,
|
|
310
|
+
samplerate: 16 /* AudioStreamingSamplerate.KHZ_16 */,
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
type: "OPUS" /* AudioStreamingCodecType.OPUS */,
|
|
314
|
+
samplerate: 24 /* AudioStreamingSamplerate.KHZ_24 */,
|
|
315
|
+
},
|
|
316
|
+
]
|
|
317
|
+
: [
|
|
318
|
+
{
|
|
319
|
+
type: "AAC-eld" /* AudioStreamingCodecType.AAC_ELD */,
|
|
320
|
+
samplerate: 16 /* AudioStreamingSamplerate.KHZ_16 */,
|
|
321
|
+
},
|
|
322
|
+
],
|
|
253
323
|
},
|
|
254
324
|
},
|
|
255
325
|
});
|
|
@@ -294,6 +364,10 @@ class CameraSource {
|
|
|
294
364
|
catch (e) {
|
|
295
365
|
this.cachedSnapshot = undefined;
|
|
296
366
|
(0, util_1.logDebug)(`Failed to cache snapshot for ${this.ringCamera.name} (${getDurationSeconds(start)}s), The camera currently reports that it is ${this.ringCamera.isOffline ? 'offline' : 'online'}`);
|
|
367
|
+
// log additioanl snapshot error message if one is present
|
|
368
|
+
if (e.message.includes('Snapshot')) {
|
|
369
|
+
(0, util_1.logDebug)(e.message);
|
|
370
|
+
}
|
|
297
371
|
}
|
|
298
372
|
}
|
|
299
373
|
getCurrentSnapshot() {
|
package/lib/homebridge/camera.js
CHANGED
|
@@ -13,7 +13,7 @@ class Camera extends base_data_accessory_1.BaseDataAccessory {
|
|
|
13
13
|
this.accessory = accessory;
|
|
14
14
|
this.logger = logger;
|
|
15
15
|
this.config = config;
|
|
16
|
-
this.cameraSource = new camera_source_1.CameraSource(this.device);
|
|
16
|
+
this.cameraSource = new camera_source_1.CameraSource(this.device, this.config.unbridgeCameras);
|
|
17
17
|
if (!hap_1.hap.CameraController) {
|
|
18
18
|
const error = 'HAP CameraController not found. Please make sure you are on homebridge version 1.0.0 or newer';
|
|
19
19
|
logger.error(error);
|
|
@@ -3,6 +3,7 @@ import { API } from 'homebridge';
|
|
|
3
3
|
export interface RingPlatformConfig extends RingApiOptions {
|
|
4
4
|
alarmOnEntryDelay?: boolean;
|
|
5
5
|
beamDurationSeconds?: number;
|
|
6
|
+
ffmpegPath?: string;
|
|
6
7
|
hideLightGroups?: boolean;
|
|
7
8
|
hideDoorbellSwitch?: boolean;
|
|
8
9
|
hideCameraLight?: boolean;
|
|
@@ -12,10 +13,10 @@ export interface RingPlatformConfig extends RingApiOptions {
|
|
|
12
13
|
hideAlarmSirenSwitch?: boolean;
|
|
13
14
|
hideUnsupportedServices?: boolean;
|
|
14
15
|
hideDeviceIds?: string[];
|
|
16
|
+
nightModeBypassFor: AlarmMode;
|
|
15
17
|
onlyDeviceTypes?: string[];
|
|
16
18
|
showPanicButtons?: boolean;
|
|
17
|
-
|
|
18
|
-
ffmpegPath?: string;
|
|
19
|
+
unbridgeCameras?: boolean;
|
|
19
20
|
}
|
|
20
21
|
export declare function updateHomebridgeConfig(homebridge: API, update: (config: string) => string): boolean;
|
|
21
22
|
export declare function getSystemId(homebridge: API): string;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// OpusRepacketizer is borrowed from scrypted
|
|
3
|
+
// Original source: https://github.com/koush/scrypted/blob/c13ba09889c3e0d9d3724cb7d49253c9d787fb97/plugins/homekit/src/types/camera/opus-repacketizer.ts
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.OpusRepacketizer = void 0;
|
|
6
|
+
// https://datatracker.ietf.org/doc/html/rfc6716
|
|
7
|
+
// INPUT
|
|
8
|
+
// 0 1 2 3
|
|
9
|
+
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
10
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
11
|
+
// | config |s|0|0| |
|
|
12
|
+
// +-+-+-+-+-+-+-+-+ |
|
|
13
|
+
// | Compressed frame 1 (N-1 bytes)... :
|
|
14
|
+
// : |
|
|
15
|
+
// | |
|
|
16
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
17
|
+
// OUTPUT
|
|
18
|
+
// 0 1 2 3
|
|
19
|
+
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
|
|
20
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
21
|
+
// | config |s|1|1|1|p| M | Padding length (Optional) :
|
|
22
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
23
|
+
// : N1 (1-2 bytes): N2 (1-2 bytes): ... : N[M-1] |
|
|
24
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
25
|
+
// | |
|
|
26
|
+
// : Compressed frame 1 (N1 bytes)... :
|
|
27
|
+
// | |
|
|
28
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
29
|
+
// | |
|
|
30
|
+
// : Compressed frame 2 (N2 bytes)... :
|
|
31
|
+
// | |
|
|
32
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
33
|
+
// | |
|
|
34
|
+
// : ... :
|
|
35
|
+
// | |
|
|
36
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
37
|
+
// | |
|
|
38
|
+
// : Compressed frame M... :
|
|
39
|
+
// | |
|
|
40
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
41
|
+
// : Opus Padding (Optional)... |
|
|
42
|
+
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|
|
43
|
+
// Figure 6: A CBR Code 3 Packet
|
|
44
|
+
// In the VBR case, the (optional) padding length is followed by M-1
|
|
45
|
+
// frame lengths (indicated by "N1" to "N[M-1]" in Figure 7), each
|
|
46
|
+
// encoded in a one- or two-byte sequence as described above. The
|
|
47
|
+
// packet MUST contain enough data for the M-1 lengths after removing
|
|
48
|
+
// the (optional) padding, and the sum of these lengths MUST be no
|
|
49
|
+
// larger than the number of bytes remaining in the packet after
|
|
50
|
+
// decoding them [R7]. The compressed data for all M frames follows,
|
|
51
|
+
// each frame consisting of the indicated number of bytes, with the
|
|
52
|
+
// final frame consuming any remaining bytes before the final padding,
|
|
53
|
+
// as illustrated in Figure 6. The number of header bytes (TOC byte,
|
|
54
|
+
// frame count byte, padding length bytes, and frame length bytes), plus
|
|
55
|
+
// the signaled length of the first M-1 frames themselves, plus the
|
|
56
|
+
// signaled length of the padding MUST be no larger than N, the total
|
|
57
|
+
// size of the packet.
|
|
58
|
+
class OpusRepacketizer {
|
|
59
|
+
constructor(framesPerPacket) {
|
|
60
|
+
this.framesPerPacket = framesPerPacket;
|
|
61
|
+
this.packets = [];
|
|
62
|
+
}
|
|
63
|
+
// repacketize a packet with a single frame into a packet with multiple frames.
|
|
64
|
+
repacketize(packet) {
|
|
65
|
+
if (this.framesPerPacket === 1)
|
|
66
|
+
return packet;
|
|
67
|
+
if (packet.payload[0] & 0b00000011) {
|
|
68
|
+
throw new Error('expected opus packet with a single frame.');
|
|
69
|
+
}
|
|
70
|
+
this.packets.push(packet);
|
|
71
|
+
if (this.packets.length !== this.framesPerPacket) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const first = this.packets[0], depacketized = this.packets.map((p) => p.payload.subarray(1));
|
|
75
|
+
this.packets = [];
|
|
76
|
+
let config = first.payload[0];
|
|
77
|
+
config |= 0b00000011;
|
|
78
|
+
const frameCount = 0b10000000 | this.framesPerPacket, newHeader = [config, frameCount];
|
|
79
|
+
// depacketize by stripping off the config byte
|
|
80
|
+
// M-1 length bytes
|
|
81
|
+
newHeader.push(...depacketized.slice(0, -1).map((data) => data.length));
|
|
82
|
+
const headerBuffer = Buffer.from(newHeader), payload = Buffer.concat([headerBuffer, ...depacketized]);
|
|
83
|
+
packet.payload = payload;
|
|
84
|
+
return packet;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
exports.OpusRepacketizer = OpusRepacketizer;
|
|
@@ -155,7 +155,7 @@ class RingPlatform {
|
|
|
155
155
|
controlCenterDisplayName: 'homebridge-ring',
|
|
156
156
|
...config,
|
|
157
157
|
systemId,
|
|
158
|
-
}), locations = await ringApi.getLocations(), cachedAccessoryIds = Object.keys(this.homebridgeAccessories), platformAccessories = [], activeAccessoryIds = [];
|
|
158
|
+
}), locations = await ringApi.getLocations(), cachedAccessoryIds = Object.keys(this.homebridgeAccessories), platformAccessories = [], externalAccessories = [], activeAccessoryIds = [];
|
|
159
159
|
this.log.info('Found the following locations:');
|
|
160
160
|
locations.forEach((location) => {
|
|
161
161
|
this.log.info(` locationId: ${location.id} - ${location.name}`);
|
|
@@ -202,7 +202,7 @@ class RingPlatform {
|
|
|
202
202
|
}
|
|
203
203
|
this.log.info(`Configuring ${cameras.length} cameras and ${hapDevices.length} devices for location "${location.name}" - locationId: ${location.id}`);
|
|
204
204
|
hapDevices.forEach(({ deviceType, device, isCamera, id, name, AccessoryClass }) => {
|
|
205
|
-
const uuid = hap_1.hap.uuid.generate(debugPrefix + id), displayName = debugPrefix + name;
|
|
205
|
+
const uuid = hap_1.hap.uuid.generate(debugPrefix + id), displayName = debugPrefix + name, isExternalCamera = isCamera && this.config.unbridgeCameras;
|
|
206
206
|
if (!AccessoryClass ||
|
|
207
207
|
(config.hideLightGroups &&
|
|
208
208
|
deviceType === api_1.RingDeviceType.BeamsLightGroupSwitch) ||
|
|
@@ -215,15 +215,30 @@ class RingPlatform {
|
|
|
215
215
|
}
|
|
216
216
|
return;
|
|
217
217
|
}
|
|
218
|
+
if (isExternalCamera && this.homebridgeAccessories[uuid]) {
|
|
219
|
+
// Camera was previously bridged. Remove it from the platform so that it can be added as an external accessory
|
|
220
|
+
this.log.warn(`Camera ${displayName} was previously bridged. You will need to manually pair it as a new accessory.`);
|
|
221
|
+
this.api.unregisterPlatformAccessories(exports.pluginName, exports.platformName, [
|
|
222
|
+
this.homebridgeAccessories[uuid],
|
|
223
|
+
]);
|
|
224
|
+
delete this.homebridgeAccessories[uuid];
|
|
225
|
+
}
|
|
218
226
|
const createHomebridgeAccessory = () => {
|
|
219
227
|
const accessory = new api.platformAccessory(displayName, uuid, isCamera
|
|
220
228
|
? 17 /* hap.Categories.CAMERA */
|
|
221
229
|
: 11 /* hap.Categories.SECURITY_SYSTEM */);
|
|
222
|
-
|
|
223
|
-
|
|
230
|
+
if (isExternalCamera) {
|
|
231
|
+
this.log.info(`Configured camera ${uuid} ${deviceType} ${displayName}`);
|
|
232
|
+
externalAccessories.push(accessory);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
this.log.info(`Adding new accessory ${uuid} ${deviceType} ${displayName}`);
|
|
236
|
+
platformAccessories.push(accessory);
|
|
237
|
+
}
|
|
224
238
|
if (isCamera &&
|
|
239
|
+
!isExternalCamera &&
|
|
225
240
|
typeof hap_1.hap.Accessory.cleanupAccessoryData === 'function') {
|
|
226
|
-
// This is a one-time cleanup that will remove persist files for old external accessories from
|
|
241
|
+
// This is a one-time cleanup that will remove persist files for old external accessories from unbridged cameras
|
|
227
242
|
hap_1.hap.Accessory.cleanupAccessoryData((0, util_2.generateMacAddress)(accessory.UUID));
|
|
228
243
|
}
|
|
229
244
|
return accessory;
|
|
@@ -236,6 +251,9 @@ class RingPlatform {
|
|
|
236
251
|
if (platformAccessories.length) {
|
|
237
252
|
api.registerPlatformAccessories(exports.pluginName, exports.platformName, platformAccessories);
|
|
238
253
|
}
|
|
254
|
+
if (externalAccessories.length) {
|
|
255
|
+
api.publishExternalAccessories(exports.pluginName, externalAccessories);
|
|
256
|
+
}
|
|
239
257
|
const staleAccessories = cachedAccessoryIds
|
|
240
258
|
.filter((cachedId) => !activeAccessoryIds.includes(cachedId))
|
|
241
259
|
.map((id) => this.homebridgeAccessories[id]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "homebridge-ring",
|
|
3
|
-
"version": "11.0.
|
|
3
|
+
"version": "11.1.0-beta.0",
|
|
4
4
|
"description": "Homebridge plugin for Ring doorbells, cameras, security alarm system and smart lighting",
|
|
5
5
|
"main": "lib/homebridge/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -40,45 +40,45 @@
|
|
|
40
40
|
"license": "MIT",
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@eneris/push-receiver": "^3.0.2",
|
|
43
|
-
"@homebridge/camera-utils": "^2.
|
|
43
|
+
"@homebridge/camera-utils": "^2.2.0",
|
|
44
44
|
"@homebridge/plugin-ui-utils": "^0.0.19",
|
|
45
|
-
"werift": "0.15.
|
|
45
|
+
"werift": "0.15.10",
|
|
46
46
|
"@types/socket.io-client": "1.4.36",
|
|
47
47
|
"colors": "^1.4.0",
|
|
48
48
|
"debug": "^4.3.4",
|
|
49
|
-
"got": "^11.8.
|
|
50
|
-
"rxjs": "^7.5.
|
|
49
|
+
"got": "^11.8.5",
|
|
50
|
+
"rxjs": "^7.5.6",
|
|
51
51
|
"socket.io-client": "^2.4.0",
|
|
52
|
-
"systeminformation": "^5.
|
|
52
|
+
"systeminformation": "^5.12.1",
|
|
53
53
|
"uuid": "^8.3.2",
|
|
54
|
-
"ws": "^8.8.
|
|
54
|
+
"ws": "^8.8.1"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@swc-node/register": "^1.5.1",
|
|
58
58
|
"@types/debug": "4.1.7",
|
|
59
|
-
"@types/jest": "28.1.
|
|
60
|
-
"@types/node": "
|
|
59
|
+
"@types/jest": "28.1.6",
|
|
60
|
+
"@types/node": "18.6.3",
|
|
61
61
|
"@types/uuid": "8.3.4",
|
|
62
62
|
"@types/ws": "^8.5.3",
|
|
63
|
-
"@typescript-eslint/eslint-plugin": "5.
|
|
64
|
-
"@typescript-eslint/parser": "5.
|
|
65
|
-
"concurrently": "^7.
|
|
63
|
+
"@typescript-eslint/eslint-plugin": "5.31.0",
|
|
64
|
+
"@typescript-eslint/parser": "5.31.0",
|
|
65
|
+
"concurrently": "^7.3.0",
|
|
66
66
|
"conventional-github-releaser": "3.1.5",
|
|
67
67
|
"dotenv": "16.0.1",
|
|
68
|
-
"esbuild": "^0.14.
|
|
69
|
-
"eslint": "8.
|
|
68
|
+
"esbuild": "^0.14.51",
|
|
69
|
+
"eslint": "8.20.0",
|
|
70
70
|
"eslint-config-prettier": "8.5.0",
|
|
71
|
-
"eslint-plugin-jest": "26.
|
|
72
|
-
"eslint-plugin-prettier": "4.
|
|
71
|
+
"eslint-plugin-jest": "26.7.0",
|
|
72
|
+
"eslint-plugin-prettier": "4.2.1",
|
|
73
73
|
"express": "4.18.1",
|
|
74
|
-
"homebridge": "1.
|
|
75
|
-
"jest": "28.1.
|
|
76
|
-
"prettier": "2.
|
|
74
|
+
"homebridge": "1.5.0",
|
|
75
|
+
"jest": "28.1.3",
|
|
76
|
+
"prettier": "2.7.1",
|
|
77
77
|
"reflect-metadata": "0.1.13",
|
|
78
78
|
"rimraf": "3.0.2",
|
|
79
79
|
"standard-version": "9.5.0",
|
|
80
|
-
"ts-jest": "28.0.
|
|
81
|
-
"typescript": "4.7.
|
|
80
|
+
"ts-jest": "28.0.7",
|
|
81
|
+
"typescript": "4.7.4"
|
|
82
82
|
},
|
|
83
83
|
"engines": {
|
|
84
84
|
"node": "^16",
|