├── .dockerignore ├── .gitattributes ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── config.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── bin ├── go2rtc_linux_amd64 ├── go2rtc_linux_arm └── go2rtc_linux_arm64 ├── config.json ├── devices ├── base-polled-device.js ├── base-ring-device.js ├── base-socket-device.js ├── base-station.js ├── beam-outdoor-plug.js ├── beam.js ├── binary-sensor.js ├── bridge.js ├── camera-livestream.js ├── camera.js ├── chime.js ├── co-alarm.js ├── fan.js ├── flood-freeze-sensor.js ├── intercom.js ├── keypad.js ├── lock.js ├── modes-panel.js ├── multi-level-switch.js ├── panic-button.js ├── range-extender.js ├── security-panel.js ├── siren.js ├── smoke-alarm.js ├── smoke-co-listener.js ├── switch.js ├── temperature-sensor.js ├── thermostat.js └── valve.js ├── docs ├── CHANGELOG-HIST.md └── CHANGELOG.md ├── eslint.config.js ├── images ├── ring-mqtt-icon.png └── ring-mqtt-logo.png ├── init-ring-mqtt.js ├── init ├── s6 │ ├── cont-init.d │ │ └── ring-mqtt.sh │ └── services.d │ │ └── ring-mqtt │ │ ├── finish │ │ └── run └── systemd │ └── ring-mqtt.service ├── lib ├── config.js ├── go2rtc.js ├── main.js ├── mqtt.js ├── process-handlers.js ├── ring.js ├── state.js ├── streaming │ ├── peer-connection.js │ ├── streaming-session.js │ ├── subscribed.js │ └── webrtc-connection.js ├── utils.js ├── web-service.js └── web-template.js ├── package-lock.json ├── package.json ├── ring-mqtt.js └── scripts ├── monitor-stream.sh ├── start-stream.sh └── update2branch.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | config.json 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | *.sh text eol=lf 4 | *.js text eol=lf 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug in ring-mqtt 3 | title: 'Bug: ' 4 | labels: [bug] 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Is there an existing issue for this? 9 | description: Please search [all open/closed issues](https://github.com/tsightler/ring-mqtt/issues?q=is%3Aissue+) for the bug you have encountered. 10 | options: 11 | - label: I have searched all existing open/closed issues 12 | required: true 13 | - type: checkboxes 14 | attributes: 15 | label: Is this really a bug? If you don't have some way to confirm it is a bug, errors in the log, code that is clearly wrong, or at least a clear method to reproduce something that doesn't work, then it's not (yet) a bug. 16 | description: Support questions or functional issues without proof should be posted in the appropriate [discussion groups](https://github.com/tsightler/ring-mqtt/discussions) 17 | instead. Note that issues posted in the dicussion group may be converted to bugs once confirmed. 18 | options: 19 | - label: I'm confident this is a bug and I have logs or other data to confirm it. 20 | required: true 21 | - type: checkboxes 22 | attributes: 23 | label: Have you read and followed any recommendations in the Support and Troubleshooting section of the wiki? 24 | description: If your issue is covered in [the wiki](https://github.com/tsightler/ring-mqtt/discussions) please follow all steps there prior to opening an issue. 25 | options: 26 | - label: I have read the support wiki and followed any applicable steps. 27 | required: true 28 | - type: checkboxes 29 | attributes: 30 | label: Did this issue start after an upgrade? 31 | description: If the issue appeared after an upgrade of ring-mqtt, please be sure to read the [release notes](https://github.com/tsightler/ring-mqtt/releases) for any mitigating information. 32 | options: 33 | - label: This not an upgrade issue or I have read the release notes and performed any applicable steps that match my issue. 34 | required: true 35 | - type: checkboxes 36 | attributes: 37 | label: Are you prepared to respond and provide all relevant information (logs, screenshots, etc) 38 | description: Please **DO NOT** open bugs if you are not prepared to provide logs or answer questions in a 39 | timely manner (at least every few days) 40 | options: 41 | - label: I am prepared to provide logs, answer questions, and respond in a reasonable timeframe. 42 | required: true 43 | - type: input 44 | attributes: 45 | label: Describe the Bug 46 | placeholder: A clear and concise description of the issue. Please be sure to enter brief summary of the issue in the title as well. 47 | validations: 48 | required: true 49 | - type: input 50 | attributes: 51 | label: Steps to Reproduce 52 | placeholder: Please provide the exact steps required to reproduce the behavior. A reproducer is something that I can do to reproduce the issue, not something that doesn't work in your environment. 53 | validations: 54 | required: true 55 | - type: input 56 | attributes: 57 | label: Expected Behavior 58 | placeholder: Descibe clearly the behavior that is expected 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: Logs 63 | attributes: 64 | label: Log Output 65 | placeholder: Please include the full ring-mqtt logs as they will be needed in almost all cases. If you don't know how to post logs, please reconsider opening a bug as you probably don't have a bug, you have a support question. If you decide to open an issue without providing logs, or providing only a snippet of logs, be prepared that the first response will likely be to provide full logs. If you do not want to post full logs here due to privacy concerns, you can send them to my email using the same username (tsightler) at gmail, and reference the issue number. If you do not know how to gather full logs for the addon see https://github.com/tsightler/ring-mqtt-ha-addon/blob/main/GET-LOGS.md 66 | render: shell 67 | validations: 68 | required: true 69 | - type: textarea 70 | attributes: 71 | label: Screenshots 72 | description: If applicable, add screenshots to help explain your problem. 73 | placeholder: You can attach images by clicking this area to highlight it and then dragging files in. 74 | validations: 75 | required: false 76 | - type: textarea 77 | id: Config 78 | attributes: 79 | label: Config File 80 | placeholder: Post the contents of YAML config from HA addon or config.json file _without sensitive information_ 81 | render: shell 82 | validations: 83 | required: true 84 | - type: markdown 85 | attributes: 86 | value: | 87 | Environment 88 | - type: input 89 | attributes: 90 | label: Install Type 91 | placeholder: Home Assistant Addon, Docker, Manual 92 | validations: 93 | required: true 94 | - type: input 95 | attributes: 96 | label: Version 97 | placeholder: v5.0.3 98 | validations: 99 | required: true 100 | - type: input 101 | attributes: 102 | label: Operating System 103 | placeholder: Home Assistant OS / Ubuntu / Debian / RaspianOS / etc. 104 | validations: 105 | required: true 106 | - type: input 107 | attributes: 108 | label: Architecture 109 | placeholder: x86_64 / arm64 / arm7 / etc. 110 | validations: 111 | required: true 112 | - type: input 113 | attributes: 114 | label: Machine Details 115 | placeholder: Raspberry Pi / Physical x86_64 / Virtual Machine (provide hypervisor details) / etc. 116 | validations: 117 | required: true 118 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Request Support 4 | url: https://github.com/tsightler/ring-mqtt/wiki/Support-&-Troubleshooting 5 | about: Please click the link and read the Support & Troubleshooting section of the Wiki 6 | - name: Feature Requests 7 | url: https://github.com/tsightler/ring-mqtt/discussions/categories/feature-requests 8 | about: Please do not open issues for feature requests, use the appropriate discussion group 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | .eslintrc.json 4 | node_modules 5 | config.*.json 6 | ring-state*.json 7 | ring-test.js 8 | config/go2rtc.yaml 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | ENV LANG="C.UTF-8" \ 4 | PS1="$(whoami)@$(hostname):$(pwd)$ " \ 5 | S6_BEHAVIOUR_IF_STAGE2_FAILS=2 \ 6 | S6_CMD_WAIT_FOR_SERVICES=1 \ 7 | S6_CMD_WAIT_FOR_SERVICES_MAXTIME=0 \ 8 | S6_SERVICES_GRACETIME=10000 \ 9 | TERM="xterm-256color" 10 | 11 | COPY . /app/ring-mqtt 12 | RUN S6_VERSION="v3.2.1.0" && \ 13 | BASHIO_VERSION="v0.17.0" && \ 14 | GO2RTC_VERSION="v1.9.4" && \ 15 | APK_ARCH="$(apk --print-arch)" && \ 16 | apk add --no-cache tar xz git bash curl jq tzdata mosquitto-clients && \ 17 | curl -L -s "https://github.com/just-containers/s6-overlay/releases/download/${S6_VERSION}/s6-overlay-noarch.tar.xz" | tar -Jxpf - -C / && \ 18 | case "${APK_ARCH}" in \ 19 | aarch64|armhf|x86_64) \ 20 | curl -L -s "https://github.com/just-containers/s6-overlay/releases/download/${S6_VERSION}/s6-overlay-${APK_ARCH}.tar.xz" | tar Jxpf - -C / ;; \ 21 | armv7) \ 22 | curl -L -s "https://github.com/just-containers/s6-overlay/releases/download/${S6_VERSION}/s6-overlay-arm.tar.xz" | tar Jxpf - -C / ;; \ 23 | *) \ 24 | echo >&2 "ERROR: Unsupported architecture '$APK_ARCH'" \ 25 | exit 1;; \ 26 | esac && \ 27 | mkdir -p /etc/fix-attrs.d && \ 28 | mkdir -p /etc/services.d && \ 29 | cp -a /app/ring-mqtt/init/s6/* /etc/. && \ 30 | chmod +x /etc/cont-init.d/*.sh && \ 31 | chmod +x /etc/services.d/ring-mqtt/* && \ 32 | rm -Rf /app/ring-mqtt/init && \ 33 | case "${APK_ARCH}" in \ 34 | x86_64) \ 35 | GO2RTC_ARCH="amd64";; \ 36 | aarch64) \ 37 | GO2RTC_ARCH="arm64";; \ 38 | armv7|armhf) \ 39 | GO2RTC_ARCH="arm";; \ 40 | *) \ 41 | echo >&2 "ERROR: Unsupported architecture '$APK_ARCH'" \ 42 | exit 1;; \ 43 | esac && \ 44 | curl -L -s -o /usr/local/bin/go2rtc "https://github.com/AlexxIT/go2rtc/releases/download/${GO2RTC_VERSION}/go2rtc_linux_${GO2RTC_ARCH}" && \ 45 | cp "/app/ring-mqtt/bin/go2rtc_linux_${GO2RTC_ARCH}" /usr/local/bin/go2rtc && \ 46 | chmod +x /usr/local/bin/go2rtc && \ 47 | rm -rf /app/ring-mqtt/bin && \ 48 | curl -J -L -o /tmp/bashio.tar.gz "https://github.com/hassio-addons/bashio/archive/${BASHIO_VERSION}.tar.gz" && \ 49 | mkdir /tmp/bashio && \ 50 | tar zxvf /tmp/bashio.tar.gz --strip 1 -C /tmp/bashio && \ 51 | mv /tmp/bashio/lib /usr/lib/bashio && \ 52 | ln -s /usr/lib/bashio/bashio /usr/bin/bashio && \ 53 | chmod +x /app/ring-mqtt/scripts/*.sh && \ 54 | mkdir /data && \ 55 | chmod 777 /data /app /run && \ 56 | cd /app/ring-mqtt && \ 57 | chmod +x ring-mqtt.js && \ 58 | chmod +x init-ring-mqtt.js && \ 59 | npm install && \ 60 | rm -Rf /root/.npm && \ 61 | rm -f -r /tmp/* 62 | ENTRYPOINT [ "/init" ] 63 | 64 | EXPOSE 8554/tcp 65 | EXPOSE 55123/tcp 66 | 67 | ARG BUILD_VERSION 68 | ARG BUILD_DATE 69 | 70 | LABEL \ 71 | io.hass.name="Ring-MQTT with Video Streaming" \ 72 | io.hass.description="Home Assistant Community Add-on for Ring Devices" \ 73 | io.hass.type="addon" \ 74 | io.hass.version=${BUILD_VERSION} \ 75 | maintainer="Tom Sightler " \ 76 | org.opencontainers.image.title="Ring-MQTT with Video Streaming" \ 77 | org.opencontainers.image.description="Intergrate wtih Ring devices using MQTT/RTSP" \ 78 | org.opencontainers.image.authors="Tom Sightler (and various other contributors)" \ 79 | org.opencontainers.image.licenses="MIT" \ 80 | org.opencontainers.image.source="https://github.com/tsightler/ring-mqtt" \ 81 | org.opencontainers.image.documentation="https://github.com/tsightler/README.md" \ 82 | org.opencontainers.image.created=${BUILD_DATE} \ 83 | org.opencontainers.image.version=${BUILD_VERSION} 84 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 tsightler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ring-mqtt-logo](https://raw.githubusercontent.com/tsightler/ring-mqtt/dev/images/ring-mqtt-logo.png) 2 | 3 | ## About 4 | Ring LLC sells security related products such as video doorbells, security cameras, alarm systems and smart lighting devices. The ring-mqtt project uses the Ring API (the same one used by Ring official apps) to act as a bridge between these devices and an local MQTT broker, thus allowing any automation tools that can leverage the open standards based MQTT protocol to effectively integrate with these devices. The project also supports video streaming by providing an RTSP gateway service that allows any media client supporting the RTSP protocol to connect to a Ring camera livestream or to play back recorded events (Ring Protect subscription required for event recording playback). Please review the full list of [supported devices and features](https://github.com/tsightler/ring-mqtt/wiki#supported-devices-and-features) for more information on current capabilities. 5 | 6 | #### IMPORTANT NOTE - Please read 7 | Ring devices are sold as cloud based devices and this project uses the same cloud based API used by the Ring apps, it does not enable local control of Ring devices as there is no known facility to do so. While using this project does not technically require a Ring Protect subscription, many capabilities are not possible without a subscription and this project is not intended as a way to bypass this requirement. If you don't like cloud powered devices my suggestion is to not purchase them. 8 | 9 | Also, this project does not turn Ring video doorbells/cameras into 24x7/continuous streaming CCTV cameras as these devices are designed for event based streaming/recording or for light interactive viewing, such as answering the a doorbell ding, or checking on a motion event. Even when using this project, all streaming still goes through Ring cloud servers and is not local. Attempting to leverage this project for continuous streaming is not a supported use case and attempts to do so will almost certainly end in disappointment, this includes use with NVR tools like Frigate, Zoneminder or others. 10 | 11 | If this advice is ignored, please note that there are significant functional side effects to doing so, most notably loss of motion/ding events while streaming (Ring cameras will only send alerts when they are not actively streaming/recording), quickly drained batteries, and potential device overheating or even early device failure as Ring cameras simply aren't designed for continuous operation. While you are of course welcome to use this project however you like, questions about use of tools that require continuous streaming will be locked and deleted. 12 | 13 | ## Installation and Configuration 14 | Please refer to the [ring-mqtt project wiki](https://github.com/tsightler/ring-mqtt/wiki) for complete documentation on the various installation methods and configuration options. 15 | -------------------------------------------------------------------------------- /bin/go2rtc_linux_amd64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsightler/ring-mqtt/f00d9bb3c438a8ba8b2413102723313f747bd08a/bin/go2rtc_linux_amd64 -------------------------------------------------------------------------------- /bin/go2rtc_linux_arm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsightler/ring-mqtt/f00d9bb3c438a8ba8b2413102723313f747bd08a/bin/go2rtc_linux_arm -------------------------------------------------------------------------------- /bin/go2rtc_linux_arm64: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsightler/ring-mqtt/f00d9bb3c438a8ba8b2413102723313f747bd08a/bin/go2rtc_linux_arm64 -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mqtt_url": "mqtt://localhost:1883", 3 | "mqtt_options": "", 4 | "livestream_user": "", 5 | "livestream_pass": "", 6 | "disarm_code": "", 7 | "enable_cameras": true, 8 | "enable_modes": false, 9 | "enable_panic": false, 10 | "hass_topic": "homeassistant/status", 11 | "ring_topic": "ring", 12 | "location_ids": [] 13 | } 14 | -------------------------------------------------------------------------------- /devices/base-polled-device.js: -------------------------------------------------------------------------------- 1 | import RingDevice from './base-ring-device.js' 2 | import utils from '../lib/utils.js' 3 | 4 | // Base class for devices/features that communicate via HTTP polling interface (cameras/chime/modes) 5 | export default class RingPolledDevice extends RingDevice { 6 | constructor(deviceInfo, category, primaryAttribute) { 7 | super(deviceInfo, category, primaryAttribute, 'polled') 8 | this.heartbeat = 3 9 | 10 | // Sevice data for Home Assistant device registry 11 | this.deviceData = { 12 | ids: [ this.deviceId ], 13 | name: this.device.name, 14 | mf: 'Ring', 15 | mdl: this.device.model 16 | } 17 | 18 | this.device.onData.subscribe((data) => { 19 | // Reset heartbeat counter on every polled state 20 | this.heartbeat = 3 21 | if (this.isOnline()) { this.publishState(data) } 22 | }) 23 | 24 | this.monitorHeartbeat() 25 | } 26 | 27 | // Publish device discovery, set online, and send all state data 28 | async publish() { 29 | await this.publishDiscovery() 30 | // Sleep for a few seconds to give HA time to process discovery message 31 | await utils.sleep(2) 32 | await this.online() 33 | this.publishState() 34 | } 35 | 36 | // This is a simple heartbeat function for devices which use polling. This 37 | // function decrements the heartbeat counter every 20 seconds. In normal operation 38 | // the heartbeat is constantly reset in the data publish function due to data 39 | // polling events however, if something interrupts the connection, polling stops 40 | // and this function will decrement until the heartbeat reaches zero. In this case 41 | // this function sets the device status offline. When polling resumes the heartbeat 42 | // is set > 0 and this function will set the device back online after a short delay. 43 | async monitorHeartbeat() { 44 | if (this.heartbeat > 0) { 45 | if (this.availabilityState !== 'online') { 46 | // If device was offline wait 10 seconds and check again, if still offline 47 | // put device online. Useful for initial startup or republish scenarios 48 | // as publish will forcelly put the device online. 49 | await utils.sleep(10) 50 | if (this.heartbeat > 0 && this.availabilityState !== 'online') { 51 | await this.online() 52 | } 53 | } 54 | this.heartbeat-- 55 | } else { 56 | if (this.availabilityState !== 'offline') { 57 | this.offline() 58 | } 59 | } 60 | await utils.sleep(20) 61 | this.monitorHeartbeat() 62 | } 63 | 64 | async getDeviceHistory(options) { 65 | try { 66 | const response = await this.device.restClient.request({ 67 | method: 'GET', 68 | url: `https://api.ring.com/evm/v2/history/devices/${this.device.id}${this.getSearchQueryString({ 69 | capabilities: 'offline_event', 70 | ...options, 71 | })}` 72 | }) 73 | return response 74 | } catch (err) { 75 | this.debug(err) 76 | this.debug('Failed to retrieve device event history from Ring API') 77 | } 78 | } 79 | 80 | getSearchQueryString(options) { 81 | const queryString = Object.entries(options) 82 | .map(([key, value]) => { 83 | if (value === undefined) { 84 | return ''; 85 | } 86 | return `${key}=${value}`; 87 | }) 88 | .filter((x) => x) 89 | .join('&'); 90 | return queryString.length ? `?${queryString}` : ''; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /devices/base-ring-device.js: -------------------------------------------------------------------------------- 1 | import utils from '../lib/utils.js' 2 | import state from '../lib/state.js' 3 | import chalk from 'chalk' 4 | 5 | // Base class with functions common to all devices 6 | export default class RingDevice { 7 | constructor(deviceInfo, category, primaryAttribute, apiType) { 8 | this.device = deviceInfo.device 9 | this.deviceId = apiType === 'socket' ? deviceInfo.device.id : deviceInfo.device.data.device_id 10 | this.locationId = apiType === 'socket' ? deviceInfo.device.location.locationId : deviceInfo.device.data.location_id 11 | this.availabilityState = 'unpublished' 12 | this.entity = {} 13 | this.isOnline = () => { 14 | return this.availabilityState === 'online' ? true : false 15 | } 16 | 17 | this.debug = (message, debugType) => { 18 | utils.debug(debugType === 'disc' ? message : chalk.green(`[${this.deviceData.name}] `)+message, debugType || 'mqtt') 19 | } 20 | // Build device base and availability topic 21 | this.deviceTopic = `${utils.config().ring_topic}/${this.locationId}/${category}/${this.deviceId}` 22 | this.availabilityTopic = `${this.deviceTopic}/status` 23 | 24 | if (deviceInfo.hasOwnProperty('parentDevice')) { 25 | this.parentDevice = deviceInfo.parentDevice 26 | } 27 | 28 | if (deviceInfo.hasOwnProperty('childDevices')) { 29 | this.childDevices = deviceInfo.childDevices 30 | } 31 | 32 | if (primaryAttribute !== 'disable') { 33 | this.initAttributeEntities(primaryAttribute) 34 | this.schedulePublishAttributes() 35 | } 36 | } 37 | 38 | // This function loops through each entity of the device, creates a unique 39 | // device ID for each one, builds the required state, command, and attribute 40 | // topics and, finally, generates a Home Assistant MQTT discovery message for 41 | // the entity and publishes this message to the Home Assistant config topic 42 | async publishDiscovery() { 43 | const debugMsg = (this.availabilityState === 'unpublished') ? 'Publishing new ' : 'Republishing existing ' 44 | this.debug(debugMsg+'device id: '+this.deviceId, 'disc') 45 | 46 | Object.keys(this.entity).forEach(entityKey => { 47 | const entity = this.entity[entityKey] 48 | const entityTopic = `${this.deviceTopic}/${entityKey}` 49 | 50 | // If this entity uses state values from the JSON attributes of a parent entity use that topic, 51 | // otherwise use standard state topic for entity ('image' for camera, 'state' for all others) 52 | const entityStateTopic = entity.hasOwnProperty('parent_state_topic') 53 | ? `${this.deviceTopic}/${entity.parent_state_topic}` 54 | : entity.component === 'camera' 55 | ? `${entityTopic}/image` 56 | : `${entityTopic}/state` 57 | 58 | // Build a Home Assistant style MQTT discovery message for the entity 59 | let discoveryMessage = { 60 | ...entity.hasOwnProperty('isMainEntity') || entity.hasOwnProperty('name') 61 | ? { name: entity.hasOwnProperty('name') ? entity.name : '' } 62 | : { name: `${entityKey.replace(/_/g," ").replace(/(^\w{1})|(\s+\w{1})/g, letter => letter.toUpperCase())}` }, 63 | ...entity.hasOwnProperty('isMainEntity') || entity.hasOwnProperty('unique_id') 64 | ? { unique_id: entity.hasOwnProperty('unique_id') ? entity.unique_id : `${this.deviceId}` } 65 | : { unique_id: `${this.deviceId}_${entityKey}`}, 66 | ...!entity.component.match(/^(camera|climate|button)$/) 67 | ? { state_topic: entityStateTopic } 68 | : entity.component === 'climate' 69 | ? { mode_state_topic: entityStateTopic } 70 | : entity.component === 'camera' 71 | ? { topic: entityStateTopic } 72 | : {}, 73 | ...entity.component.match(/^(switch|number|light|fan|lock|alarm_control_panel|select|button|valve)$/) 74 | ? { command_topic: `${entityTopic}/command` } 75 | : {}, 76 | ...entity.hasOwnProperty('device_class') 77 | ? { device_class: entity.device_class } 78 | : {}, 79 | ...entity.hasOwnProperty('unit_of_measurement') 80 | ? { unit_of_measurement: entity.unit_of_measurement } 81 | : {}, 82 | ...entity.hasOwnProperty('state_class') 83 | ? { state_class: entity.state_class } 84 | : {}, 85 | ...entity.hasOwnProperty('value_template') 86 | ? { value_template: entity.value_template } 87 | : {}, 88 | ...entity.hasOwnProperty('min') 89 | ? { min: entity.min } 90 | : {}, 91 | ...entity.hasOwnProperty('max') 92 | ? { max: entity.max } 93 | : {}, 94 | ...entity.hasOwnProperty('mode') 95 | ? { mode: entity.mode } 96 | : {}, 97 | ...entity.hasOwnProperty('category') 98 | ? { entity_category: entity.category } 99 | : {}, 100 | ...entity.hasOwnProperty('attributes') 101 | ? { json_attributes_topic: `${entityTopic}/attributes` } 102 | : entityKey === "info" 103 | ? { json_attributes_topic: `${entityStateTopic}` } 104 | : {}, 105 | ...entity.hasOwnProperty('icon') 106 | ? { icon: entity.icon } 107 | : entityKey === "info" 108 | ? { icon: 'mdi:information-outline' } 109 | : {}, 110 | ...entity.component === 'alarm_control_panel' 111 | ? { supported_features: ['arm_home', 'arm_away'], 112 | code_arm_required: false, 113 | code_disarm_required: Boolean(utils.config().disarm_code), 114 | ...utils.config().disarm_code 115 | ? { code: utils.config().disarm_code.toString() } 116 | : {} 117 | } 118 | : {}, 119 | ...entity.hasOwnProperty('brightness_scale') 120 | ? { brightness_state_topic: `${entityTopic}/brightness_state`, 121 | brightness_command_topic: `${entityTopic}/brightness_command`, 122 | brightness_scale: entity.brightness_scale } 123 | : {}, 124 | ...entity.component === 'fan' 125 | ? { percentage_state_topic: `${entityTopic}/percent_speed_state`, 126 | percentage_command_topic: `${entityTopic}/percent_speed_command`, 127 | preset_mode_state_topic: `${entityTopic}/speed_state`, 128 | preset_mode_command_topic: `${entityTopic}/speed_command`, 129 | preset_modes: [ "low", "medium", "high" ], 130 | speed_range_min: 11, 131 | speed_range_max: 100 } 132 | : {}, 133 | ...entity.component === 'climate' 134 | ? { action_topic: `${entityTopic}/action_state`, 135 | current_temperature_topic: `${entityTopic}/current_temperature_state`, 136 | fan_mode_command_topic: `${entityTopic}/fan_mode_command`, 137 | fan_mode_state_topic: `${entityTopic}/fan_mode_state`, 138 | fan_modes: entity.fan_modes, 139 | max_temp: 37, 140 | min_temp: 10, 141 | modes: entity.modes, 142 | mode_state_topic: `${entityTopic}/mode_state`, 143 | mode_command_topic: `${entityTopic}/mode_command`, 144 | preset_mode_command_topic: `${entityTopic}/preset_mode_command`, 145 | preset_mode_state_topic: `${entityTopic}/preset_mode_state`, 146 | preset_modes: ['Auxillary'], 147 | temperature_command_topic: `${entityTopic}/temperature_command`, 148 | temperature_state_topic: `${entityTopic}/temperature_state`, 149 | ...entity.modes.includes('auto') 150 | ? { temperature_high_command_topic: `${entityTopic}/temperature_high_command`, 151 | temperature_high_state_topic: `${entityTopic}/temperature_high_state`, 152 | temperature_low_command_topic: `${entityTopic}/temperature_low_command`, 153 | temperature_low_state_topic: `${entityTopic}/temperature_low_state`, 154 | } : {}, 155 | temperature_unit: 'C' } 156 | : {}, 157 | ...entity.component === 'select' 158 | ? { options: entity.options } 159 | : {}, 160 | availability_topic: this.availabilityTopic, 161 | payload_available: 'online', 162 | payload_not_available: 'offline', 163 | device: this.deviceData 164 | } 165 | 166 | const configTopic = `homeassistant/${entity.component}/${this.locationId}/${this.deviceId}_${entityKey}/config` 167 | this.debug(`HASS config topic: ${configTopic}`, 'disc') 168 | this.debug(discoveryMessage, 'disc') 169 | this.mqttPublish(configTopic, JSON.stringify(discoveryMessage), false) 170 | 171 | // On first publish store generated topics in entities object and subscribe to command/debug topics 172 | if (!this.entity[entityKey].hasOwnProperty('published')) { 173 | this.entity[entityKey].published = true 174 | Object.keys(discoveryMessage).filter(property => property.match('topic')).forEach(topic => { 175 | this.entity[entityKey][topic] = discoveryMessage[topic] 176 | if (topic.match('command_topic')) { 177 | utils.event.emit('mqtt_subscribe', discoveryMessage[topic]) 178 | utils.event.on(discoveryMessage[topic], (command, message) => { 179 | if (message) { 180 | this.processCommand(command, message) 181 | } else { 182 | this.debug(`Received invalid or null value to command topic ${command}`) 183 | } 184 | }) 185 | 186 | // Entity uses internal MQTT broker for inter-process communications 187 | if (this.entity[entityKey]?.ipc) { 188 | const debugTopic = discoveryMessage[topic].split('/').slice(0,-1).join('/')+'/debug' 189 | utils.event.emit('mqtt_ipc_subscribe', discoveryMessage[topic]) 190 | utils.event.emit('mqtt_ipc_subscribe', debugTopic) 191 | utils.event.on(debugTopic, (command, message) => { 192 | if (message) { 193 | this.debug(message, 'rtsp') 194 | } else { 195 | this.debug(`Received invalid or null value to debug log topic ${command}`) 196 | } 197 | }) 198 | } 199 | } 200 | }) 201 | } 202 | }) 203 | } 204 | 205 | // Refresh device info attributes on a sechedule 206 | async schedulePublishAttributes() { 207 | while (true) { 208 | await utils.sleep(this.availabilityState === 'offline' ? 60 : 300) 209 | if (this.availabilityState === 'online') { 210 | this.publishAttributes() 211 | } 212 | } 213 | } 214 | 215 | publishAttributeEntities(attributes) { 216 | // Find any attribute entities and publish the matching subset of attributes 217 | Object.keys(this.entity).forEach(entityKey => { 218 | if (this.entity[entityKey].hasOwnProperty('attributes') && this.entity[entityKey].attributes !== true) { 219 | const entityAttributes = Object.keys(attributes) 220 | .filter(key => key.match(this.entity[entityKey].attributes.toLowerCase())) 221 | .reduce((filteredAttributes, key) => { 222 | filteredAttributes[key] = attributes[key] 223 | return filteredAttributes 224 | }, {}) 225 | if (Object.keys(entityAttributes).length > 0) { 226 | this.mqttPublish(this.entity[entityKey].json_attributes_topic, JSON.stringify(entityAttributes), 'attr') 227 | } 228 | } 229 | }) 230 | } 231 | 232 | // Publish state messages with debug 233 | mqttPublish(topic, message, debugType, maskedMessage) { 234 | if (debugType !== false) { 235 | this.debug(chalk.blue(`${topic} `)+chalk.cyan(`${maskedMessage ? maskedMessage : message}`), debugType) 236 | } 237 | utils.event.emit('mqtt_publish', topic, message) 238 | } 239 | 240 | // Gets all saved state data for device 241 | getSavedState() { 242 | return state.getDeviceSavedState(this.deviceId) 243 | } 244 | 245 | // Called to update saved state data for device 246 | setSavedState(stateData) { 247 | state.setDeviceSavedState(this.deviceId, stateData) 248 | } 249 | 250 | async getUserInfo(userId) { 251 | const response = await this.device.location.restClient.request({ 252 | url: `https://app.ring.com/api/v1/rs/users/summaries?locationId=${this.locationId}`, 253 | method: 'POST', 254 | json: [userId] 255 | }) 256 | return (Array.isArray(response) && response.length > 0) ? response[0] : false 257 | } 258 | 259 | // Set state topic online 260 | async online() { 261 | if (this.shutdown) { return } // Supress any delayed online state messages if ring-mqtt is shutting down 262 | const debugType = (this.availabilityState === 'online') ? false : 'mqtt' 263 | this.availabilityState = 'online' 264 | this.mqttPublish(this.availabilityTopic, this.availabilityState, debugType) 265 | await utils.sleep(2) 266 | } 267 | 268 | // Set state topic offline 269 | offline() { 270 | const debugType = (this.availabilityState === 'offline') ? false : 'mqtt' 271 | this.availabilityState = 'offline' 272 | this.mqttPublish(this.availabilityTopic, this.availabilityState, debugType) 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /devices/base-socket-device.js: -------------------------------------------------------------------------------- 1 | import RingDevice from './base-ring-device.js' 2 | import utils from '../lib/utils.js' 3 | 4 | // Base class for devices that communicate with hubs via websocket (alarm/smart lighting) 5 | export default class RingSocketDevice extends RingDevice { 6 | constructor(deviceInfo, category, primaryAttribute) { 7 | super(deviceInfo, category, primaryAttribute, 'socket') 8 | 9 | // Set default device data for Home Assistant device registry 10 | this.deviceData = { 11 | ids: [ this.deviceId ], 12 | name: this.device.name, 13 | mf: (this.device.data && this.device.data.manufacturerName) ? this.device.data.manufacturerName : 'Ring', 14 | mdl: this.device.deviceType 15 | } 16 | 17 | this.device.onData.subscribe((data) => { 18 | if (this.isOnline()) { this.publishState(data) } 19 | }) 20 | } 21 | 22 | // Publish device discovery, set online, and send all state data 23 | async publish(locationConnected) { 24 | if (locationConnected) { 25 | await this.publishDiscovery() 26 | // Sleep for a few seconds to give HA time to process discovery message 27 | await utils.sleep(2) 28 | await this.online() 29 | this.publishState() 30 | } 31 | } 32 | 33 | // Create device discovery data 34 | initAttributeEntities(primaryAttribute) { 35 | this.entity = { 36 | ...this.entity, 37 | info: { 38 | component: 'sensor', 39 | category: 'diagnostic', 40 | ...primaryAttribute 41 | ? { value_template: `{{ value_json["${primaryAttribute}"] | default("") }}` } 42 | : { value_template: '{{ value_json["commStatus"] | default("") }}' } 43 | }, 44 | ...this.device.data.hasOwnProperty('batteryLevel') ? { 45 | battery: { 46 | component: 'sensor', 47 | category: 'diagnostic', 48 | device_class: 'battery', 49 | unit_of_measurement: '%', 50 | state_class: 'measurement', 51 | parent_state_topic: 'info/state', 52 | attributes: 'battery', 53 | value_template: '{{ value_json["batteryLevel"] | default("") }}' 54 | } 55 | } : {}, 56 | ...this.device.data.hasOwnProperty('tamperStatus') ? { 57 | tamper: { 58 | component: 'binary_sensor', 59 | category: 'diagnostic', 60 | device_class: 'tamper', 61 | parent_state_topic: 'info/state', 62 | value_template: '{% if value_json["tamperStatus"] is equalto "tamper" %}ON{% else %}OFF{% endif %}' 63 | } 64 | } : {}, 65 | ... this.device.data.hasOwnProperty('networkConnection') && this.device.data.networkConnection === 'wlan0' 66 | && this.device.data.hasOwnProperty('networks') && this.device.data.networks.hasOwnProperty('wlan0') ? { 67 | wireless: { 68 | component: 'sensor', 69 | category: 'diagnostic', 70 | device_class: 'signal_strength', 71 | unit_of_measurement: 'dBm', 72 | parent_state_topic: 'info/state', 73 | attributes: 'wireless', 74 | value_template: '{{ value_json["wirelessSignal"] | default("") }}' 75 | } 76 | } : {} 77 | } 78 | } 79 | 80 | // Publish device info 81 | async publishAttributes() { 82 | // Get full set of device data and publish to info topic JSON attributes 83 | const attributes = { 84 | ... this.device.data.hasOwnProperty('acStatus') ? { acStatus: this.device.data.acStatus } : {}, 85 | ... this.device.data.hasOwnProperty('alarmInfo') && this.device.data.alarmInfo !== null && this.device.data.alarmInfo.hasOwnProperty('state') 86 | ? { alarmState: this.device.data.alarmInfo.state } 87 | : (this.device.deviceType === 'security-panel') 88 | ? { alarmState: 'all-clear' } : {}, 89 | ... this.device.data.hasOwnProperty('batteryLevel') 90 | ? { batteryLevel: this.device.data.batteryLevel === 99 ? 100 : this.device.data.batteryLevel } : {}, 91 | ... this.device.data.hasOwnProperty('batteryStatus') && this.device.data.batteryStatus !== 'none' 92 | ? { batteryStatus: this.device.data.batteryStatus } : {}, 93 | ... (this.device.data.hasOwnProperty('auxBattery') && this.device.data.auxBattery.hasOwnProperty('level')) 94 | ? { auxBatteryLevel: this.device.data.auxBattery.level === 99 ? 100 : this.device.data.auxBattery.level } : {}, 95 | ... (this.device.data.hasOwnProperty('auxBattery') && this.device.data.auxBattery.hasOwnProperty('status')) 96 | ? { auxBatteryStatus: this.device.data.auxBattery.status } : {}, 97 | ... this.device.data.hasOwnProperty('brightness') 98 | ? { brightness: this.device.data.brightness } : {}, 99 | ... this.device.data.hasOwnProperty('chirps') && this.device.deviceType == 'security-keypad' 100 | ? { chirps: this.device.data.chirps } : {}, 101 | ... this.device.data.hasOwnProperty('commStatus') 102 | ? { commStatus: this.device.data.commStatus } : {}, 103 | ... this.device.data.hasOwnProperty('firmwareUpdate') 104 | ? { firmwareStatus: this.device.data.firmwareUpdate.state } : {}, 105 | ... this.device.data.hasOwnProperty('lastCommTime') 106 | ? { lastCommTime: utils.getISOTime(this.device.data.lastCommTime) } : {}, 107 | ... this.device.data.hasOwnProperty('lastUpdate') 108 | ? { lastUpdate: utils.getISOTime(this.device.data.lastUpdate) } : {}, 109 | ... this.device.data.hasOwnProperty('linkQuality') 110 | ? { linkQuality: this.device.data.linkQuality } : {}, 111 | ... this.device.data.hasOwnProperty('powerSave') 112 | ? { powerSave: this.device.data.powerSave } : {}, 113 | ... this.device.data.hasOwnProperty('serialNumber') 114 | ? { serialNumber: this.device.data.serialNumber } : {}, 115 | ... this.device.data.hasOwnProperty('tamperStatus') 116 | ? { tamperStatus: this.device.data.tamperStatus } : {}, 117 | ... this.device.data.hasOwnProperty('volume') 118 | ? {volume: this.device.data.volume } : {}, 119 | ... this.device.data.hasOwnProperty('maxVolume') 120 | ? {maxVolume: this.device.data.maxVolume } : {}, 121 | ... this.device.data.hasOwnProperty('networkConnection') && this.device.data.networkConnection === 'wlan0' 122 | && this.device.data.hasOwnProperty('networks') && this.device.data.networks.hasOwnProperty('wlan0') 123 | ? { wirelessNetwork: this.device.data.networks.wlan0.ssid, wirelessSignal: this.device.data.networks.wlan0.rssi } : {} 124 | } 125 | this.mqttPublish(this.entity.info.state_topic, JSON.stringify(attributes), 'attr') 126 | this.publishAttributeEntities(attributes) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /devices/base-station.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | import utils from '../lib/utils.js' 3 | 4 | export default class BaseStation extends RingSocketDevice { 5 | constructor(deviceInfo) { 6 | super(deviceInfo, 'alarm', 'acStatus') 7 | this.deviceData.mdl = 'Alarm Base Station' 8 | this.deviceData.name = this.device.location.name + ' Base Station' 9 | 10 | this.detectVolumeAccess() 11 | } 12 | 13 | async detectVolumeAccess() { 14 | const origVolume = (this.device.data.volume && !isNaN(this.device.data.volume) ? this.device.data.volume : 0) 15 | const testVolume = (origVolume === 1) ? .99 : origVolume+.01 16 | this.device.setVolume(testVolume) 17 | await utils.sleep(1) 18 | if (this.device.data.volume === testVolume) { 19 | this.debug('Account has access to set volume on base station, enabling volume control') 20 | this.device.setVolume(origVolume) 21 | this.entity.volume = { 22 | component: 'number', 23 | category: 'config', 24 | min: 0, 25 | max: 100, 26 | mode: 'slider', 27 | icon: 'hass:volume-high' 28 | } 29 | } else { 30 | this.debug('Account does not have access to set volume on base station, disabling volume control') 31 | } 32 | } 33 | 34 | publishState(data) { 35 | const isPublish = Boolean(data === undefined) 36 | 37 | if (this.entity.hasOwnProperty('volume')) { 38 | const currentVolume = (this.device.data.volume && !isNaN(this.device.data.volume) ? Math.round(100 * this.device.data.volume) : 0) 39 | this.mqttPublish(this.entity.volume.state_topic, currentVolume.toString()) 40 | 41 | // Eventually remove this but for now this attempts to delete the old light component based volume control from Home Assistant 42 | if (isPublish) { 43 | this.mqttPublish('homeassistant/light/'+this.locationId+'/'+this.deviceId+'_audio/config', '', false) 44 | } 45 | } 46 | this.publishAttributes() 47 | } 48 | 49 | // Process messages from MQTT command topic 50 | processCommand(command, message) { 51 | const entityKey = command.split('/')[0] 52 | switch (command) { 53 | case 'volume/command': 54 | if (this.entity.hasOwnProperty(entityKey)) { 55 | this.setVolumeLevel(message) 56 | } 57 | break; 58 | default: 59 | this.debug(`Received message to unknown command topic: ${command}`) 60 | } 61 | } 62 | 63 | // Set volume level on received MQTT command message 64 | setVolumeLevel(message) { 65 | const volume = message 66 | this.debug(`Received set volume level to ${volume}%`) 67 | if (isNaN(message)) { 68 | this.debug('Volume command received but value is not a number') 69 | } else if (!(message >= 0 && message <= 100)) { 70 | this.debug('Volume command received but out of range (0-100)') 71 | } else { 72 | this.device.setVolume(volume/100) 73 | } 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /devices/beam-outdoor-plug.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | import { RingDeviceType } from 'ring-client-api' 3 | 4 | export default class BeamOutdoorPlug extends RingSocketDevice { 5 | constructor(deviceInfo) { 6 | super(deviceInfo, 'lighting') 7 | this.deviceData.mdl = 'Outdoor Smart Plug' 8 | 9 | this.outlet1 = this.childDevices.find(d => d.deviceType === RingDeviceType.BeamsSwitch && d.data.relToParentZid === "1"), 10 | this.outlet2 = this.childDevices.find(d => d.deviceType === RingDeviceType.BeamsSwitch && d.data.relToParentZid === "2") 11 | 12 | this.entity.outlet1 = { 13 | component: (this.outlet1.data.categoryId === 2) ? 'light' : 'switch', 14 | name: `${this.outlet1.name}` 15 | } 16 | 17 | this.entity.outlet2 = { 18 | component: (this.outlet2.data.categoryId === 2) ? 'light' : 'switch', 19 | name: `${this.outlet2.name}` 20 | } 21 | 22 | this.outlet1.onData.subscribe(() => { 23 | if (this.isOnline()) { this.publishOutletState('outlet1') } 24 | }) 25 | 26 | this.outlet2.onData.subscribe(() => { 27 | if (this.isOnline()) { this.publishOutletState('outlet2') } 28 | }) 29 | } 30 | 31 | publishState() { 32 | this.publishOutletState('outlet1') 33 | this.publishOutletState('outlet2') 34 | this.publishAttributes() 35 | } 36 | 37 | publishOutletState(outletId) { 38 | this.mqttPublish(this.entity[outletId].state_topic, this[outletId].data.on ? "ON" : "OFF") 39 | this.publishAttributes() 40 | } 41 | 42 | // Process messages from MQTT command topic 43 | processCommand(command, message) { 44 | const entityKey = command.split('/')[0] 45 | switch (command) { 46 | case 'outlet1/command': 47 | if (this.entity.hasOwnProperty(entityKey)) { 48 | this.setOutletState(message, 'outlet1') 49 | } 50 | break; 51 | case 'outlet2/command': 52 | if (this.entity.hasOwnProperty(entityKey)) { 53 | this.setOutletState(message, 'outlet2') 54 | } 55 | break; 56 | default: 57 | this.debug(`Received message to unknown command topic: ${command}`) 58 | } 59 | } 60 | 61 | // Set switch target state on received MQTT command message 62 | setOutletState(message, outletId) { 63 | this.debug(`Received set ${outletId} state ${message}`) 64 | const command = message.toLowerCase() 65 | switch(command) { 66 | case 'on': 67 | case 'off': { 68 | const duration = 32767 69 | const data = command === 'on' ? { lightMode: 'on', duration } : { lightMode: 'default' } 70 | this[outletId].sendCommand('light-mode.set', data) 71 | break; 72 | } 73 | default: 74 | this.debug(`Received invalid ${outletId} state command`) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /devices/beam.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class Beam extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'lighting') 6 | 7 | this.data = {} 8 | 9 | // Setup device topics based on capabilities. 10 | switch (this.device.data.deviceType) { 11 | case 'group.light-group.beams': 12 | this.deviceData.mdl = 'Lighting Group' 13 | this.isLightGroup = true 14 | this.groupId = this.device.data.groupId 15 | this.initMotionEntity() 16 | this.initLightEntity() 17 | break; 18 | case 'switch.transformer.beams': 19 | this.deviceData.mdl = 'Lighting Transformer' 20 | this.initLightEntity() 21 | break; 22 | case 'switch.multilevel.beams': 23 | this.deviceData.mdl = 'Lighting Switch/Light' 24 | this.initMotionEntity() 25 | this.initLightEntity() 26 | break; 27 | case 'motion-sensor.beams': 28 | this.deviceData.mdl = 'Lighting Motion Sensor' 29 | this.initMotionEntity() 30 | break; 31 | } 32 | } 33 | 34 | initMotionEntity() { 35 | this.entity.motion = { 36 | component: 'binary_sensor', 37 | device_class: 'motion' 38 | } 39 | } 40 | 41 | initLightEntity() { 42 | const savedState = this.getSavedState() 43 | 44 | if (savedState?.beam_duration) { 45 | this.data.beam_duration = savedState?.beam_duration 46 | } else { 47 | this.data.beam_duration = this.device.data.hasOwnProperty('onDuration') ? this.device.data.onDuration : 0 48 | } 49 | 50 | this.entity.light = { 51 | component: 'light', 52 | ...this.device.data.deviceType === 'switch.multilevel.beams' ? { brightness_scale: 100 } : {} 53 | } 54 | 55 | this.entity.beam_duration = { 56 | name: 'Duration', 57 | unique_id: this.deviceId+'_duration', 58 | component: 'number', 59 | min: 0, 60 | max: 32767, 61 | mode: 'box', 62 | icon: 'hass:timer' 63 | } 64 | 65 | this.updateDeviceState() 66 | } 67 | 68 | updateDeviceState() { 69 | const stateData = { 70 | beam_duration: this.data.beam_duration 71 | } 72 | this.setSavedState(stateData) 73 | } 74 | 75 | publishState() { 76 | if (this.entity.hasOwnProperty('motion') && this.entity.motion.hasOwnProperty('state_topic')) { 77 | const motionState = this.device.data.motionStatus === 'faulted' ? 'ON' : 'OFF' 78 | this.mqttPublish(this.entity.motion.state_topic, motionState) 79 | } 80 | if (this.entity.hasOwnProperty('light') && this.entity.light.hasOwnProperty('state_topic')) { 81 | const switchState = this.device.data.on ? 'ON' : 'OFF' 82 | this.mqttPublish(this.entity.light.state_topic, switchState) 83 | if (this.entity.light.hasOwnProperty('brightness_state_topic')) { 84 | const switchLevel = (this.device.data.level && !isNaN(this.device.data.level) ? Math.round(100 * this.device.data.level) : 0) 85 | this.mqttPublish(this.entity.light.brightness_state_topic, switchLevel.toString()) 86 | } 87 | this.mqttPublish(this.entity.beam_duration.state_topic, this.data.beam_duration) 88 | } 89 | if (!this.isLightGroup) { 90 | this.publishAttributes() 91 | } 92 | } 93 | 94 | // Process messages from MQTT command topic 95 | processCommand(command, message) { 96 | const entityKey = command.split('/')[0] 97 | switch (command) { 98 | case 'light/command': 99 | if (this.entity.hasOwnProperty(entityKey)) { 100 | this.setLightState(message) 101 | } 102 | break; 103 | case 'light/brightness_command': 104 | if (this.entity.hasOwnProperty(entityKey)) { 105 | this.setLightLevel(message) 106 | } 107 | break; 108 | case 'beam_duration/command': 109 | if (this.entity.hasOwnProperty(entityKey)) { 110 | this.setLightDuration(message) 111 | } 112 | break; 113 | default: 114 | this.debug(`Received message to unknown command topic: ${command}`) 115 | } 116 | } 117 | 118 | // Set switch target state on received MQTT command message 119 | setLightState(message) { 120 | this.debug(`Received set light state ${message}`) 121 | const command = message.toLowerCase() 122 | switch(command) { 123 | case 'on': 124 | case 'off': { 125 | const duration = this.data.beam_duration ? Math.min(this.data.beam_duration, 32767) : undefined 126 | if (this.isLightGroup && this.groupId) { 127 | this.device.location.setLightGroup(this.groupId, Boolean(command === 'on'), duration) 128 | } else { 129 | const data = command === 'on' ? { lightMode: 'on', duration } : { lightMode: 'default' } 130 | this.device.sendCommand('light-mode.set', data) 131 | } 132 | break; 133 | } 134 | default: 135 | this.debug('Received invalid light state command') 136 | } 137 | } 138 | 139 | // Set switch target state on received MQTT command message 140 | setLightLevel(message) { 141 | const level = message 142 | this.debug(`Received set brightness level to ${level}`) 143 | if (isNaN(level)) { 144 | this.debug('Brightness command received but not a number') 145 | } else if (!(level >= 0 && level <= 100)) { 146 | this.debug('Brightness command received but out of range (0-100)') 147 | } else { 148 | this.device.setInfo({ device: { v1: { level: level / 100 } } }) 149 | } 150 | } 151 | 152 | setLightDuration(message) { 153 | const duration = message 154 | this.debug(`Received set light duration to ${duration} seconds`) 155 | if (isNaN(duration)) { 156 | this.debug('Light duration command received but value is not a number') 157 | } else if (!(duration >= 0 && duration <= 32767)) { 158 | this.debug('Light duration command received but out of range (0-32767)') 159 | } else { 160 | this.data.beam_duration = parseInt(duration) 161 | this.mqttPublish(this.entity.beam_duration.state_topic, this.data.beam_duration) 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /devices/binary-sensor.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | import { RingDeviceType } from 'ring-client-api' 3 | 4 | // Helper functions 5 | function chirpToMqttState(chirp) { 6 | return chirp.replace('cowbell', 'dinner-bell') 7 | .replace('none', 'disabled') 8 | .replace("-", " ") 9 | .replace(/(^\w{1})|(\s+\w{1})/g, letter => letter.toUpperCase()) 10 | } 11 | 12 | // Main device class 13 | export default class BinarySensor extends RingSocketDevice { 14 | constructor(deviceInfo) { 15 | super(deviceInfo, 'alarm') 16 | 17 | let device_class = false 18 | let bypass_modes = false 19 | this.securityPanel = deviceInfo.securityPanel 20 | 21 | // Override icons and and topics 22 | switch (this.device.deviceType) { 23 | case RingDeviceType.ContactSensor: 24 | this.entityName = 'contact' 25 | this.deviceData.mdl = 'Contact Sensor' 26 | device_class = (this.device.data.subCategoryId == 2) ? 'window' : 'door' 27 | bypass_modes = [ 'Never', 'Faulted', 'Always' ] 28 | break; 29 | case RingDeviceType.MotionSensor: 30 | this.entityName = 'motion' 31 | this.deviceData.mdl = 'Motion Sensor', 32 | device_class = 'motion' 33 | bypass_modes = [ 'Never', 'Always' ] 34 | break; 35 | case RingDeviceType.RetrofitZone: 36 | this.entityName = 'zone' 37 | this.deviceData.mdl = 'Retrofit Zone' 38 | device_class = 'safety' 39 | bypass_modes = [ 'Never', 'Faulted', 'Always' ] 40 | break; 41 | case RingDeviceType.TiltSensor: 42 | this.entityName = 'tilt' 43 | this.deviceData.mdl = 'Tilt Sensor' 44 | device_class = 'garage_door' 45 | bypass_modes = [ 'Never', 'Faulted', 'Always' ] 46 | break; 47 | case RingDeviceType.GlassbreakSensor: 48 | this.entityName = 'glassbreak' 49 | this.deviceData.mdl = 'Glassbreak Sensor' 50 | device_class = 'safety' 51 | bypass_modes = [ 'Never', 'Always' ] 52 | break; 53 | default: 54 | delete this.securityPanel 55 | if (this.device.name.toLowerCase().includes('motion')) { 56 | this.entityName = 'motion' 57 | this.deviceData.mdl = 'Motion Sensor', 58 | device_class = 'motion' 59 | } else { 60 | this.entityName = 'binary_sensor' 61 | this.deviceData.mdl = 'Generic Binary Sensor' 62 | } 63 | } 64 | 65 | this.entity[this.entityName] = { 66 | component: 'binary_sensor', 67 | ...device_class ? { device_class: device_class } : {}, 68 | isMainEntity: true 69 | } 70 | 71 | // Only official Ring sensors can be bypassed 72 | if (bypass_modes) { 73 | const savedState = this.getSavedState() 74 | this.data = { 75 | bypass_mode: savedState?.bypass_mode ? savedState.bypass_mode[0].toUpperCase() + savedState.bypass_mode.slice(1) : 'Never', 76 | published_bypass_mode: false 77 | } 78 | this.entity.bypass_mode = { 79 | component: 'select', 80 | options: bypass_modes 81 | } 82 | this.updateDeviceState() 83 | } 84 | 85 | if (this?.securityPanel?.data?.chirps?.[this.device.id]?.type) { 86 | this.data.chirp_tone = chirpToMqttState(this.securityPanel.data.chirps[this.device.id].type) 87 | this.data.published_chirp_tone = false 88 | this.entity.chirp_tone = { 89 | component: 'select', 90 | options: [ 91 | 'Disabled', 'Ding Dong', 'Harp', 'Navi', 'Wind Chime', 92 | 'Dinner Bell', 'Echo', 'Ping Pong', 'Siren', 'Sonar', 'Xylophone' 93 | ] 94 | } 95 | this.securityPanel.onData.subscribe(() => { 96 | if (this?.securityPanel?.data?.chirps?.[this.device.id]?.type) { 97 | this.data.chirp_tone = chirpToMqttState(this.securityPanel.data.chirps[this.device.id].type) 98 | } 99 | if (this.isOnline()) { 100 | this.publishChirpToneState() 101 | } 102 | }) 103 | } 104 | } 105 | 106 | updateDeviceState() { 107 | const stateData = { 108 | bypass_mode: this.data.bypass_mode 109 | } 110 | this.setSavedState(stateData) 111 | } 112 | 113 | publishState(data) { 114 | const isPublish = Boolean(data === undefined) 115 | const contactState = this.device.data.faulted ? 'ON' : 'OFF' 116 | this.mqttPublish(this.entity[this.entityName].state_topic, contactState) 117 | this.publishBypassModeState(isPublish) 118 | this.publishChirpToneState(isPublish) 119 | this.publishAttributes() 120 | } 121 | 122 | publishBypassModeState(isPublish) { 123 | if (this.entity?.bypass_mode && (this.data.bypass_mode !== this.data.published_bypass_mode || isPublish)) { 124 | this.mqttPublish(this.entity.bypass_mode.state_topic, this.data.bypass_mode) 125 | this.data.published_bypass_mode = this.data.bypass_mode 126 | } 127 | } 128 | 129 | publishChirpToneState(isPublish) { 130 | if (this.entity?.chirp_tone && (this.data.chirp_tone !== this.data.published_chirp_tone || isPublish)) { 131 | this.mqttPublish(this.entity.chirp_tone.state_topic, this.data.chirp_tone) 132 | this.data.published_chirp_tone = this.data.chirp_tone 133 | } 134 | } 135 | 136 | // Process messages from MQTT command topic 137 | processCommand(command, message) { 138 | switch (command) { 139 | case 'bypass_mode/command': 140 | if (this.entity?.bypass_mode) { 141 | this.setBypassMode(message) 142 | } 143 | break; 144 | case 'chirp_tone/command': 145 | if (this.entity?.chirp_tone) { 146 | this.setChirpTone(message) 147 | } 148 | break; 149 | default: 150 | this.debug(`Received message to unknown command topic: ${command}`) 151 | } 152 | } 153 | 154 | // Set Stream Select Option 155 | async setBypassMode(message) { 156 | const mode = message[0].toUpperCase() + message.slice(1) 157 | if (this.entity.bypass_mode.options.includes(mode)) { 158 | this.debug(`Received set bypass mode to ${message}`) 159 | this.data.bypass_mode = mode 160 | this.publishBypassModeState() 161 | this.updateDeviceState() 162 | this.debug(`Bypass mode has been set to ${mode}`) 163 | } else { 164 | this.debug(`Received invalid bypass mode for this sensor: ${message}`) 165 | } 166 | } 167 | 168 | async setChirpTone(message) { 169 | this.debug(`Recevied command to set chirp tone ${message}`) 170 | let chirpTone = this.entity.chirp_tone.options.find(o => o.toLowerCase() === message.toLowerCase()) 171 | if (chirpTone) { 172 | chirpTone = chirpTone 173 | .toLowerCase() 174 | .replace(/\s+/g, "-") 175 | .replace('dinner-bell', 'cowbell') 176 | .replace('disabled', 'none') 177 | this.securityPanel.setInfo({ device: { v1: { chirps: { [this.deviceId]: { type: chirpTone }}}}}) 178 | } else { 179 | this.debug('Received command to set unknown chirp tone') 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /devices/bridge.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class Bridge extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm', 'commStatus') 6 | this.deviceData.mdl = 'Bridge' 7 | this.deviceData.name = this.device.location.name + ' Bridge' 8 | } 9 | 10 | publishState() { 11 | // This device only has attributes and attribute based entities 12 | this.publishAttributes() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /devices/camera-livestream.js: -------------------------------------------------------------------------------- 1 | import { parentPort, workerData } from 'worker_threads' 2 | import { WebrtcConnection } from '../lib/streaming/webrtc-connection.js' 3 | import { StreamingSession } from '../lib/streaming/streaming-session.js' 4 | 5 | const deviceName = workerData.deviceName 6 | const doorbotId = workerData.doorbotId 7 | let liveStream = false 8 | let streamStopping = false 9 | 10 | parentPort.on("message", async(data) => { 11 | const streamData = data.streamData 12 | switch (data.command) { 13 | case 'start': 14 | if (streamStopping) { 15 | parentPort.postMessage({type: 'log_error', data: "Live stream could not be started because it is in stopping state"}) 16 | parentPort.postMessage({type: 'state', data: 'failed'}) 17 | } else if (!liveStream) { 18 | startLiveStream(streamData) 19 | } else { 20 | parentPort.postMessage({type: 'log_error', data: "Live stream could not be started because there is already an active stream"}) 21 | parentPort.postMessage({type: 'state', data: 'active'}) 22 | } 23 | break; 24 | case 'stop': 25 | if (liveStream) { 26 | stopLiveStream() 27 | } 28 | break; 29 | } 30 | }) 31 | 32 | async function startLiveStream(streamData) { 33 | parentPort.postMessage({type: 'log_info', data: 'Live stream WebRTC worker received start command'}) 34 | try { 35 | const cameraData = { 36 | name: deviceName, 37 | id: doorbotId 38 | } 39 | 40 | const streamConnection = new WebrtcConnection(streamData.ticket, cameraData) 41 | liveStream = new StreamingSession(cameraData, streamConnection) 42 | 43 | liveStream.connection.pc.onConnectionState.subscribe(async (data) => { 44 | switch(data) { 45 | case 'connected': 46 | parentPort.postMessage({type: 'state', data: 'active'}) 47 | parentPort.postMessage({type: 'log_info', data: 'Live stream WebRTC session is connected'}) 48 | break; 49 | case 'failed': 50 | parentPort.postMessage({type: 'state', data: 'failed'}) 51 | parentPort.postMessage({type: 'log_info', data: 'Live stream WebRTC connection has failed'}) 52 | liveStream.stop() 53 | await new Promise(res => setTimeout(res, 2000)) 54 | liveStream = false 55 | break; 56 | } 57 | }) 58 | 59 | parentPort.postMessage({type: 'log_info', data: 'Live stream transcoding process is starting'}) 60 | await liveStream.startTranscoding({ 61 | // The native AVC video stream is copied to the RTSP server unmodified while the audio 62 | // stream is converted into two output streams using both AAC and Opus codecs. This 63 | // provides a stream with wide compatibility across various media player technologies. 64 | audio: [ 65 | '-map', '0:v', 66 | '-map', '0:a', 67 | '-map', '0:a', 68 | '-c:a:0', 'aac', 69 | '-c:a:1', 'copy', 70 | ], 71 | video: [ 72 | '-c:v', 'copy' 73 | ], 74 | output: [ 75 | '-flags', '+global_header', 76 | '-f', 'rtsp', 77 | '-rtsp_transport', 'tcp', 78 | streamData.rtspPublishUrl 79 | ] 80 | }) 81 | 82 | parentPort.postMessage({type: 'log_info', data: 'Live stream transcoding process has started'}) 83 | 84 | liveStream.onCallEnded.subscribe(() => { 85 | parentPort.postMessage({type: 'log_info', data: 'Live stream WebRTC session has disconnected'}) 86 | parentPort.postMessage({type: 'state', data: 'inactive'}) 87 | liveStream = false 88 | }) 89 | } catch(error) { 90 | parentPort.postMessage({type: 'log_error', data: error}) 91 | parentPort.postMessage({type: 'state', data: 'failed'}) 92 | liveStream = false 93 | } 94 | } 95 | 96 | async function stopLiveStream() { 97 | if (!streamStopping) { 98 | streamStopping = true 99 | let stopTimeout = 10 100 | liveStream.stop() 101 | do { 102 | await new Promise(res => setTimeout(res, 200)) 103 | if (liveStream) { 104 | parentPort.postMessage({type: 'log_info', data: 'Live stream failed to stop on request, deleting anyway...'}) 105 | parentPort.postMessage({type: 'state', data: 'inactive'}) 106 | liveStream = false 107 | } 108 | stopTimeout-- 109 | } while (liveStream && stopTimeout) 110 | streamStopping = false 111 | } 112 | } -------------------------------------------------------------------------------- /devices/chime.js: -------------------------------------------------------------------------------- 1 | import RingPolledDevice from './base-polled-device.js' 2 | import utils from '../lib/utils.js' 3 | 4 | export default class Chime extends RingPolledDevice { 5 | constructor(deviceInfo) { 6 | super(deviceInfo, 'chime') 7 | 8 | const savedState = this.getSavedState() 9 | 10 | this.data = { 11 | volume: null, 12 | snooze: null, 13 | snooze_minutes: savedState?.snooze_minutes ? savedState.snooze_minutes : 1440, 14 | snooze_minutes_remaining: Math.floor(this.device.data.do_not_disturb.seconds_left/60), 15 | play_ding_sound: 'OFF', 16 | play_motion_sound: 'OFF', 17 | nightlight: { 18 | enabled: null, 19 | state: null, 20 | set_time: Math.floor(Date.now()/1000) 21 | } 22 | } 23 | 24 | // Define entities for this device 25 | this.entity = { 26 | ...this.entity, 27 | volume: { 28 | component: 'number', 29 | category: 'config', 30 | min: 0, 31 | max: 11, 32 | mode: 'slider', 33 | icon: 'hass:volume-high' 34 | }, 35 | snooze: { 36 | component: 'switch', 37 | icon: 'hass:bell-sleep', 38 | attributes: true 39 | }, 40 | snooze_minutes: { 41 | component: 'number', 42 | category: 'config', 43 | min: 1, 44 | max: 1440, 45 | mode: 'box', 46 | icon: 'hass:timer-sand' 47 | }, 48 | play_ding_sound: { 49 | component: 'switch', 50 | icon: 'hass:bell-ring' 51 | }, 52 | play_motion_sound: { 53 | component: 'switch', 54 | icon: 'hass:bell-ring' 55 | }, 56 | ...this.device.data.settings.night_light_settings?.hasOwnProperty('light_sensor_enabled') ? { 57 | nightlight_enabled: { 58 | component: 'switch', 59 | category: 'config', 60 | icon: "mdi:lightbulb-night", 61 | attributes: true 62 | } 63 | } : {}, 64 | info: { 65 | component: 'sensor', 66 | category: 'diagnostic', 67 | device_class: 'timestamp', 68 | value_template: '{{ value_json["lastUpdate"] | default("") }}' 69 | } 70 | } 71 | 72 | this.updateDeviceState() 73 | } 74 | 75 | updateDeviceState() { 76 | const stateData = { 77 | snooze_minutes: this.data.snooze_minutes 78 | } 79 | this.setSavedState(stateData) 80 | } 81 | 82 | initAttributeEntities() { 83 | this.entity.wireless = { 84 | component: 'sensor', 85 | category: 'diagnostic', 86 | device_class: 'signal_strength', 87 | unit_of_measurement: 'dBm', 88 | parent_state_topic: 'info/state', 89 | attributes: 'wireless', 90 | value_template: '{{ value_json["wirelessSignal"] | default("") }}' 91 | } 92 | } 93 | 94 | publishState(data) { 95 | const isPublish = Boolean(data === undefined) 96 | 97 | // Polled states are published only if value changes or it's a device publish 98 | const volumeState = this.device.data.settings.volume 99 | if (volumeState !== this.data.volume || isPublish) { 100 | this.mqttPublish(this.entity.volume.state_topic, volumeState.toString()) 101 | this.data.volume = volumeState 102 | } 103 | 104 | const snoozeState = this.device.data.do_not_disturb.seconds_left ? 'ON' : 'OFF' 105 | if (snoozeState !== this.data.snooze || isPublish) { 106 | this.mqttPublish(this.entity.snooze.state_topic, snoozeState) 107 | this.data.snooze = snoozeState 108 | } 109 | 110 | const snoozeMinutesRemaining = Math.floor(this.device.data.do_not_disturb.seconds_left/60) 111 | if (snoozeMinutesRemaining !== this.data.snooze_minutes_remaining || isPublish) { 112 | this.mqttPublish(this.entity.snooze.json_attributes_topic, JSON.stringify({ minutes_remaining: snoozeMinutesRemaining }), 'attr') 113 | this.data.snooze_minutes_remaining = snoozeMinutesRemaining 114 | } 115 | 116 | if (this.entity.hasOwnProperty('nightlight_enabled')) { 117 | const nightlightEnabled = this.device.data.settings.night_light_settings.light_sensor_enabled ? 'ON' : 'OFF' 118 | const nightlightState = this.device.data.night_light_state.toUpperCase() 119 | if ((nightlightEnabled !== this.data.nightlight.enabled && Date.now()/1000 - this.data.nightlight.set_time > 30) || isPublish) { 120 | this.data.nightlight.enabled = nightlightEnabled 121 | this.mqttPublish(this.entity.nightlight_enabled.state_topic, this.data.nightlight.enabled) 122 | } 123 | 124 | if (nightlightState !== this.data.nightlight.state || isPublish) { 125 | this.data.nightlight.state = nightlightState 126 | const attributes = { nightlightState: this.data.nightlight.state } 127 | this.mqttPublish(this.entity.nightlight_enabled.json_attributes_topic, JSON.stringify(attributes), 'attr') 128 | } 129 | } 130 | 131 | // Local states are published only for publish/republish 132 | if (isPublish) { 133 | this.mqttPublish(this.entity.snooze_minutes.state_topic, this.data.snooze_minutes.toString()) 134 | this.mqttPublish(this.entity.play_ding_sound.state_topic, this.data.play_ding_sound) 135 | this.mqttPublish(this.entity.play_motion_sound.state_topic, this.data.play_motion_sound) 136 | this.publishAttributes() 137 | } 138 | } 139 | 140 | // Publish device data to info topic 141 | async publishAttributes() { 142 | const deviceHealth = await this.device.getHealth() 143 | if (deviceHealth) { 144 | const attributes = { 145 | firmwareStatus: deviceHealth.firmware, 146 | lastUpdate: deviceHealth.updated_at.slice(0,-6)+"Z", 147 | wirelessNetwork: deviceHealth.wifi_name, 148 | wirelessSignal: deviceHealth.latest_signal_strength 149 | } 150 | this.mqttPublish(this.entity.info.state_topic, JSON.stringify(attributes), 'attr') 151 | this.publishAttributeEntities(attributes) 152 | } 153 | } 154 | 155 | async setDeviceSettings(settings) { 156 | const response = await this.device.restClient.request({ 157 | method: 'PATCH', 158 | url: `https://api.ring.com/devices/v1/devices/${this.device.id}/settings`, 159 | json: settings 160 | }) 161 | return response 162 | } 163 | 164 | // Process messages from MQTT command topic 165 | processCommand(command, message) { 166 | switch (command) { 167 | case 'snooze/command': 168 | this.setSnoozeState(message) 169 | break; 170 | case 'snooze_minutes/command': 171 | this.setSnoozeMinutes(message) 172 | break; 173 | case 'volume/command': 174 | this.setVolumeLevel(message) 175 | break; 176 | case 'play_ding_sound/command': 177 | this.playSound(message, 'ding') 178 | break; 179 | case 'play_motion_sound/command': 180 | this.playSound(message, 'motion') 181 | break; 182 | case 'nightlight_enabled/command': 183 | this.setNightlightState(message) 184 | break; 185 | default: 186 | this.debug(`Received message to unknown command topic: ${command}`) 187 | } 188 | } 189 | 190 | async setSnoozeState(message) { 191 | this.debug(`Received set snooze ${message}`) 192 | const command = message.toLowerCase() 193 | 194 | switch(command) { 195 | case 'on': 196 | await this.device.snooze(this.data.snooze_minutes) 197 | break; 198 | case 'off': { 199 | await this.device.clearSnooze() 200 | break; 201 | } 202 | default: 203 | this.debug('Received invalid command for set snooze!') 204 | } 205 | this.device.requestUpdate() 206 | } 207 | 208 | setSnoozeMinutes(message) { 209 | const minutes = message 210 | this.debug(`Received set snooze minutes to ${minutes} minutes`) 211 | if (isNaN(minutes)) { 212 | this.debug('Snooze minutes command received but value is not a number') 213 | } else if (!(minutes >= 0 && minutes <= 32767)) { 214 | this.debug('Snooze minutes command received but out of range (0-1440 minutes)') 215 | } else { 216 | this.data.snooze_minutes = parseInt(minutes) 217 | this.mqttPublish(this.entity.snooze_minutes.state_topic, this.data.snooze_minutes.toString()) 218 | this.updateDeviceState() 219 | } 220 | } 221 | 222 | async setVolumeLevel(message) { 223 | const volume = message 224 | this.debug(`Received set volume level to ${volume}`) 225 | if (isNaN(message)) { 226 | this.debug('Volume command received but value is not a number') 227 | } else if (!(message >= 0 && message <= 11)) { 228 | this.debug('Volume command received but out of range (0-11)') 229 | } else { 230 | await this.device.setVolume(volume) 231 | this.device.requestUpdate() 232 | } 233 | } 234 | 235 | async playSound(message, chimeType) { 236 | this.debug(`Receieved play ${chimeType} chime sound ${message}`) 237 | const command = message.toLowerCase() 238 | 239 | switch(command) { 240 | case 'on': 241 | this.mqttPublish(this.entity[`play_${chimeType}_sound`].state_topic, 'ON') 242 | await this.device.playSound(chimeType) 243 | await utils.sleep(5) 244 | this.mqttPublish(this.entity[`play_${chimeType}_sound`].state_topic, 'OFF') 245 | break; 246 | case 'off': { 247 | break; 248 | } 249 | default: 250 | this.debug('Received invalid command for play chime sound!') 251 | } 252 | } 253 | 254 | async setNightlightState(message) { 255 | this.debug(`Received set nightlight enabled ${message}`) 256 | const command = message.toLowerCase() 257 | switch(command) { 258 | case 'on': 259 | case 'off': 260 | this.data.nightlight.set_time = Math.floor(Date.now()/1000) 261 | await this.setDeviceSettings({ 262 | "night_light_settings": { 263 | "light_sensor_enabled": Boolean(command === 'on') 264 | } 265 | }) 266 | this.data.nightlight.enabled = command.toUpperCase() 267 | this.mqttPublish(this.entity.nightlight_enabled.state_topic, this.data.nightlight.enabled) 268 | break; 269 | default: 270 | this.debug('Received invalid command for nightlight enabled mode!') 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /devices/co-alarm.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class CoAlarm extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'CO Alarm' 7 | this.deviceData.mf = this.parentDevice?.data?.manufacturerName 8 | ? this.parentDevice.data.manufacturerName 9 | : 'Ring' 10 | 11 | this.entity.co = { 12 | component: 'binary_sensor', 13 | device_class: 'gas', 14 | isMainEntity: true 15 | } 16 | } 17 | 18 | publishState() { 19 | const coState = this.device.data.alarmStatus === 'active' ? 'ON' : 'OFF' 20 | this.mqttPublish(this.entity.co.state_topic, coState) 21 | this.publishAttributes() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /devices/fan.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | import utils from '../lib/utils.js' 3 | 4 | export default class Fan extends RingSocketDevice { 5 | constructor(deviceInfo) { 6 | super(deviceInfo, 'alarm') 7 | this.deviceData.mdl = 'Fan Control' 8 | 9 | this.entity.fan = { 10 | component: 'fan', 11 | isMainEntity: true 12 | } 13 | 14 | this.data = { 15 | targetFanPercent: undefined 16 | } 17 | } 18 | 19 | publishState() { 20 | const fanState = this.device.data.on ? "ON" : "OFF" 21 | const fanPercent = (this.device.data.level && !isNaN(this.device.data.level) ? Math.round(this.device.data.level*100) : 0) 22 | let fanPreset = "unknown" 23 | if (fanPercent > 67) { 24 | fanPreset = 'high' 25 | } else if (fanPercent > 33) { 26 | fanPreset = 'medium' 27 | } else if (fanPercent >= 0) { 28 | fanPreset = 'low' 29 | } else { 30 | this.debug(`ERROR - Could not determine fan preset value. Raw percent value: ${fanPercent}%`) 31 | } 32 | 33 | // Publish device state 34 | // targetFanPercent is a small hack to work around Home Assistant UI behavior 35 | if (this.data.targetFanPercent && this.data.targetFanPercent !== fanPercent) { 36 | this.mqttPublish(this.entity.fan.percentage_state_topic, this.data.targetFanPercent.toString()) 37 | this.data.targetFanPercent = undefined 38 | } else { 39 | this.mqttPublish(this.entity.fan.percentage_state_topic, fanPercent.toString()) 40 | } 41 | this.mqttPublish(this.entity.fan.state_topic, fanState) 42 | this.mqttPublish(this.entity.fan.preset_mode_state_topic, fanPreset) 43 | 44 | // Publish device attributes (batterylevel, tamper status) 45 | this.publishAttributes() 46 | } 47 | 48 | // Process messages from MQTT command topic 49 | processCommand(command, message) { 50 | switch (command) { 51 | case 'fan/command': 52 | this.setFanState(message) 53 | break; 54 | case 'fan/percent_speed_command': 55 | this.setFanPercent(message) 56 | break; 57 | case 'fan/speed_command': 58 | this.setFanPreset(message) 59 | break; 60 | default: 61 | this.debug(`Received message to unknown command topic: ${command}`) 62 | } 63 | } 64 | 65 | // Set fan target state from received MQTT command message 66 | setFanState(message) { 67 | this.debug(`Received set fan state ${message}`) 68 | const command = message.toLowerCase() 69 | switch(command) { 70 | case 'on': 71 | case 'off': 72 | this.device.setInfo({ device: { v1: { on: Boolean(command === 'on') } } }) 73 | break; 74 | default: 75 | this.debug('Received invalid command for fan!') 76 | } 77 | } 78 | 79 | // Set fan speed based on percent 80 | async setFanPercent(message) { 81 | if (isNaN(message)) { 82 | this.debug('Fan speed percent command received but value is not a number') 83 | return 84 | } 85 | 86 | let setFanPercent = parseInt(message) 87 | 88 | if (setFanPercent === 0) { 89 | this.debug('Received fan speed of 0%, turning fan off') 90 | if (this.device.data.on) { this.setFanState('off') } 91 | return 92 | } else if (setFanPercent < 10) { 93 | this.debug(`Received fan speed of ${setFanPercent}% which is < 10%, overriding to 10%`) 94 | setFanPercent = 10 95 | } else if (setFanPercent > 100) { 96 | this.debug(`Received fan speed of ${setFanPercent}% which is > 100%, overriding to 100%`) 97 | setFanPercent = 100 98 | } 99 | 100 | this.data.targetFanPercent = setFanPercent 101 | 102 | this.debug(`Setting fan speed percentage to ${this.data.targetFanPercent}%`) 103 | 104 | this.device.setInfo({ device: { v1: { level: this.data.targetFanPercent / 100 } } }) 105 | // Automatically turn on fan when level is sent. 106 | await utils.sleep(1) 107 | if (!this.device.data.on) { this.setFanState('on') } 108 | } 109 | 110 | // Set fan speed state from received MQTT command message 111 | async setFanPreset(message) { 112 | let fanPercent 113 | switch(message.toLowerCase()) { 114 | case 'low': 115 | fanPercent = 33 116 | break; 117 | case 'medium': 118 | fanPercent = 67 119 | break; 120 | case 'high': 121 | fanPercent = 100 122 | break; 123 | default: 124 | this.debug(`Received invalid fan preset command ${message.toLowerCase()}`) 125 | } 126 | 127 | if (fanPercent) { 128 | this.debug(`Received set fan preset to ${message.toLowerCase()}`) 129 | this.setFanPercent(fanPercent) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /devices/flood-freeze-sensor.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class FloodFreezeSensor extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Flood & Freeze Sensor' 7 | 8 | this.entity.flood = { 9 | component: 'binary_sensor', 10 | device_class: 'moisture', 11 | unique_id: `${this.deviceId}_moisture` // Force backward compatible unique ID for this entity 12 | } 13 | this.entity.freeze = { 14 | component: 'binary_sensor', 15 | device_class: 'cold', 16 | unique_id: `${this.deviceId}_cold` // Force backward compatible unique ID for this entity 17 | } 18 | } 19 | 20 | publishState() { 21 | const floodState = this.device.data.flood && this.device.data.flood.faulted ? 'ON' : 'OFF' 22 | const freezeState = this.device.data.freeze && this.device.data.freeze.faulted ? 'ON' : 'OFF' 23 | this.mqttPublish(this.entity.flood.state_topic, floodState) 24 | this.mqttPublish(this.entity.freeze.state_topic, freezeState) 25 | this.publishAttributes() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /devices/intercom.js: -------------------------------------------------------------------------------- 1 | import RingPolledDevice from './base-polled-device.js' 2 | 3 | export default class Lock extends RingPolledDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'intercom') 6 | this.deviceData.mdl = 'Intercom' 7 | 8 | this.data = { 9 | lock: { 10 | state: 'LOCKED', 11 | publishedState: null, 12 | unlockTimeout: false 13 | }, 14 | ding: { 15 | state: 'OFF', 16 | publishedState: null, 17 | timeout: false 18 | } 19 | } 20 | 21 | this.entity = { 22 | ...this.entity, 23 | lock: { 24 | component: 'lock' 25 | }, 26 | ding: { 27 | component: 'binary_sensor', 28 | attributes: true, 29 | icon: 'mdi:doorbell' 30 | }, 31 | info: { 32 | component: 'sensor', 33 | category: 'diagnostic', 34 | device_class: 'timestamp', 35 | value_template: '{{ value_json["lastUpdate"] | default("") }}' 36 | } 37 | } 38 | 39 | this.device.onUnlocked.subscribe(() => { 40 | this.setDoorUnlocked() 41 | }) 42 | 43 | this.device.onDing.subscribe(() => { 44 | this.processDing() 45 | }) 46 | } 47 | 48 | async initAttributeEntities() { 49 | // If device is battery powered publish battery entity 50 | if (this.device.batteryLevel !== null) { 51 | this.entity.battery = { 52 | component: 'sensor', 53 | category: 'diagnostic', 54 | device_class: 'battery', 55 | unit_of_measurement: '%', 56 | state_class: 'measurement', 57 | parent_state_topic: 'info/state', 58 | attributes: 'battery', 59 | value_template: '{{ value_json["batteryLevel"] | default("") }}' 60 | } 61 | } 62 | const deviceHealth = await this.getHealth() 63 | if (deviceHealth && !(deviceHealth?.network_connection && deviceHealth.network_connection === 'ethernet')) { 64 | this.entity.wireless = { 65 | component: 'sensor', 66 | category: 'diagnostic', 67 | device_class: 'signal_strength', 68 | unit_of_measurement: 'dBm', 69 | parent_state_topic: 'info/state', 70 | attributes: 'wireless', 71 | value_template: '{{ value_json["wirelessSignal"] | default("") }}' 72 | } 73 | } 74 | } 75 | 76 | publishState(data) { 77 | const isPublish = Boolean(data === undefined) 78 | 79 | this.publishDingState(isPublish) 80 | this.publishLockState(isPublish) 81 | 82 | if (isPublish) { 83 | this.publishAttributes() 84 | } 85 | } 86 | 87 | publishDingState(isPublish) { 88 | if (this.data.ding.state !== this.data.ding.publishedState || isPublish) { 89 | this.mqttPublish(this.entity.ding.state_topic, this.data.ding.state) 90 | this.data.ding.publishedState = this.data.ding.state 91 | } 92 | } 93 | 94 | publishLockState(isPublish) { 95 | if (this.data.lock.state !== this.data.lock.publishedState || isPublish) { 96 | this.mqttPublish(this.entity.lock.state_topic, this.data.lock.state) 97 | this.data.lock.publishedState = this.data.lock.state 98 | } 99 | } 100 | 101 | // Publish device data to info topic 102 | async publishAttributes() { 103 | try { 104 | const deviceHealth = await this.getHealth() 105 | const attributes = { 106 | ...this.device?.batteryLevel 107 | ? { batteryLevel: this.device.batteryLevel } : {}, 108 | firmwareStatus: deviceHealth.firmware, 109 | lastUpdate: deviceHealth.updated_at.slice(0,-6)+"Z", 110 | wirelessNetwork: deviceHealth.wifi_name, 111 | wirelessSignal: deviceHealth.latest_signal_strength 112 | } 113 | this.mqttPublish(this.entity.info.state_topic, JSON.stringify(attributes), 'attr') 114 | this.publishAttributeEntities(attributes) 115 | } catch { 116 | this.debug('Could not publish attributes due to no health data') 117 | } 118 | } 119 | 120 | processDing() { 121 | if (this.data.ding.timeout) { 122 | clearTimeout(this.data.ding.timeout) 123 | this.data.ding.timeout = false 124 | } 125 | this.data.ding.state = 'ON' 126 | this.publishDingState() 127 | this.data.ding.timeout = setTimeout(() => { 128 | this.data.ding.state = 'OFF' 129 | this.publishDingState() 130 | this.data.ding.timeout = false 131 | }, 20000) 132 | } 133 | 134 | setDoorUnlocked() { 135 | if (this.data.lock.unlockTimeout) { 136 | clearTimeout(this.data.lock.unlockTimeout) 137 | this.data.lock.unlockTimeout = false 138 | } 139 | this.data.lock.state = 'UNLOCKED' 140 | this.publishLockState() 141 | this.data.lock.unlockTimeout = setTimeout(() => { 142 | this.data.lock.state = 'LOCKED' 143 | this.publishLockState() 144 | this.data.lock.unlockTimeout = false 145 | }, 5000) 146 | } 147 | 148 | async getHealth() { 149 | try { 150 | const response = await this.device.restClient.request({ 151 | url: this.device.doorbotUrl('health') 152 | }) 153 | 154 | if (response.hasOwnProperty('device_health')) { 155 | return response.device_health 156 | } else { 157 | this.debug('Failed to parse response from device health query') 158 | this.debug(JSON.stringify(response)) 159 | } 160 | } catch(error) { 161 | this.debug('Failed to retrieve health data for Intercom') 162 | this.debug(error) 163 | } 164 | return false 165 | } 166 | 167 | // Process messages from MQTT command topic 168 | processCommand(command, message) { 169 | switch (command) { 170 | case 'lock/command': 171 | this.setLockState(message) 172 | break; 173 | default: 174 | this.debug(`Received message to unknown command topic: ${command}`) 175 | } 176 | } 177 | 178 | // Set lock target state on received MQTT command message 179 | async setLockState(message) { 180 | const command = message.toLowerCase() 181 | switch(command) { 182 | case 'lock': 183 | if (this.data.lock.state === 'UNLOCKED') { 184 | this.debug('Received lock door command, setting locked state') 185 | this.data.lock.state === 'LOCKED' 186 | this.publishLockState() 187 | } else { 188 | this.debug('Received lock door command, but door is already locked') 189 | } 190 | break; 191 | case 'unlock': 192 | this.debug('Received unlock door command, sending unlock command to intercom') 193 | try { 194 | await this.device.unlock() 195 | this.debug('Request to unlock door was successful') 196 | this.setDoorUnlocked() 197 | } catch(error) { 198 | this.debug(error) 199 | this.debug('Request to unlock door failed') 200 | } 201 | break; 202 | default: 203 | this.debug('Received invalid command for lock') 204 | } 205 | } 206 | } -------------------------------------------------------------------------------- /devices/keypad.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class Keypad extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Security Keypad' 7 | 8 | this.data = { 9 | motion: { 10 | state: 'OFF', 11 | publishedState: null, 12 | timeout: false 13 | } 14 | } 15 | 16 | this.entity = { 17 | ...this.entity, 18 | volume: { 19 | component: 'number', 20 | category: 'config', 21 | min: 0, 22 | max: 100, 23 | mode: 'slider', 24 | icon: 'hass:volume-high' 25 | }, 26 | motion: { 27 | component: 'binary_sensor', 28 | device_class: 'motion', 29 | attributes: true 30 | }, 31 | chirps: { 32 | component: 'switch', 33 | category: 'config', 34 | icon: 'mdi:bird' 35 | } 36 | } 37 | 38 | // Listen to raw data updates for all devices and pick out 39 | // proximity detection events for this keypad. 40 | this.device.location.onDataUpdate.subscribe((message) => { 41 | if (this.isOnline() && 42 | message.datatype === 'DeviceInfoDocType' && 43 | message.body?.[0]?.general?.v2?.zid === this.deviceId && 44 | message.body[0].impulse?.v1?.[0]?.impulseType === 'keypad.motion' 45 | ) { 46 | this.processMotion() 47 | } 48 | }) 49 | } 50 | 51 | publishState(data) { 52 | const isPublish = Boolean(data === undefined) 53 | if (isPublish) { 54 | // Eventually remove this but for now this attempts to delete the old light component based volume control from Home Assistant 55 | this.mqttPublish(`homeassistant/light/${this.locationId}/${this.deviceId}_audio/config`, '', false) 56 | } 57 | const currentVolume = (this.device.data.volume && !isNaN(this.device.data.volume) ? Math.round(100 * this.device.data.volume) : 0) 58 | this.mqttPublish(this.entity.volume.state_topic, currentVolume.toString()) 59 | this.mqttPublish(this.entity.chirps.state_topic, this.device.data?.chirps === 'enabled' ? 'ON' : 'OFF') 60 | this.publishMotionState(isPublish) 61 | this.publishAttributes() 62 | } 63 | 64 | publishMotionState(isPublish) { 65 | if (this.data.motion.state !== this.data.motion.publishedState || isPublish) { 66 | this.mqttPublish(this.entity.motion.state_topic, this.data.motion.state) 67 | this.data.motion.publishedState = this.data.motion.state 68 | } 69 | } 70 | 71 | processMotion() { 72 | if (this.data.motion.timeout) { 73 | clearTimeout(this.data.motion.timeout) 74 | this.data.motion.timeout = false 75 | } 76 | this.data.motion.state = 'ON' 77 | this.publishMotionState() 78 | this.data.motion.timeout = setTimeout(() => { 79 | this.data.motion.state = 'OFF' 80 | this.publishMotionState() 81 | this.data.motion.timeout = false 82 | }, 20000) 83 | } 84 | 85 | // Process messages from MQTT command topic 86 | processCommand(command, message) { 87 | switch (command) { 88 | case 'volume/command': 89 | this.setVolumeLevel(message) 90 | break; 91 | case 'chirps/command': 92 | this.setChirpsState(message) 93 | break; 94 | default: 95 | this.debug(`Received message to unknown command topic: ${command}`) 96 | } 97 | } 98 | 99 | // Set volume level on received MQTT command message 100 | setVolumeLevel(message) { 101 | const volume = message 102 | this.debug(`Received set volume level to ${volume}%`) 103 | if (isNaN(message)) { 104 | this.debug('Volume command received but value is not a number') 105 | } else if (!(message >= 0 && message <= 100)) { 106 | this.debug('Volume command received but out of range (0-100)') 107 | } else { 108 | this.device.setVolume(volume/100) 109 | } 110 | } 111 | 112 | // Set chirps target state on received MQTT command message 113 | setChirpsState(message) { 114 | this.debug(`Received set chirps state ${message}`) 115 | const command = message.toLowerCase() 116 | switch(command) { 117 | case 'on': 118 | case 'off': { 119 | this.device.setInfo({ device: { v1: { chirps: command === 'on' ? 'enabled' : 'disabled' } } }) 120 | break; 121 | } 122 | default: 123 | this.debug('Received invalid command for chirps switch!') 124 | } 125 | } 126 | 127 | } 128 | -------------------------------------------------------------------------------- /devices/lock.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class Lock extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Lock' 7 | 8 | this.entity.lock = { 9 | component: 'lock', 10 | isMainEntity: true 11 | } 12 | } 13 | 14 | publishState() { 15 | let lockState 16 | switch(this.device.data.locked) { 17 | case 'locked': 18 | lockState = 'LOCKED' 19 | break; 20 | case 'unlocked': 21 | lockState = 'UNLOCKED' 22 | break; 23 | default: 24 | lockState = 'UNKNOWN' 25 | } 26 | this.mqttPublish(this.entity.lock.state_topic, lockState) 27 | this.publishAttributes() 28 | } 29 | 30 | // Process messages from MQTT command topic 31 | processCommand(command, message) { 32 | switch (command) { 33 | case 'lock/command': 34 | this.setLockState(message) 35 | break; 36 | default: 37 | this.debug(`Received message to unknown command topic: ${command}`) 38 | } 39 | } 40 | 41 | // Set lock target state on received MQTT command message 42 | setLockState(message) { 43 | this.debug(`Received set lock state ${message}`) 44 | const command = message.toLowerCase() 45 | switch(command) { 46 | case 'lock': 47 | case 'unlock': 48 | this.mqttPublish(this.entity.lock.state_topic, `${command.toUpperCase()}ING`) 49 | this.device.sendCommand(`lock.${command}`) 50 | break; 51 | default: 52 | this.debug('Received invalid command for lock') 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /devices/modes-panel.js: -------------------------------------------------------------------------------- 1 | import RingPolledDevice from './base-polled-device.js' 2 | import utils from '../lib/utils.js' 3 | 4 | export default class ModesPanel extends RingPolledDevice { 5 | constructor(deviceInfo) { 6 | super(deviceInfo, 'alarm', 'disable') 7 | this.deviceData.mdl = 'Mode Control Panel' 8 | this.deviceData.name = `${this.device.location.name} Mode` 9 | 10 | this.entity.mode = { 11 | component: 'alarm_control_panel', 12 | isMainEntity: true 13 | } 14 | 15 | this.data = { 16 | currentMode: undefined 17 | } 18 | } 19 | 20 | async publishState(data) { 21 | const isPublish = Boolean(data === undefined) 22 | const mode = (isPublish) ? (await this.device.location.getLocationMode()).mode : data 23 | // Publish device state if it's changed from prior state 24 | if (this.data.currentMode !== mode || isPublish) { 25 | this.data.currentMode = mode 26 | let mqttMode 27 | switch(mode) { 28 | case 'disarmed': 29 | mqttMode = 'disarmed' 30 | break; 31 | case 'home': 32 | mqttMode = 'armed_home' 33 | break; 34 | case 'away': 35 | mqttMode = 'armed_away' 36 | break; 37 | default: 38 | mqttMode = 'disarmed' 39 | } 40 | this.mqttPublish(this.entity.mode.state_topic, mqttMode) 41 | } 42 | } 43 | 44 | // Process messages from MQTT command topic 45 | processCommand(command, message) { 46 | switch (command) { 47 | case 'mode/command': 48 | this.setLocationMode(message) 49 | break; 50 | default: 51 | this.debug(`Received message to unknown command topic: ${command}`) 52 | } 53 | } 54 | 55 | // Set Alarm Mode on received MQTT command message 56 | async setLocationMode(message) { 57 | this.debug(`Received command set mode ${message} for location ${this.device.location.name} (${this.locationId})`) 58 | 59 | // Try to set alarm mode and retry after delay if mode set fails 60 | // Initial attempt with no delay 61 | let delay = 0 62 | let retries = 6 63 | let setModeSuccess = false 64 | while (retries-- > 0 && !(setModeSuccess)) { 65 | setModeSuccess = await this.trySetMode(message, delay) 66 | // On failure delay 10 seconds before next set attempt 67 | delay = 10 68 | } 69 | // Check the return status and print some debugging for failed states 70 | if (setModeSuccess == false ) { 71 | this.debug('Location could not enter proper mode after all retries...Giving up!') 72 | } else if (setModeSuccess == 'unknown') { 73 | this.debug('Ignoring unknown command.') 74 | } 75 | } 76 | 77 | async trySetMode(message, delay) { 78 | await utils.sleep(delay) 79 | let targetMode 80 | switch(message.toLowerCase()) { 81 | case 'disarm': 82 | targetMode = 'disarmed' 83 | break 84 | case 'arm_home': 85 | targetMode = 'home' 86 | break 87 | case 'arm_away': 88 | targetMode = 'away' 89 | break 90 | default: 91 | this.debug('Cannot set location mode: Unknown') 92 | return 'unknown' 93 | } 94 | this.debug(`Set location mode: ${targetMode}`) 95 | await this.device.location.setLocationMode(targetMode) 96 | 97 | // Sleep a 1 second and check if location entered the requested mode 98 | await utils.sleep(1); 99 | if (targetMode == (await this.device.location.getLocationMode()).mode) { 100 | this.debug(`Location ${this.device.location.name} successfully entered ${message} mode`) 101 | return true 102 | } else { 103 | this.debug(`Location ${this.device.location.name} failed to enter requested mode!`) 104 | return false 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /devices/multi-level-switch.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class MultiLevelSwitch extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Dimming Light' 7 | 8 | this.entity.light = { 9 | component: 'light', 10 | brightness_scale: 100, 11 | isMainEntity: true 12 | } 13 | } 14 | 15 | publishState() { 16 | const switchState = this.device.data.on ? "ON" : "OFF" 17 | const switchLevel = (this.device.data.level && !isNaN(this.device.data.level) ? Math.round(100 * this.device.data.level) : 0) 18 | this.mqttPublish(this.entity.light.state_topic, switchState) 19 | this.mqttPublish(this.entity.light.brightness_state_topic, switchLevel.toString()) 20 | this.publishAttributes() 21 | } 22 | 23 | // Process messages from MQTT command topic 24 | processCommand(command, message) { 25 | switch (command) { 26 | case 'light/command': 27 | this.setSwitchState(message) 28 | break; 29 | case 'light/brightness_command': 30 | this.setSwitchLevel(message) 31 | break; 32 | default: 33 | this.debug(`Received message to unknown command topic: ${command}`) 34 | } 35 | } 36 | 37 | // Set switch target state on received MQTT command message 38 | setSwitchState(message) { 39 | this.debug(`Received set switch state ${message}`) 40 | const command = message.toLowerCase() 41 | switch(command) { 42 | case 'on': 43 | case 'off': { 44 | this.device.setInfo({ device: { v1: { on: Boolean(command === 'on') } } }) 45 | break; 46 | } 47 | default: 48 | this.debug('Received invalid command for switch!') 49 | } 50 | } 51 | 52 | // Set switch target state on received MQTT command message 53 | setSwitchLevel(message) { 54 | const level = message 55 | this.debug(`Received set switch level to ${level}%`) 56 | if (isNaN(message)) { 57 | this.debug('Brightness command received but not a number!') 58 | } else if (!(message >= 0 && message <= 100)) { 59 | this.debug('Brightness command received but out of range (0-100)!') 60 | } else { 61 | this.device.setInfo({ device: { v1: { level: level / 100 } } }) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /devices/panic-button.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class PanicButton extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Panic Button' 7 | 8 | 9 | // Listen to raw data updates for all devices and log any events 10 | this.device.location.onDataUpdate.subscribe(async (message) => { 11 | if (!this.isOnline()) { return } 12 | 13 | if (message.datatype === 'DeviceInfoDocType' && 14 | message.body?.[0]?.general?.v2?.zid === this.deviceId 15 | ) { 16 | this.debug(JSON.stringify(message), 'data') 17 | } 18 | }) 19 | 20 | } 21 | 22 | publishState() { 23 | // This device only has attributes and attribute based entities 24 | this.publishAttributes() 25 | } 26 | } -------------------------------------------------------------------------------- /devices/range-extender.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class RangeExtender extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm', 'acStatus') 6 | this.deviceData.mdl = 'Z-Wave Range Extender' 7 | this.deviceData.name = this.device.location.name + ' Range Extender' 8 | } 9 | 10 | publishState() { 11 | // This device only has attributes and attribute based entities 12 | this.publishAttributes() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /devices/security-panel.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | import { allAlarmStates } from 'ring-client-api' 3 | import utils from '../lib/utils.js' 4 | import state from '../lib/state.js' 5 | 6 | export default class SecurityPanel extends RingSocketDevice { 7 | constructor(deviceInfo) { 8 | super(deviceInfo, 'alarm', 'alarmState') 9 | this.deviceData.mdl = 'Alarm Control Panel' 10 | this.deviceData.name = `${this.device.location.name} Alarm` 11 | 12 | this.bypassCapableDevices = deviceInfo.bypassCapableDevices 13 | 14 | this.data = { 15 | publishedState: this.ringModeToMqttState(), 16 | attributes: { 17 | alarmClearedBy: '', 18 | alarmClearedTime: '', 19 | entrySecondsLeft: 0, 20 | exitSecondsLeft: 0, 21 | lastArmedBy: 'Unknown', 22 | lastArmedTime: '', 23 | lastDisarmedBy: 'Unknown', 24 | lastDisarmedTime: '', 25 | targetState: this.ringModeToMqttState(this.device.data.mode), 26 | } 27 | } 28 | 29 | this.entity = { 30 | ...this.entity, 31 | alarm: { 32 | component: 'alarm_control_panel', 33 | attributes: true, 34 | isMainEntity: true 35 | }, 36 | siren: { 37 | component: 'switch', 38 | icon: 'mdi:alarm-light', 39 | name: `Siren` 40 | }, 41 | ...utils.config().enable_panic ? { 42 | police: { 43 | component: 'switch', 44 | name: `Panic - Police`, 45 | icon: 'mdi:police-badge' 46 | }, 47 | fire: { 48 | component: 'switch', 49 | name: `Panic - Fire`, 50 | icon: 'mdi:fire' 51 | } 52 | } : {} 53 | } 54 | 55 | this.initAlarmAttributes() 56 | 57 | // Listen to raw data updates for all devices and pick out 58 | // arm/disarm and countdown events for this security panel 59 | this.device.location.onDataUpdate.subscribe(async (message) => { 60 | if (!this.isOnline()) { return } 61 | 62 | if (message.datatype === 'DeviceInfoDocType' && 63 | message.body?.[0]?.general?.v2?.zid === this.deviceId && 64 | message.body[0].impulse?.v1?.[0] && 65 | message.body[0].impulse.v1.filter(i => 66 | i.impulseType.match('security-panel.mode-switched.') || 67 | i.impulseType.match('security-panel.exit-delay') || 68 | i.impulseType.match('security-panel.alarm-cleared') 69 | ).length > 0 70 | ) { 71 | this.processAlarmMode(message) 72 | } 73 | 74 | if (message.datatype === 'PassthruType' && 75 | message.body?.[0]?.zid === this.deviceId && 76 | message.body?.[0]?.type === 'security-panel.countdown' && 77 | message.body[0]?.data 78 | ) { 79 | this.processCountdown(message.body[0].data) 80 | } 81 | }) 82 | } 83 | 84 | // Convert Ring alarm modes to Home Asisstan Alarm Control Panel MQTT state 85 | ringModeToMqttState(mode) { 86 | // If using actual device mode, return arming/pending/triggered states 87 | if (!mode) { 88 | if (this.device.data.mode.match(/some|all/) && (this.device.data?.transitionDelayEndTimestamp - Date.now() > 0)) { 89 | return 'arming' 90 | } else if (allAlarmStates.includes(this.device.data.alarmInfo?.state)) { 91 | return this.device.data.alarmInfo.state === 'entry-delay' ? 'pending' : 'triggered' 92 | } 93 | } 94 | 95 | // If mode was passed to the function use it, oterwise use currently active device mode 96 | switch (mode ? mode : this.device.data.mode) { 97 | case 'none': 98 | return 'disarmed' 99 | case 'some': 100 | return 'armed_home' 101 | case 'all': 102 | return 'armed_away' 103 | default: 104 | return 'unknown' 105 | } 106 | } 107 | 108 | async initAlarmAttributes() { 109 | const alarmEvents = await this.device.location.getHistory({ affectedId: this.deviceId }) 110 | const armEvents = alarmEvents.filter(e => 111 | Array.isArray(e.body?.[0]?.impulse?.v1) && 112 | e.body[0].impulse.v1.filter(i => 113 | i.impulseType.match(/security-panel\.mode-switched\.(?:some|all)/) 114 | ).length > 0 115 | ) 116 | if (armEvents.length > 0) { 117 | this.updateAlarmAttributes(armEvents[0], 'Armed') 118 | } 119 | 120 | const disarmEvents = alarmEvents.filter(e => 121 | Array.isArray(e.body?.[0]?.impulse?.v1) && 122 | e.body[0].impulse.v1.filter(i => 123 | i.impulseType.match(/security-panel\.mode-switched\.none/) 124 | ).length > 0 125 | ) 126 | if (disarmEvents.length > 0) { 127 | this.updateAlarmAttributes(disarmEvents[0], 'Disarmed') 128 | } 129 | } 130 | 131 | async updateAlarmAttributes(message, attrPrefix) { 132 | let initiatingUser = message.context.initiatingEntityName 133 | ? message.context.initiatingEntityName 134 | : message.context.initiatingEntityType 135 | 136 | if (message.context.initiatingEntityType === 'user' && message.context.initiatingEntityId) { 137 | try { 138 | const userInfo = await this.getUserInfo(message.context.initiatingEntityId) 139 | if (userInfo) { 140 | initiatingUser = `${userInfo.firstName} ${userInfo.lastName}` 141 | } else { 142 | throw new Error('Invalid user information was returned by API') 143 | } 144 | } catch (err) { 145 | this.debug(err.message) 146 | this.debug('Could not get user information from Ring API') 147 | } 148 | } 149 | 150 | this.data.attributes[`${attrPrefix}By`] = initiatingUser 151 | this.data.attributes[`${attrPrefix}Time`] = message?.context?.eventOccurredTsMs 152 | ? new Date(message.context.eventOccurredTsMs).toISOString() 153 | : new Date(now).toISOString() 154 | } 155 | 156 | async processAlarmMode(message) { 157 | // Pending and triggered modes are handled by publishState() 158 | const { impulseType } = message.body[0].impulse.v1.find(i => i.impulseType.match(/some|all|none|exit-delay|alarm-cleared/)) 159 | switch(impulseType.split('.').pop()) { 160 | case 'some': 161 | case 'all': 162 | case 'exit-delay': 163 | this.data.attributes.targetState = this.ringModeToMqttState() === 'arming' 164 | ? this.ringModeToMqttState(this.device.data.mode) 165 | : this.ringModeToMqttState() 166 | await this.updateAlarmAttributes(message, 'lastArmed') 167 | break; 168 | case 'alarm-cleared': 169 | this.data.attributes.targetState = this.ringModeToMqttState() 170 | await this.updateAlarmAttributes(message, 'alarmCleared') 171 | break; 172 | case 'none': 173 | this.data.attributes.targetState = this.ringModeToMqttState() 174 | await this.updateAlarmAttributes(message, 'lastDisarmed') 175 | } 176 | this.publishAlarmState() 177 | } 178 | 179 | processCountdown(countdown) { 180 | if (countdown) { 181 | if (countdown.transition === 'exit') { 182 | this.data.attributes.entrySecondsLeft = 0 183 | this.data.attributes.exitSecondsLeft = countdown.timeLeft 184 | } else { 185 | this.data.attributes.entrySecondsLeft = countdown.timeLeft 186 | this.data.attributes.exitSecondsLeft = 0 187 | } 188 | 189 | // Suppress attribute publish if countdown event comes before mode switch 190 | if (this.data.publishedState !== this.data.attributes.targetState) { 191 | this.publishAlarmAttributes() 192 | } 193 | } 194 | } 195 | 196 | publishState(data) { 197 | const isPublish = Boolean(data === undefined) 198 | 199 | // Publish alarm states for events not handled by processAlarmMode() as well as 200 | // any explicit publish requests 201 | if (this.ringModeToMqttState().match(/pending|triggered/) || isPublish) { 202 | this.publishAlarmState() 203 | } 204 | 205 | const sirenState = (this.device.data.siren?.state === 'on') ? 'ON' : 'OFF' 206 | this.mqttPublish(this.entity.siren.state_topic, sirenState) 207 | 208 | if (utils.config().enable_panic) { 209 | const policeState = this.device.data.alarmInfo?.state?.match(/burglar|panic/) ? 'ON' : 'OFF' 210 | if (policeState === 'ON') { 211 | this.debug('Burglar alarm is triggered for ' + this.device.location.name) 212 | } 213 | this.mqttPublish(this.entity.police.state_topic, policeState) 214 | 215 | const fireState = this.device.data.alarmInfo?.state?.match(/co|fire/) ? 'ON' : 'OFF' 216 | if (fireState === 'ON') { 217 | this.debug('Fire alarm is triggered for ' + this.device.location.name) 218 | } 219 | this.mqttPublish(this.entity.fire.state_topic, fireState) 220 | } 221 | } 222 | 223 | publishAlarmState() { 224 | this.data.publishedState = this.ringModeToMqttState() 225 | this.mqttPublish(this.entity.alarm.state_topic, this.data.publishedState) 226 | this.publishAlarmAttributes() 227 | this.publishAttributes() 228 | } 229 | 230 | publishAlarmAttributes() { 231 | // If published state is not a state with a countdown timer, zero out entry/exit times 232 | if (!this.data.publishedState.match(/arming|pending/)) { 233 | this.data.attributes.entrySecondsLeft = 0 234 | this.data.attributes.exitSecondsLeft = 0 235 | } 236 | this.mqttPublish(this.entity.alarm.json_attributes_topic, JSON.stringify(this.data.attributes), 'attr') 237 | } 238 | 239 | // Process messages from MQTT command topic 240 | processCommand(command, message) { 241 | const entityKey = command.split('/')[0] 242 | switch (command) { 243 | case 'alarm/command': 244 | this.setAlarmMode(message) 245 | break; 246 | case 'siren/command': 247 | this.setSirenMode(message) 248 | break; 249 | case 'police/command': 250 | if (this.entity.hasOwnProperty(entityKey)) { 251 | this.setPoliceMode(message) 252 | } 253 | break; 254 | case 'fire/command': 255 | if (this.entity.hasOwnProperty(entityKey)) { 256 | this.setFireMode(message) 257 | } 258 | break; 259 | default: 260 | this.debug(`Received message to unknown command topic: ${command}`) 261 | } 262 | } 263 | 264 | // Set Alarm Mode on received MQTT command message 265 | async setAlarmMode(message) { 266 | this.debug(`Received set alarm mode ${message} for location ${this.device.location.name} (${this.locationId})`) 267 | 268 | // Try to set alarm mode and retry after delay if mode set fails 269 | // Performing initial arming attempt with no delay 270 | let retries = 5 271 | let setAlarmSuccess = false 272 | while (retries-- > 0 && !(setAlarmSuccess)) { 273 | let bypassDevices = new Array() 274 | 275 | if (message.toLowerCase() !== 'disarm') { 276 | // During arming, check for sensors that require bypass 277 | // Get all devices that allow bypass 278 | const savedStates = state.getAllSavedStates() 279 | 280 | bypassDevices = this.bypassCapableDevices 281 | .filter((device) => { 282 | return savedStates[device.id]?.bypass_mode === 'Always' || 283 | (savedStates[device.id]?.bypass_mode === 'Faulted' && device.data.faulted) 284 | }).map((d) => { 285 | return { name: `${d.name} [${savedStates[d.id].bypass_mode}]`, id: d.id } 286 | }) 287 | 288 | if (bypassDevices.length > 0) { 289 | this.debug(`The following sensors will be bypassed [Reason]: ${bypassDevices.map(d => d.name).join(', ')}`) 290 | } else { 291 | this.debug('No sensors will be bypased') 292 | } 293 | } 294 | 295 | setAlarmSuccess = await this.trySetAlarmMode(message, bypassDevices.map(d => d.id)) 296 | 297 | // On failure delay 10 seconds for next set attempt 298 | if (!setAlarmSuccess) { await utils.sleep(10) } 299 | } 300 | 301 | // Check the return status and print some debugging for failed states 302 | if (!setAlarmSuccess) { 303 | this.debug('Alarm could not enter proper arming mode after all retries...Giving up!') 304 | } else if (setAlarmSuccess == 'unknown') { 305 | this.debug('Unknown alarm arming mode requested.') 306 | } 307 | } 308 | 309 | async trySetAlarmMode(message, bypassDeviceIds) { 310 | let alarmTargetMode 311 | this.debug(`Set alarm mode: ${message}`) 312 | switch(message.toLowerCase()) { 313 | case 'disarm': 314 | this.device.location.disarm().catch(err => { this.debug(err) }) 315 | alarmTargetMode = 'none' 316 | break 317 | case 'arm_home': 318 | this.device.location.armHome(bypassDeviceIds).catch(err => { this.debug(err) }) 319 | alarmTargetMode = 'some' 320 | break 321 | case 'arm_away': 322 | this.device.location.armAway(bypassDeviceIds).catch(err => { this.debug(err) }) 323 | alarmTargetMode = 'all' 324 | break 325 | default: 326 | this.debug('Cannot set alarm mode: Unknown') 327 | return 'unknown' 328 | } 329 | 330 | // Sleep a few seconds and check if alarm entered requested mode 331 | await utils.sleep(1); 332 | if (this.device.data.mode == alarmTargetMode) { 333 | this.debug(`Alarm for location ${this.device.location.name} successfully entered ${message} mode`) 334 | return true 335 | } else { 336 | this.debug(`Alarm for location ${this.device.location.name} failed to enter requested arm/disarm mode!`) 337 | return false 338 | } 339 | } 340 | 341 | async setSirenMode(message) { 342 | switch(message.toLowerCase()) { 343 | case 'on': 344 | this.debug(`Activating siren for ${this.device.location.name}`) 345 | this.device.location.soundSiren().catch(err => { this.debug(err) }) 346 | break; 347 | case 'off': { 348 | this.debug(`Deactivating siren for ${this.device.location.name}`) 349 | this.device.location.silenceSiren().catch(err => { this.debug(err) }) 350 | break; 351 | } 352 | default: 353 | this.debug('Received invalid command for siren!') 354 | } 355 | } 356 | 357 | async setPoliceMode(message) { 358 | switch(message.toLowerCase()) { 359 | case 'on': 360 | this.debug(`Activating burglar alarm for ${this.device.location.name}`) 361 | this.device.location.triggerBurglarAlarm().catch(err => { this.debug(err) }) 362 | break; 363 | case 'off': { 364 | this.debug(`Deactivating burglar alarm for ${this.device.location.name}`) 365 | this.device.location.setAlarmMode('none').catch(err => { this.debug(err) }) 366 | break; 367 | } 368 | default: 369 | this.debug('Received invalid command for panic!') 370 | } 371 | } 372 | 373 | async setFireMode(message) { 374 | switch(message.toLowerCase()) { 375 | case 'on': 376 | this.debug(`Activating fire alarm for ${this.device.location.name}`) 377 | this.device.location.triggerFireAlarm().catch(err => { this.debug(err) }) 378 | break; 379 | case 'off': { 380 | this.debug(`Deactivating fire alarm for ${this.device.location.name}`) 381 | this.device.location.setAlarmMode('none').catch(err => { this.debug(err) }) 382 | break; 383 | } 384 | default: 385 | this.debug('Received invalid command for panic!') 386 | } 387 | } 388 | } 389 | -------------------------------------------------------------------------------- /devices/siren.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class Siren extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = (this.device.data.deviceType === 'siren.outdoor-strobe') ? 'Outdoor Siren' : 'Siren' 7 | this.entity = { 8 | ...this.entity, 9 | siren: { 10 | component: 'switch', 11 | icon: 'mdi:alarm-light', 12 | isMainEntity: true 13 | }, 14 | ...(this.device.data.deviceType === 'siren.outdoor-strobe') ? { 15 | volume: { 16 | component: 'number', 17 | category: 'diagnostic', 18 | min: 0, 19 | max: 4, 20 | mode: 'slider', 21 | icon: 'hass:volume-high' 22 | } 23 | } : {} 24 | } 25 | } 26 | 27 | publishState(data) { 28 | const isPublish = Boolean(data === undefined) 29 | if (isPublish) { 30 | // Eventually remove this but for now this attempts to delete the old siren binary_sensor 31 | this.mqttPublish('homeassistant/binary_sensor/'+this.locationId+'/'+this.deviceId+'_siren/config', '', false) 32 | } 33 | 34 | const sirenState = this.device.data.sirenStatus === 'active' ? 'ON' : 'OFF' 35 | this.mqttPublish(this.entity.siren.state_topic, sirenState) 36 | if (this.entity.hasOwnProperty('volume')) { 37 | const currentVolume = (this.device.data.volume && !isNaN(this.device.data.volume) ? Math.round(1 * this.device.data.volume) : 0) 38 | this.mqttPublish(this.entity.volume.state_topic, currentVolume) 39 | } 40 | this.publishAttributes() 41 | } 42 | 43 | // Process messages from MQTT command topic 44 | processCommand(command, message) { 45 | switch (command) { 46 | case 'siren/command': 47 | this.setSirenState(message) 48 | break; 49 | case 'volume/command': 50 | if (this.entity.hasOwnProperty('volume')) { 51 | this.setVolumeLevel(message) 52 | } 53 | break; 54 | default: 55 | this.debug(`Received message to unknown command topic: ${command}`) 56 | } 57 | } 58 | 59 | setSirenState(message) { 60 | const command = message.toLowerCase() 61 | switch(command) { 62 | case 'on': 63 | case 'off': 64 | this.debug(`Received set siren state ${message}`) 65 | if (this.device.data.deviceType === 'siren.outdoor-strobe') { 66 | this.device.sendCommand((command ==='on') ? 'siren-test.start' : 'siren-test.stop') 67 | } else { 68 | this.device.setInfo({ device: { v1: { on: Boolean(command === 'on') } } }) 69 | } 70 | break; 71 | default: 72 | this.debug('Received invalid siren state command') 73 | } 74 | } 75 | 76 | // Set volume level on received MQTT command message 77 | setVolumeLevel(message) { 78 | const volume = message / 1 79 | this.debug(`Received set volume level to ${volume}`) 80 | if (isNaN(message)) { 81 | this.debug('Volume command received but value is not a number') 82 | } else if (!(message >= 0 && message <= 4)) { 83 | this.debug('Volume command received but out of range (0-4)') 84 | } else { 85 | this.device.setInfo({ device: { v1: { volume } } }) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /devices/smoke-alarm.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class SmokeAlarm extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Smoke Alarm' 7 | 8 | // Combination Smoke/CO alarm is handled as separate devices (same as Ring app) 9 | // Delete childDevices key here to prevent duplicate discovery entries in log 10 | if (this.hasOwnProperty('childDevices')) { 11 | delete this.childDevices 12 | } 13 | 14 | this.entity.smoke = { 15 | component: 'binary_sensor', 16 | device_class: 'smoke', 17 | isMainEntity: true 18 | } 19 | } 20 | 21 | publishState() { 22 | const smokeState = this.device.data.alarmStatus === 'active' ? 'ON' : 'OFF' 23 | this.mqttPublish(this.entity.smoke.state_topic, smokeState) 24 | this.publishAttributes() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /devices/smoke-co-listener.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class SmokeCoListener extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Smoke & CO Listener' 7 | 8 | this.entity.smoke = { 9 | component: 'binary_sensor', 10 | device_class: 'smoke' 11 | } 12 | this.entity.co = { 13 | component: 'binary_sensor', 14 | device_class: 'gas', 15 | name: `CO`, 16 | unique_id: `${this.deviceId}_gas` // Force backward compatible unique ID for this entity 17 | } 18 | } 19 | 20 | publishState() { 21 | const smokeState = this.device.data.smoke && this.device.data.smoke.alarmStatus === 'active' ? 'ON' : 'OFF' 22 | const coState = this.device.data.co && this.device.data.co.alarmStatus === 'active' ? 'ON' : 'OFF' 23 | this.mqttPublish(this.entity.smoke.state_topic, smokeState) 24 | this.mqttPublish(this.entity.co.state_topic, coState) 25 | this.publishAttributes() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /devices/switch.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class Switch extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = (this.device.data.categoryId === 2) ? 'Light' : 'Switch' 7 | this.component = (this.device.data.categoryId === 2) ? 'light' : 'switch' 8 | 9 | this.entity[this.component] = { 10 | component: this.component, 11 | isMainEntity: true 12 | } 13 | } 14 | 15 | publishState() { 16 | this.mqttPublish(this.entity[this.component].state_topic, this.device.data.on ? "ON" : "OFF") 17 | this.publishAttributes() 18 | } 19 | 20 | // Process messages from MQTT command topic 21 | processCommand(command, message) { 22 | switch (command) { 23 | case 'switch/command': 24 | case 'light/command': 25 | this.setSwitchState(message) 26 | break; 27 | default: 28 | this.debug(`Received message to unknown command topic: ${command}`) 29 | } 30 | } 31 | 32 | // Set switch target state on received MQTT command message 33 | setSwitchState(message) { 34 | const command = message.toLowerCase() 35 | switch(command) { 36 | case 'on': 37 | case 'off': 38 | this.debug(`Received set switch state ${message}`) 39 | this.device.setInfo({ device: { v1: { on: Boolean(command === 'on') } } }) 40 | break; 41 | default: 42 | this.debug(`Received invalid switch state command`) 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /devices/temperature-sensor.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class TemperatureSensor extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Temperature Sensor' 7 | 8 | this.entity.temperature = { 9 | component: 'sensor', 10 | device_class: 'temperature', 11 | unit_of_measurement: '°C', 12 | state_class: 'measurement' 13 | } 14 | } 15 | 16 | publishState() { 17 | const temperature = this.device.data.celsius.toString() 18 | this.mqttPublish(this.entity.temperature.state_topic, temperature) 19 | this.publishAttributes() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /devices/thermostat.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | import { RingDeviceType } from 'ring-client-api' 3 | import utils from '../lib/utils.js' 4 | 5 | export default class Thermostat extends RingSocketDevice { 6 | constructor(deviceInfo) { 7 | super(deviceInfo, 'alarm') 8 | this.deviceData.mdl = 'Thermostat' 9 | 10 | this.operatingStatus = this.childDevices.find(d => d.deviceType === 'thermostat-operating-status'), 11 | this.temperatureSensor = this.childDevices.find(d => d.deviceType === RingDeviceType.TemperatureSensor) 12 | 13 | this.entity.thermostat = { 14 | component: 'climate', 15 | modes: Object.keys(this.device.data.modeSetpoints).filter(mode => ["off", "cool", "heat", "auto"].includes(mode)), 16 | fan_modes: this.device.data.hasOwnProperty('supportedFanModes') 17 | ? this.device.data.supportedFanModes.map(f => f.charAt(0).toUpperCase() + f.slice(1)) 18 | : ["Auto"] 19 | } 20 | 21 | this.data = { 22 | currentMode: (() => { 23 | return this.device.data.mode === 'aux' ? 'heat' : this.device.data.mode 24 | }), 25 | publishedMode: false, 26 | fanMode: (() => { 27 | return this.device.data.fanMode.replace(/^./, str => str.toUpperCase()) 28 | }), 29 | presetMode: (() => { 30 | return this.device.data.mode === 'aux' ? 'Auxillary' : 'None' 31 | }), 32 | setPoint: (() => { 33 | return this.device.data.setPoint 34 | ? this.device.data.setPoint 35 | : this.temperatureSensor.data.celsius 36 | }), 37 | operatingMode: (() => { 38 | return this.operatingStatus.data.operatingMode !== 'off' 39 | ? `${this.operatingStatus.data.operatingMode}ing` 40 | : this.device.data.mode === 'off' 41 | ? 'off' 42 | : this.device.data.fanMode === 'on' ? 'fan' : 'idle' 43 | }), 44 | temperature: (() => { 45 | return this.temperatureSensor.data.celsius 46 | }), 47 | ...this.entity.thermostat.modes.includes('auto') 48 | ? { 49 | autoSetPointInProgress: false, 50 | autoSetPoint: { 51 | low: this.device.data.modeSetpoints.auto.setPoint-this.device.data.modeSetpoints.auto.deadBand, 52 | high: this.device.data.modeSetpoints.auto.setPoint+this.device.data.modeSetpoints.auto.deadBand 53 | }, 54 | deadBandMin: this.device.data.modeSetpoints.auto.deadBandMin ? this.device.data.modeSetpoints.auto.deadBandMin : 1.11111 55 | } : {} 56 | } 57 | 58 | this.operatingStatus.onData.subscribe(() => { 59 | if (this.isOnline()) { 60 | this.publishOperatingMode() 61 | this.publishAttributes() 62 | } 63 | }) 64 | 65 | this.temperatureSensor.onData.subscribe(() => { 66 | if (this.isOnline()) { 67 | this.publishTemperature() 68 | this.publishAttributes() 69 | } 70 | }) 71 | } 72 | 73 | async publishState(data) { 74 | const isPublish = Boolean(data === undefined) 75 | 76 | this.publishModeAndSetpoints() 77 | this.mqttPublish(this.entity.thermostat.fan_mode_state_topic, this.data.fanMode()) 78 | this.mqttPublish(this.entity.thermostat.preset_mode_state_topic, this.data.presetMode()) 79 | this.publishOperatingMode() 80 | 81 | if (isPublish) { this.publishTemperature() } 82 | this.publishAttributes() 83 | } 84 | 85 | publishModeAndSetpoints() { 86 | const mode = this.data.currentMode() 87 | 88 | // Publish new mode 89 | this.mqttPublish(this.entity.thermostat.mode_state_topic, mode) 90 | 91 | // Publish setpoints for mode 92 | if (mode === 'auto') { 93 | // When in auto mode publish separate low/high set point values. The Ring API 94 | // does not use low/high settings, but rather uses a single setpoint with deadBand 95 | // representing the low/high temp offset from the set point 96 | if (!this.data.setPointInProgress) { 97 | // Only publish state if there are no pending setpoint commands in progress 98 | // since update commands take ~100ms to complete and always publish new state 99 | // as soon as the update is completed 100 | this.data.autoSetPoint.low = this.device.data.setPoint-this.device.data.deadBand 101 | this.data.autoSetPoint.high = this.device.data.setPoint+this.device.data.deadBand 102 | this.mqttPublish(this.entity.thermostat.temperature_low_state_topic, this.data.autoSetPoint.low) 103 | this.mqttPublish(this.entity.thermostat.temperature_high_state_topic, this.data.autoSetPoint.high) 104 | } 105 | } else if (mode !== 'off') { 106 | this.mqttPublish(this.entity.thermostat.temperature_state_topic, this.data.setPoint()) 107 | } 108 | 109 | // Clear any unused setpoints from previous mode 110 | if (mode !== this.data.publishedMode) { 111 | if (mode === 'off') { 112 | this.mqttPublish(this.entity.thermostat.temperature_state_topic, 'None') 113 | this.mqttPublish(this.entity.thermostat.temperature_low_state_topic, 'None') 114 | this.mqttPublish(this.entity.thermostat.temperature_high_state_topic, 'None') 115 | } else if (this.entity.thermostat.modes.includes('auto') && mode !== this.data.publishedMode) { 116 | if (mode === 'auto') { 117 | this.mqttPublish(this.entity.thermostat.temperature_state_topic, 'None') 118 | } else if (this.data.publishedMode === 'auto') { 119 | this.mqttPublish(this.entity.thermostat.temperature_low_state_topic, 'None') 120 | this.mqttPublish(this.entity.thermostat.temperature_high_state_topic, 'None') 121 | } 122 | } 123 | this.data.publishedMode = mode 124 | } 125 | } 126 | 127 | publishOperatingMode() { 128 | this.mqttPublish(this.entity.thermostat.action_topic, this.data.operatingMode()) 129 | } 130 | 131 | publishTemperature() { 132 | this.mqttPublish(this.entity.thermostat.current_temperature_topic, this.data.temperature()) 133 | } 134 | 135 | // Process messages from MQTT command topic 136 | processCommand(command, message) { 137 | switch (command) { 138 | case 'thermostat/mode_command': 139 | this.setMode(message) 140 | break; 141 | case 'thermostat/temperature_command': 142 | this.setSetPoint(message) 143 | break; 144 | case 'thermostat/temperature_low_command': 145 | this.setAutoSetPoint(message, 'low') 146 | break; 147 | case 'thermostat/temperature_high_command': 148 | this.setAutoSetPoint(message, 'high') 149 | break; 150 | case 'thermostat/fan_mode_command': 151 | this.setFanMode(message) 152 | break; 153 | case 'thermostat/preset_mode_command': 154 | this.setPresetMode(message) 155 | break; 156 | default: 157 | this.debug(`Received message to unknown command topic: ${command}`) 158 | } 159 | } 160 | 161 | async setMode(value) { 162 | this.debug(`Received set mode ${value}`) 163 | const mode = value.toLowerCase() 164 | switch(mode) { 165 | case 'off': 166 | this.mqttPublish(this.entity.thermostat.action_topic, mode) 167 | // Fall through 168 | case 'cool': 169 | case 'heat': 170 | case 'auto': 171 | case 'aux': 172 | if (this.entity.thermostat.modes.map(e => e.toLocaleLowerCase()).includes(mode) || mode === 'aux') { 173 | this.device.setInfo({ device: { v1: { mode } } }) 174 | this.mqttPublish(this.entity.thermostat.mode_state_topic, mode) 175 | } 176 | break; 177 | default: 178 | this.debug(`Received invalid set mode command`) 179 | } 180 | } 181 | 182 | async setSetPoint(value) { 183 | const mode = this.data.currentMode() 184 | switch(mode) { 185 | case 'off': 186 | this.debug('Recevied set target temperature but current thermostat mode is off') 187 | break; 188 | case 'auto': 189 | this.debug('Recevied set target temperature but thermostat is in dual setpoint (auto) mode') 190 | break; 191 | default: 192 | if (isNaN(value)) { 193 | this.debug(`Received set target temperature to ${value} which is not a number`) 194 | } else if (!(value >= 10 && value <= 37.22223)) { 195 | this.debug(`Received set target temperature to ${value} which is out of allowed range (10-37.22223°C)`) 196 | } else { 197 | this.debug(`Received set target temperature to ${value}`) 198 | this.device.setInfo({ device: { v1: { setPoint: Number(value) } } }) 199 | this.mqttPublish(this.entity.thermostat.temperature_state_topic, value) 200 | } 201 | } 202 | } 203 | 204 | async setAutoSetPoint(value, type) { 205 | const mode = this.data.currentMode() 206 | switch(mode) { 207 | case 'auto': 208 | if (isNaN(value)) { 209 | this.debug(`Received set auto range ${type} temperature to ${value} which is not a number`) 210 | } else if (!(value >= 10 && value <= 37.22223)) { 211 | this.debug(`Received set auto range ${type} temperature to ${value} which is out of allowed range (10-37.22223°C)`) 212 | } else { 213 | this.debug(`Received set auto range ${type} temperature to ${value}`) 214 | this.data.autoSetPoint[type] = Number(value) 215 | // Home Assistant always sends both low/high values when changing range on dual-setpoint mode 216 | // so this function will be called twice for every change. The code below blocks for 100 milliseconds 217 | // to allow time for the second value to be updated before proceeding to call the set function once. 218 | if (!this.data.setPointInProgress) { 219 | this.data.setPointInProgress = true 220 | await utils.msleep(100) 221 | 222 | const setPoint = (this.data.autoSetPoint.low+this.data.autoSetPoint.high)/2 223 | let deadBand = this.data.autoSetPoint.high-setPoint 224 | 225 | if (deadBand < this.data.deadBandMin) { 226 | // If the difference between the two temps is less than the allowed deadband take the 227 | // setPoint average and add the minimum deadband to the low and high values 228 | deadBand = this.data.deadBandMin 229 | this.data.autoSetPoint.low = setPoint-deadBand 230 | this.data.autoSetPoint.high = setPoint+deadBand 231 | this.debug(`Received auto range temerature is below the minimum allowed deadBand range of ${this.data.deadBandMin}`) 232 | this.debug(`Setting auto range low temperature to ${this.data.autoSetPoint.low} and high temperature to ${this.data.autoSetPoint.high}`) 233 | } 234 | 235 | this.device.setInfo({ device: { v1: { setPoint, deadBand } } }) 236 | this.mqttPublish(this.entity.thermostat.temperature_low_state_topic, this.data.autoSetPoint.low) 237 | this.mqttPublish(this.entity.thermostat.temperature_high_state_topic, this.data.autoSetPoint.high) 238 | this.data.setPointInProgress = false 239 | } 240 | } 241 | break; 242 | case 'off': 243 | this.debug(`Recevied set auto range ${type} temperature but current thermostat mode is off`) 244 | break; 245 | default: 246 | this.debug(`Received set ${type} temperature but thermostat is in single setpoint (cool/heat) mode`) 247 | } 248 | } 249 | 250 | async setFanMode(value) { 251 | this.debug(`Recevied set fan mode ${value}`) 252 | const fanMode = value.toLowerCase() 253 | if (this.entity.thermostat.fan_modes.map(e => e.toLocaleLowerCase()).includes(fanMode)) { 254 | this.device.setInfo({ device: { v1: { fanMode }}}) 255 | this.mqttPublish(this.entity.thermostat.fan_mode_state_topic, fanMode.replace(/^./, str => str.toUpperCase())) 256 | } else { 257 | this.debug('Received invalid fan mode command') 258 | } 259 | } 260 | 261 | async setPresetMode(value) { 262 | this.debug(`Received set preset mode ${value}`) 263 | const presetMode = value.toLowerCase() 264 | switch(presetMode) { 265 | case 'auxillary': 266 | case 'none': { 267 | const mode = presetMode === 'auxillary' ? 'aux' : 'heat' 268 | this.device.setInfo({ device: { v1: { mode } } }) 269 | this.mqttPublish(this.entity.thermostat.preset_mode_state_topic, presetMode.replace(/^./, str => str.toUpperCase())) 270 | break; 271 | } 272 | default: 273 | this.debug('Received invalid preset mode command') 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /devices/valve.js: -------------------------------------------------------------------------------- 1 | import RingSocketDevice from './base-socket-device.js' 2 | 3 | export default class Valve extends RingSocketDevice { 4 | constructor(deviceInfo) { 5 | super(deviceInfo, 'alarm') 6 | this.deviceData.mdl = 'Water Valve' 7 | 8 | this.entity.valve = { 9 | component: 'valve' 10 | } 11 | } 12 | 13 | publishState() { 14 | let valveState 15 | switch(this.device.data.valveState) { 16 | case 'open': 17 | case 'closed': 18 | valveState = this.device.data.valveState 19 | break; 20 | default: 21 | // HA doesn't support broken state so setting unknown state is the best we can do 22 | valveState = 'None' 23 | } 24 | this.mqttPublish(this.entity.valve.state_topic, valveState) 25 | this.publishAttributes() 26 | } 27 | 28 | // Process messages from MQTT command topic 29 | processCommand(command, message) { 30 | switch (command) { 31 | case 'valve/command': 32 | this.setValveState(message) 33 | break; 34 | default: 35 | this.debug(`Received message to unknown command topic: ${command}`) 36 | } 37 | } 38 | 39 | // Set valve target state on received MQTT command message 40 | setValveState(message) { 41 | this.debug(`Received set valve state ${message}`) 42 | const command = message.toLowerCase() 43 | switch(command) { 44 | case 'open': 45 | case 'close': { 46 | let valveState = command === 'open' ? 'opening' : 'closing' 47 | this.mqttPublish(this.entity.valve.state_topic, valveState) 48 | this.device.sendCommand(`valve.${command}`) 49 | break; 50 | } 51 | default: 52 | this.debug('Received invalid command for valve') 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import pluginJs from "@eslint/js"; 3 | 4 | 5 | export default [ 6 | { 7 | languageOptions: { 8 | globals: globals.node 9 | } 10 | }, 11 | pluginJs.configs.recommended, 12 | { 13 | rules: { 14 | "no-prototype-builtins": "off" 15 | } 16 | } 17 | ]; -------------------------------------------------------------------------------- /images/ring-mqtt-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsightler/ring-mqtt/f00d9bb3c438a8ba8b2413102723313f747bd08a/images/ring-mqtt-icon.png -------------------------------------------------------------------------------- /images/ring-mqtt-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsightler/ring-mqtt/f00d9bb3c438a8ba8b2413102723313f747bd08a/images/ring-mqtt-logo.png -------------------------------------------------------------------------------- /init-ring-mqtt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import fs from 'fs' 3 | import { dirname } from 'path' 4 | import { fileURLToPath } from 'url' 5 | import { readFile } from 'fs/promises' 6 | import writeFileAtomic from 'write-file-atomic' 7 | import { createHash, randomBytes } from 'crypto' 8 | import { RingRestClient } from 'ring-client-api/rest-client' 9 | import { requestInput } from './node_modules/ring-client-api/lib/util.js' 10 | 11 | async function getRefreshToken(systemId) { 12 | let generatedToken 13 | const email = await requestInput('Email: ') 14 | const password = await requestInput('Password: ') 15 | const restClient = new RingRestClient({ 16 | email, 17 | password, 18 | controlCenterDisplayName: `ring-mqtt-${systemId.slice(-5)}`, 19 | systemId: systemId 20 | }) 21 | try { 22 | await restClient.getCurrentAuth() 23 | } catch(err) { 24 | if (restClient.using2fa) { 25 | console.log('Username/Password was accepted, waiting for 2FA code to be entered.') 26 | } else { 27 | throw(err.message) 28 | } 29 | } 30 | 31 | while(!generatedToken) { 32 | const code = await requestInput('2FA Code: ') 33 | try { 34 | generatedToken = await restClient.getAuth(code) 35 | return generatedToken.refresh_token 36 | } catch { 37 | throw('Failed to validate the entered 2FA code. (error: invalid_code)') 38 | } 39 | } 40 | } 41 | 42 | const main = async() => { 43 | let refresh_token 44 | let stateData = {} 45 | // If running in Docker set state file path as appropriate 46 | const stateFile = (fs.existsSync('/etc/cont-init.d/ring-mqtt.sh')) 47 | ? '/data/ring-state.json' 48 | : dirname(fileURLToPath(new URL(import.meta.url)))+'/ring-state.json' 49 | 50 | const configFile = (fs.existsSync('/etc/cont-init.d/ring-mqtt.sh')) 51 | ? '/data/config.json' 52 | : dirname(fileURLToPath(new URL(import.meta.url)))+'/config.json' 53 | 54 | if (fs.existsSync(stateFile)) { 55 | console.log('Reading latest data from state file: '+stateFile) 56 | try { 57 | stateData = JSON.parse(await readFile(stateFile)) 58 | } catch(err) { 59 | console.log(err.message) 60 | console.log('Saved state file '+stateFile+' exist but could not be parsed!') 61 | console.log('To create new state file please rename/delete existing file and re-run this tool.') 62 | process.exit(1) 63 | } 64 | } 65 | 66 | if (!stateData.hasOwnProperty('systemId') || (stateData.hasOwnProperty('systemId') && !stateData.systemId)) { 67 | stateData.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex')) 68 | } 69 | 70 | try { 71 | refresh_token = await getRefreshToken(stateData.systemId) 72 | } catch(err) { 73 | console.log(err) 74 | console.log('Please re-run this tool to retry authentication.') 75 | process.exit(1) 76 | } 77 | 78 | stateData.ring_token = refresh_token 79 | 80 | try { 81 | await writeFileAtomic(stateFile, JSON.stringify(stateData)) 82 | console.log(`State file ${stateFile} saved with updated refresh token.`) 83 | console.log(`Device name: ring-mqtt-${stateData.systemId.slice(-5)}`) 84 | } catch (err) { 85 | console.log('Saving state file '+stateFile+' failed with error: ') 86 | console.log(err) 87 | } 88 | 89 | if (!fs.existsSync(configFile)) { 90 | try { 91 | const configData = { 92 | "mqtt_url": "mqtt://localhost:1883", 93 | "mqtt_options": "", 94 | "livestream_user": "", 95 | "livestream_pass": "", 96 | "disarm_code": "", 97 | "enable_cameras": true, 98 | "enable_modes": false, 99 | "enable_panic": false, 100 | "hass_topic": "homeassistant/status", 101 | "ring_topic": "ring", 102 | "location_ids": [] 103 | } 104 | 105 | const mqttUrl = await requestInput('MQTT URL (enter to skip and edit config manually): ') 106 | configData.mqtt_url = mqttUrl ? mqttUrl : configData.mqtt_url 107 | 108 | await writeFileAtomic(configFile, JSON.stringify(configData, null, 4)) 109 | console.log('New config file written to '+configFile) 110 | } catch (err) { 111 | console.log('Failed to create new config file at '+stateFile) 112 | console.log(err) 113 | } 114 | } 115 | } 116 | 117 | main() 118 | -------------------------------------------------------------------------------- /init/s6/cont-init.d/ring-mqtt.sh: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bashio 2 | 3 | # ============================================================================= 4 | # ring-mqtt run script for s6-init # 5 | # 6 | # This script automatically detects if it is running as the Home Assistant 7 | # addon or a standard docker environment and takes actions as appropriate 8 | # for the detected environment. 9 | # ============================================================================== 10 | 11 | # If HASSIO_TOKEN variable exist we are running as addon 12 | if [ -v HASSIO_TOKEN ]; then 13 | RUNMODE_BANNER="Addon for Home Assistant" 14 | # Use bashio to get configured branch 15 | export BRANCH=$(bashio::config "branch") 16 | else 17 | RUNMODE_BANNER="Docker Edition " 18 | fi 19 | 20 | # Short delay to keep log messages from overlapping with s6 logs 21 | sleep .5 22 | 23 | echo "-------------------------------------------------------" 24 | echo "| Ring-MQTT with Video Streaming |" 25 | echo "| ${RUNMODE_BANNER} |" 26 | echo "| |" 27 | echo "| For support questions please visit: |" 28 | echo "| https://github.com/tsightler/ring-mqtt/discussions |" 29 | echo "-------------------------------------------------------" 30 | 31 | if [ -v BRANCH ]; then 32 | if [ "${BRANCH}" = "latest" ] || [ "${BRANCH}" = "dev" ]; then 33 | /app/ring-mqtt/scripts/update2branch.sh 34 | fi 35 | fi 36 | -------------------------------------------------------------------------------- /init/s6/services.d/ring-mqtt/finish: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bashio 2 | # ============================================================================== 3 | # Take down the S6 supervision tree when service fails 4 | # s6-overlay docs: https://github.com/just-containers/s6-overlay 5 | # ============================================================================== 6 | 7 | if [[ "${1}" -ne 0 ]] && [[ "${1}" -ne 256 ]]; then 8 | bashio::log.warning "A critical error was detected, shutting down container..." 9 | /run/s6/basedir/bin/halt 10 | fi 11 | -------------------------------------------------------------------------------- /init/s6/services.d/ring-mqtt/run: -------------------------------------------------------------------------------- 1 | #!/command/with-contenv bashio 2 | 3 | # ============================================================================= 4 | # ring-mqtt run script for s6-init 5 | # 6 | # This script automatically detects if it is running as the Home Assistant 7 | # addon or a standard docker environment and sets configuration variables as 8 | # appropriate. 9 | # ============================================================================== 10 | 11 | # Delay to keep logs messages from overlapping with s6 logs 12 | sleep 1 13 | 14 | # If HASSIO_TOKEN variable exist we are running as addon 15 | if [ -v HASSIO_TOKEN ]; then 16 | # If addon mode is detected but config isn't available exit immediately 17 | bashio::config.require 'mqtt_url' 18 | 19 | export RUNMODE="addon" 20 | export BRANCH=$(bashio::config "branch") 21 | export DEBUG=$(bashio::config "debug") 22 | 23 | # Export MQTT service discovery data for use within NodeJS process 24 | if bashio::services.available 'mqtt'; then 25 | export HAMQTTHOST=$(bashio::services mqtt "host") 26 | export HAMQTTPORT=$(bashio::services mqtt "port") 27 | export HAMQTTUSER=$(bashio::services mqtt "username") 28 | export HAMQTTPASS=$(bashio::services mqtt "password") 29 | fi 30 | 31 | # Export a few helper variables for building the Streaming and Still Image URLs 32 | export HAHOSTNAME=$(bashio::info.hostname) 33 | export ADDONHOSTNAME=$HOSTNAME 34 | else 35 | export RUNMODE="docker" 36 | 37 | # If branch is not explicitly defined, use builtin branch 38 | if [ ! -v BRANCH ]; then 39 | export BRANCH="builtin" 40 | fi 41 | 42 | # If debug is not explicitly defined, use default 43 | if [ ! -v DEBUG ]; then 44 | export DEBUG="ring-*" 45 | fi 46 | fi 47 | 48 | export FORCE_COLOR=2 49 | 50 | if [ "${BRANCH}" = "latest" ] || [ "${BRANCH}" = "dev" ]; then 51 | cd "/app/ring-mqtt-${BRANCH}" 52 | else 53 | cd /app/ring-mqtt 54 | fi 55 | 56 | echo "-------------------------------------------------------" 57 | echo ring-mqtt.js version: $(cat package.json | grep version | cut -f4 -d'"') 58 | echo Node version $(node -v) 59 | echo NPM version $(npm -v) 60 | echo $(git --version) 61 | echo "-------------------------------------------------------" 62 | 63 | echo "Running ring-mqtt..." 64 | exec ./ring-mqtt.js 65 | -------------------------------------------------------------------------------- /init/systemd/ring-mqtt.service: -------------------------------------------------------------------------------- 1 | #!/bin/sh - 2 | [Unit] 3 | Description=ring-mqtt 4 | After=network.target 5 | 6 | [Service] 7 | ExecStart=/usr/bin/node /opt/ring-mqtt/ring-mqtt.js 8 | Restart=always 9 | User=root 10 | Group=root 11 | Environment=PATH=/usr/bin/:/usr/local/bin 12 | Environment=NODE_ENV=production 13 | Environment=DEBUG=ring-mqtt 14 | StandardOutput=file:/var/log/ring-mqtt.log 15 | StandardError=file:/var/log/ring-mqtt.log 16 | WorkingDirectory=/opt/ring-mqtt 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | Alias=ring-mqtt.service 21 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import fs from 'fs' 3 | import { readFile } from 'fs/promises' 4 | import { dirname } from 'path' 5 | import { fileURLToPath } from 'url' 6 | import debugModule from 'debug' 7 | const debug = debugModule('ring-mqtt') 8 | 9 | export default new class Config { 10 | constructor() { 11 | this.data = new Object() 12 | process.env.RUNMODE = process.env.hasOwnProperty('RUNMODE') ? process.env.RUNMODE : 'standard' 13 | debug(`Detected runmode: ${process.env.RUNMODE}`) 14 | this.init() 15 | } 16 | 17 | async init() { 18 | switch (process.env.RUNMODE) { 19 | case 'docker': 20 | this.file = '/data/config.json' 21 | if (fs.existsSync(this.file)) { 22 | await this.loadConfigFile() 23 | } else { 24 | debug(chalk.red(`No configuration file found at ${this.file}`)) 25 | debug(chalk.red('Please map a persistent volume to this location and place a configuration file there.')) 26 | process.exit(1) 27 | } 28 | break; 29 | case 'addon': 30 | this.file = '/data/options.json' 31 | await this.loadConfigFile() 32 | this.doMqttDiscovery() 33 | break; 34 | default: { 35 | const configPath = dirname(fileURLToPath(new URL('.', import.meta.url)))+'/' 36 | this.file = (process.env.RINGMQTT_CONFIG) ? configPath+process.env.RINGMQTT_CONFIG : configPath+'config.json' 37 | await this.loadConfigFile() 38 | } 39 | } 40 | 41 | // If there's still no configured settings, force some defaults. 42 | this.data.ring_topic = this.data.hasOwnProperty('ring_topic') ? this.data.ring_topic : 'ring' 43 | this.data.hass_topic = this.data.hasOwnProperty('hass_topic') ? this.data.hass_topic : 'homeassistant/status' 44 | this.data.enable_cameras = this.data.hasOwnProperty('enable_cameras') ? this.data.enable_cameras : true 45 | this.data.enable_modes = this.data.hasOwnProperty('enable_modes') ? this.data.enable_modes : false 46 | this.data.enable_panic = this.data.hasOwnProperty('enable_panic') ? this.data.enable_panic : false 47 | this.data.disarm_code = this.data.hasOwnProperty('disarm_code') ? this.data.disarm_code : '' 48 | 49 | const mqttURL = new URL(this.data.mqtt_url) 50 | debug(`MQTT URL: ${mqttURL.protocol}//${mqttURL.username ? mqttURL.username+':********@' : ''}${mqttURL.hostname}:${mqttURL.port}`) 51 | } 52 | 53 | // Create CONFIG object from file or envrionment variables 54 | async loadConfigFile() { 55 | debug('Configuration file: '+this.file) 56 | try { 57 | this.data = JSON.parse(await readFile(this.file)) 58 | } catch (err) { 59 | debug(err.message) 60 | debug(chalk.red('Configuration file could not be read, check that it exist and is valid.')) 61 | process.exit(1) 62 | } 63 | } 64 | 65 | doMqttDiscovery() { 66 | try { 67 | // Parse the MQTT URL and resolve any auto configuration 68 | const mqttURL = new URL(this.data.mqtt_url) 69 | if (mqttURL.hostname === "auto_hostname") { 70 | if (mqttURL.protocol === 'mqtt:') { 71 | if (process.env.HAMQTTHOST) { 72 | mqttURL.hostname = process.env.HAMQTTHOST 73 | if (mqttURL.hostname === 'localhost' || mqttURL.hostname === '127.0.0.1') { 74 | debug(`Discovered invalid value for MQTT host: ${mqttURL.hostname}`) 75 | debug('Overriding with default alias for Mosquitto MQTT addon') 76 | mqttURL.hostname = 'core-mosquitto' 77 | } 78 | } else { 79 | debug('No Home Assistant MQTT service found, using Home Assistant hostname as default') 80 | mqttURL.hostname = process.env.HAHOSTNAME 81 | } 82 | } else if (mqttURL.protocol === 'mqtts:') { 83 | mqttURL.hostname = process.env.HAHOSTNAME 84 | } 85 | debug(`Discovered MQTT Host: ${mqttURL.hostname}`) 86 | } else { 87 | debug(`Configured MQTT Host: ${mqttURL.hostname}`) 88 | } 89 | 90 | if (!mqttURL.port) { 91 | mqttURL.port = mqttURL.protocol === 'mqtts:' ? '8883' : '1883' 92 | debug(`Discovered MQTT Port: ${mqttURL.port}`) 93 | } else { 94 | debug(`Configured MQTT Port: ${mqttURL.port}`) 95 | } 96 | 97 | if (mqttURL.username === 'auto_username') { 98 | mqttURL.username = process.env.HAMQTTUSER ? process.env.HAMQTTUSER : '' 99 | if (mqttURL.username) { 100 | debug(`Discovered MQTT User: ${mqttURL.username}`) 101 | } else { 102 | mqttURL.username = '' 103 | debug('Using anonymous MQTT connection') 104 | } 105 | } else { 106 | debug(`Configured MQTT User: ${mqttURL.username}`) 107 | } 108 | 109 | if (mqttURL.username) { 110 | if (mqttURL.password === "auto_password") { 111 | mqttURL.password = process.env.HAMQTTPASS ? process.env.HAMQTTPASS : '' 112 | if (mqttURL.password) { 113 | debug('Discovered MQTT password: ') 114 | } 115 | } else { 116 | debug('Configured MQTT password: ') 117 | } 118 | } 119 | 120 | this.data.mqtt_url = mqttURL.href 121 | } catch (err) { 122 | debug(err.message) 123 | debug(chalk.red('MQTT URL could not be parsed, please verify that it is in a valid format.')) 124 | process.exit(1) 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /lib/go2rtc.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import utils from './utils.js' 3 | import { spawn } from 'child_process' 4 | import readline from 'readline' 5 | import yaml from 'js-yaml' 6 | import { dirname } from 'path' 7 | import { fileURLToPath } from 'url' 8 | import writeFileAtomic from 'write-file-atomic' 9 | import debugModule from 'debug' 10 | const debug = debugModule('ring-rtsp') 11 | 12 | export default new class Go2RTC { 13 | constructor() { 14 | this.started = false 15 | this.go2rtcProcess = false 16 | } 17 | 18 | async init(cameras) { 19 | this.started = true 20 | debug(chalk.green('-'.repeat(90))) 21 | debug('Creating go2rtc configuration and starting go2rtc process...') 22 | 23 | const configFile = (process.env.RUNMODE === 'standard') 24 | ? dirname(fileURLToPath(new URL('.', import.meta.url)))+'/config/go2rtc.yaml' 25 | : '/data/go2rtc.yaml' 26 | 27 | let config = { 28 | log: { 29 | level: 'debug', 30 | hass: 'info' 31 | }, 32 | api: { 33 | listen: '' 34 | }, 35 | srtp: { 36 | listen: '' 37 | }, 38 | rtsp: { 39 | listen: ':8554', 40 | ...(utils.config().livestream_user && utils.config().livestream_pass) 41 | ? { 42 | username: utils.config().livestream_user, 43 | password: utils.config().livestream_pass 44 | } : {}, 45 | default_query: 'video&audio=aac&audio=opus' 46 | }, 47 | webrtc: { 48 | listen: '' 49 | } 50 | } 51 | 52 | if (cameras) { 53 | config.streams = {} 54 | for (const camera of cameras) { 55 | config.streams[`${camera.deviceId}_live`] = 56 | `exec:${dirname(fileURLToPath(new URL('.', import.meta.url)))}/scripts/start-stream.sh ${camera.deviceId} live ${camera.deviceTopic} {output}#killsignal=15` 57 | config.streams[`${camera.deviceId}_event`] = 58 | `exec:${dirname(fileURLToPath(new URL('.', import.meta.url)))}/scripts/start-stream.sh ${camera.deviceId} event ${camera.deviceTopic} {output}#killsignal=15` 59 | } 60 | try { 61 | await writeFileAtomic(configFile, yaml.dump(config, { lineWidth: -1 })) 62 | debug('Successfully wrote go2rtc configuration file: '+configFile) 63 | } catch (err) { 64 | debug(chalk.red('Failed to write go2rtc configuration file: '+configFile)) 65 | debug(err.message) 66 | } 67 | } 68 | 69 | this.go2rtcProcess = spawn('go2rtc', ['-config', configFile], { 70 | env: process.env, // set env vars 71 | cwd: '.', // set cwd 72 | stdio: 'pipe' // forward stdio options 73 | }) 74 | 75 | this.go2rtcProcess.on('spawn', async () => { 76 | debug('The go2rtc process was started successfully') 77 | await utils.sleep(2) // Give the process a second to start the API server 78 | debug(chalk.green('-'.repeat(90))) 79 | }) 80 | 81 | this.go2rtcProcess.on('close', async () => { 82 | await utils.sleep(1) // Delay to avoid spurious messages if shutting down 83 | if (this.started !== 'shutdown') { 84 | debug('The go2rtc process exited unexpectedly, will restart in 5 seconds...') 85 | this.go2rtcProcess.kill(9) // Sometimes rtsp-simple-server crashes and doesn't exit completely, try to force kill it 86 | await utils.sleep(5) 87 | this.init() 88 | } 89 | }) 90 | 91 | const stdoutLine = readline.createInterface({ input: this.go2rtcProcess.stdout }) 92 | stdoutLine.on('line', (line) => { 93 | // Replace time in go2rtc log messages with tag 94 | debug(line.replace(/^.*\d{2}:\d{2}:\d{2}\.\d{3} /, chalk.green('[go2rtc] '))) 95 | }) 96 | 97 | const stderrLine = readline.createInterface({ input: this.go2rtcProcess.stderr }) 98 | stderrLine.on('line', (line) => { 99 | // Replace time in go2rtc log messages with tag 100 | debug(line.replace(/^.*\d{2}:\d{2}:\d{2}\.\d{3} /, chalk.green('[go2rtc] '))) 101 | }) 102 | } 103 | 104 | shutdown() { 105 | this.started = 'shutdown' 106 | if (this.go2rtcProcess) { 107 | this.go2rtcProcess.kill() 108 | this.go2rtcProcess = false 109 | } 110 | return 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | import './process-handlers.js' 2 | import './mqtt.js' 3 | import state from './state.js' 4 | import ring from './ring.js' 5 | import utils from './utils.js' 6 | import webService from './web-service.js' 7 | import chalk from 'chalk' 8 | import isOnline from 'is-online' 9 | import debugModule from 'debug' 10 | 11 | const debug = debugModule('ring-mqtt') 12 | 13 | export default new class Main { 14 | constructor() { 15 | console.warn = (message) => { 16 | const suppressedMessages = [ 17 | /^Retry\.\.\./, 18 | /PHONE_REGISTRATION_ERROR/, 19 | /Message dropped as it could not be decrypted:/ 20 | ] 21 | 22 | if (!suppressedMessages.some(suppressedMessages => suppressedMessages.test(message))) { 23 | console.error(message) 24 | } 25 | } 26 | 27 | utils.event.on('generated_token', (generatedToken) => { 28 | this.init(generatedToken) 29 | }) 30 | 31 | this.init() 32 | } 33 | 34 | async init(generatedToken) { 35 | if (!state.valid) { 36 | await state.init() 37 | } 38 | 39 | // For the HA addon, Web UI is always started 40 | if (process.env.RUNMODE === 'addon') { 41 | webService.start(state.data.systemId) 42 | } 43 | 44 | const hasToken = state.data.ring_token || generatedToken 45 | if (!hasToken) { 46 | this.handleNoToken() 47 | return 48 | } 49 | 50 | await this.waitForNetwork() 51 | await this.attemptRingConnection(generatedToken) 52 | } 53 | 54 | async waitForNetwork() { 55 | while (!(await isOnline())) { 56 | debug(chalk.yellow('Network is offline, waiting 10 seconds to check again...')) 57 | await utils.sleep(10) 58 | } 59 | } 60 | 61 | async attemptRingConnection(generatedToken) { 62 | if (!await ring.init(state, generatedToken)) { 63 | debug(chalk.red('Failed to connect to Ring API using saved token, generate a new token using the Web UI')) 64 | debug(chalk.red('or wait 60 seconds to automatically retry authentication using the existing token')) 65 | webService.start(state.data.systemId) 66 | await utils.sleep(60) 67 | 68 | if (!ring.client) { 69 | debug(chalk.yellow('Retrying authentication with existing saved token...')) 70 | this.init() 71 | } 72 | } 73 | } 74 | 75 | handleNoToken() { 76 | if (process.env.RUNMODE === 'addon') { 77 | debug(chalk.red('No refresh token was found in state file, generate a token using the addon Web UI')) 78 | } else { 79 | webService.start(state.data.systemId) 80 | debug(chalk.red('No refresh token was found in the state file, use the Web UI at http://:55123/ to generate a token.')) 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /lib/mqtt.js: -------------------------------------------------------------------------------- 1 | import mqttApi from 'mqtt' 2 | import chalk from 'chalk' 3 | import utils from './utils.js' 4 | import fs from 'fs' 5 | import parseArgs from 'minimist' 6 | import Aedes from 'aedes' 7 | import net from 'net' 8 | import debugModule from 'debug' 9 | const debug = debugModule('ring-mqtt') 10 | 11 | export default new class Mqtt { 12 | constructor() { 13 | this.client = false 14 | this.ipcClient = false 15 | this.connected = false 16 | 17 | // Start internal broker, used only for inter-process communication (IPC) 18 | const mqttServer = new Aedes() 19 | const netServer = net.createServer(mqttServer.handle) 20 | netServer.listen(51883, '127.0.0.1') 21 | 22 | // Configure event listeners 23 | utils.event.on('ring_api_state', async (state) => { 24 | if (!this.client && state === 'connected') { 25 | // Ring API connected, short wait before starting MQTT client 26 | await utils.sleep(2) 27 | this.init() 28 | } 29 | }) 30 | 31 | // Handle client MQTT broker events 32 | utils.event.on('mqtt_publish', (topic, message) => { 33 | this.client.publish(topic, (typeof message === 'number') ? message.toString() : message, { qos: 1 }) 34 | }) 35 | 36 | utils.event.on('mqtt_subscribe', (topic) => { 37 | this.client.subscribe(topic) 38 | }) 39 | 40 | // Handle IPC broker events 41 | utils.event.on('mqtt_ipc_publish', (topic, message) => { 42 | this.ipcClient.publish(topic, (typeof message === 'number') ? message.toString() : message, { qos: 1 }) 43 | }) 44 | 45 | utils.event.on('mqtt_ipc_subscribe', (topic) => { 46 | this.ipcClient.subscribe(topic) 47 | }) 48 | 49 | } 50 | 51 | async init() { 52 | try { 53 | let mqttOptions = {} 54 | if (utils.config().mqtt_options) { 55 | // If any of the cerficiate keys are in mqtt_options, read the data from the file 56 | try { 57 | const mqttConfigOptions = parseArgs(utils.config().mqtt_options.split(',')) 58 | Object.keys(mqttConfigOptions).forEach(key => { 59 | switch (key) { 60 | // For any of the file based options read the file into the option property 61 | case 'key': 62 | case 'cert': 63 | case 'ca': 64 | case 'pfx': 65 | mqttConfigOptions[key] = fs.readFileSync(mqttConfigOptions[key]) 66 | break; 67 | case '_': 68 | delete mqttConfigOptions[key] 69 | break; 70 | default: 71 | // Convert any string true/false values to boolean equivalent 72 | mqttConfigOptions[key] = (mqttConfigOptions[key] === 'true') ? true : mqttConfigOptions[key] 73 | mqttConfigOptions[key] = (mqttConfigOptions[key] === 'false') ? false : mqttConfigOptions[key] 74 | } 75 | }) 76 | mqttOptions = mqttConfigOptions 77 | } catch(err) { 78 | debug(err) 79 | debug(chalk.yellow('Could not parse MQTT advanced options, continuing with default settings')) 80 | } 81 | } 82 | debug('Attempting connection to MQTT broker...') 83 | 84 | // Connect to client facing MQTT broker 85 | this.client = await mqttApi.connect(utils.config().mqtt_url, mqttOptions); 86 | 87 | // Connect to internal IPC broker 88 | this.ipcClient = await mqttApi.connect('mqtt://127.0.0.1:51883', {}) 89 | 90 | this.start() 91 | 92 | // Subscribe to configured/default/legacay Home Assistant status topics 93 | this.client.subscribe(utils.config().hass_topic) 94 | this.client.subscribe('hass/status') 95 | this.client.subscribe('hassio/status') 96 | } catch (error) { 97 | debug(error) 98 | debug(chalk.red(`Could not authenticate to MQTT broker. Please check the broker and configuration settings.`)) 99 | process.exit(1) 100 | } 101 | } 102 | 103 | start() { 104 | // On MQTT connect/reconnect send config/state information after delay 105 | this.client.on('connect', () => { 106 | if (!this.connected) { 107 | this.connected = true 108 | utils.event.emit('mqtt_state', 'connected') 109 | } 110 | }) 111 | 112 | this.client.on('reconnect', () => { 113 | if (this.connected) { 114 | debug('Connection to MQTT broker lost. Attempting to reconnect...') 115 | } else { 116 | debug('Attempting to reconnect to MQTT broker...') 117 | } 118 | this.connected = false 119 | utils.event.emit('mqtt_state', 'disconnected') 120 | }) 121 | 122 | this.client.on('error', (error) => { 123 | debug('Unable to connect to MQTT broker', error.message) 124 | this.connected = false 125 | utils.event.emit('mqtt_state', 'disconnected') 126 | }) 127 | 128 | // Process subscribed MQTT messages from subscribed command topics 129 | this.client.on('message', (topic, message) => { 130 | message = message.toString() 131 | if (topic === utils.config().hass_topic || topic === 'hass/status' || topic === 'hassio/status') { 132 | utils.event.emit('ha_status', topic, message) 133 | } else { 134 | utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message) 135 | } 136 | }) 137 | 138 | // Process MQTT messages from the IPC broker 139 | this.ipcClient.on('message', (topic, message) => { 140 | utils.event.emit(topic, topic.split("/").slice(-2).join("/"), message.toString()) 141 | }) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /lib/process-handlers.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import utils from './utils.js' 3 | import ring from './ring.js' 4 | import debugModule from 'debug' 5 | const debug = debugModule('ring-mqtt') 6 | 7 | export default new class ProcessHandlers { 8 | constructor() { 9 | this.init() 10 | } 11 | 12 | init() { 13 | process.on('exit', this.processExit.bind(null, 0)) 14 | process.on('SIGINT', this.processExit.bind(null, 0)) 15 | process.on('SIGTERM', this.processExit.bind(null, 0)) 16 | process.on('uncaughtException', (err) => { 17 | debug(chalk.red('ERROR - Uncaught Exception')) 18 | debug(chalk.red(err.message)) 19 | debug(err.stack) 20 | this.processExit(2) 21 | }) 22 | process.on('unhandledRejection', (err) => { 23 | switch(true) { 24 | // For these strings suppress the stack trace and only print the message 25 | case /token is not valid/.test(err.message): 26 | case /https:\/\/github.com\/dgreif\/ring\/wiki\/Refresh-Tokens/.test(err.message): 27 | case /error: access_denied/.test(err.message): 28 | debug(chalk.yellow(err.message)) 29 | break; 30 | default: 31 | debug(chalk.yellow('WARNING - Unhandled Promise Rejection')) 32 | debug(chalk.yellow(err.message)) 33 | debug(err.stack) 34 | } 35 | }) 36 | } 37 | 38 | // Set offline status on exit 39 | async processExit(exitCode) { 40 | await utils.sleep(1) 41 | debug('The ring-mqtt process is shutting down...') 42 | await ring.go2rtcShutdown() 43 | if (ring.devices.length > 0) { 44 | debug('Setting all devices offline...') 45 | await utils.sleep(1) 46 | ring.devices.forEach(ringDevice => { 47 | if (ringDevice.availabilityState === 'online') { 48 | ringDevice.shutdown = true 49 | ringDevice.offline() 50 | } 51 | }) 52 | } 53 | await utils.sleep(2) 54 | if (exitCode || exitCode === 0) debug(`Exit code: ${exitCode}`); 55 | process.exit() 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/state.js: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import fs from 'fs' 3 | import { readFile } from 'fs/promises' 4 | import { dirname } from 'path' 5 | import { fileURLToPath } from 'url' 6 | import utils from './utils.js' 7 | import { createHash, randomBytes } from 'crypto' 8 | import writeFileAtomic from 'write-file-atomic' 9 | import debugModule from 'debug' 10 | const debug = debugModule('ring-mqtt') 11 | 12 | export default new class State { 13 | constructor() { 14 | this.valid = false 15 | this.writeScheduled = false 16 | this.data = { 17 | ring_token: '', 18 | systemId: '', 19 | devices: {} 20 | } 21 | } 22 | 23 | async init() { 24 | this.file = (process.env.RUNMODE === 'standard') 25 | ? dirname(fileURLToPath(new URL('.', import.meta.url)))+'/ring-state.json' 26 | : '/data/ring-state.json' 27 | await this.loadStateData() 28 | 29 | // Only temporary to remove any legacy values from state file 30 | if (this.data.hasOwnProperty('push_credentials')) { 31 | delete this.data.push_credentials 32 | await this.saveStateFile() 33 | } 34 | } 35 | 36 | async loadStateData() { 37 | if (fs.existsSync(this.file)) { 38 | debug('Reading latest data from state file: '+this.file) 39 | try { 40 | this.data = JSON.parse(await readFile(this.file)) 41 | this.valid = true 42 | if (!this.data.hasOwnProperty('systemId')) { 43 | this.data.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex')) 44 | } 45 | // Convert legacy state file with empty device array 46 | if (!this.data.hasOwnProperty('devices') || Array.isArray(this.data.devices)) { 47 | this.data.devices = {} 48 | } 49 | } catch (err) { 50 | debug(err.message) 51 | debug(chalk.red('Saved state file exist but could not be parsed!')) 52 | await this.initStateData() 53 | } 54 | } else { 55 | await this.initStateData() 56 | } 57 | } 58 | 59 | async initStateData() { 60 | this.data.systemId = (createHash('sha256').update(randomBytes(32)).digest('hex')) 61 | debug(chalk.yellow('State file '+this.file+' not found. No saved state data available.')) 62 | } 63 | 64 | async saveStateFile() { 65 | // The writeScheduled flag is a hack to keep from writing too often when there are burst 66 | // of state updates such as during startup. If a state file update is already scheduled 67 | // then calls to this function are skipped. 68 | if (!this.writeScheduled) { 69 | this.writeScheduled = true 70 | await utils.sleep(1) 71 | this.writeScheduled = false 72 | try { 73 | await writeFileAtomic(this.file, JSON.stringify(this.data)) 74 | debug('Successfully saved updated state file: '+this.file) 75 | } catch (err) { 76 | debug(chalk.red('Failed to save updated state file: '+this.file)) 77 | debug(err.message) 78 | } 79 | } 80 | } 81 | 82 | updateToken(newRefreshToken) { 83 | debug('Saving updated refresh token to state file') 84 | this.data.ring_token = newRefreshToken 85 | this.saveStateFile() 86 | } 87 | 88 | setDeviceSavedState(deviceId, stateData) { 89 | this.data.devices[deviceId] = stateData 90 | this.saveStateFile() 91 | } 92 | 93 | getDeviceSavedState(deviceId) { 94 | return this.data.devices.hasOwnProperty(deviceId) ? this.data.devices[deviceId] : false 95 | } 96 | 97 | getAllSavedStates() { 98 | return this.data.devices 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /lib/streaming/peer-connection.js: -------------------------------------------------------------------------------- 1 | // This code is largely copied from ring-client-api, but converted from Typescript 2 | // to native Javascript with custom logging for ring-mqtt and some unused code removed. 3 | // Much thanks to @dgreif for the original code which is the basis for this work. 4 | 5 | import { RTCPeerConnection, RTCRtpCodecParameters } from 'werift' 6 | import { interval, merge, ReplaySubject, Subject } from 'rxjs' 7 | import { Subscribed } from './subscribed.js' 8 | 9 | const ringIceServers = [ 10 | 'stun:stun.kinesisvideo.us-east-1.amazonaws.com:443', 11 | 'stun:stun.kinesisvideo.us-east-2.amazonaws.com:443', 12 | 'stun:stun.kinesisvideo.us-west-2.amazonaws.com:443', 13 | 'stun:stun.l.google.com:19302', 14 | 'stun:stun1.l.google.com:19302', 15 | 'stun:stun2.l.google.com:19302', 16 | 'stun:stun3.l.google.com:19302', 17 | 'stun:stun4.l.google.com:19302', 18 | ] 19 | 20 | export class WeriftPeerConnection extends Subscribed { 21 | constructor() { 22 | super() 23 | this.onAudioRtp = new Subject() 24 | this.onVideoRtp = new Subject() 25 | this.onIceCandidate = new Subject() 26 | this.onConnectionState = new ReplaySubject(1) 27 | this.onRequestKeyFrame = new Subject() 28 | const pc = (this.pc = new RTCPeerConnection({ 29 | codecs: { 30 | audio: [ 31 | new RTCRtpCodecParameters({ 32 | mimeType: 'audio/opus', 33 | clockRate: 48000, 34 | channels: 2, 35 | }), 36 | new RTCRtpCodecParameters({ 37 | mimeType: 'audio/PCMU', 38 | clockRate: 8000, 39 | channels: 1, 40 | payloadType: 0, 41 | }), 42 | ], 43 | video: [ 44 | new RTCRtpCodecParameters({ 45 | mimeType: 'video/H264', 46 | clockRate: 90000, 47 | rtcpFeedback: [ 48 | { type: 'transport-cc' }, 49 | { type: 'ccm', parameter: 'fir' }, 50 | { type: 'nack' }, 51 | { type: 'nack', parameter: 'pli' }, 52 | { type: 'goog-remb' }, 53 | ], 54 | parameters: 'packetization-mode=1;profile-level-id=42001f;level-asymmetry-allowed=1', 55 | }), 56 | new RTCRtpCodecParameters({ 57 | mimeType: "video/rtx", 58 | clockRate: 90000, 59 | }) 60 | ], 61 | }, 62 | iceServers: ringIceServers.map((server) => ({ urls: server })), 63 | iceTransportPolicy: 'all', 64 | bundlePolicy: 'disable' 65 | })) 66 | 67 | const audioTransceiver = pc.addTransceiver('audio', { 68 | direction: 'sendrecv', 69 | }) 70 | 71 | const videoTransceiver = pc.addTransceiver('video', { 72 | direction: 'recvonly', 73 | }) 74 | 75 | audioTransceiver.onTrack.subscribe((track) => { 76 | track.onReceiveRtp.subscribe((rtp) => { 77 | this.onAudioRtp.next(rtp) 78 | }) 79 | }) 80 | 81 | videoTransceiver.onTrack.subscribe((track) => { 82 | track.onReceiveRtp.subscribe((rtp) => { 83 | this.onVideoRtp.next(rtp) 84 | }) 85 | track.onReceiveRtp.once(() => { 86 | // debug('received first video packet') 87 | this.addSubscriptions(merge(this.onRequestKeyFrame, interval(4000)).subscribe(() => { 88 | videoTransceiver.receiver 89 | .sendRtcpPLI(track.ssrc) 90 | .catch() 91 | })) 92 | this.requestKeyFrame() 93 | }) 94 | }) 95 | 96 | this.pc.onIceCandidate.subscribe((iceCandidate) => { 97 | if (iceCandidate) { 98 | this.onIceCandidate.next(iceCandidate) 99 | } 100 | }) 101 | 102 | pc.iceConnectionStateChange.subscribe(() => { 103 | // debug(`iceConnectionStateChange: ${pc.iceConnectionState}`) 104 | if (pc.iceConnectionState === 'closed') { 105 | this.onConnectionState.next('closed') 106 | } 107 | }) 108 | 109 | pc.connectionStateChange.subscribe(() => { 110 | // debug(`connectionStateChange: ${pc.connectionState}`) 111 | this.onConnectionState.next(pc.connectionState) 112 | }) 113 | } 114 | 115 | async createOffer() { 116 | const offer = await this.pc.createOffer() 117 | await this.pc.setLocalDescription(offer) 118 | return offer 119 | } 120 | 121 | async acceptAnswer(answer) { 122 | await this.pc.setRemoteDescription(answer) 123 | } 124 | 125 | addIceCandidate(candidate) { 126 | return this.pc.addIceCandidate(candidate) 127 | } 128 | 129 | requestKeyFrame() { 130 | this.onRequestKeyFrame.next() 131 | } 132 | 133 | close() { 134 | this.pc.close().catch() 135 | this.unsubscribe() 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /lib/streaming/streaming-session.js: -------------------------------------------------------------------------------- 1 | // This code is largely copied from ring-client-api, but converted from Typescript 2 | // to native Javascript with custom logging for ring-mqtt and some unused code removed. 3 | // Much thanks to @dgreif for the original code which is the basis for this work. 4 | 5 | import { FfmpegProcess, reservePorts, RtpSplitter } from '@homebridge/camera-utils' 6 | import { firstValueFrom, ReplaySubject, Subject } from 'rxjs' 7 | import pathToFfmpeg from 'ffmpeg-for-homebridge' 8 | import { concatMap, take } from 'rxjs/operators' 9 | import { Subscribed } from './subscribed.js' 10 | 11 | function getCleanSdp(sdp) { 12 | return sdp 13 | .split('\nm=') 14 | .slice(1) 15 | .map((section) => 'm=' + section) 16 | .join('\n') 17 | } 18 | 19 | export class StreamingSession extends Subscribed { 20 | constructor(camera, connection) { 21 | super() 22 | this.camera = camera 23 | this.connection = connection 24 | this.onCallEnded = new ReplaySubject(1) 25 | this.onUsingOpus = new ReplaySubject(1) 26 | this.onVideoRtp = new Subject() 27 | this.onAudioRtp = new Subject() 28 | this.audioSplitter = new RtpSplitter() 29 | this.videoSplitter = new RtpSplitter() 30 | this.hasEnded = false 31 | this.bindToConnection(connection) 32 | } 33 | 34 | bindToConnection(connection) { 35 | this.addSubscriptions( 36 | connection.onAudioRtp.subscribe(this.onAudioRtp), 37 | connection.onVideoRtp.subscribe(this.onVideoRtp), 38 | connection.onCallAnswered.subscribe((sdp) => { 39 | this.onUsingOpus.next(sdp.toLocaleLowerCase().includes(' opus/')) 40 | }), 41 | connection.onCallEnded.subscribe(() => this.callEnded())) 42 | } 43 | 44 | async reservePort(bufferPorts = 0) { 45 | const ports = await reservePorts({ count: bufferPorts + 1 }) 46 | return ports[0] 47 | } 48 | 49 | get isUsingOpus() { 50 | return firstValueFrom(this.onUsingOpus) 51 | } 52 | 53 | async startTranscoding(ffmpegOptions) { 54 | if (this.hasEnded) { 55 | return 56 | } 57 | const videoPort = await this.reservePort(1) 58 | const audioPort = await this.reservePort(1) 59 | 60 | const ringSdp = await Promise.race([ 61 | firstValueFrom(this.connection.onCallAnswered), 62 | firstValueFrom(this.onCallEnded), 63 | ]) 64 | 65 | if (!ringSdp) { 66 | // Call ended before answered' 67 | return 68 | } 69 | const usingOpus = await this.isUsingOpus 70 | 71 | const ffmpegInputArguments = [ 72 | '-hide_banner', 73 | '-protocol_whitelist', 74 | 'pipe,udp,rtp,file,crypto', 75 | // Ring will answer with either opus or pcmu 76 | ...(usingOpus ? ['-acodec', 'libopus'] : []), 77 | '-f', 78 | 'sdp', 79 | ...(ffmpegOptions.input || []), 80 | '-i', 81 | 'pipe:' 82 | ] 83 | 84 | const inputSdp = getCleanSdp(ringSdp) 85 | .replace(/m=audio \d+/, `m=audio ${audioPort}`) 86 | .replace(/m=video \d+/, `m=video ${videoPort}`) 87 | 88 | const ff = new FfmpegProcess({ 89 | ffmpegArgs: ffmpegInputArguments.concat( 90 | ...(ffmpegOptions.audio || ['-acodec', 'aac']), 91 | ...(ffmpegOptions.video || ['-vcodec', 'copy']), 92 | ...(ffmpegOptions.output || [])), 93 | ffmpegPath: pathToFfmpeg, 94 | exitCallback: () => this.callEnded() 95 | }) 96 | 97 | this.addSubscriptions(this.onAudioRtp.pipe(concatMap((rtp) => { 98 | return this.audioSplitter.send(rtp.serialize(), { port: audioPort }) 99 | })).subscribe()) 100 | 101 | this.addSubscriptions(this.onVideoRtp.pipe(concatMap((rtp) => { 102 | return this.videoSplitter.send(rtp.serialize(), { port: videoPort }) 103 | })).subscribe()) 104 | 105 | this.onCallEnded.pipe(take(1)).subscribe(() => ff.stop()) 106 | 107 | ff.writeStdin(inputSdp) 108 | 109 | // Request a key frame now that ffmpeg is ready to receive 110 | this.requestKeyFrame() 111 | } 112 | 113 | callEnded() { 114 | if (this.hasEnded) { 115 | return 116 | } 117 | this.hasEnded = true 118 | this.unsubscribe() 119 | this.onCallEnded.next() 120 | this.connection.stop() 121 | this.audioSplitter.close() 122 | this.videoSplitter.close() 123 | } 124 | 125 | stop() { 126 | this.callEnded() 127 | } 128 | 129 | requestKeyFrame() { 130 | this.connection.requestKeyFrame() 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /lib/streaming/subscribed.js: -------------------------------------------------------------------------------- 1 | // This code is largely copied from ring-client-api, but converted from Typescript 2 | // to native Javascript with custom logging for ring-mqtt and some unused code removed. 3 | // Much thanks to @dgreif for the original code which is the basis for this work. 4 | 5 | export class Subscribed { 6 | constructor() { 7 | this.subscriptions = [] 8 | } 9 | addSubscriptions(...subscriptions) { 10 | this.subscriptions.push(...subscriptions) 11 | } 12 | unsubscribe() { 13 | this.subscriptions.forEach((subscription) => subscription.unsubscribe()) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /lib/streaming/webrtc-connection.js: -------------------------------------------------------------------------------- 1 | // This code is largely copied from ring-client-api, but converted from Typescript 2 | // to native Javascript with custom logging for ring-mqtt and some unused code removed. 3 | // Much thanks to @dgreif for the original code which is the basis for this work. 4 | 5 | import { parentPort } from 'worker_threads' 6 | import { WebSocket } from 'ws' 7 | import { firstValueFrom, fromEvent, interval, ReplaySubject } from 'rxjs' 8 | import { concatMap, take } from 'rxjs/operators' 9 | import crypto from 'crypto' 10 | import { WeriftPeerConnection } from './peer-connection.js' 11 | import { Subscribed } from './subscribed.js' 12 | 13 | export class WebrtcConnection extends Subscribed { 14 | constructor(ticket, camera) { 15 | super() 16 | this.ws = new WebSocket( 17 | `wss://api.prod.signalling.ring.devices.a2z.com:443/ws?api_version=4.0&auth_type=ring_solutions&client_id=ring_site-${crypto.randomUUID()}&token=${ticket}`, 18 | { 19 | headers: { 20 | // This must exist or the socket will close immediately but content does not seem to matter 21 | 'User-Agent': 'android:com.ringapp' 22 | } 23 | } 24 | ) 25 | this.camera = camera 26 | this.onSessionId = new ReplaySubject(1) 27 | this.onOfferSent = new ReplaySubject(1) 28 | this.sessionId = null 29 | this.dialogId = crypto.randomUUID() 30 | this.onCameraConnected = new ReplaySubject(1) 31 | this.onCallAnswered = new ReplaySubject(1) 32 | this.onCallEnded = new ReplaySubject(1) 33 | this.onMessage = new ReplaySubject() 34 | this.hasEnded = false 35 | const pc = new WeriftPeerConnection() 36 | this.pc = pc 37 | this.onAudioRtp = pc.onAudioRtp 38 | this.onVideoRtp = pc.onVideoRtp 39 | this.onWsOpen = fromEvent(this.ws, 'open') 40 | const onMessage = fromEvent(this.ws, 'message') 41 | const onError = fromEvent(this.ws, 'error') 42 | const onClose = fromEvent(this.ws, 'close') 43 | 44 | this.addSubscriptions( 45 | onMessage.pipe(concatMap((event) => { 46 | const message = JSON.parse(event.data) 47 | this.onMessage.next(message) 48 | return this.handleMessage(message) 49 | })).subscribe(), 50 | 51 | onError.subscribe((error) => { 52 | parentPort.postMessage({type: 'log_error', data: error}) 53 | this.callEnded() 54 | }), 55 | 56 | onClose.subscribe(() => { 57 | this.callEnded() 58 | }), 59 | 60 | this.pc.onConnectionState.subscribe((state) => { 61 | if (state === 'failed') { 62 | parentPort.postMessage({type: 'log_error', data: 'WebRTC peer connection failed'}) 63 | this.callEnded() 64 | } 65 | 66 | if (state === 'closed') { 67 | parentPort.postMessage({type: 'log_info', data: 'WebRTC peer connection closed'}) 68 | this.callEnded() 69 | } 70 | }), 71 | 72 | this.onWsOpen.subscribe(() => { 73 | parentPort.postMessage({type: 'log_info', data: 'Websocket signaling for WebRTC session connected successfully'}) 74 | this.initiateCall().catch((error) => { 75 | parentPort.postMessage({type: 'log_error', data: error}) 76 | this.callEnded() 77 | }) 78 | }), 79 | 80 | // The ring-edge session needs a ping every 5 seconds to keep the connection alive 81 | interval(5000).subscribe(() => { 82 | this.sendSessionMessage('ping') 83 | }), 84 | 85 | this.pc.onIceCandidate.subscribe(async (iceCandidate) => { 86 | await firstValueFrom(this.onOfferSent) 87 | this.sendMessage({ 88 | body: { 89 | doorbot_id: camera.id, 90 | ice: iceCandidate.candidate, 91 | mlineindex: iceCandidate.sdpMLineIndex, 92 | }, 93 | dialog_id: this.dialogId, 94 | method: 'ice', 95 | }) 96 | }) 97 | ) 98 | } 99 | 100 | async initiateCall() { 101 | const { sdp } = await this.pc.createOffer() 102 | 103 | this.sendMessage({ 104 | body: { 105 | doorbot_id: this.camera.id, 106 | stream_options: { audio_enabled: true, video_enabled: true }, 107 | sdp, 108 | }, 109 | dialog_id: this.dialogId, 110 | method: 'live_view' 111 | }) 112 | 113 | this.onOfferSent.next() 114 | } 115 | 116 | async handleMessage(message) { 117 | if (message.body.doorbot_id !== this.camera.id) { 118 | // ignore messages for other cameras 119 | return 120 | } 121 | 122 | if (['session_created', 'session_started'].includes(message.method) && 123 | 'session_id' in message.body && 124 | !this.sessionId 125 | ) { 126 | this.sessionId = message.body.session_id 127 | this.onSessionId.next(this.sessionId) 128 | } 129 | 130 | if (message.body.session_id && message.body.session_id !== this.sessionId) { 131 | // ignore messages for other sessions 132 | return 133 | } 134 | 135 | switch (message.method) { 136 | case 'session_created': 137 | case 'session_started': 138 | // session already stored above 139 | return 140 | case 'sdp': 141 | await this.pc.acceptAnswer(message.body) 142 | this.onCallAnswered.next(message.body.sdp) 143 | this.activate() 144 | return 145 | case 'ice': 146 | await this.pc.addIceCandidate({ 147 | candidate: message.body.ice, 148 | sdpMLineIndex: message.body.mlineindex, 149 | }) 150 | return 151 | case 'pong': 152 | return 153 | case 'notification': { 154 | const { text } = message.body 155 | if (text === 'camera_connected') { 156 | this.onCameraConnected.next() 157 | return 158 | } else if ( 159 | text === 'PeerConnectionState::kConnecting' || 160 | text === 'PeerConnectionState::kConnected' 161 | ) { 162 | return 163 | } 164 | break 165 | } 166 | case 'close': 167 | this.callEnded() 168 | return 169 | } 170 | } 171 | 172 | sendSessionMessage(method, body = {}) { 173 | const sendSessionMessage = (sessionId) => { 174 | const message = { 175 | body: { 176 | ...body, 177 | doorbot_id: this.camera.id, 178 | session_id: sessionId, 179 | }, 180 | dialog_id: this.dialogId, 181 | method 182 | } 183 | this.sendMessage(message) 184 | } 185 | if (this.sessionId) { 186 | // Send immediately if we already have a session id 187 | // This is needed to send `close` before closing the websocket 188 | sendSessionMessage(this.sessionId) 189 | } else { 190 | this.addSubscriptions( 191 | this.onSessionId.pipe(take(1)).subscribe(sendSessionMessage) 192 | ) 193 | } 194 | } 195 | 196 | sendMessage(message) { 197 | if (this.hasEnded) { 198 | return 199 | } 200 | this.ws.send(JSON.stringify(message)) 201 | } 202 | 203 | activate() { 204 | // the activate_session message is required to keep the stream alive longer than 70 seconds 205 | this.sendSessionMessage('activate_session') 206 | this.sendSessionMessage('stream_options', { 207 | audio_enabled: true, 208 | video_enabled: true, 209 | }) 210 | } 211 | 212 | callEnded() { 213 | if (this.hasEnded) { 214 | return 215 | } 216 | try { 217 | this.sendMessage({ 218 | reason: { code: 0, text: '' }, 219 | method: 'close', 220 | }) 221 | this.ws.close() 222 | } 223 | catch { 224 | // ignore any errors since we are stopping the call 225 | } 226 | this.hasEnded = true 227 | this.unsubscribe() 228 | this.onCallEnded.next() 229 | this.pc.close() 230 | } 231 | 232 | stop() { 233 | this.callEnded() 234 | } 235 | 236 | requestKeyFrame() { 237 | this.pc.requestKeyFrame?.() 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | import config from './config.js' 2 | import dns from 'dns' 3 | import os from 'os' 4 | import { promisify } from 'util' 5 | import { EventEmitter } from 'events' 6 | import debug from 'debug' 7 | 8 | const debuggers = { 9 | mqtt: debug('ring-mqtt'), 10 | attr: debug('ring-attr'), 11 | disc: debug('ring-disc'), 12 | rtsp: debug('ring-rtsp'), 13 | wrtc: debug('ring-wrtc') 14 | } 15 | 16 | class Utils { 17 | constructor() { 18 | this.event = new EventEmitter() 19 | this.dnsLookup = promisify(dns.lookup) 20 | this.dnsLookupService = promisify(dns.lookupService) 21 | } 22 | 23 | config() { 24 | return config.data 25 | } 26 | 27 | sleep(sec) { 28 | return this.msleep(sec * 1000) 29 | } 30 | 31 | msleep(msec) { 32 | return new Promise(res => setTimeout(res, msec)) 33 | } 34 | 35 | getISOTime(epoch) { 36 | return new Date(epoch).toISOString().slice(0, -5) + 'Z' 37 | } 38 | 39 | async getHostFqdn() { 40 | try { 41 | const ip = await this.getHostIp() 42 | const { hostname } = await this.dnsLookupService(ip, 0) 43 | return hostname 44 | } catch (error) { 45 | console.warn('Failed to resolve FQDN, using os.hostname() instead:', error.message) 46 | return os.hostname() 47 | } 48 | } 49 | 50 | async getHostIp() { 51 | try { 52 | const { address } = await this.dnsLookup(os.hostname()) 53 | return address 54 | } catch (error) { 55 | console.warn('Failed to resolve hostname IP address, returning localhost instead:', error.message) 56 | return 'localhost' 57 | } 58 | } 59 | 60 | isNumeric(num) { 61 | return !isNaN(parseFloat(num)) && isFinite(num) 62 | } 63 | 64 | debug(message, debugType = 'mqtt') { 65 | debuggers[debugType]?.(message) 66 | } 67 | } 68 | 69 | export default new Utils() -------------------------------------------------------------------------------- /lib/web-service.js: -------------------------------------------------------------------------------- 1 | import { RingRestClient } from 'ring-client-api/rest-client' 2 | import utils from './utils.js' 3 | import express from 'express' 4 | import bodyParser from 'body-parser' 5 | import chalk from 'chalk' 6 | import debugModule from 'debug' 7 | import { webTemplate } from './web-template.js' 8 | 9 | const debug = debugModule('ring-mqtt') 10 | 11 | class WebService { 12 | constructor() { 13 | this.app = express() 14 | this.listener = null 15 | this.ringConnected = false 16 | this.initializeEventListeners() 17 | } 18 | 19 | initializeEventListeners() { 20 | utils.event.on('ring_api_state', async (state) => { 21 | this.ringConnected = state === 'connected' 22 | 23 | if (this.ringConnected && process.env.RUNMODE !== 'addon') { 24 | await this.stop() 25 | } 26 | }) 27 | } 28 | 29 | async handleAccountSubmission(req, res, restClient) { 30 | try { 31 | await restClient.getCurrentAuth() 32 | res.json({ success: true }) 33 | } catch (error) { 34 | if (restClient.using2fa) { 35 | debug('Username/Password was accepted, waiting for 2FA code to be entered.') 36 | res.json({ requires2fa: true }) 37 | } else { 38 | const errorMessage = error.message || 'Null response, you may be temporarily throttled/blocked. Please shut down ring-mqtt and try again in a few hours.' 39 | debug(chalk.red(errorMessage)) 40 | res.status(400).json({ error: errorMessage }) 41 | } 42 | } 43 | } 44 | 45 | async handleCodeSubmission(req, res, restClient) { 46 | try { 47 | const generatedToken = await restClient.getAuth(req.body.code) 48 | if (generatedToken) { 49 | utils.event.emit('generated_token', generatedToken.refresh_token) 50 | res.json({ success: true }) 51 | } 52 | } catch (error) { 53 | const errorMessage = error.message || 'The 2FA code was not accepted, please verify the code and try again.' 54 | debug(chalk.red(errorMessage)) 55 | res.status(400).json({ error: errorMessage }) 56 | } 57 | } 58 | 59 | setupRoutes() { 60 | let restClient 61 | this.app.use(bodyParser.urlencoded({ extended: false })) 62 | this.app.use(bodyParser.json()) 63 | 64 | const router = express.Router() 65 | 66 | router.get('/get-state', (req, res) => { 67 | res.json({ 68 | connected: this.ringConnected, 69 | displayName: this.displayName 70 | }) 71 | }) 72 | 73 | router.post('/submit-account', async (req, res) => { 74 | restClient = new RingRestClient({ 75 | email: req.body.email, 76 | password: req.body.password, 77 | controlCenterDisplayName: this.displayName, 78 | systemId: this.systemId 79 | }) 80 | await this.handleAccountSubmission(req, res, restClient) 81 | }) 82 | 83 | router.post('/submit-code', async (req, res) => { 84 | await this.handleCodeSubmission(req, res, restClient) 85 | }) 86 | 87 | // Mount router at base URL 88 | this.app.use('/', router) 89 | 90 | // Serve the static HTML 91 | this.app.get('*', (req, res) => { 92 | res.send(webTemplate) 93 | }) 94 | } 95 | 96 | async start(systemId) { 97 | if (this.listener) { 98 | return 99 | } 100 | 101 | this.systemId = systemId 102 | this.displayName = `${process.env.RUNMODE === 'addon' ? 'ring-mqtt-addon' : 'ring-mqtt'}-${systemId.slice(-5)}` 103 | 104 | this.setupRoutes() 105 | 106 | this.listener = this.app.listen(55123, () => { 107 | debug('Successfully started the ring-mqtt web UI') 108 | }) 109 | } 110 | 111 | async stop() { 112 | if (this.listener) { 113 | await this.listener.close() 114 | this.listener = null 115 | } 116 | } 117 | } 118 | 119 | export default new WebService() -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ring-mqtt", 3 | "version": "5.8.1", 4 | "type": "module", 5 | "description": "Ring Devices via MQTT", 6 | "main": "ring-mqtt.js", 7 | "dependencies": { 8 | "@homebridge/camera-utils": "^2.2.7", 9 | "aedes": "^0.51.3", 10 | "body-parser": "^1.20.3", 11 | "chalk": "^5.4.1", 12 | "date-fns": "^4.1.0", 13 | "debug": "^4.4.0", 14 | "express": "^4.21.2", 15 | "is-online": "^11.0.0", 16 | "js-yaml": "^4.1.0", 17 | "minimist": "^1.2.8", 18 | "mqtt": "^5.13.0", 19 | "ring-client-api": "^14.0.0-beta.2", 20 | "rxjs": "^7.8.2", 21 | "werift": "^0.22.1", 22 | "write-file-atomic": "^6.0.0" 23 | }, 24 | "devDependencies": { 25 | "@eslint/js": "^9.13.0", 26 | "eslint": "^9.16.0", 27 | "globals": "^15.13.0" 28 | }, 29 | "scripts": { 30 | "test": "echo \"Error: no test specified\" && exit 1", 31 | "start": "node ring-mqtt.js" 32 | }, 33 | "keywords": [ 34 | "ring", 35 | "mqtt" 36 | ], 37 | "repository": { 38 | "type": "git", 39 | "url": "git://github.com/tsightler/ring-mqtt.git" 40 | }, 41 | "author": "Tom Sightler (tsightler@gmail.com)", 42 | "license": "MIT" 43 | } 44 | -------------------------------------------------------------------------------- /ring-mqtt.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import './lib/main.js' 3 | -------------------------------------------------------------------------------- /scripts/monitor-stream.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Activate video stream on Ring cameras via ring-mqtt 3 | # Intended only for use as on-demand script for rtsp-simple-server 4 | # Requires mosquitto MQTT clients package to be installed 5 | # Uses ring-mqtt internal IPC broker for communications with main process 6 | # Provides status updates and termintates stream on script exit 7 | 8 | # Required command line arguments 9 | device_id=${1} # Camera device Id 10 | type=${2} # Stream type ("live" or "event") 11 | base_topic=${3} # Command topic for Camera entity 12 | rtsp_pub_url=${4} # URL for publishing RTSP stream 13 | client_id="${device_id}_${type}" # Id used to connect to the MQTT broker, camera Id + event type 14 | activated="false" 15 | reason="none" 16 | 17 | [[ ${type} = "live" ]] && base_topic="${base_topic}/stream" || base_topic="${base_topic}/event_stream" 18 | json_attribute_topic="${base_topic}/attributes" 19 | command_topic="${base_topic}/command" 20 | debug_topic="${base_topic}/debug" 21 | 22 | # Set some colors for debug output 23 | red='\e[0;31m' 24 | yellow='\e[0;33m' 25 | green='\e[0;32m' 26 | blue='\e[0;34m' 27 | reset='\e[0m' 28 | 29 | cleanup() { 30 | local ffpids=$(pgrep -f "ffmpeg.*${rtsp_pub_url}" | grep -v ^$$\$) 31 | [ -n "$ffpids" ] && kill -9 $ffpids 32 | local pids=$(pgrep -f "mosquitto_sub.*${client_id}_sub" | grep -v ^$$\$) 33 | [ -n "$pids" ] && kill $pids 34 | exit 0 35 | } 36 | 37 | # go2rtc does not pass stdout through from child processes so send debug logs 38 | # via main process using MQTT messages 39 | logger() { 40 | mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${debug_topic}" -m "${1}" 41 | } 42 | 43 | # Trap signals so that the MQTT command to stop the stream can be published on exit 44 | trap cleanup INT TERM 45 | 46 | # This loop starts mosquitto_sub with a subscription on the camera stream topic that sends all received 47 | # messages via file descriptor to the read process. On initial startup the script publishes the message 48 | # 'ON-DEMAND' to the stream command topic which lets ring-mqtt know that an RTSP client has requested 49 | # the stream. Stream state is determined via the the detailed stream state messages received via the 50 | # json_attributes_topic: 51 | # 52 | # "inactive" = There is no active video stream and none currently requested 53 | # "activating" = A video stream has been requested and is initializing but has not yet started 54 | # "active" = The stream was requested successfully and an active stream is currently in progress 55 | # "failed" = A live stream was requested but failed to start 56 | mosquitto_sub -q 1 -i "${client_id}_sub" -L "mqtt://127.0.0.1:51883/${json_attribute_topic}" | 57 | while read message; do 58 | # Otherwise it should be a JSON message from the stream state attribute topic so extract the detailed stream state 59 | stream_state=`echo ${message} | jq -r '.status'` 60 | case ${stream_state,,} in 61 | activating) 62 | if [ ${activated} = "false" ]; then 63 | logger "State indicates ${type} stream is activating" 64 | fi 65 | ;; 66 | active) 67 | if [ ${activated} = "false" ]; then 68 | logger "State indicates ${type} stream is active" 69 | activated="true" 70 | fi 71 | ;; 72 | deactivate) 73 | if [ ${activated} = "true" ]; then 74 | reason='deactivate' 75 | fi 76 | ;; 77 | inactive) 78 | if [ ${reason} = "deactivate" ] ; then 79 | logmsg="State indicates ${type} stream is inactive" 80 | else 81 | logmsg=$(echo -en "${yellow}State indicates ${type} stream has gone unexpectedly inactive${reset}") 82 | fi 83 | logger "${logmsg}" 84 | reason='inactive' 85 | cleanup 86 | ;; 87 | failed) 88 | logmsg=$(echo -en "${red}ERROR - State indicates ${type} stream failed to activate${reset}") 89 | logger "${logmsg}" 90 | reason='failed' 91 | cleanup 92 | ;; 93 | *) 94 | logmsg=$(echo -en "${red}ERROR - Received unknown ${type} stream state on topic ${blue}${json_attribute_topic}${reset}") 95 | logger "${logmsg}" 96 | ;; 97 | esac 98 | done 99 | 100 | cleanup -------------------------------------------------------------------------------- /scripts/start-stream.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Activate Ring camera video stream via ring-mqtt 3 | # 4 | # This script is intended for use only with ring-mqtt 5 | # and go2rtc. 6 | # 7 | # Requires mosquitto MQTT clients package to be installed 8 | # Uses ring-mqtt internal IPC broker for communication with 9 | # ring-mqtt process 10 | # 11 | # Spawns stream control in background due to issues with 12 | # process exit hanging go2rtc. Script then just monitors 13 | # for control script to exit or, if script is killed, 14 | # sends commands to control script prior to exiting 15 | 16 | # Required command line arguments 17 | device_id=${1} # Camera device Id 18 | type=${2} # Stream type ("live" or "event") 19 | base_topic=${3} # Command topic for Camera entity 20 | rtsp_pub_url=${4} # URL for publishing RTSP stream 21 | client_id="${device_id}_${type}" # Id used to connect to the MQTT broker, camera Id + event type 22 | 23 | # If previous run hasn't exited yet, just perform a short wait and exit with error 24 | if test -f /tmp/ring-mqtt-${client_id}.lock; then 25 | sleep .1 26 | exit 1 27 | else 28 | touch /tmp/ring-mqtt-${client_id}.lock 29 | fi 30 | 31 | script_dir=$(dirname "$0") 32 | ${script_dir}/monitor-stream.sh ${1} ${2} ${3} ${4} & 33 | 34 | # Build the MQTT topics 35 | [[ ${type} = "live" ]] && base_topic="${base_topic}/stream" || base_topic="${base_topic}/event_stream" 36 | json_attribute_topic="${base_topic}/attributes" 37 | command_topic="${base_topic}/command" 38 | debug_topic="${base_topic}/debug" 39 | 40 | # Set some colors for debug output 41 | red='\e[0;31m' 42 | yellow='\e[0;33m' 43 | green='\e[0;32m' 44 | blue='\e[0;34m' 45 | reset='\e[0m' 46 | 47 | stop() { 48 | # Interrupted by signal so send command to stop stream 49 | # Send message to monitor script that stream was requested to stop so that it doesn't log a warning 50 | mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${json_attribute_topic}" -m {\"status\":\"deactivate\"} 51 | 52 | # Send ring-mqtt the command to stop the stream 53 | 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)" 54 | mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${command_topic}" -m "OFF" 55 | 56 | # Send kill signal to monitor script and wait for it to exit 57 | local pids=$(jobs -pr) 58 | [ -n "$pids" ] && kill $pids 59 | wait 60 | cleanup 61 | } 62 | 63 | # If control script is still runnning send kill signal and exit 64 | cleanup() { 65 | rm -f /tmp/ring-mqtt-${client_id}.lock 66 | # For some reason sleeping for 100ms seems to keep go2rtc from hanging 67 | exit 0 68 | } 69 | 70 | # Send debug logs via main process using MQTT messages 71 | logger() { 72 | mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${debug_topic}" -m "${1}" 73 | } 74 | 75 | # Trap signals so that the MQTT command to stop the stream can be published on exit 76 | trap stop INT TERM EXIT 77 | 78 | logger "Sending command to activate ${type} stream ON-DEMAND" 79 | mosquitto_pub -i "${client_id}_pub" -L "mqtt://127.0.0.1:51883/${command_topic}" -m "ON-DEMAND ${rtsp_pub_url}" & 80 | 81 | wait 82 | cleanup -------------------------------------------------------------------------------- /scripts/update2branch.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | HOME=/app 3 | cd /app 4 | if [ ! -d "/app/ring-mqtt-${BRANCH}" ]; then 5 | echo "Updating ring-mqtt to the ${BRANCH} version..." 6 | if [ "${BRANCH}" = "latest" ]; then 7 | git clone https://github.com/tsightler/ring-mqtt ring-mqtt-latest 8 | else 9 | git clone -b dev https://github.com/tsightler/ring-mqtt ring-mqtt-dev 10 | fi 11 | cd "/app/ring-mqtt-${BRANCH}" 12 | echo "Installing node module dependencies, please wait..." 13 | npm install --no-progress > /dev/null 2>&1 14 | chmod +x ring-mqtt.js scripts/*.sh 15 | 16 | # This runs the downloaded version of this script in case there are 17 | # additonal component upgrade actions that need to be performed 18 | exec "/app/ring-mqtt-${BRANCH}/scripts/update2branch.sh" 19 | echo "-------------------------------------------------------" 20 | else 21 | # Branch has already been initialized, run any post-update command here 22 | echo "The ring-mqtt-${BRANCH} branch has been updated." 23 | 24 | APK_ARCH="$(apk --print-arch)" 25 | GO2RTC_VERSION="v1.9.4" 26 | case "${APK_ARCH}" in 27 | x86_64) 28 | GO2RTC_ARCH="amd64" 29 | ;; 30 | aarch64) 31 | GO2RTC_ARCH="arm64" 32 | ;; 33 | armv7|armhf) 34 | GO2RTC_ARCH="arm" 35 | ;; 36 | *) 37 | echo >&2 "ERROR: Unsupported architecture '$APK_ARCH'" 38 | exit 1 39 | ;; 40 | esac 41 | rm -f /usr/local/bin/go2rtc 42 | # curl -L -s -o /usr/local/bin/go2rtc "https://github.com/AlexxIT/go2rtc/releases/download/${GO2RTC_VERSION}/go2rtc_linux_${GO2RTC_ARCH}" 43 | cp "/app/ring-mqtt-${BRANCH}/bin/go2rtc_linux_${GO2RTC_ARCH}" /usr/local/bin/go2rtc 44 | chmod +x /usr/local/bin/go2rtc 45 | 46 | # case "${APK_ARCH}" in 47 | # x86_64) 48 | # apk del npm nodejs 49 | # apk add libstdc++ 50 | # cd /opt 51 | # wget https://unofficial-builds.nodejs.org/download/release/v22.11.0/node-v22.11.0-linux-x64-musl.tar.gz 52 | # mkdir nodejs 53 | # tar -zxvf *.tar.gz --directory /opt/nodejs --strip-components=1 54 | # ln -s /opt/nodejs/bin/node /usr/local/bin/node 55 | # ln -s /opt/nodejs/bin/npm /usr/local/bin/npm 56 | # ;; 57 | # esac 58 | 59 | cp -f "/app/ring-mqtt-${BRANCH}/init/s6/services.d/ring-mqtt/run" /etc/services.d/ring-mqtt/run 60 | chmod +x /etc/services.d/ring-mqtt/run 61 | fi --------------------------------------------------------------------------------