mirror of
https://github.com/tsightler/ring-mqtt.git
synced 2025-09-26 21:01:12 +08:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ node_modules
|
||||
config.dev.json
|
||||
ring-state.json
|
||||
ring-test.js
|
||||
config/go2rtc.yaml
|
15
Dockerfile
15
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM alpine:3.16.2
|
||||
FROM alpine:3.17.1
|
||||
|
||||
ENV LANG="C.UTF-8" \
|
||||
PS1="$(whoami)@$(hostname):$(pwd)$ " \
|
||||
@@ -11,9 +11,9 @@ ENV LANG="C.UTF-8" \
|
||||
COPY . /app/ring-mqtt
|
||||
RUN S6_VERSION="v3.1.2.1" && \
|
||||
BASHIO_VERSION="v0.14.3" && \
|
||||
RSS_VERSION="v0.20.0" && \
|
||||
apk add --no-cache tar xz git libcrypto1.1 libssl1.1 musl-utils musl bash curl jq tzdata nodejs npm mosquitto-clients && \
|
||||
GO2RTC_VERSION="v1.1.1" && \
|
||||
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 / && \
|
||||
case "${APK_ARCH}" in \
|
||||
aarch64|armhf|x86_64) \
|
||||
@@ -32,16 +32,17 @@ RUN S6_VERSION="v3.1.2.1" && \
|
||||
rm -Rf /app/ring-mqtt/init && \
|
||||
case "${APK_ARCH}" in \
|
||||
x86_64) \
|
||||
RSS_ARCH="amd64";; \
|
||||
GO2RTC_ARCH="amd64";; \
|
||||
aarch64) \
|
||||
RSS_ARCH="arm64v8";; \
|
||||
GO2RTC_ARCH="arm64";; \
|
||||
armv7|armhf) \
|
||||
RSS_ARCH="armv7";; \
|
||||
GO2RTC_ARCH="arm";; \
|
||||
*) \
|
||||
echo >&2 "ERROR: Unsupported architecture '$APK_ARCH'" \
|
||||
exit 1;; \
|
||||
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" && \
|
||||
mkdir /tmp/bashio && \
|
||||
tar zxvf /tmp/bashio.tar.gz --strip 1 -C /tmp/bashio && \
|
||||
|
@@ -9,7 +9,5 @@
|
||||
"enable_panic": false,
|
||||
"hass_topic": "homeassistant/status",
|
||||
"ring_topic": "ring",
|
||||
"location_ids": [
|
||||
""
|
||||
]
|
||||
"location_ids": []
|
||||
}
|
||||
|
@@ -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
|
@@ -1,8 +1,8 @@
|
||||
const RingDevice = require('./base-ring-device')
|
||||
const utils = require('../lib/utils')
|
||||
import RingDevice from './base-ring-device.js'
|
||||
import utils from '../lib/utils.js'
|
||||
|
||||
// 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) {
|
||||
super(deviceInfo, category, primaryAttribute, deviceInfo.device.data.device_id, deviceInfo.device.data.location_id)
|
||||
this.heartbeat = 3
|
||||
@@ -61,5 +61,3 @@ class RingPolledDevice extends RingDevice {
|
||||
this.monitorHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RingPolledDevice
|
@@ -1,9 +1,9 @@
|
||||
const utils = require('../lib/utils')
|
||||
const state = require('../lib/state')
|
||||
const colors = require('colors/safe')
|
||||
import utils from '../lib/utils.js'
|
||||
import state from '../lib/state.js'
|
||||
import chalk from 'chalk'
|
||||
|
||||
// Base class with functions common to all devices
|
||||
class RingDevice {
|
||||
export default class RingDevice {
|
||||
constructor(deviceInfo, category, primaryAttribute, deviceId, locationId) {
|
||||
this.device = deviceInfo.device
|
||||
this.deviceId = deviceId
|
||||
@@ -15,10 +15,10 @@ class RingDevice {
|
||||
}
|
||||
|
||||
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
|
||||
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`
|
||||
|
||||
if (deviceInfo.hasOwnProperty('parentDevice')) {
|
||||
@@ -104,8 +104,8 @@ class RingDevice {
|
||||
? { icon: entity.icon }
|
||||
: entityKey === "info"
|
||||
? { icon: 'mdi:information-outline' } : {},
|
||||
... entity.component === 'alarm_control_panel' && utils.config.disarm_code
|
||||
? { code: utils.config.disarm_code.toString(),
|
||||
... entity.component === 'alarm_control_panel' && utils.config().disarm_code
|
||||
? { code: utils.config().disarm_code.toString(),
|
||||
code_arm_required: false,
|
||||
code_disarm_required: true } : {},
|
||||
... entity.hasOwnProperty('brightness_scale')
|
||||
@@ -163,12 +163,26 @@ class RingDevice {
|
||||
if (topic.match('command_topic')) {
|
||||
utils.event.emit('mqtt_subscribe', discoveryMessage[topic])
|
||||
utils.event.on(discoveryMessage[topic], (command, message) => {
|
||||
if (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') {
|
||||
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
|
||||
mqttPublish(topic, message, debugType, maskedMessage) {
|
||||
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)
|
||||
}
|
||||
@@ -237,5 +251,3 @@ class RingDevice {
|
||||
this.mqttPublish(this.availabilityTopic, this.availabilityState, debugType)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RingDevice
|
@@ -1,8 +1,8 @@
|
||||
const RingDevice = require('./base-ring-device')
|
||||
const utils = require('../lib/utils')
|
||||
import RingDevice from './base-ring-device.js'
|
||||
import utils from '../lib/utils.js'
|
||||
|
||||
// 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) {
|
||||
super(deviceInfo, category, primaryAttribute, deviceInfo.device.id, deviceInfo.device.location.locationId)
|
||||
|
||||
@@ -122,5 +122,3 @@ class RingSocketDevice extends RingDevice {
|
||||
this.publishAttributeEntities(attributes)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RingSocketDevice
|
@@ -1,7 +1,7 @@
|
||||
const RingSocketDevice = require('./base-socket-device')
|
||||
const utils = require('../lib/utils')
|
||||
import RingSocketDevice from './base-socket-device.js'
|
||||
import utils from '../lib/utils.js'
|
||||
|
||||
class BaseStation extends RingSocketDevice {
|
||||
export default class BaseStation extends RingSocketDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'alarm', 'acStatus')
|
||||
this.deviceData.mdl = 'Alarm Base Station'
|
||||
@@ -72,5 +72,3 @@ class BaseStation extends RingSocketDevice {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = BaseStation
|
@@ -1,7 +1,7 @@
|
||||
const RingSocketDevice = require('./base-socket-device')
|
||||
const { RingDeviceType } = require('ring-client-api')
|
||||
import RingSocketDevice from './base-socket-device.js'
|
||||
import { RingDeviceType } from 'ring-client-api'
|
||||
|
||||
class BeamOutdoorPlug extends RingSocketDevice {
|
||||
export default class BeamOutdoorPlug extends RingSocketDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'lighting')
|
||||
this.deviceData.mdl = 'Outdoor Smart Plug'
|
||||
@@ -76,5 +76,3 @@ class BeamOutdoorPlug extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BeamOutdoorPlug
|
@@ -1,7 +1,6 @@
|
||||
const RingSocketDevice = require('./base-socket-device')
|
||||
const utils = require( '../lib/utils' )
|
||||
import RingSocketDevice from './base-socket-device.js'
|
||||
|
||||
class Beam extends RingSocketDevice {
|
||||
export default class Beam extends RingSocketDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'lighting')
|
||||
|
||||
@@ -163,5 +162,3 @@ class Beam extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Beam
|
@@ -1,7 +1,7 @@
|
||||
const RingSocketDevice = require('./base-socket-device')
|
||||
const { RingDeviceType } = require('ring-client-api')
|
||||
import RingSocketDevice from './base-socket-device.js'
|
||||
import { RingDeviceType } from 'ring-client-api'
|
||||
|
||||
class BinarySensor extends RingSocketDevice {
|
||||
export default class BinarySensor extends RingSocketDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'alarm')
|
||||
let device_class = 'None'
|
||||
@@ -126,5 +126,3 @@ class BinarySensor extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BinarySensor
|
||||
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm', 'commStatus')
|
||||
this.deviceData.mdl = 'Bridge'
|
||||
@@ -12,5 +12,3 @@ class Bridge extends RingSocketDevice {
|
||||
this.publishAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Bridge
|
89
devices/camera-livestream.js
Normal file
89
devices/camera-livestream.js
Normal 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
|
||||
}
|
||||
}
|
||||
})
|
@@ -1,15 +1,18 @@
|
||||
const RingPolledDevice = require('./base-polled-device')
|
||||
const utils = require( '../lib/utils' )
|
||||
const pathToFfmpeg = require('ffmpeg-for-homebridge')
|
||||
const { spawn } = require('child_process')
|
||||
const rss = require('../lib/rtsp-simple-server')
|
||||
import RingPolledDevice from './base-polled-device.js'
|
||||
import utils from '../lib/utils.js'
|
||||
import pathToFfmpeg from 'ffmpeg-for-homebridge'
|
||||
import { Worker } from 'worker_threads'
|
||||
import { spawn } from 'child_process'
|
||||
|
||||
class Camera extends RingPolledDevice {
|
||||
export default class Camera extends RingPolledDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'camera')
|
||||
|
||||
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 = {
|
||||
motion: {
|
||||
active_ding: false,
|
||||
@@ -20,7 +23,7 @@ class Camera extends RingPolledDevice {
|
||||
is_person: false,
|
||||
detection_enabled: null
|
||||
},
|
||||
... this.device.isDoorbot ? {
|
||||
...this.device.isDoorbot ? {
|
||||
ding: {
|
||||
active_ding: false,
|
||||
ding_duration: 180,
|
||||
@@ -51,25 +54,22 @@ class Camera extends RingPolledDevice {
|
||||
status: 'inactive',
|
||||
session: false,
|
||||
publishedStatus: '',
|
||||
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`,
|
||||
worker: new Worker('./devices/camera-livestream.js', {
|
||||
workerData: {
|
||||
doorbotId: this.device.id,
|
||||
deviceName: this.deviceData.name
|
||||
}
|
||||
})
|
||||
},
|
||||
event: {
|
||||
state: 'OFF',
|
||||
status: 'inactive',
|
||||
session: false,
|
||||
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`
|
||||
publishedStatus: ''
|
||||
},
|
||||
keepalive:{
|
||||
active: false,
|
||||
session: false,
|
||||
expires: 0
|
||||
}
|
||||
},
|
||||
@@ -77,7 +77,12 @@ class Camera extends RingPolledDevice {
|
||||
state: savedState?.event_select?.state
|
||||
? savedState.event_select.state
|
||||
: 'Motion 1',
|
||||
publishedState: null
|
||||
publishedState: null,
|
||||
pollCycle: 0,
|
||||
recordingUrl: null,
|
||||
recordingUrlExpire: null,
|
||||
transcoded: false,
|
||||
eventId: '0'
|
||||
},
|
||||
...this.device.hasLight ? {
|
||||
light: {
|
||||
@@ -114,10 +119,15 @@ class Camera extends RingPolledDevice {
|
||||
component: 'select',
|
||||
options: [
|
||||
...(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',
|
||||
'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
|
||||
},
|
||||
@@ -161,27 +171,23 @@ class Camera extends RingPolledDevice {
|
||||
}
|
||||
}
|
||||
|
||||
utils.event.on(`livestream_${this.deviceId}`, (state) => {
|
||||
switch (state) {
|
||||
this.data.stream.live.worker.on('message', (message) => {
|
||||
switch (message) {
|
||||
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.session = true
|
||||
break;
|
||||
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.session = false
|
||||
break;
|
||||
case 'failed':
|
||||
this.debug('Live stream failed to activate')
|
||||
this.data.stream.live.status = 'failed'
|
||||
this.data.stream.live.session = false
|
||||
break;
|
||||
default:
|
||||
this.debug(message, 'wrtc')
|
||||
return
|
||||
}
|
||||
this.publishStreamState()
|
||||
})
|
||||
@@ -226,7 +232,7 @@ class Camera extends RingPolledDevice {
|
||||
}
|
||||
|
||||
// If device is battery powered publish battery entity
|
||||
if (this.device.hasBattery) {
|
||||
if (this.device.batteryLevel || this.hasBattery1 || this.hasBattery2) {
|
||||
this.entity.battery = {
|
||||
component: 'sensor',
|
||||
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 lastMotionDate = (lastMotionEvent?.created_at) ? new Date(lastMotionEvent.created_at) : false
|
||||
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
|
||||
}
|
||||
|
||||
// Update motion properties with most recent historical event data
|
||||
// Get most recent ding event data
|
||||
if (this.device.isDoorbot) {
|
||||
const lastDingEvent = (await this.device.getEvents({ limit: 1, kind: 'ding'})).events[0]
|
||||
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) : ''
|
||||
}
|
||||
|
||||
if (!await this.updateEventStreamUrl()) {
|
||||
this.debug('Could not retrieve recording URL for event, assuming no Ring Protect subscription')
|
||||
// Try to get URL for most recent motion event, if it fails, assume there's no 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_select
|
||||
}
|
||||
@@ -279,8 +288,8 @@ class Camera extends RingPolledDevice {
|
||||
|
||||
// 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.streamSource = (utils.config.livestream_user && utils.config.livestream_pass)
|
||||
? `rtsp://${utils.config.livestream_user}:${utils.config.livestream_pass}@${streamSourceUrlBase}:8554/${this.deviceId}_live`
|
||||
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://${streamSourceUrlBase}:8554/${this.deviceId}_live`
|
||||
}
|
||||
|
||||
@@ -328,14 +337,16 @@ class Camera extends RingPolledDevice {
|
||||
const isPublish = data === undefined ? true : false
|
||||
this.publishPolledState(isPublish)
|
||||
|
||||
// Update every 3 polling cycles (~1 minute), check for updated event or expired recording URL
|
||||
this.data.stream.event.pollCycle--
|
||||
if (this.data.stream.event.pollCycle <= 0) {
|
||||
this.data.stream.event.pollCycle = 3
|
||||
if (this.entity.event_select && await this.updateEventStreamUrl() && !isPublish) {
|
||||
// Checks for new events or expired recording URL even 3 polling cycles (~1 minute)
|
||||
if (this.entity.hasOwnProperty('event_select')) {
|
||||
this.data.event_select.pollCycle--
|
||||
if (this.data.event_select.pollCycle <= 0) {
|
||||
this.data.event_select.pollCycle = 3
|
||||
if (await this.updateEventStreamUrl() && !isPublish) {
|
||||
this.publishEventSelectState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isPublish) {
|
||||
// Publish stream state
|
||||
@@ -488,16 +499,35 @@ class Camera extends RingPolledDevice {
|
||||
|
||||
// Publish device data to info topic
|
||||
async publishAttributes() {
|
||||
const attributes = {}
|
||||
const deviceHealth = await this.device.getHealth()
|
||||
|
||||
if (deviceHealth) {
|
||||
const attributes = {}
|
||||
if (this.device.hasBattery) {
|
||||
attributes.batteryLevel = deviceHealth.battery_percentage
|
||||
if (this.device.batteryLevel || this.hasBattery1 || this.hasBattery2) {
|
||||
if (deviceHealth && deviceHealth.hasOwnProperty('active_battery')) {
|
||||
attributes.activeBattery = deviceHealth.active_battery
|
||||
}
|
||||
|
||||
// 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.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
|
||||
} else {
|
||||
attributes.wirelessNetwork = deviceHealth.wifi_name
|
||||
@@ -505,6 +535,9 @@ class Camera extends RingPolledDevice {
|
||||
}
|
||||
attributes.stream_Source = this.data.stream.live.streamSource
|
||||
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.publishAttributeEntities(attributes)
|
||||
}
|
||||
@@ -557,8 +590,8 @@ class Camera extends RingPolledDevice {
|
||||
this.mqttPublish(this.entity.event_select.state_topic, this.data.event_select.state)
|
||||
}
|
||||
const attributes = {
|
||||
recordingUrl: this.data.stream.event.recordingUrl,
|
||||
eventId: this.data.stream.event.dingId
|
||||
recordingUrl: this.data.event_select.recordingUrl,
|
||||
eventId: this.data.event_select.eventId
|
||||
}
|
||||
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
|
||||
const streamData = {
|
||||
deviceId: this.deviceId,
|
||||
deviceName: this.device.name,
|
||||
doorbotId: this.device.id,
|
||||
rtspPublishUrl: this.data.stream.live.rtspPublishUrl,
|
||||
rtspPublishUrl,
|
||||
sessionId: false,
|
||||
authToken: false
|
||||
}
|
||||
|
||||
try {
|
||||
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()
|
||||
streamData.authToken = auth.access_token
|
||||
} 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({
|
||||
method: 'POST',
|
||||
url: this.device.doorbotUrl('live_call')
|
||||
@@ -651,49 +681,77 @@ class Camera extends RingPolledDevice {
|
||||
}
|
||||
|
||||
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 {
|
||||
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.session = false
|
||||
this.publishStreamState()
|
||||
}
|
||||
}
|
||||
|
||||
async startEventStream() {
|
||||
if (await this.updateEventStreamUrl()) {
|
||||
this.publishEventSelectState()
|
||||
async startEventStream(rtspPublishUrl) {
|
||||
const eventSelect = this.data.event_select.state.split(' ')
|
||||
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('-', '_')
|
||||
const index = streamSelect[1]
|
||||
this.debug(`Streaming the ${(index==1?"":index==2?"2nd ":index==3?"3rd ":index+"th ")}most recently recorded ${kind} event`)
|
||||
|
||||
this.debug(`Streaming the ${(eventNumber==1?"":eventNumber==2?"2nd ":eventNumber==3?"3rd ":eventNumber+"th ")}most recently recorded ${eventType} event`)
|
||||
|
||||
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, [
|
||||
'-re',
|
||||
'-max_delay', '0',
|
||||
'-i', this.data.stream.event.recordingUrl,
|
||||
'-i', this.data.event_select.recordingUrl,
|
||||
'-map', '0:v',
|
||||
'-map', '0:a',
|
||||
'-map', '0:a',
|
||||
'-c:v', 'libx264',
|
||||
'-g', '20',
|
||||
'-keyint_min', '10',
|
||||
'-crf', '18',
|
||||
'-preset', 'ultrafast',
|
||||
'-c:a:0', 'copy',
|
||||
'-c:a:1', 'libopus',
|
||||
'-c:v', 'copy',
|
||||
'-flags', '+global_header',
|
||||
'-f', 'rtsp',
|
||||
'-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.debug(`The recorded ${kind} event stream has started`)
|
||||
this.debug(`The recorded ${eventType} event stream has started`)
|
||||
this.data.stream.event.status = 'active'
|
||||
this.publishStreamState()
|
||||
})
|
||||
|
||||
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.session = false
|
||||
this.publishStreamState()
|
||||
@@ -710,87 +768,127 @@ class Camera extends RingPolledDevice {
|
||||
const duration = 86400
|
||||
if (this.data.stream.keepalive.active) { return }
|
||||
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`)
|
||||
|
||||
// Keepalive stream is used only when the live stream is started
|
||||
// manually. It copies only the audio stream to null output just to
|
||||
// trigger rtsp-simple-server to start the on-demand stream and
|
||||
// keep it running when there are no other RTSP readers.
|
||||
ffmpegProcess = spawn(pathToFfmpeg, [
|
||||
'-i', this.data.stream.live.rtspPublishUrl,
|
||||
// trigger rtsp server to start the on-demand stream and keep it running
|
||||
// when there are no other RTSP readers.
|
||||
this.data.stream.keepalive.session = spawn(pathToFfmpeg, [
|
||||
'-i', rtspPublishUrl,
|
||||
'-map', '0:a:0',
|
||||
'-c:a', 'copy',
|
||||
'-f', 'null',
|
||||
'/dev/null'
|
||||
])
|
||||
|
||||
ffmpegProcess.on('spawn', async () => {
|
||||
this.data.stream.keepalive.session.on('spawn', async () => {
|
||||
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.session = false
|
||||
this.debug(`The keepalive stream has stopped`)
|
||||
})
|
||||
|
||||
// If stream starts, set expire time, may be extended by new events
|
||||
// (if only Ring sent events while streaming)
|
||||
// The keepalive stream will time out after 24 hours
|
||||
this.data.stream.keepalive.expires = Math.floor(Date.now()/1000) + duration
|
||||
|
||||
while (Math.floor(Date.now()/1000) < this.data.stream.keepalive.expires) {
|
||||
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'
|
||||
while (this.data.stream.keepalive.active && Math.floor(Date.now()/1000) < this.data.stream.keepalive.expires) {
|
||||
await utils.sleep(60)
|
||||
}
|
||||
}
|
||||
|
||||
ffmpegProcess.kill(killSignal)
|
||||
this.data.stream.keepalive.session.kill()
|
||||
this.data.stream.keepalive.active = false
|
||||
this.data.stream.keepalive.session = false
|
||||
}
|
||||
|
||||
async updateEventStreamUrl() {
|
||||
const streamSelect = this.data.event_select.state.split(' ')
|
||||
const kind = streamSelect[0].toLowerCase().replace('-', '_')
|
||||
const index = streamSelect[1]-1
|
||||
const eventSelect = this.data.event_select.state.split(' ')
|
||||
const eventType = eventSelect[0].toLowerCase().replace('-', '_')
|
||||
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 dingId
|
||||
|
||||
try {
|
||||
const events = ((await this.device.getEvents({ limit: 10, kind })).events).filter(event => event.recording_status === 'ready')
|
||||
dingId = events[index].ding_id_str
|
||||
if (dingId !== this.data.stream.event.dingId) {
|
||||
if (this.data.stream.event.recordingUrlExpire) {
|
||||
// Only log after first update
|
||||
this.debug(`New ${kind} event detected, updating the event recording URL`)
|
||||
const events = await(this.getRecordedEvents(eventType, eventNumber))
|
||||
selectedEvent = events[eventNumber-1]
|
||||
|
||||
if (selectedEvent) {
|
||||
if (selectedEvent.event_id !== this.data.event_select.eventId || this.data.event_select.transcoded !== transcoded) {
|
||||
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)
|
||||
} else if (Math.floor(Date.now()/1000) - this.data.stream.event.recordingUrlExpire > 0) {
|
||||
this.debug(`Previous ${kind} event recording URL has expired, updating the event recording URL`)
|
||||
recordingUrl = await this.device.getRecordingUrl(dingId)
|
||||
recordingUrl = await this.device.getRecordingUrl(selectedEvent.event_id, { transcoded })
|
||||
} else if (urlExpired) {
|
||||
this.debug(`Previous ${this.data.event_select.state} URL has expired, updating the recording URL`)
|
||||
recordingUrl = await this.device.getRecordingUrl(selectedEvent.event_id, { transcoded })
|
||||
}
|
||||
} catch {
|
||||
this.debug(`Failed to retrieve recording URL for ${kind} event`)
|
||||
return false
|
||||
}
|
||||
} catch(error) {
|
||||
this.debug(error)
|
||||
this.debug(`Failed to retrieve recording URL for ${this.data.event_select.state} event`)
|
||||
}
|
||||
|
||||
if (recordingUrl) {
|
||||
this.data.stream.event.dingId = dingId
|
||||
this.data.stream.event.recordingUrl = recordingUrl
|
||||
this.data.stream.event.recordingUrlExpire = Math.floor(Date.now()/1000) + 600
|
||||
return true
|
||||
this.data.event_select.recordingUrl = recordingUrl
|
||||
this.data.event_select.transcoded = transcoded
|
||||
this.data.event_select.eventId = selectedEvent.event_id
|
||||
|
||||
// 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 {
|
||||
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 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
|
||||
@@ -832,14 +930,14 @@ class Camera extends RingPolledDevice {
|
||||
// Set switch target state on received MQTT command message
|
||||
async setLightState(message) {
|
||||
this.debug(`Received set light state ${message}`)
|
||||
const command = message.toLowerCase()
|
||||
const command = message.toUpperCase()
|
||||
|
||||
switch (command) {
|
||||
case 'on':
|
||||
case 'off':
|
||||
case 'ON':
|
||||
case 'OFF':
|
||||
this.data.light.setTime = Math.floor(Date.now()/1000)
|
||||
await this.device.setLight(command === 'on' ? true : false)
|
||||
this.data.light.state = command === 'on' ? 'ON' : 'OFF'
|
||||
await this.device.setLight(command === 'ON' ? true : false)
|
||||
this.data.light.state = command
|
||||
this.mqttPublish(this.entity.light.state_topic, this.data.light.state)
|
||||
break;
|
||||
default:
|
||||
@@ -922,28 +1020,31 @@ class Camera extends RingPolledDevice {
|
||||
setLiveStreamState(message) {
|
||||
const command = message.toLowerCase()
|
||||
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) {
|
||||
case 'on':
|
||||
// Stream was manually started, create a dummy, audio only
|
||||
// RTSP source stream to trigger stream startup and keep it active
|
||||
this.startKeepaliveStream()
|
||||
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':
|
||||
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 = {
|
||||
deviceId: this.deviceId,
|
||||
deviceName: this.device.name
|
||||
}
|
||||
utils.event.emit('stop_livestream', streamData)
|
||||
this.data.stream.live.worker.postMessage({ command: 'stop' })
|
||||
} else {
|
||||
this.data.stream.live.status = 'inactive'
|
||||
this.publishStreamState()
|
||||
@@ -953,22 +1054,23 @@ class Camera extends RingPolledDevice {
|
||||
this.debug(`Received unknown command for live stream`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setEventStreamState(message) {
|
||||
const command = message.toLowerCase()
|
||||
this.debug(`Received set event stream state ${message}`)
|
||||
switch (command) {
|
||||
case 'on':
|
||||
this.debug(`Event stream can only be started on-demand!`)
|
||||
break;
|
||||
case 'on-demand':
|
||||
if (command.startsWith('on-demand')) {
|
||||
if (this.data.stream.event.status === 'active' || this.data.stream.event.status === 'activating') {
|
||||
this.publishStreamState()
|
||||
} else {
|
||||
this.data.stream.event.status = 'activating'
|
||||
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;
|
||||
case 'off':
|
||||
if (this.data.stream.event.session) {
|
||||
@@ -982,6 +1084,7 @@ class Camera extends RingPolledDevice {
|
||||
this.debug(`Received unknown command for event stream`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set Stream Select Option
|
||||
async setEventSelect(message) {
|
||||
@@ -992,13 +1095,10 @@ class Camera extends RingPolledDevice {
|
||||
}
|
||||
this.data.event_select.state = message
|
||||
this.updateDeviceState()
|
||||
if (await this.updateEventStreamUrl()) {
|
||||
await this.updateEventStreamUrl()
|
||||
this.publishEventSelectState()
|
||||
}
|
||||
} else {
|
||||
this.debug('Received invalid value for event stream')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Camera
|
@@ -1,7 +1,7 @@
|
||||
const RingPolledDevice = require('./base-polled-device')
|
||||
const utils = require( '../lib/utils' )
|
||||
import RingPolledDevice from './base-polled-device.js'
|
||||
import utils from '../lib/utils.js'
|
||||
|
||||
class Chime extends RingPolledDevice {
|
||||
export default class Chime extends RingPolledDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'chime')
|
||||
|
||||
@@ -13,7 +13,12 @@ class Chime extends RingPolledDevice {
|
||||
snooze_minutes: savedState?.snooze_minutes ? savedState.snooze_minutes : 1440,
|
||||
snooze_minutes_remaining: Math.floor(this.device.data.do_not_disturb.seconds_left/60),
|
||||
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
|
||||
@@ -44,6 +49,13 @@ class Chime extends RingPolledDevice {
|
||||
component: 'switch',
|
||||
icon: 'hass:bell-ring'
|
||||
},
|
||||
...this.device.deviceType.startsWith('chime_pro') ? {
|
||||
nightlight_enabled: {
|
||||
component: 'switch',
|
||||
icon: "mdi:lightbulb-night",
|
||||
attributes: true
|
||||
}
|
||||
} : {},
|
||||
info: {
|
||||
component: 'sensor',
|
||||
device_class: 'timestamp',
|
||||
@@ -77,6 +89,8 @@ class Chime extends RingPolledDevice {
|
||||
const volumeState = this.device.data.settings.volume
|
||||
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 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
|
||||
if (volumeState !== this.data.volume || isPublish) {
|
||||
@@ -94,6 +108,19 @@ class Chime extends RingPolledDevice {
|
||||
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
|
||||
if (isPublish) {
|
||||
this.mqttPublish(this.entity.snooze_minutes.state_topic, this.data.snooze_minutes.toString())
|
||||
@@ -107,16 +134,26 @@ class Chime extends RingPolledDevice {
|
||||
async publishAttributes() {
|
||||
const deviceHealth = await this.device.getHealth()
|
||||
if (deviceHealth) {
|
||||
const attributes = {}
|
||||
attributes.wirelessNetwork = deviceHealth.wifi_name
|
||||
attributes.wirelessSignal = deviceHealth.latest_signal_strength
|
||||
attributes.firmwareStatus = deviceHealth.firmware
|
||||
attributes.lastUpdate = deviceHealth.updated_at.slice(0,-6)+"Z"
|
||||
const attributes = {
|
||||
wirelessNetwork: deviceHealth.wifi_name,
|
||||
wirelessSignal: deviceHealth.latest_signal_strength,
|
||||
firmwareStatus: deviceHealth.firmware,
|
||||
lastUpdate: deviceHealth.updated_at.slice(0,-6)+"Z"
|
||||
}
|
||||
this.mqttPublish(this.entity.info.state_topic, JSON.stringify(attributes), 'attr')
|
||||
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
|
||||
processCommand(command, message) {
|
||||
switch (command) {
|
||||
@@ -135,6 +172,9 @@ class Chime extends RingPolledDevice {
|
||||
case 'play_motion_sound/command':
|
||||
this.playSound(message, 'motion')
|
||||
break;
|
||||
case 'nightlight_enabled/command':
|
||||
this.setNightlightState(message)
|
||||
break;
|
||||
default:
|
||||
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!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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!')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'CO Alarm'
|
||||
@@ -21,5 +21,3 @@ class CoAlarm extends RingSocketDevice {
|
||||
this.publishAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CoAlarm
|
@@ -1,7 +1,7 @@
|
||||
const RingSocketDevice = require('./base-socket-device')
|
||||
const utils = require( '../lib/utils' )
|
||||
import RingSocketDevice from './base-socket-device.js'
|
||||
import utils from '../lib/utils.js'
|
||||
|
||||
class Fan extends RingSocketDevice {
|
||||
export default class Fan extends RingSocketDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Fan Control'
|
||||
@@ -131,5 +131,3 @@ class Fan extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Fan
|
||||
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Flood & Freeze Sensor'
|
||||
@@ -25,5 +25,3 @@ class FloodFreezeSensor extends RingSocketDevice {
|
||||
this.publishAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = FloodFreezeSensor
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Security Keypad'
|
||||
@@ -50,5 +50,3 @@ class Keypad extends RingSocketDevice {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Keypad
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Lock'
|
||||
@@ -52,5 +52,3 @@ class Lock extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Lock
|
@@ -1,7 +1,7 @@
|
||||
const RingPolledDevice = require('./base-polled-device')
|
||||
const utils = require( '../lib/utils' )
|
||||
import RingPolledDevice from './base-polled-device.js'
|
||||
import utils from '../lib/utils.js'
|
||||
|
||||
class ModesPanel extends RingPolledDevice {
|
||||
export default class ModesPanel extends RingPolledDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'alarm', 'disable')
|
||||
this.deviceData.mdl = 'Mode Control Panel'
|
||||
@@ -105,5 +105,3 @@ class ModesPanel extends RingPolledDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ModesPanel
|
||||
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Dimming Light'
|
||||
@@ -63,5 +63,3 @@ class MultiLevelSwitch extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MultiLevelSwitch
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm', 'acStatus')
|
||||
this.deviceData.mdl = 'Z-Wave Range Extender'
|
||||
@@ -12,5 +12,3 @@ class RangeExtender extends RingSocketDevice {
|
||||
this.publishAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RangeExtender
|
@@ -1,9 +1,9 @@
|
||||
const RingSocketDevice = require('./base-socket-device')
|
||||
const { allAlarmStates, RingDeviceType } = require('ring-client-api')
|
||||
const utils = require( '../lib/utils' )
|
||||
const state = require('../lib/state')
|
||||
import RingSocketDevice from './base-socket-device.js'
|
||||
import { allAlarmStates, RingDeviceType } from 'ring-client-api'
|
||||
import utils from '../lib/utils.js'
|
||||
import state from '../lib/state.js'
|
||||
|
||||
class SecurityPanel extends RingSocketDevice {
|
||||
export default class SecurityPanel extends RingSocketDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'alarm', 'alarmState')
|
||||
this.deviceData.mdl = 'Alarm Control Panel'
|
||||
@@ -20,7 +20,7 @@ class SecurityPanel extends RingSocketDevice {
|
||||
icon: 'mdi:alarm-light',
|
||||
name: `${this.device.location.name} Siren`
|
||||
},
|
||||
...utils.config.enable_panic ? {
|
||||
...utils.config().enable_panic ? {
|
||||
police: {
|
||||
component: 'switch',
|
||||
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'
|
||||
this.mqttPublish(this.entity.siren.state_topic, sirenState)
|
||||
|
||||
if (utils.config.enable_panic) {
|
||||
if (utils.config().enable_panic) {
|
||||
let policeState = 'OFF'
|
||||
let fireState = 'OFF'
|
||||
const alarmState = this.device.data.alarmInfo ? this.device.data.alarmInfo.state : ''
|
||||
@@ -268,5 +268,3 @@ class SecurityPanel extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SecurityPanel
|
||||
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = (this.device.data.deviceType === 'siren.outdoor-strobe') ? 'Outdoor Siren' : 'Siren'
|
||||
@@ -84,5 +84,3 @@ class Siren extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Siren
|
||||
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Smoke Alarm'
|
||||
@@ -24,5 +24,3 @@ class SmokeAlarm extends RingSocketDevice {
|
||||
this.publishAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SmokeAlarm
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Smoke & CO Listener'
|
||||
@@ -25,5 +25,3 @@ class SmokeCoListener extends RingSocketDevice {
|
||||
this.publishAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SmokeCoListener
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = (this.device.data.categoryId === 2) ? 'Light' : 'Switch'
|
||||
@@ -43,5 +43,3 @@ class Switch extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Switch
|
@@ -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) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Temperature Sensor'
|
||||
@@ -19,5 +19,3 @@ class TemperatureSensor extends RingSocketDevice {
|
||||
this.publishAttributes()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TemperatureSensor
|
@@ -1,8 +1,8 @@
|
||||
const RingSocketDevice = require('./base-socket-device')
|
||||
const { RingDeviceType } = require('ring-client-api')
|
||||
const utils = require( '../lib/utils' )
|
||||
import RingSocketDevice from './base-socket-device.js'
|
||||
import { RingDeviceType } from 'ring-client-api'
|
||||
import utils from '../lib/utils.js'
|
||||
|
||||
class Thermostat extends RingSocketDevice {
|
||||
export default class Thermostat extends RingSocketDevice {
|
||||
constructor(deviceInfo) {
|
||||
super(deviceInfo, 'alarm')
|
||||
this.deviceData.mdl = 'Thermostat'
|
||||
@@ -273,5 +273,3 @@ class Thermostat extends RingSocketDevice {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Thermostat
|
@@ -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
|
||||
**!!!!! 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.
|
||||
|
@@ -1,9 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs')
|
||||
const writeFileAtomic = require('write-file-atomic')
|
||||
const { createHash, randomBytes } = require('crypto')
|
||||
const { RingRestClient } = require('./node_modules/ring-client-api/lib/api/rest-client')
|
||||
const { requestInput } = require('./node_modules/ring-client-api/lib/api/util')
|
||||
import fs from 'fs'
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { readFile } from 'fs/promises'
|
||||
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() {
|
||||
let generatedToken
|
||||
@@ -37,16 +40,16 @@ const main = async() => {
|
||||
// If running in Docker set state file path as appropriate
|
||||
const stateFile = (fs.existsSync('/etc/cont-init.d/ring-mqtt.sh'))
|
||||
? '/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'))
|
||||
? '/data/config.json'
|
||||
: require('path').dirname(require.main.filename)+'/config.json'
|
||||
: dirname(fileURLToPath(new URL(import.meta.url)))+'/config.json'
|
||||
|
||||
if (fs.existsSync(stateFile)) {
|
||||
console.log('Reading latest data from state file: '+stateFile)
|
||||
try {
|
||||
stateData = require(stateFile)
|
||||
stateData = JSON.parse(await readFile(stateFile))
|
||||
} catch(err) {
|
||||
console.log(err.message)
|
||||
console.log('Saved state file '+stateFile+' exist but could not be parsed!')
|
||||
@@ -75,6 +78,8 @@ const main = async() => {
|
||||
console.log(err)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(configFile)) {
|
||||
try {
|
||||
const configData = {
|
||||
"mqtt_url": "mqtt://localhost:1883",
|
||||
"mqtt_options": "",
|
||||
@@ -86,13 +91,12 @@ const main = async() => {
|
||||
"enable_panic": false,
|
||||
"hass_topic": "homeassistant/status",
|
||||
"ring_topic": "ring",
|
||||
"location_ids": [
|
||||
""
|
||||
]
|
||||
"location_ids": []
|
||||
}
|
||||
|
||||
if (!fs.existsSync(configFile)) {
|
||||
try {
|
||||
const mqttUrl = await requestInput('MQTT URL (enter to skip and edit config manually): ')
|
||||
configData.mqtt_url = mqttUrl ? mqttUrl : configData.mqtt_url
|
||||
|
||||
await writeFileAtomic(configFile, JSON.stringify(configData, null, 4))
|
||||
console.log('New config file written to '+configFile)
|
||||
} catch (err) {
|
||||
|
@@ -10,7 +10,7 @@
|
||||
|
||||
# If HASSIO_TOKEN variable exist we are running as addon
|
||||
if [ -v HASSIO_TOKEN ]; then
|
||||
RUNMODE_BANNER="Addon for Home Assistant "
|
||||
RUNMODE_BANNER="Addon for Home Assistant"
|
||||
# Use bashio to get configured branch
|
||||
export BRANCH=$(bashio::config "branch")
|
||||
else
|
||||
|
@@ -45,6 +45,8 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
export FORCE_COLOR=2
|
||||
|
||||
if [ "${BRANCH}" = "latest" ] || [ "${BRANCH}" = "dev" ]; then
|
||||
cd "/app/ring-mqtt-${BRANCH}"
|
||||
else
|
||||
|
123
lib/config.js
123
lib/config.js
@@ -1,32 +1,40 @@
|
||||
const debug = require('debug')('ring-mqtt')
|
||||
const colors = require('colors/safe')
|
||||
const fs = require('fs')
|
||||
const writeFileAtomic = require('write-file-atomic')
|
||||
import chalk from 'chalk'
|
||||
import fs from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
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() {
|
||||
this.data = new Object()
|
||||
process.env.RUNMODE = process.env.hasOwnProperty('RUNMODE') ? process.env.RUNMODE : 'standard'
|
||||
debug(`Detected runmode: ${process.env.RUNMODE}`)
|
||||
this.init()
|
||||
}
|
||||
|
||||
async init() {
|
||||
switch (process.env.RUNMODE) {
|
||||
case 'docker':
|
||||
this.file = '/data/config.json'
|
||||
if (fs.existsSync(this.file)) {
|
||||
this.loadConfigFile()
|
||||
await this.loadConfigFile()
|
||||
} else {
|
||||
// Configure using legacy environment variables
|
||||
this.loadConfigEnv()
|
||||
debug(chalk.red(`No configuration file found at ${this.file}`))
|
||||
debug(chalk.red('Please map a persistent volume to this location and place a configuration file there.'))
|
||||
process.exit(1)
|
||||
}
|
||||
break;
|
||||
case 'addon':
|
||||
this.file = '/data/options.json'
|
||||
this.loadConfigFile()
|
||||
await this.loadConfigFile()
|
||||
this.doMqttDiscovery()
|
||||
break;
|
||||
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.loadConfigFile()
|
||||
await this.loadConfigFile()
|
||||
}
|
||||
|
||||
// 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.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)
|
||||
debug(`MQTT URL: ${mqttURL.protocol}//${mqttURL.username ? mqttURL.username+':********@' : ''}${mqttURL.hostname}:${mqttURL.port}`)
|
||||
}
|
||||
|
||||
// Create CONFIG object from file or envrionment variables
|
||||
loadConfigFile() {
|
||||
async loadConfigFile() {
|
||||
debug('Configuration file: '+this.file)
|
||||
try {
|
||||
this.data = require(this.file)
|
||||
this.data = JSON.parse(await readFile(this.file))
|
||||
} catch (err) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Parse the MQTT URL and resolve any auto configuration
|
||||
const mqttURL = new URL(this.data.mqtt_url)
|
||||
@@ -130,74 +119,8 @@ class Config {
|
||||
this.data.mqtt_url = mqttURL.href
|
||||
} catch (err) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
@@ -1,9 +1,10 @@
|
||||
const debug = require('debug')('ring-mqtt')
|
||||
const colors = require('colors/safe')
|
||||
const utils = require('./utils')
|
||||
const ring = require('./ring')
|
||||
import chalk from 'chalk'
|
||||
import utils from './utils.js'
|
||||
import ring from './ring.js'
|
||||
import debugModule from 'debug'
|
||||
const debug = debugModule('ring-mqtt')
|
||||
|
||||
class ExitHandler {
|
||||
export default new class ExitHandler {
|
||||
constructor() {
|
||||
this.init()
|
||||
}
|
||||
@@ -14,8 +15,8 @@ class ExitHandler {
|
||||
process.on('SIGINT', this.processExit.bind(null, 0))
|
||||
process.on('SIGTERM', this.processExit.bind(null, 0))
|
||||
process.on('uncaughtException', (err) => {
|
||||
debug(colors.red('ERROR - Uncaught Exception'))
|
||||
console.log(colors.red(err))
|
||||
debug(chalk.red('ERROR - Uncaught Exception'))
|
||||
debug(chalk.red(err))
|
||||
this.processExit(2)
|
||||
})
|
||||
process.on('unhandledRejection', (err) => {
|
||||
@@ -24,11 +25,11 @@ class ExitHandler {
|
||||
case /token is not valid/.test(err.message):
|
||||
case /https:\/\/github.com\/dgreif\/ring\/wiki\/Refresh-Tokens/.test(err.message):
|
||||
case /error: access_denied/.test(err.message):
|
||||
debug(colors.yellow(err.message))
|
||||
debug(chalk.yellow(err.message))
|
||||
break;
|
||||
default:
|
||||
debug(colors.yellow('WARNING - Unhandled Promise Rejection'))
|
||||
console.log(colors.yellow(err))
|
||||
debug(chalk.yellow('WARNING - Unhandled Promise Rejection'))
|
||||
debug(chalk.yellow(err))
|
||||
break;
|
||||
}
|
||||
})
|
||||
@@ -38,7 +39,7 @@ class ExitHandler {
|
||||
async processExit(exitCode) {
|
||||
await utils.sleep(1)
|
||||
debug('The ring-mqtt process is shutting down...')
|
||||
await ring.rssShutdown()
|
||||
await ring.go2rtcShutdown()
|
||||
if (ring.devices.length > 0) {
|
||||
debug('Setting all devices offline...')
|
||||
await utils.sleep(1)
|
||||
@@ -54,5 +55,3 @@ class ExitHandler {
|
||||
process.exit()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ExitHandler()
|
108
lib/go2rtc.js
Normal file
108
lib/go2rtc.js
Normal 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
|
||||
}
|
||||
}
|
@@ -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 }})
|
||||
}
|
||||
}
|
||||
})
|
37
lib/main.js
37
lib/main.js
@@ -1,15 +1,16 @@
|
||||
require('./exithandler')
|
||||
require('./mqtt')
|
||||
const config = require('./config')
|
||||
const state = require('./state')
|
||||
const ring = require('./ring')
|
||||
const debug = require('debug')('ring-mqtt')
|
||||
const colors = require('colors/safe')
|
||||
const utils = require('./utils')
|
||||
const tokenApp = require('./tokenapp')
|
||||
const isOnline = require('is-online')
|
||||
import exithandler from './exithandler.js'
|
||||
import mqtt from './mqtt.js'
|
||||
import config from './config.js'
|
||||
import state from './state.js'
|
||||
import ring from './ring.js'
|
||||
import utils from './utils.js'
|
||||
import tokenApp from './tokenapp.js'
|
||||
import chalk from 'chalk'
|
||||
import isOnline from 'is-online'
|
||||
import debugModule from 'debug'
|
||||
const debug = debugModule('ring-mqtt')
|
||||
|
||||
class Main {
|
||||
export default new class Main {
|
||||
constructor() {
|
||||
// Hack to suppress spurious message from push-receiver during startup
|
||||
console.warn = (data) => {
|
||||
@@ -36,29 +37,27 @@ class Main {
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
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(colors.red('or wait 60 seconds to automatically retry authentication using the existing token'))
|
||||
debug(chalk.red('Failed to connect to Ring API using saved token, generate a new token using the Web UI'))
|
||||
debug(chalk.red('or wait 60 seconds to automatically retry authentication using the existing token'))
|
||||
tokenApp.start()
|
||||
await utils.sleep(60)
|
||||
if (!ring.client) {
|
||||
debug(colors.yellow('Retrying authentication with existing saved token...'))
|
||||
debug(chalk.yellow('Retrying authentication with existing saved token...'))
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
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()
|
43
lib/mqtt.js
43
lib/mqtt.js
@@ -1,21 +1,23 @@
|
||||
const mqttApi = require('mqtt')
|
||||
const debug = require('debug')('ring-mqtt')
|
||||
const colors = require('colors/safe')
|
||||
const utils = require('./utils')
|
||||
const fs = require('fs')
|
||||
const parseArgs = require('minimist')
|
||||
const aedes = require('aedes')()
|
||||
const net = require('net')
|
||||
import mqttApi from 'mqtt'
|
||||
import chalk from 'chalk'
|
||||
import utils from './utils.js'
|
||||
import fs from 'fs'
|
||||
import parseArgs from 'minimist'
|
||||
import Aedes from 'aedes'
|
||||
import net from 'net'
|
||||
import debugModule from 'debug'
|
||||
const debug = debugModule('ring-mqtt')
|
||||
|
||||
class Mqtt {
|
||||
export default new class Mqtt {
|
||||
constructor() {
|
||||
this.client = false
|
||||
this.ipcClient = false
|
||||
this.connected = false
|
||||
|
||||
// Start internal broker, used only for inter-process communication (IPC)
|
||||
const mqttServer = net.createServer(aedes.handle)
|
||||
mqttServer.listen(51883, '127.0.0.1')
|
||||
const mqttServer = new Aedes()
|
||||
const netServer = net.createServer(mqttServer.handle)
|
||||
netServer.listen(51883, '127.0.0.1')
|
||||
|
||||
// Configure event listeners
|
||||
utils.event.on('ring_api_state', async (state) => {
|
||||
@@ -49,10 +51,10 @@ class Mqtt {
|
||||
async init() {
|
||||
try {
|
||||
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
|
||||
try {
|
||||
const mqttConfigOptions = parseArgs(utils.config.mqtt_options.split(','))
|
||||
const mqttConfigOptions = parseArgs(utils.config().mqtt_options.split(','))
|
||||
Object.keys(mqttConfigOptions).forEach(key => {
|
||||
switch (key) {
|
||||
// For any of the file based options read the file into the option property
|
||||
@@ -74,13 +76,13 @@ class Mqtt {
|
||||
mqttOptions = mqttConfigOptions
|
||||
} catch(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...')
|
||||
|
||||
// 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
|
||||
this.ipcClient = await mqttApi.connect('mqtt://127.0.0.1:51883', {})
|
||||
@@ -88,12 +90,12 @@ class Mqtt {
|
||||
this.start()
|
||||
|
||||
// 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('hassio/status')
|
||||
} catch (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)
|
||||
}
|
||||
}
|
||||
@@ -126,7 +128,7 @@ class Mqtt {
|
||||
// Process subscribed MQTT messages from subscribed command topics
|
||||
this.client.on('message', (topic, message) => {
|
||||
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)
|
||||
} else {
|
||||
utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message)
|
||||
@@ -135,10 +137,7 @@ class Mqtt {
|
||||
|
||||
// Process MQTT messages from the IPC broker
|
||||
this.ipcClient.on('message', (topic, message) => {
|
||||
message = message.toString()
|
||||
utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message)
|
||||
utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message.toString())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Mqtt()
|
145
lib/ring.js
145
lib/ring.js
@@ -1,33 +1,33 @@
|
||||
const { RingApi, RingDeviceType, RingCamera, RingChime } = require('ring-client-api')
|
||||
const debug = require('debug')('ring-mqtt')
|
||||
const colors = require('colors/safe')
|
||||
const utils = require('./utils')
|
||||
const rss = require('./rtsp-simple-server')
|
||||
const streamWorkers = require('./stream-workers')
|
||||
const BaseStation = require('../devices/base-station')
|
||||
const Beam = require('../devices/beam')
|
||||
const BeamOutdoorPlug = require('../devices/beam-outdoor-plug')
|
||||
const BinarySensor = require('../devices/binary-sensor')
|
||||
const Bridge = require('../devices/bridge')
|
||||
const Camera = require('../devices/camera')
|
||||
const CoAlarm = require('../devices/co-alarm')
|
||||
const Chime = require('../devices/chime')
|
||||
const Fan = require('../devices/fan')
|
||||
const FloodFreezeSensor = require('../devices/flood-freeze-sensor')
|
||||
const Keypad = require('../devices/keypad')
|
||||
const Lock = require('../devices/lock')
|
||||
const ModesPanel = require('../devices/modes-panel')
|
||||
const MultiLevelSwitch = require('../devices/multi-level-switch')
|
||||
const RangeExtender = require('../devices/range-extender')
|
||||
const SecurityPanel = require('../devices/security-panel')
|
||||
const Siren = require('../devices/siren')
|
||||
const SmokeAlarm = require('../devices/smoke-alarm')
|
||||
const SmokeCoListener = require('../devices/smoke-co-listener')
|
||||
const Switch = require('../devices/switch')
|
||||
const TemperatureSensor = require('../devices/temperature-sensor')
|
||||
const Thermostat = require('../devices/thermostat')
|
||||
import { RingApi, RingDeviceType, RingCamera, RingChime } from 'ring-client-api'
|
||||
import chalk from 'chalk'
|
||||
import utils from './utils.js'
|
||||
import go2rtc from './go2rtc.js'
|
||||
import BaseStation from '../devices/base-station.js'
|
||||
import Beam from '../devices/beam.js'
|
||||
import BeamOutdoorPlug from '../devices/beam-outdoor-plug.js'
|
||||
import BinarySensor from '../devices/binary-sensor.js'
|
||||
import Bridge from '../devices/bridge.js'
|
||||
import Camera from '../devices/camera.js'
|
||||
import CoAlarm from '../devices/co-alarm.js'
|
||||
import Chime from '../devices/chime.js'
|
||||
import Fan from '../devices/fan.js'
|
||||
import FloodFreezeSensor from '../devices/flood-freeze-sensor.js'
|
||||
import Keypad from '../devices/keypad.js'
|
||||
import Lock from '../devices/lock.js'
|
||||
import ModesPanel from '../devices/modes-panel.js'
|
||||
import MultiLevelSwitch from '../devices/multi-level-switch.js'
|
||||
import RangeExtender from '../devices/range-extender.js'
|
||||
import SecurityPanel from '../devices/security-panel.js'
|
||||
import Siren from '../devices/siren.js'
|
||||
import SmokeAlarm from '../devices/smoke-alarm.js'
|
||||
import SmokeCoListener from '../devices/smoke-co-listener.js'
|
||||
import Switch from '../devices/switch.js'
|
||||
import TemperatureSensor from '../devices/temperature-sensor.js'
|
||||
import Thermostat from '../devices/thermostat.js'
|
||||
import debugModule from 'debug'
|
||||
const debug = debugModule('ring-mqtt')
|
||||
|
||||
class RingMqtt {
|
||||
export default new class RingMqtt {
|
||||
constructor() {
|
||||
this.locations = new Array()
|
||||
this.devices = new Array()
|
||||
@@ -74,7 +74,7 @@ class RingMqtt {
|
||||
// This usually indicates a Ring service outage impacting authentication
|
||||
setInterval(() => {
|
||||
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._authPromise = undefined
|
||||
}
|
||||
@@ -92,8 +92,8 @@ class RingMqtt {
|
||||
await this.client.getProfile()
|
||||
debug(`Successfully re-established connection to Ring API using generated refresh token`)
|
||||
} catch (error) {
|
||||
debug(colors.brightYellow(error.message))
|
||||
debug(colors.brightYellow(`Failed to re-establish connection to Ring API using generated refresh token`))
|
||||
debug(chalk.yellowBright(error.message))
|
||||
debug(chalk.yellowBright(`Failed to re-establish connection to Ring API using generated refresh token`))
|
||||
|
||||
}
|
||||
} else {
|
||||
@@ -101,9 +101,9 @@ class RingMqtt {
|
||||
refreshToken: this.refreshToken,
|
||||
systemId: state.data.systemId,
|
||||
controlCenterDisplayName: (process.env.RUNMODE === 'addon') ? 'ring-mqtt-addon' : 'ring-mqtt',
|
||||
...utils.config.enable_cameras ? { cameraStatusPollingSeconds: 20 } : {},
|
||||
...utils.config.enable_modes ? { locationModePollingSeconds: 20 } : {},
|
||||
...!(utils.config.location_ids === undefined || utils.config.location_ids == 0) ? { locationIds: utils.config.location_ids } : {}
|
||||
...utils.config().enable_cameras ? { cameraStatusPollingSeconds: 20 } : {},
|
||||
...utils.config().enable_modes ? { locationModePollingSeconds: 20 } : {},
|
||||
...!(utils.config().location_ids === undefined || utils.config().location_ids == 0) ? { locationIds: utils.config().location_ids } : {}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -124,8 +124,8 @@ class RingMqtt {
|
||||
})
|
||||
} catch(error) {
|
||||
this.client = false
|
||||
debug(colors.brightYellow(error.message))
|
||||
debug(colors.brightYellow(`Failed to establish connection to Ring API using ${generatedToken ? 'generated' : 'saved'} refresh token`))
|
||||
debug(chalk.yellowBright(error.message))
|
||||
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
|
||||
const locations = await this.client.getLocations()
|
||||
|
||||
debug(colors.green('-'.repeat(90)))
|
||||
debug(colors.white('This account has access to the following locations:'))
|
||||
debug(chalk.green('-'.repeat(90)))
|
||||
debug(chalk.white('This account has access to the following locations:'))
|
||||
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(colors.brightYellow('IMPORTANT: ')+colors.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(colors.white(' published! '))
|
||||
debug(chalk.yellowBright('IMPORTANT: ')+chalk.white('If *ANY* alarm or smart lighting hubs at these locations are *OFFLINE* '))
|
||||
debug(chalk.white(' the device discovery process below will hang and no devices will be '))
|
||||
debug(chalk.white(' published! '))
|
||||
debug(' '.repeat(90))
|
||||
debug(colors.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(colors.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(' '.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(chalk.white(' If the message "Device Discovery Complete!" is not logged below, please'))
|
||||
debug(chalk.white(' carefully check the Ring app for any hubs or smart lighting devices '))
|
||||
debug(chalk.white(' that are in offline state and either remove them from the location or '))
|
||||
debug(chalk.white(' bring them back online prior to restarting ring-mqtt. '))
|
||||
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
|
||||
for (const location of locations) {
|
||||
@@ -207,21 +206,15 @@ class RingMqtt {
|
||||
let chimes = new Array()
|
||||
const unsupportedDevices = new Array()
|
||||
|
||||
/*
|
||||
location.onDeviceDataUpdate.subscribe((data) => {
|
||||
console.log(data)
|
||||
})
|
||||
*/
|
||||
|
||||
debug(colors.green('-'.repeat(90)))
|
||||
debug(colors.white('Starting Device Discovery...'))
|
||||
debug(chalk.green('-'.repeat(90)))
|
||||
debug(chalk.white('Starting Device Discovery...'))
|
||||
debug(' '.repeat(90))
|
||||
|
||||
// If new location, set custom properties and add to location list
|
||||
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 {
|
||||
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.isConnected = false
|
||||
this.locations.push(location)
|
||||
@@ -229,14 +222,14 @@ class RingMqtt {
|
||||
|
||||
// Get all location devices and, if camera support is enabled, cameras and chimes
|
||||
const devices = await location.getDevices()
|
||||
if (utils.config.enable_cameras) {
|
||||
if (utils.config().enable_cameras) {
|
||||
cameras = await location.cameras
|
||||
chimes = await location.chimes
|
||||
}
|
||||
const allDevices = [...devices, ...cameras, ...chimes]
|
||||
|
||||
// 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({
|
||||
deviceType: 'location.mode',
|
||||
location: location,
|
||||
@@ -271,36 +264,34 @@ class RingMqtt {
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
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(colors.white(`${indent}${(keys > 1) ? '│ ' : ' '}`)+colors.gray(ringDevice.childDevices[key].deviceType))
|
||||
debug(chalk.white(`${indent}${(keys > 1) ? '├─: ' : '└─: '}`)+chalk.green(`${ringDevice.childDevices[key].name}`)+chalk.cyan(` (${ringDevice.childDevices[key].id})`))
|
||||
debug(chalk.white(`${indent}${(keys > 1) ? '│ ' : ' '}`)+chalk.gray(ringDevice.childDevices[key].deviceType))
|
||||
keys--
|
||||
})
|
||||
} else {
|
||||
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
|
||||
unsupportedDevices.forEach(deviceType => {
|
||||
debug(colors.yellow(` Unsupported device: ${deviceType}`))
|
||||
debug(chalk.yellow(` Unsupported device: ${deviceType}`))
|
||||
})
|
||||
}
|
||||
debug(' '.repeat(90))
|
||||
debug(colors.white('Device Discovery Complete!'))
|
||||
debug(colors.green('-'.repeat(90)))
|
||||
debug(chalk.white('Device Discovery Complete!'))
|
||||
debug(chalk.green('-'.repeat(90)))
|
||||
await utils.sleep(2)
|
||||
const cameras = await this.devices.filter(d => d.device instanceof RingCamera)
|
||||
if (cameras.length > 0 && !rss.started) {
|
||||
await streamWorkers.init()
|
||||
await utils.sleep(1)
|
||||
await rss.init(cameras)
|
||||
if (cameras.length > 0 && !go2rtc.started) {
|
||||
await go2rtc.init(cameras)
|
||||
}
|
||||
await utils.sleep(3)
|
||||
}
|
||||
@@ -404,9 +395,7 @@ class RingMqtt {
|
||||
}
|
||||
}
|
||||
|
||||
async rssShutdown() {
|
||||
await rss.shutdown()
|
||||
async go2rtcShutdown() {
|
||||
await go2rtc.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RingMqtt()
|
@@ -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()
|
34
lib/state.js
34
lib/state.js
@@ -1,11 +1,15 @@
|
||||
const debug = require('debug')('ring-mqtt')
|
||||
const colors = require('colors/safe')
|
||||
const fs = require('fs')
|
||||
const utils = require( '../lib/utils' )
|
||||
const { createHash, randomBytes } = require('crypto')
|
||||
const writeFileAtomic = require('write-file-atomic')
|
||||
import chalk from 'chalk'
|
||||
import fs from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
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() {
|
||||
this.valid = false
|
||||
this.writeScheduled = false
|
||||
@@ -19,8 +23,8 @@ class State {
|
||||
async init(config) {
|
||||
this.config = config
|
||||
this.file = (process.env.RUNMODE === 'standard')
|
||||
? require('path').dirname(require.main.filename)+'/ring-state.json'
|
||||
: this.file = '/data/ring-state.json'
|
||||
? dirname(fileURLToPath(new URL('.', import.meta.url)))+'/ring-state.json'
|
||||
: '/data/ring-state.json'
|
||||
await this.loadStateData()
|
||||
}
|
||||
|
||||
@@ -28,7 +32,7 @@ class State {
|
||||
if (fs.existsSync(this.file)) {
|
||||
debug('Reading latest data from state file: '+this.file)
|
||||
try {
|
||||
this.data = require(this.file)
|
||||
this.data = JSON.parse(await readFile(this.file))
|
||||
this.valid = true
|
||||
if (!this.data.hasOwnProperty('systemId')) {
|
||||
this.data.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex'))
|
||||
@@ -39,7 +43,7 @@ class State {
|
||||
}
|
||||
} catch (err) {
|
||||
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()
|
||||
}
|
||||
} else {
|
||||
@@ -50,13 +54,13 @@ class State {
|
||||
async initStateData() {
|
||||
this.data.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex'))
|
||||
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)
|
||||
debug ('Removing legacy ring_token value from config file...')
|
||||
delete this.config.data.ring_token
|
||||
await this.config.updateConfigFile()
|
||||
} 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))
|
||||
debug('Successfully saved updated state file: '+this.file)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
@@ -97,5 +101,3 @@ class State {
|
||||
return this.data.devices
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new State()
|
@@ -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()
|
142
lib/streaming/peer-connection.js
Normal file
142
lib/streaming/peer-connection.js
Normal 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()
|
||||
}
|
||||
}
|
147
lib/streaming/ring-edge-connection.js
Normal file
147
lib/streaming/ring-edge-connection.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
96
lib/streaming/streaming-connection-base.js
Normal file
96
lib/streaming/streaming-connection-base.js
Normal 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?.()
|
||||
}
|
||||
}
|
132
lib/streaming/streaming-session.js
Normal file
132
lib/streaming/streaming-session.js
Normal 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()
|
||||
}
|
||||
}
|
15
lib/streaming/subscribed.js
Normal file
15
lib/streaming/subscribed.js
Normal 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())
|
||||
}
|
||||
}
|
61
lib/streaming/webrtc-connection.js
Normal file
61
lib/streaming/webrtc-connection.js
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
@@ -1,10 +1,13 @@
|
||||
const { RingRestClient } = require('../node_modules/ring-client-api/lib/api/rest-client')
|
||||
const debug = require('debug')('ring-mqtt')
|
||||
const utils = require('./utils')
|
||||
const express = require('express')
|
||||
const bodyParser = require("body-parser")
|
||||
import { RingRestClient } from '../node_modules/ring-client-api/lib/rest-client.js'
|
||||
import utils from './utils.js'
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
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() {
|
||||
this.app = express()
|
||||
this.listener = false
|
||||
@@ -34,7 +37,7 @@ class TokenApp {
|
||||
return
|
||||
}
|
||||
|
||||
const webdir = __dirname+'/../web'
|
||||
const webdir = dirname(fileURLToPath(new URL('.', import.meta.url)))+'/web'
|
||||
let restClient
|
||||
|
||||
this.listener = this.app.listen(55123, () => {
|
||||
@@ -99,5 +102,3 @@ class TokenApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new TokenApp()
|
||||
|
44
lib/utils.js
44
lib/utils.js
@@ -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 = {
|
||||
mqtt: require('debug')('ring-mqtt'),
|
||||
attr: require('debug')('ring-attr'),
|
||||
disc: require('debug')('ring-disc')
|
||||
mqtt: debugModule('ring-mqtt'),
|
||||
attr: debugModule('ring-attr'),
|
||||
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 {
|
||||
// Define a few helper variables for sharing
|
||||
event = new EventEmitter()
|
||||
config = config.data
|
||||
export default new class Utils {
|
||||
|
||||
constructor() {
|
||||
this.event = new EventEmitter()
|
||||
}
|
||||
|
||||
config() {
|
||||
return config.data
|
||||
}
|
||||
|
||||
// Sleep function (seconds)
|
||||
sleep(sec) {
|
||||
@@ -42,8 +50,8 @@ class Utils {
|
||||
}
|
||||
|
||||
async getHostIp() {
|
||||
const pLookup = promisify(dns.lookup)
|
||||
try {
|
||||
const pLookup = promisify(dns.lookup)
|
||||
return (await pLookup(os.hostname())).address
|
||||
} catch {
|
||||
console.log('Failed to resolve hostname IP address, returning localhost instead')
|
||||
@@ -74,10 +82,12 @@ class Utils {
|
||||
return detectedCores
|
||||
}
|
||||
|
||||
isNumeric(num) {
|
||||
return !isNaN(parseFloat(num)) && isFinite(num);
|
||||
}
|
||||
|
||||
debug(message, debugType) {
|
||||
debugType = debugType ? debugType : 'mqtt'
|
||||
debug[debugType](message)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new Utils()
|
||||
|
3052
package-lock.json
generated
3052
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,20 +1,24 @@
|
||||
{
|
||||
"name": "ring-mqtt",
|
||||
"version": "5.0.5",
|
||||
"version": "5.1.0",
|
||||
"type": "module",
|
||||
"description": "Ring Devices via MQTT",
|
||||
"main": "ring-mqtt.js",
|
||||
"dependencies": {
|
||||
"aedes": "0.48.1",
|
||||
"body-parser": "^1.20.1",
|
||||
"chalk": "^5.2.0",
|
||||
"debug": "^4.3.4",
|
||||
"express": "^4.18.2",
|
||||
"got": "^11.8.5",
|
||||
"ip": "^1.1.8",
|
||||
"is-online": "9.0.1",
|
||||
"write-file-atomic": "^4.0.2",
|
||||
"minimist": "^1.2.6",
|
||||
"mqtt": "4.3.7",
|
||||
"aedes": "0.48.0",
|
||||
"ring-client-api": "^11.3.0"
|
||||
"is-online": "^10.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"minimist": "^1.2.7",
|
||||
"mqtt": "^4.3.7",
|
||||
"ring-client-api": "11.7.1",
|
||||
"write-file-atomic": "^5.0.0",
|
||||
"werift": "^0.18.1",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.19.0"
|
||||
|
@@ -1,2 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('./lib/main')
|
||||
import Main from './lib/main.js'
|
||||
|
@@ -6,27 +6,30 @@
|
||||
# Provides status updates and termintates stream on script exit
|
||||
|
||||
# Required command line arguments
|
||||
client_name=${1} # Friendly name of camera (used for logging)
|
||||
device_id=${2} # Camera device Id
|
||||
type=${3} # Stream type ("live" or "event")
|
||||
base_topic=${4} # Command topic for Camera entity
|
||||
device_id=${1} # Camera device Id
|
||||
type=${2} # Stream type ("live" or "event")
|
||||
base_topic=${3} # 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
|
||||
activated="false"
|
||||
|
||||
json_attribute_topic="${base_topic}stream/attributes"
|
||||
command_topic="${base_topic}stream/command"
|
||||
[[ ${type} = "live" ]] && base_topic="${base_topic}/stream" || base_topic="${base_topic}/event_stream"
|
||||
|
||||
json_attribute_topic="${base_topic}/attributes"
|
||||
command_topic="${base_topic}/command"
|
||||
debug_topic="${base_topic}/debug"
|
||||
|
||||
# Set some colors for debug output
|
||||
red='\033[0;31m'
|
||||
yellow='\033[0;33m'
|
||||
green='\033[0;32m'
|
||||
blue='\033[0;34m'
|
||||
reset='\033[0m'
|
||||
red='\e[0;31m'
|
||||
yellow='\e[0;33m'
|
||||
green='\e[0;32m'
|
||||
blue='\e[0;34m'
|
||||
reset='\e[0m'
|
||||
|
||||
cleanup() {
|
||||
if [ -z ${reason} ]; then
|
||||
# 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"
|
||||
fi
|
||||
# Kill the spawed mosquitto_sub process or it will stay listening forever
|
||||
@@ -34,6 +37,12 @@ cleanup() {
|
||||
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 cleanup INT TERM QUIT
|
||||
|
||||
@@ -51,35 +60,38 @@ while read -u 10 message
|
||||
do
|
||||
# If start message received, publish the command to start stream
|
||||
if [ ${message} = "START" ]; then
|
||||
echo -e "${green}[${client_name}]${reset} 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"
|
||||
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 ${rtsp_pub_url}"
|
||||
else
|
||||
# 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'`
|
||||
case ${stream_state,,} in
|
||||
activating)
|
||||
if [ ${activated} = "false" ]; then
|
||||
echo -e "${green}[${client_name}]${reset} State indicates ${type} stream is activating"
|
||||
logger "State indicates ${type} stream is activating"
|
||||
fi
|
||||
;;
|
||||
active)
|
||||
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"
|
||||
fi
|
||||
;;
|
||||
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'
|
||||
cleanup
|
||||
;;
|
||||
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'
|
||||
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
|
||||
fi
|
||||
|
@@ -19,5 +19,25 @@ if [ ! -d "/app/ring-mqtt-${BRANCH}" ]; then
|
||||
echo "-------------------------------------------------------"
|
||||
else
|
||||
# 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
|
Reference in New Issue
Block a user