diff --git a/readme.md b/readme.md index daead7e..0fa8ccc 100644 --- a/readme.md +++ b/readme.md @@ -123,7 +123,8 @@ sudo systemctl start wyzesense2mqtt sudo systemctl status wyzesense2mqtt sudo systemctl enable wyzesense2mqtt # Enable start on reboot ``` -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. +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 and the +class will default to opening, I.E. a contact sensor. ## Config Files @@ -179,16 +180,22 @@ root: ``` ### sensors.yaml -This file will store basic information about each sensor paired to the Wyse Sense Bridge. The entries can be modified to set the class type and sensor name as it will show in Home Assistant. Class types can be automatically filled for `opening`, `motion`, and `moisture`, depending on the type of sensor. Since this file can be automatically generated, Python may automatically quote the MACs or not depending on if they are fully numeric. +This file will store basic information about each sensor paired to the Wyse Sense Bridge. The entries can be modified to set the class type and sensor name as it will show in Home Assistant. Class types can be automatically filled for `opening`, `motion`, and `moisture`, +depending on the type of sensor. Since this file can be automatically generated, Python may automatically quote the MACs or not depending on if they are fully numeric. Sensors that were previously linked and automatically added will default to class `opening` and will not +have a "sw_version" set. For the original version 1 devices, the sw_version should be 19. For the newer version 2 devices, the sw_version should be 23. This will be automatically have the correct settings for devices added via a scan. A custom timeout for device +availability can also be added per device by setting the "timeout" setting, in seconds. For version 1 devices, the default timeout is 8 hours and for version 2 device, the default timeout is 4 hours. ```yaml 'AAAAAAAA': class: door name: Entry Door invert_state: false + sw_version: 19 'BBBBBBBB': class: window name: Office Window invert_state: false + sw_version: 23 + timeout: 7200 'CCCCCCCC': class: opening name: Kitchen Fridge diff --git a/wyzesense2mqtt/samples/logging.yaml b/wyzesense2mqtt/samples/logging.yaml index 2a2ef28..089417e 100644 --- a/wyzesense2mqtt/samples/logging.yaml +++ b/wyzesense2mqtt/samples/logging.yaml @@ -9,7 +9,7 @@ handlers: console: class: logging.StreamHandler formatter: simple - level: DEBUG + level: INFO file: backupCount: 7 class: logging.handlers.TimedRotatingFileHandler @@ -22,4 +22,4 @@ root: handlers: - file - console - level: DEBUG + level: INFO diff --git a/wyzesense2mqtt/wyzesense.py b/wyzesense2mqtt/wyzesense.py index 1281cdc..e964918 100755 --- a/wyzesense2mqtt/wyzesense.py +++ b/wyzesense2mqtt/wyzesense.py @@ -9,7 +9,6 @@ import binascii import logging -log = logging.getLogger(__name__) def bytes_to_hex(s): @@ -26,6 +25,11 @@ def checksum_from_bytes(s): TYPE_SYNC = 0x43 TYPE_ASYNC = 0x53 +# {sensor_id: "sensor type", "states": ["off state", "on state"]} +CONTACT_IDS = {0x01: "switch", 0x0E: "switchv2", "states": ["close", "open"]} +MOTION_IDS = {0x02: "motion", 0x0F: "motionv2", "states": ["inactive", "active"]} +LEAK_IDS = {0x03: "leak", "states": ["dry", "wet"]} + def MAKE_CMD(type, cmd): return (type << 8) | cmd @@ -104,7 +108,7 @@ def Send(self, fd): checksum = checksum_from_bytes(pkt) pkt += struct.pack(">H", checksum) - log.debug("Sending: %s", bytes_to_hex(pkt)) + LOGGER.debug("Sending: %s", bytes_to_hex(pkt)) ss = os.write(fd, pkt) assert ss == len(pkt) @@ -113,14 +117,15 @@ def Parse(cls, s): assert isinstance(s, bytes) if len(s) < 5: - log.error("Invalid packet: %s", bytes_to_hex(s)) - log.error("Invalid packet length: %d", len(s)) - return None + LOGGER.error("Invalid packet: %s", bytes_to_hex(s)) + LOGGER.error("Invalid packet length: %d", len(s)) + # This error can be corrected by waiting for additional data, throw an exception we can catch to handle differently + raise EOFError magic, cmd_type, b2, cmd_id = struct.unpack_from(">HBBB", s) if magic != 0x55AA and magic != 0xAA55: - log.error("Invalid packet: %s", bytes_to_hex(s)) - log.error("Invalid packet magic: %4X", magic) + LOGGER.error("Invalid packet: %s", bytes_to_hex(s)) + LOGGER.error("Invalid packet magic: %4X", magic) return None cmd = MAKE_CMD(cmd_type, cmd_id) @@ -132,14 +137,16 @@ def Parse(cls, s): s = s[: b2 + 4] payload = s[5:-2] else: - log.error("Invalid packet: %s", bytes_to_hex(s)) - return None + LOGGER.error("Invalid packet: %s", bytes_to_hex(s)) + LOGGER.error("Short packet: expected %d, got %d", (b2 + 4), len(s)) + # This error can be corrected by waiting for additional data, throw an exception we can catch to handle differently + raise EOFError cs_remote = (s[-2] << 8) | s[-1] cs_local = checksum_from_bytes(s[:-2]) if cs_remote != cs_local: - log.error("Invalid packet: %s", bytes_to_hex(s)) - log.error("Mismatched checksum, remote=%04X, local=%04X", cs_remote, cs_local) + LOGGER.error("Invalid packet: %s", bytes_to_hex(s)) + LOGGER.error("Mismatched checksum, remote=%04X, local=%04X", cs_remote, cs_local) return None return cls(cmd, payload) @@ -233,14 +240,12 @@ def __init__(self, mac, timestamp, event_type, event_data): self.Data = event_data def __str__(self): - s = "[%s][%s]" % (self.Timestamp.strftime("%Y-%m-%d %H:%M:%S"), self.MAC) if self.Type == 'alarm': - s += "AlarmEvent: sensor_type=%s, state=%s, battery=%d, signal=%d" % self.Data + return f"AlarmEvent [{self.MAC}]: time={self.Timestamp.strftime('%Y-%m-%d %H:%M:%S')}, sensor_type={self.Data[0]}, state={self.Data[1]}, battery={self.Data[2]}, signal={self.Data[3]}" elif self.Type == 'status': - s += "StatusEvent: sensor_type=%s, state=%s, battery=%d, signal=%d" % self.Data + return f"StatusEvent [{self.MAC}]: time={self.Timestamp.strftime('%Y-%m-%d %H:%M:%S')}, sensor_type={self.Data[0]}, state={self.Data[1]}, battery={self.Data[2]}, signal={self.Data[3]}" else: - s += "RawEvent: type=%s, data=%s" % (self.Type, bytes_to_hex(self.Data)) - return s + return f"RawEvent [{self.MAC}]: time={self.Timestamp.strftime('%Y-%m-%d %H:%M:%S')}, event_type={self.Type}, data={bytes_to_hex(self.Data)}" class Dongle(object): @@ -252,45 +257,42 @@ def __init__(self, **kwargs): setattr(self, key, kwargs[key]) def _OnSensorAlarm(self, pkt): + global CONTACT_IDS, MOTION_IDS, LEAK_IDS + if len(pkt.Payload) < 18: - log.info("Unknown alarm packet: %s", bytes_to_hex(pkt.Payload)) + LOGGER.info("Unknown alarm packet: %s", bytes_to_hex(pkt.Payload)) return - timestamp, event_type, sensor_mac = struct.unpack_from(">QB8s", pkt.Payload) + timestamp, event, mac = struct.unpack_from(">QB8s", pkt.Payload) + data = pkt.Payload[17:] timestamp = datetime.datetime.fromtimestamp(timestamp / 1000.0) - sensor_mac = sensor_mac.decode('ascii') - alarm_data = pkt.Payload[17:] + mac = mac.decode('ascii') - # {sensor_id: "sensor type", "states": ["off state", "on state"]} - contact_ids = {0x01: "switch", 0x0E: "switchv2", "states": ["close", "open"]} - motion_ids = {0x02: "motion", 0x0F: "motionv2", "states": ["inactive", "active"]} - leak_ids = {0x03: "leak", "states": ["dry", "wet"]} - - if event_type == 0xA2 or event_type == 0xA1: + if event == 0xA2 or event == 0xA1: + type, b1, battery, b2, state1, state2, counter, signal = struct.unpack_from(">BBBBBBHB", data) sensor = {} - if alarm_data[0] in contact_ids: - sensor = contact_ids - elif alarm_data[0] in motion_ids: - sensor = motion_ids - elif alarm_data[0] in leak_ids: - sensor = leak_ids - + if type in CONTACT_IDS: + sensor = CONTACT_IDS + elif type in MOTION_IDS: + sensor = MOTION_IDS + elif type in LEAK_IDS: + sensor = LEAK_IDS + if sensor: - sensor_type = sensor[alarm_data[0]] - sensor_state = sensor["states"][alarm_data[5]] + sensor_type = sensor[type] + sensor_state = sensor["states"][state2] else: - sensor_type = "unknown (" + alarm_data[0] + ")" - sensor_state = "unknown (" + alarm_data[5] + ")" - 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 = "unknown (" + type + ")" + sensor_state = "unknown (" + state2 + ")" + e = SensorEvent(mac, timestamp, ("alarm" if event == 0xA2 else "status"), (sensor_type, sensor_state, battery, signal)) + elif event == 0xE8: + type, b1, battery, b2, state1, state2, counter, signal = struct.unpack_from(">BBBBBBHB", data) + if type == 0x03: 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])) + sensor_state = "%d.%d" % (state1, state2) + e = SensorEvent(mac, timestamp, "state", (sensor_type, sensor_state, battery, signal)) else: - e = SensorEvent(sensor_mac, timestamp, "raw_%02X" % event_type, alarm_data) + e = SensorEvent(mac, timestamp, "%02X" % event, data) self.__on_event(self, e) @@ -298,12 +300,18 @@ def _OnSyncTime(self, pkt): self._SendPacket(Packet.SyncTimeAck()) def _OnEventLog(self, pkt): +# global CONTACT_IDS, MOTION_IDS, LEAK_IDS + 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) msg = pkt.Payload[9:] - log.info("LOG: time=%s, data=%s", tm.isoformat(), bytes_to_hex(msg)) + LOGGER.info("LOG: time=%s, data=%s", tm.isoformat(), bytes_to_hex(msg)) + # Check if we have a message after, length includes the msglen byte +# if ((len(msg) + 1) >= msg_len and msg_len >= 13): +# event, mac, type, state, counter = struct.unpack(">B8sBBH", msg) +# TODO: What can we do with this? At the very least, we can update the last seen time for the sensor +# and it appears that the log message happens before every alarm message, so doesn't really gain much of anything def __init__(self, device, event_handler): self.__lock = threading.Lock() @@ -328,7 +336,7 @@ def _ReadRawHID(self): return b"" if not s: - log.info("Nothing read") + LOGGER.info("Nothing read") return b"" s = bytes(s) @@ -337,7 +345,7 @@ def _ReadRawHID(self): if length > 0x3F: length = 0x3F - # log.debug("Raw HID packet: %s", bytes_to_hex(s)) + # LOGGER.debug("Raw HID packet: %s", bytes_to_hex(s)) assert len(s) >= length + 1 return s[1: 1 + length] @@ -349,19 +357,19 @@ def _SetHandler(self, cmd, handler): return oldHandler def _SendPacket(self, pkt): - log.debug("===> Sending: %s", str(pkt)) + LOGGER.debug("===> Sending: %s", str(pkt)) pkt.Send(self.__fd) def _DefaultHandler(self, pkt): pass def _HandlePacket(self, pkt): - log.debug("<=== Received: %s", str(pkt)) + LOGGER.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) + LOGGER.debug("Sending ACK packet for cmd %04X", pkt.Cmd) self._SendPacket(Packet.AsyncAck(pkt.Cmd)) handler(pkt) @@ -373,20 +381,29 @@ def _Worker(self): s += self._ReadRawHID() # if s: - # log.info("Incoming buffer: %s", bytes_to_hex(s)) + # LOGGER.info("Incoming buffer: %s", bytes_to_hex(s)) + start = s.find(b"\x55\xAA") if start == -1: time.sleep(0.1) continue s = s[start:] - log.debug("Trying to parse: %s", bytes_to_hex(s)) - pkt = Packet.Parse(s) - if not pkt: - s = s[2:] + LOGGER.debug("Trying to parse: %s", bytes_to_hex(s)) + try: + pkt = Packet.Parse(s) + if not pkt: + # Packet was invalid and couldn't be processed, remove the magic bytes and continue + # looking for another start of message. This essentially tosses the bad message. + s = s[2:] + time.sleep(0.1) + continue + except EOFError: + # Not enough data to parse a packet, keep the partial packet for now + time.sleep(0.1) continue - log.debug("Received: %s", bytes_to_hex(s[:pkt.Length])) + LOGGER.debug("Received: %s", bytes_to_hex(s[:pkt.Length])) s = s[pkt.Length:] self._HandlePacket(pkt) @@ -411,69 +428,69 @@ def cmd_handler(pkt, e): return ctx.result def _Inquiry(self): - log.debug("Start Inquiry...") + LOGGER.debug("Start Inquiry...") resp = self._DoSimpleCommand(Packet.Inquiry()) assert len(resp.Payload) == 1 result = resp.Payload[0] - log.debug("Inquiry returns %d", result) + LOGGER.debug("Inquiry returns %d", result) assert result == 1, "Inquiry failed, result=%d" % result def _GetEnr(self, r): - log.debug("Start GetEnr...") + LOGGER.debug("Start GetEnr...") assert len(r) == 4 assert all(isinstance(x, int) for x in r) r_string = bytes(struct.pack(" 0: - log.debug("%d sensors reported, waiting for each one to report...", count) + LOGGER.info("%d sensors reported, waiting for each one to report...", count) def cmd_handler(pkt, e): assert len(pkt.Payload) == 8 mac = pkt.Payload.decode('ascii') - log.debug("Sensor %d/%d, MAC:%s", ctx.index + 1, ctx.count, mac) + LOGGER.info("Sensor %d/%d, MAC:%s", ctx.index + 1, ctx.count, mac) ctx.sensors.append(mac) ctx.index += 1 if ctx.index == ctx.count: e.set() - self._DoCommand(Packet.GetSensorList(count), cmd_handler, timeout=self._CMD_TIMEOUT * count) + self._DoCommand(Packet.GetSensorList(count), cmd_handler, timeout=self._CMD_TIMEOUT) else: - log.debug("No sensors bond yet...") + LOGGER.info("No sensors bond yet...") return ctx.sensors def _FinishAuth(self): @@ -510,10 +527,10 @@ def _Start(self): self.ENR = self._GetEnr([0x30303030] * 4) self.MAC = self._GetMac() - log.debug("Dongle MAC is [%s]", self.MAC) + LOGGER.info("Dongle MAC is [%s]", self.MAC) self.Version = self._GetVersion() - log.debug("Dongle version: %s", self.Version) + LOGGER.info("Dongle version: %s", self.Version) self._FinishAuth() except: @@ -522,19 +539,16 @@ def _Start(self): def List(self): sensors = self._GetSensors() - for x in sensors: - log.debug("Sensor found: %s", x) - return sensors def Stop(self, timeout=_CMD_TIMEOUT): self.__exit_event.set() + self.__thread.join(timeout) os.close(self.__fd) self.__fd = None - self.__thread.join(timeout) def Scan(self, timeout=60): - log.debug("Start Scan...") + LOGGER.info("Start Scan...") ctx = self.CmdContext(evt=threading.Event(), result=None) @@ -549,11 +563,11 @@ def scan_handler(pkt): if ctx.evt.wait(timeout): s_mac, s_type, s_ver = ctx.result - log.debug("Sensor found: mac=[%s], type=%d, version=%d", s_mac, s_type, s_ver) + LOGGER.info("Sensor found: mac=[%s], type=%d, version=%d", s_mac, s_type, s_ver) r1 = self._GetSensorR1(s_mac, b'Ok5HPNQ4lf77u754') - log.debug("Sensor R1: %r", bytes_to_hex(r1)) + LOGGER.debug("Sensor R1: %r", bytes_to_hex(r1)) else: - log.debug("Sensor discovery timeout...") + LOGGER.info("Sensor discovery timeout...") self._DoSimpleCommand(Packet.DisableScan()) finally: @@ -565,14 +579,19 @@ def scan_handler(pkt): def Delete(self, mac): resp = self._DoSimpleCommand(Packet.DelSensor(str(mac))) - log.debug("CmdDelSensor returns %s", bytes_to_hex(resp.Payload)) + LOGGER.debug("CmdDelSensor returns %s", bytes_to_hex(resp.Payload)) assert len(resp.Payload) == 9 ack_mac = resp.Payload[:8].decode('ascii') ack_code = resp.Payload[8] assert ack_code == 0xFF, "CmdDelSensor: Unexpected ACK code: 0x%02X" % ack_code assert ack_mac == mac, "CmdDelSensor: MAC mismatch, requested:%s, returned:%s" % (mac, ack_mac) - log.debug("CmdDelSensor: %s deleted", mac) + LOGGER.info("CmdDelSensor: %s deleted", mac) -def Open(device, event_handler): +def Open(device, event_handler, logger): + global LOGGER + if logger is not None: + LOGGER = logger + else: + LOGGER = logging.getLogger(__name__) return Dongle(device, event_handler) diff --git a/wyzesense2mqtt/wyzesense2mqtt.py b/wyzesense2mqtt/wyzesense2mqtt.py index 9676331..54fe3c6 100755 --- a/wyzesense2mqtt/wyzesense2mqtt.py +++ b/wyzesense2mqtt/wyzesense2mqtt.py @@ -9,10 +9,7 @@ import shutil import subprocess import yaml - -# Used for alternate MQTT connection method -# import signal -# import time +import time import paho.mqtt.client as mqtt import wyzesense @@ -25,6 +22,8 @@ MAIN_CONFIG_FILE = "config.yaml" LOGGING_CONFIG_FILE = "logging.yaml" SENSORS_CONFIG_FILE = "sensors.yaml" +SENSORS_STATE_FILE = "state.yaml" + # Simplify mapping of device classes. # { **dict.fromkeys(['list', 'of', 'possible', 'identifiers'], 'device_class') } @@ -34,6 +33,33 @@ **dict.fromkeys([0x03, 'leak'], 'moisture') } + +# List of states that correlate to ON. +STATES_ON = ['active', 'open', 'wet'] + +# Oldest state data that is considered fresh, older state data is stale and ignored +# 1 hour, converted to seconds +STALE_STATE = 1*60*60 + +# Keep persistant data about the sensors that isn't configurable in a seperate state variable +# Read/write this file to try and maintain a consistent state +SENSORS_STATE = {} + + +# V1 sensors send state every 4 hours, V2 sensors send every 2 hours +# For timeout of availability, use 2 times the report period to allow +# for one missed message. If 2 are missed, then the sensor probably is +# offline. +DEFAULT_V1_TIMEOUT_HOURS = 8 +DEFAULT_V2_TIMEOUT_HOURS = 4 + + +# List of sw versions for V1 and V2 sensors, to determine which timeout to use by default +V1_SW=[19] +V2_SW=[23] + +INITIALIZED = False + # Read data from YAML file def read_yaml_file(filename): try: @@ -78,13 +104,13 @@ def init_logging(): print("Unable to create log folder") logging.config.dictConfig(logging_config) LOGGER = logging.getLogger("wyzesense2mqtt") - LOGGER.debug("Logging initialized...") + LOGGER.info("Logging initialized...") # Initialize configuration def init_config(): global CONFIG - LOGGER.debug("Initializing configuration...") + LOGGER.info("Initializing configuration...") # load base config - allows for auto addition of new settings if (os.path.isfile(os.path.join(SAMPLES_PATH, MAIN_CONFIG_FILE))): @@ -110,7 +136,7 @@ def init_config(): def init_mqtt_client(): global MQTT_CLIENT, CONFIG, LOGGER # Used for alternate MQTT connection method - # mqtt.Client.connected_flag = False + mqtt.Client.connected_flag = False # Configure MQTT Client MQTT_CLIENT = mqtt.Client(client_id=CONFIG['mqtt_client_id'], clean_session=CONFIG['mqtt_clean_session']) @@ -126,10 +152,11 @@ def init_mqtt_client(): MQTT_CLIENT.connect_async(CONFIG['mqtt_host'], port=CONFIG['mqtt_port'], keepalive=CONFIG['mqtt_keepalive']) # Used for alternate MQTT connection method - # MQTT_CLIENT.loop_start() - # while (not MQTT_CLIENT.connected_flag): - # time.sleep(1) + MQTT_CLIENT.loop_start() + while (not MQTT_CLIENT.connected_flag): + time.sleep(1) + mqtt_publish(f"{CONFIG['self_topic_root']}/status", "online", is_json=False) # Retry forever on IO Error def retry_if_io_error(exception): @@ -139,11 +166,9 @@ def retry_if_io_error(exception): # Initialize USB dongle @retry(wait_exponential_multiplier=1000, wait_exponential_max=30000, retry_on_exception=retry_if_io_error) def init_wyzesense_dongle(): - global WYZESENSE_DONGLE, CONFIG + global WYZESENSE_DONGLE, CONFIG, LOGGER if (CONFIG['usb_dongle'].lower() == "auto"): - device_list = subprocess.check_output( - ["ls", "-la", "/sys/class/hidraw"] - ).decode("utf-8").lower() + device_list = subprocess.check_output(["ls", "-la", "/sys/class/hidraw"]).decode("utf-8").lower() for line in device_list.split("\n"): if (("e024" in line) and ("1a86" in line)): for device_name in line.split(" "): @@ -153,28 +178,28 @@ def init_wyzesense_dongle(): LOGGER.info(f"Connecting to dongle {CONFIG['usb_dongle']}") try: - WYZESENSE_DONGLE = wyzesense.Open(CONFIG['usb_dongle'], on_event) - LOGGER.debug(f"Dongle {CONFIG['usb_dongle']}: [" - f" MAC: {WYZESENSE_DONGLE.MAC}," - f" VER: {WYZESENSE_DONGLE.Version}," - f" ENR: {WYZESENSE_DONGLE.ENR}]") + WYZESENSE_DONGLE = wyzesense.Open(CONFIG['usb_dongle'], on_event, LOGGER) + LOGGER.info(f"Dongle {CONFIG['usb_dongle']}: [" + f" MAC: {WYZESENSE_DONGLE.MAC}," + f" VER: {WYZESENSE_DONGLE.Version}," + f" ENR: {WYZESENSE_DONGLE.ENR}]") except IOError as error: LOGGER.warning(f"No device found on path {CONFIG['usb_dongle']}: {str(error)}") # Initialize sensor configuration -def init_sensors(): +def init_sensors(wait=True): # Initialize sensor dictionary - global SENSORS + global SENSORS, SENSORS_STATE SENSORS = {} # Load config file - LOGGER.debug("Reading sensors configuration...") if (os.path.isfile(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE))): + LOGGER.info("Reading sensors configuration...") SENSORS = read_yaml_file(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE)) sensors_config_file_found = True else: - LOGGER.info("No sensors config file found.") + LOGGER.warning("No sensors config file found.") sensors_config_file_found = False # Add invert_state value if missing @@ -182,20 +207,68 @@ def init_sensors(): if (SENSORS[sensor_mac].get('invert_state') is None): SENSORS[sensor_mac]['invert_state'] = False + # Load previous known states + if (os.path.isfile(os.path.join(CONFIG_PATH, SENSORS_STATE_FILE))): + LOGGER.info("Reading sensors last known state...") + SENSORS_STATE = read_yaml_file(os.path.join(CONFIG_PATH, SENSORS_STATE_FILE)) + if (SENSORS_STATE.get('modified') is not None and (time.time() - SENSORS_STATE['modified']) > STALE_STATE): + LOGGER.warning("Ignoring stale state data") + SENSORS_STATE = {} + # Check config against linked sensors + checked_linked = False try: + LOGGER.info("Checking sensors against dongle list...") result = WYZESENSE_DONGLE.List() - LOGGER.debug(f"Linked sensors: {result}") if (result): + checked_linked = True + for sensor_mac in result: if (valid_sensor_mac(sensor_mac)): if (SENSORS.get(sensor_mac) is None): add_sensor_to_config(sensor_mac, None, None) + LOGGER.warning(f"Linked sensor with mac {event.MAC} automatically added to sensors configuration") + LOGGER.warning(f"Please update sensor configuration file {os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE)} restart the service/reload the sensors") + + # If not a configured sensor, then adding it will also add it to the state + # So only check if in the state if it is a configured sensor + elif (SENSORS_STATE.get(sensor_mac) is None): + # Only track state for linked sensors + # If it wasn't configured, it'd get added above, including in state + # Intialize last seen time to now and start online + SENSORS_STATE[sensor_mac] = { + 'last_seen': time.time(), + 'online': True + } + + # We could save sensor state for sensors that aren't linked to the dongle if we fail + # to check, then add a configured sensor to the state which gets written on stop. The + # Next run it'll add the bad state mac. So to help with that, when we do check the + # linked sensors, we should also remove anything in the state that wasn't linked + delete = [sensor_mac for sensor_mac in SENSORS_STATE if sensor_mac not in result] + for sensor_mac in delete: + del SENSORS_STATE[sensor_mac] + LOGGER.info(f"Removed unlinked sensor ({sensor_mac}) from state") + else: LOGGER.warning(f"Sensor list failed with result: {result}") + except TimeoutError: + LOGGER.error("Dongle list timeout") pass + if not checked_linked: + # Unable to get linked sensors + # Make sure all configured sensors have a state + for sensor_mac in SENSORS: + if (SENSORS_STATE.get(sensor_mac) is None): + # Intialize last seen time to now and start online + SENSORS_STATE[sensor_mac] = { + 'last_seen': time.time(), + 'online': True + } + + # Save sensors file if didn't exist if (not sensors_config_file_found): LOGGER.info("Writing Sensors Config File") @@ -203,14 +276,13 @@ def init_sensors(): # Send discovery topics if(CONFIG['hass_discovery']): - for sensor_mac in SENSORS: + for sensor_mac in SENSORS_STATE: if (valid_sensor_mac(sensor_mac)): - send_discovery_topics(sensor_mac) + send_discovery_topics(sensor_mac, wait=wait) # Validate sensor MAC def valid_sensor_mac(sensor_mac): - #LOGGER.debug(f"Validating MAC: {sensor_mac}") invalid_mac_list = [ "00000000", "\0\0\0\0\0\0\0\0", @@ -230,46 +302,59 @@ def valid_sensor_mac(sensor_mac): # Add sensor to config def add_sensor_to_config(sensor_mac, sensor_type, sensor_version): - global SENSORS + global SENSORS, SENSORS_STATE LOGGER.info(f"Adding sensor to config: {sensor_mac}") SENSORS[sensor_mac] = { 'name': f"Wyze Sense {sensor_mac}", - 'class': DEVICE_CLASSES.get(sensor_type), 'invert_state': False } + + SENSORS[sensor_mac]['class'] = "opening" if sensor_type is None else DEVICE_CLASSES.get(sensor_type) + if (sensor_version is not None): SENSORS[sensor_mac]['sw_version'] = sensor_version + # Intialize last seen time to now and start online + SENSORS_STATE[sensor_mac] = { + 'last_seen': time.time(), + 'online': True + } + write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE), SENSORS) # Delete sensor from config def delete_sensor_from_config(sensor_mac): - global SENSORS + global SENSORS, SENSORS_STATE LOGGER.info(f"Deleting sensor from config: {sensor_mac}") try: del SENSORS[sensor_mac] write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE), SENSORS) + del SENSORS_STATE[sensor_mac] except KeyError: LOGGER.debug(f"{sensor_mac} not found in SENSORS") # Publish MQTT topic -def mqtt_publish(mqtt_topic, mqtt_payload): +def mqtt_publish(mqtt_topic, mqtt_payload, is_json=True, wait=True): global MQTT_CLIENT, CONFIG mqtt_message_info = MQTT_CLIENT.publish( mqtt_topic, - payload=json.dumps(mqtt_payload), + payload=(json.dumps(mqtt_payload) if is_json else mqtt_payload), qos=CONFIG['mqtt_qos'], retain=CONFIG['mqtt_retain'] ) - if (mqtt_message_info.rc != mqtt.MQTT_ERR_SUCCESS): - LOGGER.warning(f"MQTT publish error: {mqtt.error_string(mqtt_message_info.rc)}") + if (mqtt_message_info.rc == mqtt.MQTT_ERR_SUCCESS): + if (wait): + mqtt_message_info.wait_for_publish(2) + return + + LOGGER.warning(f"MQTT publish error: {mqtt.error_string(mqtt_message_info.rc)}") # Send discovery topics -def send_discovery_topics(sensor_mac): - global SENSORS, CONFIG +def send_discovery_topics(sensor_mac, wait=True): + global SENSORS, CONFIG, SENSORS_STATE LOGGER.info(f"Publishing discovery topics for {sensor_mac}") @@ -288,48 +373,63 @@ def send_discovery_topics(sensor_mac): else "Sense Contact Sensor" ), 'name': sensor_name, - 'sw_version': sensor_version + 'sw_version': sensor_version, + 'via_device': "wyzesense2mqtt" } + mac_topic = f"{CONFIG['self_topic_root']}/{sensor_mac}" + entity_payloads = { 'state': { 'name': sensor_name, - 'dev_cla': sensor_class, - 'pl_on': "1", - 'pl_off': "0", - 'json_attr_t': f"{CONFIG['self_topic_root']}/{sensor_mac}" + 'device_class': sensor_class, + 'payload_on': "1", + 'payload_off': "0", + 'json_attributes_topic': mac_topic }, 'signal_strength': { 'name': f"{sensor_name} Signal Strength", - 'dev_cla': "signal_strength", - 'unit_of_meas': "dBm" + 'device_class': "signal_strength", + 'state_class': "measurement", + 'unit_of_measurement': "dBm", + 'entity_category': "diagnostic" }, 'battery': { 'name': f"{sensor_name} Battery", - 'dev_cla': "battery", - 'unit_of_meas': "%" + 'device_class': "battery", + 'state_class': "measurement", + 'unit_of_measurement': "%", + 'entity_category': "diagnostic" } } + availability_topics = [ + { 'topic': f"{CONFIG['self_topic_root']}/{sensor_mac}/status" }, + { 'topic': f"{CONFIG['self_topic_root']}/status" } + ] + for entity, entity_payload in entity_payloads.items(): - entity_payload['val_tpl'] = f"{{{{ value_json.{entity} }}}}" - entity_payload['uniq_id'] = f"wyzesense_{sensor_mac}_{entity}" - entity_payload['stat_t'] = f"{CONFIG['self_topic_root']}/{sensor_mac}" - entity_payload['dev'] = device_payload - sensor_type = ("binary_sensor" if (entity == "state") else "sensor") + entity_payload['value_template'] = f"{{{{ value_json.{entity} }}}}" + entity_payload['unique_id'] = f"wyzesense_{sensor_mac}_{entity}" + entity_payload['state_topic'] = mac_topic + entity_payload['availability'] = availability_topics + entity_payload['availability_mode'] = "all" + entity_payload['platform'] = "mqtt" + entity_payload['device'] = device_payload - entity_topic = f"{CONFIG['hass_topic_root']}/{sensor_type}/wyzesense_{sensor_mac}/{entity}/config" - mqtt_publish(entity_topic, entity_payload) - LOGGER.debug(f" {entity_topic}") - LOGGER.debug(f" {json.dumps(entity_payload)}") + entity_topic = f"{CONFIG['hass_topic_root']}/{'binary_sensor' if (entity == 'state') else 'sensor'}/wyzesense_{sensor_mac}/{entity}/config" + mqtt_publish(entity_topic, entity_payload, wait=wait) + LOGGER.info(f" {entity_topic}") + LOGGER.info(f" {json.dumps(entity_payload)}") + mqtt_publish(f"{CONFIG['self_topic_root']}/{sensor_mac}/status", "online" if SENSORS_STATE[sensor_mac]['online'] else "offline", is_json=False, wait=wait) # Clear any retained topics in MQTT -def clear_topics(sensor_mac): +def clear_topics(sensor_mac, wait=True): global CONFIG LOGGER.info("Clearing sensor topics") - state_topic = f"{CONFIG['self_topic_root']}/{sensor_mac}" - mqtt_publish(state_topic, None) + mqtt_publish(f"{CONFIG['self_topic_root']}/{sensor_mac}/status", None, wait=wait) + mqtt_publish(f"{CONFIG['self_topic_root']}/{sensor_mac}", None, wait=wait) # clear discovery topics if configured if(CONFIG['hass_discovery']): @@ -339,8 +439,9 @@ def clear_topics(sensor_mac): "binary_sensor" if (entity_type == "state") else "sensor" ) - entity_topic = f"{CONFIG['hass_topic_root']}/{sensor_type}/wyzesense_{sensor_mac}/{entity_type}/config" - mqtt_publish(entity_topic, None) + mqtt_publish(f"{CONFIG['hass_topic_root']}/{sensor_type}/wyzesense_{sensor_mac}/{entity_type}/config", None, wait=wait) + mqtt_publish(f"{CONFIG['hass_topic_root']}/{sensor_type}/wyzesense_{sensor_mac}/{entity_type}", None, wait=wait) + mqtt_publish(f"{CONFIG['hass_topic_root']}/{sensor_type}/wyzesense_{sensor_mac}", None, wait=wait) def on_connect(MQTT_CLIENT, userdata, flags, rc): @@ -354,8 +455,7 @@ def on_connect(MQTT_CLIENT, userdata, flags, rc): MQTT_CLIENT.message_callback_add(SCAN_TOPIC, on_message_scan) MQTT_CLIENT.message_callback_add(REMOVE_TOPIC, on_message_remove) MQTT_CLIENT.message_callback_add(RELOAD_TOPIC, on_message_reload) - # Used for alternate MQTT connection method - # MQTT_CLIENT.connected_flag = True + MQTT_CLIENT.connected_flag = True LOGGER.info(f"Connected to MQTT: {mqtt.error_string(rc)}") else: LOGGER.warning(f"Connection to MQTT failed: {mqtt.error_string(rc)}") @@ -365,42 +465,41 @@ def on_disconnect(MQTT_CLIENT, userdata, rc): MQTT_CLIENT.message_callback_remove(SCAN_TOPIC) MQTT_CLIENT.message_callback_remove(REMOVE_TOPIC) MQTT_CLIENT.message_callback_remove(RELOAD_TOPIC) - # Used for alternate MQTT connection method - # MQTT_CLIENT.connected_flag = False + MQTT_CLIENT.connected_flag = False LOGGER.info(f"Disconnected from MQTT: {mqtt.error_string(rc)}") -# Process messages +# We don't handle any additional messages from MQTT, just log them def on_message(MQTT_CLIENT, userdata, msg): LOGGER.info(f"{msg.topic}: {str(msg.payload)}") # Process message to scan for new sensors def on_message_scan(MQTT_CLIENT, userdata, msg): - global SENSORS + global SENSORS, CONFIG LOGGER.info(f"In on_message_scan: {msg.payload.decode()}") + # The scan will do a couple additional calls even after the new sensor is found + # These calls may time out, so catch it early so we can still add the sensor properly try: result = WYZESENSE_DONGLE.Scan() - LOGGER.debug(f"Scan result: {result}") - if (result): - sensor_mac, sensor_type, sensor_version = result - if (valid_sensor_mac(sensor_mac)): - if (SENSORS.get(sensor_mac)) is None: - add_sensor_to_config( - sensor_mac, - sensor_type, - sensor_version - ) - if(CONFIG['hass_discovery']): - send_discovery_topics(sensor_mac) - else: - LOGGER.debug(f"Invalid sensor found: {sensor_mac}") - else: - LOGGER.debug("No new sensor found") except TimeoutError: pass + LOGGER.info(f"Scan result: {result}") + if (result): + sensor_mac, sensor_type, sensor_version = result + if (valid_sensor_mac(sensor_mac)): + if (SENSORS.get(sensor_mac)) is None: + add_sensor_to_config(sensor_mac, sensor_type, sensor_version) + if(CONFIG['hass_discovery']): + # We are in a mqtt callback, so can not wait for new messages to publish + send_discovery_topics(sensor_mac, wait=False) + else: + LOGGER.info(f"Invalid sensor found: {sensor_mac}") + else: + LOGGER.info("No new sensor found") + # Process message to remove sensor def on_message_remove(MQTT_CLIENT, userdata, msg): @@ -408,78 +507,118 @@ def on_message_remove(MQTT_CLIENT, userdata, msg): sensor_mac = msg.payload.decode() if (valid_sensor_mac(sensor_mac)): + # Deleting from the dongle may timeout, but we still need to do + # the rest so catch it early try: WYZESENSE_DONGLE.Delete(sensor_mac) - clear_topics(sensor_mac) - delete_sensor_from_config(sensor_mac) except TimeoutError: pass + # We are in a mqtt callback so cannot wait for new messages to publish + clear_topics(sensor_mac, wait=False) + delete_sensor_from_config(sensor_mac) else: - LOGGER.debug(f"Invalid mac address: {sensor_mac}") + LOGGER.info(f"Invalid mac address: {sensor_mac}") # Process message to reload sensors def on_message_reload(MQTT_CLIENT, userdata, msg): LOGGER.info(f"In on_message_reload: {msg.payload.decode()}") - init_sensors() + + # Save off the last known state so we don't overwrite new state by re-reading the previously saved file + LOGGER.info("Writing Sensors State File") + write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_STATE_FILE), SENSORS_STATE) + + # We are in a mqtt callback so cannot wait for new messages to publish + init_sensors(wait=False) # Process event def on_event(WYZESENSE_DONGLE, event): - global SENSORS + global SENSORS, SENSORS_STATE - # List of states that correlate to ON. - STATES_ON = ['active', 'open', 'wet'] + if not INITIALIZED: + return if (valid_sensor_mac(event.MAC)): + if (event.MAC not in SENSORS): + add_sensor_to_config(sensor_mac, None, None) + if(CONFIG['hass_discovery']): + send_discovery_topics(sensor_mac) + LOGGER.warning(f"Linked sensor with mac {event.MAC} automatically added to sensors configuration") + LOGGER.warning(f"Please update sensor configuration file {os.path.join(CONFIG_PATH, SENSORS_CONFIG_FILE)} restart the service/reload the sensors") + + # Store last seen time for availability + SENSORS_STATE[event.MAC]['last_seen'] = event.Timestamp.timestamp() + + # Set back online if it was offline + if not SENSORS_STATE[event.MAC]['online']: + mqtt_publish(f"{CONFIG['self_topic_root']}/{event.MAC}/status", "online", is_json=False) + SENSORS_STATE[event.MAC]['online'] = True + LOGGER.info(f"{event.MAC} is back online!") + if (event.Type == "alarm") or (event.Type == "status"): - LOGGER.info(f"State event data: {event}") + LOGGER.debug(f"State event data: {event}") (sensor_type, sensor_state, sensor_battery, sensor_signal) = event.Data - # Add sensor if it doesn't already exist - if (event.MAC not in SENSORS): - add_sensor_to_config(event.MAC, sensor_type, None) - if(CONFIG['hass_discovery']): - send_discovery_topics(event.MAC) + # Set state depending on state string and `invert_state` setting. + # State ON ^ NOT Inverted = True + # State OFF ^ NOT Inverted = False + # State ON ^ Inverted = False + # State OFF ^ Inverted = True + sensor_state = int((sensor_state in STATES_ON) ^ (SENSORS[event.MAC].get('invert_state'))) + + # Adjust battery to max it at 100% + sensor_battery = 100 if sensor_battery > 100 else sensor_battery + + # Negate signal strength to match dbm vs percent + sensor_signal = sensor_signal * -1 # Build event payload event_payload = { - 'event': event.Type, - 'available': True, 'mac': event.MAC, - 'device_class': DEVICE_CLASSES.get(sensor_type), - 'last_seen': event.Timestamp.timestamp(), - 'last_seen_iso': event.Timestamp.isoformat(), - 'signal_strength': sensor_signal * -1, - 'battery': sensor_battery + 'signal_strength': sensor_signal, + 'battery': sensor_battery, + 'state': sensor_state } if (CONFIG['publish_sensor_name']): event_payload['name'] = SENSORS[event.MAC]['name'] - # Set state depending on state string and `invert_state` setting. - # State ON ^ NOT Inverted = True - # State OFF ^ NOT Inverted = False - # State ON ^ Inverted = False - # State OFF ^ Inverted = True - event_payload['state'] = int((sensor_state in STATES_ON) ^ (SENSORS[event.MAC].get('invert_state'))) + mqtt_publish(f"{CONFIG['self_topic_root']}/{event.MAC}", event_payload) - LOGGER.debug(event_payload) - - state_topic = f"{CONFIG['self_topic_root']}/{event.MAC}" - mqtt_publish(state_topic, event_payload) + LOGGER.info(f"{CONFIG['self_topic_root']}/{event.MAC}") + LOGGER.info(event_payload) else: - LOGGER.debug(f"Non-state event data: {event}") + LOGGER.info(f"{event}") else: LOGGER.warning("!Invalid MAC detected!") LOGGER.warning(f"Event data: {event}") +def Stop(): + # Stop the dongle first, letting this thread finish anything it might be busy doing, like handling an event + WYZESENSE_DONGLE.Stop() + + mqtt_publish(f"{CONFIG['self_topic_root']}/status", "offline", is_json=False) + + # All event handling should now be done, close the mqtt connection + MQTT_CLIENT.loop_stop() + MQTT_CLIENT.disconnect() + + # Save off the last known state + LOGGER.info("Writing Sensors State File") + SENSORS_STATE['modified'] = time.time() + write_yaml_file(os.path.join(CONFIG_PATH, SENSORS_STATE_FILE), SENSORS_STATE) + + LOGGER.info("********************************** Wyzesense2mqtt stopped ***********************************") + if __name__ == "__main__": # Initialize logging init_logging() + LOGGER.info("********************************** Wyzesense2mqtt starting **********************************") + # Initialize configuration init_config() @@ -497,18 +636,49 @@ def on_event(WYZESENSE_DONGLE, event): # Initialize sensor configuration init_sensors() + INITIALIZED = True + # Loop forever until keyboard interrupt or SIGINT try: + loop_counter = 0 while True: - MQTT_CLIENT.loop_forever(retry_first_connection=False) - - # Used for alternate MQTT connection method - # signal.pause() + time.sleep(5) + loop_counter += 1 + + if not MQTT_CLIENT.connected_flag: + LOGGER.warning("Reconnecting MQTT...") + MQTT_CLIENT.reconnect() + + # Communication with the dongle may go down, if a timeout occurs, try to reopen the dongle + if loop_counter > 12: + loop_counter = 0 + try: + WYZESENSE_DONGLE._GetMac() + except TimeoutError: + LOGGER.error("Failed to communicate with dongle, reopening...") + WYZESENSE_DONGLE.Stop() + init_wyzesense_dongle() + + # Check for availability of the devices + now = time.time() + for mac in SENSORS_STATE: + if SENSORS_STATE[mac]['online']: + LOGGER.debug(f"Checking availability of {mac}") + # If there is a timeout configured, use that. Must be in seconds. + # If no timeout configured, check if it's a V2 device (quicker reporting period) + # Otherwise, use the longer V1 timeout period + if (SENSORS[mac].get('timeout') is not None): + timeout = SENSORS[mac]['timeout'] + elif (SENSORS[mac].get('sw_version') is not None and SENSORS[mac]['sw_version'] in V2_SW): + timeout = DEFAULT_V2_TIMEOUT_HOURS*60*60 + else: + timeout = DEFAULT_V1_TIMEOUT_HOURS*60*60 + + if ((now - SENSORS_STATE[mac]['last_seen']) > timeout): + mqtt_publish(f"{CONFIG['self_topic_root']}/{mac}/status", "offline", is_json=False) + LOGGER.warning(f"{mac} has gone offline!") + SENSORS_STATE[mac]['online'] = False except KeyboardInterrupt: pass finally: - # Used with alternate MQTT connection method - # MQTT_CLIENT.loop_stop() - - MQTT_CLIENT.disconnect() - WYZESENSE_DONGLE.Stop() + Stop() diff --git a/wyzesense2mqtt/wyzesense2mqtt.service b/wyzesense2mqtt/wyzesense2mqtt.service index 9ba91f2..1b76bae 100644 --- a/wyzesense2mqtt/wyzesense2mqtt.service +++ b/wyzesense2mqtt/wyzesense2mqtt.service @@ -8,6 +8,7 @@ Type=simple WorkingDirectory=/wyzesense2mqtt ExecStart=/wyzesense2mqtt/service.sh Restart=always +KillSignal=SIGINT [Install] WantedBy=multi-user.target