Initial commit
This commit is contained in:
commit
926e652f67
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/*
|
||||
/*/
|
||||
!.gitignore
|
||||
!custom_components/
|
||||
**/__pycache__/
|
106
custom_components/plejd/__init__.py
Normal file
106
custom_components/plejd/__init__.py
Normal file
@ -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
|
||||
|
||||
|
||||
|
52
custom_components/plejd/config_flow.py
Normal file
52
custom_components/plejd/config_flow.py
Normal file
@ -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)
|
97
custom_components/plejd/light.py
Normal file
97
custom_components/plejd/light.py
Normal file
@ -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
|
||||
|
||||
|
16
custom_components/plejd/manifest.json
Normal file
16
custom_components/plejd/manifest.json
Normal file
@ -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
|
||||
}
|
||||
]
|
||||
}
|
68
custom_components/plejd/pyplejd/__init__.py
Normal file
68
custom_components/plejd/pyplejd/__init__.py
Normal 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()
|
138
custom_components/plejd/pyplejd/api.py
Normal file
138
custom_components/plejd/pyplejd/api.py
Normal 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
|
7
custom_components/plejd/pyplejd/const.py
Normal file
7
custom_components/plejd/pyplejd/const.py
Normal 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}'
|
29
custom_components/plejd/pyplejd/crypto.py
Normal file
29
custom_components/plejd/pyplejd/crypto.py
Normal 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)])
|
191
custom_components/plejd/pyplejd/mesh.py
Normal file
191
custom_components/plejd/pyplejd/mesh.py
Normal 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")
|
53
custom_components/plejd/pyplejd/plejd_device.py
Normal file
53
custom_components/plejd/pyplejd/plejd_device.py
Normal 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)
|
||||
|
||||
|
17
custom_components/plejd/translations/en.json
Normal file
17
custom_components/plejd/translations/en.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Plejd",
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Log in to Plejd",
|
||||
"data": {
|
||||
"username": "Email address",
|
||||
"password": "Password"
|
||||
}
|
||||
},
|
||||
"picksite": {
|
||||
"title": "Pick your site"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user