Merge 4.0.0 Release (#101)

* Add Home Assistant Device Model support
* Major refactor of device discovery and republish to simplify code
* Add support for alarm status
* Implement new topic heirarchy
* Add support for keypad status monitoring
* Misc bugfixes from refactor
* Remove legacy attribute support
* Display chirps only for security-keypad
* Add support for panic buttons
* Must be explicitly enabled with "enable_panic"
* Addon auto configuration
* Enhance web based token generator
* Improve error handling for token generation
* Add support for Home Assistant device registry for cameras
This commit is contained in:
tsightler
2020-09-04 23:25:14 -04:00
committed by GitHub
parent 485b58bb96
commit 796d099cba
32 changed files with 1891 additions and 4539 deletions

198
README.md
View File

@@ -1,38 +1,22 @@
# ring-mqtt # ring-mqtt
This script leverages the excellent [ring-client-api](https://github.com/dgreif/ring) to provide a bridge between MQTT and suppoted Ring devices such as alarm control panel, lights and cameras ([full list of supported devices and features](#current-features)). It also provides support for Home Assistant style MQTT discovery which allows for simple Home Assistant integration with minimal configuration (assuming MQTT is already configured), including an optional [Hass.io Addon](https://github.com/tsightler/ring-mqtt-hassio-addon) for users of that platform. It can also be used with any other tool capable of working with MQTT as it provides consistent topic naming based on location/device ID. This script leverages the excellent [ring-client-api](https://github.com/dgreif/ring) to provide a bridge between MQTT and suppoted Ring devices such as alarm control panel, lights and cameras ([full list of supported devices and features](#current-features)). It also provides support for Home Assistant style MQTT auto-discovery which allows for easy Home Assistant integration with minimal configuration (requires Home Assistant MQTT integration to be enabled). This also includes an optional [Home Assistant Addon](https://github.com/tsightler/ring-mqtt-ha-addon) for users of HassOS/Home Assistant Installer. It can also be used with any other tool capable of working with MQTT as it provides consistent topic naming based on location/device ID.
## !!! Important Changes for the 4.0.0 Release !!!
The primary goal of the 4.0.0 release was to improve supportability and reliability while also adding some long requested features. Unfortunately the development of these capapabilities required introducing a few breaking changes. Most of these changes will not impact Home Assistant users as they will be handled automatically but the MQTT discovery process however, if you were previously using the old JSON attribute topic to monitor battery levels or tamper status, you will need to modify those automations to use the new device level info sensor JSON topic which includes this information and much more.
For non-Home Assistant users, the topic levels have changed in this release to provide better consistency. This new model is descibed in [docs/TOPICS.md](docs/TOPICS.md).
For a full list of changes and new features please see [docs/CHANGELOG.md](docs/CHANGELOG.md).
## !!! Important MQTT changes for Home Assistant >=0.113 !!! ## !!! Important MQTT changes for Home Assistant >=0.113 !!!
Prior to Home Assistant 0.113 it was highly recommended to configure birth messages for the MQTT [component by manual settings](https://www.home-assistant.io/docs/mqtt/birth_will/) in configuration.yaml. This way ring-mqtt could monitor for restarts of the Home Assistant server and automatically resend devices and state updates after a restart. The example for this setting in the documtation, and prior versions of this script, used hass/status as the topic for these messages. Prior to Home Assistant 0.113 it was highly recommended to configure birth messages for the MQTT [component by manual settings](https://www.home-assistant.io/docs/mqtt/birth_will/) in configuration.yaml. With this configuration ring-mqtt could monitor for restarts of the Home Assistant server and automatically resend devices and state updates after a restart. In prior Home Assistant documentation, and prior versions of this script, the topic used was hass/status.
However, for Home Assistant >=0.113, birth/last will messages are now enabled by default but, unfortunately, use the default topic of homeasssitant/status instead. To comply with this new default behavior the config.json included with this script has been modified to use the homeassistant/status topic instead. This means, for new installs, no special configuration should be needed to take advantage of this feature and state updates will happen automatically after Home Assistant restart. Home Assistant >=0.113 has now enabled birth/last will messages by default however, it uses the default topic of homeasssitant/status instead. To comply with this new default behavior the config.json included with this script has been modified to use the homeassistant/status topic instead. This means, for new installs, no special configuration should be needed to take advantage of this feature and state updates will happen automatically after Home Assistant restart.
For existing users who have implemented the previously recommended configuration, everything should continue to work without changes after the upgrade, however, for consistency with future configurations, it is now recommended to revert the Home Assistant MQTT configuraiton to defaults and modify the config.json file to change the hass_topic value from hass/status to homeassistant/status. You can also completely switch to UI configuration for the MQTT component after making this change if you wish since the script no longer depends on any special configuration to monitor Home Assistant status. For existing users who have implemented the previously recommended configuration, everything should continue to work without changes after the upgrade, however, for consistency with future configurations, it is now recommended to revert the Home Assistant MQTT configuraiton to defaults and modify the config.json file to change the hass_topic value from hass/status to homeassistant/status. You can also completely switch to UI configuration for the MQTT component after making this change if you wish since the script no longer depends on any special configuration to monitor Home Assistant status.
## Standard Installation (Linux)
Make sure Node.js (tested with 10.16.x and higher) is installed on your system and then clone this repo:
`git clone https://github.com/tsightler/ring-mqtt.git`
Change to the ring-mqtt directory and run:
```
chmod +x ring-mqtt.js
npm install
```
This will install all required dependencies. Edit the config.js and enter your Ring account user/password and MQTT broker connection information. You can also change the top level topic used for creating ring device topics as well as the Home Assistant state topic, but most people should leave these as default.
### Starting the service automatically during boot
I've included a sample service file which you can use to automaticlly start the script during system boot as long as your system uses systemd (most modern Linux distros). The service file assumes you've installed the script in /opt/ring-mqtt and that you want to run the process as the homeassistant user, but you can easily modify this to any path and user you'd like. Just edit the file as required and drop it in /lib/systemd/system then run the following:
```
systemctl daemon-reload
systemctl enable ring-mqtt
systemctl start ring-mqtt
```
## Docker Installation ## Docker Installation
Ring-mqtt is now on Docker Hub! While you're still welcome to build your own image from the Dockerfile you can now install and update by pulling directly from Docker Hub: With version 4.0.0 Docker is now the recommended install method. While you're still welcome to build your own image from the included Dockerfile you can now install and update by pulling directly from Docker Hub:
``` ```
docker pull tsightler/ring-mqtt docker pull tsightler/ring-mqtt
@@ -43,11 +27,14 @@ Or just run directly (Docker will automatically pull the image if it doesn't exi
``` ```
docker run --rm -e "MQTTHOST={host_name}" -e "MQTTPORT={host_port}" -e "MQTTRINGTOPIC={ring_topic}" -e "MQTTHASSTOPIC={hass_topic}" -e "MQTTUSER={mqtt_user}" -e "MQTTPASSWORD={mqtt_pw}" -e "RINGTOKEN={ring_refreshToken}" -e "ENABLECAMERAS={true-or-false}" -e "RINGLOCATIONIDS={comma-separated location IDs}" tsightler/ring-mqtt docker run --rm -e "MQTTHOST={host_name}" -e "MQTTPORT={host_port}" -e "MQTTRINGTOPIC={ring_topic}" -e "MQTTHASSTOPIC={hass_topic}" -e "MQTTUSER={mqtt_user}" -e "MQTTPASSWORD={mqtt_pw}" -e "RINGTOKEN={ring_refreshToken}" -e "ENABLECAMERAS={true-or-false}" -e "RINGLOCATIONIDS={comma-separated location IDs}" tsightler/ring-mqtt
``` ```
In ring-mqtt version >=3.2.0 the Docker build supports the use of a bind mount for persistent storage, this is used to store updated refresh tokens in a persistent fashion. While this is not absolutely required, it can be useful as refresh tokens eventually expire and are renewed automatically by the script. Using persistent storage will store these refreshed tokens in a state file which will be read during script startup and the connection to the Ring server will attempt to use this token before any manually configured token. If you do not specify a bind mount the script will continue function without state, as in previous versions, but during restarts you may have to manually regenerate a token and modify the configuration value when they expire. For more details see ([Authentication](#authentication)). Here is an example docker run command with a bind mount which mount this host directory /etc/ring-mqtt to the container path /data:
The Docker build supports the use of a bind mount for persistent storage. This location is used to store updated refresh tokens in a persistent fashion, and may be used for storing other state information in the future. While the use of persistent storage is not absolutely required, it is highly recommended as manually acquired refresh tokens will eventually expire and are renewed automatically by the script. Without persistent storage there is nowhere to save these renewed tokens so, on restart, you may have to manually acquire a new token again. Providing persistent storage to store these updated tokens will avoid this issue. For more details on acquiring an initial refresh token please see ([Authentication](#authentication)).
Here is an example docker run command with a bind mount which mount this host directory /etc/ring-mqtt to the container path /data:
``` ```
docker run --rm --mount type=bind,source=/etc/ring-mqtt,target=/data -e "MQTTHOST={host_name}" -e "MQTTUSER={mqtt_user}" -e "MQTTPASSWORD={mqtt_pw}" -e "RINGTOKEN={ring_refreshToken}" tsightler/ring-mqtt docker run --rm --mount type=bind,source=/etc/ring-mqtt,target=/data -e "MQTTHOST={host_name}" -e "MQTTUSER={mqtt_user}" -e "MQTTPASSWORD={mqtt_pw}" -e "RINGTOKEN={ring_refreshToken}" tsightler/ring-mqtt
``` ```
Note that only **RINGTOKEN** is technically required but in practice at least **MQTTHOST** will likely be required as well (unless you use the host network option in "docker run" command). **MQTTUSER/MQTTPASSWORD** will be required if the MQTT broker does not accept anonymous connections. Default values for the environment values if they are not defined are as follows: Note that the only absolutely required parameter for initial start is **RINGTOKEN** but, in practice, at least **MQTTHOST** will likely be required as well, and **MQTTUSER/MQTTPASSWORD** will be required if the MQTT broker does not accept anonymous connections. Default values for the environment values if they are not defined are as follows:
| Environment Variable Name | Default | | Environment Variable Name | Default |
| --- | --- | | --- | --- |
@@ -63,34 +50,53 @@ Note that only **RINGTOKEN** is technically required but in practice at least **
When submitting any issue with the Docker build, please be sure to add '-e "DEBUG=ring-mqtt"' to the Docker run command before submitting. When submitting any issue with the Docker build, please be sure to add '-e "DEBUG=ring-mqtt"' to the Docker run command before submitting.
## Standard Installation (Linux)
Stanard installation is still fully supported, please make sure Node.js is installed (tested with 12.18.x and higher) on your system and then clone this repo:
`git clone https://github.com/tsightler/ring-mqtt.git`
Change to the ring-mqtt directory and run:
```
chmod +x ring-mqtt.js
npm install
```
This will install all required dependencies. Edit the config.js and configure your Ring refresh token and MQTT broker connection information. Note that the user the script runs as will need permission to write the config.json as, for the standalone version of the script, updated refresh tokens are written directly to the config.json file.
### Starting the service automatically during boot
For Docker you can simply use the standard Docker methods for starting containers during boot or any other method for starting the container.
For standalone installs the repo includes a sample unit file which can be used to automaticlly start the script during system boot as long as your system uses systemd (most modern Linux distros). The unit file assumes that the script is installed in /opt/ring-mqtt and it runs the script as the root user (to make sure it has permissions to write config.json), but you can easily modify this to any path and user you'd like. Just edit the file as required and drop it in /lib/systemd/system then run the following:
```
systemctl daemon-reload
systemctl enable ring-mqtt
systemctl start ring-mqtt
```
## Authentication ## Authentication
Ring has made two factor authentication (2FA) mandatory thus the script now only supports this authentication method. Using 2FA requires acquiring a refresh token for your Ring account and seting the ring_token parameter in the config file (standard/Hass.io installs) or passing the token with the RINGTOKEN environment variable (Docker installs). Ring has made two factor authentication (2FA) mandatory thus the script now only supports this authentication method. Using 2FA requires manually acquiring a refresh token for your Ring account and seting the ring_token parameter in the config file (standard/Hass.io installs) or passing the token with the RINGTOKEN environment variable (Docker installs).
There are two primary ways to acquire this token: There are two primary ways to acquire this token:
**Option 1:** Use ring-auth-cli from the command line. This command can be run from any system with NodeJS installed. If you are using the standard Linux installation method after running the "npm install" step you can execute the following from the ring-mqtt directory: **Docker Installs**
``` For Docker it is possible to use the CLI to acquire a token for initial startup by executing the following:
node node_modules/ring-client-api/ring-auth-cli.js
```
If you are using the Docker, you can execute:
``` ```
docker run -it --rm --entrypoint node_modules/ring-client-api/ring-auth-cli.js tsightler/ring-mqtt docker run -it --rm --entrypoint node_modules/ring-client-api/ring-auth-cli.js tsightler/ring-mqtt
``` ```
For more details please check the [Two Factor Auth](https://github.com/dgreif/ring/wiki/Two-Factor-Auth) documentation from the ring client API.
**Option 2:** This method is primarily for Home Assistant add-on, but also works with the standard script method (it does not work for the Docker method). If you leave the ring_token parameter blank in the config file and run the script, it will detect that you don't yet have a refresh token and start a small web service at http://<ip_of_server>:55123. Simply go to this URL with your browser, enter your username/password and then 2FA code, and it will display the Ring refresh token that you can just copy/paste into the config file. **Standard Installs** For standard installs the script as an emedded web interface to make acquiring a token as simple as possible or you can manually acquire a token via the command line.
For more details please check the [Two Factor Auth](https://github.com/dgreif/ring/wiki/Two-Factor-Auth) documentation from the ring client API. **Web Interface**
If the script is started and the ring_token parameter is empty it will start a small web service at http://<ip_of_server>:55123. Simply go to this URL with your browser, enter your username/password and then 2FA code, and it will display the Ring refresh token that you can just copy/paste into the config file.
### ***Important Note regarding expiring refresh tokens*** **CLI Option** Use ring-auth-cli from the command line. This command can be run from any system with NodeJS installed. If you are using the standard Linux installation method after running the "npm install" step you can execute the following from the ring-mqtt directory:
Refresh tokens do expire and this can cause issues during restarts since you may have to manually acquire a new token. Starting with version 3.2.0 of this script, updated refresh tokens are automatically stored in a persistent manner where possible. The exact nature of how these updated tokens are stored varies slightly based on installation type as described below: ```
npx -p ring-client-api ring-auth-cli
```
**Standard Installation:** The script will attempt to automatically write new tokens to the config.json file. Note that this means the script must be running under an account which has permissions to this file/directory. For more details please check the [Refresh Tokens](https://github.com/dgreif/ring/wiki/Refresh-Tokens) documentation from the ring client API Wiki.
**Docker Installation:** The script will attempt to store refresh tokens in /data/ring-state.json. Note that for this file to be persistent accross restarts you must provide a bind mount to this path during the docker run stage as descibed in the Docker installation section. If /data/ring-state.json doesn't exist during startup, or if the system fails to authenticate using this token, it will fall back to using the RINGTOKEN envrionment variable, if defined.
**Home Assistant Add-on:** The script will store refresh tokens in /data/ring-state.json. If /data/ring-state.json doesn't exist during startup, or if the system fails to authenticate using this token, it will fall back to using the ring_token value defined in the configuration. If no tokens are available, or if all tokens fail to authenticate, it will start the web service and allow you to generate a new token. Note that it is no longer required to manually copy/paste the token into the config file in this case, once you generate the token via the web UI it will save the token in /data/ring-state.json and automatically attempt to connect using this new token. The ring_token value can stay completely blank in this case, however, if you prefer to manually create a token without using the Web UI, it is still possible to set this value in the config, but it is no longer required.
### ***Important Note regarding the security of your refresh token*** ### ***Important Note regarding the security of your refresh token***
Using 2FA authentication opens up the possibility that, if your Home Assistant environment is comporomised, an attacker can acquire the refresh token and use this to authenticate to your Ring account without knowing your username/password and completely bypassing any 2FA protections. Please secure your Home Assistant environment carefully. Using 2FA authentication opens up the possibility that, if your Home Assistant environment is comporomised, an attacker can acquire the refresh token and use this to authenticate to your Ring account without knowing your username/password and completely bypassing any 2FA protections. Please secure your Home Assistant environment carefully.
@@ -108,52 +114,33 @@ Because of this added risk, it's a good idea to create a second account dedicate
| mqtt_pass | Password for MQTT broker | blank | | mqtt_pass | Password for MQTT broker | blank |
| ring_token | The refresh token received after authenticating with 2FA - See Authentication section | blank | ring_token | The refresh token received after authenticating with 2FA - See Authentication section | blank
| enable_cameras | Enable camera support, otherwise only alarm devices will be discovered | false | | enable_cameras | Enable camera support, otherwise only alarm devices will be discovered | false |
| enable_modes | Enable support for Location Modes for sites without a Ring Alarm Panel | enable_modes | Enable support for Location Modes for sites without a Ring Alarm Panel | false |
| location_ids | Array of location Ids in format: ["loc-id", "loc-id2"] | blank | | location_ids | Array of location Ids in format: ["loc-id", "loc-id2"] | blank |
By default, this script will discover and monitor enabled devices across all locations, even shared locations for which you have permissions. To limit locations you can create a separate account and assign only the desired resources to it, or you can pass location_ids using the appropriate config option. To get the location id from the ring website simply login to [Ring.com](https://ring.com/users/sign_in) and look at the address bar in the browser. It will look similar to ```https://app.ring.com/location/{location_id}``` with the last path element being the location id. By default, this script will discover and monitor enabled devices across all locations, even shared locations for which you have permissions. To limit locations you can create a separate account and assign only the desired resources to it, or you can pass location_ids using the appropriate config option. To get the location id from the ring website simply login to [Ring.com](https://ring.com/users/sign_in) and look at the address bar in the browser. It will look similar to ```https://app.ring.com/location/{location_id}``` with the last path element being the location id.
## Using with MQTT tools other than Home Assistant (ex: Node Red) ## Using with MQTT tools other than Home Assistant (ex: Node Red)
MQTT topics are built consistently during each startup. The easiest way to determine the device topics is to run the script with debug output as noted below and it will dump the state and command topics for all devices, the general format for topics is as follows: MQTT topics are built consistently during each startup. The easiest way to determine the device topics is to run the script with debug output. More details about the topic format for all devices is available in [docs/TOPICS.md](docs/TOPICS.md).
```
ring/<location_id>/alarm/<ha_platform_type>/<device_id>/<prefix>_state
ring/<location_id>/alarm/<ha_platform_type>/<device_id>/<prefix>_command
```
An example for the Smoke/CO listener:
```
ring/<location_id>/alarm/<ha_platform_type>/<device_id>/gas_state
ring/<location_id>/alarm/<ha_platform_type>/<device_id>/co_state
```
Or for a multi-level switch:
```
ring/<location_id>/alarm/switch/<device_id>/switch_state <-- For on/off state
ring/<location_id>/alarm/switch/<device_id>/switch_brightness_state <-- For brightness state
ring/<location_id>/alarm/switch/<device_id>/switch_command <-- Set on/off state
ring/<location_id>/alarm/switch/<device_id>/switch_brightness_command <-- Set brightness state
```
For cameras the overall structure is the same:
```
ring/<location_id>/camera/binary_sensor/<device_id>/ding_state <-- Doorbell state
ring/<location_id>/camera/binary_sensor/<device_id>/motion_state <-- Motion state
ring/<location_id>/camera/light/<device_id>/light_state <-- Light on/off state
ring/<location_id>/camera/light/<device_id>/light_command <-- Set light on/off state
ring/<location_id>/camera/switch/<device_id>/siren_state <-- Siren state
ring/<location_id>/camera/switch/<device_id>/siren_command <-- Set siren state
```
## Features and Plans ## Features and Plans
### Current features ### Current features
- Full support for 2FA including embedded web service to simplfiy generation of refresh token - Full support for 2FA including embedded web service to simplfiy generation of refresh token
- Supports the following devices and features: - Supports the following devices and features:
- Alarm Devices - Alarm Devices
- Alarm control panel (Monitor arming state + Arm/Disarm actions) - Alarm Control Panel
- Arm/Disarm actions
- Alarm states:
- Pending (entry delay)
- Triggered
- Base Station
- Panic Buttons
- Siren
- Volume Control (if account has access to change volume)
- Keypad
- Volume Control
- Battery level
- AC/Charging state
- Ring Contact and Motion Sensors - Ring Contact and Motion Sensors
- Ring Flood/Freeze Sensor - Ring Flood/Freeze Sensor
- Ring Smoke/CO Listener - Ring Smoke/CO Listener
@@ -161,33 +148,43 @@ ring/<location_id>/camera/switch/<device_id>/siren_command <-- Set sire
- Ring Retro Kit Zones - Ring Retro Kit Zones
- Ring integrated door locks (status and lock control) - Ring integrated door locks (status and lock control)
- 3rd party Z-Wave switches, dimmers, and fans - 3rd party Z-Wave switches, dimmers, and fans
- 3rd party motion/contact sensors (basic support)
- Device info sensor with detailed state information such as (exact info varies by device):
- Battery level
- Tamper state
- Communication status
- Z-wave Link Quality
- Serial Number
- Firmware status
- Device volume
- Camera Devices - Camera Devices
- Motion Events - Motion Events
- Doorbell (Ding) Events - Doorbell (Ding) Events
- Lights (for devices with lights) - Lights (for devices with lights)
- Siren (for devices with siren support) - Siren (for devices with siren support)
- Device info sensor with detailed state information such as (exact info varies by device):
- Wireless Signal/Info
- Wired network status
- Firmware Info
- Latest communications status
- Smart Lighting - Smart Lighting
- Lighting and motion sensor devices - Lighting and motion sensor devices
- Light groups - Light groups
- Device info sensor with detailed state information (exact info varies by device)
- Location Modes - Location Modes
- For locations without a Ring Alarm, can add a panel for controlling camera settings via Ring Location Modes - For locations without a Ring Alarm, can add a panel for controlling camera settings via Ring Location Modes
- Displays as an Alarm Panel in Home Assistant for setting modes and displaying mode state - Displays as an Alarm Panel in Home Assistant for setting modes and displaying mode state
- Must be explicitly enabled using "enabled_modes" config or ENABLEMODES envrionment variable - Must be explicitly enabled using "enabled_modes" config or ENABLEMODES envrionment variable
- Provides battery and tamper status for supported Alarm devices via JSON attribute topic (visible in Home Assistant UI)
- Full Home Assistant MQTT Discovery - devices appear automatically - Full Home Assistant MQTT Discovery - devices appear automatically
- Full Home Assistant Device registry support - entities appear with parent device
- Consistent topic creation based on location/device ID - easy to use with MQTT tools like Node-RED - Consistent topic creation based on location/device ID - easy to use with MQTT tools like Node-RED
- Arm/Disarm commands are monitored for success and retried automatically - Arm/Disarm commands are monitored for success and retried automatically
- Support for mulitple locations - Support for mulitple locations
- Monitors websocket connection to each alarm and sets reachability status if socket is unavailable (Home Assistant UI reports "unknown" status for unreachable devices), automatically resends device state when connection is established - Monitors websocket connection to each alarm and sets reachability status if socket is unavailable (Home Assistant UI reports "unknown" status for unreachable devices), automatically resends device state when connection is established
- Monitors MQTT connection and Home Assistant MQTT birth messages ([if configured](#optional-home-assistant-configuration)) to trigger automatic resend of configuration data after restart/disconnect - Monitors MQTT connection and Home Assistant MQTT birth messages to trigger automatic resend of configuration and state data after restart/disconnect
- Does not require MQTT retain and can work well with brokers that provide no persistent storage - Does not require MQTT retain and can work well with brokers that provide no persistent storage
### Planned features
- Support for additional 3rd party sensors/devices
- Additional Devices (base station, keypad - at least for tamper/battery status)
### Possible future features ### Possible future features
- Base station settings (volume, chime)
- Arm/Disarm with code - Arm/Disarm with code
- Arm/Disarm with sensor bypass - Arm/Disarm with sensor bypass
- Dynamic add/remove of alarms/devices (i.e. no service restart required) - Dynamic add/remove of alarms/devices (i.e. no service restart required)
@@ -195,39 +192,16 @@ ring/<location_id>/camera/switch/<device_id>/siren_command <-- Set sire
## Debugging ## Debugging
By default the script should produce no console output, however, the script does leverage the terriffic [debug](https://www.npmjs.com/package/debug) package. To get debug output, simply run the script like this: By default the script should produce no console output, however, the script does leverage the terriffic [debug](https://www.npmjs.com/package/debug) package. To get debug output, simply run the script like this:
**Debug messages from all modules** (Warning, this very verbose!)
```
DEBUG=* ./ring-mqtt.js
````
**Debug messages from ring-mqtt only** **Debug messages from ring-mqtt only**
``` ```
DEBUG=ring-mqtt ./ring-mqtt.js DEBUG=ring-mqtt ./ring-mqtt.js
``` ```
This option is also useful when using the script with external MQTT tools as it dumps all discovered sensors and their topics. Also allows you to monitor sensor states in real-time on the console. This option is also useful when using the script with external MQTT tools as it dumps all discovered sensors and their topics. Also allows you to monitor sensor states in real-time on the console.
## Breaking changes in v3.0 **Debug messages from all modules** (Warning, this very verbose!)
The 3.0 release is a major refactor with the goal to dramatically simplfy the ability to add support for new devices and reduce complexity in the main code by implementing standardized devices functions. Each device is now defined in it's own class, stored in separate files, and this class implements at least two standard methods, one for initializing the device (publish discovery message, subscribe to events and publish state updates) and a second for processing commands (only for devices that accept commands). While this creates some code redundancy, it eliminates lots of ugly conditions and switch commands that were previously far too easy to break when adding new devices.
Also, rather than a single, global avaialbaility state for each location, each device now has a device specific availability topic. Cameras track their own availability state by querying for device health data on a polling interval (60 seconds). Alarms are still monitored by the state of the websocket connection for each location but, in the future, offline devices (such as devices with dead batteries or otherwise disconnected) will be monitored as well.
For those using this script with 3rd party MQTT tools (not Home Assistant) the state and command topics have been standardized to use consistent, Ring-like prefixes across topic names. This way topic lengths for all devices are always the identical. This makes internal processing in the code simpler and makes state and command topics consistent across both single and dual sensor devices. For example, with 2.0 and earlier the state topic for the standaline co sensor would be:
``` ```
ring/<location_id>/alarm/binary_sensor/<device_id>/state DEBUG=* ./ring-mqtt.js
``` ```
While for the combined co/smoke listener it would be:
```
ring/<location_id>/alarm/binary_sensor/<device_id>/smoke/state
ring/<location_id>/alarm/binary_sensor/<device_id>/gas/state
```
This was inconsistent so now, with 3.0 the topics for the co sensor would be:
```
ring/<location_id>/alarm/binary_sensor/<device_id>/co_state
```
While for the combined device it will be
```
ring/<location_id>/alarm/binary_sensor/<device_id>/smoke_state
ring/<location_id>/alarm/binary_sensor/<device_id>/co_state
## Thanks ## Thanks
Many thanks to @dgrief and his excellent [ring-client-api API](https://github.com/dgreif/ring/) as well as his homebridge plugin, from which I've learned a lot. Without his work it would have taken far more effort and time, probably more time than I had, to get this working. Many thanks to @dgrief and his excellent [ring-client-api API](https://github.com/dgreif/ring/) as well as his homebridge plugin, from which I've learned a lot. Without his work it would have taken far more effort and time, probably more time than I had, to get this working.

View File

@@ -8,5 +8,6 @@
"ring_token": "", "ring_token": "",
"enable_cameras": false, "enable_cameras": false,
"enable_modes" : false, "enable_modes" : false,
"enable_panic" : false,
"location_ids": [""] "location_ids": [""]
} }

View File

@@ -2,15 +2,34 @@ const debug = require('debug')('ring-mqtt')
const utils = require('../lib/utils') const utils = require('../lib/utils')
class AlarmDevice { class AlarmDevice {
constructor(device, mqttClient, ringTopic) { constructor(deviceInfo) {
this.device = device // Set default properties for alarm device object model
this.mqttClient = mqttClient this.device = deviceInfo.device
// Set device location and top level MQTT topics this.mqttClient = deviceInfo.mqttClient
this.locationId = this.device.location.locationId this.subscribed = false
this.availabilityState = 'init'
this.discoveryData = new Array()
this.deviceId = this.device.id this.deviceId = this.device.id
this.ringTopic = ringTopic this.locationId = this.device.location.locationId
this.alarmTopic = ringTopic+'/'+this.locationId+'/alarm' this.config = deviceInfo.CONFIG
this.availabilityState = 'offline'
// Set default device data for Home Assistant device registry
// Values may be overridden by individual devices
this.deviceData = {
ids: [ this.deviceId ],
name: this.device.name,
mf: (this.device.data && this.device.data.manufacturerName) ? this.device.data.manufacturerName : 'Ring',
mdl: this.device.deviceType
}
// Set device location and top level MQTT topics
this.ringTopic = this.config.ring_topic
this.deviceTopic = this.ringTopic+'/'+this.locationId+'/alarm/'+this.deviceId
this.availabilityTopic = this.deviceTopic+'/status'
// Create info device topics
this.stateTopic_info = this.deviceTopic+'/info/state'
this.configTopic_info = 'homeassistant/sensor/'+this.locationId+'/'+this.deviceId+'_info/config'
} }
// Return batterylevel or convert battery status to estimated level // Return batterylevel or convert battery status to estimated level
@@ -18,9 +37,9 @@ class AlarmDevice {
if (this.device.data.batteryLevel !== undefined) { if (this.device.data.batteryLevel !== undefined) {
// Return 100% if 99% reported, otherwise return reported battery level // Return 100% if 99% reported, otherwise return reported battery level
return (this.device.data.batteryLevel === 99) ? 100 : this.device.data.batteryLevel return (this.device.data.batteryLevel === 99) ? 100 : this.device.data.batteryLevel
} else if (this.device.data.batteryStatus === 'full') { } else if (this.device.data.batteryStatus === 'full' || this.device.data.batteryStatus === 'charged') {
return 100 return 100
} else if (this.device.data.batteryStatus === 'ok') { } else if (this.device.data.batteryStatus === 'ok' || this.device.data.batteryStatus === 'charging') {
return 50 return 50
} else if (this.device.data.batteryStatus === 'none') { } else if (this.device.data.batteryStatus === 'none') {
return 'none' return 'none'
@@ -28,6 +47,45 @@ class AlarmDevice {
return 0 return 0
} }
// Create device discovery data
initInfoDiscoveryData(deviceValue) {
// If set override value tempate setting with device specific value
const value = deviceValue
? { template: '{{value_json["'+deviceValue+'"]}}' }
: { template: '{{value_json["batteryLevel"]}}', uom: '%' }
// Init info entity (extended device data)
this.discoveryData.push({
message: {
name: this.deviceData.name+' Info',
unique_id: this.deviceId+'_info',
availability_topic: this.availabilityTopic,
payload_available: 'online',
payload_not_available: 'offline',
state_topic: this.stateTopic_info,
json_attributes_topic: this.stateTopic_info,
icon: "mdi:information-outline",
... value.template ? { value_template: value.template } : {},
... value.uom ? { unit_of_measurement: value.uom } : {},
device: this.deviceData
},
configTopic: this.configTopic_info
})
}
// Publish all discovery data for device
async publishDiscoveryData() {
const debugMsg = (this.availabilityState == 'init') ? 'Publishing new ' : 'Republishing existing '
debug(debugMsg+'device id: '+this.deviceId)
this.discoveryData.forEach(dd => {
debug('HASS config topic: '+dd.configTopic)
debug(dd.message)
this.publishMqtt(dd.configTopic, JSON.stringify(dd.message))
})
// Sleep for a few seconds to give HA time to process discovery message
await utils.sleep(2)
}
// Publish state messages with debug // Publish state messages with debug
publishMqtt(topic, message, isDebug) { publishMqtt(topic, message, isDebug) {
if (isDebug) { debug(topic, message) } if (isDebug) { debug(topic, message) }
@@ -40,26 +98,52 @@ class AlarmDevice {
if (this.subscribed) { if (this.subscribed) {
this.publishData() this.publishData()
} else { } else {
this.device.onData.subscribe(() => { this.device.onData.subscribe(() => { this.publishData() })
this.publishData()
})
this.subscribed = true this.subscribed = true
} }
// Publish availability state for device
this.online() this.online()
} }
// Publish device attributes // Publish device info
publishAttributes() { publishAttributes() {
const attributes = {} let alarmState
const batteryLevel = this.getBatteryLevel()
if (batteryLevel !== 'none') { if (this.device.deviceType === 'security-panel') {
attributes.battery_level = batteryLevel alarmState = this.device.data.alarmInfo ? this.device.data.alarmInfo.state : 'all-clear'
} }
if (this.device.data.tamperStatus) {
attributes.tamper_status = this.device.data.tamperStatus // Get full set of device data and publish to info topic
const attributes = {
... this.device.data.acStatus ? { acStatus: this.device.data.acStatus } : {},
... alarmState ? { alarmState: alarmState } : {},
... this.device.data.batteryLevel
? { batteryLevel: this.device.data.batteryLevel === 99 ? 100 : this.device.data.batteryLevel }
: {},
... this.device.data.batteryStatus && this.device.data.batteryStatus !== 'none'
? { batteryStatus: this.device.data.batteryStatus }
: {},
... this.device.data.brightness ? {brightness: this.device.data.brightness } : {},
... this.device.data.chirps && this.device.deviceType == 'security-keypad' ? {chirps: this.device.data.chirps } : {},
... this.device.data.commStatus ? { commStatus: this.device.data.commStatus } : {},
... this.device.data.firmwareUpdate ? { firmwareStatus: this.device.data.firmwareUpdate.state } : {},
... this.device.data.lastCommTime ? { lastCommTime: new Date(this.device.data.lastCommTime).toISOString() } : {},
... this.device.data.lastUpdate ? { lastUpdate: new Date(this.device.data.lastUpdate).toISOString() } : {},
... this.device.data.linkQuality ? { linkQuality: this.device.data.linkQuality } : {},
... this.device.data.powerSave ? {powerSave: this.device.data.powerSave } : {},
... this.device.data.serialNumber ? { serialNumber: this.device.data.serialNumber } : {},
... this.device.data.tamperStatus ? { tamperStatus: this.device.data.tamperStatus } : {},
... this.device.data.volume ? {volume: this.device.data.volume } : {},
}
this.publishMqtt(this.stateTopic_info, JSON.stringify(attributes), true)
// If first publish schedule attributes to be resent every 5 minutes
if (!this.attributesScheduled) {
this.attributesScheduled = true
const _this = this
setInterval(function () {
if (_this.availabilityState = 'online') { _this.publishAttributes() }
}, 300000)
} }
this.publishMqtt(this.attributesTopic, JSON.stringify(attributes), true)
} }
// Set state topic online // Set state topic online

138
devices/base-station.js Normal file
View File

@@ -0,0 +1,138 @@
const debug = require('debug')('ring-mqtt')
const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device')
class BaseStation extends AlarmDevice {
async publish(locationConnected) {
// Only publish if location websocket is connected
if (!locationConnected) { return }
// If this is the very first publish for this device (device is not yet subscribed)
// check if account has access set volume and, if so, enable volume control
if (!this.subscribed) {
const origVolume = (this.device.data.volume && !isNaN(this.device.data.volume) ? this.device.data.volume : 0)
const testVolume = (origVolume === 1) ? .99 : origVolume+.01
this.device.setVolume(testVolume)
await utils.sleep(1)
if (this.device.data.volume === testVolume) {
debug('Account has access to set volume on base station, enabling volume control')
this.device.setVolume(origVolume)
this.setVolume = true
} else {
debug('Account does not have access to set volume on base station, disabling volume control')
this.setVolume = false
}
}
// Device data for Home Assistant device registry
this.deviceData.mdl = 'Alarm Base Station'
this.deviceData.name = this.device.location.name + ' Base Station'
if (this.setVolume) {
// Build required MQTT topics
this.stateTopic_audio = this.deviceTopic+'/audio/state'
this.commandTopic_audio = this.deviceTopic+'/audio/command'
this.stateTopic_audio_volume = this.deviceTopic+'/audio/volume_state'
this.commandTopic_audio_volume = this.deviceTopic+'/audio/volume_command'
this.configTopic_audio = 'homeassistant/light/'+this.locationId+'/'+this.deviceId+'_audio/config'
}
// Publish discovery message
if (!this.discoveryData.length) { await this.initDiscoveryData() }
await this.publishDiscoveryData()
// Publish device state data with optional subscribe
this.publishSubscribeDevice()
if (this.setVolume) {
// Subscribe to device command topics
this.mqttClient.subscribe(this.commandTopic_audio)
this.mqttClient.subscribe(this.commandTopic_audio_volume)
}
}
initDiscoveryData() {
if (this.setVolume) {
// Build the MQTT discovery messages
this.discoveryData.push({
message: {
name: this.device.name+' Audio Settings',
unique_id: this.deviceId+'_audio',
availability_topic: this.availabilityTopic,
payload_available: 'online',
payload_not_available: 'offline',
state_topic: this.stateTopic_audio,
command_topic: this.commandTopic_audio,
brightness_scale: 100,
brightness_state_topic: this.stateTopic_audio_volume,
brightness_command_topic: this.commandTopic_audio_volume,
on_command_type: 'brightness',
icon: "mdi:volume-high",
device: this.deviceData
},
configTopic: this.configTopic_audio
})
}
// Device has no sensors, only publish info data
this.initInfoDiscoveryData()
}
publishData() {
if (this.setVolume) {
// Publish volume state to switch entity
const audioVolume = (this.device.data.volume && !isNaN(this.device.data.volume) ? Math.round(100 * this.device.data.volume) : 0)
const audioState = (audioVolume > 0) ? "ON" : "OFF"
this.publishMqtt(this.stateTopic_audio, audioState, true)
this.publishMqtt(this.stateTopic_audio_volume, audioVolume.toString(), true)
}
// Publish device attributes (batterylevel, tamper status)
this.publishAttributes()
}
// Process messages from MQTT command topic
processCommand(message, topic) {
if (topic == this.commandTopic_audio) {
this.setAudioState(message)
} else if (topic == this.commandTopic_audio_volume) {
this.setVolumeLevel(message)
} else {
debug('Somehow received unknown command topic '+topic+' for keypad Id: '+this.deviceId)
}
}
// Set switch target state on received MQTT command message
setAudioState(message) {
const command = message.toLowerCase()
switch(command) {
case 'on':
case 'off': {
debug('Received command to turn '+command+' audio for base station Id: '+this.deviceId)
const volume = (command === 'on') ? .65 : 0
debug('Setting volume level to '+volume*100+'%')
this.device.setVolume(volume)
break;
}
default:
debug('Received invalid audio command for keypad!')
}
}
// Set switch target state on received MQTT command message
setVolumeLevel(message) {
const volume = message
debug('Received set volume level to '+volume+'% for base station Id: '+this.deviceId)
debug('Location Id: '+ this.locationId)
if (isNaN(message)) {
debug('Volume command received but not a number!')
} else if (!(message >= 0 && message <= 100)) {
debug('Volume command received but out of range (0-100)!')
} else {
this.device.setVolume(volume/100)
}
}
}
module.exports = BaseStation

View File

@@ -3,11 +3,13 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class Beam extends AlarmDevice { class Beam extends AlarmDevice {
async init() { async publish(locationConnected) {
// Only initialize if location websocket is connected
this.availabilityTopic = this.alarmTopic+'/beam/'+this.deviceId+'/status' if (!locationConnected) { return }
this.attributesTopic = this.alarmTopic+'/beam/'+this.deviceId+'/attributes'
// Ovverride default device topic to use "lighting" instead of "alarm"
this.deviceTopic = this.ringTopic+'/'+this.locationId+'/smart_lighting/'+this.deviceId
// Build required MQTT topics for device for each entity // Build required MQTT topics for device for each entity
if (this.device.data.deviceType === 'group.light-group.beams') { if (this.device.data.deviceType === 'group.light-group.beams') {
this.isLightGroup = true this.isLightGroup = true
@@ -15,74 +17,72 @@ class Beam extends AlarmDevice {
} }
if (this.deviceType !== 'switch.transformer.beams') { if (this.deviceType !== 'switch.transformer.beams') {
this.deviceTopic_motion = this.alarmTopic+'/binary_sensor/'+this.deviceId this.stateTopic_motion = this.deviceTopic+'/motion/state'
this.stateTopic_motion = this.deviceTopic_motion+'/motion_state'
this.configTopic_motion = 'homeassistant/binary_sensor/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic_motion = 'homeassistant/binary_sensor/'+this.locationId+'/'+this.deviceId+'/config'
} }
if (this.deviceType !== 'motion-sensor.beams') { if (this.deviceType !== 'motion-sensor.beams') {
this.deviceTopic_light = this.alarmTopic+'/light/'+this.deviceId this.stateTopic_light = this.deviceTopic+'/light/state'
this.stateTopic_light = this.deviceTopic_light+'/switch_state' this.commandTopic_light = this.deviceTopic+'/light/command'
this.commandTopic_light = this.deviceTopic_light+'/switch_command'
this.configTopic_light = 'homeassistant/light/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic_light = 'homeassistant/light/'+this.locationId+'/'+this.deviceId+'/config'
} }
if (this.deviceType === 'switch.multilevel.beams') { if (this.deviceType === 'switch.multilevel.beams') {
this.stateTopic_brightness = this.deviceTopic_light+'brightness_state' this.stateTopic_brightness = this.deviceTopic+'/light/brightness_state'
this.commandTopic_brightness = this.deviceTopic_light+'brightness_command' this.commandTopic_brightness = this.deviceTopic+'/light/brightness_command'
} }
// Publish discovery message for HA and wait 2 seoonds before sending state // Publish discovery message
this.publishDiscovery() if (!this.discoveryData.length) { await this.initDiscoveryData() }
await utils.sleep(2) await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
// Subscribe to device command topics
if (this.commandTopic_light) { this.mqttClient.subscribe(this.commandTopic_brightness) }
if (this.commandTopic_brightness) { this.mqttClient.subscribe(this.commandTopic_brightness) }
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery messages and publish devices // Build the MQTT discovery messages for beam components
if (this.stateTopic_motion) { if (this.stateTopic_motion) {
const message = { this.discoveryData.push({
name: this.device.name+' - Motion', message: {
unique_id: this.deviceId+'_motion', name: this.device.name+' Motion',
availability_topic: this.availabilityTopic, unique_id: this.deviceId+'_motion',
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic_motion, device_class: 'motion',
json_attributes_topic: this.attributesTopic, device: this.deviceData
device_class: 'motion' },
} configTopic: this.configTopic_motion
debug('HASS config topic: '+this.configTopic_motion) })
debug(message)
this.publishMqtt(this.configTopic_motion, JSON.stringify(message))
} }
if (this.stateTopic_light) { if (this.stateTopic_light) {
const message = { let discoveryMessage = {
name: this.device.name+' - Light', name: this.device.name+' Light',
unique_id: this.deviceId+'_light', unique_id: this.deviceId+'_light',
availability_topic: this.availabilityTopic, availability_topic: this.availabilityTopic,
payload_available: 'online', payload_available: 'online',
payload_not_available: 'offline', payload_not_available: 'offline',
state_topic: this.stateTopic_light, state_topic: this.stateTopic_light,
json_attributes_topic: this.attributesTopic,
command_topic: this.commandTopic_light command_topic: this.commandTopic_light
} }
if (this.stateTopic_brightness) { if (this.stateTopic_brightness) {
message.brightness_scale = 100 discoveryMessage.brightness_scale = 100
message.brightness_state_topic = this.stateTopic_brightness, discoveryMessage.brightness_state_topic = this.stateTopic_brightness,
message.brightness_command_topic = this.commandTopic_brightness discoveryMessage.brightness_command_topic = this.commandTopic_brightness
} }
debug('HASS config topic: '+this.configTopic_light) discoveryMessage.device = this.deviceData
debug(message) this.discoveryData.push({
this.publishMqtt(this.configTopic_light, JSON.stringify(message)) message: discoveryMessage,
this.mqttClient.subscribe(this.commandTopic_light) configTopic: this.configTopic_light
if (this.commandTopic_brightness) { })
this.mqttClient.subscribe(this.commandTopic_brightness)
}
} }
this.initInfoDiscoveryData()
} }
publishData() { publishData() {
@@ -104,13 +104,13 @@ class Beam extends AlarmDevice {
} }
// Process messages from MQTT command topic // Process messages from MQTT command topic
processCommand(message, cmdTopicLevel) { processCommand(message, topic) {
if (cmdTopicLevel == 'switch_command') { if (topic == this.commandTopic_light) {
this.setSwitchState(message) this.setSwitchState(message)
} else if (cmdTopicLevel == 'brightness_command') { } else if (topic == this.commandTopic_brightness) {
this.setSwitchLevel(message) this.setSwitchLevel(message)
} else { } else {
debug('Somehow received unknown command topic level '+cmdTopicLevel+' for switch Id: '+this.deviceId) debug('Somehow received unknown command topic '+topic+' for switch Id: '+this.deviceId)
} }
} }

View File

@@ -2,17 +2,26 @@ const debug = require('debug')('ring-mqtt')
const utils = require( '../lib/utils' ) const utils = require( '../lib/utils' )
class Camera { class Camera {
constructor(camera, mqttClient, ringTopic) { constructor(deviceInfo) {
this.camera = camera // Set default properties for camera device object model
this.mqttClient = mqttClient this.camera = deviceInfo.device
this.mqttClient = deviceInfo.mqttClient
this.subscribed = false this.subscribed = false
this.availabilityState = 'init'
// Set device location and top level MQTT topics
this.locationId = this.camera.data.location_id this.locationId = this.camera.data.location_id
this.deviceId = this.camera.data.device_id this.deviceId = this.camera.data.device_id
this.cameraTopic = ringTopic+'/'+this.locationId+'/camera'
this.availabilityTopic = this.cameraTopic+'/'+this.deviceId+'/status' // Sevice data for Home Assistant device registry
this.availabilityState = 'offline' this.deviceData = {
ids: [ this.deviceId ],
name: this.camera.name,
mf: 'Ring',
mdl: this.camera.model
}
// Set device location and top level MQTT topics
this.cameraTopic = deviceInfo.CONFIG.ring_topic+'/'+this.locationId+'/camera/'+this.deviceId
this.availabilityTopic = this.cameraTopic+'/status'
// Create properties to store motion ding state // Create properties to store motion ding state
this.motion = { this.motion = {
@@ -44,54 +53,60 @@ class Camera {
} }
// Publishing camera capabilities and state and subscribe to events
// Initialize camera by publishing capabilities and state and subscribing to events async publish() {
async init() { const debugMsg = (this.availabilityState == 'init') ? 'Publishing new ' : 'Republishing existing '
debug(debugMsg+'device id: '+this.deviceId)
this.published = true
// Publish motion sensor feature for camera // Publish motion sensor feature for camera
var capability = { this.publishCapability({
type: 'motion', type: 'motion',
component: 'binary_sensor', component: 'binary_sensor',
className: 'motion', className: 'motion',
suffix: 'Motion', suffix: 'Motion',
hasCommand: false, hasCommand: false,
} })
this.publishCapability(capability)
// If camera is a doorbell publish doorbell sensor // If camera is a doorbell publish doorbell sensor
if (this.camera.isDoorbot) { if (this.camera.isDoorbot) {
capability = { this.publishCapability({
type: 'ding', type: 'ding',
component: 'binary_sensor', component: 'binary_sensor',
className: 'occupancy', className: 'occupancy',
suffix: 'Ding', suffix: 'Ding',
hasCommand: false hasCommand: false
} })
this.publishCapability(capability)
} }
// If camera has a light publish light component // If camera has a light publish light component
if (this.camera.hasLight) { if (this.camera.hasLight) {
capability = { this.publishCapability({
type: 'light', type: 'light',
component: 'light', component: 'light',
suffix: 'Light', suffix: 'Light',
hasCommand: true hasCommand: true
} })
this.publishCapability(capability)
} }
// If camera has a siren publish switch component // If camera has a siren publish switch component
if (this.camera.hasSiren) { if (this.camera.hasSiren) {
capability = { this.publishCapability({
type: 'siren', type: 'siren',
component: 'switch', component: 'switch',
suffix: 'Siren', suffix: 'Siren',
hasCommand: true hasCommand: true
} })
this.publishCapability(capability)
} }
// Publish info sensor for camera
this.publishCapability({
type: 'info',
component: 'sensor',
suffix: 'Info',
hasCommand: false,
})
// Give Home Assistant time to configure device before sending first state data // Give Home Assistant time to configure device before sending first state data
await utils.sleep(2) await utils.sleep(2)
@@ -101,10 +116,10 @@ class Camera {
this.camera.onNewDing.subscribe(ding => { this.camera.onNewDing.subscribe(ding => {
this.publishDingState(ding) this.publishDingState(ding)
}) })
// Since this is initial publish of device publish ding state as well // Since this is initial publish of device publish current ding state as well
this.publishDingState() this.publishDingState()
// If camers as light/siren subsribed to those events as well (only polls, default 20 seconds) // If camera has light/siren subscribe to those events as well (only polls, default 20 seconds)
if (this.camera.hasLight || this.camera.hasSiren) { if (this.camera.hasLight || this.camera.hasSiren) {
this.camera.onData.subscribe(() => { this.camera.onData.subscribe(() => {
this.publishPolledState() this.publishPolledState()
@@ -112,7 +127,10 @@ class Camera {
} }
this.subscribed = true this.subscribed = true
// Start monitor of availability state for device // Publish info state for device
this.publishInfoState()
// Start monitor of availability state for camera
this.monitorCameraConnection() this.monitorCameraConnection()
// Set camera online (sends availability status via MQTT) // Set camera online (sends availability status via MQTT)
@@ -125,9 +143,10 @@ class Camera {
if (this.camera.hasSiren) { this.publishedSirenState = 'republish' } if (this.camera.hasSiren) { this.publishedSirenState = 'republish' }
this.publishPolledState() this.publishPolledState()
} }
this.publishInfoState()
this.publishAvailabilityState() this.publishAvailabilityState()
} }
} }
// Publish state messages via MQTT with optional debug // Publish state messages via MQTT with optional debug
publishMqtt(topic, message, enableDebug) { publishMqtt(topic, message, enableDebug) {
@@ -136,9 +155,8 @@ class Camera {
} }
// Build and publish a Home Assistant MQTT discovery packet for camera capability // Build and publish a Home Assistant MQTT discovery packet for camera capability
publishCapability(capability) { async publishCapability(capability) {
const componentTopic = this.cameraTopic+'/'+capability.type
const componentTopic = this.cameraTopic+'/'+capability.component+'/'+this.deviceId
const configTopic = 'homeassistant/'+capability.component+'/'+this.locationId+'/'+this.deviceId+'_'+capability.type+'/config' const configTopic = 'homeassistant/'+capability.component+'/'+this.locationId+'/'+this.deviceId+'_'+capability.type+'/config'
const message = { const message = {
@@ -147,17 +165,37 @@ class Camera {
availability_topic: this.availabilityTopic, availability_topic: this.availabilityTopic,
payload_available: 'online', payload_available: 'online',
payload_not_available: 'offline', payload_not_available: 'offline',
state_topic: componentTopic+'/'+capability.type+'_state' state_topic: componentTopic+'/state'
} }
if (capability.className) { message.device_class = capability.className } if (capability.className) { message.device_class = capability.className }
if (capability.hasCommand) { if (capability.hasCommand) {
const commandTopic = componentTopic+'/'+capability.type+'_command' const commandTopic = componentTopic+'/command'
message.command_topic = commandTopic message.command_topic = commandTopic
this.mqttClient.subscribe(commandTopic) this.mqttClient.subscribe(commandTopic)
} }
// Set the primary state value for info sensors based on power (battery/wired)
// and connectivity (Wifi/ethernet)
if (capability.type === 'info') {
message.json_attributes_topic = componentTopic+'/state'
message.icon = 'mdi:information-outline'
const deviceHealth = await Promise.race([this.camera.getHealth(), utils.sleep(5)]).then(function(result) { return result; })
if (deviceHealth) {
if (deviceHealth.network_connection && deviceHealth.network_connection === 'ethernet') {
message.value_template = '{{value_json["wiredNetwork"]}}'
} else {
// Device is connected via wifi, track that as primary
message.value_template = '{{value_json["wirelessSignal"]}}'
message.unit_of_measurement = 'RSSI'
}
}
}
// Add device data for Home Assistant device registry
message.device = this.deviceData
debug('HASS config topic: '+configTopic) debug('HASS config topic: '+configTopic)
debug(message) debug(message)
this.mqttClient.publish(configTopic, JSON.stringify(message), { qos: 1 }) this.mqttClient.publish(configTopic, JSON.stringify(message), { qos: 1 })
@@ -165,13 +203,11 @@ class Camera {
// Process a ding event from camera or publish existing ding state // Process a ding event from camera or publish existing ding state
async publishDingState(ding) { async publishDingState(ding) {
const componentTopic = this.cameraTopic+'/binary_sensor/'+this.deviceId
// Is it an active ding (i.e. from a subscribed event)? // Is it an active ding (i.e. from a subscribed event)?
if (ding) { if (ding) {
// Is it a motion or doorbell ding? // Is it a motion or doorbell ding?
const dingType = ding.kind const dingType = ding.kind
const stateTopic = componentTopic+'/'+dingType+'_state' const stateTopic = this.cameraTopic+'/'+dingType+'/state'
// Update time for most recent ding and expire time of ding (Ring seems to be 180 seconds for all dings) // Update time for most recent ding and expire time of ding (Ring seems to be 180 seconds for all dings)
this[dingType].last_ding = Math.floor(ding.now) this[dingType].last_ding = Math.floor(ding.now)
@@ -203,9 +239,9 @@ class Camera {
} }
} else { } else {
// Not an active ding so just publish existing ding state // Not an active ding so just publish existing ding state
this.publishMqtt(componentTopic+'/motion_state', (this.motion.active_ding ? 'ON' : 'OFF'), true) this.publishMqtt(this.cameraTopic+'/motion/state', (this.motion.active_ding ? 'ON' : 'OFF'), true)
if (this.camera.isDoorbot) { if (this.camera.isDoorbot) {
this.publishMqtt(componentTopic+'/ding_state', (this.ding.active_ding ? 'ON' : 'OFF'), true) this.publishMqtt(this.cameraTopic+'/ding/state', (this.ding.active_ding ? 'ON' : 'OFF'), true)
} }
} }
} }
@@ -215,16 +251,14 @@ class Camera {
// when values change from previous polling interval // when values change from previous polling interval
publishPolledState() { publishPolledState() {
if (this.camera.hasLight) { if (this.camera.hasLight) {
const componentTopic = this.cameraTopic+'/light/'+this.deviceId const stateTopic = this.cameraTopic+'/light/state'
const stateTopic = componentTopic+'/light_state'
if (this.camera.data.led_status !== this.publishedLightState) { if (this.camera.data.led_status !== this.publishedLightState) {
this.publishMqtt(stateTopic, (this.camera.data.led_status === 'on' ? 'ON' : 'OFF'), true) this.publishMqtt(stateTopic, (this.camera.data.led_status === 'on' ? 'ON' : 'OFF'), true)
this.publishedLightState = this.camera.data.led_status this.publishedLightState = this.camera.data.led_status
} }
} }
if (this.camera.hasSiren) { if (this.camera.hasSiren) {
const componentTopic = this.cameraTopic+'/switch/'+this.deviceId const stateTopic = this.cameraTopic+'/siren/state'
const stateTopic = componentTopic+'/siren_state'
const sirenStatus = this.camera.data.siren_status.seconds_remaining > 0 ? 'ON' : 'OFF' const sirenStatus = this.camera.data.siren_status.seconds_remaining > 0 ? 'ON' : 'OFF'
if (sirenStatus !== this.publishedSirenState) { if (sirenStatus !== this.publishedSirenState) {
this.publishMqtt(stateTopic, sirenStatus, true) this.publishMqtt(stateTopic, sirenStatus, true)
@@ -233,11 +267,39 @@ class Camera {
} }
} }
// Publish device data to info topic
async publishInfoState(deviceHealth) {
if (!deviceHealth) {
deviceHealth = await Promise.race([this.camera.getHealth(), utils.sleep(5)]).then(function(result) {
return result;
})
}
if (deviceHealth) {
const attributes = {}
if (this.camera.hasBattery) {
attributes.batteryLevel = deviceHealth.battery_percentage
}
attributes.firmwareStatus = deviceHealth.firmware
attributes.lastUpdate = deviceHealth.updated_at
if (deviceHealth.network_connection && deviceHealth.network_connection === 'ethernet') {
attributes.wiredNetwork = this.camera.data.alerts.connection
} else {
attributes.wirelessNetwork = deviceHealth.wifi_name
attributes.wirelessSignal = deviceHealth.latest_signal_strength
}
this.publishMqtt(this.cameraTopic+'/info/state', JSON.stringify(attributes), true)
}
}
// Interval loop to check communications with cameras/Ring API since, unlike alarm, // Interval loop to check communications with cameras/Ring API since, unlike alarm,
// there's no websocket to monitor. // there's no websocket to monitor.
// Also monitor subscriptions to ding/motion events and attempt resubscribe if false // Also monitor subscriptions to ding/motion events and attempt resubscribe if false
// and call function to update info data on every 5th cycle
monitorCameraConnection() { monitorCameraConnection() {
const _this = this const _this = this
let intervalCount = 1
let cameraState
setInterval(async function() { setInterval(async function() {
const camera = _this.camera const camera = _this.camera
@@ -245,7 +307,17 @@ class Camera {
const deviceHealth = await Promise.race([camera.getHealth(), utils.sleep(60)]).then(function(result) { const deviceHealth = await Promise.race([camera.getHealth(), utils.sleep(60)]).then(function(result) {
return result; return result;
}); });
const cameraState = (deviceHealth) ? 'online' : 'offline'
// Every 5th loop (~5 minutes) publish device info sensor data
if (deviceHealth) {
cameraState = 'online'
if (intervalCount % 5 === 0) {
_this.publishInfoState(deviceHealth)
}
intervalCount++
} else {
cameraState = 'offline'
}
// Publish camera availability state if different from prior state // Publish camera availability state if different from prior state
if (_this.availabilityState !== cameraState) { if (_this.availabilityState !== cameraState) {
@@ -253,7 +325,7 @@ class Camera {
_this.offline() _this.offline()
} else { } else {
// If camera switching to online republish discovery and state before going online // If camera switching to online republish discovery and state before going online
_this.init() _this.publish()
await utils.sleep(2) await utils.sleep(2)
_this.online() _this.online()
} }
@@ -278,12 +350,14 @@ class Camera {
} }
// Process messages from MQTT command topic // Process messages from MQTT command topic
processCommand(message, cmdTopicLevel) { processCommand(message, topic) {
switch(cmdTopicLevel) { topic = topic.split('/')
case 'light_command': const component = topic[topic.length - 2]
switch(component) {
case 'light':
this.setLightState(message) this.setLightState(message)
break; break;
case 'siren_command': case 'siren':
this.setSirenState(message) this.setSirenState(message)
break; break;
default: default:
@@ -303,7 +377,7 @@ class Camera {
this.camera.setLight(false) this.camera.setLight(false)
break; break;
default: default:
debug('Received unkonw command for light on camera ID '+this.deviceId) debug('Received unknown command for light on camera ID '+this.deviceId)
} }
} }
@@ -330,17 +404,17 @@ class Camera {
// Set state topic online // Set state topic online
async online() { async online() {
//const enableDebug = this.availabilityState !== 'online' const enableDebug = (this.availabilityState == 'online') ? false : true
await utils.sleep(1) await utils.sleep(1)
this.availabilityState = 'online' this.availabilityState = 'online'
this.publishAvailabilityState(false) this.publishAvailabilityState(enableDebug)
} }
// Set state topic offline // Set state topic offline
offline() { offline() {
//const enableDebug = this.availabilityState !== 'offline' const enableDebug = (this.availabilityState == 'offline') ? false : true
this.availabilityState = 'offline' this.availabilityState = 'offline'
this.publishAvailabilityState(false) this.publishAvailabilityState(enableDebug)
} }
} }

View File

@@ -3,42 +3,46 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class CoAlarm extends AlarmDevice { class CoAlarm extends AlarmDevice {
async init() { async publish(locationConnected) {
// Home Assistant component type and device class (set appropriate icon) // Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type and device class (set appropriate icon)
this.component = 'binary_sensor' this.component = 'binary_sensor'
this.className = 'gas' this.className = 'gas'
// Build required MQTT topics for device // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'CO Alarm'
this.stateTopic = this.deviceTopic+'/co_state'
this.attributesTopic = this.deviceTopic+'/attributes'
this.availabilityTopic = this.deviceTopic+'/status'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message for HA and wait 2 seoonds before sending state // Build required MQTT topics
this.publishDiscovery() this.stateTopic = this.deviceTopic+'/co/state'
await utils.sleep(2) this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message
if (!this.discoveryData.length) { await this.initDiscoveryData() }
await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.device.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic,
device_class: this.className device_class: this.className,
} device: this.deviceData
},
configTopic: this.configTopic
})
debug('HASS config topic: '+this.configTopic) this.initInfoDiscoveryData()
debug(message)
this.publishMqtt(this.configTopic, JSON.stringify(message))
} }
publishData() { publishData() {

View File

@@ -3,50 +3,56 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class ContactSensor extends AlarmDevice { class ContactSensor extends AlarmDevice {
async init() { async publish(locationConnected) {
// Home Assistant component type and device class (set appropriate icon) // Only publish if location websocket is connected
if (!locationConnected) { return }
this.component = 'binary_sensor' this.component = 'binary_sensor'
if (this.device.deviceType == 'sensor.zone') { if (this.device.deviceType == 'sensor.zone') {
// Device is Retrofit Zone sensor // Home Assistant component type and device class (set appropriate icon)
this.className = 'safety' this.className = 'safety'
this.sensorType = 'zone' this.sensorType = 'zone'
// Device data for Home Assistant device registry
this.deviceData.mdl = 'Retrofit Zone'
} else { } else {
// Device is contact sensor // Home Assistant component type and device class (set appropriate icon)
this.className = (this.device.data.subCategoryId == 2) ? 'window' : 'door' this.className = (this.device.data.subCategoryId == 2) ? 'window' : 'door'
this.sensorType = 'contact' this.sensorType = 'contact'
// Device data for Home Assistant device registry
this.deviceData.mdl = 'Contact Sensor'
} }
// Build required MQTT topics for device // Build required MQTT topics
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.stateTopic = this.deviceTopic+'/'+this.sensorType+'/state'
this.stateTopic = this.deviceTopic+'/'+this.sensorType+'_state'
this.attributesTopic = this.deviceTopic+'/attributes'
this.availabilityTopic = this.deviceTopic+'/status'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message for HA and wait 2 seoonds before sending state // Publish discovery message
this.publishDiscovery() if (!this.discoveryData.length) { await this.initDiscoveryData() }
await utils.sleep(2) await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.device.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic,
device_class: this.className device_class: this.className,
} device: this.deviceData
},
configTopic: this.configTopic
})
debug('HASS config topic: '+this.configTopic) this.initInfoDiscoveryData()
debug(message)
this.publishMqtt(this.configTopic, JSON.stringify(message))
} }
publishData() { publishData() {

View File

@@ -3,51 +3,57 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class Fan extends AlarmDevice { class Fan extends AlarmDevice {
async init() { async publish(locationConnected) {
// Home Assistant component type and device class (set appropriate icon) // Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type
this.component = 'fan' this.component = 'fan'
// Build required MQTT topics for device // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Fan Control'
this.stateTopic = this.deviceTopic+'/fan_state'
this.commandTopic = this.deviceTopic+'/fan_command' // Build required MQTT topics
this.speedStateTopic = this.deviceTopic+'/fan_speed_state' this.stateTopic_fan = this.deviceTopic+'/fan/state'
this.speedCommandTopic = this.deviceTopic+'/fan_speed_command' this.commandTopic_fan = this.deviceTopic+'/fan/command'
this.attributesTopic = this.deviceTopic+'/attributes' this.stateTopic_speed = this.deviceTopic+'/fan/speed_state'
this.availabilityTopic = this.deviceTopic+'/status' this.commandTopic_speed = this.deviceTopic+'/fan/speed_command'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
this.prevFanState = undefined this.prevFanState = undefined
this.targetFanLevel = undefined this.targetFanLevel = undefined
// Publish discovery message for HA and wait 2 seoonds before sending state // Publish discovery message
this.publishDiscovery() if (!this.discoveryData.length) { await this.initDiscoveryData() }
await utils.sleep(2) await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
// Subscribe to device command topics
this.mqttClient.subscribe(this.commandTopic_fan)
this.mqttClient.subscribe(this.commandTopic_speed)
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.device.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic_fan,
command_topic: this.commandTopic, command_topic: this.commandTopic_fan,
speed_state_topic: this.speedStateTopic, speed_state_topic: this.stateTopic_speed,
speed_command_topic: this.speedCommandTopic, speed_command_topic: this.commandTopic_speed,
speeds: [ "low", "medium", "high" ] speeds: [ "low", "medium", "high" ],
} device: this.deviceData
},
configTopic: this.configTopic
})
debug('HASS config topic: '+this.configTopic) this.initInfoDiscoveryData('commStatus')
debug(message)
this.publishMqtt(this.configTopic, JSON.stringify(message))
this.mqttClient.subscribe(this.commandTopic)
this.mqttClient.subscribe(this.speedCommandTopic)
} }
publishData() { publishData() {
@@ -67,24 +73,24 @@ class Fan extends AlarmDevice {
// Publish device state // Publish device state
// targetFanLevel is a hack to work around Home Assistant UI behavior // targetFanLevel is a hack to work around Home Assistant UI behavior
if (this.targetFanLevel && this.targetFanLevel != fanLevel) { if (this.targetFanLevel && this.targetFanLevel != fanLevel) {
this.publishMqtt(this.speedStateTopic, this.targetFanLevel, true) this.publishMqtt(this.stateTopic_speed, this.targetFanLevel, true)
} else { } else {
this.publishMqtt(this.speedStateTopic, fanLevel, true) this.publishMqtt(this.stateTopic_speed, fanLevel, true)
} }
this.publishMqtt(this.stateTopic, fanState, true) this.publishMqtt(this.stateTopic_fan, fanState, true)
// Publish device attributes (batterylevel, tamper status) // Publish device attributes (batterylevel, tamper status)
this.publishAttributes() this.publishAttributes()
} }
// Process messages from MQTT command topic // Process messages from MQTT command topic
processCommand(message, cmdTopicLevel) { processCommand(message, topic) {
if (cmdTopicLevel == 'fan_command') { if (topic == this.commandTopic_fan) {
this.setFanState(message) this.setFanState(message)
} else if (cmdTopicLevel == 'fan_speed_command') { } else if (topic == this.commandTopic_speed) {
this.setFanLevel(message) this.setFanLevel(message)
} else { } else {
debug('Somehow received unknown command topic level '+cmdTopicLevel+' for fan Id: '+this.deviceId) debug('Somehow received unknown command topic '+topic+' for fan Id: '+this.deviceId)
} }
} }

View File

@@ -3,62 +3,63 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class FloodFreezeSensor extends AlarmDevice { class FloodFreezeSensor extends AlarmDevice {
async init() { async publish(locationConnected) {
// Only publish if location websocket is connected
if (!locationConnected) { return }
// Set Home Assistant component type and device class (appropriate icon in UI) // Set Home Assistant component type and device class (appropriate icon in UI)
this.className_flood = 'moisture' this.className_flood = 'moisture'
this.className_freeze = 'cold' this.className_freeze = 'cold'
this.component = 'binary_sensor' this.component = 'binary_sensor'
// Build a save MQTT topics for future use // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Flood & Freeze Sensor'
this.stateTopic_flood = this.deviceTopic+'/flood_state'
this.stateTopic_freeze = this.deviceTopic+'/freeze_state' // Build a save MQTT topics
this.attributesTopic = this.deviceTopic+'/attributes' this.stateTopic_flood = this.deviceTopic+'/flood/state'
this.availabilityTopic = this.deviceTopic+'/status' this.stateTopic_freeze = this.deviceTopic+'/freeze/state'
this.configTopic_flood = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'_flood/config' this.configTopic_flood = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'_flood/config'
this.configTopic_freeze = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'_freeze/config' this.configTopic_freeze = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'_freeze/config'
this.publishDiscovery() // Publish discovery messages
await utils.sleep(2) if (!this.discoveryData.length) { await this.initDiscoveryData() }
await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery messages // Build the MQTT discovery messages
const message_flood = { this.discoveryData.push({
name: this.device.name+' - Flood', message: {
unique_id: this.deviceId+'_'+this.className_flood, name: this.device.name+' Flood',
availability_topic: this.availabilityTopic, unique_id: this.deviceId+'_'+this.className_flood,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic_flood, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic_flood,
device_class: this.className_flood device_class: this.className_flood,
} device: this.deviceData
},
configTopic: this.configTopic_flood
})
const message_freeze = { this.discoveryData.push({
name: this.device.name+' - Freeze', message: {
unique_id: this.deviceId+'_'+this.className_freeze, name: this.device.name+' Freeze',
availability_topic: this.availabilityTopic, unique_id: this.deviceId+'_'+this.className_freeze,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic_freeze, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic_freeze,
device_class: this.className_freeze device_class: this.className_freeze,
} device: this.deviceData
},
configTopic: this.configTopic_freeze
})
// Publish flood sensor this.initInfoDiscoveryData()
debug('HASS config topic: '+this.configTopic_flood)
debug(message_flood)
this.publishMqtt(this.configTopic_flood, JSON.stringify(message_flood))
// Publish freeze sensor
debug('HASS config topic: '+this.configTopic_freeze)
debug(message_freeze)
this.publishMqtt(this.configTopic_freeze, JSON.stringify(message_freeze))
} }
publishData() { publishData() {

112
devices/keypad.js Normal file
View File

@@ -0,0 +1,112 @@
const debug = require('debug')('ring-mqtt')
const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device')
class Keypad extends AlarmDevice {
async publish(locationConnected) {
// Only publish if location websocket is connected
if (!locationConnected) { return }
// Device data for Home Assistant device registry
this.deviceData.mdl = 'Security Keypad'
// Build required MQTT topics
this.stateTopic_audio = this.deviceTopic+'/audio/state'
this.commandTopic_audio = this.deviceTopic+'/audio/command'
this.stateTopic_audio_volume = this.deviceTopic+'/audio/volume_state'
this.commandTopic_audio_volume = this.deviceTopic+'/audio/volume_command'
this.configTopic_audio = 'homeassistant/light/'+this.locationId+'/'+this.deviceId+'_audio/config'
// Publish discovery message
if (!this.discoveryData.length) { await this.initDiscoveryData() }
await this.publishDiscoveryData()
// Publish device state data with optional subscribe
this.publishSubscribeDevice()
// Subscribe to device command topics
this.mqttClient.subscribe(this.commandTopic_audio)
this.mqttClient.subscribe(this.commandTopic_audio_volume)
}
initDiscoveryData() {
// Build the MQTT discovery messages
this.discoveryData.push({
message: {
name: this.device.name+' Audio Settings',
unique_id: this.deviceId+'_audio',
availability_topic: this.availabilityTopic,
payload_available: 'online',
payload_not_available: 'offline',
state_topic: this.stateTopic_audio,
command_topic: this.commandTopic_audio,
brightness_scale: 100,
brightness_state_topic: this.stateTopic_audio_volume,
brightness_command_topic: this.commandTopic_audio_volume,
on_command_type: 'brightness',
icon: "mdi:volume-high",
device: this.deviceData
},
configTopic: this.configTopic_audio
})
// Device has no sensors, only publish info data
this.initInfoDiscoveryData()
}
publishData() {
const audioVolume = (this.device.data.volume && !isNaN(this.device.data.volume) ? Math.round(100 * this.device.data.volume) : 0)
const audioState = (audioVolume > 0) ? "ON" : "OFF"
// Publish device state
this.publishMqtt(this.stateTopic_audio, audioState, true)
this.publishMqtt(this.stateTopic_audio_volume, audioVolume.toString(), true)
// Publish device attributes (batterylevel, tamper status)
this.publishAttributes()
}
// Process messages from MQTT command topic
processCommand(message, topic) {
if (topic == this.commandTopic_audio) {
this.setAudioState(message)
} else if (topic == this.commandTopic_audio_volume) {
this.setVolumeLevel(message)
} else {
debug('Somehow received unknown command topic '+topic+' for keypad Id: '+this.deviceId)
}
}
// Set switch target state on received MQTT command message
setAudioState(message) {
const command = message.toLowerCase()
switch(command) {
case 'on':
case 'off': {
debug('Received command to turn '+command+' audio for keypad Id: '+this.deviceId)
const volume = (command === 'on') ? .65 : 0
debug('Setting volume level to '+volume*100+'%')
this.device.setVolume(volume)
break;
}
default:
debug('Received invalid audio command for keypad!')
}
}
// Set switch target state on received MQTT command message
setVolumeLevel(message) {
const volume = message
debug('Received set volume level to '+volume+'% for keypad Id: '+this.deviceId)
debug('Location Id: '+ this.locationId)
if (isNaN(message)) {
debug('Volume command received but not a number!')
} else if (!(message >= 0 && message <= 100)) {
debug('Volume command received but out of range (0-100)!')
} else {
this.device.setVolume(volume/100)
}
}
}
module.exports = Keypad

View File

@@ -3,43 +3,49 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class Lock extends AlarmDevice { class Lock extends AlarmDevice {
async init() { async publish(locationConnected) {
// Home Assistant component type and device class (set appropriate icon) // Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type
this.component = 'lock' this.component = 'lock'
// Build required MQTT topics for device // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Lock'
this.stateTopic = this.deviceTopic+'/lock_state'
this.commandTopic = this.deviceTopic+'/lock_command' // Build required MQTT topics
this.attributesTopic = this.deviceTopic+'/attributes' this.stateTopic = this.deviceTopic+'/lock/state'
this.availabilityTopic = this.deviceTopic+'/status' this.commandTopic = this.deviceTopic+'/lock/command'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message for HA and wait 2 seoonds before sending state // Publish discovery message
this.publishDiscovery() if (!this.discoveryData.length) { await this.initDiscoveryData() }
await utils.sleep(2) await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
// Subscribe to device command topic
this.mqttClient.subscribe(this.commandTopic)
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.device.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic,
command_topic: this.commandTopic command_topic: this.commandTopic,
} device: this.deviceData
},
debug('HASS config topic: '+this.configTopic) configTopic: this.configTopic
debug(message) })
this.publishMqtt(this.configTopic, JSON.stringify(message))
this.mqttClient.subscribe(this.commandTopic) this.initInfoDiscoveryData()
} }
publishData() { publishData() {

View File

@@ -3,23 +3,25 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class ModesPanel extends AlarmDevice { class ModesPanel extends AlarmDevice {
async init() { async publish() {
// Home Assistant component type and device class (set appropriate icon) // Home Assistant component type
this.component = 'alarm_control_panel' this.component = 'alarm_control_panel'
// Build required MQTT topics for device // Device data for Home Assistant device registry
this.deviceTopic = this.ringTopic+'/'+this.locationId+'/mode/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Mode Control Panel'
this.stateTopic = this.deviceTopic+'/mode_state' this.deviceData.name = this.device.location.name + ' Mode'
this.commandTopic = this.deviceTopic+'/mode_command'
this.availabilityTopic = this.deviceTopic+'/status' // Build required MQTT topics
this.stateTopic = this.deviceTopic+'/mode/state'
this.commandTopic = this.deviceTopic+'/mode/command'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Device specific properties // Save current mode if known
this.currentMode = this.currentMode ? this.currentMode : 'unknown' this.currentMode = this.currentMode ? this.currentMode : 'unknown'
// Publish discovery message for HA and wait 2 seoonds before sending state // Publish discovery message
this.publishDiscovery() if (!this.discoveryData.length) { await this.initDiscoveryData() }
await utils.sleep(2) await this.publishDiscoveryData()
// This is a polled device so don't use common publish/subscribe function // This is a polled device so don't use common publish/subscribe function
if (this.subscribed) { if (this.subscribed) {
@@ -33,24 +35,26 @@ class ModesPanel extends AlarmDevice {
this.subscribed = true this.subscribed = true
} }
this.online() this.online()
// Subscribe to device command topic
this.mqttClient.subscribe(this.commandTopic)
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.location.name + ' Mode', message: {
unique_id: this.deviceId, name: this.deviceData.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
command_topic: this.commandTopic state_topic: this.stateTopic,
} command_topic: this.commandTopic,
device: this.deviceData
debug('HASS config topic: '+this.configTopic) },
debug(message) configTopic: this.configTopic
this.publishMqtt(this.configTopic, JSON.stringify(message)) })
this.mqttClient.subscribe(this.commandTopic)
} }
async publishData(mode) { async publishData(mode) {
@@ -83,7 +87,7 @@ class ModesPanel extends AlarmDevice {
// Set Alarm Mode on received MQTT command message // Set Alarm Mode on received MQTT command message
async setLocationMode(message) { async setLocationMode(message) {
debug('Received set mode command '+message+' for location: '+this.device.location.name) debug('Received set mode command '+message+' for location '+this.device.location.name+' ('+this.location+')')
// Try to set alarm mode and retry after delay if mode set fails // Try to set alarm mode and retry after delay if mode set fails
// Initial attempt with no delay // Initial attempt with no delay
@@ -97,7 +101,7 @@ class ModesPanel extends AlarmDevice {
} }
// Check the return status and print some debugging for failed states // Check the return status and print some debugging for failed states
if (setModeSuccess == false ) { if (setModeSuccess == false ) {
debug('Could not enter proper mode state after all retries...Giving up!') debug('Location could not enter proper mode after all retries...Giving up!')
} else if (setModeSuccess == 'unknown') { } else if (setModeSuccess == 'unknown') {
debug('Ignoring unknown command.') debug('Ignoring unknown command.')
} }
@@ -126,10 +130,10 @@ class ModesPanel extends AlarmDevice {
// Sleep a 1 second and check if location entered the requested mode // Sleep a 1 second and check if location entered the requested mode
await utils.sleep(1); await utils.sleep(1);
if (targetMode == (await this.device.location.getLocationMode()).mode) { if (targetMode == (await this.device.location.getLocationMode()).mode) {
debug('Location '+this.device.location.name+' successfully entered mode: '+message) debug('Location '+this.device.location.name+' successfully entered '+message+' mode')
return true return true
} else { } else {
debug('Location failed to enter requested mode!') debug('Location '+this.device.location.name+' failed to enter requested mode!')
return false return false
} }
} }

View File

@@ -3,42 +3,46 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class MotionSensor extends AlarmDevice { class MotionSensor extends AlarmDevice {
async init() { async publish(locationConnected) {
// Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type and device class (set appropriate icon) // Home Assistant component type and device class (set appropriate icon)
this.component = 'binary_sensor' this.component = 'binary_sensor'
this.className = 'motion' this.className = 'motion'
// Build required MQTT topics for device // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Motion Sensor'
this.stateTopic = this.deviceTopic+'/motion_state'
this.attributesTopic = this.deviceTopic+'/attributes' // Build required MQTT topics
this.availabilityTopic = this.deviceTopic+'/status' this.stateTopic = this.deviceTopic+'/motion/state'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message for HA and wait 2 seoonds before sending state // Publish discovery message
this.publishDiscovery() if (!this.discoveryData.length) { await this.initDiscoveryData() }
await utils.sleep(2) await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.device.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic,
device_class: this.className device_class: this.className,
} device: this.deviceData
},
configTopic: this.configTopic
})
debug('HASS config topic: '+this.configTopic) this.initInfoDiscoveryData()
debug(message)
this.publishMqtt(this.configTopic, JSON.stringify(message))
} }
publishData() { publishData() {

View File

@@ -3,69 +3,75 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class MultiLevelSwitch extends AlarmDevice { class MultiLevelSwitch extends AlarmDevice {
async init() { async publish(locationConnected) {
// Home Assistant component type and device class (set appropriate icon) // Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type
this.component = 'light' this.component = 'light'
// Build required MQTT topics for device // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Dimmer Switch'
this.stateTopic = this.deviceTopic+'/switch_state'
this.commandTopic = this.deviceTopic+'/switch_command' // Build required MQTT topics
this.brightnessStateTopic = this.deviceTopic+'/brightness_state' this.stateTopic_light = this.deviceTopic+'/light/state'
this.brightnessCommandTopic = this.deviceTopic+'/brightness_command' this.commandTopic_light = this.deviceTopic+'/light/command'
this.attributesTopic = this.deviceTopic+'/attributes' this.stateTopic_brightness = this.deviceTopic+'/light/brightness_state'
this.availabilityTopic = this.deviceTopic+'/status' this.commandTopic_brightness = this.deviceTopic+'/light/brightness_command'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message for HA and wait 2 seoonds before sending state // Publish discovery message
this.publishDiscovery() if (!this.discoveryData.length) { await this.initDiscoveryData() }
await utils.sleep(2) await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
// Subscribe to device command topics
this.mqttClient.subscribe(this.commandTopic_light)
this.mqttClient.subscribe(this.commandTopic_brightness)
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.device.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic_light,
command_topic: this.commandTopic, command_topic: this.commandTopic_light,
brightness_scale: 100, brightness_scale: 100,
brightness_state_topic: this.brightnessStateTopic, brightness_state_topic: this.stateTopic_brightness,
brightness_command_topic: this.brightnessCommandTopic brightness_command_topic: this.commandTopic_brightness,
} device: this.deviceData
},
configTopic: this.configTopic
})
debug('HASS config topic: '+this.configTopic) this.initInfoDiscoveryData('commStatus')
debug(message)
this.publishMqtt(this.configTopic, JSON.stringify(message))
this.mqttClient.subscribe(this.commandTopic)
this.mqttClient.subscribe(this.brightnessCommandTopic)
} }
publishData() { publishData() {
const switchState = this.device.data.on ? "ON" : "OFF" const switchState = this.device.data.on ? "ON" : "OFF"
const switchLevel = (this.device.data.level && !isNaN(this.device.data.level) ? Math.round(100 * this.device.data.level) : 0) const switchLevel = (this.device.data.level && !isNaN(this.device.data.level) ? Math.round(100 * this.device.data.level) : 0)
// Publish device state // Publish device state
this.publishMqtt(this.stateTopic, switchState, true) this.publishMqtt(this.stateTopic_light, switchState, true)
this.publishMqtt(this.brightnessStateTopic, switchLevel.toString(), true) this.publishMqtt(this.stateTopic_brightness, switchLevel.toString(), true)
// Publish device attributes (batterylevel, tamper status) // Publish device attributes (batterylevel, tamper status)
this.publishAttributes() this.publishAttributes()
} }
// Process messages from MQTT command topic // Process messages from MQTT command topic
processCommand(message, cmdTopicLevel) { processCommand(message, topic) {
if (cmdTopicLevel == 'switch_command') { if (topic == this.commandTopic_light) {
this.setSwitchState(message) this.setSwitchState(message)
} else if (cmdTopicLevel == 'brightness_command') { } else if (topic == this.commandTopic_brightness) {
this.setSwitchLevel(message) this.setSwitchLevel(message)
} else { } else {
debug('Somehow received unknown command topic level '+cmdTopicLevel+' for switch Id: '+this.deviceId) debug('Somehow received unknown command topic '+topic+' for switch Id: '+this.deviceId)
} }
} }

View File

@@ -1,77 +1,189 @@
const debug = require('debug')('ring-mqtt') const debug = require('debug')('ring-mqtt')
const utils = require( '../lib/utils' ) const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
const alarmStates = require('ring-client-api').allAlarmStates
class SecurityPanel extends AlarmDevice { class SecurityPanel extends AlarmDevice {
async init() { async publish(locationConnected) {
// Home Assistant component type and device class (set appropriate icon) // Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type
this.component = 'alarm_control_panel' this.component = 'alarm_control_panel'
// Build required MQTT topics for device // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Alarm Control Panel'
this.stateTopic = this.deviceTopic+'/alarm_state' this.deviceData.name = this.device.location.name + ' Alarm'
this.commandTopic = this.deviceTopic+'/alarm_command'
this.attributesTopic = this.deviceTopic+'/attributes'
this.availabilityTopic = this.deviceTopic+'/status'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message for HA and wait 2 seoonds before sending state // Build required MQTT topics
this.publishDiscovery() this.stateTopic = this.deviceTopic+'/alarm/state'
await utils.sleep(2) this.commandTopic = this.deviceTopic+'/alarm/command'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
this.stateTopic_siren = this.deviceTopic+'/siren/state'
this.commandTopic_siren = this.deviceTopic+'/siren/command'
this.configTopic_siren = 'homeassistant/switch/'+this.locationId+'/'+this.deviceId+'_siren/config'
if (this.config.enable_panic) {
// Build required MQTT topics for device
this.stateTopic_police = this.deviceTopic+'/police/state'
this.commandTopic_police = this.deviceTopic+'/police/command'
this.configTopic_police = 'homeassistant/switch/'+this.locationId+'/'+this.deviceId+'_police/config'
this.stateTopic_fire = this.deviceTopic+'/fire/state'
this.commandTopic_fire = this.deviceTopic+'/fire/command'
this.configTopic_fire = 'homeassistant/switch/'+this.locationId+'/'+this.deviceId+'_fire/config'
}
// Publish discovery message
if (!this.discoveryData.length) { await this.initDiscoveryData() }
await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
// Subscribe to device command topic
this.mqttClient.subscribe(this.commandTopic)
this.mqttClient.subscribe(this.commandTopic_siren)
if (this.config.enable_panic) {
this.mqttClient.subscribe(this.commandTopic_police)
this.mqttClient.subscribe(this.commandTopic_fire)
}
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery messages
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.deviceData.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic,
command_topic: this.commandTopic command_topic: this.commandTopic,
} device: this.deviceData
},
configTopic: this.configTopic
})
debug('HASS config topic: '+this.configTopic) this.discoveryData.push({
debug(message) message: {
this.publishMqtt(this.configTopic, JSON.stringify(message)) name: this.device.location.name+' Siren',
this.mqttClient.subscribe(this.commandTopic) unique_id: this.deviceId+'_siren',
availability_topic: this.availabilityTopic,
payload_available: 'online',
payload_not_available: 'offline',
state_topic: this.stateTopic_siren,
command_topic: this.commandTopic_siren,
device: this.deviceData
},
configTopic: this.configTopic_siren
})
if (this.config.enable_panic) {
this.discoveryData.push({
message: {
name: this.device.location.name+' Panic - Police',
unique_id: this.deviceId+'_police',
availability_topic: this.availabilityTopic,
payload_available: 'online',
payload_not_available: 'offline',
state_topic: this.stateTopic_police,
command_topic: this.commandTopic_police,
device: this.deviceData
},
configTopic: this.configTopic_police
})
this.discoveryData.push({
message: {
name: this.device.location.name+' Panic - Fire',
unique_id: this.deviceId+'_fire',
availability_topic: this.availabilityTopic,
payload_available: 'online',
payload_not_available: 'offline',
state_topic: this.stateTopic_fire,
command_topic: this.commandTopic_fire,
device: this.deviceData
},
configTopic: this.configTopic_fire
})
}
this.initInfoDiscoveryData('alarmState')
} }
publishData() { publishData() {
var alarmMode var alarmMode
switch(this.device.data.mode) { const alarmInfo = this.device.data.alarmInfo ? this.device.data.alarmInfo : []
case 'none':
alarmMode = 'disarmed' // If alarm is active report triggered or, if entry-delay, pending
break; if (alarmStates.includes(alarmInfo.state)) {
case 'some': alarmMode = alarmInfo.state === 'entry-delay' ? 'pending' : 'triggered'
alarmMode = 'armed_home' } else {
break; switch(this.device.data.mode) {
case 'all': case 'none':
alarmMode = 'armed_away' alarmMode = 'disarmed'
break; break;
default: case 'some':
alarmMode = 'unknown' alarmMode = 'armed_home'
break;
case 'all':
alarmMode = 'armed_away'
break;
default:
alarmMode = 'unknown'
}
} }
// Publish device sensor state // Publish device sensor state
this.publishMqtt(this.stateTopic, alarmMode, true) this.publishMqtt(this.stateTopic, alarmMode, true)
// Publish siren state
const sirenState = (this.device.data.siren && this.device.data.siren.state === 'on') ? 'ON' : 'OFF'
this.publishMqtt(this.stateTopic_siren, sirenState, true)
if (this.config.enable_panic) {
let policeState = 'OFF'
let fireState = 'OFF'
const alarmState = this.device.data.alarmInfo ? this.device.data.alarmInfo.state : ''
switch (alarmState) {
case 'burglar-alarm':
case 'user-verified-burglar-alarm':
case 'burglar-accelerated-alarm':
policeState = 'ON'
debug('Burgler alarm is active for '+this.device.location.name)
case 'fire-alarm':
case 'co-alarm':
case 'user-verified-co-or-fire-alarm':
case 'fire-accelerated-alarm':
fireState = 'ON'
debug('Fire alarm is active for '+this.device.location.name)
}
this.publishMqtt(this.stateTopic_police, policeState, true)
this.publishMqtt(this.stateTopic_fire, fireState, true)
}
// Publish device attributes (batterylevel, tamper status) // Publish device attributes (batterylevel, tamper status)
this.publishAttributes() this.publishAttributes()
} }
// Process messages from MQTT command topic // Process messages from MQTT command topic
processCommand(message) { processCommand(message, topic) {
this.setAlarmMode(message) if (topic == this.commandTopic) {
this.setAlarmMode(message)
} else if (topic == this.commandTopic_siren) {
this.setSirenMode(message)
} else if (topic == this.commandTopic_police) {
this.setPoliceMode(message)
} else if (topic == this.commandTopic_fire) {
this.setFireMode(message)
} else {
debug('Somehow received unknown command topic '+topic+' for switch Id: '+this.deviceId)
}
} }
// Set Alarm Mode on received MQTT command message // Set Alarm Mode on received MQTT command message
async setAlarmMode(message) { async setAlarmMode(message) {
debug('Received set alarm mode '+message+' for Security Panel Id: '+this.deviceId) debug('Received set alarm mode '+message+' for location '+this.device.location.name+' ('+this.location+')')
debug('Location Id: '+ this.locationId)
// Try to set alarm mode and retry after delay if mode set fails // Try to set alarm mode and retry after delay if mode set fails
// Initial attempt with no delay // Initial attempt with no delay
@@ -85,7 +197,7 @@ class SecurityPanel extends AlarmDevice {
} }
// Check the return status and print some debugging for failed states // Check the return status and print some debugging for failed states
if (setAlarmSuccess == false ) { if (setAlarmSuccess == false ) {
debug('Device could not enter proper arming mode after all retries...Giving up!') debug('Alarm could not enter proper arming mode after all retries...Giving up!')
} else if (setAlarmSuccess == 'unknown') { } else if (setAlarmSuccess == 'unknown') {
debug('Ignoring unknown command.') debug('Ignoring unknown command.')
} }
@@ -95,16 +207,16 @@ class SecurityPanel extends AlarmDevice {
await utils.sleep(delay) await utils.sleep(delay)
var alarmTargetMode var alarmTargetMode
debug('Set alarm mode: '+message) debug('Set alarm mode: '+message)
switch(message) { switch(message.toLowerCase()) {
case 'DISARM': case 'disarm':
this.device.location.disarm().catch(err => { debug(err) }) this.device.location.disarm().catch(err => { debug(err) })
alarmTargetMode = 'none' alarmTargetMode = 'none'
break break
case 'ARM_HOME': case 'arm_home':
this.device.location.armHome().catch(err => { debug(err) }) this.device.location.armHome().catch(err => { debug(err) })
alarmTargetMode = 'some' alarmTargetMode = 'some'
break break
case 'ARM_AWAY': case 'arm_away':
this.device.location.armAway().catch(err => { debug(err) }) this.device.location.armAway().catch(err => { debug(err) })
alarmTargetMode = 'all' alarmTargetMode = 'all'
break break
@@ -114,15 +226,63 @@ class SecurityPanel extends AlarmDevice {
} }
// Sleep a few seconds and check if alarm entered requested mode // Sleep a few seconds and check if alarm entered requested mode
await utils.sleep(2); await utils.sleep(1);
if (this.device.data.mode == alarmTargetMode) { if (this.device.data.mode == alarmTargetMode) {
debug('Alarm successfully entered mode: '+message) debug('Alarm for location '+this.device.location.name+' successfully entered '+message+' mode')
return true return true
} else { } else {
debug('Device failed to enter requested arm/disarm mode!') debug('Alarm for location '+this.device.location.name+' failed to enter requested arm/disarm mode!')
return false return false
} }
} }
async setSirenMode(message) {
switch(message.toLowerCase()) {
case 'on':
debug('Activating siren for '+this.device.location.name)
this.device.location.soundSiren().catch(err => { debug(err) })
break;
case 'off': {
debug('Deactivating siren for '+this.device.location.name)
this.device.location.silenceSiren().catch(err => { debug(err) })
break;
}
default:
debug('Received invalid command for siren!')
}
}
async setPoliceMode(message) {
switch(message.toLowerCase()) {
case 'on':
debug('Activating burglar alarm for '+this.device.location.name)
this.device.location.triggerBurglarAlarm().catch(err => { debug(err) })
break;
case 'off': {
debug('Deactivating burglar alarm for '+this.device.location.name)
this.device.location.setAlarmMode('none').catch(err => { debug(err) })
break;
}
default:
debug('Received invalid command for panic!')
}
}
async setFireMode(message) {
switch(message.toLowerCase()) {
case 'on':
debug('Activating fire alarm for '+this.device.location.name)
this.device.location.triggerFireAlarm().catch(err => { debug(err) })
break;
case 'off': {
debug('Deactivating fire alarm for '+this.device.location.name)
this.device.location.setAlarmMode('none').catch(err => { debug(err) })
break;
}
default:
debug('Received invalid command for panic!')
}
}
} }
module.exports = SecurityPanel module.exports = SecurityPanel

View File

@@ -3,42 +3,46 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class SmokeAlarm extends AlarmDevice { class SmokeAlarm extends AlarmDevice {
async init() { async publish(locationConnected) {
// Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type and device class (set appropriate icon) // Home Assistant component type and device class (set appropriate icon)
this.component = 'binary_sensor' this.component = 'binary_sensor'
this.className = 'smoke' this.className = 'smoke'
// Build required MQTT topics for device // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Smoke Alarm'
this.stateTopic = this.deviceTopic+'/smoke_state'
this.attributesTopic = this.deviceTopic+'/attributes'
this.availabilityTopic = this.deviceTopic+'/status'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message for HA and wait 2 seoonds before sending state // Build required MQTT topics
this.publishDiscovery() this.stateTopic = this.deviceTopic+'/smoke/state'
await utils.sleep(2) this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message
if (!this.discoveryData.length) { await this.initDiscoveryData() }
await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.device.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic,
device_class: this.className device_class: this.className,
} device: this.deviceData
},
debug('HASS config topic: '+this.configTopic) configTopic: this.configTopic
debug(message) })
this.publishMqtt(this.configTopic, JSON.stringify(message))
this.initInfoDiscoveryData()
} }
publishData() { publishData() {

View File

@@ -3,62 +3,64 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class SmokeCoListener extends AlarmDevice { class SmokeCoListener extends AlarmDevice {
async init() { async publish(locationConnected) {
// Set Home Assistant component type and device class (appropriate icon in UI) // Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type and device class (set appropriate icon)
this.className_smoke = 'smoke' this.className_smoke = 'smoke'
this.className_co = 'gas' this.className_co = 'gas'
this.component = 'binary_sensor' this.component = 'binary_sensor'
// Build a save MQTT topics for future use // Device data for Home Assistant device registry
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.deviceData.mdl = 'Smoke & CO Listener'
this.stateTopic_smoke = this.deviceTopic+'/smoke_state'
this.stateTopic_co = this.deviceTopic+'/co_state' // Build a save MQTT topics
this.attributesTopic = this.deviceTopic+'/attributes' this.stateTopic_smoke = this.deviceTopic+'/smoke/state'
this.availabilityTopic = this.deviceTopic+'/status' this.stateTopic_co = this.deviceTopic+'/co/state'
this.configTopic_smoke = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'_smoke/config' this.configTopic_smoke = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'_smoke/config'
this.configTopic_co = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'_gas/config' this.configTopic_co = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'_gas/config'
this.publishDiscovery() // Publish discovery message
await utils.sleep(2) if (!this.discoveryData.length) { await this.initDiscoveryData() }
await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message for smoke detector
this.discoveryData.push({
message: {
name: this.device.name+' Smoke',
unique_id: this.deviceId+'_'+this.className_smoke,
availability_topic: this.availabilityTopic,
payload_available: 'online',
payload_not_available: 'offline',
state_topic: this.stateTopic_smoke,
device_class: this.className_smoke,
device: this.deviceData
},
configTopic: this.configTopic_smoke
})
// Build the MQTT discovery messages // Build the MQTT discovery message for co detector
const message_smoke = { this.discoveryData.push({
name: this.device.name+' - Smoke', message: {
unique_id: this.deviceId+'_'+this.className_smoke, name: this.device.name+' CO',
availability_topic: this.availabilityTopic, unique_id: this.deviceId+'_'+this.className_co,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic_smoke, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic_co,
device_class: this.className_smoke device_class: this.className_co,
} device: this.deviceData
},
configTopic: this.configTopic_co
})
const message_co = { this.initInfoDiscoveryData()
name: this.device.name+' - CO',
unique_id: this.deviceId+'_'+this.className_co,
availability_topic: this.availabilityTopic,
payload_available: 'online',
payload_not_available: 'offline',
state_topic: this.stateTopic_co,
json_attributes_topic: this.attributesTopic,
device_class: this.className_co
}
// Publish smoke sensor
debug('HASS config topic: '+this.configTopic_smoke)
debug(message_smoke)
this.publishMqtt(this.configTopic_smoke, JSON.stringify(message_smoke))
// Publish CO sensor
debug('HASS config topic: '+this.configTopic_co)
debug(message_co)
this.publishMqtt(this.configTopic_co, JSON.stringify(message_co))
} }
publishData() { publishData() {

View File

@@ -3,43 +3,49 @@ const utils = require( '../lib/utils' )
const AlarmDevice = require('./alarm-device') const AlarmDevice = require('./alarm-device')
class Switch extends AlarmDevice { class Switch extends AlarmDevice {
async init() { async publish(locationConnected) {
// Home Assistant component type and device class (set appropriate icon) // Only publish if location websocket is connected
if (!locationConnected) { return }
// Home Assistant component type
this.component = (this.device.data.categoryId === 2) ? 'light' : 'switch' this.component = (this.device.data.categoryId === 2) ? 'light' : 'switch'
// Device data for Home Assistant device registry
this.deviceData.mdl = (this.device.data.categoryId === 2) ? 'Light' : 'Switch'
// Build required MQTT topics for device // Build required MQTT topics for device
this.deviceTopic = this.alarmTopic+'/'+this.component+'/'+this.deviceId this.stateTopic = this.deviceTopic+'/switch/state'
this.stateTopic = this.deviceTopic+'/switch_state' this.commandTopic = this.deviceTopic+'/switch/command'
this.commandTopic = this.deviceTopic+'/switch_command'
this.attributesTopic = this.deviceTopic+'/attributes'
this.availabilityTopic = this.deviceTopic+'/status'
this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config' this.configTopic = 'homeassistant/'+this.component+'/'+this.locationId+'/'+this.deviceId+'/config'
// Publish discovery message for HA and wait 2 seoonds before sending state // Publish discovery message
this.publishDiscovery() if (!this.discoveryData.length) { await this.initDiscoveryData() }
await utils.sleep(2) await this.publishDiscoveryData()
// Publish device state data with optional subscribe // Publish device state data with optional subscribe
this.publishSubscribeDevice() this.publishSubscribeDevice()
// Subscribe to device command topic
this.mqttClient.subscribe(this.commandTopic)
} }
publishDiscovery() { initDiscoveryData() {
// Build the MQTT discovery message // Build the MQTT discovery message
const message = { this.discoveryData.push({
name: this.device.name, message: {
unique_id: this.deviceId, name: this.device.name,
availability_topic: this.availabilityTopic, unique_id: this.deviceId,
payload_available: 'online', availability_topic: this.availabilityTopic,
payload_not_available: 'offline', payload_available: 'online',
state_topic: this.stateTopic, payload_not_available: 'offline',
json_attributes_topic: this.attributesTopic, state_topic: this.stateTopic,
command_topic: this.commandTopic command_topic: this.commandTopic,
} device: this.deviceData
},
configTopic: this.configTopic
})
debug('HASS config topic: '+this.configTopic) this.initInfoDiscoveryData('commStatus')
debug(message)
this.publishMqtt(this.configTopic, JSON.stringify(message))
this.mqttClient.subscribe(this.commandTopic)
} }
publishData() { publishData() {

32
docs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,32 @@
##v4.0.0
- Support for Home Assistant Device Registry
- Each device has an "info" sensor updated at least every 5 minutes. State is JSON data with values unique to each device type (battery level, tamper status, AC state, wireless strength, firmware info, serial number, etc)
- Support for monitoring alarm state
- "Pending" state is equivalent to "entry delay"
- "Triggered" state is any alarm
- Detailed alarm state is available via info sensor to get detailed alarm state info:
- 'entry-delay'
- 'burglar-alarm'
- 'fire-alarm'
- 'co-alarm'
- 'panic'
- 'user-verified-burglar-alarm'
- 'user-verified-co-or-fire-alarm'
- 'burglar-accelerated-alarm'
| - 'fire-accelerated-alarm'
- Support for Fire and Polic Panic Buttons
- *** Should be Used with Caution *** -- Can trigger alarm events with response
- Can also be used as a high level monitor for burglar or fire alarms (panic state will trigger based on alarm type)
- Support for Base Station
- Info sensor for monitoring battery/ac status
- Ability to set volume (requires use of master account as other accounts don't have permission)
- Support for Keypad
- Info sensor for monitoring batter/ac status, charging state, etc.
- Ability to set volume
- Basic support for 3rd party Z-wave contact and motion sensors
- Assumes device is concact sensor unless device name contains the word "motion"
- Significantly enhanced Web UI for token generation (mostly for Home Assistant Addon)
- Improved debug output with more organized location/device discovery output
- Simplified and standardized location/device handling code (still more work to do but becoming far more maintainable)
##Changes for v3.3.0 and earlier were not tracked in this file

151
docs/TOPICS.md Normal file
View File

@@ -0,0 +1,151 @@
With 4.0.0 topics level have been refactored for consistency using the following general format
```
ring/<location_id>/<ring_category>/<device_id>/<device_type>/state
ring/<location_id>/<ring_category>/<device_id>/<device_type>/command
```
The <ring_category> is either "alarm", "smart_lighting", or "camera" based on the type of device. Cameras are doorbells, stickup, spotlight or floodlight cams. Alarm devices are any devices connected via the Alarm base station and smart_lighting is any device connected via the smart lighting bridge.
The script monitors cameras by polling health status every 60 seconds and monitors the websocket connections for alarm and smart lighting devices, automatically updating the online/offline state of the devices based on this connectivity information. As this is device level connectivity is published to "status" at the device_id level:
```
ring/<location_id>/<ring_category>/<device_id>/status
```
Each device also inlcudes an "info" sensor where the state topic includes various supplemental data for the device in JSON format. This information varies by devies and includes data such as battery level, tamper status, communicaton state, volume, wifi signal strength, and other device specific data.
For the individual device capabilities the state and command topics are simple text strings (not JSON), which use the default values for the equivalent Home Assistant device integration. Some sensors may have multiple attribues, such as a multi-level-switch as both on/off and brightness, so they will have a standard state/command topic and an additional topic in the format of <attribute>_state and <attribute>_topic. Below is a listing of all currently supported devices and topics.
Alarm Control Panel (virtual device):
```
ring/<location_id>/alarm/<device_id>/alarm/state <-- Alarm arming state (pending = entry delay)
disarmed/armed_home/armed_away/pending/triggered
ring/<location_id>/alarm/<device_id>/alarm/state <-- Set alarm mode: disarm/arm_home/arm_away
ring/<location_id>/alarm/<device_id>/siren/state <-- Get ON/OFF Siren State
ring/<location_id>/alarm/<device_id>/siren/command <-- Set ON/OFF Siren State
ring/<location_id>/alarm/<device_id>/police/state <-- Get ON/OFF Police Panic State
ring/<location_id>/alarm/<device_id>/police/command <-- Set ON/OFF Police Panic State
ring/<location_id>/alarm/<device_id>/fire/state <-- Get ON/OFF Fire Panic State
ring/<location_id>/alarm/<device_id>/fire/command <-- Set ON/OFF Fire Panic State
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Alarm Base Station:
```
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
ring/<location_id>/alarm/<device_id>/volume/state <-- Get Volume State (0-100)
ring/<location_id>/alarm/<device_id>/volume/command <-- Set Volume State (0-100)
(Requires master account, shared
account does not have permission
to control base station volume)
```
Ring Keypad:
```
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
ring/<location_id>/alarm/<device_id>/volume/state <-- Get Volume State (0-100)
ring/<location_id>/alarm/<device_id>/volume/command <-- Set Volume State (0-100)
```
CO detector:
```
ring/<location_id>/alarm/<device_id>/co/state <-- ON = CO Detected
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Contact Sensor:
```
ring/<location_id>/alarm/<device_id>/cotact/state <-- ON = Contact Open
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Ring Retrofit Sensor:
```
ring/<location_id>/alarm/<device_id>/zone/state <-- ON = Zone Tripped
ring/<location_id>/alarm/<device_id>/zone/state <-- Device info sensor
```
Fan switch:
```
ring/<location_id>/alarm/<device_id>/fan/state <-- Get ON/OFF state
ring/<location_id>/alarm/<device_id>/fan/command <-- Set ON/OF state
ring/<location_id>/alarm/<device_id>/fan/speed_state <-- Get brightness state (0-100)
ring/<location_id>/alarm/<device_id>/fan/speed_command <-- Set brightness state (0-100)
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Ring Flood/Freeze Sensor:
```
ring/<location_id>/alarm/<device_id>/flood/state <-- ON = Flood Detected
ring/<location_id>/alarm/<device_id>/freeze/state <-- ON = Freeze Detected
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Locks:
```
ring/<location_id>/alarm/<device_id>/lock/state <-- Get LOCKED/UNLOCKED state
ring/<location_id>/alarm/<device_id>/lock/command <-- Set LOCK/UNLOCK state
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Modes Control Panel (virtual alarm control panel for setting Ring location modes for
locations with Ring cameras but not Ring alarm):
```
ring/<location_id>/alarm/<device_id>/mode/state <-- Location mode state
disarmed/armed_home/armed_away
ring/<location_id>/alarm/<device_id>/mode/state <-- Set location mode: disarm/arm_home/arm_away
```
Motion Sensor:
```
ring/<location_id>/alarm/<device_id>/motion/state <-- ON = Motion Detected
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Dimmer switch:
```
ring/<location_id>/alarm/<device_id>/light/state <-- Get ON/OFF state
ring/<location_id>/alarm/<device_id>/light/command <-- Set ON/OF state
ring/<location_id>/alarm/<device_id>/light/brightness_state <-- Get brightness state (0-100)
ring/<location_id>/alarm/<device_id>/light/brightness_command <-- Set brightness state (0-100)
```
Smoke Detector:
```
ring/<location_id>/alarm/<device_id>/smoke/state <-- ON = Smoke Detected
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Ring Smoke/CO listener :
```
ring/<location_id>/alarm/<device_id>/smoke/state <-- ON = Smoke Detected
ring/<location_id>/alarm/<device_id>/co/state <-- ON = CO Detected
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Switch:
```
ring/<location_id>/alarm/<device_id>/switch/state <-- Get ON/OFF state
ring/<location_id>/alarm/<device_id>/switch/command <-- Set ON/OF state
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Cameras (available topics vary based by device capabilities):
```
ring/<location_id>/camera/<device_id>/ding/state <-- ON = Doorbell Ding Detected
ring/<location_id>/camera/<device_id>/motion/state <-- ON = Motion Detected
ring/<location_id>/camera/<device_id>/light/state <-- Get ON/OFF Light State
ring/<location_id>/camera/<device_id>/light/command <-- Set ON/OFF Light State
ring/<location_id>/camera/<device_id>/siren/state <-- Get ON/OFF Siren State
ring/<location_id>/camera/<device_id>/siren/command <-- Set ON/OFF Siren State
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```
Ring Smart Lighting (available topics vary by device capabilities)
```
ring/<location_id>/alarm/<device_id>/motion/state <-- ON = Motion Detected
ring/<location_id>/alarm/<device_id>/light/state <-- Get ON/OFF state
ring/<location_id>/alarm/<device_id>/light/command <-- Set ON/OF state
ring/<location_id>/alarm/<device_id>/light/brightness_state <-- Get brightness state (0-100)
ring/<location_id>/alarm/<device_id>/light/brightness_command <-- Set brightness state (0-100)
ring/<location_id>/alarm/<device_id>/info/state <-- Device info sensor
```

106
lib/tokenapp.js Normal file
View File

@@ -0,0 +1,106 @@
const RingRestClient = require('../node_modules/ring-client-api/lib/api/rest-client').RingRestClient
const debug = require('debug')('ring-mqtt')
const colors = require('colors/safe')
const express = require('express')
const bodyParser = require("body-parser")
const utils = require('./utils.js')
class TokenApp {
constructor() {
this.app = express()
this.listener
}
// Helper property to pass values between main code and web server
token = {
connected: '',
generatedInternal: '',
generatedListener: function(val) {},
set generated(val) {
this.generatedInternal = val;
this.generatedListener(val);
},
get generated() {
return this.generatedInternal;
},
registerListener: function(listener) {
this.generatedListener = listener;
}
}
updateConnectedToken(token) {
this.token.connected = token
}
// Super simple web service to acquire refresh tokens
async start() {
const webdir = __dirname+'/../web'
let restClient
this.listener = this.app.listen(55123, () => {
if (!process.env.HASSADDON) {
debug('Go to http://<host_ip_address>:55123/ to generate a valid token.')
}
})
this.app.use(bodyParser.urlencoded({ extended: false }))
this.app.get('/', (req, res) => {
if (!this.token.connected) {
res.sendFile('account.html', {root: webdir})
} else {
res.sendFile('connected.html', {root: webdir})
}
})
this.app.get(/.*force-token-generation$/, (req, res) => {
res.sendFile('account.html', {root: webdir})
})
this.app.post(/.*submit-account$/, async (req, res) => {
const email = req.body.email
const password = req.body.password
restClient = await new RingRestClient({ email, password })
// Check if the user/password was accepted
try {
await restClient.getCurrentAuth()
} catch(error) {
if (restClient.using2fa) {
debug('Username/Password was accepted, waiting for 2FA code to be entered.')
res.sendFile('code.html', {root: webdir})
} else {
debug(error.message)
res.cookie('error', error.message, { maxAge: 1000, encode: String })
res.sendFile('account.html', {root: webdir})
}
}
})
this.app.post(/.*submit-code$/, async (req, res) => {
let generatedToken
const code = req.body.code
try {
generatedToken = await restClient.getAuth(code)
} catch(_) {
generatedToken = ''
const errormsg = 'The 2FA code was not accepted, please verify the code and try again.'
debug(errormsg)
res.cookie('error', errormsg, { maxAge: 1000, encode: String })
res.sendFile('code.html', {root: webdir})
}
if (generatedToken) {
if (process.env.HASSADDON) {
res.sendFile('restart.html', {root: webdir})
this.token.generated = generatedToken.refresh_token
} else {
res.cookie('token', generatedToken.refresh_token, { maxAge: 1000, encode: String })
res.sendFile('token.html', {root: webdir})
await utils.sleep(2)
process.exit(0)
}
}
})
}
}
module.exports = new TokenApp()

View File

@@ -6,11 +6,6 @@ class Utils
return new Promise(res => setTimeout(res, sec*1000)) return new Promise(res => setTimeout(res, sec*1000))
} }
// Check if devices list from location has an alarm panel (could be only camera/lights)
hasAlarm(devices) {
return (devices.filter(device => device.data.deviceType === 'security-panel') ? true : false)
}
} }
module.exports = new Utils() module.exports = new Utils()

3448
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "ring-mqtt", "name": "ring-mqtt",
"version": "3.3.0", "version": "4.0.0",
"description": "Ring to MQTT Bridge", "description": "Ring to MQTT Bridge",
"main": "ring-mqtt.js", "main": "ring-mqtt.js",
"dependencies": { "dependencies": {
@@ -8,12 +8,12 @@
"debug": "^4.1.1", "debug": "^4.1.1",
"express": "^4.17.1", "express": "^4.17.1",
"is-online": "^8.4.0", "is-online": "^8.4.0",
"mqtt": "^4.1.0", "mqtt": "^4.2.0",
"ring-client-api": "^9.6.0", "ring-client-api": "^9.9.0",
"yargs-parser": "^18.1.3" "yargs-parser": "^19.0.1"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^6.8.0" "eslint": "^7.7.0"
}, },
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",

View File

@@ -4,15 +4,13 @@
const RingApi = require('ring-client-api').RingApi const RingApi = require('ring-client-api').RingApi
const RingDeviceType = require('ring-client-api').RingDeviceType const RingDeviceType = require('ring-client-api').RingDeviceType
const RingCamera = require('ring-client-api').RingCamera const RingCamera = require('ring-client-api').RingCamera
const RingRestClient = require('./node_modules/ring-client-api/lib/api/rest-client').RingRestClient
const mqttApi = require ('mqtt') const mqttApi = require ('mqtt')
const isOnline = require ('is-online') const isOnline = require ('is-online')
const debug = require('debug')('ring-mqtt') const debug = require('debug')('ring-mqtt')
const colors = require('colors/safe') const colors = require('colors/safe')
const utils = require('./lib/utils.js') const utils = require('./lib/utils.js')
const tokenApp = require('./lib/tokenapp.js')
const fs = require('fs') const fs = require('fs')
const express = require('express')
const bodyParser = require("body-parser")
const SecurityPanel = require('./devices/security-panel') const SecurityPanel = require('./devices/security-panel')
const ContactSensor = require('./devices/contact-sensor') const ContactSensor = require('./devices/contact-sensor')
const MotionSensor = require('./devices/motion-sensor') const MotionSensor = require('./devices/motion-sensor')
@@ -27,10 +25,12 @@ const Fan = require('./devices/fan')
const Beam = require('./devices/beam') const Beam = require('./devices/beam')
const Camera = require('./devices/camera') const Camera = require('./devices/camera')
const ModesPanel = require('./devices/modes-panel') const ModesPanel = require('./devices/modes-panel')
const Keypad = require('./devices/keypad')
const BaseStation = require('./devices/base-station')
var CONFIG var CONFIG
var publishedLocations = new Array() var ringLocations = new Array()
var publishedDevices = new Array() var ringDevices = new Array()
var mqttConnected = false var mqttConnected = false
var republishCount = 10 // Republish config/state this many times after startup or HA start/restart var republishCount = 10 // Republish config/state this many times after startup or HA start/restart
var republishDelay = 30 // Seconds var republishDelay = 30 // Seconds
@@ -43,73 +43,133 @@ process.on('uncaughtException', processExit.bind(null, 1))
// Set unreachable status on exit // Set unreachable status on exit
async function processExit(options, exitCode) { async function processExit(options, exitCode) {
publishedDevices.forEach(publishedDevice => { ringDevices.forEach(ringDevice => {
publishedDevice.offline() if (ringDevice.availabilityState == 'online') { ringDevice.offline() }
}) })
if (exitCode || exitCode === 0) debug('Exit code: '+exitCode) if (exitCode || exitCode === 0) debug('Exit code: '+exitCode)
await utils.sleep(1) await utils.sleep(1)
process.exit() process.exit()
} }
// Loop through each location and call publishLocation for supported/connected devices // Return supported device
async function processLocations(mqttClient, ringClient) { function getDevice(device, mqttClient) {
// Get all locations via API const deviceInfo = {
const locations = await ringClient.getLocations() device: device,
mqttClient: mqttClient,
// For each location get alarm devices and cameras CONFIG
locations.forEach(async location => { }
// Get all devices for location if (device instanceof RingCamera) {
const devices = await location.getDevices() return new Camera(deviceInfo)
let cameras }
switch (device.deviceType) {
// If camera support is enabled get cameras and join them with other devices case RingDeviceType.ContactSensor:
if (CONFIG.enable_cameras) { case RingDeviceType.RetrofitZone:
cameras = await location.cameras return new ContactSensor(deviceInfo)
} case RingDeviceType.MotionSensor:
return new MotionSensor(deviceInfo)
// If this is the initial publish for location add to publishedLocations case RingDeviceType.FloodFreezeSensor:
if (!(publishedLocations.includes(location.locationId))) { return new FloodFreezeSensor(deviceInfo)
publishedLocations.push(location.locationId) case RingDeviceType.SecurityPanel:
if (devices && devices.length > 0 && location.hasHubs) { return new SecurityPanel(deviceInfo)
// Location has an alarm or lighting bridge so subscribe to websocket connection monitor case RingDeviceType.SmokeAlarm:
location.onConnected.subscribe(async connected => { return new SmokeAlarm(deviceInfo)
if (connected) { case RingDeviceType.CoAlarm:
debug('Location '+location.locationId+' is connected') return new CoAlarm(deviceInfo)
location.enablePublish = true case RingDeviceType.SmokeCoListener:
publishLocation(location, devices, mqttClient) return new SmokeCoListener(deviceInfo)
setLocationOnline(location) case RingDeviceType.BeamsMotionSensor:
} else { case RingDeviceType.BeamsSwitch:
debug('Location '+location.locationId+' is disconnected') case RingDeviceType.BeamsTransformerSwitch:
location.enablePublish = false case RingDeviceType.BeamsLightGroupSwitch:
setLocationOffline(location) return new Beam(deviceInfo)
} case RingDeviceType.MultiLevelSwitch:
}) return newDevice = (device.categoryId === 17)
} ? new Fan(deviceInfo)
if (cameras && cameras.length > 0) { : new MultiLevelSwitch(deviceInfo)
publishLocation(location, cameras, mqttClient) case RingDeviceType.Switch:
} return new Switch(deviceInfo)
if (!devices || !cameras){ case RingDeviceType.Keypad:
debug('No devices found for location ID '+location.id) return new Keypad(deviceInfo)
} case RingDeviceType.BaseStation:
} else { return new BaseStation(deviceInfo)
if (devices && devices.length > 0 && location.hasHubs) { case RingDeviceType.Sensor:
publishLocation(location, devices, mqttClient) return newDevice = (device.name.toLowerCase().includes('motion'))
} ? new MotionSensor(deviceInfo)
if (cameras && cameras.length > 0 ) { : new ContactSensor(deviceInfo)
publishLocation(location, cameras, mqttClient) case 'location.mode':
} return new ModesPanel(deviceInfo)
}
} if (/^lock($|\.)/.test(device.deviceType)) {
}) return new Lock(deviceInfo)
}
return null
} }
// Set all devices for location online // Update all Ring location/device data
async function setLocationOnline(location) { async function updateRingData(mqttClient, ringClient) {
publishedDevices.forEach(async publishedDevice => { // Small delay makes debug output more readable
if (publishedDevice.locationId == location.locationId && publishedDevice.device) { await utils.sleep(1)
publishedDevice.online()
// Get all Ring locations
const locations = await ringClient.getLocations()
// Loop through each location and update stored locations/devices
for (const location of locations) {
let cameras = new Array()
const unsupportedDevices = new Array()
debug(colors.green('-'.repeat(80)))
let foundLocation = ringLocations.find(l => l.locationId == location.locationId)
// If new location, set custom properties and add to location list
if (foundLocation) {
debug(colors.green('Found existing location '+location.name+' with id '+location.id))
} else {
debug(colors.green('Found new location '+location.name+' with id '+location.id))
if (location.hasHubs) { location.needsSubscribe = true }
ringLocations.push(location)
foundLocation = location
} }
})
// Get all location devices and, if configured, cameras
const devices = await foundLocation.getDevices()
if (CONFIG.enable_cameras) { cameras = await location.cameras }
const allDevices = [...devices, ...cameras]
// Add modes panel, if configured and the location supports it
if (CONFIG.enable_modes && (await foundLocation.supportsLocationModeSwitching())) {
allDevices.push({
deviceType: 'location.mode',
location: location,
id: location.locationId + '_mode',
deviceId: location.locationId + '_mode'
})
}
// Update Ring devices for location
for (const device of allDevices) {
const deviceId = (device instanceof RingCamera) ? device.data.device_id : device.id
const foundDevice = ringDevices.find(d => d.deviceId == deviceId && d.locationId == location.locationId)
if (foundDevice) {
debug(colors.green(' Existing device of type: '+device.deviceType))
} else {
const newDevice = getDevice(device, mqttClient)
if (newDevice) {
ringDevices.push(newDevice)
debug(colors.green(' New device of type: '+device.deviceType))
} else {
// Save unsupported device type
unsupportedDevices.push(device.deviceType)
}
}
}
// Output any unsupported devices to debug with warning
unsupportedDevices.forEach(deviceType => {
debug(colors.yellow(' Unsupported device of type: '+deviceType))
})
}
debug(colors.green('-'.repeat(80)))
debug('Ring location/device data updated, sleeping for 5 seconds.')
await utils.sleep(5)
} }
// Set all devices for location offline // Set all devices for location offline
@@ -118,44 +178,23 @@ async function setLocationOffline(location) {
// Keeps from creating "unknown" state for sensors if connection error is short lived // Keeps from creating "unknown" state for sensors if connection error is short lived
await utils.sleep(30) await utils.sleep(30)
if (location.onConnected._value) { return } if (location.onConnected._value) { return }
publishedDevices.forEach(async publishedDevice => { ringDevices.forEach(device => {
if (publishedDevice.locationId == location.locationId && publishedDevice.device) { if (device.locationId == location.locationId && !device.camera) {
publishedDevice.offline() device.offline()
} }
}) })
} }
// Publish devices/cameras for given location // Publish devices/cameras for given location
async function publishLocation(location, devices, mqttClient) { async function publishDevices(devices, location) {
if (republishCount < 1) { republishCount = 1 } republishCount = (republishCount < 1) ? 1 : republishCount
while (republishCount > 0 && mqttConnected) { while (republishCount > 0 && mqttConnected) {
try { try {
if (devices && devices.length > 0) { if (devices && devices.length) {
devices.forEach((device) => { devices.forEach(device => {
if (device instanceof RingCamera) { // Provide location websocket connection state to device
// Device is a camera, set deviceId, location and deviceType device.publish(location.onConnected._value)
device.deviceId = device.data.device_id
device.location = location
device.isCamera = true
publishDevice(device, mqttClient)
} else if (location.hasHubs && location.enablePublish) {
// Device is alarm/hub device, only deviceID is required
device.deviceId = device.id
publishDevice(device, mqttClient)
}
}) })
// If Ring modes support is enabled and this location supports modes
// publish a virtual control panel device for monitoring/changing modes
if (CONFIG.enable_modes && (await location.supportsLocationModeSwitching())) {
device = {
deviceType: 'location.mode',
location: location,
id: location.locationId + '_mode_settings',
deviceId: location.locationId + '_mode_settings'
}
publishDevice(device, mqttClient)
}
await utils.sleep(1)
} }
} catch (error) { } catch (error) {
debug(error) debug(error)
@@ -165,76 +204,44 @@ async function publishLocation(location, devices, mqttClient) {
} }
} }
// Return supportted alarm device class // Loop through each location and call publishLocation for supported/connected devices
function getDevice(device, mqttClient, ringTopic) { async function processLocations(mqttClient, ringClient) {
if (device.isCamera) { return new Camera(device, mqttClient, ringTopic) } // Update Ring location and device data
switch (device.deviceType) { await updateRingData(mqttClient, ringClient)
case RingDeviceType.ContactSensor:
case RingDeviceType.RetrofitZone: // For each location get existing alarm & camera devices
return new ContactSensor(device, mqttClient, ringTopic) ringLocations.forEach(async location => {
case RingDeviceType.MotionSensor: const devices = await ringDevices.filter(d => d.locationId == location.locationId)
return new MotionSensor(device, mqttClient, ringTopic) // If location has devices publish them
case RingDeviceType.FloodFreezeSensor: if (devices && devices.length) {
return new FloodFreezeSensor(device, mqttClient, ringTopic) if (location.needsSubscribe) {
case RingDeviceType.SecurityPanel: // Location has an alarm or smart bridge so subscribe to websocket connection monitor
return new SecurityPanel(device, mqttClient, ringTopic) location.needsSubscribe = false
case RingDeviceType.SmokeAlarm: location.onConnected.subscribe(async connected => {
return new SmokeAlarm(device, mqttClient, ringTopic) if (connected) {
case RingDeviceType.CoAlarm: debug('Websocket for location id '+location.locationId+' is connected')
return new CoAlarm(device, mqttClient, ringTopic) publishDevices(devices, location)
case RingDeviceType.SmokeCoListener: } else {
return new SmokeCoListener(device, mqttClient, ringTopic) debug('Websocket for location id '+location.locationId+' is disconnected')
case RingDeviceType.BeamsMotionSensor: setLocationOffline(location)
case RingDeviceType.BeamsSwitch: }
case RingDeviceType.BeamsTransformerSwitch: })
case RingDeviceType.BeamsLightGroupSwitch: } else {
return new Beam(device, mqttClient, ringTopic) publishDevices(devices, location)
case RingDeviceType.MultiLevelSwitch: }
if (device.categoryId == 17) {
return new Fan(device, mqttClient, ringTopic)
} else {
return new MultiLevelSwitch(device, mqttClient, ringTopic)
}
case RingDeviceType.Switch:
return new Switch(device, mqttClient, ringTopic)
case 'location.mode':
return new ModesPanel(device, mqttClient,ringTopic)
}
if (/^lock($|\.)/.test(device.deviceType)) {
return new Lock(device, mqttClient, ringTopic)
}
return null
}
// Publish a device
function publishDevice(device, mqttClient) {
const existingDevice = publishedDevices.find(d => (d.deviceId == device.deviceId && d.locationId == device.location.locationId))
if (existingDevice) {
if (!existingDevice.cameraTopic || (existingDevice.cameraTopic && existingDevice.availabilityState == 'online')) {
debug('Republishing existing device id: '+existingDevice.deviceId)
existingDevice.init()
}
} else {
const newDevice = getDevice(device, mqttClient, CONFIG.ring_topic)
if (newDevice) {
debug('Publishing new device id: '+newDevice.deviceId)
newDevice.init()
publishedDevices.push(newDevice)
} else { } else {
debug('!!! Found unsupported device type: '+device.deviceType+' !!!') debug('No devices found for location ID '+location.id)
} }
} })
} }
// Process received MQTT command // Process received MQTT command
async function processMqttMessage(topic, message, mqttClient, ringClient) { async function processMqttMessage(topic, message, mqttClient, ringClient) {
message = message.toString() message = message.toString()
if (topic === CONFIG.hass_topic) { if (topic === CONFIG.hass_topic) {
// Republish devices and state after 60 seconds if restart of HA is detected
debug('Home Assistant state topic '+topic+' received message: '+message) debug('Home Assistant state topic '+topic+' received message: '+message)
if (message == 'online') { if (message == 'online') {
// Republish devices and state after 60 seconds if restart of HA is detected
debug('Resending device config/state in 30 seconds') debug('Resending device config/state in 30 seconds')
// Make sure any existing republish dies // Make sure any existing republish dies
republishCount = 0 republishCount = 0
@@ -242,93 +249,26 @@ async function processMqttMessage(topic, message, mqttClient, ringClient) {
// Reset republish counter and start publishing config/state // Reset republish counter and start publishing config/state
republishCount = 10 republishCount = 10
processLocations(mqttClient, ringClient) processLocations(mqttClient, ringClient)
debug('Resent device config/state information')
} }
} else { } else {
topic = topic.split('/')
// Parse topic to get location/device ID // Parse topic to get location/device ID
const locationId = topic[topic.length - 5] const ringTopicLevels = (CONFIG.ring_topic).split('/').length
const deviceId = topic[topic.length - 2] splitTopic = topic.split('/')
const locationId = splitTopic[ringTopicLevels]
// Some devices use the command topic level to determine the device action const deviceId = splitTopic[ringTopicLevels + 2]
const commandTopicLevel = topic[topic.length - 1]
// Find existing device by matching location & device ID // Find existing device by matching location & device ID
const cmdDevice = publishedDevices.find(d => (d.deviceId == deviceId && d.locationId == locationId)) const cmdDevice = ringDevices.find(d => (d.deviceId == deviceId && d.locationId == locationId))
if (cmdDevice) { if (cmdDevice) {
cmdDevice.processCommand(message, commandTopicLevel) cmdDevice.processCommand(message, topic)
} else { } else {
debug('Received MQTT message for device Id '+deviceId+' at location Id '+locationId+' but could not find matching device') debug('Received MQTT message for device Id '+deviceId+' at location Id '+locationId+' but could not find matching device')
} }
} }
} }
// Initiate the connection to MQTT broker
// This is a quick and dirty hack to provide a web based method for
// acquiring a refresh token from Ring.com. It's ugly, and has too
// little error handling, but seems to work well enough for now.
async function startWeb() {
const webTokenApp = express()
let restClient
const listener = webTokenApp.listen(55123, () => {
debug('Go to http://<host_ip_address>:55123/ to generate a valid token.')
})
webTokenApp.use(bodyParser.urlencoded({ extended: false }))
webTokenApp.get('/', (req, res) => {
res.sendFile('./web/account.html', {root: __dirname})
})
webTokenApp.post('/submit-account', async (req, res) => {
const email = req.body.email
const password = req.body.password
let errmsg
restClient = await new RingRestClient({ email, password })
// Check if the user/password was accepted
try {
await restClient.getAuth()
} catch(error) {
errmsg = error.message
}
debug(errmsg)
if (errmsg.match(/^Your Ring account is configured to use 2-factor authentication.*$/)) {
debug('Username/Password was accepted, waiting for 2FA code to be entered.')
res.sendFile('./web/code.html', {root: __dirname})
} else {
debug('Authentication error, check username/password and try again.')
res.sendFile('./web/account-error.html', {root: __dirname})
}
})
webTokenApp.post('/submit-code', async (req, res) => {
let token
const code = req.body.code
try {
token = await restClient.getAuth(code)
} catch(error) {
token = ''
debug(error.message)
res.sendFile('./web/code-error.html', {root: __dirname})
}
if (token) {
if (process.env.HASSADDON) {
res.sendFile('./web/restart.html', {root: __dirname})
listener.close()
main(token.refresh_token)
} else {
// Super ugly...don't judge me!!! :)
const head = '<html><head><style>body {font-family: Arial, Helvetica, sans-serif; max-width: 500px;margin-top: 20px;word-wrap: break-word;}.button { background-color: #47a9e6; color: white; padding: 12px 20px; border: none; border-radius: 4px; cursor: pointer;}.button:hover {background-color: #315b82}</style></head><body><h3>Refresh Token</h3><b>Copy and paste the following string, exactly as shown, to ring_token:</b><br><br><textarea rows = "6" cols = "70" type="text" id="token">'
const tail = '</textarea><br><br><button class="button" onclick="copyToClipboard()">Copy to clipboard</button><script> function copyToClipboard() { var copyText = document.getElementById("token");copyText.select();copyText.setSelectionRange(0, 99999);document.execCommand("copy");alert("The refresh token has been copied to the clipboard.");}</script></body></html>'
res.send(head+token.refresh_token+tail)
process.exit(0)
}
}
})
}
function initMqtt() { function initMqtt() {
const mqtt = mqttApi.connect({ const mqtt = mqttApi.connect({
host:CONFIG.host, host:CONFIG.host,
@@ -339,83 +279,93 @@ function initMqtt() {
return mqtt return mqtt
} }
// MQTT initialization successful, setup actions for MQTT events
function startMqtt(mqttClient, ringClient) { function startMqtt(mqttClient, ringClient) {
// On MQTT connect/reconnect send config/state information after delay // On MQTT connect/reconnect send config/state information after delay
mqttClient.on('connect', async function () { mqttClient.on('connect', async function () {
if (!mqttConnected) { if (!mqttConnected) {
mqttConnected = true mqttConnected = true
debug('MQTT connection established, sending config/state information in 5 seconds.') debug('MQTT connection established, processing locations...')
}
await utils.sleep(5)
processLocations(mqttClient, ringClient)
})
mqttClient.on('reconnect', function () {
if (mqttConnected) {
debug('Connection to MQTT broker lost. Attempting to reconnect...')
} else {
debug('Attempting to reconnect to MQTT broker...')
}
mqttConnected = false
})
mqttClient.on('error', function (error) {
debug('Unable to connect to MQTT broker.', error.message)
mqttConnected = false
})
// Process MQTT messages from subscribed command topics
mqttClient.on('message', async function (topic, message) {
processMqttMessage(topic, message, mqttClient, ringClient)
})
}
// Create CONFIG object from file or envrionment variables
async function initConfig(configFile) {
debug('Using configuration file: '+configFile)
try {
CONFIG = require(configFile)
} catch (error) {
debug('Configuration file not found, attempting to use environment variables for configuration.')
CONFIG = {
"host": process.env.MQTTHOST,
"port": process.env.MQTTPORT,
"ring_topic": process.env.MQTTRINGTOPIC,
"hass_topic": process.env.MQTTHASSTOPIC,
"mqtt_user": process.env.MQTTUSER,
"mqtt_pass": process.env.MQTTPASSWORD,
"ring_token": process.env.RINGTOKEN,
"enable_cameras": process.env.ENABLECAMERAS,
"enable_modes" : process.env.ENABLEMODES,
"location_ids" : process.env.RINGLOCATIONIDS
}
if (CONFIG.enable_cameras && CONFIG.enable_cameras != 'true') { CONFIG.enable_cameras = false}
if (CONFIG.location_ids) { CONFIG.location_ids = CONFIG.location_ids.split(',') }
} }
// Set some defaults if undefined processLocations(mqttClient, ringClient)
CONFIG.host = CONFIG.host ? CONFIG.host : 'localhost' })
CONFIG.port = CONFIG.port ? CONFIG.port : '1883'
CONFIG.ring_topic = CONFIG.ring_topic ? CONFIG.ring_topic : 'ring' mqttClient.on('reconnect', function () {
CONFIG.hass_topic = CONFIG.hass_topic ? CONFIG.hass_topic : 'homeassistant/status' if (mqttConnected) {
if (!CONFIG.enable_cameras) { CONFIG.enable_cameras = false } debug('Connection to MQTT broker lost. Attempting to reconnect...')
if (!CONFIG.enable_modes) { CONFIG.enable_modes = false } } else {
debug('Attempting to reconnect to MQTT broker...')
}
mqttConnected = false
})
mqttClient.on('error', function (error) {
debug('Unable to connect to MQTT broker.', error.message)
mqttConnected = false
})
// Process MQTT messages from subscribed command topics
mqttClient.on('message', async function (topic, message) {
processMqttMessage(topic, message, mqttClient, ringClient)
})
}
// Create CONFIG object from file or envrionment variables
async function initConfig(configFile) {
debug('Using configuration file: '+configFile)
try {
CONFIG = require(configFile)
} catch (error) {
debug('Configuration file not found, attempting to use environment variables for configuration.')
CONFIG = {
"host": process.env.MQTTHOST,
"port": process.env.MQTTPORT,
"ring_topic": process.env.MQTTRINGTOPIC,
"hass_topic": process.env.MQTTHASSTOPIC,
"mqtt_user": process.env.MQTTUSER,
"mqtt_pass": process.env.MQTTPASSWORD,
"ring_token": process.env.RINGTOKEN,
"enable_cameras": process.env.ENABLECAMERAS,
"enable_modes" : process.env.ENABLEMODES,
"location_ids" : process.env.RINGLOCATIONIDS
}
if (CONFIG.enable_cameras && CONFIG.enable_cameras != 'true') { CONFIG.enable_cameras = false}
if (CONFIG.location_ids) { CONFIG.location_ids = CONFIG.location_ids.split(',') }
}
// If Home Assistant addon, try config or environment for MQTT settings
if (process.env.HASSADDON) {
CONFIG.host = CONFIG.host ? CONFIG.host : process.env.MQTTHOST
CONFIG.port = CONFIG.port ? CONFIG.port : process.env.MQTTPORT
CONFIG.mqtt_user = CONFIG.mqtt_user ? CONFIG.mqtt_user : process.env.MQTTUSER
CONFIG.mqtt_pass = CONFIG.mqtt_pass ? CONFIG.mqtt_pass : process.env.MQTTPASSWORD
} }
async function updateToken(newRefreshToken, oldRefreshToken, stateFile, configFile) { // If there's still no configured settings, force some defaults.
if (!oldRefreshToken) { return } CONFIG.host = CONFIG.host ? CONFIG.host : 'localhost'
if (process.env.HASSADDON || process.env.ISDOCKER) { CONFIG.port = CONFIG.port ? CONFIG.port : '1883'
fs.writeFile(stateFile, JSON.stringify({ ring_token: newRefreshToken }), (err) => { CONFIG.ring_topic = CONFIG.ring_topic ? CONFIG.ring_topic : 'ring'
if (err) throw err; CONFIG.hass_topic = CONFIG.hass_topic ? CONFIG.hass_topic : 'homeassistant/status'
debug('File ' + stateFile + ' saved with updated refresh token.') if (!CONFIG.enable_cameras) { CONFIG.enable_cameras = false }
}) if (!CONFIG.enable_modes) { CONFIG.enable_modes = false }
} else if (configFile) { if (!CONFIG.enable_panic) { CONFIG.enable_panic = false }
CONFIG.ring_token = newRefreshToken }
fs.writeFile(configFile, JSON.stringify(CONFIG, null, 4), (err) => {
if (err) throw err; // Save updated refresh token to config or state file
debug('Config file saved with updated refresh token.') async function updateToken(newRefreshToken, oldRefreshToken, stateFile, configFile) {
}) if (!oldRefreshToken) { return }
} if (process.env.HASSADDON || process.env.ISDOCKER) {
fs.writeFile(stateFile, JSON.stringify({ ring_token: newRefreshToken }), (err) => {
if (err) throw err;
debug('File ' + stateFile + ' saved with updated refresh token.')
})
} else if (configFile) {
CONFIG.ring_token = newRefreshToken
fs.writeFile(configFile, JSON.stringify(CONFIG, null, 4), (err) => {
if (err) throw err;
debug('Config file saved with updated refresh token.')
})
} }
}
/* End Functions */ /* End Functions */
// Main code loop // Main code loop
@@ -429,8 +379,19 @@ const main = async(generatedToken) => {
// For HASSIO and DOCKER latest token is saved in /data/ring-state.json // For HASSIO and DOCKER latest token is saved in /data/ring-state.json
if (process.env.HASSADDON || process.env.ISDOCKER) { if (process.env.HASSADDON || process.env.ISDOCKER) {
configFile = (process.env.HASSADDON) ? '/data/options.json' : '/data/config.json'
stateFile = '/data/ring-state.json' stateFile = '/data/ring-state.json'
if (process.env.HASSADDON) {
configFile = '/data/options.json'
// For addon config is performed via Web UI
if (!tokenApp.listener) {
tokenApp.start()
tokenApp.token.registerListener(function(generatedToken) {
main(generatedToken)
})
}
} else {
configFile = '/data/config.json'
}
} }
// Initiate CONFIG object from file or environment variables // Initiate CONFIG object from file or environment variables
@@ -457,10 +418,11 @@ const main = async(generatedToken) => {
} else { } else {
if (process.env.HASSADDON) { if (process.env.HASSADDON) {
debug('No refresh token was found in saved state file or config file.') debug('No refresh token was found in saved state file or config file.')
debug('Use the web interface to generate a new token.')
} else { } else {
debug('No refresh token was found in config file.') debug('No refresh token was found in config file.')
tokenApp.start()
} }
startWeb()
} }
} else { } else {
// There is at least one token in state file or config // There is at least one token in state file or config
@@ -512,7 +474,6 @@ const main = async(generatedToken) => {
debug(colors.brightRed('or maybe all available refresh tokens are invalid.')) debug(colors.brightRed('or maybe all available refresh tokens are invalid.'))
if (process.env.HASSADDON) { if (process.env.HASSADDON) {
debug('Restart the addon to try again or use the web interface to generate a new token.') debug('Restart the addon to try again or use the web interface to generate a new token.')
startWeb()
} else { } else {
debug('Please check the configuration and network settings, or generate a new refresh token, and try again.') debug('Please check the configuration and network settings, or generate a new refresh token, and try again.')
process.exit(2) process.exit(2)
@@ -526,7 +487,6 @@ const main = async(generatedToken) => {
} else if (process.env.HASSADDON) { } else if (process.env.HASSADDON) {
debug('Could not connect with saved refresh token and no refresh token exist in config file.') debug('Could not connect with saved refresh token and no refresh token exist in config file.')
debug('Restart the addon to try again or use the web interface to generate a new token.') debug('Restart the addon to try again or use the web interface to generate a new token.')
startWeb()
} }
} }
} }
@@ -534,6 +494,10 @@ const main = async(generatedToken) => {
if (ringClient) { if (ringClient) {
debug('Connection to Ring API successful') debug('Connection to Ring API successful')
// Update the web app with current connected refresh token
const currentAuth = await ringClient.restClient.authPromise
tokenApp.updateConnectedToken(currentAuth.refresh_token)
// Subscribed to token update events and save new token // Subscribed to token update events and save new token
ringClient.onRefreshTokenUpdated.subscribe(async ({ newRefreshToken, oldRefreshToken }) => { ringClient.onRefreshTokenUpdated.subscribe(async ({ newRefreshToken, oldRefreshToken }) => {
updateToken(newRefreshToken, oldRefreshToken, stateFile, configFile) updateToken(newRefreshToken, oldRefreshToken, stateFile, configFile)
@@ -551,7 +515,7 @@ const main = async(generatedToken) => {
startMqtt(mqttClient, ringClient) startMqtt(mqttClient, ringClient)
} catch (error) { } catch (error) {
debug(error) debug(error)
debug( colors.red('Couldn\'t connect to MQTT broker. Please check the broker and configuration settings.')) debug( colors.red('Couldn\'t authenticate to MQTT broker. Please check the broker and configuration settings.'))
process.exit(1) process.exit(1)
} }
} }

View File

@@ -1,83 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {font-family: Arial, Helvetica, sans-serif; max-width: 500px;}
* {box-sizing: border-box;}
input[type=text], select, textarea {
width: 100%;
padding: 12px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
margin-top: 6px;
margin-bottom: 16px;
resize: vertical;
}
input[type=password], select, textarea {
width: 100%;
padding: 12px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
margin-top: 6px;
margin-bottom: 16px;
resize: vertical;
}
input[type=submit] {
background-color: #47a9e6;
color: white;
padding: 12px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
input[type=submit]:hover {
background-color: #315b82;
}
.container {
max-width: 500px;
border-radius: 5px;
background-color: #f2f2f2;
padding: 20px;
}
</style>
</head>
<body>
<h3>Acquire Refresh Token</h3>
Use this form to acquire a refresh token from Ring.com. Submit the login information for the desired account and then the 2FA code. Upon succesful 2FA authentication the refresh token will be displayed. Simply copy the entire string and paste into the config file.<br>
<br>
<p style="color:Red;">Authentication Failed. Check the username/password and try again.</p>
<h3>Login</h3>
<div class="container">
<form action="/submit-account" method="post">
<label for="email">Email</label>
<input type="text" id="email" name="email">
<label for="password">Password</label>
<input type="password" id="password" name="password">
<input type="checkbox" onclick="myFunction()">Show Password
<br><br>
<input type="submit" value="Submit">
</form>
</div>
<script>
function myFunction() {
var x = document.getElementById("password");
if (x.type === "password") {
x.type = "text";
} else {
x.type = "password";
}
}
</script>
</body>
</html>

View File

@@ -53,30 +53,35 @@ input[type=submit]:hover {
<h3>Acquire Refresh Token</h3> <h3>Acquire Refresh Token</h3>
Use this form to acquire a refresh token from Ring.com. Submit the login information for the desired account and then the 2FA code. Upon succesful 2FA authentication the refresh token will be displayed. Simply copy the entire string and paste into the config file.<br> Use this form to acquire a refresh token from Ring.com. Submit the login information for the desired account and then the 2FA code. Upon succesful 2FA authentication the refresh token will be displayed. Simply copy the entire string and paste into the config file.<br>
<br> <p style="color:Red;" id="errormsg"></p>
<h3>Login</h3> <h3>Login</h3>
<div class="container"> <div class="container">
<form action="/submit-account" method="post"> <form action="./submit-account" method="post">
<label for="email">Email</label> <label for="email">Email</label>
<input type="text" id="email" name="email"> <input type="text" id="email" name="email">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password"> <input type="password" id="password" name="password">
<input type="checkbox" onclick="myFunction()">Show Password <input type="checkbox" onclick="showPassword()">Show Password
<br><br> <br><br>
<input type="submit" value="Submit"> <input type="submit" value="Submit">
</form> </form>
</div> </div>
<script> <script>
function myFunction() { function showPassword() {
var x = document.getElementById("password"); var x = document.getElementById("password");
if (x.type === "password") { if (x.type === "password") {
x.type = "text"; x.type = "text";
} else { } else {
x.type = "password"; x.type = "password";
} }
}
function getCookie(key) {
var keyValue = document.cookie.match('(^|;) ?' + key + '=([^;]*)(;|$)');
return keyValue ? keyValue[2] : null;
}
if (getCookie('error')) {
document.getElementById("errormsg").innerHTML = getCookie('error')
} }
</script> </script>
</body> </body>
</html> </html>

View File

@@ -1,54 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {font-family: Arial, Helvetica, sans-serif;}
* {box-sizing: border-box;}
input[type=text], select, textarea {
width: 100%;
padding: 12px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
margin-top: 6px;
margin-bottom: 16px;
resize: vertical;
}
input[type=submit] {
background-color: #47a9e6;
color: white;
padding: 12px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
input[type=submit]:hover {
background-color: #315b82;
}
.container {
max-width: 500px;
border-radius: 5px;
background-color: #f2f2f2;
padding: 20px;
}
</style>
</head>
<body>
<h3>Enter 2FA Code</h3>
<p style="color:Red;">2FA code was not accepted. Verify code and try again.</p>
<div class="container">
<form action="/submit-code" method="post">
<label for="2facode">Code</label>
<input type="text" id="code" name="code">
<input type="submit" value="Submit">
</form>
</div>
</body>
</html>

View File

@@ -41,13 +41,22 @@ input[type=submit]:hover {
<body> <body>
<h3>Enter 2FA Code</h3> <h3>Enter 2FA Code</h3>
<p style="color:Red;" id="errormsg"></p></p>
<div class="container"> <div class="container">
<form action="/submit-code" method="post"> <form action="./submit-code" method="post">
<label for="2facode">Code</label> <label for="2facode">Code</label>
<input type="text" id="code" name="code"> <input type="text" id="code" name="code">
<input type="submit" value="Submit"> <input type="submit" value="Submit">
</form> </form>
</div> </div>
<script>
function getCookie(key) {
var keyValue = document.cookie.match('(^|;) ?' + key + '=([^;]*)(;|$)');
return keyValue ? keyValue[2] : null;
}
if (getCookie('error')) {
document.getElementById("errormsg").innerHTML = getCookie('error')
}
</script>
</body> </body>
</html> </html>

35
web/connected.html Normal file
View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {font-family: Arial, Helvetica, sans-serif; max-width: 500px;}
input[type=submit] {
background-color: #47a9e6;
color: white;
padding: 12px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
input[type=submit]:hover {
background-color: #315b82;
}
.container {
padding: 20px 5px;
}
</style>
</head>
<body>
<h3>Ring Device Addon Connected</h3>
It appears that this addon is already connected to Ring. To force generation of a new token click the button below.
<div class="container">
<form action="./force-token-generation" method="get">
<input type="submit" value="Regenerate Token">
</form>
</div>
</body>
</html>

48
web/token.html Normal file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
max-width: 500px;
margin-top: 20px;
word-wrap: break-word;
}
.button {
background-color: #47a9e6;
color: white;
padding: 12px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.button:hover {
background-color: #315b82
}
</style>
</head>
<body>
<h3>Refresh Token</h3>
<b>Copy and paste the following string, exactly as shown, to ring_token:</b><br><br>
<textarea rows = "6" cols = "70" type="text" id="token"></textarea><br><br>
<button class="button" onclick="copyToClipboard()">Copy to clipboard</button>
<script>
function getCookie(key) {
var keyValue = document.cookie.match('(^|;) ?' + key + '=([^;]*)(;|$)');
return keyValue ? keyValue[2] : null;
}
if (getCookie('token')) {
document.getElementById("token").innerHTML = getCookie('token')
}
function copyToClipboard() {
var copyText = document.getElementById("token");
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
alert("The refresh token has been copied to the clipboard.");
}
</script>
</body>
</html>