Skip to content

Instantly share code, notes, and snippets.

@mariusmuja
Last active June 25, 2025 03:09
Show Gist options
  • Save mariusmuja/d1953a965ea3755e0ef4099cfaafd732 to your computer and use it in GitHub Desktop.
Save mariusmuja/d1953a965ea3755e0ef4099cfaafd732 to your computer and use it in GitHub Desktop.
"""Support for the Wyze lock."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Final
from enum import Enum
from zigpy.typing import AddressingMode
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice
import zigpy.types as t
from zigpy.zcl import foundation
from zigpy.zcl.clusters.closures import DoorLock, LockState
from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster
from zigpy.zcl.clusters.general import (
Basic,
Identify,
Ota,
PollControl,
PowerConfiguration,
Time,
)
from zigpy.zcl.clusters.homeautomation import Diagnostic
from zigpy.zcl.clusters.security import IasZone
from zhaquirks import Bus, LocalDataCluster
from zhaquirks.const import (
CLUSTER_COMMAND,
DEVICE_TYPE,
ENDPOINTS,
INPUT_CLUSTERS,
MODELS_INFO,
OFF,
ON,
OUTPUT_CLUSTERS,
PROFILE_ID,
ZONE_STATE,
)
YUNDING = "Yunding"
WYZE_CLUSTER_ID = 0xFC00
ZONE_TYPE = 0x0001
_LOGGER = logging.getLogger(__name__)
class DoorLockCluster(CustomCluster, DoorLock):
"""DoorLockCluster cluster."""
def __init__(self, *args, **kwargs):
"""Init."""
super().__init__(*args, **kwargs)
self.endpoint.device.lock_bus.add_listener(self)
def lock_state(self, locked):
"""Lock state."""
self._update_attribute(0x0000, locked)
class MotionCluster(LocalDataCluster, IasZone):
"""Motion cluster."""
def __init__(self, *args, **kwargs):
"""Init."""
super().__init__(*args, **kwargs)
self.endpoint.device.motion_bus.add_listener(self)
super()._update_attribute(ZONE_TYPE, IasZone.ZoneType.Contact_Switch)
def motion_event(self, zone_state):
"""Motion event."""
super().listener_event(CLUSTER_COMMAND, None, ZONE_STATE, [zone_state])
_LOGGER.debug("%s - Received motion event message", self.endpoint.device.ieee)
class WyzeStatusArgs(t.List, item_type=t.uint8_t):
""" Just a list of bytes until we figure out something better """
class Action(Enum):
APP_LOCK = 1,
APP_UNLOCK = 2,
MANUAL_LOCK = 3,
MANUAL_UNLOCK = 4,
AUTO_LOCK = 5,
DOOR_OPEN = 6,
DOOR_CLOSE = 7
actions = {
# TODO: add and entry for you lock here
(t.EUI64.convert("60:a4:23:ff:fe:8b:ba:85"), 1) : {
# TODO update below with the values in the logs corresponding to your lock for each action
(14, 27) : Action.APP_LOCK,
(14, 28) : Action.APP_UNLOCK,
(10, 27) : Action.MANUAL_LOCK,
(10, 28) : Action.MANUAL_UNLOCK,
(7, 27) : Action.AUTO_LOCK,
(240, 8) : Action.DOOR_OPEN,
(240, 11) : Action.DOOR_CLOSE,
}
}
class WyzeCluster(CustomCluster, ManufacturerSpecificCluster):
"""Wyze manufacturer specific cluster implementation."""
class ServerCommandDefs(foundation.BaseCommandDefs):
status_update: Final = foundation.ZCLCommandDef(
id=0x00, schema=WyzeStatusArgs, direction=foundation.Direction.Client_to_Server, is_manufacturer_specific = True # pyright: ignore[reportArgumentType]
)
cluster_id = WYZE_CLUSTER_ID # pyright: ignore[reportAssignmentType]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.seq = 0
def handle_cluster_request(
self,
hdr: foundation.ZCLHeader,
args: list[Any],
*,
dst_addressing: AddressingMode | None = None,
):
"""Handle a cluster request."""
seq = args[8]
if seq <= self.seq and (self.seq - seq < 50):
self.warning(f"[Wyze Lock] Ignoring duplicate message, seq: {seq}")
return
self.seq = seq
self.warning(f"[Wyze Lock] Received command: {args}")
if not (args[0] == 79 and args[1] == 171):
self.warning(f"[Wyze Lock] Ignoring mesage of unknown type: {(args[0], args[1])}, len: {len(args)}")
return
lock_actions = actions.get(self.endpoint.unique_id, None)
action = lock_actions.get((args[52], args[41]), None) if lock_actions is not None else None
if action is None:
self.warning(f"Cannot find action for lock {self.endpoint.unique_id} and values {(args[52], args[41])}, update the 'actions' dict.")
return
self.warning(f"Door action: {action}")
if action == Action.APP_UNLOCK:
self.endpoint.device.lock_bus.listener_event("lock_state", LockState.Unlocked)
elif action == Action.APP_LOCK:
self.endpoint.device.lock_bus.listener_event("lock_state", LockState.Locked)
elif action == Action.MANUAL_UNLOCK:
self.endpoint.device.lock_bus.listener_event("lock_state", LockState.Unlocked)
elif action == Action.MANUAL_LOCK:
self.endpoint.device.lock_bus.listener_event("lock_state", LockState.Locked)
elif action == Action.AUTO_LOCK:
self.endpoint.device.lock_bus.listener_event("lock_state", LockState.Locked)
elif action == Action.DOOR_OPEN:
self.endpoint.device.motion_bus.listener_event("motion_event", ON)
elif action == Action.DOOR_CLOSE:
self.endpoint.device.motion_bus.listener_event("motion_event", OFF)
class WyzeLock(CustomDevice):
"""Wyze lock device."""
def __init__(self, *args, **kwargs):
"""Init."""
self.lock_bus = Bus()
self.motion_bus = Bus()
super().__init__(*args, **kwargs)
signature = {
# <SimpleDescriptor endpoint=1 profile=260 device_type=10
# device_version=1
# input_clusters=[0, 1, 3, 32, 257, 2821, 64512]
# "0x0000", "0x0001", "0x0003", "0x0020", "0x0101", "0x0b05", "0xfc00"
# output_clusters=[10, 25, 64512]>
# "0x000a", "0x0019", "0xfc00"
MODELS_INFO: [(YUNDING, "Ford")],
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.DOOR_LOCK,
INPUT_CLUSTERS: [
Basic.cluster_id,
PowerConfiguration.cluster_id,
Identify.cluster_id,
PollControl.cluster_id,
DoorLock.cluster_id,
Diagnostic.cluster_id,
WYZE_CLUSTER_ID,
],
OUTPUT_CLUSTERS: [Ota.cluster_id, Time.cluster_id, WYZE_CLUSTER_ID],
}
},
}
replacement = {
ENDPOINTS: {
1: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.DOOR_LOCK,
INPUT_CLUSTERS: [
Basic.cluster_id,
PowerConfiguration.cluster_id,
Identify.cluster_id,
PollControl.cluster_id,
DoorLockCluster,
Diagnostic.cluster_id,
WyzeCluster,
MotionCluster,
],
OUTPUT_CLUSTERS: [Ota.cluster_id, Time.cluster_id, WyzeCluster],
}
}
}
@mariusmuja
Copy link
Author

mariusmuja commented Feb 7, 2025

This is a ZHA quirk for handling the manufacturer specific zigbee message that the Wyze Lock sends when the door is locked/unlocked and opened/closed (based on this code).

Installation steps:

  • copy wyze.py to your HASS config directory inside a custom_zha_quirks subdirectory
  • update your configuration.yaml file:
zha:
  enable_quirks: true  # it's true by default, so you can leave this out, just make sure there's no false setting here
  custom_quirks_path: /config/custom_zha_quirks/ # update this with your config path
  • restart Home Assistant or reload the Zigbee integration, if all work well you should see a message like this in the logs when Home Assistant starts:
025-02-06 15:51:03.121 WARNING (SyncWorker_7) [zhaquirks] Loaded custom quirks. Please contribute them to https://github.com/zigpy/zha-device-handlers
  • your Wyze lock should be calibrated (using the app), then paired to Home Assistant on Zigbee (unplug the gateway when pairing on Zigbee).
  • check Home Assistant logs for messages like this, while doing the following operations: lock/unlock manually, lock/unlock using the app, open/close door and after an auto-lock:
2025-03-10 11:56:06.106 WARNING (MainThread) [zigpy.zcl] [0x2F7D:1:0xfc00] Cannot find action for lock (60:a4:23:ff:fe:8b:ba:85, 1) and values (10, 28), update the 'actions' dict.
  • Update the actions dictionary for the lock and the values you see in the log for each action (APP_LOCK, APP_UNLOCK, MANUAL_LOCK,...) , these values seem to be different for different locks (probably depends on lock calibration?).
  • Restart Home Assistant or the Zigbee integration and verify that the lock and door states are updated correctly.

@lygris
Copy link

lygris commented Mar 9, 2025

Got one of mine working today, will this work with 2 wyze locks? guessing they won't have the same values and i'm not sure i can just add an 'or' into the quick and have it track to each lock correctly?

@mariusmuja
Copy link
Author

Got one of mine working today, will this work with 2 wyze locks? guessing they won't have the same values and i'm not sure i can just add an 'or' into the quick and have it track to each lock correctly?

You could use self.endpoint.unique_id to distinguish between the two devices.

@lygris
Copy link

lygris commented Mar 10, 2025

Any chance you can point me in the right direction for that? i'm not great with python, but i'm guessing i need to add that as part of an if statement around the arg if/elif and then copy the whole thing and replace the unique id with the one of the second lock?

@mariusmuja
Copy link
Author

Any chance you can point me in the right direction for that? i'm not great with python, but i'm guessing i need to add that as part of an if statement around the arg if/elif and then copy the whole thing and replace the unique id with the one of the second lock?

I updated the code, so it's easy to add multiple locks. You just need to add an entry to the actions dictionary with the values from the log for each of the locks you have.

@lygris
Copy link

lygris commented Mar 10, 2025

Thanks i got it working now, noticed some similarities with the second lock. The 2nd part of the lock and unlock codes are the same for all types, and it seems the app lock/unlock first number is the manual # - 4. the auto lock # doesn't seem to be following a pattern.

I'll also post battery percent for each device and their messages below
actions = { # TODO: add and entry for you lock here (t.EUI64.convert("****device_ID1*****"), 1) : { # TODO update below with the values in the logs corresponding to your lock for each action (114, 125) : Action.APP_LOCK, (114, 122) : Action.APP_UNLOCK, (118, 125) : Action.MANUAL_LOCK, (118, 122) : Action.MANUAL_UNLOCK, (123, 125) : Action.AUTO_LOCK, (140, 110) : Action.DOOR_OPEN, (140, 109) : Action.DOOR_CLOSE, }, (t.EUI64.convert("****device_ID2*****"), 1) : { # TODO update below with the values in the logs corresponding to your lock for each action (211, 6) : Action.APP_LOCK, (211, 1) : Action.APP_UNLOCK, (215, 6) : Action.MANUAL_LOCK, (215, 1) : Action.MANUAL_UNLOCK, (218, 6) : Action.AUTO_LOCK, (45, 21) : Action.DOOR_OPEN, (45, 22) : Action.DOOR_CLOSE, } }

Lock2 app battery=94%, HA showing 100%
[Wyze Lock] Received command: [79, 171, 64, 0, 69, 55, 109, 128, 161, 0, 3, 6, 96, 47, 211, 193, 70, 163, 105, 162, 191, 191, 45, 203, 199, 29, 59, 37, 222, 95, 62, 90, 209, 157, 141, 59, 230, 125, 189, 107, 128, 1, 39, 8, 213, 202, 28, 161, 255, 215, 233, 55, 211, 201, 41, 174, 11, 147, 70, 73, 223, 92, 230, 80, 174, 29, 150, 69, 129, 170, 172, 210, 246, 0, 166]

lock1 app battery=74%, HA showing 100%
[Wyze Lock] Received command: [79, 171, 64, 0, 69, 171, 91, 128, 116, 0, 2, 6, 96, 248, 245, 183, 207, 0, 138, 178, 30, 105, 73, 36, 187, 117, 244, 84, 38, 7, 91, 68, 208, 77, 227, 11, 93, 4, 143, 227, 161, 122, 185, 231, 173, 29, 58, 214, 224, 116, 10, 221, 114, 31, 77, 65, 119, 249, 17, 56, 39, 4, 237, 223, 175, 205, 248, 117, 58, 211, 159, 204, 215, 123, 56]

@lygris
Copy link

lygris commented Jun 22, 2025

I had to change the batteries on my locks and noticed for awhile now i'm not getting the locked/unlock events anymore. not sure if it was before or after the battery change. Do i need to change the action values every recalibration? Sending the lock and unlock commands from HA still works, but the status isn't updating for manual or auto lock events.

@mariusmuja
Copy link
Author

I had to change the batteries on my locks and noticed for awhile now i'm not getting the locked/unlock events anymore. not sure if it was before or after the battery change. Do i need to change the action values every recalibration? Sending the lock and unlock commands from HA still works, but the status isn't updating for manual or auto lock events.

I changed the batteries and didn't have to change the values, but if you did a recalibration I'm guessing that yes, you'll have to update the action values because calibrations changes them

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment