diff --git a/custom_components/wyzeapi/__init__.py b/custom_components/wyzeapi/__init__.py index 948d7347..8557c48b 100644 --- a/custom_components/wyzeapi/__init__.py +++ b/custom_components/wyzeapi/__init__.py @@ -29,6 +29,8 @@ API_KEY, ) from .coordinator import WyzeLockBoltCoordinator +from .iot3_coordinator import WyzeLockBoltV2Coordinator +from .iot3_service import Iot3Service from .token_manager import TokenManager PLATFORMS = [ @@ -195,19 +197,46 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def setup_coordinators( hass: HomeAssistant, config_entry: ConfigEntry, client: Wyzeapy ): - """Set up coordinators for Wyze devices that require Bluetooth.""" - # Check if Bluetooth is active and functioning + """Set up coordinators for Wyze Lock Bolt devices (BLE and IoT3).""" + from .const import IOT3_MODELS + + lock_service = await client.lock_service + all_locks = await lock_service.get_locks() + coordinators = hass.data[DOMAIN][config_entry.entry_id].setdefault( + "coordinators", {} + ) + + # IoT3 devices have product_type "Common" so get_locks() won't find them. + # Search the full device list by product_model instead. + all_devices = await lock_service.get_object_list() + iot3_devices = [d for d in all_devices if d.product_model in IOT3_MODELS] + # Store them so lock.py can find them later + hass.data[DOMAIN][config_entry.entry_id]["iot3_devices"] = iot3_devices + + # IoT3 coordinators for DX-family locks (no Bluetooth needed) + iot3_locks = iot3_devices + if iot3_locks: + iot3_service = Iot3Service(hass, config_entry) + hass.data[DOMAIN][config_entry.entry_id]["iot3_service"] = iot3_service + for lock in iot3_locks: + _LOGGER.info( + "Setting up IoT3 coordinator for %s (%s)", + lock.nickname, + lock.product_model, + ) + coordinators[lock.mac] = WyzeLockBoltV2Coordinator( + hass, iot3_service, lock + ) + + # BLE coordinators for YD_BT1 Lock Bolt v1 if bluetooth.async_scanner_count(hass, connectable=True) == 0: - _LOGGER.info( - "Bluetooth is not active or no scanners available. Skipping WyzeLockBoltCoordinator setup." - ) + if any(l.product_model == "YD_BT1" for l in all_locks): + _LOGGER.info( + "Bluetooth is not active. Skipping WyzeLockBoltCoordinator setup." + ) return - lock_service = await client.lock_service - for lock in await lock_service.get_locks(): + for lock in all_locks: if lock.product_model == "YD_BT1": - coordinators = hass.data[DOMAIN][config_entry.entry_id].setdefault( - "coordinators", {} - ) coordinators[lock.mac] = WyzeLockBoltCoordinator(hass, lock_service, lock) await coordinators[lock.mac].update_lock_info() diff --git a/custom_components/wyzeapi/const.py b/custom_components/wyzeapi/const.py index 84bd7a70..96e6d7b6 100644 --- a/custom_components/wyzeapi/const.py +++ b/custom_components/wyzeapi/const.py @@ -26,3 +26,13 @@ YDBLE_LOCK_STATE_UUID = "00002220-0000-6b63-6f6c-2e6b636f6f6c" YDBLE_UART_RX_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e" YDBLE_UART_TX_UUID = "6e400002-b5a3-f393-e0a9-e50e24dcca9e" + +# IoT3 API for DX-family devices (Lock Bolt v2, Palm Lock, etc.) +IOT3_BASE_URL = "https://app.wyzecam.com/app/v4/iot3" +IOT3_GET_PROPERTY_PATH = "/app/v4/iot3/get-property" +IOT3_RUN_ACTION_PATH = "/app/v4/iot3/run-action" +IOT3_APP_HOST = "https://app.wyzecam.com" +OLIVE_SIGNING_SECRET = "wyze_app_secret_key_132" +OLIVE_APP_ID = "9319141212m2ik" +OLIVE_APP_INFO = "wyze_android_3.11.0.758" +IOT3_MODELS = {"DX_LB2", "DX_PVLOC"} diff --git a/custom_components/wyzeapi/iot3_coordinator.py b/custom_components/wyzeapi/iot3_coordinator.py new file mode 100644 index 00000000..8eee8111 --- /dev/null +++ b/custom_components/wyzeapi/iot3_coordinator.py @@ -0,0 +1,82 @@ +"""DataUpdateCoordinator for Wyze DX-family devices via IoT3 API.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .iot3_service import Iot3Service + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=30) + + +class WyzeLockBoltV2Coordinator(DataUpdateCoordinator): + """Coordinator for Wyze Lock Bolt v2 (DX_LB2) via IoT3 cloud API.""" + + def __init__( + self, + hass: HomeAssistant, + iot3_service: Iot3Service, + lock, + ): + super().__init__( + hass, + _LOGGER, + name=f"Wyze Lock Bolt V2 {lock.nickname}", + update_interval=UPDATE_INTERVAL, + ) + self._iot3_service = iot3_service + self._lock = lock + self._current_command: str | None = None + + async def _async_update_data(self) -> dict: + """Poll IoT3 get-property for current lock state.""" + if self._current_command is not None: + return self.data or {} + + try: + result = await self._iot3_service.get_properties(self._lock.mac) + except Exception as exc: + raise UpdateFailed(f"Error fetching lock state: {exc}") from exc + + if result.get("code") != "1": + raise UpdateFailed( + f"IoT3 API returned error: {result.get('msg', 'unknown')}" + ) + + props = result.get("data", {}).get("props", {}) + return { + "locked": props.get("lock::lock-status", None), + "door_open": not props.get("lock::door-status", True), + "online": props.get("iot-device::iot-state", False), + "battery_level": props.get("battery::battery-level", None), + "power_source": props.get("battery::power-source", None), + "firmware_ver": props.get("device-info::firmware-ver", None), + } + + async def lock_unlock(self, command: str): + """Execute lock or unlock command.""" + self._current_command = command + self.async_update_listeners() + try: + if command == "lock": + result = await self._iot3_service.lock(self._lock.mac) + else: + result = await self._iot3_service.unlock(self._lock.mac) + + if result.get("code") != "1": + _LOGGER.error( + "Lock %s command failed: %s", + command, + result.get("msg", "unknown"), + ) + except Exception: + _LOGGER.exception("Failed to %s lock %s", command, self._lock.nickname) + finally: + self._current_command = None + await self.async_request_refresh() diff --git a/custom_components/wyzeapi/iot3_service.py b/custom_components/wyzeapi/iot3_service.py new file mode 100644 index 00000000..16a04587 --- /dev/null +++ b/custom_components/wyzeapi/iot3_service.py @@ -0,0 +1,160 @@ +"""IoT3 API service for Wyze DX-family devices (Lock Bolt v2, Palm Lock, etc.).""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import random +import time +import uuid + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + ACCESS_TOKEN, + IOT3_APP_HOST, + IOT3_GET_PROPERTY_PATH, + IOT3_RUN_ACTION_PATH, + OLIVE_APP_ID, + OLIVE_APP_INFO, + OLIVE_SIGNING_SECRET, +) + +_LOGGER = logging.getLogger(__name__) + + +class Iot3Service: + """Client for the Wyze IoT3 API (DX-family devices).""" + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry): + self._hass = hass + self._config_entry = config_entry + self._phone_id = str(uuid.uuid4()) + self._session = async_get_clientsession(hass) + + @property + def _access_token(self) -> str: + return self._config_entry.data.get(ACCESS_TOKEN, "") + + @property + def username(self) -> str: + return self._config_entry.data.get(CONF_USERNAME, "") + + def _compute_signature(self, body: str) -> str: + access_key = self._access_token + OLIVE_SIGNING_SECRET + secret = hashlib.md5(access_key.encode()).hexdigest() + return hmac.new(secret.encode(), body.encode(), hashlib.md5).hexdigest() + + def _build_headers(self, body: str) -> dict: + return { + "access_token": self._access_token, + "appid": OLIVE_APP_ID, + "appinfo": OLIVE_APP_INFO, + "appversion": "3.11.0.758", + "env": "Prod", + "phoneid": self._phone_id, + "requestid": uuid.uuid4().hex, + "Signature2": self._compute_signature(body), + "Content-Type": "application/json; charset=utf-8", + } + + @staticmethod + def _extract_model(device_mac: str) -> str: + """Extract model from MAC (e.g., DX_LB2_80482C9C659C -> DX_LB2).""" + parts = device_mac.split("_") + if len(parts) >= 3: + return "_".join(parts[:2]) + return device_mac + + async def _post(self, path: str, payload: dict) -> dict: + body = json.dumps(payload) + headers = self._build_headers(body) + url = f"{IOT3_APP_HOST}{path}" + try: + async with self._session.post(url, headers=headers, data=body) as resp: + result = await resp.json() + if result.get("code") != "1": + _LOGGER.warning( + "IoT3 API error on %s: code=%s msg=%s", + path, + result.get("code"), + result.get("msg"), + ) + return result + except Exception as exc: + _LOGGER.error("IoT3 API request failed for %s: %s", path, exc) + raise + + async def get_properties( + self, + device_mac: str, + props: list[str] | None = None, + ) -> dict: + """Get device properties via IoT3 get-property endpoint.""" + if props is None: + props = [ + "lock::lock-status", + "lock::door-status", + "iot-device::iot-state", + "battery::battery-level", + "battery::power-source", + "device-info::firmware-ver", + ] + + ts = int(time.time() * 1000) + payload = { + "nonce": str(ts), + "payload": { + "cmd": "get_property", + "props": props, + "tid": random.randint(1000, 99999), + "ts": ts, + "ver": 1, + }, + "targetInfo": { + "id": device_mac, + "model": self._extract_model(device_mac), + }, + } + return await self._post(IOT3_GET_PROPERTY_PATH, payload) + + async def run_action( + self, + device_mac: str, + action: str, + ) -> dict: + """Run an action (e.g., lock::lock, lock::unlock) via IoT3 run-action endpoint.""" + ts = int(time.time() * 1000) + payload = { + "nonce": str(ts), + "payload": { + "action": action, + "cmd": "run_action", + "params": { + "action_id": random.randint(10000, 99999), + "type": 1, + "username": self.username, + }, + "tid": random.randint(1000, 99999), + "ts": ts, + "ver": 1, + }, + "targetInfo": { + "id": device_mac, + "model": self._extract_model(device_mac), + }, + } + return await self._post(IOT3_RUN_ACTION_PATH, payload) + + async def lock(self, device_mac: str) -> dict: + """Lock the device.""" + return await self.run_action(device_mac, "lock::lock") + + async def unlock(self, device_mac: str) -> dict: + """Unlock the device.""" + return await self.run_action(device_mac, "lock::unlock") diff --git a/custom_components/wyzeapi/lock.py b/custom_components/wyzeapi/lock.py index e8318247..571faa7c 100644 --- a/custom_components/wyzeapi/lock.py +++ b/custom_components/wyzeapi/lock.py @@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.exceptions import HomeAssistantError -from .const import CONF_CLIENT, DOMAIN, LOCK_UPDATED +from .const import CONF_CLIENT, DOMAIN, IOT3_MODELS, LOCK_UPDATED from .token_manager import token_exception_handler _LOGGER = logging.getLogger(__name__) @@ -52,20 +52,35 @@ async def async_setup_entry( all_locks = await lock_service.get_locks() + skip_models = {"YD_BT1"} | IOT3_MODELS + locks = [ WyzeLock(lock_service, lock) for lock in all_locks - if lock.product_model != "YD_BT1" + if lock.product_model not in skip_models ] + lock_bolts = [] for lock in all_locks: if lock.product_model == "YD_BT1": - coordinator = hass.data[DOMAIN][config_entry.entry_id]["coordinators"][ - lock.mac - ] - lock_bolts.append(WyzeLockBolt(coordinator)) - - async_add_entities(locks + lock_bolts, True) + coordinator = hass.data[DOMAIN][config_entry.entry_id].get( + "coordinators", {} + ).get(lock.mac) + if coordinator: + lock_bolts.append(WyzeLockBolt(coordinator)) + + # IoT3 devices (DX_LB2, DX_PVLOC) are discovered from the full device list + # since they have product_type "Common" and don't appear in get_locks() + lock_bolts_v2 = [] + iot3_devices = hass.data[DOMAIN][config_entry.entry_id].get("iot3_devices", []) + for device in iot3_devices: + coordinator = hass.data[DOMAIN][config_entry.entry_id].get( + "coordinators", {} + ).get(device.mac) + if coordinator: + lock_bolts_v2.append(WyzeLockBoltV2(coordinator)) + + async_add_entities(locks + lock_bolts + lock_bolts_v2, True) class WyzeLock(homeassistant.components.lock.LockEntity, ABC): @@ -262,3 +277,66 @@ def is_unlocking(self, **kwargs): @property def state_attributes(self): return {"last_operated": self.coordinator.data["timestamp"]} + + +class WyzeLockBoltV2(CoordinatorEntity, homeassistant.components.lock.LockEntity): + """Representation of a Wyze Lock Bolt v2 (DX_LB2) via IoT3 cloud API.""" + + def __init__(self, coordinator): + super().__init__(coordinator) + self._lock = coordinator._lock + + @property + def name(self): + return self._lock.nickname + + @property + def unique_id(self): + return self._lock.mac + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._lock.mac)}, + "name": self._lock.nickname, + "manufacturer": "WyzeLabs", + "model": self._lock.product_model, + } + + @property + def is_locked(self): + if self.coordinator.data is None: + return None + return self.coordinator.data.get("locked", None) + + @property + def available(self): + if self.coordinator.data is None: + return False + return self.coordinator.data.get("online", False) + + @property + def is_locking(self): + return self.coordinator._current_command == "lock" + + @property + def is_unlocking(self): + return self.coordinator._current_command == "unlock" + + @property + def extra_state_attributes(self): + attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + if self.coordinator.data: + if self.coordinator.data.get("battery_level") is not None: + attrs["battery_level"] = self.coordinator.data["battery_level"] + if self.coordinator.data.get("firmware_ver") is not None: + attrs["firmware_version"] = self.coordinator.data["firmware_ver"] + if self.coordinator.data.get("door_open") is not None: + attrs["door_open"] = self.coordinator.data["door_open"] + return attrs + + async def async_lock(self, **kwargs): + await self.coordinator.lock_unlock(command="lock") + + async def async_unlock(self, **kwargs): + await self.coordinator.lock_unlock(command="unlock") diff --git a/custom_components/wyzeapi/sensor.py b/custom_components/wyzeapi/sensor.py index bb954e9f..a4c6ad67 100644 --- a/custom_components/wyzeapi/sensor.py +++ b/custom_components/wyzeapi/sensor.py @@ -35,10 +35,13 @@ async_track_time_change, ) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + from .const import ( CAMERA_UPDATED, CONF_CLIENT, DOMAIN, + IOT3_MODELS, LOCK_UPDATED, RESET_BUTTON_PRESSED, ) @@ -110,9 +113,93 @@ async def async_setup_entry( ] ) + # IoT3 lock sensors (battery, firmware) via coordinator + iot3_devices = hass.data[DOMAIN][config_entry.entry_id].get("iot3_devices", []) + for device in iot3_devices: + coordinator = hass.data[DOMAIN][config_entry.entry_id].get( + "coordinators", {} + ).get(device.mac) + if coordinator: + sensors.append(WyzeIot3BatterySensor(coordinator)) + sensors.append(WyzeIot3FirmwareSensor(coordinator)) + async_add_entities(sensors, True) +class WyzeIot3BatterySensor(CoordinatorEntity, SensorEntity): + """Battery sensor for IoT3 devices (Lock Bolt v2, Palm Lock).""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + _attr_state_class = SensorStateClass.MEASUREMENT + + def __init__(self, coordinator): + super().__init__(coordinator) + self._device = coordinator._lock + + @property + def name(self): + return f"{self._device.nickname} Battery" + + @property + def unique_id(self): + return f"{self._device.mac}-battery" + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._device.mac)}, + "name": self._device.nickname, + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + + @property + def available(self): + if self.coordinator.data is None: + return False + return self.coordinator.data.get("online", False) + + @property + def native_value(self): + if self.coordinator.data is None: + return None + return self.coordinator.data.get("battery_level") + + +class WyzeIot3FirmwareSensor(CoordinatorEntity, SensorEntity): + """Firmware version sensor for IoT3 devices.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator): + super().__init__(coordinator) + self._device = coordinator._lock + + @property + def name(self): + return f"{self._device.nickname} Firmware" + + @property + def unique_id(self): + return f"{self._device.mac}-firmware" + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self._device.mac)}, + "name": self._device.nickname, + "manufacturer": "WyzeLabs", + "model": self._device.product_model, + } + + @property + def native_value(self): + if self.coordinator.data is None: + return None + return self.coordinator.data.get("firmware_ver") + + class WyzeLockBatterySensor(SensorEntity): """Representation of a Wyze Lock or Lock Keypad Battery.""" diff --git a/iot3_api.md b/iot3_api.md new file mode 100644 index 00000000..5536a09d --- /dev/null +++ b/iot3_api.md @@ -0,0 +1,521 @@ +# Wyze IoT3 API - Lock Bolt v2 (DX_LB2) & New Device Family + +> **Reverse-engineered March 26, 2026.** This API is unofficial and subject to change. +> Applies to the Wyze Lock Bolt v2 (`DX_LB2`) and likely the Wyze Palm Lock (`DX_PVLOC`) and other `DX_` prefix devices. + +## Overview + +Wyze introduced a new **IoT3 API** (`/app/v4/iot3/`) for their next-generation "DX" device family. This API is completely separate from the legacy APIs used by older Wyze devices: + +| Legacy API | Used By | Base URL | +|------------|---------|----------| +| Ford/Yunding | Original Wyze Lock (`YD.LO1`) | `yd-saas-toc.wyzecam.com` | +| Olive/Earth | Thermostats, Switches | `wyze-earth-service.wyzecam.com` | +| Standard v2 | Cameras, Plugs, Bulbs | `api.wyzecam.com/app/v2/` | +| BLE (Bleak) | Lock Bolt v1 (`YD_BT1`) | Direct Bluetooth | +| **IoT3 (NEW)** | **Lock Bolt v2 (`DX_LB2`), Palm Lock (`DX_PVLOC`)** | **`app.wyzecam.com/app/v4/iot3/`** | + +### Key Characteristics + +- **Base URL**: `https://app.wyzecam.com` +- **API Path Prefix**: `/app/v4/iot3/` +- **Auth**: Olive-style HMAC-MD5 signature +- **Property Format**: `namespace::property` (e.g., `lock::lock-status`, `battery::battery-level`) +- **Action Format**: `namespace::action` (e.g., `lock::lock`, `lock::unlock`) +- **Device Targeting**: Uses `targetInfo` with device `id` (MAC) and `model` + +--- + +## Authentication + +### Step 1: Login (get access token) + +```bash +curl -s -X POST "https://auth-prod.api.wyze.com/api/user/login" \ + -H "Content-Type: application/json" \ + -H "keyid: YOUR_KEY_ID" \ + -H "apikey: YOUR_API_KEY" \ + -d '{ + "email": "YOUR_EMAIL", + "password": "TRIPLE_MD5_HASHED_PASSWORD" + }' +``` + +**Password hashing** (triple MD5): +``` +step1 = md5("your_plaintext_password") +step2 = md5(step1) +final = md5(step2) +``` + +**Get your KEY_ID and API_KEY** from https://developer-api-console.wyze.com/ + +**Response:** +```json +{ + "access_token": "eyJhbG...", + "refresh_token": "eyJhbG..." +} +``` + +### Step 2: Compute Signature2 + +Every IoT3 API request requires a `Signature2` header computed as: + +``` +signing_secret = "wyze_app_secret_key_132" +access_key = access_token + signing_secret +secret = MD5(access_key) +Signature2 = HMAC-MD5(key=secret, message=raw_json_request_body) +``` + +**Python example:** +```python +import hashlib, hmac, json + +def compute_signature(access_token: str, request_body: str) -> str: + signing_secret = "wyze_app_secret_key_132" + access_key = access_token + signing_secret + secret = hashlib.md5(access_key.encode()).hexdigest() + return hmac.new(secret.encode(), request_body.encode(), hashlib.md5).hexdigest() + +# Usage +payload = json.dumps(your_payload_dict) +sig = compute_signature(your_access_token, payload) +``` + +### Required Headers + +| Header | Value | Description | +|--------|-------|-------------| +| `access_token` | `eyJhbG...` | JWT from login | +| `appid` | `9319141212m2ik` | App identifier | +| `appinfo` | `wyze_android_3.11.0.758` | App info string | +| `appversion` | `3.11.0.758` | App version | +| `env` | `Prod` | Environment | +| `phoneid` | `` | Random UUID (persist per session) | +| `requestid` | `` | Unique per request | +| `Signature2` | `` | HMAC-MD5 signature (see above) | +| `Content-Type` | `application/json; charset=utf-8` | Content type | + +--- + +## Device Discovery + +Use the standard Wyze device list API to find DX_LB2 devices: + +```bash +curl -s -X POST "https://api.wyzecam.com/app/v2/home_page/get_object_list" \ + -H "Content-Type: application/json" \ + -d '{ + "phone_system_type": "1", + "app_version": "2.18.43", + "app_ver": "com.hualai.WyzeCam___2.18.43", + "sc": "9f275790cab94a72bd206c8876429f3c", + "ts": TIMESTAMP_SECONDS, + "sv": "9d74946e652647e9b6c9d59326aef104", + "access_token": "YOUR_ACCESS_TOKEN", + "phone_id": "YOUR_PHONE_UUID", + "app_name": "com.hualai.WyzeCam" + }' +``` + +Look for devices with `"product_model": "DX_LB2"` in the response. The `mac` field (e.g., `DX_LB2_80482C9C659C`) is the device ID used in all IoT3 calls. + +--- + +## Endpoints + +### 1. Get Device Properties + +Read the current state of the lock. + +**Endpoint:** `POST https://app.wyzecam.com/app/v4/iot3/get-property` + +**Request Body:** +```json +{ + "nonce": "1774567387005", + "payload": { + "cmd": "get_property", + "props": [ + "lock::lock-status", + "lock::door-status", + "iot-device::iot-state", + "battery::battery-level", + "battery::power-source", + "device-info::firmware-ver", + "device-info::timezone", + "lock::lock-install-mode", + "lock::gyroscope-calibration-step", + "battery::battery-state-spare" + ], + "tid": 7189, + "ts": 1774567387005, + "ver": 1 + }, + "targetInfo": { + "id": "DX_LB2_YOUR_DEVICE_MAC", + "model": "DX_LB2" + } +} +``` + +**Response:** +```json +{ + "code": "1", + "ts": 1774567377882, + "msg": "SUCCESS", + "data": { + "props": { + "battery::power-source": 1, + "iot-device::iot-state": true, + "battery::battery-level": 100, + "lock::lock-status": true, + "device-info::firmware-ver": "1.0.8", + "lock::lock-install-mode": 2, + "device-info::timezone": "EST+0500EDT+0400,M3.2.0/02:00:00,M11.1.0/02:00:00" + } + }, + "traceId": "6a7b7b33cc272a57b38e9e8482fb2572" +} +``` + +**curl example:** +```bash +BODY='{"nonce":"'$(date +%s%3N)'","payload":{"cmd":"get_property","props":["lock::lock-status","iot-device::iot-state","battery::battery-level"],"tid":'$RANDOM',"ts":'$(date +%s%3N)',"ver":1},"targetInfo":{"id":"DX_LB2_YOUR_DEVICE_MAC","model":"DX_LB2"}}' + +# Compute signature (requires python) +SIG=$(python3 -c " +import hashlib, hmac +secret = hashlib.md5(('YOUR_ACCESS_TOKEN' + 'wyze_app_secret_key_132').encode()).hexdigest() +print(hmac.new(secret.encode(), '''$BODY'''.encode(), hashlib.md5).hexdigest()) +") + +curl -s -X POST "https://app.wyzecam.com/app/v4/iot3/get-property" \ + -H "Content-Type: application/json; charset=utf-8" \ + -H "access_token: YOUR_ACCESS_TOKEN" \ + -H "appid: 9319141212m2ik" \ + -H "appinfo: wyze_android_3.11.0.758" \ + -H "env: Prod" \ + -H "phoneid: YOUR_PHONE_UUID" \ + -H "requestid: $(uuidgen)" \ + -H "Signature2: $SIG" \ + -d "$BODY" +``` + +### 2. Lock + +Lock the device. + +**Endpoint:** `POST https://app.wyzecam.com/app/v4/iot3/run-action` + +**Request Body:** +```json +{ + "nonce": "1774567407305", + "payload": { + "action": "lock::lock", + "cmd": "run_action", + "params": { + "action_id": 34919, + "type": 1, + "username": "YOUR_WYZE_EMAIL" + }, + "tid": 33646, + "ts": 1774567407305, + "ver": 1 + }, + "targetInfo": { + "id": "DX_LB2_YOUR_DEVICE_MAC", + "model": "DX_LB2" + } +} +``` + +**Response:** +```json +{ + "code": "1", + "ts": 1774568740228, + "msg": "SUCCESS", + "data": null, + "traceId": "56e2a9503e20ab341fa64442f1a3b23c" +} +``` + +### 3. Unlock + +Unlock the device. + +**Endpoint:** `POST https://app.wyzecam.com/app/v4/iot3/run-action` + +**Request Body:** +```json +{ + "nonce": "1774567388251", + "payload": { + "action": "lock::unlock", + "cmd": "run_action", + "params": { + "action_id": 36604, + "type": 1, + "username": "YOUR_WYZE_EMAIL" + }, + "tid": 3133, + "ts": 1774567388250, + "ver": 1 + }, + "targetInfo": { + "id": "DX_LB2_YOUR_DEVICE_MAC", + "model": "DX_LB2" + } +} +``` + +**Response:** +```json +{ + "code": "1", + "ts": 1774568688669, + "msg": "SUCCESS", + "data": null, + "traceId": "226a5611d676b3f9b22c6006c8999113" +} +``` + +### 4. Get App Settings + +**Endpoint:** `POST https://app.wyzecam.com/app/v4/iot3/get-app-setting` + +*(Payload structure TBD - observed in app traffic but not fully explored)* + +### 5. Event History + +**Endpoint:** `POST https://app.wyzecam.com/app/v4/iot3/event-history` + +*(Payload structure TBD - observed in app traffic but not fully explored)* + +--- + +## Payload Field Reference + +### Common Fields + +| Field | Type | Description | +|-------|------|-------------| +| `nonce` | string | Millisecond timestamp as string | +| `payload.cmd` | string | Command: `"get_property"` or `"run_action"` | +| `payload.tid` | int | Transaction ID (random integer) | +| `payload.ts` | int | Millisecond timestamp | +| `payload.ver` | int | Always `1` | +| `targetInfo.id` | string | Device MAC (e.g., `DX_LB2_80482C9C659C`) | +| `targetInfo.model` | string | Device model (e.g., `DX_LB2`) | + +### Action-Specific Fields (run-action) + +| Field | Type | Description | +|-------|------|-------------| +| `payload.action` | string | Action to perform (e.g., `lock::lock`, `lock::unlock`) | +| `payload.params.action_id` | int | Random integer (unique per action invocation) | +| `payload.params.type` | int | Always `1` | +| `payload.params.username` | string | Wyze account email | + +### Property Names + +| Property | Type | Description | +|----------|------|-------------| +| `lock::lock-status` | bool | `true` = locked, `false` = unlocked | +| `lock::door-status` | bool | Door open/close state | +| `lock::lock-install-mode` | int | Installation mode | +| `lock::gyroscope-calibration-step` | int | Calibration state | +| `iot-device::iot-state` | bool | `true` = online | +| `battery::battery-level` | int | 0-100 percentage | +| `battery::power-source` | int | `1` = battery | +| `battery::battery-state-spare` | any | Spare battery state | +| `device-info::firmware-ver` | string | Firmware version (e.g., `"1.0.8"`) | +| `device-info::timezone` | string | Timezone string | + +--- + +## Complete Python Example + +```python +import hashlib +import hmac +import json +import random +import time +import uuid +import requests + +class WyzeLockBoltV2: + BASE_URL = "https://app.wyzecam.com" + SIGNING_SECRET = "wyze_app_secret_key_132" + APP_ID = "9319141212m2ik" + APP_INFO = "wyze_android_3.11.0.758" + + def __init__(self, access_token: str, email: str, device_mac: str): + self.access_token = access_token + self.email = email + self.device_mac = device_mac + self.phone_id = str(uuid.uuid4()) + + # Compute signing key + access_key = self.access_token + self.SIGNING_SECRET + self._secret = hashlib.md5(access_key.encode()).hexdigest() + + def _sign(self, body: str) -> str: + return hmac.new( + self._secret.encode(), body.encode(), hashlib.md5 + ).hexdigest() + + def _headers(self, body: str) -> dict: + return { + "access_token": self.access_token, + "appid": self.APP_ID, + "appinfo": self.APP_INFO, + "appversion": "3.11.0.758", + "env": "Prod", + "phoneid": self.phone_id, + "requestid": uuid.uuid4().hex, + "Signature2": self._sign(body), + "Content-Type": "application/json; charset=utf-8", + } + + def _target(self) -> dict: + model = self.device_mac.rsplit("_", 1)[0] # e.g., DX_LB2 + # Handle models like DX_LB2 where the MAC is DX_LB2_XXXXXXXXXXXX + parts = self.device_mac.split("_") + if len(parts) >= 3: + model = "_".join(parts[:2]) # DX_LB2 + return {"id": self.device_mac, "model": model} + + def _call(self, path: str, payload: dict) -> dict: + body = json.dumps(payload) + resp = requests.post( + f"{self.BASE_URL}{path}", + headers=self._headers(body), + data=body, + ) + return resp.json() + + def get_status(self) -> dict: + """Get lock status, battery, and online state.""" + ts = int(time.time() * 1000) + payload = { + "nonce": str(ts), + "payload": { + "cmd": "get_property", + "props": [ + "lock::lock-status", + "lock::door-status", + "iot-device::iot-state", + "battery::battery-level", + "battery::power-source", + "device-info::firmware-ver", + ], + "tid": random.randint(1000, 99999), + "ts": ts, + "ver": 1, + }, + "targetInfo": self._target(), + } + return self._call("/app/v4/iot3/get-property", payload) + + def lock(self) -> dict: + """Lock the device.""" + ts = int(time.time() * 1000) + payload = { + "nonce": str(ts), + "payload": { + "action": "lock::lock", + "cmd": "run_action", + "params": { + "action_id": random.randint(10000, 99999), + "type": 1, + "username": self.email, + }, + "tid": random.randint(1000, 99999), + "ts": ts, + "ver": 1, + }, + "targetInfo": self._target(), + } + return self._call("/app/v4/iot3/run-action", payload) + + def unlock(self) -> dict: + """Unlock the device.""" + ts = int(time.time() * 1000) + payload = { + "nonce": str(ts), + "payload": { + "action": "lock::unlock", + "cmd": "run_action", + "params": { + "action_id": random.randint(10000, 99999), + "type": 1, + "username": self.email, + }, + "tid": random.randint(1000, 99999), + "ts": ts, + "ver": 1, + }, + "targetInfo": self._target(), + } + return self._call("/app/v4/iot3/run-action", payload) + + +# Usage example: +if __name__ == "__main__": + # First, login to get an access token (see Authentication section above) + ACCESS_TOKEN = "your_access_token_here" + EMAIL = "your_email@example.com" + DEVICE_MAC = "DX_LB2_XXXXXXXXXXXX" # From device discovery + + lock = WyzeLockBoltV2(ACCESS_TOKEN, EMAIL, DEVICE_MAC) + + # Check status + status = lock.get_status() + print("Status:", json.dumps(status, indent=2)) + + # Unlock + result = lock.unlock() + print("Unlock:", result) + + # Lock + result = lock.lock() + print("Lock:", result) +``` + +--- + +## Notes + +- The `DX_LB2` (Lock Bolt v2) has built-in WiFi, unlike the `YD_BT1` (Lock Bolt v1) which is BLE-only +- The `DX_` prefix represents a new device generation; the Wyze Palm Lock (`DX_PVLOC`) likely uses the same IoT3 API +- The `action_id` and `tid` fields appear to be random integers - the server does not enforce uniqueness +- The `nonce` is a string representation of millisecond timestamp; `ts` is the same value as an integer +- Response `"code": "1"` indicates success; other codes indicate errors +- Rate limiting is enforced: `X-RateLimit-Remaining: 299` with 5-minute reset windows +- The `devicemgmt-service.wyze.com` API can also read properties but does NOT support write operations for locks +- This API was discovered by MITM-intercepting the Wyze Android app v3.11.0.758 using HTTP Toolkit + Frida on an Android 12 emulator + +--- + +## Error Codes + +| Code | Message | Meaning | +|------|---------|---------| +| `1` | `SUCCESS` | Request succeeded | +| `1001` | `INVALID_PARAMETER` | Missing or malformed request field | +| `1004` | `INVALID_SIGNATURE` | Signature2 header is wrong | +| `1000` | `internal error` | Server-side processing error | +| `2` | `404 NOT_FOUND` | Endpoint does not exist | + +--- + +## Changelog + +- **2026-03-26**: Initial discovery of IoT3 API for DX_LB2 (Lock Bolt v2). Confirmed lock, unlock, and get-property all work via cloud API.