Compare commits
17 Commits
1812642773
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 49e97e0a72 | |||
| 0be19a6211 | |||
| f730b9b8c6 | |||
| 54bdce2646 | |||
| 600194b16d | |||
| 071aa7a9ac | |||
| bdd6ab840c | |||
| b241bf10b0 | |||
| 8797b119f2 | |||
| 87ed481f80 | |||
| d4254be81b | |||
| a7ecb054c1 | |||
| 5dd7dd331b | |||
| 0d52d7dfcb | |||
| ee45fa097d | |||
| 2a1bb9ee63 | |||
| b2592a2f4c |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@
|
|||||||
/*/
|
/*/
|
||||||
!.gitignore
|
!.gitignore
|
||||||
!custom_components/
|
!custom_components/
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
|
!README.md
|
||||||
|
|||||||
43
README.md
Normal file
43
README.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
Plejd integration for Home Assistant
|
||||||
|
===
|
||||||
|
|
||||||
|
No Plejd hub needed.
|
||||||
|
|
||||||
|
This integration is a work in progress.
|
||||||
|
|
||||||
|
I do not guarantee it will work or even that it will not harm your system. I don't see what harm it *could* cause, but I promise nothing.
|
||||||
|
|
||||||
|
## Installation for testing
|
||||||
|
|
||||||
|
- Make sure you have a working Bluetooth integration in Home Assistant (a bluetooth proxy should work too)
|
||||||
|
- Make sure you have no other plejd custom components or add-ons running.
|
||||||
|
- Put the `plejd` directory in your `<config>/custom_components`.
|
||||||
|
- Restart Home Assistant
|
||||||
|
- Hopefully, your Plejd mesh will be auto discovered and you should see a message popping up in your integrations page.
|
||||||
|
- Log in with the credentials you use in the Plejd app when prompted (email address and password)
|
||||||
|
|
||||||
|
|
||||||
|
## Getting more debug information
|
||||||
|
|
||||||
|
Add this to your Home Assistant configuration.yaml to get as much information as possible
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
logger:
|
||||||
|
default: warn
|
||||||
|
logs:
|
||||||
|
custom_components.plejd: debug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Other integrations
|
||||||
|
|
||||||
|
Here are some other Plejd integrations by some awesome people and how this is different:
|
||||||
|
|
||||||
|
I've pulled inspiration from all of them.
|
||||||
|
|
||||||
|
| | | |
|
||||||
|
|---|---|---|
|
||||||
|
|[hassio-plejd](https://github.com/icanos/hassio-plejd) | [@icanos](https://github.com/icanos) | Works only with Home Assistant OS.<br> Requires exclusive access to the Bluetooth dongle.<br> Does not support Bluetooth Proxy. |
|
||||||
|
|[plejd2mqtt](https://github.com/thomasloven/plejd2mqtt) | [@thomasloven](https://github.com/thomasloven) | Somewhat outdated stand-alone version of the above.<br> Relies on MQTT for communication.<br> Requires exclusive access to the Bluetooth dongle.<br> Does not support Bluetooth Proxy.<br> Does not support switches or scenes. |
|
||||||
|
|[ha-plejd](https://github.com/klali/ha-plejd) | [@klali](https://github.com/klali) <br> (also check [this fork](https://github.com/bnordli/ha-plejd/tree/to-integration) by [@bnordli](https://github.com/bnordli))| Does not communicate with the Plejd API and therefore requires you to extract the cryptokey and device data from the Plejd app somehow.<br> No auto discovery.<br> Requires exclusive access to the Bluetooth dongle.<br> Does not support Bluetooth Proxy. |
|
||||||
|
| [homey-plejd](https://github.com/emilohman/homey-plejd) | [@emilohman](https://github.com/emilohman) | For Homey |
|
||||||
|
| [homebridge-plejd](https://github.com/blommegard/homebridge-plejd) | [@blommegard](https://github.com/blommegard) | For Homebridge |
|
||||||
@@ -15,7 +15,6 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
DOMAIN = "plejd"
|
DOMAIN = "plejd"
|
||||||
|
|
||||||
async def async_setup(hass, config):
|
async def async_setup(hass, config):
|
||||||
_LOGGER.error("Setting up plejd")
|
|
||||||
if not hass.config_entries.async_entries("plejd"):
|
if not hass.config_entries.async_entries("plejd"):
|
||||||
hass.async_create_task(
|
hass.async_create_task(
|
||||||
hass.config_entries.flow.async_init(
|
hass.config_entries.flow.async_init(
|
||||||
@@ -31,7 +30,9 @@ async def async_setup_entry(hass, config_entry):
|
|||||||
plejdManager = pyplejd.PlejdManager(config_entry.data)
|
plejdManager = pyplejd.PlejdManager(config_entry.data)
|
||||||
|
|
||||||
devices = await plejdManager.get_devices()
|
devices = await plejdManager.get_devices()
|
||||||
|
scenes = await plejdManager.get_scenes()
|
||||||
|
|
||||||
|
# Add a service entry if there are no devices - just so the user can get diagnostics data
|
||||||
if sum(d.type in ["light", "switch"] for d in devices.values()) == 0:
|
if sum(d.type in ["light", "switch"] for d in devices.values()) == 0:
|
||||||
site_data = await plejdManager.get_site_data()
|
site_data = await plejdManager.get_site_data()
|
||||||
|
|
||||||
@@ -44,6 +45,22 @@ async def async_setup_entry(hass, config_entry):
|
|||||||
entry_type=dr.DeviceEntryType.SERVICE,
|
entry_type=dr.DeviceEntryType.SERVICE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
hass.data.setdefault(DOMAIN, {}).update(
|
||||||
|
{
|
||||||
|
"stopping": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
hass.data[DOMAIN].setdefault("devices", {}).update({
|
||||||
|
config_entry.entry_id: devices
|
||||||
|
})
|
||||||
|
hass.data[DOMAIN].setdefault("scenes", {}).update({
|
||||||
|
config_entry.entry_id: scenes
|
||||||
|
})
|
||||||
|
hass.data[DOMAIN].setdefault("manager", {}).update({
|
||||||
|
config_entry.entry_id: plejdManager,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Close any stale connections that may be open
|
||||||
for dev in devices.values():
|
for dev in devices.values():
|
||||||
ble_device = bluetooth.async_ble_device_from_address(
|
ble_device = bluetooth.async_ble_device_from_address(
|
||||||
hass, dev.BLE_address, True
|
hass, dev.BLE_address, True
|
||||||
@@ -51,16 +68,30 @@ async def async_setup_entry(hass, config_entry):
|
|||||||
if ble_device:
|
if ble_device:
|
||||||
await plejdManager.close_stale(ble_device)
|
await plejdManager.close_stale(ble_device)
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {}).update(
|
# Search for devices in the mesh
|
||||||
{
|
def _discovered_plejd(service_info, *_):
|
||||||
"stopping": False,
|
plejdManager.add_mesh_device(service_info.device)
|
||||||
"manager": plejdManager,
|
bluetooth.async_register_callback(
|
||||||
"devices": devices,
|
hass,
|
||||||
}
|
_discovered_plejd,
|
||||||
)
|
BluetoothCallbackMatcher(
|
||||||
|
connectable=True,
|
||||||
|
service_uuid=pyplejd.const.PLEJD_SERVICE.lower()
|
||||||
|
),
|
||||||
|
bluetooth.BluetoothScanningMode.PASSIVE
|
||||||
|
)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(config_entry, ["light", "switch"])
|
# Run through already discovered devices and add plejds to the mesh
|
||||||
|
for service_info in bluetooth.async_discovered_service_info(hass, True):
|
||||||
|
if pyplejd.PLEJD_SERVICE.lower() in service_info.advertisement.service_uuids:
|
||||||
|
plejdManager.add_mesh_device(service_info.device)
|
||||||
|
|
||||||
|
|
||||||
|
await hass.config_entries.async_forward_entry_setups(config_entry,
|
||||||
|
["light", "switch", "button"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ping mesh intermittently to keep the connection alive
|
||||||
async def _ping(now=None):
|
async def _ping(now=None):
|
||||||
if hass.data[DOMAIN]["stopping"]: return
|
if hass.data[DOMAIN]["stopping"]: return
|
||||||
if not await plejdManager.keepalive():
|
if not await plejdManager.keepalive():
|
||||||
@@ -72,17 +103,7 @@ async def async_setup_entry(hass, config_entry):
|
|||||||
)
|
)
|
||||||
hass.async_create_task(_ping())
|
hass.async_create_task(_ping())
|
||||||
|
|
||||||
bluetooth.async_register_callback(
|
# Cleanup when Home Assistant stops
|
||||||
hass,
|
|
||||||
plejdManager.discover_plejd,
|
|
||||||
BluetoothCallbackMatcher(
|
|
||||||
connectable=True,
|
|
||||||
service_uuid=pyplejd.const.PLEJD_SERVICE.lower()
|
|
||||||
),
|
|
||||||
bluetooth.BluetoothScanningMode.PASSIVE
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _stop(ev):
|
async def _stop(ev):
|
||||||
hass.data[DOMAIN]["stopping"] = True
|
hass.data[DOMAIN]["stopping"] = True
|
||||||
if "ping_timer" in hass.data[DOMAIN]:
|
if "ping_timer" in hass.data[DOMAIN]:
|
||||||
@@ -91,13 +112,5 @@ async def async_setup_entry(hass, config_entry):
|
|||||||
config_entry.async_on_unload(
|
config_entry.async_on_unload(
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop)
|
||||||
)
|
)
|
||||||
|
|
||||||
for service_info in bluetooth.async_discovered_service_info(hass, True):
|
|
||||||
if pyplejd.PLEJD_SERVICE.lower() in service_info.advertisement.service_uuids:
|
|
||||||
plejdManager.discover_plejd(service_info)
|
|
||||||
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
47
custom_components/plejd/button.py
Normal file
47
custom_components/plejd/button.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DOMAIN = "plejd"
|
||||||
|
|
||||||
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
|
scenes = hass.data[DOMAIN]["scenes"].get(config_entry.entry_id, [])
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
for s in scenes:
|
||||||
|
button = PlejdSceneButton(s, config_entry.entry_id)
|
||||||
|
entities.append(button)
|
||||||
|
async_add_entities(entities, False)
|
||||||
|
|
||||||
|
class PlejdSceneButton(ButtonEntity):
|
||||||
|
|
||||||
|
def __init__(self, device, entry_id):
|
||||||
|
super().__init__()
|
||||||
|
self.device = device
|
||||||
|
self.entry_id = entry_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self):
|
||||||
|
return {
|
||||||
|
"identifiers": {(DOMAIN, f"{self.entry_id}:{self.device.index}")},
|
||||||
|
"name": self.device.name,
|
||||||
|
"manufacturer": "Plejd",
|
||||||
|
#"connections": ???,
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_entity_name(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unique_id(self):
|
||||||
|
return f"{self.entry_id}:{self.device.index}"
|
||||||
|
|
||||||
|
async def async_press(self):
|
||||||
|
await self.device.activate()
|
||||||
@@ -13,6 +13,8 @@ class PlejdConfigFlow(ConfigFlow, domain="plejd"):
|
|||||||
async def async_step_user(self, info=None):
|
async def async_step_user(self, info=None):
|
||||||
|
|
||||||
if info is None:
|
if info is None:
|
||||||
|
if self._async_current_entries():
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="user",
|
step_id="user",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(
|
||||||
@@ -22,31 +24,27 @@ class PlejdConfigFlow(ConfigFlow, domain="plejd"):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
_LOGGER.info("Log in with %s %s", info["username"], info["password"])
|
|
||||||
self.credentials = info
|
self.credentials = info
|
||||||
return await self.async_step_picksite()
|
return await self.async_step_picksite()
|
||||||
|
|
||||||
async def async_step_picksite(self, info=None):
|
async def async_step_picksite(self, info=None):
|
||||||
if info is None:
|
if info is None:
|
||||||
sites = await api.get_sites(self.credentials["username"], self.credentials["password"])
|
sites = await api.get_sites(self.credentials["username"], self.credentials["password"])
|
||||||
_LOGGER.info(sites)
|
self.sites = {site["site"]["siteId"]: site["site"]["title"] for site in sites}
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="picksite",
|
step_id="picksite",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("site"): vol.In(
|
vol.Required("site"): vol.In(self.sites)
|
||||||
{
|
|
||||||
site["site"]["siteId"]: site["site"]["title"]
|
|
||||||
for site in sites
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
siteTitle = self.sites[info["site"]]
|
||||||
data={
|
data={
|
||||||
"username": self.credentials["username"],
|
"username": self.credentials["username"],
|
||||||
"password": self.credentials["password"],
|
"password": self.credentials["password"],
|
||||||
"siteId": info["site"]
|
"siteId": info["site"],
|
||||||
|
"siteTitle": siteTitle,
|
||||||
}
|
}
|
||||||
_LOGGER.debug("Saving: %s", data)
|
return self.async_create_entry(title=siteTitle, data=data)
|
||||||
return self.async_create_entry(title="Plejd", data=data)
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
from builtins import property
|
|
||||||
import logging
|
import logging
|
||||||
from homeassistant.components.light import LightEntity, ColorMode
|
from homeassistant.components.light import LightEntity, ColorMode
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity, DataUpdateCoordinator
|
||||||
@@ -9,16 +8,14 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
DOMAIN = "plejd"
|
DOMAIN = "plejd"
|
||||||
|
|
||||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
devices = hass.data[DOMAIN]["devices"]
|
devices = hass.data[DOMAIN]["devices"].get(config_entry.entry_id, [])
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
for d in devices:
|
for d in devices:
|
||||||
dev = devices[d]
|
dev = devices[d]
|
||||||
if dev.type == "light":
|
if dev.type == "light":
|
||||||
coordinator = Coordinator(hass, dev)
|
coordinator = Coordinator(hass, dev)
|
||||||
async def updateCallback(data):
|
dev.updateCallback = coordinator.async_set_updated_data
|
||||||
coordinator.async_set_updated_data(data)
|
|
||||||
dev.updateCallback = updateCallback
|
|
||||||
light = PlejdLight(coordinator, dev)
|
light = PlejdLight(coordinator, dev)
|
||||||
entities.append(light)
|
entities.append(light)
|
||||||
async_add_entities(entities, False)
|
async_add_entities(entities, False)
|
||||||
@@ -38,14 +35,10 @@ class PlejdLight(LightEntity, CoordinatorEntity):
|
|||||||
def _data(self):
|
def _data(self):
|
||||||
return self.coordinator.data or {}
|
return self.coordinator.data or {}
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self):
|
|
||||||
return self._data.get("state", None) is not None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self):
|
def device_info(self):
|
||||||
return {
|
return {
|
||||||
"identifiers": {(DOMAIN, self.device.BLE_address)},
|
"identifiers": {(DOMAIN, f"{self.device.BLE_address}:{self.device.address}")},
|
||||||
"name": self.device.name,
|
"name": self.device.name,
|
||||||
"manufacturer": "Plejd",
|
"manufacturer": "Plejd",
|
||||||
"model": {self.device.model},
|
"model": {self.device.model},
|
||||||
@@ -64,15 +57,15 @@ class PlejdLight(LightEntity, CoordinatorEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
return self.device.BLE_address
|
return f"{self.device.BLE_address}:{self.device.address}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self):
|
def is_on(self):
|
||||||
return self._data.get("state")
|
return self.device.state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def brightness(self):
|
def brightness(self):
|
||||||
return self._data.get("dim",0)
|
return self.device.dim
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supported_color_modes(self):
|
def supported_color_modes(self):
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from datetime import timedelta
|
|||||||
from bleak_retry_connector import close_stale_connections
|
from bleak_retry_connector import close_stale_connections
|
||||||
|
|
||||||
from .mesh import PlejdMesh
|
from .mesh import PlejdMesh
|
||||||
from .api import get_cryptokey, get_devices, get_site_data
|
from .api import get_cryptokey, get_devices, get_site_data, get_scenes
|
||||||
from .plejd_device import PlejdDevice
|
from .plejd_device import PlejdDevice, PlejdScene
|
||||||
|
|
||||||
from .const import PLEJD_SERVICE
|
from .const import PLEJD_SERVICE
|
||||||
|
|
||||||
@@ -18,14 +18,19 @@ class PlejdManager:
|
|||||||
self.mesh = PlejdMesh()
|
self.mesh = PlejdMesh()
|
||||||
self.mesh.statecallback = self._update_device
|
self.mesh.statecallback = self._update_device
|
||||||
self.devices = { }
|
self.devices = { }
|
||||||
|
self.scenes = []
|
||||||
self.credentials = credentials
|
self.credentials = credentials
|
||||||
|
|
||||||
def discover_plejd(self, service_info, *_):
|
def add_mesh_device(self, device):
|
||||||
_LOGGER.debug("Adding plejd %s", service_info)
|
_LOGGER.debug("Adding plejd %s", device)
|
||||||
self.mesh.add_mesh_node(service_info.device)
|
# for d in self.devices.values():
|
||||||
|
# addr = device.address.replace(":","").replace("-","").upper()
|
||||||
|
# if d.BLE_address.upper() == addr or addr in device.name:
|
||||||
|
return self.mesh.add_mesh_node(device)
|
||||||
|
# _LOGGER.debug("Device was not expected in current mesh")
|
||||||
|
|
||||||
async def close_stale(self, device):
|
async def close_stale(self, device):
|
||||||
_LOGGER.info("Closing stale connections for %s", device)
|
_LOGGER.debug("Closing stale connections for %s", device)
|
||||||
await close_stale_connections(device)
|
await close_stale_connections(device)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -38,10 +43,17 @@ class PlejdManager:
|
|||||||
async def get_devices(self):
|
async def get_devices(self):
|
||||||
devices = await get_devices(**self.credentials)
|
devices = await get_devices(**self.credentials)
|
||||||
self.devices = {k: PlejdDevice(self, **v) for (k,v) in devices.items()}
|
self.devices = {k: PlejdDevice(self, **v) for (k,v) in devices.items()}
|
||||||
_LOGGER.info("Devices")
|
_LOGGER.debug("Devices")
|
||||||
_LOGGER.info(self.devices)
|
_LOGGER.debug(self.devices)
|
||||||
return self.devices
|
return self.devices
|
||||||
|
|
||||||
|
async def get_scenes(self):
|
||||||
|
scenes = await get_scenes(**self.credentials)
|
||||||
|
self.scenes = [PlejdScene(self, **s) for s in scenes]
|
||||||
|
_LOGGER.debug("Scenes")
|
||||||
|
_LOGGER.debug(self.scenes)
|
||||||
|
return self.scenes
|
||||||
|
|
||||||
async def _update_device(self, deviceState):
|
async def _update_device(self, deviceState):
|
||||||
address = deviceState["address"]
|
address = deviceState["address"]
|
||||||
if address in self.devices:
|
if address in self.devices:
|
||||||
@@ -60,7 +72,10 @@ class PlejdManager:
|
|||||||
if not self.mesh.connected:
|
if not self.mesh.connected:
|
||||||
if not await self.mesh.connect():
|
if not await self.mesh.connect():
|
||||||
return False
|
return False
|
||||||
return await self.mesh.ping()
|
retval = await self.mesh.ping()
|
||||||
|
if retval and self.mesh.pollonWrite:
|
||||||
|
await self.mesh.poll()
|
||||||
|
return retval
|
||||||
|
|
||||||
async def disconnect(self):
|
async def disconnect(self):
|
||||||
_LOGGER.debug("DISCONNECT")
|
_LOGGER.debug("DISCONNECT")
|
||||||
|
|||||||
@@ -43,26 +43,23 @@ async def _get_site_details(session, siteId):
|
|||||||
# fp.write(json.dumps(data))
|
# fp.write(json.dumps(data))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
site_data = None
|
site_data = {}
|
||||||
async def get_site_data(username, password, siteId):
|
async def get_site_data(username, password, siteId, **_):
|
||||||
global site_data
|
global site_data
|
||||||
if site_data is not None:
|
if site_data.get(siteId) is not None:
|
||||||
return site_data
|
return site_data.get(siteId)
|
||||||
async with ClientSession(base_url=API_BASE_URL, headers=headers) as session:
|
async with ClientSession(base_url=API_BASE_URL, headers=headers) as session:
|
||||||
session_token = await _login(session, username, password)
|
session_token = await _login(session, username, password)
|
||||||
_LOGGER.debug("Session token: %s", session_token)
|
|
||||||
session.headers["X-Parse-Session-Token"] = session_token
|
session.headers["X-Parse-Session-Token"] = session_token
|
||||||
details = await _get_site_details(session, siteId)
|
details = await _get_site_details(session, siteId)
|
||||||
site_data = details
|
site_data[siteId] = details
|
||||||
return details
|
return details
|
||||||
|
|
||||||
async def get_sites(username, password):
|
async def get_sites(username, password, **_):
|
||||||
async with ClientSession(base_url=API_BASE_URL, headers=headers) as session:
|
async with ClientSession(base_url=API_BASE_URL, headers=headers) as session:
|
||||||
session_token = await _login(session, username, password)
|
session_token = await _login(session, username, password)
|
||||||
_LOGGER.debug("Session token: %s", session_token)
|
|
||||||
session.headers["X-Parse-Session-Token"] = session_token
|
session.headers["X-Parse-Session-Token"] = session_token
|
||||||
sites = await _get_sites(session)
|
sites = await _get_sites(session)
|
||||||
_LOGGER.debug("Sites: %s", sites)
|
|
||||||
return sites["result"]
|
return sites["result"]
|
||||||
|
|
||||||
|
|
||||||
@@ -77,13 +74,11 @@ async def get_devices(**credentials):
|
|||||||
for device in site_data["devices"]:
|
for device in site_data["devices"]:
|
||||||
BLE_address = device["deviceId"]
|
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]
|
address = site_data["deviceAddress"][BLE_address]
|
||||||
dimmable = None
|
dimmable = None
|
||||||
|
|
||||||
settings = find_deviceId(site_data["outputSettings"])
|
settings = next((s for s in site_data["outputSettings"]
|
||||||
|
if s["deviceParseId"] == device["objectId"]))
|
||||||
if settings is not None:
|
if settings is not None:
|
||||||
outputs = site_data["outputAddress"][BLE_address]
|
outputs = site_data["outputAddress"][BLE_address]
|
||||||
address = outputs[str(settings["output"])]
|
address = outputs[str(settings["output"])]
|
||||||
@@ -91,9 +86,10 @@ async def get_devices(**credentials):
|
|||||||
if settings is not None and settings["dimCurve"] == "nonDimmable":
|
if settings is not None and settings["dimCurve"] == "nonDimmable":
|
||||||
dimmable = False
|
dimmable = False
|
||||||
|
|
||||||
plejdDevice = find_deviceId(site_data["plejdDevices"])
|
plejdDevice = next((s for s in site_data["plejdDevices"]
|
||||||
|
if s["deviceId"] == BLE_address))
|
||||||
room = next((r for r in site_data["rooms"] if r["roomId"] == device["roomId"]), {})
|
room = next((r for r in site_data["rooms"] if r["roomId"] == device["roomId"]), {})
|
||||||
|
|
||||||
retval[address] = {
|
retval[address] = {
|
||||||
"address": address,
|
"address": address,
|
||||||
"BLE_address": BLE_address,
|
"BLE_address": BLE_address,
|
||||||
@@ -107,3 +103,18 @@ async def get_devices(**credentials):
|
|||||||
}
|
}
|
||||||
|
|
||||||
return retval
|
return retval
|
||||||
|
|
||||||
|
async def get_scenes(**credentials):
|
||||||
|
site_data = await get_site_data(**credentials)
|
||||||
|
retval = []
|
||||||
|
for scene in site_data["scenes"]:
|
||||||
|
if scene["hiddenFromSceneList"]: continue
|
||||||
|
sceneId = scene["sceneId"]
|
||||||
|
index = site_data["sceneIndex"].get(sceneId)
|
||||||
|
|
||||||
|
retval.append({
|
||||||
|
"index": index,
|
||||||
|
"title": scene["title"],
|
||||||
|
})
|
||||||
|
|
||||||
|
return retval
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import binascii
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import struct
|
import struct
|
||||||
|
from datetime import datetime
|
||||||
from bleak import BleakClient, BleakError
|
from bleak import BleakClient, BleakError
|
||||||
from bleak_retry_connector import establish_connection
|
from bleak_retry_connector import establish_connection
|
||||||
|
|
||||||
@@ -24,7 +25,10 @@ class PlejdMesh():
|
|||||||
self.statecallback = None
|
self.statecallback = None
|
||||||
|
|
||||||
def add_mesh_node(self, device):
|
def add_mesh_node(self, device):
|
||||||
self.mesh_nodes.append(device)
|
if device not in self.mesh_nodes:
|
||||||
|
self.mesh_nodes.append(device)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Plejd already added")
|
||||||
|
|
||||||
def set_crypto_key(self, key):
|
def set_crypto_key(self, key):
|
||||||
self.crypto_key = binascii.a2b_hex(key.replace("-", ""))
|
self.crypto_key = binascii.a2b_hex(key.replace("-", ""))
|
||||||
@@ -48,13 +52,13 @@ class PlejdMesh():
|
|||||||
|
|
||||||
async def connect(self, disconnect_callback=None, key=None):
|
async def connect(self, disconnect_callback=None, key=None):
|
||||||
await self.disconnect()
|
await self.disconnect()
|
||||||
_LOGGER.info("Trying to connect")
|
_LOGGER.debug("Trying to connect to mesh")
|
||||||
|
|
||||||
client = None
|
client = None
|
||||||
|
|
||||||
def _disconnect(arg):
|
def _disconnect(arg):
|
||||||
if not self.connected: return
|
if not self.connected: return
|
||||||
_LOGGER.error("_disconnect %s", arg)
|
_LOGGER.debug("_disconnect %s", arg)
|
||||||
self.client = None
|
self.client = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
if disconnect_callback:
|
if disconnect_callback:
|
||||||
@@ -63,42 +67,50 @@ class PlejdMesh():
|
|||||||
self.mesh_nodes.sort(key = lambda a: a.rssi, reverse = True)
|
self.mesh_nodes.sort(key = lambda a: a.rssi, reverse = True)
|
||||||
for plejd in self.mesh_nodes:
|
for plejd in self.mesh_nodes:
|
||||||
try:
|
try:
|
||||||
_LOGGER.warning("Connecting to %s", plejd)
|
_LOGGER.debug("Connecting to %s", plejd)
|
||||||
client = await establish_connection(BleakClient, plejd, "plejd", _disconnect)
|
client = await establish_connection(BleakClient, plejd, "plejd", _disconnect)
|
||||||
address = plejd.address
|
address = plejd.address
|
||||||
self._connected = True
|
self._connected = True
|
||||||
|
self.client = client
|
||||||
|
_LOGGER.debug("Connected to Plejd mesh")
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
if not await self._authenticate():
|
||||||
|
await self.client.disconnect()
|
||||||
|
self._connected = False
|
||||||
|
continue
|
||||||
break
|
break
|
||||||
except (BleakError, asyncio.TimeoutError) as e:
|
except (BleakError, asyncio.TimeoutError) as e:
|
||||||
_LOGGER.error("Error connecting to plejd: %s", str(e))
|
_LOGGER.warning("Error connecting to Plejd device: %s", str(e))
|
||||||
else:
|
else:
|
||||||
|
if len(self.mesh_nodes) == 0:
|
||||||
|
_LOGGER.debug("Failed to connect to plejd mesh - no devices discovered")
|
||||||
|
else:
|
||||||
|
_LOGGER.warning("Failed to connect to plejd mesh - %s", self.mesh_nodes)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
self.client = client
|
|
||||||
self.connected_node = binascii.a2b_hex(address.replace(":", "").replace("-", ""))[::-1]
|
self.connected_node = binascii.a2b_hex(address.replace(":", "").replace("-", ""))[::-1]
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
if not await self._authenticate():
|
|
||||||
await self.disconnect()
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _lastdata(_, lastdata):
|
async def _lastdata(_, lastdata):
|
||||||
self.pollonWrite = False
|
self.pollonWrite = False
|
||||||
data = encrypt_decrypt(self.crypto_key, self.connected_node, lastdata)
|
data = encrypt_decrypt(self.crypto_key, self.connected_node, lastdata)
|
||||||
|
_LOGGER.debug("Received LastData %s", data)
|
||||||
deviceState = decode_state(data)
|
deviceState = decode_state(data)
|
||||||
_LOGGER.debug("Lastdata %s", deviceState)
|
_LOGGER.debug("Decoded LastData %s", deviceState)
|
||||||
if self.statecallback and deviceState is not None:
|
if self.statecallback and deviceState is not None:
|
||||||
await self.statecallback(deviceState)
|
await self.statecallback(deviceState)
|
||||||
|
|
||||||
async def _lightlevel(_, lightlevel):
|
async def _lightlevel(_, lightlevel):
|
||||||
deviceState = {
|
_LOGGER.debug("Received LightLevel %s", lightlevel)
|
||||||
"address": int(lightlevel[0]),
|
for i in range(0, len(lightlevel), 10):
|
||||||
"state": bool(lightlevel[1]),
|
ll = lightlevel[i:i+10]
|
||||||
"dim": int.from_bytes(lightlevel[5:7], "little"),
|
deviceState = {
|
||||||
}
|
"address": int(ll[0]),
|
||||||
_LOGGER.debug("Lightlevel %s", deviceState)
|
"state": bool(ll[1]),
|
||||||
if self.statecallback and deviceState is not None:
|
"dim": int.from_bytes(ll[5:7], "little"),
|
||||||
await self.statecallback(deviceState)
|
}
|
||||||
|
_LOGGER.debug("Decoded 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_LASTDATA, _lastdata)
|
||||||
await client.start_notify(PLEJD_LIGHTLEVEL, _lightlevel)
|
await client.start_notify(PLEJD_LIGHTLEVEL, _lightlevel)
|
||||||
@@ -109,10 +121,11 @@ class PlejdMesh():
|
|||||||
|
|
||||||
async def write(self, payload):
|
async def write(self, payload):
|
||||||
try:
|
try:
|
||||||
|
_LOGGER.debug("Writing data to Plejd mesh CT: %s", payload)
|
||||||
data = encrypt_decrypt(self.crypto_key, self.connected_node, payload)
|
data = encrypt_decrypt(self.crypto_key, self.connected_node, payload)
|
||||||
await self.client.write_gatt_char(PLEJD_DATA, data, response=True)
|
await self.client.write_gatt_char(PLEJD_DATA, data, response=True)
|
||||||
except (BleakError, asyncio.TimeoutError) as e:
|
except (BleakError, asyncio.TimeoutError) as e:
|
||||||
_LOGGER.error("Write failed: %s", str(e))
|
_LOGGER.warning("Plejd mesh write command failed: %s", str(e))
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -123,6 +136,13 @@ class PlejdMesh():
|
|||||||
await self.poll()
|
await self.poll()
|
||||||
return retval
|
return retval
|
||||||
|
|
||||||
|
async def activate_scene(self, index):
|
||||||
|
payload = binascii.a2b_hex(f"0201100021{index:02x}")
|
||||||
|
retval = await self.write(payload)
|
||||||
|
if self.pollonWrite:
|
||||||
|
await self.poll()
|
||||||
|
return retval
|
||||||
|
|
||||||
async def ping(self):
|
async def ping(self):
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
return False
|
return False
|
||||||
@@ -135,25 +155,29 @@ class PlejdMesh():
|
|||||||
if (ping[0] + 1) & 0xFF == pong[0]:
|
if (ping[0] + 1) & 0xFF == pong[0]:
|
||||||
return True
|
return True
|
||||||
except (BleakError, asyncio.TimeoutError) as e:
|
except (BleakError, asyncio.TimeoutError) as e:
|
||||||
_LOGGER.warning("Error sending ping: %s", str(e))
|
_LOGGER.warning("Plejd mesh keepalive signal failed: %s", str(e))
|
||||||
self.pollonWrite = True
|
self.pollonWrite = True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def poll(self):
|
async def poll(self):
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
return
|
return
|
||||||
|
_LOGGER.debug("Polling Plejd mesh for current state")
|
||||||
await self.client.write_gatt_char(PLEJD_LIGHTLEVEL, b"\x01", response=True)
|
await self.client.write_gatt_char(PLEJD_LIGHTLEVEL, b"\x01", response=True)
|
||||||
|
|
||||||
async def _authenticate(self):
|
async def _authenticate(self):
|
||||||
if self.client is None:
|
if self.client is None:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
_LOGGER.debug("Authenticating")
|
_LOGGER.debug("Authenticating to plejd mesh")
|
||||||
await self.client.write_gatt_char(PLEJD_AUTH, b"\0x00", response=True)
|
await self.client.write_gatt_char(PLEJD_AUTH, b"\0x00", response=True)
|
||||||
challenge = await self.client.read_gatt_char(PLEJD_AUTH)
|
challenge = await self.client.read_gatt_char(PLEJD_AUTH)
|
||||||
response = auth_response(self.crypto_key, challenge)
|
response = auth_response(self.crypto_key, challenge)
|
||||||
await self.client.write_gatt_char(PLEJD_AUTH, response, response=True)
|
await self.client.write_gatt_char(PLEJD_AUTH, response, response=True)
|
||||||
_LOGGER.debug("Authenticated")
|
if not await self.ping():
|
||||||
|
_LOGGER.debug("Authenticion failed")
|
||||||
|
return False
|
||||||
|
_LOGGER.debug("Authenticated successfully")
|
||||||
return True
|
return True
|
||||||
except (BleakError, asyncio.TimeoutError) as e:
|
except (BleakError, asyncio.TimeoutError) as e:
|
||||||
_LOGGER.warning("Plejd authentication failed: %s", str(e))
|
_LOGGER.warning("Plejd authentication failed: %s", str(e))
|
||||||
@@ -166,7 +190,8 @@ def decode_state(data):
|
|||||||
if address == 1 and cmd == b"\x00\x1b":
|
if address == 1 and cmd == b"\x00\x1b":
|
||||||
_LOGGER.debug("Got time data?")
|
_LOGGER.debug("Got time data?")
|
||||||
ts = struct.unpack_from("<I", data, 5)[0]
|
ts = struct.unpack_from("<I", data, 5)[0]
|
||||||
_LOGGER.debug("Timestamp: %s", ts)
|
dt = datetime.fromtimestamp(ts)
|
||||||
|
_LOGGER.debug("Timestamp: %s (%s)", ts, dt)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
dim, state = None, None
|
dim, state = None, None
|
||||||
@@ -175,6 +200,12 @@ def decode_state(data):
|
|||||||
dim = int.from_bytes(data[6:8], "little")
|
dim = int.from_bytes(data[6:8], "little")
|
||||||
elif cmd == b"\x00\x97":
|
elif cmd == b"\x00\x97":
|
||||||
state = bool(data[5])
|
state = bool(data[5])
|
||||||
|
elif cmd == b"\x00\x16":
|
||||||
|
_LOGGER.debug("A button was pressed")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Unknown command %s", cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"address": address,
|
"address": address,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
|
||||||
from builtins import property
|
from builtins import property
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
Device = namedtuple("Device", ["model", "type", "dimmable"])
|
Device = namedtuple("Device", ["model", "type", "dimmable"])
|
||||||
|
|
||||||
@@ -20,10 +23,10 @@ HARDWARE_TYPES = {
|
|||||||
"7": Device("REL-01", SWITCH, False),
|
"7": Device("REL-01", SWITCH, False),
|
||||||
"8": Device("SPR-01", SWITCH, False),
|
"8": Device("SPR-01", SWITCH, False),
|
||||||
"10": Device("WRT-01", SWITCH, False),
|
"10": Device("WRT-01", SWITCH, False),
|
||||||
"11": Device("DIM-01", LIGHT, True),
|
"11": Device("DIM-01-2P", LIGHT, True),
|
||||||
"13": Device("Generic", LIGHT, False),
|
"13": Device("Generic", LIGHT, False),
|
||||||
"14": Device("DIM-01", LIGHT, True),
|
"14": Device("DIM-01-LC", LIGHT, True),
|
||||||
"15": Device("DIM-02", LIGHT, True),
|
"15": Device("DIM-02-LC", LIGHT, True),
|
||||||
"17": Device("REL-01-2P", SWITCH, False),
|
"17": Device("REL-01-2P", SWITCH, False),
|
||||||
"18": Device("REL-02", SWITCH, False),
|
"18": Device("REL-02", SWITCH, False),
|
||||||
"20": Device("SPR-01", SWITCH, False),
|
"20": Device("SPR-01", SWITCH, False),
|
||||||
@@ -43,13 +46,20 @@ class PlejdDevice:
|
|||||||
self._state = None
|
self._state = None
|
||||||
self._dim = None
|
self._dim = None
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PlejdDevice(<manager>, {self.address}, {self.BLE_address}, {self.data}>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
return self._state is not None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self):
|
def state(self):
|
||||||
return self._state
|
return self._state if self.available else False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dim(self):
|
def dim(self):
|
||||||
return self._dim/255
|
return self._dim/255 if self._dim else 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def BLE_address(self):
|
def BLE_address(self):
|
||||||
@@ -93,7 +103,7 @@ class PlejdDevice:
|
|||||||
self._dim = dim
|
self._dim = dim
|
||||||
if update:
|
if update:
|
||||||
if self.updateCallback:
|
if self.updateCallback:
|
||||||
await self.updateCallback({"state": self._state, "dim": self._dim})
|
self.updateCallback({"state": self._state, "dim": self._dim})
|
||||||
|
|
||||||
async def turn_on(self, dim=0):
|
async def turn_on(self, dim=0):
|
||||||
await self.manager.mesh.set_state(self.address, True, dim)
|
await self.manager.mesh.set_state(self.address, True, dim)
|
||||||
@@ -101,4 +111,23 @@ class PlejdDevice:
|
|||||||
async def turn_off(self):
|
async def turn_off(self):
|
||||||
await self.manager.mesh.set_state(self.address, False)
|
await self.manager.mesh.set_state(self.address, False)
|
||||||
|
|
||||||
|
class PlejdScene:
|
||||||
|
|
||||||
|
def __init__(self, manager, index, title):
|
||||||
|
self._manager = manager
|
||||||
|
self._index = index
|
||||||
|
self._title = title
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<PlejdScene(<manager>, {self._index}, '{self._title}'>"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self._title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def index(self):
|
||||||
|
return self._index
|
||||||
|
|
||||||
|
async def activate(self):
|
||||||
|
await self._manager.mesh.activate_scene(self._index)
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
DOMAIN = "plejd"
|
DOMAIN = "plejd"
|
||||||
|
|
||||||
async def async_setup_entry(hass, config_entry, async_add_entities):
|
async def async_setup_entry(hass, config_entry, async_add_entities):
|
||||||
devices = hass.data[DOMAIN]["devices"]
|
devices = hass.data[DOMAIN]["devices"].get(config_entry.entry_id, [])
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
for d in devices:
|
for d in devices:
|
||||||
dev = devices[d]
|
dev = devices[d]
|
||||||
if dev.type == "switch":
|
if dev.type == "switch":
|
||||||
coordinator = Coordinator(hass, dev)
|
coordinator = Coordinator(hass, dev)
|
||||||
async def updateCallback(data):
|
dev.updateCallback = coordinator.async_set_updated_data
|
||||||
coordinator.async_set_updated_data(data)
|
|
||||||
dev.updateCallback = updateCallback
|
|
||||||
switch = PlejdSwitch(coordinator, dev)
|
switch = PlejdSwitch(coordinator, dev)
|
||||||
entities.append(switch)
|
entities.append(switch)
|
||||||
async_add_entities(entities, False)
|
async_add_entities(entities, False)
|
||||||
@@ -38,14 +36,10 @@ class PlejdSwitch(SwitchEntity, CoordinatorEntity):
|
|||||||
def _data(self):
|
def _data(self):
|
||||||
return self.coordinator.data or {}
|
return self.coordinator.data or {}
|
||||||
|
|
||||||
@property
|
|
||||||
def available(self):
|
|
||||||
return self._data.get("state", None) is not None
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_info(self):
|
def device_info(self):
|
||||||
return {
|
return {
|
||||||
"identifiers": {(DOMAIN, self.device.BLE_address)},
|
"identifiers": {(DOMAIN, f"{self.device.BLE_address}:{self.device.address}")},
|
||||||
"name": self.device.name,
|
"name": self.device.name,
|
||||||
"manufacturer": "Plejd",
|
"manufacturer": "Plejd",
|
||||||
"model": self.device.model,
|
"model": self.device.model,
|
||||||
@@ -64,7 +58,7 @@ class PlejdSwitch(SwitchEntity, CoordinatorEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
return self.device.BLE_address
|
return f"{self.device.BLE_address}:{self.device.address}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self):
|
def is_on(self):
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
{
|
{
|
||||||
"title": "Plejd",
|
"title": "Plejd",
|
||||||
"config": {
|
"config": {
|
||||||
|
"abort": {
|
||||||
|
"single_instance_allowed": "Only one Plejd instance at a time is currently supported."
|
||||||
|
},
|
||||||
"step": {
|
"step": {
|
||||||
"user": {
|
"user": {
|
||||||
"title": "Log in to Plejd",
|
"title": "Log in to Plejd",
|
||||||
|
|||||||
Reference in New Issue
Block a user