mirror of
https://github.com/tsightler/ring-mqtt.git
synced 2025-09-26 21:01:12 +08:00

* Adding camera motion detection enabled status for MQTT messaging. (#174) * Temporarily warn on promise rejections only * Temp hack to test fixes for losing camera ding/motion events * Attempted fix for camera motion/ding events becoming unavailable Co-authored-by: Steve Stevenson <sstevenson72@gmail.com>
746 lines
30 KiB
JavaScript
746 lines
30 KiB
JavaScript
const debug = require('debug')('ring-mqtt')
|
|
const utils = require( '../lib/utils' )
|
|
const clientApi = require('../node_modules/@tsightler/ring-client-api/lib/api/rest-client').clientApi
|
|
const P2J = require('pipe2jpeg')
|
|
const net = require('net');
|
|
const getPort = require('get-port')
|
|
|
|
class Camera {
|
|
constructor(deviceInfo) {
|
|
// Set default properties for camera device object model
|
|
this.camera = deviceInfo.device
|
|
|
|
this.mqttClient = deviceInfo.mqttClient
|
|
this.subscribed = false
|
|
this.availabilityState = 'init'
|
|
this.heartbeat = 3
|
|
this.locationId = this.camera.data.location_id
|
|
this.deviceId = this.camera.data.device_id
|
|
this.config = deviceInfo.CONFIG
|
|
this.publishedLightState = this.camera.hasLight ? 'init' : 'none'
|
|
this.publishedSirenState = this.camera.hasSiren ? 'init' : 'none'
|
|
|
|
// Configure initial snapshot parameters based on device type and app settings
|
|
this.snapshot = {
|
|
motion: false,
|
|
interval: false,
|
|
autoInterval: false,
|
|
imageData: null,
|
|
timestamp: null,
|
|
updating: null
|
|
}
|
|
|
|
if (this.config.snapshot_mode === "motion" || this.config.snapshot_mode === "interval" || this.config.snapshot_mode === "all" ) {
|
|
this.snapshot.motion = (this.config.snapshot_mode === "motion" || this.config.snapshot_mode === "all") ? true : false
|
|
|
|
if (this.config.snapshot_mode === "interval" || this.config.snapshot_mode === "all") {
|
|
this.snapshot.autoInterval = true
|
|
if (this.camera.operatingOnBattery) {
|
|
if (this.camera.data.settings.hasOwnProperty('lite_24x7') && this.camera.data.settings.lite_24x7.enabled) {
|
|
this.snapshot.interval = this.camera.data.settings.lite_24x7.frequency_secs
|
|
} else {
|
|
this.snapshot.interval = 600
|
|
}
|
|
} else {
|
|
this.snapshot.interval = 30
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize livestream parameters
|
|
this.livestream = {
|
|
duration: (this.camera.data.settings.video_settings.hasOwnProperty('clip_length_max') && this.camera.data.settings.video_settings.clip_length_max)
|
|
? this.camera.data.settings.video_settings.clip_length_max
|
|
: 60,
|
|
active: false,
|
|
expires: 0,
|
|
updateSnapshot: false
|
|
}
|
|
|
|
// Sevice data for Home Assistant device registry
|
|
this.deviceData = {
|
|
ids: [ this.deviceId ],
|
|
name: this.camera.name,
|
|
mf: 'Ring',
|
|
mdl: this.camera.model
|
|
}
|
|
|
|
// Create top level MQTT topics
|
|
this.cameraTopic = deviceInfo.CONFIG.ring_topic+'/'+this.locationId+'/camera/'+this.deviceId
|
|
this.availabilityTopic = this.cameraTopic+'/status'
|
|
|
|
// Create properties to store ding states
|
|
this.motion = {
|
|
name: 'motion',
|
|
active_ding: false,
|
|
ding_duration: 180,
|
|
last_ding: 0,
|
|
last_ding_expires: 0,
|
|
last_ding_time: 'none',
|
|
is_person: false
|
|
}
|
|
|
|
if (this.camera.isDoorbot) {
|
|
this.ding = {
|
|
name: 'doorbell',
|
|
active_ding: false,
|
|
ding_duration: 180,
|
|
last_ding: 0,
|
|
last_ding_expires: 0,
|
|
last_ding_time: 'none'
|
|
}
|
|
}
|
|
|
|
// Properties to store state published to MQTT
|
|
// Used to keep from sending state updates on every poll (20 seconds)
|
|
if (this.camera.hasLight) {
|
|
this.publishedLightState = 'unknown'
|
|
}
|
|
|
|
if (this.camera.hasSiren) {
|
|
this.publishedSirenState = 'unknown'
|
|
}
|
|
|
|
this.publishedMotionDetectionStatus = 'unkown'
|
|
}
|
|
|
|
// Publish camera capabilities and state and subscribe to events
|
|
async publish() {
|
|
const debugMsg = (this.availabilityState === 'init') ? 'Publishing new ' : 'Republishing existing '
|
|
debug(debugMsg+'device id: '+this.deviceId)
|
|
|
|
await this.publishCapabilities()
|
|
|
|
// Give Home Assistant time to configure device before sending first state data
|
|
await utils.sleep(2)
|
|
await this.online()
|
|
|
|
// Publish device state and, if new device, subscribe for state updates
|
|
if (!this.subscribed) {
|
|
this.subscribed = true
|
|
|
|
// Update motion properties with most recent historical event data
|
|
const lastMotionEvent = (await this.camera.getEvents({ limit: 1, kind: 'motion'})).events[0]
|
|
const lastMotionDate = (lastMotionEvent && lastMotionEvent.hasOwnProperty('created_at')) ? new Date(lastMotionEvent.created_at) : false
|
|
this.motion.last_ding = lastMotionDate ? Math.floor(lastMotionDate/1000) : 0
|
|
this.motion.last_ding_time = lastMotionDate ? utils.getISOTime(lastMotionDate) : ''
|
|
if (lastMotionEvent && lastMotionEvent.hasOwnProperty('cv_properties')) {
|
|
this.motion.is_person = (lastMotionEvent.cv_properties.detection_type === 'human') ? true : false
|
|
}
|
|
|
|
// Update motion properties with most recent historical event data
|
|
if (this.camera.isDoorbot) {
|
|
const lastDingEvent = (await this.camera.getEvents({ limit: 1, kind: 'ding'})).events[0]
|
|
const lastDingDate = (lastDingEvent && lastDingEvent.hasOwnProperty('created_at')) ? new Date(lastDingEvent.created_at) : false
|
|
this.ding.last_ding = lastDingDate ? Math.floor(lastDingDate/1000) : 0
|
|
this.ding.last_ding_time = lastDingDate ? utils.getISOTime(lastDingDate) : ''
|
|
}
|
|
|
|
// Subscribe to Ding events (all cameras have at least motion events)
|
|
this.camera.onNewDing.subscribe(ding => {
|
|
this.processDing(ding)
|
|
})
|
|
this.publishDingStates()
|
|
|
|
// Subscribe to poll events, default every 20 seconds
|
|
this.camera.onData.subscribe(() => {
|
|
this.publishPolledState()
|
|
})
|
|
|
|
// Publish snapshot if enabled
|
|
if (this.snapshot.motion || this.snapshot.interval > 0) {
|
|
this.refreshSnapshot()
|
|
// If interval based snapshots are enabled, start snapshot refresh loop
|
|
if (this.snapshot.interval > 0) {
|
|
this.scheduleSnapshotRefresh()
|
|
}
|
|
}
|
|
|
|
// Start monitor of availability state for camera
|
|
this.schedulePublishInfo()
|
|
this.monitorCameraConnection()
|
|
} else {
|
|
// Set states to force republish
|
|
this.publishedLightState = this.camera.hasLight ? 'republish' : 'none'
|
|
this.publishedSirenState = this.camera.hasSiren ? 'republish' : 'none'
|
|
|
|
// Republish all camera state data
|
|
this.publishDingStates()
|
|
this.publishPolledState()
|
|
|
|
// Publish snapshot image if any snapshot option is enabled
|
|
if (this.snapshot.motion || this.snapshot.interval) {
|
|
this.publishSnapshot()
|
|
}
|
|
|
|
this.publishInfoState()
|
|
this.publishAvailabilityState()
|
|
}
|
|
}
|
|
|
|
publishCapabilities() {
|
|
// Publish motion sensor feature for camera
|
|
this.publishCapability({
|
|
component: 'binary_sensor',
|
|
suffix: 'Motion',
|
|
className: 'motion',
|
|
attributes: true,
|
|
command: false
|
|
})
|
|
|
|
// Publish info around the motion detection on/off
|
|
this.publishCapability({
|
|
component: 'switch',
|
|
suffix: 'Motion Detection',
|
|
attributes: false,
|
|
command: true
|
|
})
|
|
|
|
// Publish info sensor for camera
|
|
this.publishCapability({
|
|
component: 'sensor',
|
|
suffix: 'Info',
|
|
attributes: false,
|
|
command: false
|
|
})
|
|
|
|
// If doorbell publish doorbell sensor
|
|
if (this.camera.isDoorbot) {
|
|
this.publishCapability({
|
|
component: 'binary_sensor',
|
|
suffix: 'Ding',
|
|
className: 'occupancy',
|
|
attributes: true,
|
|
command: false
|
|
})
|
|
}
|
|
|
|
// If camera has a light publish light component
|
|
if (this.camera.hasLight) {
|
|
this.publishCapability({
|
|
component: 'light',
|
|
suffix: 'Light',
|
|
attributes: false,
|
|
command: true
|
|
})
|
|
}
|
|
|
|
// If camera has a siren publish switch component
|
|
if (this.camera.hasSiren) {
|
|
this.publishCapability({
|
|
component: 'switch',
|
|
suffix: 'Siren',
|
|
attributes: false,
|
|
command: true
|
|
})
|
|
}
|
|
|
|
// If snapshots enabled, publish snapshot capability
|
|
if (this.snapshot.motion || this.snapshot.interval) {
|
|
this.publishCapability({
|
|
component: 'camera',
|
|
suffix: 'Snapshot',
|
|
attributes: true,
|
|
command: false
|
|
})
|
|
|
|
this.publishCapability({
|
|
component: 'number',
|
|
suffix: 'Snapshot Interval',
|
|
attributes: false,
|
|
command: true
|
|
})
|
|
}
|
|
}
|
|
|
|
// Build and publish a Home Assistant MQTT discovery packet for camera capability
|
|
async publishCapability(capability) {
|
|
const capabilityType = capability.suffix.toLowerCase().replace(" ","_")
|
|
const capabilityTopic = this.cameraTopic+'/'+capabilityType
|
|
const configTopic = 'homeassistant/'+capability.component+'/'+this.locationId+'/'+this.deviceId+'_'+capabilityType+'/config'
|
|
|
|
const message = {
|
|
name: this.camera.name+' '+capability.suffix,
|
|
unique_id: this.deviceId+'_'+capabilityType,
|
|
availability_topic: this.availabilityTopic,
|
|
payload_available: 'online',
|
|
payload_not_available: 'offline',
|
|
device: this.deviceData,
|
|
... capability.attributes ? { json_attributes_topic: capabilityTopic+'/attributes' } : {},
|
|
... capability.className ? { device_class: capability.className } : {},
|
|
... capability.command ? { command_topic: capabilityTopic+'/command' } : {}
|
|
}
|
|
|
|
// Subscribe to command topic if required
|
|
if (capability.command) {
|
|
this.mqttClient.subscribe(capabilityTopic+'/command')
|
|
}
|
|
|
|
switch (capabilityType) {
|
|
case 'info':
|
|
// Set the primary state value for info sensors based on power (battery/wired)
|
|
// and connectivity (Wifi/ethernet)
|
|
message.state_topic = capabilityTopic+'/state'
|
|
message.json_attributes_topic = capabilityTopic+'/state'
|
|
message.icon = 'mdi:information-outline'
|
|
const deviceHealth = await Promise.race([this.camera.getHealth(), utils.sleep(5)]).then(function(result) { return result; })
|
|
if (deviceHealth) {
|
|
if (deviceHealth.network_connection && deviceHealth.network_connection === 'ethernet') {
|
|
message.value_template = '{{value_json["wiredNetwork"]}}'
|
|
} else {
|
|
// Device is connected via wifi, track that as primary
|
|
message.value_template = '{{value_json["wirelessSignal"]}}'
|
|
message.unit_of_measurement = 'RSSI'
|
|
}
|
|
}
|
|
break;
|
|
case 'snapshot':
|
|
message.topic = capabilityTopic+'/image'
|
|
break;
|
|
default:
|
|
message.state_topic = capabilityTopic+'/state'
|
|
}
|
|
|
|
debug('HASS config topic: '+configTopic)
|
|
debug(message)
|
|
this.mqttClient.publish(configTopic, JSON.stringify(message), { qos: 1 })
|
|
}
|
|
|
|
// Publish state messages via MQTT with optional debug
|
|
publishMqtt(topic, message, enableDebug) {
|
|
if (enableDebug) debug(topic, message)
|
|
this.mqttClient.publish(topic, message, { qos: 1 })
|
|
}
|
|
|
|
// Process a ding event
|
|
async processDing(ding) {
|
|
// Is it a motion or doorbell ding? (for others we do nothing)
|
|
if (ding.kind !== 'ding' && ding.kind !== 'motion') { return }
|
|
debug('Camera '+this.deviceId+' received '+this[ding.kind].name+' ding at '+Math.floor(ding.now)+', expires in '+ding.expires_in+' seconds')
|
|
|
|
// Is this a new Ding or refresh of active ding?
|
|
const newDing = (!this[ding.kind].active_ding) ? true : false
|
|
this[ding.kind].active_ding = true
|
|
|
|
// Update last_ding, duration and expire time
|
|
this[ding.kind].last_ding = Math.floor(ding.now)
|
|
this[ding.kind].last_ding_time = utils.getISOTime(ding.now*1000)
|
|
this[ding.kind].ding_duration = ding.expires_in
|
|
this[ding.kind].last_ding_expires = this[ding.kind].last_ding+ding.expires_in
|
|
|
|
// If motion ding and snapshots on motion are enabled, publish a new snapshot
|
|
if (ding.kind === 'motion') {
|
|
this[ding.kind].is_person = (ding.detection_type === 'human') ? true : false
|
|
if (this.snapshot.motion) {
|
|
this.refreshSnapshot()
|
|
}
|
|
}
|
|
|
|
// Publish MQTT active sensor state
|
|
// Will republish to MQTT for new dings even if ding is already active
|
|
this.publishDingState(ding.kind)
|
|
|
|
// If new ding, begin expiration loop (only needed for first ding as others just extend time)
|
|
if (newDing) {
|
|
// Loop until current time is > last_ding expires time. Sleeps until
|
|
// estimated expire time, but may loop if new dings increase last_ding_expires
|
|
while (Math.floor(Date.now()/1000) < this[ding.kind].last_ding_expires) {
|
|
const sleeptime = (this[ding.kind].last_ding_expires - Math.floor(Date.now()/1000)) + 1
|
|
await utils.sleep(sleeptime)
|
|
}
|
|
// All dings have expired, set ding state back to false/off and publish
|
|
debug('All '+this[ding.kind].name+' dings for camera '+this.deviceId+' have expired')
|
|
this[ding.kind].active_ding = false
|
|
this.publishDingState(ding.kind)
|
|
}
|
|
}
|
|
|
|
// Publishes all current ding states for this camera
|
|
publishDingStates() {
|
|
this.publishDingState('motion')
|
|
if (this.camera.isDoorbot) {
|
|
this.publishDingState('ding')
|
|
}
|
|
}
|
|
|
|
// Publish ding state and attributes
|
|
publishDingState(dingKind) {
|
|
const dingTopic = this.cameraTopic+'/'+dingKind
|
|
const dingState = this[dingKind].active_ding ? 'ON' : 'OFF'
|
|
const attributes = {}
|
|
if (dingKind === 'motion') {
|
|
attributes.lastMotion = this[dingKind].last_ding
|
|
attributes.lastMotionTime = this[dingKind].last_ding_time
|
|
attributes.personDetected = this[dingKind].is_person
|
|
} else {
|
|
attributes.lastDing = this[dingKind].last_ding
|
|
attributes.lastDingTime = this[dingKind].last_ding_time
|
|
}
|
|
this.publishMqtt(dingTopic+'/state', dingState, true)
|
|
this.publishMqtt(dingTopic+'/attributes', JSON.stringify(attributes), true)
|
|
}
|
|
|
|
// Publish camera state for polled attributes (light/siren state, etc)
|
|
// Writes state to custom property to keep from publishing state except
|
|
// when values change from previous polling interval
|
|
async publishPolledState() {
|
|
// Reset heartbeat counter on every polled state and set device online if not already
|
|
this.heartbeat = 3
|
|
if (this.availabilityState !== 'online') {
|
|
await this.online()
|
|
}
|
|
|
|
if (this.camera.hasLight) {
|
|
const stateTopic = this.cameraTopic+'/light/state'
|
|
if (this.camera.data.led_status !== this.publishedLightState) {
|
|
this.publishMqtt(stateTopic, (this.camera.data.led_status === 'on' ? 'ON' : 'OFF'), true)
|
|
this.publishedLightState = this.camera.data.led_status
|
|
}
|
|
}
|
|
if (this.camera.hasSiren) {
|
|
const stateTopic = this.cameraTopic+'/siren/state'
|
|
const sirenStatus = this.camera.data.siren_status.seconds_remaining > 0 ? 'ON' : 'OFF'
|
|
if (sirenStatus !== this.publishedSirenState) {
|
|
this.publishMqtt(stateTopic, sirenStatus, true)
|
|
this.publishedSirenState = sirenStatus
|
|
}
|
|
}
|
|
|
|
if (this.camera.data && this.camera.data.settings && typeof this.camera.data.settings.motion_detection_enabled !== 'undefined') {
|
|
const stateTopic = this.cameraTopic+'/motion_detection/state'
|
|
const motionDetectionStatus = this.camera.data.settings.motion_detection_enabled === true ? 'ON' : 'OFF'
|
|
if (motionDetectionStatus !== this.publishedMotionDetectionStatus) {
|
|
this.publishMqtt(stateTopic, motionDetectionStatus, true)
|
|
this.publishedMotionDetectionStatus = motionDetectionStatus
|
|
}
|
|
}
|
|
|
|
// Update snapshot frequency in case it's changed
|
|
if (this.snapshot.autoInterval && this.camera.data.settings.hasOwnProperty('lite_24x7')) {
|
|
this.snapshot.interval = this.camera.data.settings.lite_24x7.frequency_secs
|
|
}
|
|
}
|
|
|
|
// Publish device data to info topic
|
|
async publishInfoState() {
|
|
const deviceHealth = await this.camera.getHealth()
|
|
|
|
if (deviceHealth) {
|
|
const attributes = {}
|
|
if (this.camera.hasBattery) {
|
|
attributes.batteryLevel = deviceHealth.battery_percentage
|
|
}
|
|
attributes.firmwareStatus = deviceHealth.firmware
|
|
attributes.lastUpdate = deviceHealth.updated_at.slice(0,-6)+"Z"
|
|
if (deviceHealth.network_connection && deviceHealth.network_connection === 'ethernet') {
|
|
attributes.wiredNetwork = this.camera.data.alerts.connection
|
|
} else {
|
|
attributes.wirelessNetwork = deviceHealth.wifi_name
|
|
attributes.wirelessSignal = deviceHealth.latest_signal_strength
|
|
}
|
|
this.publishMqtt(this.cameraTopic+'/info/state', JSON.stringify(attributes), true)
|
|
}
|
|
}
|
|
|
|
async refreshSnapshot() {
|
|
let newSnapshot
|
|
try {
|
|
newSnapshot = await this.getRefreshedSnapshot()
|
|
} catch(e) {
|
|
debug(e.message)
|
|
}
|
|
if (newSnapshot && newSnapshot === 'SnapFromStream') {
|
|
// Livestream snapshots publish automatically from the stream so just return
|
|
return
|
|
} else if (newSnapshot) {
|
|
this.snapshot.imageData = newSnapshot
|
|
this.snapshot.timestamp = Math.round(Date.now()/1000)
|
|
this.publishSnapshot()
|
|
} else {
|
|
debug('Could not retrieve updated snapshot for camera '+this.deviceId)
|
|
}
|
|
}
|
|
|
|
// Publish snapshot image/metadata
|
|
async publishSnapshot() {
|
|
debug(this.cameraTopic+'/snapshot/image', '<binary_image_data>')
|
|
this.publishMqtt(this.cameraTopic+'/snapshot/image', this.snapshot.imageData)
|
|
this.publishMqtt(this.cameraTopic+'/snapshot/attributes', JSON.stringify({ timestamp: this.snapshot.timestamp }))
|
|
}
|
|
|
|
// This function uses various methods to get a snapshot to work around limitations
|
|
// of Ring API, ring-client-api snapshot caching, battery cameras, etc.
|
|
async getRefreshedSnapshot() {
|
|
if (this.camera.snapshotsAreBlocked) {
|
|
debug('Snapshots are unavailable for camera '+this.deviceId+', check if motion capture is disabled manually or via modes settings')
|
|
return false
|
|
}
|
|
|
|
if (this.motion.active_ding) {
|
|
if (this.camera.operatingOnBattery) {
|
|
// Battery powered cameras can't take snapshots while recording, try to get image from video stream instead
|
|
debug('Motion event detected on battery powered camera '+this.deviceId+' snapshot will be updated from live stream')
|
|
this.getSnapshotFromStream()
|
|
return 'SnapFromStream'
|
|
} else {
|
|
// Line powered cameras can take a snapshot while recording, but ring-client-api will return a cached
|
|
// snapshot if a previous snapshot was taken within 10 seconds. If a motion event occurs during this time
|
|
// a stale image would be returned so, instead, we call our local function to force an uncached snapshot.
|
|
debug('Motion event detected for line powered camera '+this.deviceId+', forcing a non-cached snapshot update')
|
|
return await this.getUncachedSnapshot()
|
|
}
|
|
} else {
|
|
// If not an active ding it's a scheduled refresh, just call getSnapshot()
|
|
return await this.camera.getSnapshot()
|
|
}
|
|
}
|
|
|
|
// Bypass ring-client-api cached snapshot behavior by calling refresh snapshot API directly
|
|
async getUncachedSnapshot() {
|
|
await this.camera.requestSnapshotUpdate()
|
|
await utils.sleep(1)
|
|
const newSnapshot = await this.camera.restClient.request({
|
|
url: clientApi(`snapshots/image/${this.camera.id}`),
|
|
responseType: 'buffer',
|
|
})
|
|
return newSnapshot
|
|
}
|
|
|
|
// Refresh snapshot on scheduled interval
|
|
async scheduleSnapshotRefresh() {
|
|
await utils.sleep(this.snapshot.interval)
|
|
// During active motion events or device offline state, stop interval snapshots
|
|
if (this.snapshot.motion && !this.motion.active_ding && this.availabilityState === 'online') {
|
|
this.refreshSnapshot()
|
|
}
|
|
this.scheduleSnapshotRefresh()
|
|
}
|
|
|
|
async getSnapshotFromStream() {
|
|
// This will trigger P2J to publish one new snapshot from the live stream
|
|
this.livestream.updateSnapshot = true
|
|
|
|
// If there's no active live stream, start it, otherwise, extend live stream timeout
|
|
if (!this.livestream.active) {
|
|
this.startLiveStream()
|
|
} else {
|
|
this.livestream.expires = Math.floor(Date.now()/1000) + this.livestream.duration
|
|
}
|
|
}
|
|
|
|
// Start P2J server to emit complete JPEG images from livestream
|
|
async startP2J() {
|
|
const p2j = new P2J()
|
|
const p2jPort = await getPort()
|
|
|
|
let p2jServer = net.createServer(function(p2jStream) {
|
|
p2jStream.pipe(p2j)
|
|
|
|
// Close the p2j server on stream end
|
|
p2jStream.on('end', function() {
|
|
p2jServer.close()
|
|
})
|
|
})
|
|
|
|
// Listen to pipe on localhost only
|
|
p2jServer.listen(p2jPort, 'localhost')
|
|
|
|
p2j.on('jpeg', (jpegFrame) => {
|
|
// If updateSnapshot = true then publish the next full JPEG frame as new snapshot
|
|
if (this.livestream.updateSnapshot) {
|
|
this.snapshot.imageData = jpegFrame
|
|
this.snapshot.timestamp = Math.round(Date.now()/1000)
|
|
this.publishSnapshot()
|
|
this.livestream.updateSnapshot = false
|
|
}
|
|
})
|
|
|
|
// Return TCP port for SIP stream to send stream
|
|
return p2jPort
|
|
}
|
|
|
|
// Start a live stream and send mjpeg stream to p2j server
|
|
async startLiveStream() {
|
|
this.livestream.active = true
|
|
|
|
// Start a P2J pipeline and server and get the listening TCP port
|
|
const p2jPort = await this.startP2J()
|
|
|
|
// Start livestream with MJPEG output directed to P2J server with one frame every 2 seconds
|
|
debug('Establishing connection to video stream for camera '+this.deviceId)
|
|
try {
|
|
const sipSession = await this.camera.streamVideo({
|
|
output: [
|
|
'-y',
|
|
'-c:v',
|
|
'mjpeg',
|
|
'-pix_fmt',
|
|
'yuvj422p',
|
|
'-f',
|
|
'image2pipe',
|
|
'-s',
|
|
'640:360',
|
|
'-r',
|
|
'.5',
|
|
'-q:v',
|
|
'2',
|
|
'tcp://localhost:'+p2jPort
|
|
]
|
|
})
|
|
|
|
// If stream starts, set expire time, may be extended by new events
|
|
this.livestream.expires = Math.floor(Date.now()/1000) + this.livestream.duration
|
|
|
|
sipSession.onCallEnded.subscribe(() => {
|
|
debug('Video stream ended for camera '+this.deviceId)
|
|
this.livestream.active = false
|
|
})
|
|
|
|
// Don't stop SIP session until current tyime > expire time
|
|
// Expire time may be extedned by new motion events
|
|
while (Math.floor(Date.now()/1000) < this.livestream.expires) {
|
|
const sleeptime = (this.livestream.expires - Math.floor(Date.now()/1000)) + 1
|
|
await utils.sleep(sleeptime)
|
|
}
|
|
|
|
// Stream time has expired, stop the current SIP session
|
|
sipSession.stop()
|
|
|
|
} catch(e) {
|
|
debug(e)
|
|
this.livestream.active = false
|
|
}
|
|
}
|
|
|
|
// Publish heath state every 5 minutes when online
|
|
async schedulePublishInfo() {
|
|
await utils.sleep(this.availabilityState === 'offline' ? 60 : 300)
|
|
if (this.availabilityState === 'online') { this.publishInfoState() }
|
|
this.schedulePublishInfo()
|
|
}
|
|
|
|
// Simple heartbeat function decrements the heartbeat counter every 20 seconds.
|
|
// Normallt the 20 second polling events reset the heartbeat counter. If counter
|
|
// reaches 0 it indicates that polling has stopped so device is set offline.
|
|
// When polling resumes and heartbeat counter is reset above zero, device is set online.
|
|
async monitorCameraConnection() {
|
|
if (this.heartbeat > 0) {
|
|
this.heartbeat--
|
|
} else {
|
|
if (this.availabilityState !== 'offline') {
|
|
this.offline()
|
|
} else {
|
|
// If camera remains offline more than one cycle, try to tickle it back alive
|
|
this.camera.requestUpdate()
|
|
}
|
|
}
|
|
|
|
// Check for subscription to ding and motion events and attempt to resubscribe
|
|
if (!this.camera.data.subscribed === true) {
|
|
debug('Camera Id '+camera.data.device_id+' lost subscription to ding events, attempting to resubscribe...')
|
|
this.camera.subscribeToDingEvents().catch(e => {
|
|
debug('Failed to resubscribe camera Id ' +this.deviceId+' to ding events. Will retry in 60 seconds.')
|
|
debug(e)
|
|
})
|
|
}
|
|
if (!this.camera.data.subscribed_motions === true) {
|
|
debug('Camera Id '+camera.data.device_id+' lost subscription to motion events, attempting to resubscribe...')
|
|
this.camera.subscribeToMotionEvents().catch(e => {
|
|
debug('Failed to resubscribe camera Id '+this.deviceId+' to motion events. Will retry in 60 seconds.')
|
|
debug(e)
|
|
})
|
|
}
|
|
|
|
await utils.sleep(20)
|
|
this.monitorCameraConnection()
|
|
}
|
|
|
|
// Process messages from MQTT command topic
|
|
processCommand(message, topic) {
|
|
topic = topic.split('/')
|
|
const component = topic[topic.length - 2]
|
|
switch(component) {
|
|
case 'light':
|
|
this.setLightState(message)
|
|
break;
|
|
case 'siren':
|
|
this.setSirenState(message)
|
|
break;
|
|
case 'snapshot':
|
|
this.setSnapshotInterval(message)
|
|
break;
|
|
default:
|
|
debug('Somehow received message to unknown state topic for camera '+this.deviceId)
|
|
}
|
|
}
|
|
|
|
// Set switch target state on received MQTT command message
|
|
setLightState(message) {
|
|
debug('Received set light state '+message+' for camera '+this.deviceId)
|
|
debug('Location Id: '+ this.locationId)
|
|
switch (message) {
|
|
case 'ON':
|
|
this.camera.setLight(true)
|
|
break;
|
|
case 'OFF':
|
|
this.camera.setLight(false)
|
|
break;
|
|
default:
|
|
debug('Received unknown command for light on camera '+this.deviceId)
|
|
}
|
|
}
|
|
|
|
// Set switch target state on received MQTT command message
|
|
setSirenState(message) {
|
|
debug('Received set siren state '+message+' for camera '+this.deviceId)
|
|
debug('Location '+ this.locationId)
|
|
switch (message) {
|
|
case 'ON':
|
|
this.camera.setSiren(true)
|
|
break;
|
|
case 'OFF':
|
|
this.camera.setSiren(false)
|
|
break;
|
|
default:
|
|
debug('Received unkonw command for light on camera '+this.deviceId)
|
|
}
|
|
}
|
|
|
|
// Set refresh interval for snapshots
|
|
setSnapshotInterval(message) {
|
|
debug('Received set snapshot refresh interval '+message+' for camera '+this.deviceId)
|
|
debug('Location Id: '+ this.locationId)
|
|
if (isNaN(message)) {
|
|
debug ('Received invalid interval')
|
|
} else {
|
|
this.snapshot.interval = (message >= 10) ? Math.round(message) : 10
|
|
this.snapshot.autoInterval = false
|
|
debug ('Snapshot refresh interval as been set to '+this.snapshot.interval+' seconds')
|
|
}
|
|
}
|
|
|
|
// Publish availability state
|
|
publishAvailabilityState(enableDebug) {
|
|
this.publishMqtt(this.availabilityTopic, this.availabilityState, enableDebug)
|
|
|
|
}
|
|
|
|
// Set state topic online
|
|
async online() {
|
|
const enableDebug = (this.availabilityState === 'online') ? false : true
|
|
this.availabilityState = 'online'
|
|
await utils.sleep(1)
|
|
this.publishAvailabilityState(enableDebug)
|
|
await utils.sleep(1)
|
|
}
|
|
|
|
// Set state topic offline
|
|
offline() {
|
|
const enableDebug = (this.availabilityState === 'offline') ? false : true
|
|
this.availabilityState = 'offline'
|
|
this.publishAvailabilityState(enableDebug)
|
|
}
|
|
}
|
|
|
|
module.exports = Camera
|