diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..70c1b24 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,67 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master, devel ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master, devel ] + schedule: + - cron: '40 14 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/Dockerfile b/Dockerfile index a609306..8f54bcb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,10 @@ RUN pip3 install --no-cache-dir --upgrade pip \ && pip3 install --no-cache-dir -r /wyzesense2mqtt/requirements.txt \ && chmod u+x /wyzesense2mqtt/service.sh +RUN apt-get update \ + && apt-get install -y --no-install-recommends vim \ + && rm -rf /var/lib/apt/lists/* + VOLUME /wyzesense2mqtt/config /wyzesense2mqtt/logs ENTRYPOINT /wyzesense2mqtt/service.sh diff --git a/Dockerfile.manual b/Dockerfile.manual index a35b801..c192d2a 100644 --- a/Dockerfile.manual +++ b/Dockerfile.manual @@ -8,6 +8,10 @@ RUN pip3 install --no-cache-dir --upgrade pip \ && pip3 install --no-cache-dir -r /wyzesense2mqtt/requirements.txt \ && chmod u+x /wyzesense2mqtt/service.sh +RUN apt-get update \ + && apt-get install -y --no-install-recommends vim \ + && rm -rf /var/lib/apt/lists/* + VOLUME /wyzesense2mqtt/config /wyzesense2mqtt/logs ENTRYPOINT /wyzesense2mqtt/service.sh diff --git a/readme.md b/readme.md index ba3427c..845d3ff 100644 --- a/readme.md +++ b/readme.md @@ -6,15 +6,14 @@ [![GitHub PRs](https://img.shields.io/github/issues-pr/raetha/wyzesense2mqtt)](https://github.com/raetha/wyzesense2mqtt/pulls) [![GitHub Release](https://img.shields.io/github/v/release/raetha/wyzesense2mqtt)](https://github.com/raetha/wyzesense2mqtt/releases) [![Python Validation](https://github.com/raetha/wyzesense2mqtt/workflows/Python%20Validation/badge.svg)](https://github.com/raetha/wyzesense2mqtt/actions?query=workflow%3A%22Python+Validation%22) -[![GitHub Downloads](https://img.shields.io/github/downloads/raetha/wyzesense2mqtt/total)]() [![dockeri.co](https://dockeri.co/image/raetha/wyzesense2mqtt)](https://hub.docker.com/r/raetha/wyzesense2mqtt) Configurable WyzeSense to MQTT Gateway intended for use with Home Assistant or other platforms that use MQTT discovery mechanisms. The gateway allows direct local access to [Wyze Sense](https://wyze.com/wyze-sense.html) products without the need for a Wyze Cam or cloud services. This project and its dependencies have no relation to Wyze Labs Inc. ## Special Thanks -* [HcLX](https://hclxing.wordpress.com) for [WyzeSensePy](https://github.com/HclX/WyzeSensePy), the core library this component uses. -* [Kevin Vincent](http://kevinvincent.me) for [HA-WyzeSense](https://github.com/kevinvincent/ha-wyzesense), the refernce code I used to get things working right with the calls to WyzeSensePy. +* [HcLX](https://hclxing.wordpress.com) for [WyzeSensePy](https://github.com/HclX/WyzeSensePy), the core library this project uses. +* [Kevin Vincent](http://kevinvincent.me) for [HA-WyzeSense](https://github.com/kevinvincent/ha-wyzesense), the reference code I used to get things working right with the calls to WyzeSensePy. * [ozczecho](https://github.com/ozczecho) for [wyze-mqtt](https://github.com/ozczecho/wyze-mqtt), the inspiration for this project. * [rmoriz](https://roland.io/) for [multiarch-test](https://github.com/rmoriz/multiarch-test), this allowed the Docker Hub Autobuilder to work for multiple architectures including ARM32v7 (Raspberry Pi) and AMD64 (Linux). @@ -75,22 +74,26 @@ mkdir /docker/wyzesense2mqtt/logs ```bash docker-compose up -d ``` -8. Pair sensors following instructions below. You do NOT need to re-pair sensors that were already paired, they should be found automatically on start and added to the config file with default values, but the sensor version will be unknown. +8. Pair sensors following [instructions below](#pairing-a-sensor). You do NOT need to re-pair sensors that were already paired, they should be found automatically on start and added to the config file with default values, but the sensor version will be unknown. ### Linux Systemd -The gateway can also be run as a systemd service for those not wanting to use Docker. Requires Python 3.6 or newer. +The gateway can also be run as a systemd service for those not wanting to use Docker. Requires Python 3.6 or newer. You may need to do all commands as root, depending on filesystem permissions. 1. Plug Wyze Sense Bridge into USB port on Linux host. 2. Pull down a copy of the repository ```bash cd /tmp git clone https://github.com/raetha/wyzesense2mqtt.git +# Use the below command instead if you want to help test the devel branch +# git clone -b devel https://github.com/raetha/wyzesense2mqtt.git ``` -3. Create local application folder (Select a location that works for you, example uses /wyzesense2mqtt) +3. Create local application folders (Select a location that works for you, example uses /wyzesense2mqtt) ```bash mv /tmp/wyzesense2mqtt/wyzesense2mqtt /wyzesense2mqtt rm -rf /tmp/wyzesense2mqtt cd /wyzesense2mqtt +mkdir config +mkdir logs ``` 4. Prepare config.yaml file. You must set MQTT host parameters! Username and password can be blank if unused. (see sample below) ```bash @@ -109,17 +112,18 @@ vim config/sensors.yaml ``` 7. Install dependencies ```bash -pip3 install -r requirements.txt +sudo pip3 install -r requirements.txt ``` -8. Start the service. +8. Configure the service ```bash vim wyzesense2mqtt.service # Only modify if not using default application path sudo cp wyzesense2mqtt.service /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl start wyzesense2mqtt sudo systemctl status wyzesense2mqtt +sudo systemctl enable wyzesense2mqtt # Enable start on reboot ``` -9. Pair sensors following instructions below. You do NOT need to re-pair sensors that were already paired, they should be found automatically on start and added to the config file with default values, but the sensor version will be unknown. +9. Pair sensors following [instructions below](#pairing-a-sensor). You do NOT need to re-pair sensors that were already paired, they should be found automatically on start and added to the config file with default values, but the sensor version will be unknown. ## Config Files @@ -139,6 +143,7 @@ mqtt_qos: 2 mqtt_retain: true self_topic_root: wyzesense2mqtt hass_topic_root: homeassistant +hass_discovery: true publish_sensor_name: true usb_dongle: auto ``` @@ -232,5 +237,5 @@ Home Assistant simply needs to be configured with the MQTT broker that the gatew ## Tested On -* Alpine Linux (Docker image) -* Raspbian Buster +* Debian Buster (Docker) +* Raspbian Buster (RPi 4) diff --git a/version.txt b/version.txt index 9459d4b..5625e59 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.1 +1.2 diff --git a/wyzesense2mqtt/bridge_tool_cli.py b/wyzesense2mqtt/bridge_tool_cli.py old mode 100644 new mode 100755 index 35e33ce..1f50e58 --- a/wyzesense2mqtt/bridge_tool_cli.py +++ b/wyzesense2mqtt/bridge_tool_cli.py @@ -95,9 +95,9 @@ def Unpair(mac_list): def Fix(unused_args): invalid_mac_list = [ - "00000000", - "\0\0\0\0\0\0\0\0", - "\x00\x00\x00\x00\x00\x00\x00\x00" + "00000000", + "\0\0\0\0\0\0\0\0", + "\x00\x00\x00\x00\x00\x00\x00\x00" ] print("Un-pairing bad sensors") logging.debug("Un-pairing bad sensors") diff --git a/wyzesense2mqtt/samples/config.yaml b/wyzesense2mqtt/samples/config.yaml index 112f22a..b1c34a4 100644 --- a/wyzesense2mqtt/samples/config.yaml +++ b/wyzesense2mqtt/samples/config.yaml @@ -9,5 +9,6 @@ mqtt_qos: 0 mqtt_retain: true self_topic_root: wyzesense2mqtt hass_topic_root: homeassistant +hass_discovery: true publish_sensor_name: true usb_dongle: auto diff --git a/wyzesense2mqtt/service.sh b/wyzesense2mqtt/service.sh old mode 100644 new mode 100755 diff --git a/wyzesense2mqtt/wyzesense.py b/wyzesense2mqtt/wyzesense.py index 5d29aa0..189b4e8 100755 --- a/wyzesense2mqtt/wyzesense.py +++ b/wyzesense2mqtt/wyzesense.py @@ -6,59 +6,63 @@ import struct import threading import datetime -import argparse import binascii import logging log = logging.getLogger(__name__) + def bytes_to_hex(s): if s: return binascii.hexlify(s) else: return "" + def checksum_from_bytes(s): return sum(bytes(s)) & 0xFFFF -TYPE_SYNC = 0x43 -TYPE_ASYNC = 0x53 + +TYPE_SYNC = 0x43 +TYPE_ASYNC = 0x53 + def MAKE_CMD(type, cmd): return (type << 8) | cmd + class Packet(object): _CMD_TIMEOUT = 5 # Sync packets: # Commands initiated from host side - CMD_GET_ENR = MAKE_CMD(TYPE_SYNC, 0x02) - CMD_GET_MAC = MAKE_CMD(TYPE_SYNC, 0x04) - CMD_GET_KEY = MAKE_CMD(TYPE_SYNC, 0x06) - CMD_INQUIRY = MAKE_CMD(TYPE_SYNC, 0x27) - CMD_UPDATE_CC1310 = MAKE_CMD(TYPE_SYNC, 0x12) - CMD_SET_CH554_UPGRADE = MAKE_CMD(TYPE_SYNC, 0x0E) + CMD_GET_ENR = MAKE_CMD(TYPE_SYNC, 0x02) + CMD_GET_MAC = MAKE_CMD(TYPE_SYNC, 0x04) + CMD_GET_KEY = MAKE_CMD(TYPE_SYNC, 0x06) + CMD_INQUIRY = MAKE_CMD(TYPE_SYNC, 0x27) + CMD_UPDATE_CC1310 = MAKE_CMD(TYPE_SYNC, 0x12) + CMD_SET_CH554_UPGRADE = MAKE_CMD(TYPE_SYNC, 0x0E) # Async packets: - ASYNC_ACK = MAKE_CMD(TYPE_ASYNC, 0xFF) + ASYNC_ACK = MAKE_CMD(TYPE_ASYNC, 0xFF) # Commands initiated from dongle side - CMD_FINISH_AUTH = MAKE_CMD(TYPE_ASYNC, 0x14) - CMD_GET_DONGLE_VERSION = MAKE_CMD(TYPE_ASYNC, 0x16) - CMD_START_STOP_SCAN = MAKE_CMD(TYPE_ASYNC, 0x1C) - CMD_GET_SENSOR_R1 = MAKE_CMD(TYPE_ASYNC, 0x21) - CMD_VERIFY_SENSOR = MAKE_CMD(TYPE_ASYNC, 0x23) - CMD_DEL_SENSOR = MAKE_CMD(TYPE_ASYNC, 0x25) - CMD_GET_SENSOR_COUNT = MAKE_CMD(TYPE_ASYNC, 0x2E) - CMD_GET_SENSOR_LIST = MAKE_CMD(TYPE_ASYNC, 0x30) + CMD_FINISH_AUTH = MAKE_CMD(TYPE_ASYNC, 0x14) + CMD_GET_DONGLE_VERSION = MAKE_CMD(TYPE_ASYNC, 0x16) + CMD_START_STOP_SCAN = MAKE_CMD(TYPE_ASYNC, 0x1C) + CMD_GET_SENSOR_R1 = MAKE_CMD(TYPE_ASYNC, 0x21) + CMD_VERIFY_SENSOR = MAKE_CMD(TYPE_ASYNC, 0x23) + CMD_DEL_SENSOR = MAKE_CMD(TYPE_ASYNC, 0x25) + CMD_GET_SENSOR_COUNT = MAKE_CMD(TYPE_ASYNC, 0x2E) + CMD_GET_SENSOR_LIST = MAKE_CMD(TYPE_ASYNC, 0x30) # Notifications initiated from dongle side - NOTIFY_SENSOR_ALARM = MAKE_CMD(TYPE_ASYNC, 0x19) - NOTIFY_SENSOR_SCAN = MAKE_CMD(TYPE_ASYNC, 0x20) - NOITFY_SYNC_TIME = MAKE_CMD(TYPE_ASYNC, 0x32) - NOTIFY_EVENT_LOG = MAKE_CMD(TYPE_ASYNC, 0x35) + NOTIFY_SENSOR_ALARM = MAKE_CMD(TYPE_ASYNC, 0x19) + NOTIFY_SENSOR_SCAN = MAKE_CMD(TYPE_ASYNC, 0x20) + NOITFY_SYNC_TIME = MAKE_CMD(TYPE_ASYNC, 0x32) + NOTIFY_EVENT_LOG = MAKE_CMD(TYPE_ASYNC, 0x35) - def __init__(self, cmd, payload = bytes()): + def __init__(self, cmd, payload=bytes()): self._cmd = cmd if self._cmd == self.ASYNC_ACK: assert isinstance(payload, int) @@ -82,14 +86,14 @@ def Length(self): @property def Cmd(self): return self._cmd - + @property def Payload(self): return self._payload def Send(self, fd): pkt = bytes() - + pkt += struct.pack(">HB", 0xAA55, self._cmd >> 8) if self._cmd == self.ASYNC_ACK: pkt += struct.pack("BB", (self._payload & 0xFF), self._cmd & 0xFF) @@ -143,11 +147,11 @@ def Parse(cls, s): @classmethod def GetVersion(cls): return cls(cls.CMD_GET_DONGLE_VERSION) - + @classmethod def Inquiry(cls): return cls(cls.CMD_INQUIRY) - + @classmethod def GetEnr(cls, r): assert isinstance(r, bytes) @@ -157,7 +161,7 @@ def GetEnr(cls, r): @classmethod def GetMAC(cls): return cls(cls.CMD_GET_MAC) - + @classmethod def GetKey(cls): return cls(cls.CMD_GET_KEY) @@ -188,7 +192,7 @@ def DelSensor(cls, mac): assert isinstance(mac, str) assert len(mac) == 8 return cls(cls.CMD_DEL_SENSOR, mac.encode('ascii')) - + @classmethod def GetSensorR1(cls, mac, r): assert isinstance(r, bytes) @@ -220,21 +224,25 @@ def AsyncAck(cls, cmd): assert (cmd >> 0x8) == TYPE_ASYNC return cls(cls.ASYNC_ACK, cmd) + class SensorEvent(object): def __init__(self, mac, timestamp, event_type, event_data): self.MAC = mac self.Timestamp = timestamp self.Type = event_type self.Data = event_data - + def __str__(self): s = "[%s][%s]" % (self.Timestamp.strftime("%Y-%m-%d %H:%M:%S"), self.MAC) - if self.Type == 'state': - s += "StateEvent: sensor_type=%s, state=%s, battery=%d, signal=%d" % self.Data + if self.Type == 'alarm': + s += "AlarmEvent: sensor_type=%s, state=%s, battery=%d, signal=%d" % self.Data + elif self.Type == 'status': + s += "StatusEvent: sensor_type=%s, state=%s, battery=%d, signal=%d" % self.Data else: s += "RawEvent: type=%s, data=%s" % (self.Type, bytes_to_hex(self.Data)) return s + class Dongle(object): _CMD_TIMEOUT = 2 @@ -249,19 +257,29 @@ def _OnSensorAlarm(self, pkt): return timestamp, event_type, sensor_mac = struct.unpack_from(">QB8s", pkt.Payload) - timestamp = datetime.datetime.fromtimestamp(timestamp/1000.0) + timestamp = datetime.datetime.fromtimestamp(timestamp / 1000.0) sensor_mac = sensor_mac.decode('ascii') alarm_data = pkt.Payload[17:] - if event_type == 0xA2: + if event_type == 0xA2 or event_type == 0xA1: if alarm_data[0] == 0x01: sensor_type = "switch" sensor_state = "open" if alarm_data[5] == 1 else "close" elif alarm_data[0] == 0x02: sensor_type = "motion" sensor_state = "active" if alarm_data[5] == 1 else "inactive" + elif alarm_data[0] == 0x03: + sensor_type = "leak" + sensor_state = "wet" if alarm_data[5] == 1 else "dry" else: - sensor_type = "uknown" + sensor_type = "unknown" sensor_state = "unknown" + e = SensorEvent(sensor_mac, timestamp, ("alarm" if event_type == 0xA2 else "status"), (sensor_type, sensor_state, alarm_data[2], alarm_data[8])) + elif event_type == 0xE8: + if alarm_data[0] == 0x03: + # alarm_data[7] might be humidity in some form, but as an integer + # is reporting way to high to actually be humidity. + sensor_type = "leak:temperature" + sensor_state = "%d.%d" % (alarm_data[5], alarm_data[6]) e = SensorEvent(sensor_mac, timestamp, "state", (sensor_type, sensor_state, alarm_data[2], alarm_data[8])) else: e = SensorEvent(sensor_mac, timestamp, "raw_%02X" % event_type, alarm_data) @@ -275,7 +293,7 @@ def _OnEventLog(self, pkt): assert len(pkt.Payload) >= 9 ts, msg_len = struct.unpack_from(">QB", pkt.Payload) # assert msg_len + 8 == len(pkt.Payload) - tm = datetime.datetime.fromtimestamp(ts/1000.0) + tm = datetime.datetime.fromtimestamp(ts / 1000.0) msg = pkt.Payload[9:] log.info("LOG: time=%s, data=%s", tm.isoformat(), bytes_to_hex(msg)) @@ -284,12 +302,12 @@ def __init__(self, device, event_handler): self.__fd = os.open(device, os.O_RDWR | os.O_NONBLOCK) self.__sensors = {} self.__exit_event = threading.Event() - self.__thread = threading.Thread(target = self._Worker) + self.__thread = threading.Thread(target=self._Worker) self.__on_event = event_handler self.__handlers = { Packet.NOITFY_SYNC_TIME: self._OnSyncTime, - Packet.NOTIFY_SENSOR_ALARM: self._OnSensorAlarm, + Packet.NOTIFY_SENSOR_ALARM: self._OnSensorAlarm, Packet.NOTIFY_EVENT_LOG: self._OnEventLog, } @@ -311,7 +329,7 @@ def _ReadRawHID(self): if length > 0x3F: length = 0x3F - #log.debug("Raw HID packet: %s", bytes_to_hex(s)) + # log.debug("Raw HID packet: %s", bytes_to_hex(s)) assert len(s) >= length + 1 return s[1: 1 + length] @@ -333,9 +351,9 @@ def _HandlePacket(self, pkt): log.debug("<=== Received: %s", str(pkt)) with self.__lock: handler = self.__handlers.get(pkt.Cmd, self._DefaultHandler) - + if (pkt.Cmd >> 8) == TYPE_ASYNC and pkt.Cmd != Packet.ASYNC_ACK: - #log.info("Sending ACK packet for cmd %04X", pkt.Cmd) + # log.info("Sending ACK packet for cmd %04X", pkt.Cmd) self._SendPacket(Packet.AsyncAck(pkt.Cmd)) handler(pkt) @@ -344,10 +362,10 @@ def _Worker(self): while True: if self.__exit_event.isSet(): break - + s += self._ReadRawHID() - #if s: - # log.info("Incoming buffer: %s", bytes_to_hex(s)) + # if s: + # log.info("Incoming buffer: %s", bytes_to_hex(s)) start = s.find(b"\x55\xAA") if start == -1: time.sleep(0.1) @@ -375,7 +393,7 @@ def _DoCommand(self, pkt, handler, timeout=_CMD_TIMEOUT): raise TimeoutError("_DoCommand") def _DoSimpleCommand(self, pkt, timeout=_CMD_TIMEOUT): - ctx = self.CmdContext(result = None) + ctx = self.CmdContext(result=None) def cmd_handler(pkt, e): ctx.result = pkt @@ -397,7 +415,7 @@ def _Inquiry(self): def _GetEnr(self, r): log.debug("Start GetEnr...") assert len(r) == 4 - assert all(isinstance(x, int) for x in r) + assert all(isinstance(x, int) for x in r) r_string = bytes(struct.pack("