Initial commit

This commit is contained in:
2022-10-10 22:41:15 +02:00
commit 926e652f67
12 changed files with 779 additions and 0 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -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}'

View File

@@ -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)])

View File

@@ -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("<I", data, 5)[0]
_LOGGER.debug("Timestamp: %s", ts)
return None
dim, state = None, None
if cmd == b"\x00\xc8" or cmd == b"\x00\x98":
state = bool(data[5])
dim = int.from_bytes(data[6:8], "little")
elif cmd == b"\x00\x97":
state = bool(data[5])
return {
"address": address,
"state": state,
"dim": dim,
}
def encode_state(address, state, dim):
if state:
if dim is None:
return binascii.a2b_hex(f"{address:02x}0110009701")
brightness = dim << 8 | dim
return binascii.a2b_hex(f"{address:02x}0110009801{brightness:04x}")
else:
return binascii.a2b_hex(f"{address:02x}0110009700")

View File

@@ -0,0 +1,53 @@
from builtins import property
class PlejdDevice:
def __init__(self, manager, address, BLE_address, name, type, model, dimmable, room, firmware):
self.manager = manager
self.address = address
self._BLE_address = BLE_address
self.name = name
self.type = type
self.model = model
self.dimmable = dimmable
self.room = room
self.firmware = firmware
self.updateCallback = None
self._state = None
self._dim = None
@property
def state(self):
return self._state
@property
def dim(self):
return self._dim
@property
def BLE_address(self):
return self._BLE_address
async def new_state(self, state, dim):
update = False
if state != self._state:
update = True
self._state = state
if dim != self._dim:
update = True
self._dim = dim
if update:
if self.updateCallback:
await self.updateCallback({"state": self._state, "dim": self._dim})
async def turn_on(self, dim=0):
await self.manager.mesh.set_state(self.address, True, dim)
async def turn_off(self):
await self.manager.mesh.set_state(self.address, False)