Release 5.1.0 (#537)

* Use MQTT for start-stream debug messages
* Fix ANSI colors
* Refactor event URL management
* Fix subscription detection
* Improve event URL expiry handling by parsing Amazon S3 expire time
* Convert to ESM/replace colors with chalk
* Force colors for chalk
* Migrate to ESM
* Fix stop of keepalive stream
* Add transcoded event selections
* Update event URL on raw/trancoded toggle
* Switch to per-camera livecall threads
* Customized WebRTC functions
Mostly copied from ring-client-api with port to pure Javascript, removal of unneeded features and additional debugging modified for use as worker thread with ring-mqtt.  Allows easier testing with updated Werift versions.
* Add nightlight enable/disable
* Include nightlight state as attribute
* Only pro versions have nightlight
* Tweak battery level reporting for dual battery cameras
* Release 5.1.0
This commit is contained in:
tsightler
2023-02-02 20:59:09 -05:00
committed by GitHub
parent c88b82cdb5
commit b8338e30de
57 changed files with 2832 additions and 2794 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ node_modules
config.dev.json config.dev.json
ring-state.json ring-state.json
ring-test.js ring-test.js
config/go2rtc.yaml

View File

@@ -1,4 +1,4 @@
FROM alpine:3.16.2 FROM alpine:3.17.1
ENV LANG="C.UTF-8" \ ENV LANG="C.UTF-8" \
PS1="$(whoami)@$(hostname):$(pwd)$ " \ PS1="$(whoami)@$(hostname):$(pwd)$ " \
@@ -11,9 +11,9 @@ ENV LANG="C.UTF-8" \
COPY . /app/ring-mqtt COPY . /app/ring-mqtt
RUN S6_VERSION="v3.1.2.1" && \ RUN S6_VERSION="v3.1.2.1" && \
BASHIO_VERSION="v0.14.3" && \ BASHIO_VERSION="v0.14.3" && \
RSS_VERSION="v0.20.0" && \ GO2RTC_VERSION="v1.1.1" && \
apk add --no-cache tar xz git libcrypto1.1 libssl1.1 musl-utils musl bash curl jq tzdata nodejs npm mosquitto-clients && \
APK_ARCH="$(apk --print-arch)" && \ APK_ARCH="$(apk --print-arch)" && \
apk add --no-cache tar xz git libcrypto3 libssl3 musl-utils musl bash curl jq tzdata nodejs npm mosquitto-clients && \
curl -L -s "https://github.com/just-containers/s6-overlay/releases/download/${S6_VERSION}/s6-overlay-noarch.tar.xz" | tar -Jxpf - -C / && \ curl -L -s "https://github.com/just-containers/s6-overlay/releases/download/${S6_VERSION}/s6-overlay-noarch.tar.xz" | tar -Jxpf - -C / && \
case "${APK_ARCH}" in \ case "${APK_ARCH}" in \
aarch64|armhf|x86_64) \ aarch64|armhf|x86_64) \
@@ -32,16 +32,17 @@ RUN S6_VERSION="v3.1.2.1" && \
rm -Rf /app/ring-mqtt/init && \ rm -Rf /app/ring-mqtt/init && \
case "${APK_ARCH}" in \ case "${APK_ARCH}" in \
x86_64) \ x86_64) \
RSS_ARCH="amd64";; \ GO2RTC_ARCH="amd64";; \
aarch64) \ aarch64) \
RSS_ARCH="arm64v8";; \ GO2RTC_ARCH="arm64";; \
armv7|armhf) \ armv7|armhf) \
RSS_ARCH="armv7";; \ GO2RTC_ARCH="arm";; \
*) \ *) \
echo >&2 "ERROR: Unsupported architecture '$APK_ARCH'" \ echo >&2 "ERROR: Unsupported architecture '$APK_ARCH'" \
exit 1;; \ exit 1;; \
esac && \ esac && \
curl -L -s "https://github.com/aler9/rtsp-simple-server/releases/download/${RSS_VERSION}/rtsp-simple-server_${RSS_VERSION}_linux_${RSS_ARCH}.tar.gz" | tar zxf - -C /usr/local/bin rtsp-simple-server && \ curl -L -s -o /usr/local/bin/go2rtc "https://github.com/AlexxIT/go2rtc/releases/download/${GO2RTC_VERSION}/go2rtc_linux_${GO2RTC_ARCH}" && \
chmod +x /usr/local/bin/go2rtc && \
curl -J -L -o /tmp/bashio.tar.gz "https://github.com/hassio-addons/bashio/archive/${BASHIO_VERSION}.tar.gz" && \ curl -J -L -o /tmp/bashio.tar.gz "https://github.com/hassio-addons/bashio/archive/${BASHIO_VERSION}.tar.gz" && \
mkdir /tmp/bashio && \ mkdir /tmp/bashio && \
tar zxvf /tmp/bashio.tar.gz --strip 1 -C /tmp/bashio && \ tar zxvf /tmp/bashio.tar.gz --strip 1 -C /tmp/bashio && \

View File

@@ -9,7 +9,5 @@
"enable_panic": false, "enable_panic": false,
"hass_topic": "homeassistant/status", "hass_topic": "homeassistant/status",
"ring_topic": "ring", "ring_topic": "ring",
"location_ids": [ "location_ids": []
""
]
} }

View File

@@ -1,19 +0,0 @@
readTimeout: 10s
writeTimeout: 10s
readBufferCount: 2048
rtspDisable: no
protocols: [tcp]
rtspAddress: 0.0.0.0:8554
rtmpDisable: yes
hlsDisable: yes
api: yes
apiAddress: 127.0.0.1:8880
paths:
all:
fallback:
disablePublisherOverride: no

View File

@@ -1,8 +1,8 @@
const RingDevice = require('./base-ring-device') import RingDevice from './base-ring-device.js'
const utils = require('../lib/utils') import utils from '../lib/utils.js'
// Base class for devices/features that communicate via HTTP polling interface (cameras/chime/modes) // Base class for devices/features that communicate via HTTP polling interface (cameras/chime/modes)
class RingPolledDevice extends RingDevice { export default class RingPolledDevice extends RingDevice {
constructor(deviceInfo, category, primaryAttribute) { constructor(deviceInfo, category, primaryAttribute) {
super(deviceInfo, category, primaryAttribute, deviceInfo.device.data.device_id, deviceInfo.device.data.location_id) super(deviceInfo, category, primaryAttribute, deviceInfo.device.data.device_id, deviceInfo.device.data.location_id)
this.heartbeat = 3 this.heartbeat = 3
@@ -61,5 +61,3 @@ class RingPolledDevice extends RingDevice {
this.monitorHeartbeat() this.monitorHeartbeat()
} }
} }
module.exports = RingPolledDevice

View File

@@ -1,9 +1,9 @@
const utils = require('../lib/utils') import utils from '../lib/utils.js'
const state = require('../lib/state') import state from '../lib/state.js'
const colors = require('colors/safe') import chalk from 'chalk'
// Base class with functions common to all devices // Base class with functions common to all devices
class RingDevice { export default class RingDevice {
constructor(deviceInfo, category, primaryAttribute, deviceId, locationId) { constructor(deviceInfo, category, primaryAttribute, deviceId, locationId) {
this.device = deviceInfo.device this.device = deviceInfo.device
this.deviceId = deviceId this.deviceId = deviceId
@@ -15,10 +15,10 @@ class RingDevice {
} }
this.debug = (message, debugType) => { this.debug = (message, debugType) => {
utils.debug(debugType === 'disc' ? message : colors.green(`[${this.deviceData.name}] `)+message, debugType ? debugType : 'mqtt') utils.debug(debugType === 'disc' ? message : chalk.green(`[${this.deviceData.name}] `)+message, debugType ? debugType : 'mqtt')
} }
// Build device base and availability topic // Build device base and availability topic
this.deviceTopic = `${utils.config.ring_topic}/${this.locationId}/${category}/${this.deviceId}` this.deviceTopic = `${utils.config().ring_topic}/${this.locationId}/${category}/${this.deviceId}`
this.availabilityTopic = `${this.deviceTopic}/status` this.availabilityTopic = `${this.deviceTopic}/status`
if (deviceInfo.hasOwnProperty('parentDevice')) { if (deviceInfo.hasOwnProperty('parentDevice')) {
@@ -104,8 +104,8 @@ class RingDevice {
? { icon: entity.icon } ? { icon: entity.icon }
: entityKey === "info" : entityKey === "info"
? { icon: 'mdi:information-outline' } : {}, ? { icon: 'mdi:information-outline' } : {},
... entity.component === 'alarm_control_panel' && utils.config.disarm_code ... entity.component === 'alarm_control_panel' && utils.config().disarm_code
? { code: utils.config.disarm_code.toString(), ? { code: utils.config().disarm_code.toString(),
code_arm_required: false, code_arm_required: false,
code_disarm_required: true } : {}, code_disarm_required: true } : {},
... entity.hasOwnProperty('brightness_scale') ... entity.hasOwnProperty('brightness_scale')
@@ -163,12 +163,26 @@ class RingDevice {
if (topic.match('command_topic')) { if (topic.match('command_topic')) {
utils.event.emit('mqtt_subscribe', discoveryMessage[topic]) utils.event.emit('mqtt_subscribe', discoveryMessage[topic])
utils.event.on(discoveryMessage[topic], (command, message) => { utils.event.on(discoveryMessage[topic], (command, message) => {
if (message) {
this.processCommand(command, message) this.processCommand(command, message)
} else {
this.debug(`Received invalid or null value to command topic ${command}`)
}
}) })
// For camera stream entities subscribe to IPC broker // For camera stream entities subscribe to IPC broker topics as well
if (entityKey === 'stream' || entityKey === 'event_stream') { if (entityKey === 'stream' || entityKey === 'event_stream') {
utils.event.emit('mqtt_ipc_subscribe', discoveryMessage[topic]) utils.event.emit('mqtt_ipc_subscribe', discoveryMessage[topic])
// Also subscribe to debug topic used to log debug messages from start-stream.sh script
const streamDebugTopic = discoveryMessage[topic].split('/').slice(0,-1).join('/')+'/debug'
utils.event.emit('mqtt_ipc_subscribe', streamDebugTopic)
utils.event.on(streamDebugTopic, (command, message) => {
if (message) {
this.debug(message, 'rtsp')
} else {
this.debug(`Received invalid or null value to debug log topic ${command}`)
}
})
} }
} }
}) })
@@ -206,7 +220,7 @@ class RingDevice {
// Publish state messages with debug // Publish state messages with debug
mqttPublish(topic, message, debugType, maskedMessage) { mqttPublish(topic, message, debugType, maskedMessage) {
if (debugType !== false) { if (debugType !== false) {
this.debug(colors.blue(`${topic} `)+colors.cyan(`${maskedMessage ? maskedMessage : message}`), debugType) this.debug(chalk.blue(`${topic} `)+chalk.cyan(`${maskedMessage ? maskedMessage : message}`), debugType)
} }
utils.event.emit('mqtt_publish', topic, message) utils.event.emit('mqtt_publish', topic, message)
} }
@@ -237,5 +251,3 @@ class RingDevice {
this.mqttPublish(this.availabilityTopic, this.availabilityState, debugType) this.mqttPublish(this.availabilityTopic, this.availabilityState, debugType)
} }
} }
module.exports = RingDevice

View File

@@ -1,8 +1,8 @@
const RingDevice = require('./base-ring-device') import RingDevice from './base-ring-device.js'
const utils = require('../lib/utils') import utils from '../lib/utils.js'
// Base class for devices that communicate with hubs via websocket (alarm/smart lighting) // Base class for devices that communicate with hubs via websocket (alarm/smart lighting)
class RingSocketDevice extends RingDevice { export default class RingSocketDevice extends RingDevice {
constructor(deviceInfo, category, primaryAttribute) { constructor(deviceInfo, category, primaryAttribute) {
super(deviceInfo, category, primaryAttribute, deviceInfo.device.id, deviceInfo.device.location.locationId) super(deviceInfo, category, primaryAttribute, deviceInfo.device.id, deviceInfo.device.location.locationId)
@@ -122,5 +122,3 @@ class RingSocketDevice extends RingDevice {
this.publishAttributeEntities(attributes) this.publishAttributeEntities(attributes)
} }
} }
module.exports = RingSocketDevice

View File

@@ -1,7 +1,7 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
const utils = require('../lib/utils') import utils from '../lib/utils.js'
class BaseStation extends RingSocketDevice { export default class BaseStation extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm', 'acStatus') super(deviceInfo, 'alarm', 'acStatus')
this.deviceData.mdl = 'Alarm Base Station' this.deviceData.mdl = 'Alarm Base Station'
@@ -72,5 +72,3 @@ class BaseStation extends RingSocketDevice {
} }
} }
module.exports = BaseStation

View File

@@ -1,7 +1,7 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
const { RingDeviceType } = require('ring-client-api') import { RingDeviceType } from 'ring-client-api'
class BeamOutdoorPlug extends RingSocketDevice { export default class BeamOutdoorPlug extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'lighting') super(deviceInfo, 'lighting')
this.deviceData.mdl = 'Outdoor Smart Plug' this.deviceData.mdl = 'Outdoor Smart Plug'
@@ -76,5 +76,3 @@ class BeamOutdoorPlug extends RingSocketDevice {
} }
} }
} }
module.exports = BeamOutdoorPlug

View File

@@ -1,7 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
const utils = require( '../lib/utils' )
class Beam extends RingSocketDevice { export default class Beam extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'lighting') super(deviceInfo, 'lighting')
@@ -163,5 +162,3 @@ class Beam extends RingSocketDevice {
} }
} }
} }
module.exports = Beam

View File

@@ -1,7 +1,7 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
const { RingDeviceType } = require('ring-client-api') import { RingDeviceType } from 'ring-client-api'
class BinarySensor extends RingSocketDevice { export default class BinarySensor extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
let device_class = 'None' let device_class = 'None'
@@ -126,5 +126,3 @@ class BinarySensor extends RingSocketDevice {
} }
} }
} }
module.exports = BinarySensor

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class Bridge extends RingSocketDevice { export default class Bridge extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm', 'commStatus') super(deviceInfo, 'alarm', 'commStatus')
this.deviceData.mdl = 'Bridge' this.deviceData.mdl = 'Bridge'
@@ -12,5 +12,3 @@ class Bridge extends RingSocketDevice {
this.publishAttributes() this.publishAttributes()
} }
} }
module.exports = Bridge

View File

@@ -0,0 +1,89 @@
import { parentPort, workerData } from 'worker_threads'
import { WebrtcConnection } from '../lib/streaming/webrtc-connection.js'
import { RingEdgeConnection } from '../lib/streaming/ring-edge-connection.js'
import { StreamingSession } from '../lib/streaming/streaming-session.js'
const deviceName = workerData.deviceName
const doorbotId = workerData.doorbotId
let liveStream = false
parentPort.on("message", async(data) => {
const streamData = data.streamData
if (data.command === 'start' && !liveStream) {
parentPort.postMessage('Live stream WebRTC worker received start command')
try {
const cameraData = {
name: deviceName,
id: doorbotId
}
const streamConnection = (streamData.sessionId)
? new WebrtcConnection(streamData.sessionId, cameraData)
: new RingEdgeConnection(streamData.authToken, cameraData)
liveStream = new StreamingSession(cameraData, streamConnection)
liveStream.connection.pc.onConnectionState.subscribe(async (data) => {
switch(data) {
case 'connected':
parentPort.postMessage('active')
parentPort.postMessage('Live stream WebRTC session is connected')
break;
case 'failed':
parentPort.postMessage('failed')
parentPort.postMessage('Live stream WebRTC connection has failed')
liveStream.stop()
await new Promise(res => setTimeout(res, 2000))
liveStream = false
break;
}
})
parentPort.postMessage('Live stream transcoding process is starting')
await liveStream.startTranscoding({
// The native AVC video stream is copied to the RTSP server unmodified while the audio
// stream is converted into two output streams using both AAC and Opus codecs. This
// provides a stream with wide compatibility across various media player technologies.
audio: [
'-map', '0:v',
'-map', '0:a',
'-map', '0:a',
'-c:a:0', 'aac',
'-c:a:1', 'copy',
],
video: [
'-c:v', 'copy',
],
output: [
'-flags', '+global_header',
'-f', 'rtsp',
'-rtsp_transport', 'tcp',
streamData.rtspPublishUrl
]
})
parentPort.postMessage('Live stream transcoding process has started')
liveStream.onCallEnded.subscribe(() => {
parentPort.postMessage('Live stream WebRTC session has disconnected')
parentPort.postMessage('inactive')
liveStream = false
})
} catch(error) {
parentPort.postMessage(error)
parentPort.postMessage('failed')
liveStream = false
}
} else if (data.command === 'stop') {
if (liveStream) {
liveStream.stop()
await new Promise(res => setTimeout(res, 2000))
if (liveStream) {
parentPort.postMessage('Live stream failed to stop on request, deleting anyway...')
parentPort.postMessage('inactive')
liveStream = false
}
} else {
parentPort.postMessage('Received live stream stop command but no active live call found')
parentPort.postMessage('inactive')
liveStream = false
}
}
})

View File

@@ -1,15 +1,18 @@
const RingPolledDevice = require('./base-polled-device') import RingPolledDevice from './base-polled-device.js'
const utils = require( '../lib/utils' ) import utils from '../lib/utils.js'
const pathToFfmpeg = require('ffmpeg-for-homebridge') import pathToFfmpeg from 'ffmpeg-for-homebridge'
const { spawn } = require('child_process') import { Worker } from 'worker_threads'
const rss = require('../lib/rtsp-simple-server') import { spawn } from 'child_process'
class Camera extends RingPolledDevice { export default class Camera extends RingPolledDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'camera') super(deviceInfo, 'camera')
const savedState = this.getSavedState() const savedState = this.getSavedState()
this.hasBattery1 = this.device.data.hasOwnProperty('battery_voltage') ? true : false
this.hasBattery2 = this.device.data.hasOwnProperty('battery_voltage_2') ? true : false
this.data = { this.data = {
motion: { motion: {
active_ding: false, active_ding: false,
@@ -51,25 +54,22 @@ class Camera extends RingPolledDevice {
status: 'inactive', status: 'inactive',
session: false, session: false,
publishedStatus: '', publishedStatus: '',
rtspPublishUrl: (utils.config.livestream_user && utils.config.livestream_pass) worker: new Worker('./devices/camera-livestream.js', {
? `rtsp://${utils.config.livestream_user}:${utils.config.livestream_pass}@localhost:8554/${this.deviceId}_live` workerData: {
: `rtsp://localhost:8554/${this.deviceId}_live`, doorbotId: this.device.id,
deviceName: this.deviceData.name
}
})
}, },
event: { event: {
state: 'OFF', state: 'OFF',
status: 'inactive', status: 'inactive',
session: false, session: false,
publishedStatus: '', publishedStatus: ''
dingId: null,
recordingUrl: null,
recordingUrlExpire: null,
pollCycle: 0,
rtspPublishUrl: (utils.config.livestream_user && utils.config.livestream_pass)
? `rtsp://${utils.config.livestream_user}:${utils.config.livestream_pass}@localhost:8554/${this.deviceId}_event`
: `rtsp://localhost:8554/${this.deviceId}_event`
}, },
keepalive:{ keepalive:{
active: false, active: false,
session: false,
expires: 0 expires: 0
} }
}, },
@@ -77,7 +77,12 @@ class Camera extends RingPolledDevice {
state: savedState?.event_select?.state state: savedState?.event_select?.state
? savedState.event_select.state ? savedState.event_select.state
: 'Motion 1', : 'Motion 1',
publishedState: null publishedState: null,
pollCycle: 0,
recordingUrl: null,
recordingUrlExpire: null,
transcoded: false,
eventId: '0'
}, },
...this.device.hasLight ? { ...this.device.hasLight ? {
light: { light: {
@@ -114,10 +119,15 @@ class Camera extends RingPolledDevice {
component: 'select', component: 'select',
options: [ options: [
...(this.device.isDoorbot ...(this.device.isDoorbot
? [ 'Ding 1', 'Ding 2', 'Ding 3', 'Ding 4', 'Ding 5' ] ? [ 'Ding 1', 'Ding 2', 'Ding 3', 'Ding 4', 'Ding 5',
'Ding 1 (Transcoded)', 'Ding 2 (Transcoded)', 'Ding 3 (Transcoded)', 'Ding 4 (Transcoded)', 'Ding 5 (Transcoded)' ]
: []), : []),
'Motion 1', 'Motion 2', 'Motion 3', 'Motion 4', 'Motion 5', 'Motion 1', 'Motion 2', 'Motion 3', 'Motion 4', 'Motion 5',
'On-demand 1', 'On-demand 2', 'On-demand 3', 'On-demand 4', 'On-demand 5' 'Motion 1 (Transcoded)', 'Motion 2 (Transcoded)', 'Motion 3 (Transcoded)', 'Motion 4 (Transcoded)', 'Motion 5 (Transcoded)',
'Person 1', 'Person 2', 'Person 3', 'Person 4', 'Person 5',
'Person 1 (Transcoded)', 'Person 2 (Transcoded)', 'Person 3 (Transcoded)', 'Person 4 (Transcoded)', 'Person 5 (Transcoded)',
'On-demand 1', 'On-demand 2', 'On-demand 3', 'On-demand 4', 'On-demand 5',
'On-demand 1 (Transcoded)', 'On-demand 2 (Transcoded)', 'On-demand 3 (Transcoded)', 'On-demand 4 (Transcoded)', 'On-demand 5 (Transcoded)',
], ],
attributes: true attributes: true
}, },
@@ -161,27 +171,23 @@ class Camera extends RingPolledDevice {
} }
} }
utils.event.on(`livestream_${this.deviceId}`, (state) => { this.data.stream.live.worker.on('message', (message) => {
switch (state) { switch (message) {
case 'active': case 'active':
if (this.data.stream.live.status !== 'active') {
this.debug('Live stream has been successfully activated')
}
this.data.stream.live.status = 'active' this.data.stream.live.status = 'active'
this.data.stream.live.session = true this.data.stream.live.session = true
break; break;
case 'inactive': case 'inactive':
if (this.data.stream.live.status !== 'inactive') {
this.debug('Live stream has been successfully deactivated')
}
this.data.stream.live.status = 'inactive' this.data.stream.live.status = 'inactive'
this.data.stream.live.session = false this.data.stream.live.session = false
break; break;
case 'failed': case 'failed':
this.debug('Live stream failed to activate')
this.data.stream.live.status = 'failed' this.data.stream.live.status = 'failed'
this.data.stream.live.session = false this.data.stream.live.session = false
break; break;
default:
this.debug(message, 'wrtc')
return
} }
this.publishStreamState() this.publishStreamState()
}) })
@@ -226,7 +232,7 @@ class Camera extends RingPolledDevice {
} }
// If device is battery powered publish battery entity // If device is battery powered publish battery entity
if (this.device.hasBattery) { if (this.device.batteryLevel || this.hasBattery1 || this.hasBattery2) {
this.entity.battery = { this.entity.battery = {
component: 'sensor', component: 'sensor',
device_class: 'battery', device_class: 'battery',
@@ -238,7 +244,7 @@ class Camera extends RingPolledDevice {
} }
} }
// Update motion properties with most recent historical event data // Get most recent motion event data
const lastMotionEvent = (await this.device.getEvents({ limit: 1, kind: 'motion'})).events[0] const lastMotionEvent = (await this.device.getEvents({ limit: 1, kind: 'motion'})).events[0]
const lastMotionDate = (lastMotionEvent?.created_at) ? new Date(lastMotionEvent.created_at) : false const lastMotionDate = (lastMotionEvent?.created_at) ? new Date(lastMotionEvent.created_at) : false
this.data.motion.last_ding = lastMotionDate ? Math.floor(lastMotionDate/1000) : 0 this.data.motion.last_ding = lastMotionDate ? Math.floor(lastMotionDate/1000) : 0
@@ -247,7 +253,7 @@ class Camera extends RingPolledDevice {
this.data.motion.is_person = (lastMotionEvent.cv_properties.detection_type === 'human') ? true : false this.data.motion.is_person = (lastMotionEvent.cv_properties.detection_type === 'human') ? true : false
} }
// Update motion properties with most recent historical event data // Get most recent ding event data
if (this.device.isDoorbot) { if (this.device.isDoorbot) {
const lastDingEvent = (await this.device.getEvents({ limit: 1, kind: 'ding'})).events[0] const lastDingEvent = (await this.device.getEvents({ limit: 1, kind: 'ding'})).events[0]
const lastDingDate = (lastDingEvent?.created_at) ? new Date(lastDingEvent.created_at) : false const lastDingDate = (lastDingEvent?.created_at) ? new Date(lastDingEvent.created_at) : false
@@ -255,8 +261,11 @@ class Camera extends RingPolledDevice {
this.data.ding.last_ding_time = lastDingDate ? utils.getISOTime(lastDingDate) : '' this.data.ding.last_ding_time = lastDingDate ? utils.getISOTime(lastDingDate) : ''
} }
if (!await this.updateEventStreamUrl()) { // Try to get URL for most recent motion event, if it fails, assume there's no subscription
this.debug('Could not retrieve recording URL for event, assuming no Ring Protect subscription') const events = await(this.getRecordedEvents('motion', 1))
const recordingUrl = await this.device.getRecordingUrl(events[0].event_id, { transcoded: false })
if (!recordingUrl) {
this.debug('Could not retrieve recording URL for any motion event, assuming no Ring Protect subscription')
delete this.entity.event_stream delete this.entity.event_stream
delete this.entity.event_select delete this.entity.event_select
} }
@@ -279,8 +288,8 @@ class Camera extends RingPolledDevice {
// Set some helper attributes for streaming // Set some helper attributes for streaming
this.data.stream.live.stillImageURL = `https://${stillImageUrlBase}:8123{{ states.camera.${this.device.name.toLowerCase().replace(" ","_")}_snapshot.attributes.entity_picture }}`, this.data.stream.live.stillImageURL = `https://${stillImageUrlBase}:8123{{ states.camera.${this.device.name.toLowerCase().replace(" ","_")}_snapshot.attributes.entity_picture }}`,
this.data.stream.live.streamSource = (utils.config.livestream_user && utils.config.livestream_pass) this.data.stream.live.streamSource = (utils.config().livestream_user && utils.config().livestream_pass)
? `rtsp://${utils.config.livestream_user}:${utils.config.livestream_pass}@${streamSourceUrlBase}:8554/${this.deviceId}_live` ? `rtsp://${utils.config().livestream_user}:${utils.config().livestream_pass}@${streamSourceUrlBase}:8554/${this.deviceId}_live`
: `rtsp://${streamSourceUrlBase}:8554/${this.deviceId}_live` : `rtsp://${streamSourceUrlBase}:8554/${this.deviceId}_live`
} }
@@ -328,14 +337,16 @@ class Camera extends RingPolledDevice {
const isPublish = data === undefined ? true : false const isPublish = data === undefined ? true : false
this.publishPolledState(isPublish) this.publishPolledState(isPublish)
// Update every 3 polling cycles (~1 minute), check for updated event or expired recording URL // Checks for new events or expired recording URL even 3 polling cycles (~1 minute)
this.data.stream.event.pollCycle-- if (this.entity.hasOwnProperty('event_select')) {
if (this.data.stream.event.pollCycle <= 0) { this.data.event_select.pollCycle--
this.data.stream.event.pollCycle = 3 if (this.data.event_select.pollCycle <= 0) {
if (this.entity.event_select && await this.updateEventStreamUrl() && !isPublish) { this.data.event_select.pollCycle = 3
if (await this.updateEventStreamUrl() && !isPublish) {
this.publishEventSelectState() this.publishEventSelectState()
} }
} }
}
if (isPublish) { if (isPublish) {
// Publish stream state // Publish stream state
@@ -488,16 +499,35 @@ class Camera extends RingPolledDevice {
// Publish device data to info topic // Publish device data to info topic
async publishAttributes() { async publishAttributes() {
const attributes = {}
const deviceHealth = await this.device.getHealth() const deviceHealth = await this.device.getHealth()
if (deviceHealth) { if (this.device.batteryLevel || this.hasBattery1 || this.hasBattery2) {
const attributes = {} if (deviceHealth && deviceHealth.hasOwnProperty('active_battery')) {
if (this.device.hasBattery) { attributes.activeBattery = deviceHealth.active_battery
attributes.batteryLevel = deviceHealth.battery_percentage
} }
// Reports the level of the currently active battery, might be null if removed so report 0% in that case
attributes.batteryLevel = this.device.batteryLevel && utils.isNumeric(this.device.batteryLevel)
? this.device.batteryLevel
: 0
// Must have at least one battery, but it might not be inserted, so report 0% in that case
attributes.batteryLife = this.device.data.hasOwnProperty('battery_life') && utils.isNumeric(this.device.data.battery_life)
? Number.parseFloat(this.device.data.battery_life)
: 0
if (this.hasBattery2) {
attributes.batteryLife2 = this.device.data.hasOwnProperty('battery_life_2') && utils.isNumeric(this.device.data.battery_life_2)
? Number.parseFloat(this.device.data.battery_life_2)
: 0
}
}
if (deviceHealth) {
attributes.firmwareStatus = deviceHealth.firmware attributes.firmwareStatus = deviceHealth.firmware
attributes.lastUpdate = deviceHealth.updated_at.slice(0,-6)+"Z" attributes.lastUpdate = deviceHealth.updated_at.slice(0,-6)+"Z"
if (deviceHealth?.network_connection && deviceHealth.network_connection === 'ethernet') { if (deviceHealth.hasOwnProperty('network_connection') && deviceHealth.network_connection === 'ethernet') {
attributes.wiredNetwork = this.device.data.alerts.connection attributes.wiredNetwork = this.device.data.alerts.connection
} else { } else {
attributes.wirelessNetwork = deviceHealth.wifi_name attributes.wirelessNetwork = deviceHealth.wifi_name
@@ -505,6 +535,9 @@ class Camera extends RingPolledDevice {
} }
attributes.stream_Source = this.data.stream.live.streamSource attributes.stream_Source = this.data.stream.live.streamSource
attributes.still_Image_URL = this.data.stream.live.stillImageURL attributes.still_Image_URL = this.data.stream.live.stillImageURL
}
if (Object.keys(attributes).length > 0) {
this.mqttPublish(this.entity.info.state_topic, JSON.stringify(attributes), 'attr') this.mqttPublish(this.entity.info.state_topic, JSON.stringify(attributes), 'attr')
this.publishAttributeEntities(attributes) this.publishAttributeEntities(attributes)
} }
@@ -557,8 +590,8 @@ class Camera extends RingPolledDevice {
this.mqttPublish(this.entity.event_select.state_topic, this.data.event_select.state) this.mqttPublish(this.entity.event_select.state_topic, this.data.event_select.state)
} }
const attributes = { const attributes = {
recordingUrl: this.data.stream.event.recordingUrl, recordingUrl: this.data.event_select.recordingUrl,
eventId: this.data.stream.event.dingId eventId: this.data.event_select.eventId
} }
this.mqttPublish(this.entity.event_select.json_attributes_topic, JSON.stringify(attributes), 'attr', '<recording_url_masked>') this.mqttPublish(this.entity.event_select.json_attributes_topic, JSON.stringify(attributes), 'attr', '<recording_url_masked>')
} }
@@ -616,24 +649,21 @@ class Camera extends RingPolledDevice {
} }
} }
async startLiveStream() { async startLiveStream(rtspPublishUrl) {
this.data.stream.live.session = true this.data.stream.live.session = true
const streamData = { const streamData = {
deviceId: this.deviceId, rtspPublishUrl,
deviceName: this.device.name,
doorbotId: this.device.id,
rtspPublishUrl: this.data.stream.live.rtspPublishUrl,
sessionId: false, sessionId: false,
authToken: false authToken: false
} }
try { try {
if (this.device.isRingEdgeEnabled) { if (this.device.isRingEdgeEnabled) {
this.debug('Starting a live stream session via Ring Edge') this.debug('Initializing a live stream session for Ring Edge')
const auth = await this.device.restClient.getCurrentAuth() const auth = await this.device.restClient.getCurrentAuth()
streamData.authToken = auth.access_token streamData.authToken = auth.access_token
} else { } else {
this.debug('Starting a live stream session via Ring servers') this.debug('Initializing a live stream session for Ring cloud')
const liveCall = await this.device.restClient.request({ const liveCall = await this.device.restClient.request({
method: 'POST', method: 'POST',
url: this.device.doorbotUrl('live_call') url: this.device.doorbotUrl('live_call')
@@ -651,49 +681,77 @@ class Camera extends RingPolledDevice {
} }
if (streamData.sessionId || streamData.authToken) { if (streamData.sessionId || streamData.authToken) {
utils.event.emit('start_livestream', streamData) this.debug('Live stream session successfully initialized, starting worker')
this.data.stream.live.worker.postMessage({ command: 'start', streamData })
} else { } else {
this.debug('Live stream failed to activate') this.debug('Live stream activation failed to initialize session data')
this.data.stream.live.status = 'failed' this.data.stream.live.status = 'failed'
this.data.stream.live.session = false this.data.stream.live.session = false
this.publishStreamState() this.publishStreamState()
} }
} }
async startEventStream() { async startEventStream(rtspPublishUrl) {
if (await this.updateEventStreamUrl()) { const eventSelect = this.data.event_select.state.split(' ')
this.publishEventSelectState() const eventType = eventSelect[0].toLowerCase().replace('-', '_')
const eventNumber = eventSelect[1]
if (this.data.event_select.recordingUrl === '<No Valid URL>') {
this.debug(`No valid recording was found for the ${(eventNumber==1?"":eventNumber==2?"2nd ":eventNumber==3?"3rd ":eventNumber+"th ")}most recent ${eventType} event!`)
this.data.stream.event.status = 'failed'
this.data.stream.event.session = false
this.publishStreamState()
return
} }
const streamSelect = this.data.event_select.state.split(' ')
const kind = streamSelect[0].toLowerCase().replace('-', '_') this.debug(`Streaming the ${(eventNumber==1?"":eventNumber==2?"2nd ":eventNumber==3?"3rd ":eventNumber+"th ")}most recently recorded ${eventType} event`)
const index = streamSelect[1]
this.debug(`Streaming the ${(index==1?"":index==2?"2nd ":index==3?"3rd ":index+"th ")}most recently recorded ${kind} event`)
try { try {
if (this.data.event_select.transcoded) {
// Ring transcoded videos are poorly optimized for RTSP streaming so they must be re-encoded on-the-fly
this.data.stream.event.session = spawn(pathToFfmpeg, [ this.data.stream.event.session = spawn(pathToFfmpeg, [
'-re', '-re',
'-max_delay', '0', '-i', this.data.event_select.recordingUrl,
'-i', this.data.stream.event.recordingUrl,
'-map', '0:v', '-map', '0:v',
'-map', '0:a', '-map', '0:a',
'-map', '0:a', '-map', '0:a',
'-c:v', 'libx264',
'-g', '20',
'-keyint_min', '10',
'-crf', '18',
'-preset', 'ultrafast',
'-c:a:0', 'copy', '-c:a:0', 'copy',
'-c:a:1', 'libopus', '-c:a:1', 'libopus',
'-c:v', 'copy',
'-flags', '+global_header', '-flags', '+global_header',
'-f', 'rtsp',
'-rtsp_transport', 'tcp', '-rtsp_transport', 'tcp',
this.data.stream.event.rtspPublishUrl '-f', 'rtsp',
rtspPublishUrl
]) ])
} else {
this.data.stream.event.session = spawn(pathToFfmpeg, [
'-re',
'-i', this.data.event_select.recordingUrl,
'-map', '0:v',
'-map', '0:a',
'-map', '0:a',
'-c:v', 'copy',
'-c:a:0', 'copy',
'-c:a:1', 'libopus',
'-flags', '+global_header',
'-rtsp_transport', 'tcp',
'-f', 'rtsp',
rtspPublishUrl
])
}
this.data.stream.event.session.on('spawn', async () => { this.data.stream.event.session.on('spawn', async () => {
this.debug(`The recorded ${kind} event stream has started`) this.debug(`The recorded ${eventType} event stream has started`)
this.data.stream.event.status = 'active' this.data.stream.event.status = 'active'
this.publishStreamState() this.publishStreamState()
}) })
this.data.stream.event.session.on('close', async () => { this.data.stream.event.session.on('close', async () => {
this.debug(`The recorded ${kind} event stream has ended`) this.debug(`The recorded ${eventType} event stream has ended`)
this.data.stream.event.status = 'inactive' this.data.stream.event.status = 'inactive'
this.data.stream.event.session = false this.data.stream.event.session = false
this.publishStreamState() this.publishStreamState()
@@ -710,87 +768,127 @@ class Camera extends RingPolledDevice {
const duration = 86400 const duration = 86400
if (this.data.stream.keepalive.active) { return } if (this.data.stream.keepalive.active) { return }
this.data.stream.keepalive.active = true this.data.stream.keepalive.active = true
let ffmpegProcess
let killSignal = 'SIGTERM'
// Start stream with MJPEG output directed to P2J server with one frame every 2 seconds const rtspPublishUrl = (utils.config().livestream_user && utils.config().livestream_pass)
? `rtsp://${utils.config().livestream_user}:${utils.config().livestream_pass}@localhost:8554/${this.deviceId}_live`
: `rtsp://localhost:8554/${this.deviceId}_live`
this.debug(`Starting a keepalive stream for camera`) this.debug(`Starting a keepalive stream for camera`)
// Keepalive stream is used only when the live stream is started // Keepalive stream is used only when the live stream is started
// manually. It copies only the audio stream to null output just to // manually. It copies only the audio stream to null output just to
// trigger rtsp-simple-server to start the on-demand stream and // trigger rtsp server to start the on-demand stream and keep it running
// keep it running when there are no other RTSP readers. // when there are no other RTSP readers.
ffmpegProcess = spawn(pathToFfmpeg, [ this.data.stream.keepalive.session = spawn(pathToFfmpeg, [
'-i', this.data.stream.live.rtspPublishUrl, '-i', rtspPublishUrl,
'-map', '0:a:0', '-map', '0:a:0',
'-c:a', 'copy', '-c:a', 'copy',
'-f', 'null', '-f', 'null',
'/dev/null' '/dev/null'
]) ])
ffmpegProcess.on('spawn', async () => { this.data.stream.keepalive.session.on('spawn', async () => {
this.debug(`The keepalive stream has started`) this.debug(`The keepalive stream has started`)
}) })
ffmpegProcess.on('close', async () => { this.data.stream.keepalive.session.on('close', async () => {
this.data.stream.keepalive.active = false this.data.stream.keepalive.active = false
this.data.stream.keepalive.session = false
this.debug(`The keepalive stream has stopped`) this.debug(`The keepalive stream has stopped`)
}) })
// If stream starts, set expire time, may be extended by new events // The keepalive stream will time out after 24 hours
// (if only Ring sent events while streaming)
this.data.stream.keepalive.expires = Math.floor(Date.now()/1000) + duration this.data.stream.keepalive.expires = Math.floor(Date.now()/1000) + duration
while (this.data.stream.keepalive.active && Math.floor(Date.now()/1000) < this.data.stream.keepalive.expires) {
while (Math.floor(Date.now()/1000) < this.data.stream.keepalive.expires) { await utils.sleep(60)
await utils.sleep(5)
const pathDetails = await rss.getPathDetails(`${this.deviceId}_live`)
if (pathDetails.hasOwnProperty('sourceReady') && !pathDetails.sourceReady) {
// If the source stream stops (due to manual cancel or Ring timeout)
// force the keepalive stream to expire
this.debug('Ring live stream has stopped publishing, killing the keepalive stream')
this.data.stream.keepalive.expires = 0
// For some reason the keepalive stream never times out so kill the process hard
killSignal = 'SIGKILL'
} }
} this.data.stream.keepalive.session.kill()
ffmpegProcess.kill(killSignal)
this.data.stream.keepalive.active = false this.data.stream.keepalive.active = false
this.data.stream.keepalive.session = false
} }
async updateEventStreamUrl() { async updateEventStreamUrl() {
const streamSelect = this.data.event_select.state.split(' ') const eventSelect = this.data.event_select.state.split(' ')
const kind = streamSelect[0].toLowerCase().replace('-', '_') const eventType = eventSelect[0].toLowerCase().replace('-', '_')
const index = streamSelect[1]-1 const eventNumber = eventSelect[1]
const transcoded = eventSelect[2] === '(Transcoded)' ? true : false
const urlExpired = Math.floor(Date.now()/1000) - this.data.event_select.recordingUrlExpire > 0 ? true : false
let selectedEvent
let recordingUrl let recordingUrl
let dingId
try { try {
const events = ((await this.device.getEvents({ limit: 10, kind })).events).filter(event => event.recording_status === 'ready') const events = await(this.getRecordedEvents(eventType, eventNumber))
dingId = events[index].ding_id_str selectedEvent = events[eventNumber-1]
if (dingId !== this.data.stream.event.dingId) {
if (this.data.stream.event.recordingUrlExpire) { if (selectedEvent) {
// Only log after first update if (selectedEvent.event_id !== this.data.event_select.eventId || this.data.event_select.transcoded !== transcoded) {
this.debug(`New ${kind} event detected, updating the event recording URL`) if (this.data.event_select.recordingUrl) {
this.debug(`New ${this.data.event_select.state} event detected, updating the recording URL`)
} }
recordingUrl = await this.device.getRecordingUrl(dingId) recordingUrl = await this.device.getRecordingUrl(selectedEvent.event_id, { transcoded })
} else if (Math.floor(Date.now()/1000) - this.data.stream.event.recordingUrlExpire > 0) { } else if (urlExpired) {
this.debug(`Previous ${kind} event recording URL has expired, updating the event recording URL`) this.debug(`Previous ${this.data.event_select.state} URL has expired, updating the recording URL`)
recordingUrl = await this.device.getRecordingUrl(dingId) recordingUrl = await this.device.getRecordingUrl(selectedEvent.event_id, { transcoded })
} }
} catch { }
this.debug(`Failed to retrieve recording URL for ${kind} event`) } catch(error) {
return false this.debug(error)
this.debug(`Failed to retrieve recording URL for ${this.data.event_select.state} event`)
} }
if (recordingUrl) { if (recordingUrl) {
this.data.stream.event.dingId = dingId this.data.event_select.recordingUrl = recordingUrl
this.data.stream.event.recordingUrl = recordingUrl this.data.event_select.transcoded = transcoded
this.data.stream.event.recordingUrlExpire = Math.floor(Date.now()/1000) + 600 this.data.event_select.eventId = selectedEvent.event_id
return true
// Try to parse URL parameters to set expire time
const urlSearch = new URLSearchParams(recordingUrl)
const amzExpires = Number(urlSearch.get('X-Amz-Expires'))
const amzDate = urlSearch.get('X-Amz-Date')
if (amzDate && amzExpires && amzExpires !== 'NaN') {
const [_, year, month, day, hour, min, sec] = amzDate.match(/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/)
this.data.event_select.recordingUrlExpire = Math.floor(Date.UTC(year, month-1, day, hour, min, sec)/1000)+amzExpires-75
} else { } else {
this.data.event_select.recordingUrlExpire = Math.floor(Date.now()/1000) + 600
}
} else if (urlExpired || !selectedEvent) {
this.data.event_select.recordingUrl = '<No Valid URL>'
this.data.event_select.transcoded = transcoded
this.data.event_select.eventId = '0'
return false return false
} }
return recordingUrl
}
async getRecordedEvents(eventType, eventNumber) {
let events = []
try {
if (eventType !== 'person') {
events = ((await this.device.getEvents({
limit: eventNumber+2,
kind: eventType
})).events).filter(event => event.recording_status === 'ready')
} else {
let loop = 0
while (loop <= 3 && events.length < eventNumber) {
events = ((await this.device.getEvents({
limit: 50,
kind: 'motion'
})).events).filter(event => event.recording_status === 'ready' && event.cv_properties.detection_type === 'human')
loop++
await utils.msleep(100)
}
}
} catch(error) {
this.debug(error)
}
if (events.length === 0) {
this.debug(`No recording corresponding to ${this.data.event_select.state} was found in event history`)
}
return events
} }
// Process messages from MQTT command topic // Process messages from MQTT command topic
@@ -832,14 +930,14 @@ class Camera extends RingPolledDevice {
// Set switch target state on received MQTT command message // Set switch target state on received MQTT command message
async setLightState(message) { async setLightState(message) {
this.debug(`Received set light state ${message}`) this.debug(`Received set light state ${message}`)
const command = message.toLowerCase() const command = message.toUpperCase()
switch (command) { switch (command) {
case 'on': case 'ON':
case 'off': case 'OFF':
this.data.light.setTime = Math.floor(Date.now()/1000) this.data.light.setTime = Math.floor(Date.now()/1000)
await this.device.setLight(command === 'on' ? true : false) await this.device.setLight(command === 'ON' ? true : false)
this.data.light.state = command === 'on' ? 'ON' : 'OFF' this.data.light.state = command
this.mqttPublish(this.entity.light.state_topic, this.data.light.state) this.mqttPublish(this.entity.light.state_topic, this.data.light.state)
break; break;
default: default:
@@ -922,28 +1020,31 @@ class Camera extends RingPolledDevice {
setLiveStreamState(message) { setLiveStreamState(message) {
const command = message.toLowerCase() const command = message.toLowerCase()
this.debug(`Received set live stream state ${message}`) this.debug(`Received set live stream state ${message}`)
if (command.startsWith('on-demand')) {
if (this.data.stream.live.status === 'active' || this.data.stream.live.status === 'activating') {
this.publishStreamState()
} else {
this.data.stream.live.status = 'activating'
this.publishStreamState()
this.startLiveStream(message.split(' ')[1]) // Portion after space is the RTSP publish URL
}
} else {
switch (command) { switch (command) {
case 'on': case 'on':
// Stream was manually started, create a dummy, audio only // Stream was manually started, create a dummy, audio only
// RTSP source stream to trigger stream startup and keep it active // RTSP source stream to trigger stream startup and keep it active
this.startKeepaliveStream() this.startKeepaliveStream()
break; break;
case 'on-demand':
if (this.data.stream.live.status === 'active' || this.data.stream.live.status === 'activating') {
this.publishStreamState()
} else {
this.data.stream.live.status = 'activating'
this.publishStreamState()
this.startLiveStream()
}
break;
case 'off': case 'off':
if (this.data.stream.live.session) { if (this.data.stream.keepalive.session) {
this.debug('Stopping the keepalive stream')
this.data.stream.keepalive.session.kill()
} else if (this.data.stream.live.session) {
const streamData = { const streamData = {
deviceId: this.deviceId, deviceId: this.deviceId,
deviceName: this.device.name deviceName: this.device.name
} }
utils.event.emit('stop_livestream', streamData) this.data.stream.live.worker.postMessage({ command: 'stop' })
} else { } else {
this.data.stream.live.status = 'inactive' this.data.stream.live.status = 'inactive'
this.publishStreamState() this.publishStreamState()
@@ -953,22 +1054,23 @@ class Camera extends RingPolledDevice {
this.debug(`Received unknown command for live stream`) this.debug(`Received unknown command for live stream`)
} }
} }
}
setEventStreamState(message) { setEventStreamState(message) {
const command = message.toLowerCase() const command = message.toLowerCase()
this.debug(`Received set event stream state ${message}`) this.debug(`Received set event stream state ${message}`)
switch (command) { if (command.startsWith('on-demand')) {
case 'on':
this.debug(`Event stream can only be started on-demand!`)
break;
case 'on-demand':
if (this.data.stream.event.status === 'active' || this.data.stream.event.status === 'activating') { if (this.data.stream.event.status === 'active' || this.data.stream.event.status === 'activating') {
this.publishStreamState() this.publishStreamState()
} else { } else {
this.data.stream.event.status = 'activating' this.data.stream.event.status = 'activating'
this.publishStreamState() this.publishStreamState()
this.startEventStream() this.startEventStream(message.split(' ')[1]) // Portion after backslash is RTSP publish URL
} }
} else {
switch (command) {
case 'on':
this.debug(`Event stream can only be started on-demand!`)
break; break;
case 'off': case 'off':
if (this.data.stream.event.session) { if (this.data.stream.event.session) {
@@ -982,6 +1084,7 @@ class Camera extends RingPolledDevice {
this.debug(`Received unknown command for event stream`) this.debug(`Received unknown command for event stream`)
} }
} }
}
// Set Stream Select Option // Set Stream Select Option
async setEventSelect(message) { async setEventSelect(message) {
@@ -992,13 +1095,10 @@ class Camera extends RingPolledDevice {
} }
this.data.event_select.state = message this.data.event_select.state = message
this.updateDeviceState() this.updateDeviceState()
if (await this.updateEventStreamUrl()) { await this.updateEventStreamUrl()
this.publishEventSelectState() this.publishEventSelectState()
}
} else { } else {
this.debug('Received invalid value for event stream') this.debug('Received invalid value for event stream')
} }
} }
} }
module.exports = Camera

View File

@@ -1,7 +1,7 @@
const RingPolledDevice = require('./base-polled-device') import RingPolledDevice from './base-polled-device.js'
const utils = require( '../lib/utils' ) import utils from '../lib/utils.js'
class Chime extends RingPolledDevice { export default class Chime extends RingPolledDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'chime') super(deviceInfo, 'chime')
@@ -13,7 +13,12 @@ class Chime extends RingPolledDevice {
snooze_minutes: savedState?.snooze_minutes ? savedState.snooze_minutes : 1440, snooze_minutes: savedState?.snooze_minutes ? savedState.snooze_minutes : 1440,
snooze_minutes_remaining: Math.floor(this.device.data.do_not_disturb.seconds_left/60), snooze_minutes_remaining: Math.floor(this.device.data.do_not_disturb.seconds_left/60),
play_ding_sound: 'OFF', play_ding_sound: 'OFF',
play_motion_sound: 'OFF' play_motion_sound: 'OFF',
nightlight: {
enabled: null,
state: null,
set_time: Math.floor(Date.now()/1000)
}
} }
// Define entities for this device // Define entities for this device
@@ -44,6 +49,13 @@ class Chime extends RingPolledDevice {
component: 'switch', component: 'switch',
icon: 'hass:bell-ring' icon: 'hass:bell-ring'
}, },
...this.device.deviceType.startsWith('chime_pro') ? {
nightlight_enabled: {
component: 'switch',
icon: "mdi:lightbulb-night",
attributes: true
}
} : {},
info: { info: {
component: 'sensor', component: 'sensor',
device_class: 'timestamp', device_class: 'timestamp',
@@ -77,6 +89,8 @@ class Chime extends RingPolledDevice {
const volumeState = this.device.data.settings.volume const volumeState = this.device.data.settings.volume
const snoozeState = Boolean(this.device.data.do_not_disturb.seconds_left) ? 'ON' : 'OFF' const snoozeState = Boolean(this.device.data.do_not_disturb.seconds_left) ? 'ON' : 'OFF'
const snoozeMinutesRemaining = Math.floor(this.device.data.do_not_disturb.seconds_left/60) const snoozeMinutesRemaining = Math.floor(this.device.data.do_not_disturb.seconds_left/60)
const nightlightEnabled = this.device.data.settings.night_light_settings.light_sensor_enabled ? 'ON' : 'OFF'
const nightlightState = this.device.data.night_light_state.toUpperCase()
// Polled states are published only if value changes or it's a device publish // Polled states are published only if value changes or it's a device publish
if (volumeState !== this.data.volume || isPublish) { if (volumeState !== this.data.volume || isPublish) {
@@ -94,6 +108,19 @@ class Chime extends RingPolledDevice {
this.data.snooze_minutes_remaining = snoozeMinutesRemaining this.data.snooze_minutes_remaining = snoozeMinutesRemaining
} }
if (this.entity.hasOwnProperty('nightlight_enabled')) {
if ((nightlightEnabled !== this.data.nightlight.enabled && Date.now()/1000 - this.data.nightlight.set_time > 30) || isPublish) {
this.data.nightlight.enabled = nightlightEnabled
this.mqttPublish(this.entity.nightlight_enabled.state_topic, this.data.nightlight.enabled)
}
if (nightlightState !== this.data.nightlight.state || isPublish) {
this.data.nightlight.state = nightlightState
const attributes = { nightlightState: this.data.nightlight.state }
this.mqttPublish(this.entity.nightlight_enabled.json_attributes_topic, JSON.stringify(attributes), 'attr')
}
}
// Local states are published only for publish/republish // Local states are published only for publish/republish
if (isPublish) { if (isPublish) {
this.mqttPublish(this.entity.snooze_minutes.state_topic, this.data.snooze_minutes.toString()) this.mqttPublish(this.entity.snooze_minutes.state_topic, this.data.snooze_minutes.toString())
@@ -107,16 +134,26 @@ class Chime extends RingPolledDevice {
async publishAttributes() { async publishAttributes() {
const deviceHealth = await this.device.getHealth() const deviceHealth = await this.device.getHealth()
if (deviceHealth) { if (deviceHealth) {
const attributes = {} const attributes = {
attributes.wirelessNetwork = deviceHealth.wifi_name wirelessNetwork: deviceHealth.wifi_name,
attributes.wirelessSignal = deviceHealth.latest_signal_strength wirelessSignal: deviceHealth.latest_signal_strength,
attributes.firmwareStatus = deviceHealth.firmware firmwareStatus: deviceHealth.firmware,
attributes.lastUpdate = deviceHealth.updated_at.slice(0,-6)+"Z" lastUpdate: deviceHealth.updated_at.slice(0,-6)+"Z"
}
this.mqttPublish(this.entity.info.state_topic, JSON.stringify(attributes), 'attr') this.mqttPublish(this.entity.info.state_topic, JSON.stringify(attributes), 'attr')
this.publishAttributeEntities(attributes) this.publishAttributeEntities(attributes)
} }
} }
async setDeviceSettings(settings) {
const response = await this.device.restClient.request({
method: 'PATCH',
url: `https://api.ring.com/devices/v1/devices/${this.device.id}/settings`,
json: settings
})
return response
}
// Process messages from MQTT command topic // Process messages from MQTT command topic
processCommand(command, message) { processCommand(command, message) {
switch (command) { switch (command) {
@@ -135,6 +172,9 @@ class Chime extends RingPolledDevice {
case 'play_motion_sound/command': case 'play_motion_sound/command':
this.playSound(message, 'motion') this.playSound(message, 'motion')
break; break;
case 'nightlight_enabled/command':
this.setNightlightState(message)
break;
default: default:
this.debug(`Received message to unknown command topic: ${command}`) this.debug(`Received message to unknown command topic: ${command}`)
} }
@@ -203,6 +243,24 @@ class Chime extends RingPolledDevice {
this.debug('Received invalid command for play chime sound!') this.debug('Received invalid command for play chime sound!')
} }
} }
}
module.exports = Chime async setNightlightState(message) {
this.debug(`Received set nightlight enabled ${message}`)
const command = message.toUpperCase()
switch(command) {
case 'ON':
case 'OFF':
this.data.nightlight.set_time = Math.floor(Date.now()/1000)
await this.setDeviceSettings({
"night_light_settings": {
"light_sensor_enabled": command === 'ON' ? true : false
}
})
this.data.nightlight.enabled = command
this.mqttPublish(this.entity.nightlight_enabled.state_topic, this.data.nightlight.enabled)
break;
default:
this.debug('Received invalid command for nightlight enabled mode!')
}
}
}

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class CoAlarm extends RingSocketDevice { export default class CoAlarm extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'CO Alarm' this.deviceData.mdl = 'CO Alarm'
@@ -21,5 +21,3 @@ class CoAlarm extends RingSocketDevice {
this.publishAttributes() this.publishAttributes()
} }
} }
module.exports = CoAlarm

View File

@@ -1,7 +1,7 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
const utils = require( '../lib/utils' ) import utils from '../lib/utils.js'
class Fan extends RingSocketDevice { export default class Fan extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Fan Control' this.deviceData.mdl = 'Fan Control'
@@ -131,5 +131,3 @@ class Fan extends RingSocketDevice {
} }
} }
} }
module.exports = Fan

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class FloodFreezeSensor extends RingSocketDevice { export default class FloodFreezeSensor extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Flood & Freeze Sensor' this.deviceData.mdl = 'Flood & Freeze Sensor'
@@ -25,5 +25,3 @@ class FloodFreezeSensor extends RingSocketDevice {
this.publishAttributes() this.publishAttributes()
} }
} }
module.exports = FloodFreezeSensor

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class Keypad extends RingSocketDevice { export default class Keypad extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Security Keypad' this.deviceData.mdl = 'Security Keypad'
@@ -50,5 +50,3 @@ class Keypad extends RingSocketDevice {
} }
} }
module.exports = Keypad

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class Lock extends RingSocketDevice { export default class Lock extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Lock' this.deviceData.mdl = 'Lock'
@@ -52,5 +52,3 @@ class Lock extends RingSocketDevice {
} }
} }
} }
module.exports = Lock

View File

@@ -1,7 +1,7 @@
const RingPolledDevice = require('./base-polled-device') import RingPolledDevice from './base-polled-device.js'
const utils = require( '../lib/utils' ) import utils from '../lib/utils.js'
class ModesPanel extends RingPolledDevice { export default class ModesPanel extends RingPolledDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm', 'disable') super(deviceInfo, 'alarm', 'disable')
this.deviceData.mdl = 'Mode Control Panel' this.deviceData.mdl = 'Mode Control Panel'
@@ -105,5 +105,3 @@ class ModesPanel extends RingPolledDevice {
} }
} }
} }
module.exports = ModesPanel

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class MultiLevelSwitch extends RingSocketDevice { export default class MultiLevelSwitch extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Dimming Light' this.deviceData.mdl = 'Dimming Light'
@@ -63,5 +63,3 @@ class MultiLevelSwitch extends RingSocketDevice {
} }
} }
} }
module.exports = MultiLevelSwitch

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class RangeExtender extends RingSocketDevice { export default class RangeExtender extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm', 'acStatus') super(deviceInfo, 'alarm', 'acStatus')
this.deviceData.mdl = 'Z-Wave Range Extender' this.deviceData.mdl = 'Z-Wave Range Extender'
@@ -12,5 +12,3 @@ class RangeExtender extends RingSocketDevice {
this.publishAttributes() this.publishAttributes()
} }
} }
module.exports = RangeExtender

View File

@@ -1,9 +1,9 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
const { allAlarmStates, RingDeviceType } = require('ring-client-api') import { allAlarmStates, RingDeviceType } from 'ring-client-api'
const utils = require( '../lib/utils' ) import utils from '../lib/utils.js'
const state = require('../lib/state') import state from '../lib/state.js'
class SecurityPanel extends RingSocketDevice { export default class SecurityPanel extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm', 'alarmState') super(deviceInfo, 'alarm', 'alarmState')
this.deviceData.mdl = 'Alarm Control Panel' this.deviceData.mdl = 'Alarm Control Panel'
@@ -20,7 +20,7 @@ class SecurityPanel extends RingSocketDevice {
icon: 'mdi:alarm-light', icon: 'mdi:alarm-light',
name: `${this.device.location.name} Siren` name: `${this.device.location.name} Siren`
}, },
...utils.config.enable_panic ? { ...utils.config().enable_panic ? {
police: { police: {
component: 'switch', component: 'switch',
name: `${this.device.location.name} Panic - Police`, name: `${this.device.location.name} Panic - Police`,
@@ -74,7 +74,7 @@ class SecurityPanel extends RingSocketDevice {
const sirenState = (this.device.data.siren && this.device.data.siren.state === 'on') ? 'ON' : 'OFF' const sirenState = (this.device.data.siren && this.device.data.siren.state === 'on') ? 'ON' : 'OFF'
this.mqttPublish(this.entity.siren.state_topic, sirenState) this.mqttPublish(this.entity.siren.state_topic, sirenState)
if (utils.config.enable_panic) { if (utils.config().enable_panic) {
let policeState = 'OFF' let policeState = 'OFF'
let fireState = 'OFF' let fireState = 'OFF'
const alarmState = this.device.data.alarmInfo ? this.device.data.alarmInfo.state : '' const alarmState = this.device.data.alarmInfo ? this.device.data.alarmInfo.state : ''
@@ -268,5 +268,3 @@ class SecurityPanel extends RingSocketDevice {
} }
} }
} }
module.exports = SecurityPanel

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class Siren extends RingSocketDevice { export default class Siren extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = (this.device.data.deviceType === 'siren.outdoor-strobe') ? 'Outdoor Siren' : 'Siren' this.deviceData.mdl = (this.device.data.deviceType === 'siren.outdoor-strobe') ? 'Outdoor Siren' : 'Siren'
@@ -84,5 +84,3 @@ class Siren extends RingSocketDevice {
} }
} }
} }
module.exports = Siren

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class SmokeAlarm extends RingSocketDevice { export default class SmokeAlarm extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Smoke Alarm' this.deviceData.mdl = 'Smoke Alarm'
@@ -24,5 +24,3 @@ class SmokeAlarm extends RingSocketDevice {
this.publishAttributes() this.publishAttributes()
} }
} }
module.exports = SmokeAlarm

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class SmokeCoListener extends RingSocketDevice { export default class SmokeCoListener extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Smoke & CO Listener' this.deviceData.mdl = 'Smoke & CO Listener'
@@ -25,5 +25,3 @@ class SmokeCoListener extends RingSocketDevice {
this.publishAttributes() this.publishAttributes()
} }
} }
module.exports = SmokeCoListener

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class Switch extends RingSocketDevice { export default class Switch extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = (this.device.data.categoryId === 2) ? 'Light' : 'Switch' this.deviceData.mdl = (this.device.data.categoryId === 2) ? 'Light' : 'Switch'
@@ -43,5 +43,3 @@ class Switch extends RingSocketDevice {
} }
} }
} }
module.exports = Switch

View File

@@ -1,6 +1,6 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
class TemperatureSensor extends RingSocketDevice { export default class TemperatureSensor extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Temperature Sensor' this.deviceData.mdl = 'Temperature Sensor'
@@ -19,5 +19,3 @@ class TemperatureSensor extends RingSocketDevice {
this.publishAttributes() this.publishAttributes()
} }
} }
module.exports = TemperatureSensor

View File

@@ -1,8 +1,8 @@
const RingSocketDevice = require('./base-socket-device') import RingSocketDevice from './base-socket-device.js'
const { RingDeviceType } = require('ring-client-api') import { RingDeviceType } from 'ring-client-api'
const utils = require( '../lib/utils' ) import utils from '../lib/utils.js'
class Thermostat extends RingSocketDevice { export default class Thermostat extends RingSocketDevice {
constructor(deviceInfo) { constructor(deviceInfo) {
super(deviceInfo, 'alarm') super(deviceInfo, 'alarm')
this.deviceData.mdl = 'Thermostat' this.deviceData.mdl = 'Thermostat'
@@ -273,5 +273,3 @@ class Thermostat extends RingSocketDevice {
} }
} }
} }
module.exports = Thermostat

View File

@@ -1,3 +1,30 @@
## v5.1.0
After several releases focused on stability and minor bug fixes this release includes significant internal changes and a few new features.
**!!!!! WARNING !!!!!**
Starting with 5.1.x all backwards compatibiltiy with prior 4.x style configuration options has been removed. Upgrades from 4.x versions are still possible but will require manual conversion of any legacy configuration methods and options. Upgrades from 5.0.x versions should not require any changes.
**New Features**
- Added ability to refine event stream to only motion events where a person is detected
- Option to select transcoded vs raw video for event stream (this also changes the URL for scripting automatic download of recordings):
- Raw video (default) - This video is exactly as it was recorded by the camera and is the same as previous versions of ring-mqtt
- Transcoded video - This is the same as selecting to share/download video from the Ring app or web dashboard. This video includes the Ring logo and timestamps and may include supplemental pre-roll video for supported devices. Note that switching from a raw to transcoded event selection can take 10-15 seconds as transcoded videos are created by Ring on-demand so ring-mqtt must wait for the Ring servers to process the video and return the URL.
- New camera models should now display with correct model/features
- Improved support for cameras with dual batteries. The BatteryLevel attribute always reports the level of currently active battery but the level of both batteries is individually available via the batteryLife and batteryLife2 attributes.
- Switch to enable/disable the Chime Pro nightlight. The current nightlight on/off state can be determined via attribute.
**Other Changes**
- Reduced average live stream startup time by several hundred milliseconds with the following changes:
- Switched from rtsp-simple-server to go2rtc as the core streaming engine which provides slightly faster stream startup and opens the door for future feature enhancements. This switch is expected to be transparent for users, but please report any issues.
- Cameras now allocate a dedicated worker thread for live streaming vs previous versions which used a pool of worker threads based on the number of processor cores detected. This simplifies the live stream code and leads to faster stream startup and, hopefully, more reliable recovery from various error conditions.
- The recommended configuration for streaming setup in Home Assistant is now to use the go2rtc addon with RTSPtoWebRTC integration. This provides fast stream startup and shutdown and low-latency live vieweing (typically <1 second latency).
**Dependency Updates**
- Replaced problematic colors package with chalk for colorizing debug log output
- Bump ring-client-api to 11.7.1 (various fixes and support for newer cameras)
- Bump other dependent packages to latest versions
- Migrated project codebase from CommonJS to ESM. As this project is not a library this should have zero impact for users, but it does ease ongoing maintenance by enabling the ability to pull in newer versions of various dependent packages that have also moved to pure ESM.
## v5.0.5 ## v5.0.5
**!!!!! NOTE !!!!!** **!!!!! NOTE !!!!!**
This is a stability release only and I'm happy to announce that, with this release, the 5.x versions are now considered stable. Analytics indicate that over 90% of ring-mqtt users are already runnning a 5.x release and, overall, there are very few reported issues. Still, it is highly recommened to **take a backup** prior to upgrading so that you can revert if things do not go to plan. This is a stability release only and I'm happy to announce that, with this release, the 5.x versions are now considered stable. Analytics indicate that over 90% of ring-mqtt users are already runnning a 5.x release and, overall, there are very few reported issues. Still, it is highly recommened to **take a backup** prior to upgrading so that you can revert if things do not go to plan.

View File

@@ -1,9 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
const fs = require('fs') import fs from 'fs'
const writeFileAtomic = require('write-file-atomic') import { dirname } from 'path'
const { createHash, randomBytes } = require('crypto') import { fileURLToPath } from 'url'
const { RingRestClient } = require('./node_modules/ring-client-api/lib/api/rest-client') import { readFile } from 'fs/promises'
const { requestInput } = require('./node_modules/ring-client-api/lib/api/util') import writeFileAtomic from 'write-file-atomic'
import { createHash, randomBytes } from 'crypto'
import { RingRestClient } from 'ring-client-api/rest-client'
import { requestInput } from './node_modules/ring-client-api/lib/util.js'
async function getRefreshToken() { async function getRefreshToken() {
let generatedToken let generatedToken
@@ -37,16 +40,16 @@ const main = async() => {
// If running in Docker set state file path as appropriate // If running in Docker set state file path as appropriate
const stateFile = (fs.existsSync('/etc/cont-init.d/ring-mqtt.sh')) const stateFile = (fs.existsSync('/etc/cont-init.d/ring-mqtt.sh'))
? '/data/ring-state.json' ? '/data/ring-state.json'
: require('path').dirname(require.main.filename)+'/ring-state.json' : dirname(fileURLToPath(new URL(import.meta.url)))+'/ring-state.json'
const configFile = (fs.existsSync('/etc/cont-init.d/ring-mqtt.sh')) const configFile = (fs.existsSync('/etc/cont-init.d/ring-mqtt.sh'))
? '/data/config.json' ? '/data/config.json'
: require('path').dirname(require.main.filename)+'/config.json' : dirname(fileURLToPath(new URL(import.meta.url)))+'/config.json'
if (fs.existsSync(stateFile)) { if (fs.existsSync(stateFile)) {
console.log('Reading latest data from state file: '+stateFile) console.log('Reading latest data from state file: '+stateFile)
try { try {
stateData = require(stateFile) stateData = JSON.parse(await readFile(stateFile))
} catch(err) { } catch(err) {
console.log(err.message) console.log(err.message)
console.log('Saved state file '+stateFile+' exist but could not be parsed!') console.log('Saved state file '+stateFile+' exist but could not be parsed!')
@@ -75,6 +78,8 @@ const main = async() => {
console.log(err) console.log(err)
} }
if (!fs.existsSync(configFile)) {
try {
const configData = { const configData = {
"mqtt_url": "mqtt://localhost:1883", "mqtt_url": "mqtt://localhost:1883",
"mqtt_options": "", "mqtt_options": "",
@@ -86,13 +91,12 @@ const main = async() => {
"enable_panic": false, "enable_panic": false,
"hass_topic": "homeassistant/status", "hass_topic": "homeassistant/status",
"ring_topic": "ring", "ring_topic": "ring",
"location_ids": [ "location_ids": []
""
]
} }
if (!fs.existsSync(configFile)) { const mqttUrl = await requestInput('MQTT URL (enter to skip and edit config manually): ')
try { configData.mqtt_url = mqttUrl ? mqttUrl : configData.mqtt_url
await writeFileAtomic(configFile, JSON.stringify(configData, null, 4)) await writeFileAtomic(configFile, JSON.stringify(configData, null, 4))
console.log('New config file written to '+configFile) console.log('New config file written to '+configFile)
} catch (err) { } catch (err) {

View File

@@ -45,6 +45,8 @@ else
fi fi
fi fi
export FORCE_COLOR=2
if [ "${BRANCH}" = "latest" ] || [ "${BRANCH}" = "dev" ]; then if [ "${BRANCH}" = "latest" ] || [ "${BRANCH}" = "dev" ]; then
cd "/app/ring-mqtt-${BRANCH}" cd "/app/ring-mqtt-${BRANCH}"
else else

View File

@@ -1,32 +1,40 @@
const debug = require('debug')('ring-mqtt') import chalk from 'chalk'
const colors = require('colors/safe') import fs from 'fs'
const fs = require('fs') import { readFile } from 'fs/promises'
const writeFileAtomic = require('write-file-atomic') import { dirname } from 'path'
import { fileURLToPath } from 'url'
import debugModule from 'debug'
const debug = debugModule('ring-mqtt')
class Config { export default new class Config {
constructor() { constructor() {
this.data = new Object() this.data = new Object()
process.env.RUNMODE = process.env.hasOwnProperty('RUNMODE') ? process.env.RUNMODE : 'standard' process.env.RUNMODE = process.env.hasOwnProperty('RUNMODE') ? process.env.RUNMODE : 'standard'
debug(`Detected runmode: ${process.env.RUNMODE}`) debug(`Detected runmode: ${process.env.RUNMODE}`)
this.init()
}
async init() {
switch (process.env.RUNMODE) { switch (process.env.RUNMODE) {
case 'docker': case 'docker':
this.file = '/data/config.json' this.file = '/data/config.json'
if (fs.existsSync(this.file)) { if (fs.existsSync(this.file)) {
this.loadConfigFile() await this.loadConfigFile()
} else { } else {
// Configure using legacy environment variables debug(chalk.red(`No configuration file found at ${this.file}`))
this.loadConfigEnv() debug(chalk.red('Please map a persistent volume to this location and place a configuration file there.'))
process.exit(1)
} }
break; break;
case 'addon': case 'addon':
this.file = '/data/options.json' this.file = '/data/options.json'
this.loadConfigFile() await this.loadConfigFile()
this.doMqttDiscovery() this.doMqttDiscovery()
break; break;
default: default:
const configPath = require('path').dirname(require.main.filename)+'/' const configPath = dirname(fileURLToPath(new URL('.', import.meta.url)))+'/'
this.file = (process.env.RINGMQTT_CONFIG) ? configPath+process.env.RINGMQTT_CONFIG : configPath+'config.json' this.file = (process.env.RINGMQTT_CONFIG) ? configPath+process.env.RINGMQTT_CONFIG : configPath+'config.json'
this.loadConfigFile() await this.loadConfigFile()
} }
// If there's still no configured settings, force some defaults. // If there's still no configured settings, force some defaults.
@@ -37,42 +45,23 @@ class Config {
this.data.enable_panic = this.data.hasOwnProperty('enable_panic') ? this.data.enable_panic : false this.data.enable_panic = this.data.hasOwnProperty('enable_panic') ? this.data.enable_panic : false
this.data.disarm_code = this.data.hasOwnProperty('disarm_code') ? this.data.disarm_code : '' this.data.disarm_code = this.data.hasOwnProperty('disarm_code') ? this.data.disarm_code : ''
// If there's a legacy configuration, migrate to MQTT url based configuration
if (!this.data.mqtt_url) {
this.migrateToMqttUrl()
this.updateConfigFile()
}
const mqttURL = new URL(this.data.mqtt_url) const mqttURL = new URL(this.data.mqtt_url)
debug(`MQTT URL: ${mqttURL.protocol}//${mqttURL.username ? mqttURL.username+':********@' : ''}${mqttURL.hostname}:${mqttURL.port}`) debug(`MQTT URL: ${mqttURL.protocol}//${mqttURL.username ? mqttURL.username+':********@' : ''}${mqttURL.hostname}:${mqttURL.port}`)
} }
// Create CONFIG object from file or envrionment variables // Create CONFIG object from file or envrionment variables
loadConfigFile() { async loadConfigFile() {
debug('Configuration file: '+this.file) debug('Configuration file: '+this.file)
try { try {
this.data = require(this.file) this.data = JSON.parse(await readFile(this.file))
} catch (err) { } catch (err) {
debug(err.message) debug(err.message)
debug(colors.red('Configuration file could not be read, check that it exist and is valid.')) debug(chalk.red('Configuration file could not be read, check that it exist and is valid.'))
process.exit(1) process.exit(1)
} }
} }
doMqttDiscovery() { doMqttDiscovery() {
// Only required for legacy configuration when used with BRANCH feature
// Can be removed on release of 5.x
if (!this.data.mqtt_url) {
this.data.mqtt_user = this.data.mqtt_user === '<auto_detect>' ? 'auto_username' : this.data.mqtt_user
this.data.mqtt_password = this.data.mqtt_password === '<auto_detect>' ? 'auto_password' : this.data.mqtt_password
this.data.mqtt_host = this.data.mqtt_host === '<auto_detect>' ? 'auto_hostname' : this.data.mqtt_host
this.data.mqtt_port = this.data.mqtt_port === '<auto_detect>' ? '1883' : this.data.mqtt_port
this.data.mqtt_url = `${this.data.mqtt_port === '8883' ? 'mqtts' : 'mqtt'}://${this.data.mqtt_user}:${this.data.mqtt_password}@${this.data.mqtt_host}:${this.data.mqtt_port}`
delete this.data.mqtt_user
delete this.data.mqtt_password
delete this.data.mqtt_host
delete this.data.mqtt_port
}
try { try {
// Parse the MQTT URL and resolve any auto configuration // Parse the MQTT URL and resolve any auto configuration
const mqttURL = new URL(this.data.mqtt_url) const mqttURL = new URL(this.data.mqtt_url)
@@ -130,74 +119,8 @@ class Config {
this.data.mqtt_url = mqttURL.href this.data.mqtt_url = mqttURL.href
} catch (err) { } catch (err) {
debug(err.message) debug(err.message)
debug(colors.red('MQTT URL could not be parsed, please verify that it is in a valid format.')) debug(chalk.red('MQTT URL could not be parsed, please verify that it is in a valid format.'))
process.exit(1) process.exit(1)
} }
} }
async loadConfigEnv() {
debug(colors.yellow('No config file found, attempting to use environment variables for configuration'))
debug(colors.yellow('******************************** IMPORTANT NOTE ********************************'))
debug(colors.yellow('The use of environment variables for configuration is deprecated, and will be'))
debug(colors.yellow('removed in a future release. Current settings will automatically be migrated to'))
debug(colors.yellow(`${this.file} and this configuration will be used during future initializations.`))
debug(colors.yellow('Please make sure you have configured a persistent volume mapping as this is now'))
debug(colors.yellow('a requirement for all Docker based installations.'))
debug(colors.yellow('********************************************************************************'))
this.data = {
"livestream_user": process.env.LIVESTREAMUSER ? process.env.LIVESTREAMUSER : '',
"livestream_pass": process.env.LIVESTREAMPASSWORD ? process.env.LIVESTREAMPASSWORD : '',
"disarm_code": process.env.DISARMCODE ? process.env.DISARMCODE : '',
"enable_cameras": process.env.ENABLECAMERAS === 'true' ? true : false,
"enable_modes": process.env.ENABLEMODES === 'true' ? true : false,
"enable_panic": process.env.ENABLEPANIC === 'true' ? true : false,
"hass_topic": process.env.MQTTHASSTOPIC ? process.env.MQTTHASSTOPIC : 'homeassistant/status',
"ring_topic": process.env.MQTTRINGTOPIC ? process.env.MQTTRINGTOPIC : 'ring',
"location_ids": process.env.RINGLOCATIONIDS ? process.env.RINGLOCATIONIDS.split(',') : [""],
"host": process.env.MQTTHOST ? process.env.MQTTHOST : 'localhost',
"port": process.env.MQTTPORT ? process.env.MQTTPORT : '1883',
"mqtt_user": process.env.MQTTUSER,
"mqtt_pass": process.env.MQTTPASSWORD
} }
}
async updateConfigFile() {
// Delete any legacy configuration options
delete this.data.beam_duration
delete this.data.snapshot_mode
try {
await writeFileAtomic(this.file, JSON.stringify(this.data, null, 4))
debug('Successfully saved updated config file: '+this.file)
} catch (err) {
debug('Failed to save updated config file: '+this.file)
debug(err.message)
}
}
migrateToMqttUrl() {
debug ('Migrating legacy MQTT config options to mqtt_url...')
const mqttURL = new URL('mqtt://localhost')
mqttURL.protocol = this.data.port == 8883 ? 'mqtts:' : 'mqtt:'
mqttURL.hostname = this.data.host
mqttURL.port = this.data.port
mqttURL.username = this.data.mqtt_user ? this.data.mqtt_user : ''
mqttURL.password = this.data.mqtt_pass ? this.data.mqtt_pass : ''
// Add new MQTT configuration options
this.data = {
mqtt_url: mqttURL.href,
mqtt_options: '',
...this.data
}
// Remove legacy MQTT configuration options
delete this.data.host
delete this.data.port
delete this.data.mqtt_user
delete this.data.mqtt_pass
}
}
module.exports = new Config()

View File

@@ -1,9 +1,10 @@
const debug = require('debug')('ring-mqtt') import chalk from 'chalk'
const colors = require('colors/safe') import utils from './utils.js'
const utils = require('./utils') import ring from './ring.js'
const ring = require('./ring') import debugModule from 'debug'
const debug = debugModule('ring-mqtt')
class ExitHandler { export default new class ExitHandler {
constructor() { constructor() {
this.init() this.init()
} }
@@ -14,8 +15,8 @@ class ExitHandler {
process.on('SIGINT', this.processExit.bind(null, 0)) process.on('SIGINT', this.processExit.bind(null, 0))
process.on('SIGTERM', this.processExit.bind(null, 0)) process.on('SIGTERM', this.processExit.bind(null, 0))
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
debug(colors.red('ERROR - Uncaught Exception')) debug(chalk.red('ERROR - Uncaught Exception'))
console.log(colors.red(err)) debug(chalk.red(err))
this.processExit(2) this.processExit(2)
}) })
process.on('unhandledRejection', (err) => { process.on('unhandledRejection', (err) => {
@@ -24,11 +25,11 @@ class ExitHandler {
case /token is not valid/.test(err.message): case /token is not valid/.test(err.message):
case /https:\/\/github.com\/dgreif\/ring\/wiki\/Refresh-Tokens/.test(err.message): case /https:\/\/github.com\/dgreif\/ring\/wiki\/Refresh-Tokens/.test(err.message):
case /error: access_denied/.test(err.message): case /error: access_denied/.test(err.message):
debug(colors.yellow(err.message)) debug(chalk.yellow(err.message))
break; break;
default: default:
debug(colors.yellow('WARNING - Unhandled Promise Rejection')) debug(chalk.yellow('WARNING - Unhandled Promise Rejection'))
console.log(colors.yellow(err)) debug(chalk.yellow(err))
break; break;
} }
}) })
@@ -38,7 +39,7 @@ class ExitHandler {
async processExit(exitCode) { async processExit(exitCode) {
await utils.sleep(1) await utils.sleep(1)
debug('The ring-mqtt process is shutting down...') debug('The ring-mqtt process is shutting down...')
await ring.rssShutdown() await ring.go2rtcShutdown()
if (ring.devices.length > 0) { if (ring.devices.length > 0) {
debug('Setting all devices offline...') debug('Setting all devices offline...')
await utils.sleep(1) await utils.sleep(1)
@@ -54,5 +55,3 @@ class ExitHandler {
process.exit() process.exit()
} }
} }
module.exports = new ExitHandler()

108
lib/go2rtc.js Normal file
View File

@@ -0,0 +1,108 @@
import chalk from 'chalk'
import utils from './utils.js'
import { spawn } from 'child_process'
import readline from 'readline'
import yaml from 'js-yaml'
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import writeFileAtomic from 'write-file-atomic'
import debugModule from 'debug'
const debug = debugModule('ring-rtsp')
export default new class Go2RTC {
constructor() {
this.started = false
this.go2rtcProcess = false
}
async init(cameras) {
this.started = true
debug(chalk.green('-'.repeat(90)))
debug('Creating go2rtc configuration and starting go2rtc process...')
const configFile = (process.env.RUNMODE === 'standard')
? dirname(fileURLToPath(new URL('.', import.meta.url)))+'/config/go2rtc.yaml'
: '/data/go2rtc.yaml'
let config = {
log: {
level: "debug"
},
api: {
listen: ""
},
srtp: {
listen: ""
},
rtsp: {
listen: ":8554",
...(utils.config().livestream_user && utils.config().livestream_pass)
? {
username: utils.config().livestream_user,
password: utils.config().livestream_pass
} : {},
default_query: "video=h264&audio=aac&audio=opus"
}
}
if (cameras) {
config.streams = {}
for (const camera of cameras) {
config.streams[`${camera.deviceId}_live`] =
`exec:${dirname(fileURLToPath(new URL('.', import.meta.url)))}/scripts/start-stream.sh ${camera.deviceId} live ${camera.deviceTopic} {output}`
config.streams[`${camera.deviceId}_event`] =
`exec:${dirname(fileURLToPath(new URL('.', import.meta.url)))}/scripts/start-stream.sh ${camera.deviceId} event ${camera.deviceTopic} {output}`
}
try {
await writeFileAtomic(configFile, yaml.dump(config, { lineWidth: -1 }))
debug('Successfully wrote go2rtc configuration file: '+configFile)
} catch (err) {
debug(chalk.red('Failed to write go2rtc configuration file: '+configFile))
debug(err.message)
}
}
this.go2rtcProcess = spawn('go2rtc', ['-config', configFile], {
env: process.env, // set env vars
cwd: '.', // set cwd
stdio: 'pipe' // forward stdio options
})
this.go2rtcProcess.on('spawn', async () => {
debug('The go2rtc process was started successfully')
await utils.sleep(2) // Give the process a second to start the API server
debug(chalk.green('-'.repeat(90)))
})
this.go2rtcProcess.on('close', async () => {
await utils.sleep(1) // Delay to avoid spurious messages if shutting down
if (this.started !== 'shutdown') {
debug('The go2rtc process exited unexpectedly, will restart in 5 seconds...')
this.go2rtcProcess.kill(9) // Sometimes rtsp-simple-server crashes and doesn't exit completely, try to force kill it
await utils.sleep(5)
this.init()
}
})
const stdoutLine = readline.createInterface({ input: this.go2rtcProcess.stdout })
stdoutLine.on('line', (line) => {
// Replace time in go2rtc log messages with tag
debug(line.replace(/^.*\d{2}:\d{2}:\d{2}\.\d{3}([^\s]+) /, chalk.green('[go2rtc] ')))
})
const stderrLine = readline.createInterface({ input: this.go2rtcProcess.stderr })
stderrLine.on('line', (line) => {
// Replace time in go2rtc log messages with tag
debug(line.replace(/^.*\d{2}:\d{2}:\d{2}\.\d{3}([^\s]+) /, '[go2rtc] '))
})
}
shutdown() {
this.started = 'shutdown'
if (this.go2rtcProcess) {
this.go2rtcProcess.kill()
this.go2rtcProcess = false
}
return
}
}

View File

@@ -1,76 +0,0 @@
const { parentPort } = require('worker_threads')
const { WebrtcConnection } = require('ring-client-api/lib/api/streaming/webrtc-connection')
const { RingEdgeConnection } = require('ring-client-api/lib/api/streaming/ring-edge-connection')
const { StreamingSession } = require('ring-client-api/lib/api/streaming/streaming-session')
const debug = require('debug')('ring-mqtt')
const colors = require('colors/safe')
let activeCalls = []
function removeActiveCall(deviceId, deviceName) {
const callIndex = activeCalls.findIndex(call => call.deviceId === deviceId )
if (callIndex > -1) {
debug(colors.green(`[${deviceName}] `)+'Removing active live stream handler')
activeCalls.splice(callIndex, 1)
}
}
parentPort.on("message", async(data) => {
const streamData = data.streamData
const activeCall = activeCalls.find(call => call.deviceId === streamData.deviceId)
if (data.command === 'start' && !activeCall) {
try {
const cameraData = {
name: streamData.deviceName,
id: streamData.doorbotId
}
const streamConnection = (streamData.sessionId)
? new WebrtcConnection(streamData.sessionId, cameraData)
: new RingEdgeConnection(streamData.authToken, cameraData)
const liveCall = new StreamingSession(cameraData, streamConnection)
await liveCall.startTranscoding({
// The native AVC video stream is copied to the RTSP server unmodified while the audio
// stream is converted into two output streams using both AAC and Opus codecs. This
// provides a stream with wide compatibility across various media player technologies.
audio: [
'-map', '0:v',
'-map', '0:a',
'-map', '0:a',
'-c:a:0', 'libfdk_aac',
'-c:a:1', 'copy',
],
video: [
'-c:v', 'copy',
],
output: [
'-flags', '+global_header',
'-f', 'rtsp',
'-rtsp_transport', 'tcp',
streamData.rtspPublishUrl
]
})
const liveCallData = {
deviceId: streamData.deviceId,
sessionId: liveCall.sessionId
}
parentPort.postMessage({ state: 'active', liveCallData })
liveCall.onCallEnded.subscribe(() => {
debug(colors.green(`[${streamData.deviceName}] `)+'Live stream for camera has ended')
parentPort.postMessage({ state: 'inactive', liveCallData })
removeActiveCall(liveCallData.deviceId, streamData.deviceName)
})
liveCall.deviceId = streamData.deviceId
activeCalls.push(liveCall)
} catch(e) {
debug(e)
parentPort.postMessage({ state: 'failed', liveCallData: { deviceId: streamData.deviceId }})
return false
}
} else if (data.command === 'stop') {
if (activeCall) {
activeCall.stop()
} else {
debug(colors.green(`[${streamData.deviceName}] `)+'Received live stream stop command but no active live call found')
parentPort.postMessage({ state: 'inactive', liveCallData: { deviceId: streamData.deviceId }})
}
}
})

View File

@@ -1,15 +1,16 @@
require('./exithandler') import exithandler from './exithandler.js'
require('./mqtt') import mqtt from './mqtt.js'
const config = require('./config') import config from './config.js'
const state = require('./state') import state from './state.js'
const ring = require('./ring') import ring from './ring.js'
const debug = require('debug')('ring-mqtt') import utils from './utils.js'
const colors = require('colors/safe') import tokenApp from './tokenapp.js'
const utils = require('./utils') import chalk from 'chalk'
const tokenApp = require('./tokenapp') import isOnline from 'is-online'
const isOnline = require('is-online') import debugModule from 'debug'
const debug = debugModule('ring-mqtt')
class Main { export default new class Main {
constructor() { constructor() {
// Hack to suppress spurious message from push-receiver during startup // Hack to suppress spurious message from push-receiver during startup
console.warn = (data) => { console.warn = (data) => {
@@ -36,29 +37,27 @@ class Main {
if (state.data.ring_token || generatedToken) { if (state.data.ring_token || generatedToken) {
// Wait for the network to be online and then attempt to connect to the Ring API using the token // Wait for the network to be online and then attempt to connect to the Ring API using the token
while (!(await isOnline())) { while (!(await isOnline())) {
debug(colors.yellow('Network is offline, waiting 10 seconds to check again...')) debug(chalk.yellow('Network is offline, waiting 10 seconds to check again...'))
await utils.sleep(10) await utils.sleep(10)
} }
if (!await ring.init(state, generatedToken)) { if (!await ring.init(state, generatedToken)) {
debug(colors.red('Failed to connect to Ring API using saved token, generate a new token using the Web UI')) debug(chalk.red('Failed to connect to Ring API using saved token, generate a new token using the Web UI'))
debug(colors.red('or wait 60 seconds to automatically retry authentication using the existing token')) debug(chalk.red('or wait 60 seconds to automatically retry authentication using the existing token'))
tokenApp.start() tokenApp.start()
await utils.sleep(60) await utils.sleep(60)
if (!ring.client) { if (!ring.client) {
debug(colors.yellow('Retrying authentication with existing saved token...')) debug(chalk.yellow('Retrying authentication with existing saved token...'))
this.init() this.init()
} }
} }
} else { } else {
if (process.env.RUNMODE === 'addon') { if (process.env.RUNMODE === 'addon') {
debug(colors.red('No refresh token was found in state file, generate a token using the addon Web UI')) debug(chalk.red('No refresh token was found in state file, generate a token using the addon Web UI'))
} else { } else {
tokenApp.start() tokenApp.start()
debug(colors.red('No refresh token was found in the state file, use the Web UI at http://<host_ip_address>:55123/ to generate a token.')) debug(chalk.red('No refresh token was found in the state file, use the Web UI at http://<host_ip_address>:55123/ to generate a token.'))
} }
} }
} }
} }
module.exports = new Main()

View File

@@ -1,21 +1,23 @@
const mqttApi = require('mqtt') import mqttApi from 'mqtt'
const debug = require('debug')('ring-mqtt') import chalk from 'chalk'
const colors = require('colors/safe') import utils from './utils.js'
const utils = require('./utils') import fs from 'fs'
const fs = require('fs') import parseArgs from 'minimist'
const parseArgs = require('minimist') import Aedes from 'aedes'
const aedes = require('aedes')() import net from 'net'
const net = require('net') import debugModule from 'debug'
const debug = debugModule('ring-mqtt')
class Mqtt { export default new class Mqtt {
constructor() { constructor() {
this.client = false this.client = false
this.ipcClient = false this.ipcClient = false
this.connected = false this.connected = false
// Start internal broker, used only for inter-process communication (IPC) // Start internal broker, used only for inter-process communication (IPC)
const mqttServer = net.createServer(aedes.handle) const mqttServer = new Aedes()
mqttServer.listen(51883, '127.0.0.1') const netServer = net.createServer(mqttServer.handle)
netServer.listen(51883, '127.0.0.1')
// Configure event listeners // Configure event listeners
utils.event.on('ring_api_state', async (state) => { utils.event.on('ring_api_state', async (state) => {
@@ -49,10 +51,10 @@ class Mqtt {
async init() { async init() {
try { try {
let mqttOptions = {} let mqttOptions = {}
if (utils.config.mqtt_options) { if (utils.config().mqtt_options) {
// If any of the cerficiate keys are in mqtt_options, read the data from the file // If any of the cerficiate keys are in mqtt_options, read the data from the file
try { try {
const mqttConfigOptions = parseArgs(utils.config.mqtt_options.split(',')) const mqttConfigOptions = parseArgs(utils.config().mqtt_options.split(','))
Object.keys(mqttConfigOptions).forEach(key => { Object.keys(mqttConfigOptions).forEach(key => {
switch (key) { switch (key) {
// For any of the file based options read the file into the option property // For any of the file based options read the file into the option property
@@ -74,13 +76,13 @@ class Mqtt {
mqttOptions = mqttConfigOptions mqttOptions = mqttConfigOptions
} catch(err) { } catch(err) {
debug(err) debug(err)
debug(colors.yellow('Could not parse MQTT advanced options, continuing with default settings')) debug(chalk.yellow('Could not parse MQTT advanced options, continuing with default settings'))
} }
} }
debug('Attempting connection to MQTT broker...') debug('Attempting connection to MQTT broker...')
// Connect to client facing MQTT broker // Connect to client facing MQTT broker
this.client = await mqttApi.connect(utils.config.mqtt_url, mqttOptions); this.client = await mqttApi.connect(utils.config().mqtt_url, mqttOptions);
// Connect to internal IPC broker // Connect to internal IPC broker
this.ipcClient = await mqttApi.connect('mqtt://127.0.0.1:51883', {}) this.ipcClient = await mqttApi.connect('mqtt://127.0.0.1:51883', {})
@@ -88,12 +90,12 @@ class Mqtt {
this.start() this.start()
// Subscribe to configured/default/legacay Home Assistant status topics // Subscribe to configured/default/legacay Home Assistant status topics
this.client.subscribe(utils.config.hass_topic) this.client.subscribe(utils.config().hass_topic)
this.client.subscribe('hass/status') this.client.subscribe('hass/status')
this.client.subscribe('hassio/status') this.client.subscribe('hassio/status')
} catch (error) { } catch (error) {
debug(error) debug(error)
debug(colors.red(`Could not authenticate to MQTT broker. Please check the broker and configuration settings.`)) debug(chalk.red(`Could not authenticate to MQTT broker. Please check the broker and configuration settings.`))
process.exit(1) process.exit(1)
} }
} }
@@ -126,7 +128,7 @@ class Mqtt {
// Process subscribed MQTT messages from subscribed command topics // Process subscribed MQTT messages from subscribed command topics
this.client.on('message', (topic, message) => { this.client.on('message', (topic, message) => {
message = message.toString() message = message.toString()
if (topic === utils.config.hass_topic || topic === 'hass/status' || topic === 'hassio/status') { if (topic === utils.config().hass_topic || topic === 'hass/status' || topic === 'hassio/status') {
utils.event.emit('ha_status', topic, message) utils.event.emit('ha_status', topic, message)
} else { } else {
utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message) utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message)
@@ -135,10 +137,7 @@ class Mqtt {
// Process MQTT messages from the IPC broker // Process MQTT messages from the IPC broker
this.ipcClient.on('message', (topic, message) => { this.ipcClient.on('message', (topic, message) => {
message = message.toString() utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message.toString())
utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message)
}) })
} }
} }
module.exports = new Mqtt()

View File

@@ -1,33 +1,33 @@
const { RingApi, RingDeviceType, RingCamera, RingChime } = require('ring-client-api') import { RingApi, RingDeviceType, RingCamera, RingChime } from 'ring-client-api'
const debug = require('debug')('ring-mqtt') import chalk from 'chalk'
const colors = require('colors/safe') import utils from './utils.js'
const utils = require('./utils') import go2rtc from './go2rtc.js'
const rss = require('./rtsp-simple-server') import BaseStation from '../devices/base-station.js'
const streamWorkers = require('./stream-workers') import Beam from '../devices/beam.js'
const BaseStation = require('../devices/base-station') import BeamOutdoorPlug from '../devices/beam-outdoor-plug.js'
const Beam = require('../devices/beam') import BinarySensor from '../devices/binary-sensor.js'
const BeamOutdoorPlug = require('../devices/beam-outdoor-plug') import Bridge from '../devices/bridge.js'
const BinarySensor = require('../devices/binary-sensor') import Camera from '../devices/camera.js'
const Bridge = require('../devices/bridge') import CoAlarm from '../devices/co-alarm.js'
const Camera = require('../devices/camera') import Chime from '../devices/chime.js'
const CoAlarm = require('../devices/co-alarm') import Fan from '../devices/fan.js'
const Chime = require('../devices/chime') import FloodFreezeSensor from '../devices/flood-freeze-sensor.js'
const Fan = require('../devices/fan') import Keypad from '../devices/keypad.js'
const FloodFreezeSensor = require('../devices/flood-freeze-sensor') import Lock from '../devices/lock.js'
const Keypad = require('../devices/keypad') import ModesPanel from '../devices/modes-panel.js'
const Lock = require('../devices/lock') import MultiLevelSwitch from '../devices/multi-level-switch.js'
const ModesPanel = require('../devices/modes-panel') import RangeExtender from '../devices/range-extender.js'
const MultiLevelSwitch = require('../devices/multi-level-switch') import SecurityPanel from '../devices/security-panel.js'
const RangeExtender = require('../devices/range-extender') import Siren from '../devices/siren.js'
const SecurityPanel = require('../devices/security-panel') import SmokeAlarm from '../devices/smoke-alarm.js'
const Siren = require('../devices/siren') import SmokeCoListener from '../devices/smoke-co-listener.js'
const SmokeAlarm = require('../devices/smoke-alarm') import Switch from '../devices/switch.js'
const SmokeCoListener = require('../devices/smoke-co-listener') import TemperatureSensor from '../devices/temperature-sensor.js'
const Switch = require('../devices/switch') import Thermostat from '../devices/thermostat.js'
const TemperatureSensor = require('../devices/temperature-sensor') import debugModule from 'debug'
const Thermostat = require('../devices/thermostat') const debug = debugModule('ring-mqtt')
class RingMqtt { export default new class RingMqtt {
constructor() { constructor() {
this.locations = new Array() this.locations = new Array()
this.devices = new Array() this.devices = new Array()
@@ -74,7 +74,7 @@ class RingMqtt {
// This usually indicates a Ring service outage impacting authentication // This usually indicates a Ring service outage impacting authentication
setInterval(() => { setInterval(() => {
if (this.client && !this.client.restClient.refreshToken) { if (this.client && !this.client.restClient.refreshToken) {
debug(colors.yellow('Possible Ring service outage detected, forcing use of refresh token from latest state')) debug(chalk.yellow('Possible Ring service outage detected, forcing use of refresh token from latest state'))
this.client.restClient.refreshToken = this.refreshToken this.client.restClient.refreshToken = this.refreshToken
this.client.restClient._authPromise = undefined this.client.restClient._authPromise = undefined
} }
@@ -92,8 +92,8 @@ class RingMqtt {
await this.client.getProfile() await this.client.getProfile()
debug(`Successfully re-established connection to Ring API using generated refresh token`) debug(`Successfully re-established connection to Ring API using generated refresh token`)
} catch (error) { } catch (error) {
debug(colors.brightYellow(error.message)) debug(chalk.yellowBright(error.message))
debug(colors.brightYellow(`Failed to re-establish connection to Ring API using generated refresh token`)) debug(chalk.yellowBright(`Failed to re-establish connection to Ring API using generated refresh token`))
} }
} else { } else {
@@ -101,9 +101,9 @@ class RingMqtt {
refreshToken: this.refreshToken, refreshToken: this.refreshToken,
systemId: state.data.systemId, systemId: state.data.systemId,
controlCenterDisplayName: (process.env.RUNMODE === 'addon') ? 'ring-mqtt-addon' : 'ring-mqtt', controlCenterDisplayName: (process.env.RUNMODE === 'addon') ? 'ring-mqtt-addon' : 'ring-mqtt',
...utils.config.enable_cameras ? { cameraStatusPollingSeconds: 20 } : {}, ...utils.config().enable_cameras ? { cameraStatusPollingSeconds: 20 } : {},
...utils.config.enable_modes ? { locationModePollingSeconds: 20 } : {}, ...utils.config().enable_modes ? { locationModePollingSeconds: 20 } : {},
...!(utils.config.location_ids === undefined || utils.config.location_ids == 0) ? { locationIds: utils.config.location_ids } : {} ...!(utils.config().location_ids === undefined || utils.config().location_ids == 0) ? { locationIds: utils.config().location_ids } : {}
} }
try { try {
@@ -124,8 +124,8 @@ class RingMqtt {
}) })
} catch(error) { } catch(error) {
this.client = false this.client = false
debug(colors.brightYellow(error.message)) debug(chalk.yellowBright(error.message))
debug(colors.brightYellow(`Failed to establish connection to Ring API using ${generatedToken ? 'generated' : 'saved'} refresh token`)) debug(chalk.yellowBright(`Failed to establish connection to Ring API using ${generatedToken ? 'generated' : 'saved'} refresh token`))
} }
} }
@@ -182,24 +182,23 @@ class RingMqtt {
// Get all Ring locations // Get all Ring locations
const locations = await this.client.getLocations() const locations = await this.client.getLocations()
debug(colors.green('-'.repeat(90))) debug(chalk.green('-'.repeat(90)))
debug(colors.white('This account has access to the following locations:')) debug(chalk.white('This account has access to the following locations:'))
locations.map(function(location) { locations.map(function(location) {
debug(' '+colors.green(location.name)+colors.cyan(` (${location.id})`)) debug(' '+chalk.green(location.name)+chalk.cyan(` (${location.id})`))
}) })
debug(' '.repeat(90)) debug(' '.repeat(90))
debug(colors.brightYellow('IMPORTANT: ')+colors.white('If *ANY* alarm or smart lighting hubs at these locations are *OFFLINE* ')) debug(chalk.yellowBright('IMPORTANT: ')+chalk.white('If *ANY* alarm or smart lighting hubs at these locations are *OFFLINE* '))
debug(colors.white(' the device discovery process below will hang and no devices will be ')) debug(chalk.white(' the device discovery process below will hang and no devices will be '))
debug(colors.white(' published! ')) debug(chalk.white(' published! '))
debug(' '.repeat(90)) debug(' '.repeat(90))
debug(colors.white(' If the message "Device Discovery Complete!" is not logged below, please')) debug(chalk.white(' If the message "Device Discovery Complete!" is not logged below, please'))
debug(colors.white(' carefully check the Ring app for any hubs or smart lighting devices ')) debug(chalk.white(' carefully check the Ring app for any hubs or smart lighting devices '))
debug(colors.white(' that are in offline state and either remove them from the location or ')) debug(chalk.white(' that are in offline state and either remove them from the location or '))
debug(colors.white(' bring them back online prior to restarting ring-mqtt. ')) debug(chalk.white(' bring them back online prior to restarting ring-mqtt. '))
debug(' '.repeat(90))
debug(colors.white(' If desired, the "location_ids" config option can be used to restrict '))
debug(colors.white(' discovery to specific locations. See the documentation for details. '))
debug(' '.repeat(90)) debug(' '.repeat(90))
debug(chalk.white(' If desired, the "location_ids" config option can be used to restrict '))
debug(chalk.white(' discovery to specific locations. See the documentation for details. '))
// Loop through each location and update stored locations/devices // Loop through each location and update stored locations/devices
for (const location of locations) { for (const location of locations) {
@@ -207,21 +206,15 @@ class RingMqtt {
let chimes = new Array() let chimes = new Array()
const unsupportedDevices = new Array() const unsupportedDevices = new Array()
/* debug(chalk.green('-'.repeat(90)))
location.onDeviceDataUpdate.subscribe((data) => { debug(chalk.white('Starting Device Discovery...'))
console.log(data)
})
*/
debug(colors.green('-'.repeat(90)))
debug(colors.white('Starting Device Discovery...'))
debug(' '.repeat(90)) debug(' '.repeat(90))
// If new location, set custom properties and add to location list // If new location, set custom properties and add to location list
if (this.locations.find(l => l.locationId == location.locationId)) { if (this.locations.find(l => l.locationId == location.locationId)) {
debug(colors.white('Existing location: ')+colors.green(location.name)+colors.cyan(` (${location.id})`)) debug(chalk.white('Existing location: ')+chalk.green(location.name)+chalk.cyan(` (${location.id})`))
} else { } else {
debug(colors.white('New location: ')+colors.green(location.name)+colors.cyan(` (${location.id})`)) debug(chalk.white('New location: ')+chalk.green(location.name)+chalk.cyan(` (${location.id})`))
location.isSubscribed = false location.isSubscribed = false
location.isConnected = false location.isConnected = false
this.locations.push(location) this.locations.push(location)
@@ -229,14 +222,14 @@ class RingMqtt {
// Get all location devices and, if camera support is enabled, cameras and chimes // Get all location devices and, if camera support is enabled, cameras and chimes
const devices = await location.getDevices() const devices = await location.getDevices()
if (utils.config.enable_cameras) { if (utils.config().enable_cameras) {
cameras = await location.cameras cameras = await location.cameras
chimes = await location.chimes chimes = await location.chimes
} }
const allDevices = [...devices, ...cameras, ...chimes] const allDevices = [...devices, ...cameras, ...chimes]
// Add modes panel, if configured and the location supports it // Add modes panel, if configured and the location supports it
if (utils.config.enable_modes && (await location.supportsLocationModeSwitching())) { if (utils.config().enable_modes && (await location.supportsLocationModeSwitching())) {
allDevices.push({ allDevices.push({
deviceType: 'location.mode', deviceType: 'location.mode',
location: location, location: location,
@@ -271,36 +264,34 @@ class RingMqtt {
} }
if (ringDevice && !ringDevice.hasOwnProperty('parentDevice')) { if (ringDevice && !ringDevice.hasOwnProperty('parentDevice')) {
debug(colors.white(foundMessage)+colors.green(`${ringDevice.deviceData.name}`)+colors.cyan(' ('+ringDevice.deviceId+')')) debug(chalk.white(foundMessage)+chalk.green(`${ringDevice.deviceData.name}`)+chalk.cyan(' ('+ringDevice.deviceId+')'))
if (ringDevice?.childDevices) { if (ringDevice?.childDevices) {
const indent = ' '.repeat(foundMessage.length-4) const indent = ' '.repeat(foundMessage.length-4)
debug(colors.white(`${indent}`)+colors.gray(ringDevice.device.deviceType)) debug(chalk.white(`${indent}`)+chalk.gray(ringDevice.device.deviceType))
let keys = Object.keys(ringDevice.childDevices).length let keys = Object.keys(ringDevice.childDevices).length
Object.keys(ringDevice.childDevices).forEach(key => { Object.keys(ringDevice.childDevices).forEach(key => {
debug(colors.white(`${indent}${(keys > 1) ? '├─: ' : '└─: '}`)+colors.green(`${ringDevice.childDevices[key].name}`)+colors.cyan(` (${ringDevice.childDevices[key].id})`)) debug(chalk.white(`${indent}${(keys > 1) ? '├─: ' : '└─: '}`)+chalk.green(`${ringDevice.childDevices[key].name}`)+chalk.cyan(` (${ringDevice.childDevices[key].id})`))
debug(colors.white(`${indent}${(keys > 1) ? '│ ' : ' '}`)+colors.gray(ringDevice.childDevices[key].deviceType)) debug(chalk.white(`${indent}${(keys > 1) ? '│ ' : ' '}`)+chalk.gray(ringDevice.childDevices[key].deviceType))
keys-- keys--
}) })
} else { } else {
const indent = ' '.repeat(foundMessage.length) const indent = ' '.repeat(foundMessage.length)
debug(colors.gray(`${indent}${ringDevice.device.deviceType}`)) debug(chalk.gray(`${indent}${ringDevice.device.deviceType}`))
} }
} }
} }
// Output any unsupported devices to debug with warning // Output any unsupported devices to debug with warning
unsupportedDevices.forEach(deviceType => { unsupportedDevices.forEach(deviceType => {
debug(colors.yellow(` Unsupported device: ${deviceType}`)) debug(chalk.yellow(` Unsupported device: ${deviceType}`))
}) })
} }
debug(' '.repeat(90)) debug(' '.repeat(90))
debug(colors.white('Device Discovery Complete!')) debug(chalk.white('Device Discovery Complete!'))
debug(colors.green('-'.repeat(90))) debug(chalk.green('-'.repeat(90)))
await utils.sleep(2) await utils.sleep(2)
const cameras = await this.devices.filter(d => d.device instanceof RingCamera) const cameras = await this.devices.filter(d => d.device instanceof RingCamera)
if (cameras.length > 0 && !rss.started) { if (cameras.length > 0 && !go2rtc.started) {
await streamWorkers.init() await go2rtc.init(cameras)
await utils.sleep(1)
await rss.init(cameras)
} }
await utils.sleep(3) await utils.sleep(3)
} }
@@ -404,9 +395,7 @@ class RingMqtt {
} }
} }
async rssShutdown() { async go2rtcShutdown() {
await rss.shutdown() await go2rtc.shutdown()
} }
} }
module.exports = new RingMqtt()

View File

@@ -1,132 +0,0 @@
const debug = require('debug')('ring-rtsp')
const colors = require('colors/safe')
const utils = require('./utils')
const { spawn } = require('child_process')
const readline = require('readline')
const got = require('got')
class RtspSimpleServer {
constructor() {
this.started = false
this.rssProcess = false
}
async init(cameras) {
if (cameras) {
this.cameras = cameras
}
this.started = true
debug(colors.green('-'.repeat(90)))
debug('Starting rtsp-simple-server process...')
this.rssProcess = spawn('rtsp-simple-server', [`${__dirname}/../config/rtsp-simple-server.yml`], {
env: process.env, // set env vars
cwd: '.', // set cwd
stdio: 'pipe' // forward stdio options
})
this.rssProcess.on('spawn', async () => {
await utils.sleep(1) // Give the process a second to start the API server
this.createAllRtspPaths()
})
this.rssProcess.on('close', async () => {
await utils.sleep(1) // Delay to avoid spurious messages if shutting down
if (this.started !== 'shutdown') {
debug('The rtsp-simple-server process exited unexpectedly, will restart in 5 seconds...')
this.rssProcess.kill(9) // Sometimes rtsp-simple-server crashes and doesn't exit completely, try to force kill it
await utils.sleep(5)
this.init()
}
})
const stdoutLine = readline.createInterface({ input: this.rssProcess.stdout })
stdoutLine.on('line', (line) => {
// Strip date from rtps-simple-server log messages since debug adds it's own
debug(line.replace(/\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} /g, ''))
})
const stderrLine = readline.createInterface({ input: this.rssProcess.stderr })
stderrLine.on('line', (line) => {
// Strip date from rtps-simple-server log messages since debug adds it's own
debug(line.replace(/\d{4}\/\d{2}\/\d{2} \d{2}:\d{2}:\d{2} /g, ''))
})
}
shutdown() {
this.started = 'shutdown'
if (this.rssProcess) {
this.rssProcess.kill()
this.rssProcess = false
}
return
}
async createAllRtspPaths() {
debug('Creating RTSP paths for all cameras...')
for (const camera of this.cameras) {
await utils.msleep(10)
let rtspPathConfig = {
source: 'publisher',
runOnDemand: `./scripts/start-stream.sh "${camera.deviceData.name}" "${camera.deviceId}" "live" "${camera.deviceTopic}/"`,
runOnDemandRestart: false,
runOnDemandStartTimeout: '10s',
runOnDemandCloseAfter: '10s',
...(utils.config.livestream_user && utils.config.livestream_pass) ? {
publishUser: utils.config.livestream_user,
publishPass: utils.config.livestream_pass,
readUser: utils.config.livestream_user,
readPass: utils.config.livestream_pass
} : {}
}
try {
await got.post(`http://localhost:8880/v1/config/paths/add/${camera.deviceId}_live`, { json: rtspPathConfig })
} catch(err) {
debug(colors.red(err.message))
}
await utils.msleep(10)
rtspPathConfig = {
source: 'publisher',
runOnDemand: `./scripts/start-stream.sh "${camera.deviceData.name}" "${camera.deviceId}" "event" "${camera.deviceTopic}/event_"`,
runOnDemandRestart: false,
runOnDemandStartTimeout: '10s',
runOnDemandCloseAfter: '5s',
...(utils.config.livestream_user && utils.config.livestream_pass) ? {
publishUser: utils.config.livestream_user,
publishPass: utils.config.livestream_pass,
readUser: utils.config.livestream_user,
readPass: utils.config.livestream_pass
} : {}
}
try {
await got.post(`http://localhost:8880/v1/config/paths/add/${camera.deviceId}_event`, { json: rtspPathConfig })
} catch(err) {
debug(colors.red(err.message))
}
}
await utils.msleep(100)
debug(colors.green('-'.repeat(90)))
}
async getPathDetails(path) {
try {
const pathDetails = await got(`http://localhost:8880/v1/paths/list`).json()
return pathDetails.items[path]
} catch(err) {
debug(colors.red(err.message))
}
}
async getActiveSessions() {
try {
return await got(`http://localhost:8880/v1/rtspsessions/list`).json()
} catch(err) {
debug(colors.red(err.message))
}
}
}
module.exports = new RtspSimpleServer()

View File

@@ -1,11 +1,15 @@
const debug = require('debug')('ring-mqtt') import chalk from 'chalk'
const colors = require('colors/safe') import fs from 'fs'
const fs = require('fs') import { readFile } from 'fs/promises'
const utils = require( '../lib/utils' ) import { dirname } from 'path'
const { createHash, randomBytes } = require('crypto') import { fileURLToPath } from 'url'
const writeFileAtomic = require('write-file-atomic') import utils from './utils.js'
import { createHash, randomBytes } from 'crypto'
import writeFileAtomic from 'write-file-atomic'
import debugModule from 'debug'
const debug = debugModule('ring-mqtt')
class State { export default new class State {
constructor() { constructor() {
this.valid = false this.valid = false
this.writeScheduled = false this.writeScheduled = false
@@ -19,8 +23,8 @@ class State {
async init(config) { async init(config) {
this.config = config this.config = config
this.file = (process.env.RUNMODE === 'standard') this.file = (process.env.RUNMODE === 'standard')
? require('path').dirname(require.main.filename)+'/ring-state.json' ? dirname(fileURLToPath(new URL('.', import.meta.url)))+'/ring-state.json'
: this.file = '/data/ring-state.json' : '/data/ring-state.json'
await this.loadStateData() await this.loadStateData()
} }
@@ -28,7 +32,7 @@ class State {
if (fs.existsSync(this.file)) { if (fs.existsSync(this.file)) {
debug('Reading latest data from state file: '+this.file) debug('Reading latest data from state file: '+this.file)
try { try {
this.data = require(this.file) this.data = JSON.parse(await readFile(this.file))
this.valid = true this.valid = true
if (!this.data.hasOwnProperty('systemId')) { if (!this.data.hasOwnProperty('systemId')) {
this.data.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex')) this.data.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex'))
@@ -39,7 +43,7 @@ class State {
} }
} catch (err) { } catch (err) {
debug(err.message) debug(err.message)
debug(colors.red('Saved state file exist but could not be parsed!')) debug(chalk.red('Saved state file exist but could not be parsed!'))
await this.initStateData() await this.initStateData()
} }
} else { } else {
@@ -50,13 +54,13 @@ class State {
async initStateData() { async initStateData() {
this.data.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex')) this.data.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex'))
if (process.env.RUNMODE === 'standard' && this.config.data?.ring_token) { if (process.env.RUNMODE === 'standard' && this.config.data?.ring_token) {
debug(colors.yellow('State file '+this.file+' not found, creating new state file using existing ring_token from config file.')) debug(chalk.yellow('State file '+this.file+' not found, creating new state file using existing ring_token from config file.'))
this.updateToken(this.config.data.ring_token, true) this.updateToken(this.config.data.ring_token, true)
debug ('Removing legacy ring_token value from config file...') debug ('Removing legacy ring_token value from config file...')
delete this.config.data.ring_token delete this.config.data.ring_token
await this.config.updateConfigFile() await this.config.updateConfigFile()
} else { } else {
debug(colors.yellow('State file '+this.file+' not found. No saved state data available.')) debug(chalk.yellow('State file '+this.file+' not found. No saved state data available.'))
} }
} }
@@ -72,7 +76,7 @@ class State {
await writeFileAtomic(this.file, JSON.stringify(this.data)) await writeFileAtomic(this.file, JSON.stringify(this.data))
debug('Successfully saved updated state file: '+this.file) debug('Successfully saved updated state file: '+this.file)
} catch (err) { } catch (err) {
debug(colors.red('Failed to save updated state file: '+this.file)) debug(chalk.red('Failed to save updated state file: '+this.file))
debug(err.message) debug(err.message)
} }
} }
@@ -97,5 +101,3 @@ class State {
return this.data.devices return this.data.devices
} }
} }
module.exports = new State()

View File

@@ -1,103 +0,0 @@
// Simple worker thread implementation for WebRTC live calls.
//
// Ring now uses WebRTC as the primary streaming protocol for both web browsers
// and mobile apps. As NodeJS does not have a native WebRTC implementation
// ring-client-api leverages the werift package which is an implementation of
// WebRTC completely in Typescript.
//
// The werift code offers excellent WebRTC compatibility but handling the inbound
// RTP traffic, including the decryption of SRTP packets, completely within
// Typescript/Javascript has quite significant CPU overhead. While this is not a
// significant issue on typical Intel processors from the last decade, it is far
// more noticable on low-power CPUs like the ARM processors in common devices
// such as the Raspberry Pi 3/4. Even as few as 2 streams can completely saturate
// a single core and keep the main NodeJS thread busy and struggling to keep up with
// the inbound RTP stream.
//
// This implementation allows live calls to be assigned to a pool of worker threads
// rather than running in the main thread, allowing these calls to take advantage
// of the additional CPU cores. This increases the number of parallel WebRTC streams
// that can be supported on these low powered CPUs. Testing shows that an RPi 4 is
// able to support ~4 streams reliably, in some cases maybe a 5th or even 6th stream,
// while using ~75-80% of the available CPU resources.
//
// Note that to get the best reliability and least videos artifacts, the default Linux
// socket buffers (net.core.rmem_default/max) should be increased from their default values
// (~200K) to at least 1MB, with 2-4MB providing the best reliability with only slightly
// increased latency during heavy loads with mulitple streaming sessions.
const { Worker } = require('worker_threads')
const utils = require('./utils')
const colors = require('colors/safe')
class StreamWorkers {
constructor() {
this.streamWorkers = []
}
init() {
const cpuCores = utils.getCpuCores()
const numWorkers = cpuCores > 4 ? 4 : Math.round(cpuCores/1.5)
utils.debug(`Detected ${cpuCores} CPU cores, starting ${numWorkers} live stream ${numWorkers > 1 ? 'workers' : 'worker'}`)
for (let i = 0; i < numWorkers; i++) {
this.streamWorkers[i] = {
liveCall: new Worker('./lib/livecall.js'),
sessions: {}
}
this.streamWorkers[i].liveCall.on('message', (data) => {
const deviceId = data.liveCallData.deviceId
const workerId = this.getWorkerId(deviceId)
if (workerId >= 0) {
switch (data.state) {
case 'active':
utils.event.emit(`livestream_${deviceId}`, 'active')
this.streamWorkers[workerId].sessions[deviceId].streamData.sessionId = data.liveCallData.sessionId
break;
case 'inactive':
utils.event.emit(`livestream_${deviceId}`, 'inactive')
delete this.streamWorkers[workerId].sessions[deviceId]
break;
case 'failed':
utils.event.emit(`livestream_${deviceId}`, 'failed')
delete this.streamWorkers[workerId].sessions[deviceId]
break;
}
}
})
}
utils.event.on('start_livestream', (streamData) => {
if (this.getWorkerId(streamData.deviceId) < 0) {
// Create an array with the number of active sessions per worker
const workerSessions = this.streamWorkers.map(worker => Object.keys(worker.sessions).length)
// Find the fewest number of active sessions for any worker
const fewestSessions = Math.min(...workerSessions)
// Find index of first worker that matches the fewest active sessions
const workerId = workerSessions.indexOf(fewestSessions)
utils.debug(colors.green(`[${streamData.deviceName}] `)+`Live stream assigned to worker ${parseInt(workerId)+1} with ${fewestSessions} current active ${fewestSessions === 1 ? 'session' : 'sessions'}`)
this.streamWorkers[workerId].sessions[streamData.deviceId] = { streamData }
this.streamWorkers[workerId].liveCall.postMessage({ command: 'start', streamData })
}
})
utils.event.on('stop_livestream', (streamData) => {
const workerId = this.getWorkerId(streamData.deviceId)
if (workerId >= 0) {
utils.debug(colors.green(`[${streamData.deviceName}] `)+`Stopping live stream session on workerId ${parseInt(workerId)+1}`)
this.streamWorkers[workerId].liveCall.postMessage({ command: 'stop', streamData: this.streamWorkers[workerId].sessions[streamData.deviceId].streamData })
} else {
utils.debug(colors.green(`[${streamData.deviceName}] `)+'Received live stream stop command but no active session was found')
// If stop received but no session found, force send inactive state
utils.event.emit(`livestream_${streamData.deviceId}`, 'inactive')
}
})
}
getWorkerId(deviceId) {
return this.streamWorkers.findIndex(worker => worker.sessions.hasOwnProperty(deviceId))
}
}
module.exports = new StreamWorkers()

View File

@@ -0,0 +1,142 @@
// This code is largely copied from ring-client-api, but converted from Typescript
// to straight Javascript and some code not required for ring-mqtt removed.
// Much thanks to @dgreif for the original code which is the basis for this work.
import { RTCPeerConnection, RTCRtpCodecParameters } from 'werift'
import { interval, merge, ReplaySubject, Subject } from 'rxjs'
import { Subscribed } from './subscribed.js'
const ringIceServers = [
'stun:stun.kinesisvideo.us-east-1.amazonaws.com:443',
'stun:stun.kinesisvideo.us-east-2.amazonaws.com:443',
'stun:stun.kinesisvideo.us-west-2.amazonaws.com:443',
'stun:stun.l.google.com:19302',
'stun:stun1.l.google.com:19302',
'stun:stun2.l.google.com:19302',
'stun:stun3.l.google.com:19302',
'stun:stun4.l.google.com:19302',
]
export class WeriftPeerConnection extends Subscribed {
constructor() {
super()
this.onAudioRtp = new Subject()
this.onVideoRtp = new Subject()
this.onIceCandidate = new Subject()
this.onConnectionState = new ReplaySubject(1)
this.onRequestKeyFrame = new Subject()
const pc = (this.pc = new RTCPeerConnection({
codecs: {
audio: [
new RTCRtpCodecParameters({
mimeType: 'audio/opus',
clockRate: 48000,
channels: 2,
}),
new RTCRtpCodecParameters({
mimeType: 'audio/PCMU',
clockRate: 8000,
channels: 1,
payloadType: 0,
}),
],
video: [
new RTCRtpCodecParameters({
mimeType: 'video/H264',
clockRate: 90000,
rtcpFeedback: [
{ type: 'transport-cc' },
{ type: 'ccm', parameter: 'fir' },
{ type: 'nack' },
{ type: 'nack', parameter: 'pli' },
{ type: 'goog-remb' },
],
parameters: 'packetization-mode=1;profile-level-id=640029;level-asymmetry-allowed=1',
}),
],
},
iceServers: ringIceServers.map((server) => ({ urls: server })),
iceTransportPolicy: 'all',
bundlePolicy: 'balanced'
}))
const audioTransceiver = pc.addTransceiver('audio', {
direction: 'recvonly',
})
const videoTransceiver = pc.addTransceiver('video', {
direction: 'recvonly',
})
audioTransceiver.onTrack.subscribe((track) => {
track.onReceiveRtp.subscribe((rtp) => {
this.onAudioRtp.next(rtp)
})
})
videoTransceiver.onTrack.subscribe((track) => {
track.onReceiveRtp.subscribe((rtp) => {
this.onVideoRtp.next(rtp)
})
track.onReceiveRtp.once(() => {
// debug('received first video packet')
this.addSubscriptions(merge(this.onRequestKeyFrame, interval(4000)).subscribe(() => {
videoTransceiver.receiver
.sendRtcpPLI(track.ssrc)
.catch((e) => {
// debug(e)
})
}))
this.requestKeyFrame()
})
})
this.pc.onIceCandidate.subscribe((iceCandidate) => {
this.onIceCandidate.next(iceCandidate)
})
pc.iceConnectionStateChange.subscribe(() => {
// debug(`iceConnectionStateChange: ${pc.iceConnectionState}`)
if (pc.iceConnectionState === 'closed') {
this.onConnectionState.next('closed')
}
})
pc.connectionStateChange.subscribe(() => {
// debug(`connectionStateChange: ${pc.connectionState}`)
this.onConnectionState.next(pc.connectionState)
})
}
async createOffer() {
const offer = await this.pc.createOffer()
await this.pc.setLocalDescription(offer)
return offer
}
async createAnswer(offer) {
await this.pc.setRemoteDescription(offer)
const answer = await this.pc.createAnswer()
await this.pc.setLocalDescription(answer)
return answer
}
async acceptAnswer(answer) {
await this.pc.setRemoteDescription(answer)
}
addIceCandidate(candidate) {
return this.pc.addIceCandidate(candidate)
}
requestKeyFrame() {
this.onRequestKeyFrame.next()
}
close() {
this.pc.close().catch((e) => {
//debug
})
this.unsubscribe()
}
}

View File

@@ -0,0 +1,147 @@
// This code is largely copied from ring-client-api, but converted from Typescript
// to straight Javascript and some code not required for ring-mqtt removed.
// Much thanks to @dgreif for the original code which is the basis for this work.
import WebSocket from 'ws'
import { parentPort } from 'worker_threads'
import { firstValueFrom, interval, ReplaySubject } from 'rxjs'
import { StreamingConnectionBase, } from './streaming-connection-base.js'
import crypto from 'crypto'
var CloseReasonCode
(function (CloseReasonCode) {
CloseReasonCode[CloseReasonCode["NormalClose"] = 0] = "NormalClose"
// reason: { code: 5, text: '[rsl-apps/webrtc-liveview-server/Session.cpp:429] [Auth] [0xd540]: [rsl-apps/session-manager/Manager.cpp:227] [AppAuth] Unauthorized: invalid or expired token' }
// reason: { code: 5, text: 'Authentication failed: -1' }
// reason: { code: 5, text: 'Sessions with the provided ID not found' }
CloseReasonCode[CloseReasonCode["AuthenticationFailed"] = 5] = "AuthenticationFailed"
// reason: { code: 6, text: 'Timeout waiting for ping' }
CloseReasonCode[CloseReasonCode["Timeout"] = 6] = "Timeout"
})(CloseReasonCode || (CloseReasonCode = {}))
export class RingEdgeConnection extends StreamingConnectionBase {
constructor(authToken, camera) {
super(new WebSocket('wss://api.prod.signalling.ring.devices.a2z.com:443/ws', {
headers: {
Authorization: `Bearer ${authToken}`,
'X-Sig-API-Version': '4.0',
'X-Sig-Client-ID': `ring_android-${crypto
.randomBytes(4)
.toString('hex')}`,
'X-Sig-Client-Info': 'Ring/3.49.0;Platform/Android;OS/7.0;Density/2.0;Device/samsung-SM-T710;Locale/en-US;TimeZone/GMT-07:00',
'X-Sig-Auth-Type': 'ring_oauth',
},
}))
this.camera = camera
this.onSessionId = new ReplaySubject(1)
this.onOfferSent = new ReplaySubject(1)
this.sessionId = null
this.addSubscriptions(this.onWsOpen.subscribe(() => {
parentPort.postMessage('Websocket signalling for Ring Edge connected successfully')
this.initiateCall().catch((error) => {
parentPort.postMessage(error)
this.callEnded()
})
}),
// The ring-edge session needs a ping every 5 seconds to keep the connection alive
interval(5000).subscribe(() => {
this.sendSessionMessage('ping')
}), this.pc.onIceCandidate.subscribe(async (iceCandidate) => {
await firstValueFrom(this.onOfferSent)
this.sendMessage({
method: 'ice',
body: {
doorbot_id: camera.id,
ice: iceCandidate.candidate,
mlineindex: iceCandidate.sdpMLineIndex,
},
})
}))
}
async initiateCall() {
const { sdp } = await this.pc.createOffer()
this.sendMessage({
method: 'live_view',
body: {
doorbot_id: this.camera.id,
stream_options: { audio_enabled: true, video_enabled: true },
sdp,
},
})
this.onOfferSent.next()
}
async handleMessage(message) {
if (message.body.doorbot_id !== this.camera.id) {
// ignore messages for other cameras
return
}
if (['session_created', 'session_started'].includes(message.method) &&
'session_id' in message.body &&
!this.sessionId) {
this.sessionId = message.body.session_id
this.onSessionId.next(this.sessionId)
}
if (message.body.session_id && message.body.session_id !== this.sessionId) {
// ignore messages for other sessions
return
}
switch (message.method) {
case 'session_created':
case 'session_started':
// session already stored above
return
case 'sdp':
await this.pc.acceptAnswer(message.body)
this.onCallAnswered.next(message.body.sdp)
this.activate()
return
case 'ice':
await this.pc.addIceCandidate({
candidate: message.body.ice,
sdpMLineIndex: message.body.mlineindex,
})
return
case 'pong':
return
case 'notification':
const { text } = message.body
if (text === 'PeerConnectionState::kConnecting' ||
text === 'PeerConnectionState::kConnected') {
return
}
break
case 'close':
this.callEnded()
return
}
}
sendSessionMessage(method, body = {}) {
const sendSessionMessage = () => {
const message = {
method,
body: {
...body,
doorbot_id: this.camera.id,
session_id: this.sessionId,
},
}
this.sendMessage(message)
}
if (this.sessionId) {
// Send immediately if we already have a session id
// This is needed to send `close` before closing the websocket
sendSessionMessage()
}
else {
firstValueFrom(this.onSessionId)
.then(sendSessionMessage)
.catch((e) => {
//debug(e)
})
}
}
}

View File

@@ -0,0 +1,96 @@
// This code is largely copied from ring-client-api, but converted from Typescript
// to straight Javascript and some code not required for ring-mqtt removed.
// Much thanks to @dgreif for the original code which is the basis for this work.
import { WeriftPeerConnection } from './peer-connection.js'
import { Subscribed } from './subscribed.js'
import { fromEvent, ReplaySubject } from 'rxjs'
import { concatMap } from 'rxjs/operators'
export class StreamingConnectionBase extends Subscribed {
constructor(ws) {
super()
this.ws = ws
this.onCallAnswered = new ReplaySubject(1)
this.onCallEnded = new ReplaySubject(1)
this.onMessage = new ReplaySubject()
this.hasEnded = false
const pc = new WeriftPeerConnection()
this.pc = pc
this.onAudioRtp = pc.onAudioRtp
this.onVideoRtp = pc.onVideoRtp
this.onWsOpen = fromEvent(this.ws, 'open')
const onMessage = fromEvent(this.ws, 'message')
const onError = fromEvent(this.ws, 'error')
const onClose = fromEvent(this.ws, 'close')
this.addSubscriptions(
onMessage.pipe(concatMap((event) => {
const message = JSON.parse(event.data)
this.onMessage.next(message)
return this.handleMessage(message)
})).subscribe(),
onError.subscribe((e) => {
this.callEnded()
}),
onClose.subscribe(() => {
this.callEnded()
}),
this.pc.onConnectionState.subscribe((state) => {
if (state === 'failed') {
this.callEnded()
}
if (state === 'closed') {
this.callEnded()
}
})
)
}
activate() {
// the activate_session message is required to keep the stream alive longer than 70 seconds
this.sendSessionMessage('activate_session')
this.sendSessionMessage('stream_options', {
audio_enabled: true,
video_enabled: true,
})
}
sendMessage(message) {
if (this.hasEnded) {
return
}
this.ws.send(JSON.stringify(message))
}
callEnded() {
if (this.hasEnded) {
return
}
try {
this.sendMessage({
reason: { code: 0, text: '' },
method: 'close',
})
this.ws.close()
}
catch (_) {
// ignore any errors since we are stopping the call
}
this.hasEnded = true
this.unsubscribe()
this.onCallEnded.next()
this.pc.close()
}
stop() {
this.callEnded()
}
requestKeyFrame() {
this.pc.requestKeyFrame?.()
}
}

View File

@@ -0,0 +1,132 @@
// This code is largely copied from ring-client-api, but converted from Typescript
// to straight Javascript and some code not required for ring-mqtt removed.
// Much thanks to @dgreif for the original code which is the basis for this work.
import { FfmpegProcess, reservePorts, RtpSplitter, } from '@homebridge/camera-utils'
import { firstValueFrom, ReplaySubject, Subject } from 'rxjs'
import pathToFfmpeg from 'ffmpeg-for-homebridge'
import { concatMap, take } from 'rxjs/operators'
import { Subscribed } from './subscribed.js'
function getCleanSdp(sdp) {
return sdp
.split('\nm=')
.slice(1)
.map((section) => 'm=' + section)
.join('\n')
}
export class StreamingSession extends Subscribed {
constructor(camera, connection) {
super()
this.camera = camera
this.connection = connection
this.onCallEnded = new ReplaySubject(1)
this.onUsingOpus = new ReplaySubject(1)
this.onVideoRtp = new Subject()
this.onAudioRtp = new Subject()
this.audioSplitter = new RtpSplitter()
this.videoSplitter = new RtpSplitter()
this.hasEnded = false
this.bindToConnection(connection)
}
bindToConnection(connection) {
this.addSubscriptions(
connection.onAudioRtp.subscribe(this.onAudioRtp),
connection.onVideoRtp.subscribe(this.onVideoRtp),
connection.onCallAnswered.subscribe((sdp) => {
this.onUsingOpus.next(sdp.toLocaleLowerCase().includes(' opus/'))
}),
connection.onCallEnded.subscribe(() => this.callEnded()))
}
async reservePort(bufferPorts = 0) {
const ports = await reservePorts({ count: bufferPorts + 1 })
return ports[0]
}
get isUsingOpus() {
return firstValueFrom(this.onUsingOpus)
}
async startTranscoding(ffmpegOptions) {
if (this.hasEnded) {
return
}
const videoPort = await this.reservePort(1)
const audioPort = await this.reservePort(1)
const ringSdp = await Promise.race([
firstValueFrom(this.connection.onCallAnswered),
firstValueFrom(this.onCallEnded),
])
if (!ringSdp) {
// Call ended before answered'
return
}
const usingOpus = await this.isUsingOpus
const ffmpegInputArguments = [
'-hide_banner',
'-protocol_whitelist',
'pipe,udp,rtp,file,crypto',
// Ring will answer with either opus or pcmu
...(usingOpus ? ['-acodec', 'libopus'] : []),
'-f',
'sdp',
...(ffmpegOptions.input || []),
'-i',
'pipe:'
]
const inputSdp = getCleanSdp(ringSdp)
.replace(/m=audio \d+/, `m=audio ${audioPort}`)
.replace(/m=video \d+/, `m=video ${videoPort}`)
const ff = new FfmpegProcess({
ffmpegArgs: ffmpegInputArguments.concat(
...(ffmpegOptions.audio || ['-acodec', 'aac']),
...(ffmpegOptions.video || ['-vcodec', 'copy']),
...(ffmpegOptions.output || [])),
ffmpegPath: pathToFfmpeg,
exitCallback: () => this.callEnded()
})
this.addSubscriptions(this.onAudioRtp.pipe(concatMap((rtp) => {
return this.audioSplitter.send(rtp.serialize(), { port: audioPort })
})).subscribe())
this.addSubscriptions(this.onVideoRtp.pipe(concatMap((rtp) => {
return this.videoSplitter.send(rtp.serialize(), { port: videoPort })
})).subscribe())
this.onCallEnded.pipe(take(1)).subscribe(() => ff.stop())
ff.writeStdin(inputSdp)
// Request a key frame now that ffmpeg is ready to receive
this.requestKeyFrame()
}
callEnded() {
if (this.hasEnded) {
return
}
this.hasEnded = true
this.unsubscribe()
this.onCallEnded.next()
this.connection.stop()
this.audioSplitter.close()
this.videoSplitter.close()
}
stop() {
this.callEnded()
}
requestKeyFrame() {
this.connection.requestKeyFrame()
}
}

View File

@@ -0,0 +1,15 @@
// This code is largely copied from ring-client-api, but converted from Typescript
// to straight Javascript and some code not required for ring-mqtt removed.
// Much thanks to @dgreif for the original code which is the basis for this work.
export class Subscribed {
constructor() {
this.subscriptions = []
}
addSubscriptions(...subscriptions) {
this.subscriptions.push(...subscriptions)
}
unsubscribe() {
this.subscriptions.forEach((subscription) => subscription.unsubscribe())
}
}

View File

@@ -0,0 +1,61 @@
// This code is largely copied from ring-client-api, but converted from Typescript
// to straight Javascript and some code not required for ring-mqtt removed.
// Much thanks to @dgreif for the original code which is the basis for this work.
import WebSocket from 'ws'
import { parentPort } from 'worker_threads'
import { StreamingConnectionBase } from './streaming-connection-base.js'
function parseLiveCallSession(sessionId) {
const encodedSession = sessionId.split('.')[1]
const buff = Buffer.from(encodedSession, 'base64')
const text = buff.toString('ascii')
return JSON.parse(text)
}
export class WebrtcConnection extends StreamingConnectionBase {
constructor(sessionId, camera) {
const liveCallSession = parseLiveCallSession(sessionId)
super(new WebSocket(`wss://${liveCallSession.rms_fqdn}:${liveCallSession.webrtc_port}/`, {
headers: {
API_VERSION: '3.1',
API_TOKEN: sessionId,
CLIENT_INFO: 'Ring/3.49.0;Platform/Android;OS/7.0;Density/2.0;Device/samsung-SM-T710;Locale/en-US;TimeZone/GMT-07:00',
},
}))
this.camera = camera
this.sessionId = sessionId
this.addSubscriptions(
this.onWsOpen.subscribe(() => {
parentPort.postMessage('Websocket signalling for Ring cloud connected successfully')
})
)
}
async handleMessage(message) {
switch (message.method) {
case 'sdp':
const answer = await this.pc.createAnswer(message)
this.sendSessionMessage('sdp', answer)
this.onCallAnswered.next(message.sdp)
this.activate()
return
case 'ice':
await this.pc.addIceCandidate({
candidate: message.ice,
sdpMLineIndex: message.mlineindex,
})
return
}
}
sendSessionMessage(method, body = {}) {
this.sendMessage({
...body,
method,
})
}
}

View File

@@ -1,10 +1,13 @@
const { RingRestClient } = require('../node_modules/ring-client-api/lib/api/rest-client') import { RingRestClient } from '../node_modules/ring-client-api/lib/rest-client.js'
const debug = require('debug')('ring-mqtt') import utils from './utils.js'
const utils = require('./utils') import { dirname } from 'path'
const express = require('express') import { fileURLToPath } from 'url'
const bodyParser = require("body-parser") import express from 'express'
import bodyParser from 'body-parser'
import debugModule from 'debug'
const debug = debugModule('ring-mqtt')
class TokenApp { export default new class TokenApp {
constructor() { constructor() {
this.app = express() this.app = express()
this.listener = false this.listener = false
@@ -34,7 +37,7 @@ class TokenApp {
return return
} }
const webdir = __dirname+'/../web' const webdir = dirname(fileURLToPath(new URL('.', import.meta.url)))+'/web'
let restClient let restClient
this.listener = this.app.listen(55123, () => { this.listener = this.app.listen(55123, () => {
@@ -99,5 +102,3 @@ class TokenApp {
} }
} }
} }
module.exports = new TokenApp()

View File

@@ -1,20 +1,28 @@
import config from './config.js'
import dns from 'dns'
import os from 'os'
import fs from 'fs'
import { promisify } from 'util'
import { execSync } from 'child_process'
import { EventEmitter } from 'events'
import debugModule from 'debug'
const debug = { const debug = {
mqtt: require('debug')('ring-mqtt'), mqtt: debugModule('ring-mqtt'),
attr: require('debug')('ring-attr'), attr: debugModule('ring-attr'),
disc: require('debug')('ring-disc') disc: debugModule('ring-disc'),
rtsp: debugModule('ring-rtsp'),
wrtc: debugModule('ring-wrtc')
} }
const config = require('./config')
const dns = require('dns')
const os = require('os')
const fs = require('fs')
const execSync = require('child_process').execSync
const { promisify } = require('util')
const EventEmitter = require('events').EventEmitter
class Utils { export default new class Utils {
// Define a few helper variables for sharing
event = new EventEmitter() constructor() {
config = config.data this.event = new EventEmitter()
}
config() {
return config.data
}
// Sleep function (seconds) // Sleep function (seconds)
sleep(sec) { sleep(sec) {
@@ -42,8 +50,8 @@ class Utils {
} }
async getHostIp() { async getHostIp() {
const pLookup = promisify(dns.lookup)
try { try {
const pLookup = promisify(dns.lookup)
return (await pLookup(os.hostname())).address return (await pLookup(os.hostname())).address
} catch { } catch {
console.log('Failed to resolve hostname IP address, returning localhost instead') console.log('Failed to resolve hostname IP address, returning localhost instead')
@@ -74,10 +82,12 @@ class Utils {
return detectedCores return detectedCores
} }
isNumeric(num) {
return !isNaN(parseFloat(num)) && isFinite(num);
}
debug(message, debugType) { debug(message, debugType) {
debugType = debugType ? debugType : 'mqtt' debugType = debugType ? debugType : 'mqtt'
debug[debugType](message) debug[debugType](message)
} }
} }
module.exports = new Utils()

3050
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,24 @@
{ {
"name": "ring-mqtt", "name": "ring-mqtt",
"version": "5.0.5", "version": "5.1.0",
"type": "module",
"description": "Ring Devices via MQTT", "description": "Ring Devices via MQTT",
"main": "ring-mqtt.js", "main": "ring-mqtt.js",
"dependencies": { "dependencies": {
"aedes": "0.48.1",
"body-parser": "^1.20.1", "body-parser": "^1.20.1",
"chalk": "^5.2.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"express": "^4.18.2", "express": "^4.18.2",
"got": "^11.8.5",
"ip": "^1.1.8", "ip": "^1.1.8",
"is-online": "9.0.1", "is-online": "^10.0.0",
"write-file-atomic": "^4.0.2", "js-yaml": "^4.1.0",
"minimist": "^1.2.6", "minimist": "^1.2.7",
"mqtt": "4.3.7", "mqtt": "^4.3.7",
"aedes": "0.48.0", "ring-client-api": "11.7.1",
"ring-client-api": "^11.3.0" "write-file-atomic": "^5.0.0",
"werift": "^0.18.1",
"rxjs": "^7.8.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^7.19.0" "eslint": "^7.19.0"

View File

@@ -1,2 +1,2 @@
#!/usr/bin/env node #!/usr/bin/env node
require('./lib/main') import Main from './lib/main.js'

View File

@@ -6,27 +6,30 @@
# Provides status updates and termintates stream on script exit # Provides status updates and termintates stream on script exit
# Required command line arguments # Required command line arguments
client_name=${1} # Friendly name of camera (used for logging) device_id=${1} # Camera device Id
device_id=${2} # Camera device Id type=${2} # Stream type ("live" or "event")
type=${3} # Stream type ("live" or "event") base_topic=${3} # Command topic for Camera entity
base_topic=${4} # Command topic for Camera entity rtsp_pub_url=${4} # URL for publishing RTSP stream
client_id="${device_id}_${type}" # Id used to connect to the MQTT broker, camera Id + event type client_id="${device_id}_${type}" # Id used to connect to the MQTT broker, camera Id + event type
activated="false" activated="false"
json_attribute_topic="${base_topic}stream/attributes" [[ ${type} = "live" ]] && base_topic="${base_topic}/stream" || base_topic="${base_topic}/event_stream"
command_topic="${base_topic}stream/command"
json_attribute_topic="${base_topic}/attributes"
command_topic="${base_topic}/command"
debug_topic="${base_topic}/debug"
# Set some colors for debug output # Set some colors for debug output
red='\033[0;31m' red='\e[0;31m'
yellow='\033[0;33m' yellow='\e[0;33m'
green='\033[0;32m' green='\e[0;32m'
blue='\033[0;34m' blue='\e[0;34m'
reset='\033[0m' reset='\e[0m'
cleanup() { cleanup() {
if [ -z ${reason} ]; then if [ -z ${reason} ]; then
# If no reason defined, that means we were interrupted by a signal, send the command to stop the live stream # If no reason defined, that means we were interrupted by a signal, send the command to stop the live stream
echo -e "${green}[${client_name}]${reset} Deactivating ${type} stream due to signal from RTSP server (no more active clients or publisher ended stream)" mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${debug_topic}" -m "Deactivating ${type} stream due to signal from RTSP server (no more active clients or publisher ended stream)"
mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${command_topic}" -m "OFF" mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${command_topic}" -m "OFF"
fi fi
# Kill the spawed mosquitto_sub process or it will stay listening forever # Kill the spawed mosquitto_sub process or it will stay listening forever
@@ -34,6 +37,12 @@ cleanup() {
exit 0 exit 0
} }
# go2rtc does not pass stdout through from child processes so send debug loggins
# via main process using MQTT messages
logger() {
mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${debug_topic}" -m "${1}"
}
# Trap signals so that the MQTT command to stop the stream can be published on exit # Trap signals so that the MQTT command to stop the stream can be published on exit
trap cleanup INT TERM QUIT trap cleanup INT TERM QUIT
@@ -51,35 +60,38 @@ while read -u 10 message
do do
# If start message received, publish the command to start stream # If start message received, publish the command to start stream
if [ ${message} = "START" ]; then if [ ${message} = "START" ]; then
echo -e "${green}[${client_name}]${reset} Sending command to activate ${type} stream ON-DEMAND" logger "Sending command to activate ${type} stream ON-DEMAND"
mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${command_topic}" -m "ON-DEMAND" mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${command_topic}" -m "ON-DEMAND ${rtsp_pub_url}"
else else
# Otherwise it should be a JSON message from the stream state attribute topic so extract the detailed stream state # Otherwise it should be a JSON message from the stream state attribute topic so extract the detailed stream state
stream_state=`echo ${message} | jq -r '.status'` stream_state=`echo ${message} | jq -r '.status'`
case ${stream_state,,} in case ${stream_state,,} in
activating) activating)
if [ ${activated} = "false" ]; then if [ ${activated} = "false" ]; then
echo -e "${green}[${client_name}]${reset} State indicates ${type} stream is activating" logger "State indicates ${type} stream is activating"
fi fi
;; ;;
active) active)
if [ ${activated} = "false" ]; then if [ ${activated} = "false" ]; then
echo -e "${green}[${client_name}]${reset} State indicates ${type} stream is active" logger "State indicates ${type} stream is active"
activated="true" activated="true"
fi fi
;; ;;
inactive) inactive)
echo -e "${green}[${client_name}]${yellow} State indicates ${type} stream has gone inactive${reset}" logmsg=$(echo -en "${yellow}State indicates ${type} stream has gone inactive${reset}")
logger "${logmsg}"
reason='inactive' reason='inactive'
cleanup cleanup
;; ;;
failed) failed)
echo -e "${green}[${client_name}]${red} ERROR - State indicates ${type} stream failed to activate${reset}" logmsg=$(echo -en "${red}ERROR - State indicates ${type} stream failed to activate${reset}")
logger "${logmsg}"
reason='failed' reason='failed'
cleanup cleanup
;; ;;
*) *)
echo -e "${green}[${client_name}]${red} ERROR - Received unknown ${type} stream state on topic ${blue}${json_attribute_topic}${reset}" logmsg=$(echo -en "${red}ERROR - Received unknown ${type} stream state on topic ${blue}${json_attribute_topic}${reset}")
logger "${logmsg}"
;; ;;
esac esac
fi fi

View File

@@ -19,5 +19,25 @@ if [ ! -d "/app/ring-mqtt-${BRANCH}" ]; then
echo "-------------------------------------------------------" echo "-------------------------------------------------------"
else else
# Branch has already been initialized, run any post-update command here # Branch has already been initialized, run any post-update command here
echo "The ring-mqtt-${BRANCH} has been updated." echo "The ring-mqtt-${BRANCH} branch has been updated."
APK_ARCH="$(apk --print-arch)"
GO2RTC_VERSION="v1.1.1"
case "${APK_ARCH}" in
x86_64)
GO2RTC_ARCH="amd64";;
aarch64)
GO2RTC_ARCH="arm64";;
armv7|armhf)
GO2RTC_ARCH="arm";;
*)
echo >&2 "ERROR: Unsupported architecture '$APK_ARCH'"
exit 1;;
esac
rm -f /usr/local/bin/go2rtc
curl -L -s -o /usr/local/bin/go2rtc "https://github.com/AlexxIT/go2rtc/releases/download/${GO2RTC_VERSION}/go2rtc_linux_${GO2RTC_ARCH}"
chmod +x /usr/local/bin/go2rtc
cp -f "/app/ring-mqtt-${BRANCH}/init/s6/services.d/ring-mqtt/run" /etc/services.d/ring-mqtt/run
chmod +x /etc/services.d/ring-mqtt/run
fi fi