├── custom_components └── wyzesense │ ├── __init__.py │ ├── services.yaml │ ├── manifest.json │ ├── binary_sensor.py │ └── wyzesense_custom.py ├── .github └── workflows │ └── hassfest.yaml ├── info.md ├── .gitignore └── README.md /custom_components/wyzesense/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v2" 14 | - uses: home-assistant/actions/hassfest@master 15 | -------------------------------------------------------------------------------- /custom_components/wyzesense/services.yaml: -------------------------------------------------------------------------------- 1 | # Describes the format for available wyzesense services 2 | 3 | scan: 4 | description: Allow new devices to join for the next 30s 5 | 6 | remove: 7 | description: Remove a device 8 | fields: 9 | mac: 10 | description: MAC address of the node to remove 11 | example: "777A4656" -------------------------------------------------------------------------------- /custom_components/wyzesense/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.0.11", 3 | "domain": "wyzesense", 4 | "name": "Wyze Sense Component", 5 | "documentation": "https://github.com/kevinvincent/wyzesense", 6 | "requirements": ["wyzesense==0.0.4","retry==0.9.2"], 7 | "dependencies": [], 8 | "codeowners": ["@kevinvincent"], 9 | "iot_class": "local_push" 10 | } 11 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | ### 0.0.9 - More stability fixes 3 | Prevents wyzesense from stopping when receiving an unparseable packet. Should help with stability issues. 4 | Thanks to @raetha for the fix. 5 | 6 | ### 0.0.7 - Major Stability and Error fix 7 | **WHEN UPGRADING TO THIS VERSION YOU WILL HAVE TO RETRIGGER EACH SENSOR TO HAVE IT DISPLAY AGAIN** 8 | 9 | This is a one time thing you have to do once updating and restarting HomeAssistant. Once you do this, stability on restarts should improve dramatically. This specifically solves any errors that contain `result = ws.List()` in the error message. This is the vast majority of errors reported. 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # Build Artifacts 107 | ._* 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## No Longer Maintained 2 | Hi all, I'm unable to maintain this component anymore due to time constraints. Unfortunately, there is a fatal issue with these sensors that has appeared over the year. They can die permanently if the battery gets too low, https://twitter.com/WyzeCam/status/1321147804610252800. Wyze is no longer selling wyze sense v1. If and when new sensors come out, I will try to create a component for those. Apologies to all who have bought these sensors, it seems like the sensors themselves are flawed, leading to a ton of issues after a year, and this component can't do much about it. 3 | 4 | I recommend upvoting this thread on the wyze forums so that wyze knows we want an official wyzesense integration: https://forums.wyzecam.com/t/home-assistant-integration/3971 5 | 6 | # Home Assistant - WYZE Sense Component 7 | 8 | > Special thanks to [HcLX](https://hclxing.wordpress.com) and his work on [WyzeSensePy](https://github.com/HclX/WyzeSensePy) which is the core of this component. His reverse engineering talents and development of WyzeSensePy made it quite easy to connect with WYZE sense devices. 9 | 10 | Are you a visual person? Here's a [video walkthrough](https://www.youtube.com/watch?v=19UCwf4uidQ) of the setup and configuration. Check this README for the most up to date information. 11 | 12 | WARNING: This component does not work on Mac OSX, Synology DSM, or other OSs that don't have hidraw drivers. 13 | 14 | ## Installation (HACS) - Highly Recommended 15 | 0. Have [HACS](https://github.com/custom-components/hacs) installed, this will allow you to easily update 16 | 1. Add `https://github.com/kevinvincent/ha-wyzesense` as a [custom repository](https://custom-components.github.io/hacs/usage/settings/#add-custom-repositories) as Type: Integration 17 | 2. Click install under "Wyze Sense Component", restart your instance. 18 | NOTE: You must have the Wyze Sense Hub removed from you device before installing the Wyze Sense Component and restarting your instance. If not, the setup may fail. 19 | 3. Plug in the Wyze Sense Hub (the usb device) into an open port on your device. 20 | 21 | ## Installation (Manual) 22 | 1. Download this repository as a ZIP (green button, top right) and unzip the archive 23 | 2. Copy `/custom_components/wyzesense` to your `/custom_components/` directory 24 | * You will need to create the `custom_components` folder if it does not exist 25 | * On Hassio the final location will be `/config/custom_components/wyzesense` 26 | * On Hassbian the final location will be `/home/homeassistant/.homeassistant/custom_components/wyzesense` 27 | 3. Plug in the WYZE Sense hub (the usb device) into an open port on your device. 28 | 29 | ## Configuration 30 | Add the following to your configuration file and restart Home Assistant to load the configuration 31 | 32 | The custom_component will use the contents of `/sys/class/hidraw` to determine which `hidraw` device is the Wyze receiver dongle. 33 | 34 | ```yaml 35 | binary_sensor: 36 | - platform: wyzesense 37 | device: auto 38 | ``` 39 | 40 | ## Advanced Configuration 41 | 42 | ### Specify hidraw device 43 | You can also optionally specify the hidraw device to use: 44 | 45 | ```yaml 46 | binary_sensor: 47 | - platform: wyzesense 48 | device: "/dev/hidraw0" 49 | ``` 50 | Most likely your device will be mounted to `/dev/hidraw0`. You can confirm the hidraw name of the device by running `dmesg | grep hidraw` to find out what hidraw number the bridge grabbed. Be aware that sometimes on restarts the hidraw device number will change. You can permanently fix the name (ex. as '/dev/wyzesense' in order to passthrough in Docker) by following the simple steps in [this comment](https://github.com/kevinvincent/ha-wyzesense/issues/66#issuecomment-569470754) 51 | 52 | ### Set initial states for sensors 53 | 54 | By default, the component will restore the last state of the entity prior to a restart. If sensors change state during a restart, the change may not be reflected in HA. In order to combat this you can optionally specify an initial_state for sensors (by mac address) that will be set upon a restart. Be sure to put quotes around "on" or "off" so that they are strings not booleans. 55 | 56 | ```yaml 57 | binary_sensor: 58 | - platform: wyzesense 59 | device: "/dev/hidraw0" 60 | initial_state: 61 | 77793176: "on" 62 | 77793193: "off" 63 | ``` 64 | 65 | 66 | ## Usage 67 | 68 | * Call the services below to add and remove sensors from your WYZE Sense hub. 69 | 70 | * If you have already bound sensors to the hub (for example using the Wyze Cam and Wyze App), they will be automatically added when the sensor is first triggered. 71 | 72 | * Entities will show up as `binary_sensor.wyzesense_` for example (`binary_sensor.wyzesense_777A4656`). 73 | * As like any other entity you can change the entity id and friendly name from the states page, which will stick even after restarts. 74 | 75 | * Notes on Individual Sensors 76 | * Motion 77 | * State `on`: Motion Detected 78 | * State `off`: No Motion Detected 79 | * Wyze motion sensors will keep reporting the `on` state for 40 seconds after the last motion is detected. This is non configurable, but in practice it isn't a big deal and usually makes automations simpler. 80 | * Door 81 | * State `on`: Sensor open 82 | * State `off`: Sensor closed 83 | * Wyze door sensors will report `off` when the magnetized portion is within ~1 inch of the door sensor body. 84 | * Notes on selected Sensor Attributes: 85 | * `rssi`: This stands for received signal strength indicator. Higher values (closer to 0) mean a stronger signal. 86 | * `battery_level`: The sensor does a basic calculation with the battery voltage. Because of this, battery percentage may be higher than 100% when you first get a sensor. Enjoy the longer battery life :) 87 | 88 | ## Services 89 | For all services a persistent notification will be sent for both successes and failures. 90 | 91 | ### `wyzesense.scan` 92 | * Call this service and then within 30 seconds, insert a pin into the hole on the side of a sensor and push until the red led flashes three times. The sensor will now be bound and show up in your entities. You will have to call this service once at a time for each sensor you want to add. 93 | 94 | ### `wyzesense.remove` 95 | * Removes a sensor. Make sure you call this service with the correct MAC address of the sensor (which is the string of numbers and possibly letters that looks like `777A4656`). You can find this in the entity's attributes in the developer section. Needs to be entered in this format mac: xxxxxxxx an example mac: 777A4656 96 | 97 | ## Troubleshooting 98 | * Passing dongle hidraw device into Docker: 99 | * Please follow the steps outlined in [this comment](https://github.com/kevinvincent/ha-wyzesense/issues/66#issuecomment-569470754) 100 | * Permission denied /dev/hidraw0 101 | * Additional Information 102 | * If you see this error on a Hassio installation please follow Reporting an Issue below. It is most likely an issue with your specific setup. 103 | * This is known to occur on Hassbian. This occurs when the group homeassistant is denied from accessing hidraw devices. 104 | * Solution 105 | * Create / Modify the file `/etc/udev/rules.d/99-com.rules` on your machine and insert `KERNEL=="hidraw*", SUBSYSTEM=="hidraw", MODE="0666"` 106 | * Restart your machine 107 | * TimeoutError: _DoCommand 108 | * Ensure that you have updated to the latest component code. If you still see this error follow Reporting an Issue below. 109 | ## Reporting an Issue 110 | 1. Setup your logger to print debug messages for this component using: 111 | ```yaml 112 | logger: 113 | default: info 114 | logs: 115 | custom_components.wyzesense: debug 116 | wyzesense.gateway: debug 117 | ``` 118 | 2. Restart HA 119 | 3. Verify you're still having the issue 120 | 4. File an issue in this Github Repository containing your HA log (Developer section > Info > Load Full Home Assistant Log) 121 | * You can paste your log file at pastebin https://pastebin.com/ and submit a link. 122 | * Please include details about your setup (Pi, NUC, etc, docker?, HASSOS?) 123 | * The log file can also be found at `//home-assistant.log` 124 | -------------------------------------------------------------------------------- /custom_components/wyzesense/binary_sensor.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | wyzesense integration 4 | v0.0.9 5 | 6 | """ 7 | 8 | from .wyzesense_custom import * 9 | import logging 10 | import voluptuous as vol 11 | import json 12 | import os.path 13 | 14 | from os import path 15 | from retry import retry 16 | import subprocess 17 | 18 | from homeassistant.const import CONF_FILENAME, CONF_DEVICE, \ 19 | EVENT_HOMEASSISTANT_STOP, STATE_ON, STATE_OFF, ATTR_BATTERY_LEVEL, \ 20 | ATTR_STATE, ATTR_DEVICE_CLASS, DEVICE_CLASS_TIMESTAMP 21 | 22 | try: 23 | from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity, DEVICE_CLASS_MOTION, DEVICE_CLASS_DOOR 24 | except ImportError: 25 | from homeassistant.components.binary_sensor import BinarySensorDevice as BinarySensorEntity, PLATFORM_SCHEMA, DEVICE_CLASS_MOTION, DEVICE_CLASS_DOOR 26 | 27 | from homeassistant.helpers.restore_state import RestoreEntity 28 | 29 | import homeassistant.helpers.config_validation as cv 30 | 31 | DOMAIN = "wyzesense" 32 | 33 | STORAGE = ".storage/wyzesense.json" 34 | 35 | ATTR_MAC = "mac" 36 | ATTR_RSSI = "rssi" 37 | ATTR_AVAILABLE = "available" 38 | CONF_INITIAL_STATE = "initial_state" 39 | 40 | PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ 41 | vol.Optional(CONF_DEVICE, default = "auto"): cv.string, 42 | vol.Optional(CONF_INITIAL_STATE, default={}): vol.Schema({cv.string : vol.In(["on","off"])}) 43 | }) 44 | 45 | SERVICE_SCAN = 'scan' 46 | SERVICE_REMOVE = 'remove' 47 | 48 | SERVICE_SCAN_SCHEMA = vol.Schema({}) 49 | 50 | SERVICE_REMOVE_SCHEMA = vol.Schema({ 51 | vol.Required(ATTR_MAC): cv.string 52 | }) 53 | 54 | _LOGGER = logging.getLogger(__name__) 55 | 56 | def getStorage(hass): 57 | if not path.exists(hass.config.path(STORAGE)): 58 | return [] 59 | with open(hass.config.path(STORAGE),'r') as f: 60 | return json.load(f) 61 | 62 | def setStorage(hass,data): 63 | with open(hass.config.path(STORAGE),'w') as f: 64 | json.dump(data, f) 65 | 66 | def findDongle(): 67 | df = subprocess.check_output(["ls", "-la", "/sys/class/hidraw"]).decode('utf-8').lower() 68 | for l in df.split('\n'): 69 | if ("e024" in l and "1a86" in l): 70 | for w in l.split(' '): 71 | if ("hidraw" in w): 72 | return "/dev/%s" % w 73 | 74 | def setup_platform(hass, config, add_entites, discovery_info=None): 75 | if config[CONF_DEVICE].lower() == 'auto': 76 | config[CONF_DEVICE] = findDongle() 77 | _LOGGER.debug("WYZESENSE v0.0.9") 78 | _LOGGER.debug("Attempting to open connection to hub at " + config[CONF_DEVICE]) 79 | 80 | forced_initial_states = config[CONF_INITIAL_STATE] 81 | entities = {} 82 | 83 | def on_event(ws, event): 84 | if event.Type == 'state': 85 | (sensor_type, sensor_state, sensor_battery, sensor_signal) = event.Data 86 | data = { 87 | ATTR_AVAILABLE: True, 88 | ATTR_MAC: event.MAC, 89 | ATTR_STATE: 1 if sensor_state == "open" or sensor_state == "active" else 0, 90 | ATTR_DEVICE_CLASS: DEVICE_CLASS_MOTION if sensor_type == "motion" else DEVICE_CLASS_DOOR , 91 | DEVICE_CLASS_TIMESTAMP: event.Timestamp.isoformat(), 92 | ATTR_RSSI: sensor_signal * -1, 93 | ATTR_BATTERY_LEVEL: sensor_battery 94 | } 95 | 96 | _LOGGER.debug(data) 97 | 98 | if not event.MAC in entities: 99 | new_entity = WyzeSensor(data) 100 | entities[event.MAC] = new_entity 101 | add_entites([new_entity]) 102 | 103 | storage = getStorage(hass) 104 | if event.MAC not in storage: 105 | storage.append(event.MAC) 106 | setStorage(hass, storage) 107 | 108 | else: 109 | entities[event.MAC]._data = data 110 | # From https://github.com/kevinvincent/ha-wyzesense/issues/189 111 | try: 112 | entities[event.MAC].schedule_update_ha_state() 113 | except (AttributeError, AssertionError): 114 | _LOGGER.debug("wyze Sensor not yet ready for update") 115 | 116 | @retry(TimeoutError, tries=10, delay=1, logger=_LOGGER) 117 | def beginConn(): 118 | return Open(config[CONF_DEVICE], on_event) 119 | 120 | ws = beginConn() 121 | 122 | storage = getStorage(hass) 123 | 124 | _LOGGER.debug("%d Sensors Loaded from storage" % len(storage)) 125 | 126 | for mac in storage: 127 | _LOGGER.debug("Registering Sensor Entity: %s" % mac) 128 | 129 | mac = mac.strip() 130 | 131 | if not len(mac) == 8: 132 | _LOGGER.debug("Ignoring %s, Invalid length for MAC" % mac) 133 | continue 134 | 135 | initial_state = forced_initial_states.get(mac) 136 | 137 | data = { 138 | ATTR_AVAILABLE: False, 139 | ATTR_MAC: mac, 140 | ATTR_STATE: 0 141 | } 142 | 143 | if not mac in entities: 144 | new_entity = WyzeSensor(data, should_restore = True, override_restore_state = initial_state) 145 | entities[mac] = new_entity 146 | add_entites([new_entity]) 147 | 148 | # Configure Destructor 149 | def on_shutdown(event): 150 | _LOGGER.debug("Closing connection to hub") 151 | ws.Stop() 152 | 153 | hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, on_shutdown) 154 | 155 | # Configure Service 156 | def on_scan(call): 157 | result = ws.Scan() 158 | if result: 159 | notification = "Sensor found and added as: binary_sensor.wyzesense_%s (unless you have customized the entity ID prior).
To add more sensors, call wyzesense.scan again.

More Info: type=%d, version=%d" % result 160 | hass.components.persistent_notification.create(notification, DOMAIN) 161 | _LOGGER.debug(notification) 162 | else: 163 | notification = "Scan completed with no sensor found." 164 | hass.components.persistent_notification.create(notification, DOMAIN) 165 | _LOGGER.debug(notification) 166 | 167 | def on_remove(call): 168 | mac = call.data.get(ATTR_MAC).upper() 169 | if entities.get(mac): 170 | ws.Delete(mac) 171 | toDelete = entities[mac] 172 | hass.add_job(toDelete.async_remove) 173 | del entities[mac] 174 | 175 | storage = getStorage(hass) 176 | storage.remove(mac) 177 | setStorage(hass, storage) 178 | 179 | notification = "Successfully removed sensor: %s" % mac 180 | hass.components.persistent_notification.create(notification, DOMAIN) 181 | _LOGGER.debug(notification) 182 | else: 183 | notification = "No sensor with mac %s found to remove." % mac 184 | hass.components.persistent_notification.create(notification, DOMAIN) 185 | _LOGGER.debug(notification) 186 | 187 | hass.services.register(DOMAIN, SERVICE_SCAN, on_scan, SERVICE_SCAN_SCHEMA) 188 | hass.services.register(DOMAIN, SERVICE_REMOVE, on_remove, SERVICE_REMOVE_SCHEMA) 189 | 190 | 191 | class WyzeSensor(BinarySensorEntity, RestoreEntity): 192 | """Class to hold Hue Sensor basic info.""" 193 | 194 | def __init__(self, data, should_restore = False, override_restore_state = None): 195 | """Initialize the sensor object.""" 196 | _LOGGER.debug(data) 197 | self._data = data 198 | self._should_restore = should_restore 199 | self._override_restore_state = override_restore_state 200 | 201 | async def async_added_to_hass(self): 202 | """Run when entity about to be added.""" 203 | await super().async_added_to_hass() 204 | 205 | if self._should_restore: 206 | 207 | last_state = await self.async_get_last_state() 208 | 209 | if last_state is not None: 210 | actual_state = last_state.state 211 | 212 | if self._override_restore_state is not None: 213 | actual_state = self._override_restore_state 214 | 215 | self._data = { 216 | ATTR_STATE: 1 if actual_state == "on" else 0, 217 | ATTR_AVAILABLE: False, 218 | **last_state.attributes 219 | } 220 | 221 | @property 222 | def assumed_state(self): 223 | return not self._data[ATTR_AVAILABLE] 224 | 225 | @property 226 | def should_poll(self): 227 | """No polling needed.""" 228 | return False 229 | 230 | @property 231 | def unique_id(self): 232 | return self._data[ATTR_MAC] 233 | 234 | @property 235 | def is_on(self): 236 | """Return the state of the sensor.""" 237 | return self._data[ATTR_STATE] 238 | 239 | @property 240 | def device_class(self): 241 | """Return the class of this device, from component DEVICE_CLASSES.""" 242 | return self._data[ATTR_DEVICE_CLASS] if self._data[ATTR_AVAILABLE] else None 243 | 244 | @property 245 | def extra_state_attributes(self): 246 | """Attributes.""" 247 | attributes = self._data.copy() 248 | del attributes[ATTR_STATE] 249 | del attributes[ATTR_AVAILABLE] 250 | 251 | return attributes 252 | -------------------------------------------------------------------------------- /custom_components/wyzesense/wyzesense_custom.py: -------------------------------------------------------------------------------- 1 | from builtins import bytes 2 | from builtins import str 3 | 4 | import os 5 | import time 6 | import six 7 | import struct 8 | import threading 9 | import datetime 10 | import argparse 11 | import binascii 12 | import errno 13 | 14 | import logging 15 | log = logging.getLogger(__name__) 16 | 17 | def bytes_to_hex(s): 18 | if s: 19 | return binascii.hexlify(s) 20 | else: 21 | return "" 22 | 23 | def checksum_from_bytes(s): 24 | return sum(bytes(s)) & 0xFFFF 25 | 26 | TYPE_SYNC = 0x43 27 | TYPE_ASYNC = 0x53 28 | 29 | def MAKE_CMD(type, cmd): 30 | return (type << 8) | cmd 31 | 32 | class Packet(object): 33 | _CMD_TIMEOUT = 5 34 | 35 | # Sync packets: 36 | # Commands initiated from host side 37 | CMD_GET_ENR = MAKE_CMD(TYPE_SYNC, 0x02) 38 | CMD_GET_MAC = MAKE_CMD(TYPE_SYNC, 0x04) 39 | CMD_GET_KEY = MAKE_CMD(TYPE_SYNC, 0x06) 40 | CMD_INQUIRY = MAKE_CMD(TYPE_SYNC, 0x27) 41 | CMD_UPDATE_CC1310 = MAKE_CMD(TYPE_SYNC, 0x12) 42 | CMD_SET_CH554_UPGRADE = MAKE_CMD(TYPE_SYNC, 0x0E) 43 | 44 | # Async packets: 45 | ASYNC_ACK = MAKE_CMD(TYPE_ASYNC, 0xFF) 46 | 47 | # Commands initiated from dongle side 48 | CMD_FINISH_AUTH = MAKE_CMD(TYPE_ASYNC, 0x14) 49 | CMD_GET_DONGLE_VERSION = MAKE_CMD(TYPE_ASYNC, 0x16) 50 | CMD_START_STOP_SCAN = MAKE_CMD(TYPE_ASYNC, 0x1C) 51 | CMD_GET_SENSOR_R1 = MAKE_CMD(TYPE_ASYNC, 0x21) 52 | CMD_VERIFY_SENSOR = MAKE_CMD(TYPE_ASYNC, 0x23) 53 | CMD_DEL_SENSOR = MAKE_CMD(TYPE_ASYNC, 0x25) 54 | CMD_GET_SENSOR_COUNT = MAKE_CMD(TYPE_ASYNC, 0x2E) 55 | CMD_GET_SENSOR_LIST = MAKE_CMD(TYPE_ASYNC, 0x30) 56 | 57 | # Notifications initiated from dongle side 58 | NOTIFY_SENSOR_ALARM = MAKE_CMD(TYPE_ASYNC, 0x19) 59 | NOTIFY_SENSOR_SCAN = MAKE_CMD(TYPE_ASYNC, 0x20) 60 | NOTIFY_SYNC_TIME = MAKE_CMD(TYPE_ASYNC, 0x32) 61 | NOTIFY_EVENT_LOG = MAKE_CMD(TYPE_ASYNC, 0x35) 62 | 63 | def __init__(self, cmd, payload = bytes()): 64 | self._cmd = cmd 65 | if self._cmd == self.ASYNC_ACK: 66 | assert isinstance(payload, int) 67 | else: 68 | assert isinstance(payload, bytes) 69 | self._payload = payload 70 | 71 | def __str__(self): 72 | if self._cmd == self.ASYNC_ACK: 73 | return "Packet: Cmd=%04X, Payload=ACK(%04X)" % (self._cmd, self._payload) 74 | else: 75 | return "Packet: Cmd=%04X, Payload=%s" % (self._cmd, bytes_to_hex(self._payload)) 76 | 77 | @property 78 | def Length(self): 79 | if self._cmd == self.ASYNC_ACK: 80 | return 7 81 | else: 82 | return len(self._payload) + 7 83 | 84 | @property 85 | def Cmd(self): 86 | return self._cmd 87 | 88 | @property 89 | def Payload(self): 90 | return self._payload 91 | 92 | def Send(self, fd): 93 | pkt = bytes() 94 | 95 | pkt += struct.pack(">HB", 0xAA55, self._cmd >> 8) 96 | if self._cmd == self.ASYNC_ACK: 97 | pkt += struct.pack("BB", (self._payload & 0xFF), self._cmd & 0xFF) 98 | else: 99 | pkt += struct.pack("BB", len(self._payload) + 3, self._cmd & 0xFF) 100 | if self._payload: 101 | pkt += self._payload 102 | 103 | checksum = checksum_from_bytes(pkt) 104 | pkt += struct.pack(">H", checksum) 105 | log.debug("Sending: %s", bytes_to_hex(pkt)) 106 | ss = os.write(fd, pkt) 107 | assert ss == len(pkt) 108 | 109 | @classmethod 110 | def Parse(cls, s): 111 | assert isinstance(s, bytes) 112 | 113 | if len(s) < 5: 114 | log.error("Invalid packet: %s", bytes_to_hex(s)) 115 | log.error("Invalid packet length: %d", len(s)) 116 | return None 117 | 118 | magic, cmd_type, b2, cmd_id = struct.unpack_from(">HBBB", s) 119 | if magic != 0x55AA and magic != 0xAA55: 120 | log.error("Invalid packet: %s", bytes_to_hex(s)) 121 | log.error("Invalid packet magic: %4X", magic) 122 | return None 123 | 124 | cmd = MAKE_CMD(cmd_type, cmd_id) 125 | if cmd == cls.ASYNC_ACK: 126 | assert len(s) >= 7 127 | s = s[:7] 128 | payload = MAKE_CMD(cmd_type, b2) 129 | elif len(s) >= b2 + 4: 130 | s = s[: b2 + 4] 131 | payload = s[5:-2] 132 | else: 133 | log.error("Invalid packet: %s", bytes_to_hex(s)) 134 | return None 135 | 136 | cs_remote = (s[-2] << 8) | s[-1] 137 | cs_local = checksum_from_bytes(s[:-2]) 138 | if cs_remote != cs_local: 139 | log.error("Invalid packet: %s", bytes_to_hex(s)) 140 | log.error("Mismatched checksum, remote=%04X, local=%04X", cs_remote, cs_local) 141 | return None 142 | 143 | return cls(cmd, payload) 144 | 145 | @classmethod 146 | def GetVersion(cls): 147 | return cls(cls.CMD_GET_DONGLE_VERSION) 148 | 149 | @classmethod 150 | def Inquiry(cls): 151 | return cls(cls.CMD_INQUIRY) 152 | 153 | @classmethod 154 | def GetEnr(cls, r): 155 | assert isinstance(r, bytes) 156 | assert len(r) == 16 157 | return cls(cls.CMD_GET_ENR, r) 158 | 159 | @classmethod 160 | def GetMAC(cls): 161 | return cls(cls.CMD_GET_MAC) 162 | 163 | @classmethod 164 | def GetKey(cls): 165 | return cls(cls.CMD_GET_KEY) 166 | 167 | @classmethod 168 | def EnableScan(cls): 169 | return cls(cls.CMD_START_STOP_SCAN, b"\x01") 170 | 171 | @classmethod 172 | def DisableScan(cls): 173 | return cls(cls.CMD_START_STOP_SCAN, b"\x00") 174 | 175 | @classmethod 176 | def GetSensorCount(cls): 177 | return cls(cls.CMD_GET_SENSOR_COUNT) 178 | 179 | @classmethod 180 | def GetSensorList(cls, count): 181 | assert count <= 0xFF 182 | return cls(cls.CMD_GET_SENSOR_LIST, struct.pack("B", count)) 183 | 184 | @classmethod 185 | def FinishAuth(cls): 186 | return cls(cls.CMD_FINISH_AUTH, b"\xFF") 187 | 188 | @classmethod 189 | def DelSensor(cls, mac): 190 | assert isinstance(mac, str) 191 | assert len(mac) == 8 192 | return cls(cls.CMD_DEL_SENSOR, mac.encode('ascii')) 193 | 194 | @classmethod 195 | def GetSensorR1(cls, mac, r): 196 | assert isinstance(r, bytes) 197 | assert len(r) == 16 198 | assert isinstance(mac, str) 199 | assert len(mac) == 8 200 | return cls(cls.CMD_GET_SENSOR_R1, mac.encode('ascii') + r) 201 | 202 | @classmethod 203 | def VerifySensor(cls, mac): 204 | assert isinstance(mac, str) 205 | assert len(mac) == 8 206 | return cls(cls.CMD_VERIFY_SENSOR, mac.encode('ascii') + b"\xFF\x04") 207 | 208 | @classmethod 209 | def UpdateCC1310(cls): 210 | return cls(cls.CMD_UPDATE_CC1310) 211 | 212 | @classmethod 213 | def Ch554Upgrade(cls): 214 | return cls(cls.CMD_SET_CH554_UPGRADE) 215 | 216 | @classmethod 217 | def SyncTimeAck(cls): 218 | return cls(cls.NOTIFY_SYNC_TIME + 1, struct.pack(">Q", int(time.time() * 1000))) 219 | 220 | @classmethod 221 | def AsyncAck(cls, cmd): 222 | assert (cmd >> 0x8) == TYPE_ASYNC 223 | return cls(cls.ASYNC_ACK, cmd) 224 | 225 | class SensorEvent(object): 226 | def __init__(self, mac, timestamp, event_type, event_data): 227 | self.MAC = mac 228 | self.Timestamp = timestamp 229 | self.Type = event_type 230 | self.Data = event_data 231 | 232 | def __str__(self): 233 | s = "[%s][%s]" % (self.Timestamp.strftime("%Y-%m-%d %H:%M:%S"), self.MAC) 234 | if self.Type == 'state': 235 | s += "StateEvent: sensor_type=%s, state=%s, battery=%d, signal=%d" % self.Data 236 | else: 237 | s += "RawEvent: type=%s, data=%s" % (self.Type, bytes_to_hex(self.Data)) 238 | return s 239 | 240 | class Dongle(object): 241 | _CMD_TIMEOUT = 5 242 | 243 | class CmdContext(object): 244 | def __init__(self, **kwargs): 245 | for key in kwargs: 246 | setattr(self, key, kwargs[key]) 247 | 248 | def _OnSensorAlarm(self, pkt): 249 | if len(pkt.Payload) < 18: 250 | log.info("Unknown alarm packet: %s", bytes_to_hex(pkt.Payload)) 251 | return 252 | 253 | timestamp, event_type, sensor_mac = struct.unpack_from(">QB8s", pkt.Payload) 254 | timestamp = datetime.datetime.fromtimestamp(timestamp/1000.0) 255 | sensor_mac = sensor_mac.decode('ascii') 256 | alarm_data = pkt.Payload[17:] 257 | if event_type == 0xA2: 258 | if alarm_data[0] == 0x01 or alarm_data[0] == 0x0E: 259 | sensor_type = "switch" 260 | sensor_state = "open" if alarm_data[5] == 1 else "close" 261 | elif alarm_data[0] == 0x02 or alarm_data[0] == 0x0F: 262 | sensor_type = "motion" 263 | sensor_state = "active" if alarm_data[5] == 1 else "inactive" 264 | else: 265 | log.info("Unknown Sensor Type: %x", alarm_data[0]) 266 | sensor_type = "unknown" 267 | sensor_state = "unknown" 268 | e = SensorEvent(sensor_mac, timestamp, "state", (sensor_type, sensor_state, alarm_data[2], alarm_data[8])) 269 | else: 270 | e = SensorEvent(sensor_mac, timestamp, "raw_%02X" % event_type, alarm_data) 271 | 272 | self.__on_event(self, e) 273 | 274 | def _OnSyncTime(self, pkt): 275 | self._SendPacket(Packet.SyncTimeAck()) 276 | 277 | def _OnEventLog(self, pkt): 278 | assert len(pkt.Payload) >= 9 279 | ts, msg_len = struct.unpack_from(">QB", pkt.Payload) 280 | # assert msg_len + 8 == len(pkt.Payload) 281 | tm = datetime.datetime.fromtimestamp(ts/1000.0) 282 | msg = pkt.Payload[9:] 283 | log.info("LOG: time=%s, data=%s", tm.isoformat(), bytes_to_hex(msg)) 284 | 285 | def __init__(self, device, event_handler): 286 | self.__lock = threading.Lock() 287 | self.__device = device 288 | self.__fd = os.open(device, os.O_RDWR | os.O_NONBLOCK) 289 | self.__sensors = {} 290 | self.__exit_event = threading.Event() 291 | self.__thread = threading.Thread(target = self._Worker) 292 | self.__on_event = event_handler 293 | 294 | self.__handlers = { 295 | Packet.NOTIFY_SYNC_TIME: self._OnSyncTime, 296 | Packet.NOTIFY_SENSOR_ALARM: self._OnSensorAlarm, 297 | Packet.NOTIFY_EVENT_LOG: self._OnEventLog, 298 | } 299 | 300 | self._Start() 301 | 302 | def _ReadRawHID(self): 303 | try: 304 | s = os.read(self.__fd, 0x40) 305 | except OSError as e: 306 | if e.errno == errno.EWOULDBLOCK: 307 | return b"" 308 | else: 309 | raise e 310 | 311 | if not s: 312 | log.info("Nothing read") 313 | return b"" 314 | 315 | s = bytes(s) 316 | length = s[0] 317 | assert length > 0 318 | if length > 0x3F: 319 | length = 0x3F 320 | 321 | #log.debug("Raw HID packet: %s", bytes_to_hex(s)) 322 | assert len(s) >= length + 1 323 | return s[1: 1 + length] 324 | 325 | def _SetHandler(self, cmd, handler): 326 | with self.__lock: 327 | oldHandler = self.__handlers.pop(cmd, None) 328 | if handler: 329 | self.__handlers[cmd] = handler 330 | return oldHandler 331 | 332 | def _SendPacket(self, pkt): 333 | log.debug("===> Sending: %s", str(pkt)) 334 | pkt.Send(self.__fd) 335 | 336 | def _DefaultHandler(self, pkt): 337 | pass 338 | 339 | def _HandlePacket(self, pkt): 340 | log.debug("<=== Received: %s", str(pkt)) 341 | with self.__lock: 342 | handler = self.__handlers.get(pkt.Cmd, self._DefaultHandler) 343 | 344 | if (pkt.Cmd >> 8) == TYPE_ASYNC and pkt.Cmd != Packet.ASYNC_ACK: 345 | #log.info("Sending ACK packet for cmd %04X", pkt.Cmd) 346 | self._SendPacket(Packet.AsyncAck(pkt.Cmd)) 347 | handler(pkt) 348 | 349 | def _Worker(self): 350 | while True: #Watchdog 351 | try: 352 | s = b"" 353 | while True: 354 | if self.__exit_event.isSet(): 355 | break 356 | 357 | s += self._ReadRawHID() 358 | #if s: 359 | # log.info("Incoming buffer: %s", bytes_to_hex(s)) 360 | start = s.find(b"\x55\xAA") 361 | if start == -1: 362 | time.sleep(0.1) 363 | continue 364 | 365 | s = s[start:] 366 | log.debug("Trying to parse: %s", bytes_to_hex(s)) 367 | pkt = Packet.Parse(s) 368 | if not pkt: 369 | s = s[2:] 370 | continue 371 | 372 | log.debug("Received: %s", bytes_to_hex(s[:pkt.Length])) 373 | s = s[pkt.Length:] 374 | self._HandlePacket(pkt) 375 | except OSError as e: 376 | log.error(e) 377 | break 378 | except: 379 | log.exception("Ignoring non-OSError in worker thread. Please share the error logs with the developers.") 380 | 381 | def _DoCommand(self, pkt, handler, timeout=_CMD_TIMEOUT): 382 | e = threading.Event() 383 | oldHandler = self._SetHandler(pkt.Cmd + 1, lambda pkt: handler(pkt, e)) 384 | self._SendPacket(pkt) 385 | result = e.wait(timeout) 386 | self._SetHandler(pkt.Cmd + 1, oldHandler) 387 | 388 | if not result: 389 | raise TimeoutError("_DoCommand") 390 | 391 | def _DoSimpleCommand(self, pkt, timeout=_CMD_TIMEOUT): 392 | ctx = self.CmdContext(result = None) 393 | 394 | def cmd_handler(pkt, e): 395 | ctx.result = pkt 396 | e.set() 397 | 398 | self._DoCommand(pkt, cmd_handler, timeout) 399 | return ctx.result 400 | 401 | def _Inquiry(self): 402 | log.debug("Start Inquiry...") 403 | resp = self._DoSimpleCommand(Packet.Inquiry()) 404 | 405 | assert len(resp.Payload) == 1 406 | result = resp.Payload[0] 407 | log.debug("Inquiry returns %d", result) 408 | 409 | assert result == 1, "Inquiry failed, result=%d" % result 410 | 411 | def _GetEnr(self, r): 412 | log.debug("Start GetEnr...") 413 | assert len(r) == 4 414 | assert all(isinstance(x, int) for x in r) 415 | r_string = bytes(struct.pack(" 0: 472 | log.debug("%d sensors reported, waiting for each one to report...", count) 473 | 474 | def cmd_handler(pkt, e): 475 | assert len(pkt.Payload) == 8 476 | mac = pkt.Payload.decode('ascii') 477 | log.debug("Sensor %d/%d, MAC:%s", ctx.index + 1, ctx.count, mac) 478 | 479 | ctx.sensors.append(mac) 480 | ctx.index += 1 481 | if ctx.index == ctx.count: 482 | e.set() 483 | 484 | self._DoCommand(Packet.GetSensorList(count), cmd_handler, timeout=self._CMD_TIMEOUT * count) 485 | else: 486 | log.debug("No sensors bond yet...") 487 | return ctx.sensors 488 | 489 | def _FinishAuth(self): 490 | resp = self._DoSimpleCommand(Packet.FinishAuth()) 491 | assert len(resp.Payload) == 0 492 | 493 | def _Start(self): 494 | self.__thread.start() 495 | 496 | try: 497 | self._Inquiry() 498 | 499 | # self.ENR = self._GetEnr([0x30303030] * 4) 500 | # self.MAC = self._GetMac() 501 | # log.debug("Dongle MAC is [%s]", self.MAC) 502 | 503 | # self.Version = self._GetVersion() 504 | # log.debug("Dongle version: %s", self.Version) 505 | 506 | self._FinishAuth() 507 | except: 508 | self.Stop() 509 | raise 510 | 511 | def List(self): 512 | sensors = self._GetSensors() 513 | for x in sensors: 514 | log.debug("Sensor found: %s", x) 515 | 516 | return sensors 517 | 518 | def Stop(self, timeout=_CMD_TIMEOUT): 519 | self.__exit_event.set() 520 | os.close(self.__fd) 521 | self.__fd = None 522 | self.__thread.join(timeout) 523 | 524 | def Scan(self, timeout=60): 525 | log.debug("Start Scan...") 526 | 527 | ctx = self.CmdContext(evt=threading.Event(), result=None) 528 | def scan_handler(pkt): 529 | assert len(pkt.Payload) == 11 530 | ctx.result = (pkt.Payload[1:9].decode('ascii'), pkt.Payload[9], pkt.Payload[10]) 531 | ctx.evt.set() 532 | 533 | old_handler = self._SetHandler(Packet.NOTIFY_SENSOR_SCAN, scan_handler) 534 | try: 535 | self._DoSimpleCommand(Packet.EnableScan()) 536 | 537 | 538 | if ctx.evt.wait(timeout): 539 | s_mac, s_type, s_ver = ctx.result 540 | log.debug("Sensor found: mac=[%s], type=%d, version=%d", s_mac, s_type, s_ver) 541 | r1 = self._GetSensorR1(s_mac, b'Ok5HPNQ4lf77u754') 542 | log.debug("Sensor R1: %r", bytes_to_hex(r1)) 543 | else: 544 | log.debug("Sensor discovery timeout...") 545 | 546 | self._DoSimpleCommand(Packet.DisableScan()) 547 | finally: 548 | self._SetHandler(Packet.NOTIFY_SENSOR_SCAN, old_handler) 549 | if ctx.result: 550 | s_mac, s_type, s_ver = ctx.result 551 | self._DoSimpleCommand(Packet.VerifySensor(s_mac)) 552 | return ctx.result 553 | 554 | def Delete(self, mac): 555 | resp = self._DoSimpleCommand(Packet.DelSensor(str(mac))) 556 | log.debug("CmdDelSensor returns %s", bytes_to_hex(resp.Payload)) 557 | assert len(resp.Payload) == 9 558 | ack_mac = resp.Payload[:8].decode('ascii') 559 | ack_code = resp.Payload[8] 560 | assert ack_code == 0xFF, "CmdDelSensor: Unexpected ACK code: 0x%02X" % ack_code 561 | assert ack_mac == mac, "CmdDelSensor: MAC mismatch, requested:%s, returned:%s" % (mac, ack_mac) 562 | log.debug("CmdDelSensor: %s deleted", mac) 563 | 564 | 565 | def Open(device, event_handler): 566 | return Dongle(device, event_handler) 567 | --------------------------------------------------------------------------------