├── .gitignore ├── hacs.json ├── custom_components └── broadlink_s1c_s2c │ ├── manifest.json │ └── sensor.py ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Broadlink s2c and s1c sensors", 3 | "render_readme": true, 4 | "homeassistant": "0.112.0" 5 | } 6 | -------------------------------------------------------------------------------- /custom_components/broadlink_s1c_s2c/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "broadlink_s1c", 3 | "name": "Broadlink s1c and s2c", 4 | "documentation": "https://github.com/nick2525/broadlink_s1c_s2c", 5 | "issue_tracker": "https://github.com/nick2525/broadlink_s1c_s2c/issues", 6 | "requirements": [], 7 | "dependencies": [ 8 | "http" 9 | ], 10 | "codeowners": [ 11 | "@nick2525" 12 | ], 13 | "version": "1.0.0" 14 | } 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Tomer Figenblat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Broadlink S1C and S2C Alarm Kit Sensors for Home Assistant 2 | 3 | __________________________________________ 4 | 5 | [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) 6 | **Name in HACS** : `Broadlink s2c and s1c sensor`
7 | **Component Type** : `platform`
8 | **Platform Name** : `broadlink_s1c`
9 | **Domain Name** : `sensor`
10 | 11 | [Community Discussion](https://community.home-assistant.io/t/broadlink-s1c-alarm-kit-custom-sensor-component/45980)
12 | 13 | #### Component Description 14 | Home Assistant Custom Component for integration with [Broadlink S1C Alarm Kit] and [Broadlink S2C Alarm Kit].
15 | S1C And S2C Alarm Kit is a alarm system made by Broadlink, it's made of a Hub which can control up to 16 designated devices: 16 | - Door/Window Sensor 17 | - Motion Detector 18 | - Key Fob 19 | 20 | According to Broadlink's site, more sensor types are to released eventually.
21 | The uniqueness of this system is its integration with the rest of the Broadlink smart home products, for instance you can create an interaction as they call it, and make it so the your Broadlink TC2 switch will be turned on when the door sensor is open.
22 | But... If you're in this repository, You probably looking for a way to integrate this system with Home Assistant, so that last fact doesn't really matters.
23 | So, let's get to it! ;-)
24 | 25 | **Table Of Contents** 26 | - [Requirements](#requirements) 27 | - [Installation](#installation) 28 | - [Configuration](#configuration) 29 | - [Configuration Keys](#configuration-keys) 30 | - [States](#states) 31 | - [All Sensors](#all-sensors) 32 | - [Door Sensor](#door-sensor) 33 | - [Motion Detector](#motion-detector) 34 | - [Key Fob](#key-fob) 35 | - [Special Notes](#special-notes) 36 | 37 | ## Requirements 38 | - **Home Assistant 39 | - Your S1C Hub or S2C Hub needs to have a **Static IP Address** reserved by your router. 40 | 41 | ## Installation 42 | (recomended) 43 | 44 | If you have HACS (Home Assistant Community Store) installed at your HA, just search for broadlink_s1c_s2c and install it direct from HACS. HACS will keep track of updates and you can easly upgrade to the latest version when a new release is available. 45 | 46 | (manual installation) 47 | 48 | - Copy the files https://github.com/nick2525/broadlink_s1c_s2c/tree/master/custom_components/ to your `ha_config_dir/custom_components/` directory. 49 | - Configure like instructed in the Configuration section below. 50 | - Restart Home-Assistant. 51 | 52 | ## Configuration 53 | To use this component in your installation, add the following to your `configuration.yaml` file: 54 | 55 | ```yaml 56 | # Example configuration.yaml 57 | 58 | sensor: 59 | - platform: broadlink_s1c 60 | ip_address: xxx.xxx.xxx.xxx 61 | mac: "xx:xx:xx:xx:xx:xx" 62 | timeout: 10 63 | ``` 64 | 65 | ### Configuration Keys 66 | - **ip_address** (*Required*) Inherited from *homeassistant.const.CONF_IP_ADDRESS*: The IP Address assigned to your device by your router. A static address is preferable.
67 | - **mac** (*Required*) Inherited from *homeassistant.const.CONF_MAC*: The MAC Address of your S1C Hub.
68 | - **timeout** (*Optional*) Inherited from *homeassistant.const.CONF_TIMEOUT*: Timeout value for S1C Hub connectio. *Default=10*.
69 | 70 | ## States 71 | ### All Sensors 72 | - `tampered` 73 | - `unknown` - Inherited from *homeassistant.const.STATE_UNKNOWN* 74 | 75 | ### Door Sensor 76 | - `open` - Inherited from *homeassistant.const.STATE_OPEN* 77 | - `closed` - Inherited from *homeassistant.const.STATE_CLOSED* 78 | 79 | ### Motion Detector 80 | - `no_motion` 81 | - `motion_detected` 82 | 83 | ### Key Fob 84 | - `disarmed` - Inherited from *homeassistant.const.STATE_ALARM_DISARMED* 85 | - `armed_away` - Inherited from *homeassistant.const.STATE_ALARM_ARMED_AWAY* 86 | - `armed_home` - Inherited from *homeassistant.const.STATE_ALARM_ARMED_HOME* 87 | - `sos` 88 | 89 | ## Special Notes 90 | - Initial configuration of the sensor in the Broadlink App is required. 91 | - The platform discovers the sensors upon loading, therefore if you add another sensor, restart Home Assistant and the new sensors will be added to HA. 92 | - The entity name of each sensor is constructed from the original sensor name from the Broadlink App concatenated with the platform name. Spaces and dashes will be replaced with underscores.
93 | For instance, if you sensor is name *Bedroom Door* the entity name will be *broadlink_s1c_bedroom_door*, and to reference it you will call *sensor.broadlink_s1c_bedroom_door* 94 | - Although this component is designed for S1C Hubs, it is working well with S2C Hubs too. 95 | 96 | -------------------------------------------------------------------------------- /custom_components/broadlink_s1c_s2c/sensor.py: -------------------------------------------------------------------------------- 1 | """//////////////////////////////////////////////////////////////////////////////////////////////// 2 | Home Assistant Custom Component for Broadlink S1C Alarm kit integration as a Sensor platform. 3 | Build by TomerFi 4 | Please visit https://github.com/TomerFi/home-assistant-custom-components for more custom components 5 | 6 | if error occures, raise the log level to debug mode and analyze the logs: 7 | custom_components.sensor.broadlink_s1c: debug 8 | 9 | installation notes: 10 | place this file in the following folder and restart home assistant: 11 | /config/custom_components/sensor 12 | 13 | yaml configuration example: 14 | 15 | sensor: 16 | - platform: broadlink_s1c 17 | ip_address: "xxx.xxx.xxx.xxx" # set your s1c hub local ip address 18 | mac: "XX:XX:XX:XX:XX:XX" # set your s1c hub mac address 19 | 20 | ////////////////////////////////////////////////////////////////////////////////////////////////""" 21 | import binascii 22 | import socket 23 | import datetime 24 | import logging 25 | import asyncio 26 | import traceback 27 | import json 28 | import threading 29 | 30 | import voluptuous as vol 31 | 32 | from homeassistant.helpers.entity import Entity 33 | import homeassistant.helpers.config_validation as cv 34 | from homeassistant.components.sensor import PLATFORM_SCHEMA 35 | from homeassistant.const import (CONF_IP_ADDRESS, CONF_MAC, CONF_TIMEOUT, STATE_UNKNOWN, STATE_OPEN, STATE_CLOSED, 36 | EVENT_HOMEASSISTANT_STOP, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY) 37 | from homeassistant.util.dt import now 38 | 39 | from broadlink import S1C 40 | 41 | REQUIREMENTS = [] 42 | 43 | _LOGGER = logging.getLogger(__name__) 44 | 45 | """platform specifics""" 46 | DOMAIN = 'sensor' 47 | ENTITY_ID_FORMAT = DOMAIN + '.broadlink_s1c_{}' 48 | DEFAULT_TIMEOUT = 10 49 | 50 | """additional states that doesn't exists in homeassistant.const""" 51 | STATE_NO_MOTION = "no_motion" 52 | STATE_MOTION_DETECTED = "motion_detected" 53 | STATE_TAMPERED = "tampered" 54 | STATE_ALARM_SOS = "sos" 55 | 56 | """sensor update event details""" 57 | UPDATE_EVENT = "BROADLINK_S1C_SENSOR_UPDATE" 58 | EVENT_PROPERTY_NAME = "name" 59 | EVENT_PROPERTY_STATE = "state" 60 | 61 | """sensor types and icons""" 62 | SENSOR_TYPE_DOOR_SENSOR = "Door Sensor" 63 | SENSOR_TYPE_DOOR_SENSOR_ICON = "mdi:door" 64 | SENSOR_TYPE_MOTION_SENSOR = "Motion Sensor" 65 | SENSOR_TYPE_MOTION_SENSOR_ICON = "mdi:walk" 66 | SENSOR_TYPE_KEY_FOB = "Key Fob" 67 | SENSOR_TYPE_KEY_FOB_ICON = "mdi:remote" 68 | SENSOR_DEFAULT_ICON = "mdi:security-home" 69 | 70 | """platform configuration schema""" 71 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 72 | vol.Required(CONF_IP_ADDRESS): cv.string, 73 | vol.Required(CONF_MAC): cv.string, 74 | vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int 75 | }) 76 | 77 | """set up broadlink s1c platform""" 78 | @asyncio.coroutine 79 | def async_setup_platform(hass, config, async_add_devices, discovery_info=None): 80 | 81 | _LOGGER.debug("starting platform setup") 82 | 83 | """get configuration params""" 84 | ip_address = config.get(CONF_IP_ADDRESS) 85 | mac = config.get(CONF_MAC).encode().replace(b':', b'') 86 | mac_addr = binascii.unhexlify(mac) 87 | timeout = config.get(CONF_TIMEOUT) 88 | 89 | """initiate connection to s1c hub""" 90 | conn_obj = HubConnection(ip_address, mac_addr, timeout) 91 | 92 | """discovering the sensors and initiating entities""" 93 | raw_data = conn_obj.get_initial_data() 94 | sensors = [] 95 | for i, sensor in enumerate(raw_data["sensors"]): 96 | sensors.append(S1C_SENSOR(hass, sensor["name"], sensor["type"], conn_obj.parse_status(sensor["type"], str(sensor["status"])), now())) 97 | if sensors: 98 | async_add_devices(sensors, True) 99 | 100 | """starting the sensors status change watcher""" 101 | WatchSensors(hass, conn_obj).start() 102 | 103 | return True 104 | 105 | 106 | class S1C_SENSOR(Entity): 107 | """representation of the sensor entity""" 108 | def __init__(self, hass, name, sensor_type, status, last_changed): 109 | """initialize the sensor entity""" 110 | self.entity_id = ENTITY_ID_FORMAT.format(name.replace(' ', '_').replace('-', '_').lower()) 111 | self._hass = hass 112 | self._name = name 113 | self._sensor_type = sensor_type 114 | self._state = status 115 | self._last_changed = last_changed 116 | """registering entity for event listenting""" 117 | hass.bus.async_listen(UPDATE_EVENT, self.async_event_listener) 118 | _LOGGER.debug(self._name + " initiated") 119 | 120 | @property 121 | def name(self): 122 | """friendly name""" 123 | return self._name 124 | 125 | @property 126 | def should_poll(self): 127 | """entity should be polled for updates""" 128 | return False 129 | 130 | @property 131 | def state(self): 132 | """sensor state""" 133 | return self._state 134 | 135 | @property 136 | def icon(self): 137 | """sensor icon""" 138 | if (self._sensor_type == SENSOR_TYPE_DOOR_SENSOR): 139 | return SENSOR_TYPE_DOOR_SENSOR_ICON 140 | elif (self._sensor_type == SENSOR_TYPE_KEY_FOB): 141 | return SENSOR_TYPE_KEY_FOB_ICON 142 | elif (self._sensor_type == SENSOR_TYPE_MOTION_SENSOR): 143 | return SENSOR_TYPE_MOTION_SENSOR_ICON 144 | else: 145 | return SENSOR_DEFAULT_ICON 146 | 147 | 148 | @property 149 | def extra_state_attributes(self): 150 | """sensor state attributes""" 151 | return { 152 | "sensor_type": self._sensor_type, 153 | "last_changed": self._last_changed 154 | } 155 | 156 | @asyncio.coroutine 157 | def async_event_listener(self, event): 158 | """handling incoming events and update ha state""" 159 | if (event.data.get(EVENT_PROPERTY_NAME) == self._name): 160 | _LOGGER.debug(self._name + " received " + UPDATE_EVENT) 161 | self._state = event.data.get(EVENT_PROPERTY_STATE) 162 | self._last_changed = event.time_fired 163 | yield from self.async_update_ha_state() 164 | 165 | 166 | class HubConnection(object): 167 | """s1c hub connection and utility class""" 168 | def __init__(self, ip_addr, mac_addr, timeout): 169 | """initialize the connection object""" 170 | self._hub = S1C((ip_addr, 80), mac_addr, 0x2714) 171 | self._hub.timeout = timeout 172 | self._authorized = self.authorize() 173 | if (self._authorized): 174 | _LOGGER.info("succesfully connected to s1c hub") 175 | self._initial_data = self._hub.get_sensors_status() 176 | else: 177 | _LOGGER.error("failed to connect s1c or s2c hub, not authorized. please fix the problem and restart the system") 178 | self._initial_data = None 179 | 180 | def authorize(self, retry=3): 181 | """authorize connection to s1c hub""" 182 | try: 183 | auth = self._hub.auth() 184 | except socket.timeout: 185 | auth = False 186 | if not auth and retry > 0: 187 | return self.authorize(retry-1) 188 | return auth 189 | 190 | def get_initial_data(self): 191 | """return initial data for discovery""" 192 | return self._initial_data 193 | 194 | def get_hub_connection(self): 195 | """return the connection object""" 196 | return self._hub 197 | 198 | def parse_status(self, sensor_type, sensor_status): 199 | """parse sensors status""" 200 | if sensor_type == SENSOR_TYPE_DOOR_SENSOR and sensor_status in ("0", "128"): 201 | return STATE_CLOSED 202 | elif sensor_type == SENSOR_TYPE_DOOR_SENSOR and sensor_status in ("16", "144"): 203 | return STATE_OPEN 204 | elif sensor_type == SENSOR_TYPE_DOOR_SENSOR and sensor_status == "48": 205 | return STATE_TAMPERED 206 | elif sensor_type == SENSOR_TYPE_MOTION_SENSOR and sensor_status in ("0", "64", "128"): 207 | return STATE_NO_MOTION 208 | elif sensor_type == SENSOR_TYPE_MOTION_SENSOR and sensor_status in ("16", "80"): 209 | return STATE_MOTION_DETECTED 210 | elif sensor_type == SENSOR_TYPE_MOTION_SENSOR and sensor_status == "32": 211 | return STATE_TAMPERED 212 | elif sensor_type == SENSOR_TYPE_KEY_FOB and sensor_status == "16": 213 | return STATE_ALARM_DISARMED 214 | elif sensor_type == SENSOR_TYPE_KEY_FOB and sensor_status == "32": 215 | return STATE_ALARM_ARMED_AWAY 216 | elif sensor_type == SENSOR_TYPE_KEY_FOB and sensor_status == "64": 217 | return STATE_ALARM_ARMED_HOME 218 | elif sensor_type == SENSOR_TYPE_KEY_FOB and sensor_status in ("0", "128"): 219 | return STATE_ALARM_SOS 220 | else: 221 | _LOGGER.debug("unknow status " + sensor_status + "for type " + sensor_type) 222 | return STATE_UNKNOWN 223 | 224 | 225 | class WatchSensors(threading.Thread): 226 | """sensor status change watcher class""" 227 | def __init__(self, hass, conn_obj): 228 | 229 | threading.Thread.__init__(self) 230 | 231 | """initialize the watcher""" 232 | self._hass = hass 233 | self._ok_to_run = False 234 | self._conn_obj = conn_obj 235 | self._last_exception_dt = None 236 | self._exception_count = 0 237 | if (self._conn_obj._authorized): 238 | self._ok_to_run = True 239 | self._hub = self._conn_obj.get_hub_connection() 240 | 241 | def run(self): 242 | """register stop function for event listening""" 243 | self._hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) 244 | 245 | """get initial sensors data""" 246 | if not (self._conn_obj.get_initial_data() is None): 247 | old_status = self._conn_obj.get_initial_data() 248 | else: 249 | old_status = self._hub.get_sensors_status() 250 | 251 | """start watcher loop""" 252 | _LOGGER.info("starting sensors watch") 253 | while self._ok_to_run: 254 | try: 255 | current_status = self._hub.get_sensors_status() 256 | for i, sensor in enumerate(current_status["sensors"]): 257 | current_fixed_status = self._conn_obj.parse_status(sensor["type"], str(sensor["status"])) 258 | if old_status.get("sensors") is not None and len(old_status.get("sensors")) >= i and \ 259 | old_status.get("sensors")[i].get("status") is not None: 260 | previous_fixed_status = self._conn_obj.parse_status(old_status["sensors"][i]["type"], str(old_status["sensors"][i]["status"])) 261 | else: 262 | previous_fixed_status = STATE_CLOSED 263 | if not (current_fixed_status == previous_fixed_status): 264 | _LOGGER.debug("status change tracked from: " + json.dumps(old_status["sensors"][i])) 265 | _LOGGER.debug("status change tracked to: " + json.dumps(sensor)) 266 | self.launch_state_change_event(sensor["name"], current_fixed_status) 267 | old_status = current_status 268 | except: 269 | _LOGGER.warning("exception while getting sensors status: " + traceback.format_exc()) 270 | self.check_loop_run() 271 | continue 272 | _LOGGER.info("sensors watch done") 273 | 274 | def check_loop_run(self): 275 | """max exceptions allowed in loop before exiting""" 276 | max_exceptions_before_stop = 500 277 | """max minutes to remmember the last excption""" 278 | max_minutes_from_last_exception = 1 279 | 280 | current_dt = now() 281 | if not (self._last_exception_dt is None): 282 | if (self._last_exception_dt.year == current_dt.year and self._last_exception_dt.month == current_dt.month and self._last_exception_dt.day == current_dt.day): 283 | calc_dt = current_dt - self._last_exception_dt 284 | diff = divmod(calc_dt.days * 86400 + calc_dt.seconds, 60) 285 | if (diff[0] > max_minutes_from_last_exception): 286 | self._exception_count = 0 287 | else: 288 | self._exception_count += 1 289 | else: 290 | self._exception_count = 0 291 | else: 292 | self._exception_count = 0 293 | 294 | if not (max_exceptions_before_stop > self._exception_count): 295 | _LOGGER.error("max exceptions allowed in watch loop exceeded, stoping watch loop") 296 | self._ok_to_run = False 297 | 298 | self._last_exception_dt = current_dt 299 | 300 | def stop(self, event): 301 | """handle stop request for events""" 302 | _LOGGER.debug("received :" + event.event_type) 303 | self._ok_to_run = False 304 | 305 | def launch_state_change_event(self, name, status): 306 | """launch events for state changes""" 307 | _LOGGER.debug("launching event for " + name + "for state changed to " + status) 308 | self._hass.bus.fire(UPDATE_EVENT, 309 | { 310 | EVENT_PROPERTY_NAME: name, 311 | EVENT_PROPERTY_STATE: status 312 | }) 313 | --------------------------------------------------------------------------------