From 926e652f67cd35a5c605be8d5f8e40b45e40990c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20Lov=C3=A9n?= Date: Mon, 10 Oct 2022 22:41:15 +0200 Subject: [PATCH] Initial commit --- .gitignore | 5 + custom_components/plejd/__init__.py | 106 ++++++++++ custom_components/plejd/config_flow.py | 52 +++++ custom_components/plejd/light.py | 97 +++++++++ custom_components/plejd/manifest.json | 16 ++ custom_components/plejd/pyplejd/__init__.py | 68 +++++++ custom_components/plejd/pyplejd/api.py | 138 +++++++++++++ custom_components/plejd/pyplejd/const.py | 7 + custom_components/plejd/pyplejd/crypto.py | 29 +++ custom_components/plejd/pyplejd/mesh.py | 191 ++++++++++++++++++ .../plejd/pyplejd/plejd_device.py | 53 +++++ custom_components/plejd/translations/en.json | 17 ++ 12 files changed, 779 insertions(+) create mode 100644 .gitignore create mode 100644 custom_components/plejd/__init__.py create mode 100644 custom_components/plejd/config_flow.py create mode 100644 custom_components/plejd/light.py create mode 100644 custom_components/plejd/manifest.json create mode 100644 custom_components/plejd/pyplejd/__init__.py create mode 100644 custom_components/plejd/pyplejd/api.py create mode 100644 custom_components/plejd/pyplejd/const.py create mode 100644 custom_components/plejd/pyplejd/crypto.py create mode 100644 custom_components/plejd/pyplejd/mesh.py create mode 100644 custom_components/plejd/pyplejd/plejd_device.py create mode 100644 custom_components/plejd/translations/en.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9201006 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/* +/*/ +!.gitignore +!custom_components/ +**/__pycache__/ \ No newline at end of file diff --git a/custom_components/plejd/__init__.py b/custom_components/plejd/__init__.py new file mode 100644 index 0000000..3c37405 --- /dev/null +++ b/custom_components/plejd/__init__.py @@ -0,0 +1,106 @@ +import logging + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher +from homeassistant import config_entries +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +import homeassistant.util.dt as dt_util + +from . import pyplejd + +_LOGGER = logging.getLogger(__name__) + +async def async_setup(hass, config): + _LOGGER.error("Setting up plejd") + if not hass.config_entries.async_entries("plejd"): + hass.async_create_task( + hass.config_entries.flow.async_init( + "plejd", context={"source": config_entries.SOURCE_IMPORT}, data={} + ) + ) + + return True + + +BLE_UUID_SUFFIX = '6085-4726-be45-040c957391b5' +PLEJD_SERVICE = f'31ba0001-{BLE_UUID_SUFFIX}' + +DEVICE_ADDR = "fc:f8:73:37:78:0e" + +DOMAIN = "plejd" + +async def async_setup_entry(hass, config_entry): + + _LOGGER.info(config_entry.data) + + plejdManager = pyplejd.PlejdManager(config_entry.data) + devices = await plejdManager.get_devices() + for dev in devices.values(): + ble_device = bluetooth.async_ble_device_from_address( + hass, dev.BLE_address, True + ) + if ble_device: + await plejdManager.close_stale(ble_device) + + hass.data.setdefault(DOMAIN, {}).update( + { + "stopping": False, + "manager": plejdManager, + "devices": devices, + } + ) + + await hass.config_entries.async_forward_entry_setups(config_entry, ["light"]) + + async def _ping(now=None): + if hass.data[DOMAIN]["stopping"]: return + if not await plejdManager.keepalive(): + _LOGGER.debug("Ping failed") + else: + await plejdManager.poll() # TODO: Remove when not needed + hass.data[DOMAIN]["ping_timer"] = async_track_point_in_utc_time( + hass, + _ping, + dt_util.utcnow() + plejdManager.keepalive_interval + ) + # TODO: Pinging often now because that's how to get updates with an ESP repeater + # Once that's been fixed and the esp gets the LASTDATA announcements this can be + # increased significantly to like 5-10 minutes + + hass.async_create_task(_ping()) + + bluetooth.async_register_callback( + hass, + plejdManager.discover_plejd, + BluetoothCallbackMatcher( + connectable=True, + service_uuid=pyplejd.const.PLEJD_SERVICE.lower() + ), + bluetooth.BluetoothScanningMode.PASSIVE + ) + + + + async def _stop(ev): + hass.data[DOMAIN]["stopping"] = True + if "ping_timer" in hass.data[DOMAIN]: + hass.data[DOMAIN]["ping_timer"]() + await plejdManager.disconnect() + + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop) + ) + + for service_info in bluetooth.async_discovered_service_info(hass, True): + if PLEJD_SERVICE.lower() in service_info.advertisement.service_uuids: + plejdManager.discover_plejd(service_info) + + + _LOGGER.error("async_setup_entry done") + + + return True + + + diff --git a/custom_components/plejd/config_flow.py b/custom_components/plejd/config_flow.py new file mode 100644 index 0000000..22613dc --- /dev/null +++ b/custom_components/plejd/config_flow.py @@ -0,0 +1,52 @@ +import voluptuous as vol +import logging +from homeassistant.config_entries import ConfigFlow + +from .pyplejd import api + +_LOGGER = logging.getLogger(__name__) + +class PlejdConfigFlow(ConfigFlow, domain="plejd"): + + VERSION = 1 + + async def async_step_user(self, info=None): + + if info is None: + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required("username"): str, + vol.Required("password"): str + } + ) + ) + _LOGGER.info("Log in with %s %s", info["username"], info["password"]) + self.credentials = info + return await self.async_step_picksite() + + async def async_step_picksite(self, info=None): + if info is None: + sites = await api.get_sites(self.credentials["username"], self.credentials["password"]) + _LOGGER.info(sites) + return self.async_show_form( + step_id="picksite", + data_schema=vol.Schema( + { + vol.Required("site"): vol.In( + { + site["site"]["siteId"]: site["site"]["title"] + for site in sites + } + ) + } + ) + ) + data={ + "username": self.credentials["username"], + "password": self.credentials["password"], + "siteId": info["site"] + } + _LOGGER.debug("Saving: %s", data) + return self.async_create_entry(title="Plejd", data=data) \ No newline at end of file diff --git a/custom_components/plejd/light.py b/custom_components/plejd/light.py new file mode 100644 index 0000000..a499d38 --- /dev/null +++ b/custom_components/plejd/light.py @@ -0,0 +1,97 @@ +from builtins import property +import logging +from homeassistant.components.light import LightEntity, ColorMode +from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator + + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "plejd" + +async def async_setup_entry(hass, config_entry, async_add_entities): + devices = hass.data[DOMAIN]["devices"] + + entities = [] + for d in devices: + dev = devices[d] + if dev.type == "light": + coordinator = Coordinator(hass, dev) + async def updateCallback(data): + coordinator.async_set_updated_data(data) + dev.updateCallback = updateCallback + light = PlejdLight(coordinator, dev) + entities.append(light) + async_add_entities(entities, False) + +class Coordinator(DataUpdateCoordinator): + def __init__(self, hass, device): + super().__init__(hass, _LOGGER, name="Plejd Coordinator") + self.device = device + +class PlejdLight(LightEntity, CoordinatorEntity): + def __init__(self, coordinator, device): + CoordinatorEntity.__init__(self, coordinator) + LightEntity.__init__(self) + self.device = device + + @property + def _data(self): + return self.coordinator.data or {} + + @property + def available(self): + return self._data.get("state", None) is not None + + @property + def device_info(self): + return { + "identifiers": {(DOMAIN, self.device.BLE_address)}, + "name": self.device.name, + "manufacturer": "Plejd", + "model": self.device.model, + #"connections": ???, + "suggested_area": self.device.room, + "sw_version": self.device.firmware, + } + + @property + def has_entity_name(self): + return True + + @property + def name(self): + return None + + @property + def unique_id(self): + return self.device.BLE_address + + @property + def is_on(self): + return self._data.get("state") + + @property + def brightness(self): + return self._data.get("dim",0)/255 + + @property + def supported_color_modes(self): + if self.device.dimmable: + return {ColorMode.BRIGHTNESS} + return {ColorMode.ONOFF} + + @property + def color_mode(self): + if self.device.dimmable: + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF + + async def async_turn_on(self, brightness=None, **_): + await self.device.turn_on(brightness) + pass + + async def async_turn_off(self, **_): + await self.device.turn_off() + pass + + diff --git a/custom_components/plejd/manifest.json b/custom_components/plejd/manifest.json new file mode 100644 index 0000000..5cc5454 --- /dev/null +++ b/custom_components/plejd/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "plejd", + "name": "plejd", + "dependencies": ["bluetooth"], + "codeowners": [], + "requirements": [], + "config_flow": true, + "version": "0.0.1", + "iot_class": "local_push", + "bluetooth": [ + { + "service_uuid": "31ba0001-6085-4726-be45-040c957391b5", + "connectable": true + } + ] +} \ No newline at end of file diff --git a/custom_components/plejd/pyplejd/__init__.py b/custom_components/plejd/pyplejd/__init__.py new file mode 100644 index 0000000..6762a07 --- /dev/null +++ b/custom_components/plejd/pyplejd/__init__.py @@ -0,0 +1,68 @@ +import logging +from datetime import timedelta + +from bleak_retry_connector import close_stale_connections + +from .mesh import PlejdMesh +from .api import get_cryptokey, get_devices +from .plejd_device import PlejdDevice + +_LOGGER = logging.getLogger(__name__) + +class PlejdManager: + + def __init__(self, credentials): + self.credentials = credentials + self.mesh = PlejdMesh() + self.mesh.statecallback = self._update_device + self.devices = { } + self.credentials = credentials + + def discover_plejd(self, service_info, *_): + _LOGGER.debug("Adding plejd %s", service_info) + self.mesh.add_mesh_node(service_info.device) + + async def close_stale(self, device): + _LOGGER.info("Closing stale connections for %s", device) + await close_stale_connections(device) + + @property + def connected(self): + return self.mesh is not None and self.mesh.connected + + async def get_devices(self): + devices = await get_devices(**self.credentials) + self.devices = {k: PlejdDevice(self, **v) for (k,v) in devices.items()} + _LOGGER.info("Devices") + _LOGGER.info(self.devices) + return self.devices + + async def _update_device(self, deviceState): + address = deviceState["address"] + if address in self.devices: + await self.devices[address].new_state(deviceState["state"], deviceState["dim"]) + + @property + def keepalive_interval(self): + if self.mesh.pollonWrite: + return timedelta(seconds=10) + else: + return timedelta(minutes=10) + + async def keepalive(self): + if self.mesh.crypto_key is None: + self.mesh.set_crypto_key(await get_cryptokey(**self.credentials)) + if not self.mesh.connected: + if not await self.mesh.connect(): + return False + return await self.mesh.ping() + + async def disconnect(self): + _LOGGER.info("DISCONNECT") + await self.mesh.disconnect() + + async def poll(self): + await self.mesh.poll() + + async def ping(self): + return await self.mesh.ping() diff --git a/custom_components/plejd/pyplejd/api.py b/custom_components/plejd/pyplejd/api.py new file mode 100644 index 0000000..331ba5f --- /dev/null +++ b/custom_components/plejd/pyplejd/api.py @@ -0,0 +1,138 @@ +from aiohttp import ClientSession +import json +from collections import namedtuple +import logging + +_LOGGER = logging.getLogger(__name__) + +API_APP_ID = 'zHtVqXt8k4yFyk2QGmgp48D9xZr2G94xWYnF4dak' +API_BASE_URL = 'https://cloud.plejd.com' +API_LOGIN_URL = '/parse/login' +API_SITE_LIST_URL = '/parse/functions/getSiteList' +API_SITE_DETAILS_URL = '/parse/functions/getSiteById' + +Device = namedtuple("Device", ["model", "type", "dimmable"]) + +LIGHT = "light" +SENSOR = "sensor" +SWITCH = "switch" + +HARDWARE_ID = { + "0": Device("-unknown-", LIGHT, False), + "1": Device("DIM-01", LIGHT, True), + "2": Device("DIM-02", LIGHT, True), + "3": Device("CTR-01", LIGHT, False), + "4": Device("GWY-01", SENSOR, False), + "5": Device("LED-10", LIGHT, True), + "6": Device("WPH-01", SWITCH, False), + "7": Device("REL-01", SWITCH, False), + "8": Device("-unknown-", LIGHT, False), + "9": Device("-unknown-", LIGHT, False), + "10": Device("-unknown-", LIGHT, False), + "11": Device("DIM-01", LIGHT, True), + "12": Device("-unknown-", LIGHT, False), + "13": Device("Generic", LIGHT, False), + "14": Device("-unknown-", LIGHT, False), + "15": Device("-unknown-", LIGHT, False), + "16": Device("-unknown-", LIGHT, False), + "17": Device("REL-01", SWITCH, False), + "18": Device("REL-02", SWITCH, False), + "19": Device("-unknown-", LIGHT, False), + "20": Device("SPR-01", SWITCH, False), +} + + +headers = { + "X-Parse-Application-Id": API_APP_ID, + "Content-Type": "application/json", + } + +async def _login(session, username, password): + body = { + "username": username, + "password": password, + } + + async with session.post(API_LOGIN_URL, json=body, raise_for_status=True) as resp: + data = await resp.json() + return data.get("sessionToken") + +async def _get_sites(session): + resp = await session.post(API_SITE_LIST_URL, raise_for_status=True) + return await resp.json() + +async def _get_site_details(session, siteId): + async with session.post( + API_SITE_DETAILS_URL, + params={"siteId": siteId}, + raise_for_status=True + ) as resp: + data = await resp.json() + data = data.get("result") + data = data[0] + # with open("site_details.json", "w") as fp: + # fp.write(json.dumps(data)) + return data + +async def get_site_data(username, password, siteId): + # TODO: Memoize this somehow? + async with ClientSession(base_url=API_BASE_URL, headers=headers) as session: + session_token = await _login(session, username, password) + _LOGGER.debug("Session token: %s", session_token) + session.headers["X-Parse-Session-Token"] = session_token + details = await _get_site_details(session, siteId) + return details + +async def get_sites(username, password): + async with ClientSession(base_url=API_BASE_URL, headers=headers) as session: + session_token = await _login(session, username, password) + _LOGGER.debug("Session token: %s", session_token) + session.headers["X-Parse-Session-Token"] = session_token + sites = await _get_sites(session) + _LOGGER.debug("Sites: %s", sites) + return sites["result"] + + +async def get_cryptokey(**credentials): + sitedata = await get_site_data(**credentials) + return sitedata["plejdMesh"]["cryptoKey"] + +async def get_devices(**credentials): + site_data = await get_site_data(**credentials) + + retval = {} + for device in site_data["devices"]: + BLE_address = device["deviceId"] + + def find_deviceId(d): + return next((s for s in d if s["deviceId"] == BLE_address), None) + + address = site_data["deviceAddress"][BLE_address] + + settings = find_deviceId(site_data["outputSettings"]) + if settings is not None: + outputs = site_data["outputAddress"][BLE_address] + address = outputs[str(settings["output"])] + + plejdDevice = find_deviceId(site_data["plejdDevices"]) + deviceType = HARDWARE_ID.get(plejdDevice["hardwareId"], HARDWARE_ID["0"]) + firmware = plejdDevice["firmware"]["version"] + + dimmable = deviceType.dimmable + if settings is not None: + dimmable = settings["dimCurve"] != "NonDimmable" + + room = next((r for r in site_data["rooms"] if r["roomId"] == device["roomId"]), {}) + + retval[address] = { + "address": address, + "BLE_address": BLE_address, + "name": device["title"], + "type": deviceType.type, + "model": deviceType.model, + "dimmable": dimmable, + "room": room.get("title"), + "firmware": firmware, + } + + return retval diff --git a/custom_components/plejd/pyplejd/const.py b/custom_components/plejd/pyplejd/const.py new file mode 100644 index 0000000..826fbfd --- /dev/null +++ b/custom_components/plejd/pyplejd/const.py @@ -0,0 +1,7 @@ +BLE_UUID_SUFFIX = '6085-4726-be45-040c957391b5' +PLEJD_SERVICE = f'31ba0001-{BLE_UUID_SUFFIX}' +PLEJD_LIGHTLEVEL = f'31ba0003-{BLE_UUID_SUFFIX}' +PLEJD_DATA = f'31ba0004-{BLE_UUID_SUFFIX}' +PLEJD_LASTDATA = f'31ba0005-{BLE_UUID_SUFFIX}' +PLEJD_AUTH = f'31ba0009-{BLE_UUID_SUFFIX}' +PLEJD_PING = f'31ba000a-{BLE_UUID_SUFFIX}' diff --git a/custom_components/plejd/pyplejd/crypto.py b/custom_components/plejd/pyplejd/crypto.py new file mode 100644 index 0000000..bb15a47 --- /dev/null +++ b/custom_components/plejd/pyplejd/crypto.py @@ -0,0 +1,29 @@ +import hashlib +import struct + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend + +def encrypt_decrypt(key, addr, data): + buf = addr + addr + addr[:4] + + ct = Cipher( + algorithms.AES(bytearray(key)), + modes.ECB(), + backend=default_backend() + ) + ct = ct.encryptor() + ct = ct.update(buf) + + output = b"" + for i,d in enumerate(data): + output += struct.pack("B", d^ct[i%16]) + return output + +def auth_response(key, challenge): + k = int.from_bytes(key, "big") + c = int.from_bytes(challenge, "big") + intermediate = hashlib.sha256((k^c).to_bytes(16, "big")).digest() + part1 = intermediate[:16] + part2 = intermediate[16:] + return bytearray([(a^b) for (a,b) in zip(part1, part2)]) diff --git a/custom_components/plejd/pyplejd/mesh.py b/custom_components/plejd/pyplejd/mesh.py new file mode 100644 index 0000000..d330b2f --- /dev/null +++ b/custom_components/plejd/pyplejd/mesh.py @@ -0,0 +1,191 @@ +import asyncio +import binascii +import logging +import os +import struct +from bleak import BleakClient, BleakError +from bleak_retry_connector import establish_connection + +from .const import PLEJD_AUTH, PLEJD_LASTDATA, PLEJD_LIGHTLEVEL, PLEJD_PING, PLEJD_DATA +from .crypto import auth_response, encrypt_decrypt + +_LOGGER = logging.getLogger(__name__) + +class PlejdMesh(): + + def __init__(self): + self._connected = False + self.client = None + self.connected_node = None + self.crypto_key = None + self.mesh_nodes = [] + + self.pollonWrite = True + self.statecallback = None + + def add_mesh_node(self, device): + self.mesh_nodes.append(device) + + def set_crypto_key(self, key): + self.crypto_key = binascii.a2b_hex(key.replace("-", "")) + + @property + def connected(self): + if self._connected and self.client and self.client.is_connected: + return True + return False + + async def disconnect(self): + if self.connected and self.client: + try: + await self.client.stop_notify(PLEJD_LASTDATA) + await self.client.stop_notify(PLEJD_LIGHTLEVEL) + await self.client.disconnect() + except BleakError: + pass + self._connected = False + self.client = None + + async def connect(self, disconnect_callback=None, key=None): + await self.disconnect() + _LOGGER.info("Trying to connect") + + client = None + + def _disconnect(arg): + if not self.connected: return + _LOGGER.error("_disconnect %s", arg) + self.client = None + self._connected = False + if disconnect_callback: + disconnect_callback() + + self.mesh_nodes.sort(key = lambda a: a.rssi, reverse = True) + for plejd in self.mesh_nodes: + try: + _LOGGER.warning("Connecting to %s", plejd) + client = await establish_connection(BleakClient, plejd, "plejd", _disconnect) + address = plejd.address + self._connected = True + break + except (BleakError, asyncio.TimeoutError) as e: + _LOGGER.error("Error connecting to plejd: %s", str(e)) + else: + return False + + self.client = client + self.connected_node = binascii.a2b_hex(address.replace(":", ""))[::-1] + + await asyncio.sleep(2) + + if not await self._authenticate(): + await self.disconnect() + return False + + async def _lastdata(_, lastdata): + self.pollonWrite = False + data = encrypt_decrypt(self.crypto_key, self.connected_node, lastdata) + deviceState = decode_state(data) + _LOGGER.debug("Lastdata %s", deviceState) + if self.statecallback and deviceState is not None: + await self.statecallback(deviceState) + + async def _lightlevel(_, lightlevel): + deviceState = { + "address": int(lightlevel[0]), + "state": bool(lightlevel[1]), + "dim": int.from_bytes(lightlevel[5:7], "little"), + } + _LOGGER.debug("Lightlevel %s", deviceState) + if self.statecallback and deviceState is not None: + await self.statecallback(deviceState) + + await client.start_notify(PLEJD_LASTDATA, _lastdata) + await client.start_notify(PLEJD_LIGHTLEVEL, _lightlevel) + + return True + + async def write(self, payload): + try: + data = encrypt_decrypt(self.crypto_key, self.connected_node, payload) + await self.client.write_gatt_char(PLEJD_DATA, data, response=True) + except (BleakError, asyncio.TimeoutError) as e: + _LOGGER.error("Write failed: %s", str(e)) + return False + return True + + async def set_state(self, address, state, dim=0): + payload = encode_state(address, state, dim) + retval = await self.write(payload) + if self.pollonWrite: + await self.poll() + return retval + + async def ping(self): + if self.client is None: + return False + try: + ping = bytearray(os.urandom(1)) + _LOGGER.debug("Ping(%s)", int.from_bytes(ping, "little")) + await self.client.write_gatt_char(PLEJD_PING, ping, response=True) + pong = await self.client.read_gatt_char(PLEJD_PING) + _LOGGER.debug("Pong(%s)", int.from_bytes(pong, "little")) + if (ping[0] + 1) & 0xFF == pong[0]: + return True + except (BleakError, asyncio.TimeoutError) as e: + _LOGGER.warning("Error sending ping: %s", str(e)) + self.pollonWrite = True + return False + + async def poll(self): + if self.client is None: + return + await self.client.write_gatt_char(PLEJD_LIGHTLEVEL, b"\x01", response=True) + + async def _authenticate(self): + if self.client is None: + return False + try: + _LOGGER.debug("Authenticating") + await self.client.write_gatt_char(PLEJD_AUTH, [0], response=True) + challenge = await self.client.read_gatt_char(PLEJD_AUTH) + response = auth_response(self.crypto_key, challenge) + await self.client.write_gatt_char(PLEJD_AUTH, response, response=True) + _LOGGER.debug("Authenticated") + return True + except (BleakError, asyncio.TimeoutError) as e: + _LOGGER.warning("Plejd authentication failed: %s", str(e)) + return False + + +def decode_state(data): + address = int(data[0]) + cmd = data[3:5] + if address == 1 and cmd == b"\x00\x1b": + _LOGGER.debug("Got time data?") + ts = struct.unpack_from("