From c879f2a9517c507c5d92740d89e896dc1341ea14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Mon, 13 Dec 2021 21:25:54 +0100 Subject: [PATCH 01/12] Rewrite code to custom integration, including sensors, switches and services --- README.md | 173 ++++--- custom_components/plejd/__init__.py | 126 +++++- custom_components/plejd/binary_sensor.py | 74 +++ custom_components/plejd/const.py | 46 ++ custom_components/plejd/light.py | 546 ++++------------------- custom_components/plejd/manifest.json | 6 +- custom_components/plejd/plejd_service.py | 518 +++++++++++++++++++++ custom_components/plejd/sensor.py | 79 ++++ custom_components/plejd/services.yaml | 12 + custom_components/plejd/switch.py | 92 ++++ upgrade_notes.md | 65 +++ 11 files changed, 1221 insertions(+), 516 deletions(-) create mode 100644 custom_components/plejd/binary_sensor.py create mode 100644 custom_components/plejd/const.py create mode 100644 custom_components/plejd/plejd_service.py create mode 100644 custom_components/plejd/sensor.py create mode 100644 custom_components/plejd/services.yaml create mode 100644 custom_components/plejd/switch.py create mode 100644 upgrade_notes.md diff --git a/README.md b/README.md index cb028cb..6f1771d 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,81 @@ # Plejd component for Home Assistant -This is a simple Plejd component for Home Assistant, interfacing with the -bluetooth le protocol. +This is a Plejd component for Home Assistant, interfacing with the Bluetooth LE +protocol. All devices are configured locally, without communicating with the +Plejd web API. The crypto key must be extracted from the app image (see below +for instructions). -## Getting started +## Upgrade notes + +If you are upgrading from version 1 to version 2 of this component, you should +read the [upgrade notes](upgrade_notes.md). + +## Entities + +Relay outputs can be configured as either `light`s or `switch`es. Dimmer outputs +should generally be configured as (dimmable) `light`s. They can also be used as +plain `switch`es. Rotary buttons are `sensor`s (measuring percentages) while +push buttons are `binary_sensor`s. + +## Events + +Plejd buttons send `plejd_button_event`s when pressed and Plejd scenarios and +timers send `plejd_scene_event`s when triggered. These events can be identified +by either by the `plejd_id` field, or the `name` field, if configured. + +## Services + +Plejd scenarios can be triggered using the `plejd.trigger_scene` service. They +will have to be defined through the Plejd app, though. + +## Time + +The component will keep the time of the Plejd system up to date. + +## Supported Plejd devices + +| Name | `light` _or_ `switch` | `binary_sensor` | `sensor` | `plejd_button_event` | Tested? | +| --------- | --------------------- | --------------- | -------- |--------------------- | ------- | +| CTR-01 | 1x (dimmable) | | | | No | +| DIM-01-2P | 1x (dimmable) | | | | | +| DIM-02 | 2x (dimmable) | | | | | +| LED-10 | 1x (dimmable) | | | | No | +| REL-01-2P | 1x | | | | No | +| REL-02 | 2x | | | | | +| RTR-01 * | | | 1x* | Yes* | | +| VRI-02 * | 1x (dimmable) | | 1x* | Yes* | | +| WPH-01 | | 2x | | Yes | | +| WRT-01 | | | 1x | Yes | | + +Note: For RTR-01 and VRI-02, when the rotary is configured to control an output +on the attached puck, Home Assistant will not receive events from the button +(only the controlled light), so it cannot be a separate `sensor`, and +`plejd_button_event`s will not be triggered. + +Home Assistant initially sets all `light`s to non-dimmable, but if it notices a +change in a light's brightness, the light will forever be set as dimmable. (To +revert this, go to Developer Tools for this entity, set `supported_color_modes` +to `- onoff` and remove the `brightness` line.) ## Tested platforms This component has been tested on the following platforms: - - Raspberry pi 3b+ running ubuntu (18.04) and home-assistant in venv - - Intel NUC NUC7i7BNH (Bluetooth 4.2 Intel 8265) running ESXi 6.7 and linux guest +* Raspberry Pi 3b+ running ubuntu (18.04) and Home Assistant in venv. +* Raspberry Pi 4b running Pi OS Lite and Home Assistant in docker. +* Intel NUC NUC7i7BNH (Bluetooth 4.2 Intel 8265) running ESXi 6.7 and linux guest. There's been reports that bluez version 5.37 is problematic while 5.48 works fine. ## Requirements -* A bluetooth adapter that supports Bluetooth Low Energy (BLE) +* A Bluetooth adapter that supports Bluetooth Low Energy (BLE). * Obtaining the Plejd crypto key and the device ids. ## Gathering crypto and device information Obtaining the crypto key and the device ids is a crucial step to get this running, for this it is required to get the .site json file from the plejd app -on android or iOS. +on Android or iOS. -### Steps for android: +### Steps for Android 1. Turn on USB debugging and connect the phone to a computer. 2. Extract a backup from the phone: @@ -38,19 +91,19 @@ $ dd if=backup.ab bs=1 skip=24 | zlib-flate -uncompress | tar -xv $ cp apps/com.plejd.plejdapp/f/*/*.site site.json ``` -### Steps for iOS: +### Steps for iOS 1. Open a backup in iBackup viewer. 2. Select raw files, look for AppDomainGroup-group.com.plejd.consumer.light. 3. In AppDomainGroup-group.com.plejd.consumer.light/Documents there should be two folders. 4. The folder that isn't named ".config" contains the .site file. -### Gather cryto key and ids for devices +### Gather crypto key and ids for devices When the site.json file has been recovered the cryptokey and the output addresses can be extracted: -1. Extract the cryptoKey: +1. Extract the CryptoKey: ``` $ cat site.json | jq '.PlejdMesh.CryptoKey' | sed 's/-//g' ``` @@ -60,69 +113,76 @@ $ cat site.json | jq '.PlejdMesh._outputAddresses' | grep -v '\$type' | jq '.[][ ``` These steps can obviously be done manually instead of extracting the fields -using jq and shell tricks. - +using jq and shell tricks. Device ids can also be found by configuring debug +logging and see when unknown devices appear in the log, while scenario and +timer ids can be found by listening for `plejd_scene_event`s. ## Installing component -### Hassbian: - -Make sure the homeassistant user has permissions to use bluetooth, this might -require putting it in the bluetooth group. +### Hassbian -Run this as a custom component, put the files light.py, manifest.json and -\_\_init\_\_.py in custom\_components/plejd in your configuration.yaml add -something like: +Make sure the Home Assistant user has permissions to use Bluetooth, this might +require putting it in the Bluetooth group. -``` -light: - - platform: plejd - crypto_key: !secret plejd - devices: - 11: - name: bedroom - 13: - name: kitchen_1 - 14: - name: kitchen_2 - 16: - name: bathroom -``` +To run this as a custom component, copy all files in `custom_components/plejd`, +to a `custom_components/plejd` folder under your Home Assistant directory. -### HASS.IO Docker container +### Hass.io Docker container -Hass.io default installation script will map /usr/share/hassio/homeassistant to the /config directory inside the docker container. -create a custom\_components directory if it doesn't exist (it doesn't by default). +Hass.io default installation script will map `/usr/share/hassio/homeassistant` +to the `/config` directory inside the docker container. +Create a `custom_components` directory if it doesn't exist (it doesn't by default): ``` -mkdir -p /usr/share/hassio/homeassistant/custom_components +mkdir -p /usr/share/hassio/homeassistant/custom_components/plejd ``` Checkout the git repo and rename folder ``` -cd /usr/share/hassio/homeassistant/custom_components +cd /usr/share/hassio/homeassistant/custom_components/plejd git checkout https://github.com/klali/ha-plejd.git -mv ha-plejd plejd -``` -Update your configuration.yaml file +mv custom_components/plejd/* . ``` -light: - - platform: plejd - crypto_key: !secret plejd - devices: - 11: - name: bedroom - 13: - name: kitchen_1 - 14: - name: kitchen_2 - 16: - name: bathroom +## Configuring component + +Put the crypto key in your `secrets.yaml` file: +`plejd_crypto: "********************************"` + +And configure the component in your `configuration.yaml`: +``` +plejd: + crypto_key: !secret plejd_crypto + lights: + 11: bedroom + 13: kitchen_1 + 14: kitchen_2 + 16: bathroom + switches: + 19: heater + binary_sensors: + 17: button bedroom left + 18: button bedroom right + sensors: + 21: bathroom rotary + scenes: + 1: morning + 2: evening + 3: night ``` -Last step is to restart homeassistant service, in the homeassistant web ui, go to Configuration -> General -> Server management and hit restart. + +All dictionary items map from (integral) plejd ids to the name they should +have in Home Assistant. + +## Restarting Home Assistant + +The last step is to restart Home Assistant service, in the Home Assistant web +UI, go to Configuration -> General -> Server management and hit restart. ## Troubleshooting -Generally it is helpful to turn on debug logging for the component for any type of troubleshooting, this will show what the component receives and interprets from the plejd network. To do this add something like the following to your configuration: +Generally it is helpful to turn on debug logging for the component for any type +of troubleshooting, this will show what the component receives and interprets +from the plejd network. To do this add something like the following to your +configuration: ``` logger: logs: @@ -134,6 +194,7 @@ logger: ``` Copyright 2019 Klas Lindfors +Copyright 2021 Børge Nordli Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/custom_components/plejd/__init__.py b/custom_components/plejd/__init__.py index 36b7d4e..9277cdc 100644 --- a/custom_components/plejd/__init__.py +++ b/custom_components/plejd/__init__.py @@ -1 +1,125 @@ -"""Plejd component.""" +# Copyright 2019 Klas Lindfors +# Copyright 2021 Børge Nordli + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Plejd integration.""" +from __future__ import annotations + +import logging + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ID, + ATTR_NAME, + CONF_BINARY_SENSORS, + CONF_LIGHTS, + CONF_SENSORS, + CONF_SWITCHES, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_CRYPTO_KEY, + CONF_DBUS_ADDRESS, + CONF_DISCOVERY_TIMEOUT, + CONF_OFFSET_MINUTES, + CONF_SCENES, + DEFAULT_DBUS_PATH, + DEFAULT_DISCOVERY_TIMEOUT, + DOMAIN, + SCENE_SERVICE, +) +from .plejd_service import PlejdService + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["binary_sensor", "light", "sensor", "switch"] + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_CRYPTO_KEY): cv.string, + vol.Optional( + CONF_DISCOVERY_TIMEOUT, default=DEFAULT_DISCOVERY_TIMEOUT + ): cv.positive_int, + vol.Optional(CONF_DBUS_ADDRESS, default=DEFAULT_DBUS_PATH): cv.string, + vol.Optional(CONF_OFFSET_MINUTES, default=0): int, + vol.Optional(CONF_LIGHTS, default={}): {cv.positive_int: cv.string}, + vol.Optional(CONF_SWITCHES, default={}): {cv.positive_int: cv.string}, + vol.Optional(CONF_BINARY_SENSORS, default={}): { + cv.positive_int: cv.string + }, + vol.Optional(CONF_SENSORS, default={}): {cv.positive_int: cv.string}, + vol.Optional(CONF_SCENES, default={}): {cv.positive_int: cv.string}, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SCENE_SERVICE_SCHEMA = vol.Schema( + {vol.Optional(ATTR_ID): cv.positive_int, vol.Optional(ATTR_NAME): cv.string} +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType): + """Activate the Plejd integration from configuration yaml.""" + if DOMAIN not in config: + return True + + plejdconfig = config[DOMAIN] + devices: dict[int, Entity] = {} + scenes: dict[int, str] = plejdconfig[CONF_SCENES] + service = PlejdService(hass, plejdconfig, devices, scenes) + plejdinfo = { + "config": plejdconfig, + "devices": devices, + "service": service, + "scenes": scenes, + } + hass.data[DOMAIN] = plejdinfo + for platform in PLATFORMS: + hass.helpers.discovery.load_platform(platform, DOMAIN, {}, config) + + if not await service.connect(): + raise PlatformNotReady + await service.check_connection() + + @callback + def handle_scene_service(call: ServiceCall) -> None: + """Handle the trigger scene service.""" + id = call.data.get(ATTR_ID) + if id is not None: + service.trigger_scene(id) + return + name = call.data.get(ATTR_NAME, "") + for id, scene_name in scenes.items(): + if name.lower() == scene_name.lower(): + service.trigger_scene(id) + return + _LOGGER.warning( + f"Scene triggered with unknown name '{name}'. Known scenes: {', '.join(s for s in scenes.values())}" + ) + + hass.services.async_register( + DOMAIN, SCENE_SERVICE, handle_scene_service, schema=SCENE_SERVICE_SCHEMA + ) + _LOGGER.debug("Plejd platform setup completed") + hass.async_create_task(service.request_update()) + return True diff --git a/custom_components/plejd/binary_sensor.py b/custom_components/plejd/binary_sensor.py new file mode 100644 index 0000000..ea4cd52 --- /dev/null +++ b/custom_components/plejd/binary_sensor.py @@ -0,0 +1,74 @@ +# Copyright 2021 Børge Nordli + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Plejd binary sensor platform.""" + +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import CONF_BINARY_SENSORS, STATE_ON +from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DOMAIN +from .plejd_service import PlejdService + +_LOGGER = logging.getLogger(__name__) + + +class PlejdButton(BinarySensorEntity, RestoreEntity): + """Representation of a Plejd button.""" + + _attr_should_poll = False + _attr_assumed_state = False + + def __init__(self, name: str, identity: int, service: PlejdService) -> None: + """Initialize the binary sensor.""" + self._attr_name = name + self._attr_unique_id = str(identity) + self._service = service + + async def async_added_to_hass(self) -> None: + """Read the current state of the button when it is added to Home Assistant.""" + await super().async_added_to_hass() + old = await self.async_get_last_state() + if old is not None: + self._attr_is_on = old.state == STATE_ON + + @callback + def update_state(self, state: bool) -> None: + """Update the state of the button.""" + self._attr_is_on = state + _LOGGER.debug(f"{self.name} ({self.unique_id}) turned {self.state}") + self.async_schedule_update_ha_state() + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Plejd binary sensor platform.""" + if discovery_info is None: + return + + plejdinfo = hass.data[DOMAIN] + service: PlejdService = plejdinfo["service"] + buttons = [] + + for id, sensor_name in plejdinfo["config"][CONF_BINARY_SENSORS].items(): + if id in plejdinfo["devices"]: + _LOGGER.warning(f"Found duplicate definition for Plejd device {id}.") + continue + _LOGGER.debug(f"Adding binary sensor {id} ({sensor_name})") + button = PlejdButton(sensor_name, id, service) + plejdinfo["devices"][id] = button + buttons.append(button) + + add_entities(buttons) diff --git a/custom_components/plejd/const.py b/custom_components/plejd/const.py new file mode 100644 index 0000000..ea2f86c --- /dev/null +++ b/custom_components/plejd/const.py @@ -0,0 +1,46 @@ +# Copyright 2019 Klas Lindfors +# Copyright 2021 Børge Nordli + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Constants for the Plejd integration.""" + +DOMAIN = "plejd" +BUTTON_EVENT = DOMAIN + "_button_event" +SCENE_EVENT = DOMAIN + "_scene_event" +SCENE_SERVICE = "trigger_scene" + +CONF_CRYPTO_KEY = "crypto_key" +CONF_DISCOVERY_TIMEOUT = "discovery_timeout" +CONF_DBUS_ADDRESS = "dbus_address" +CONF_OFFSET_MINUTES = "offset_minutes" +CONF_SCENES = "scenes" + +DEFAULT_DISCOVERY_TIMEOUT = 2 +DEFAULT_DBUS_PATH = "unix:path=/run/dbus/system_bus_socket" +TIME_DELTA_SYNC = 60 # if delta is more than a minute, sync time + +BLUEZ_SERVICE_NAME = "org.bluez" +DBUS_OM_IFACE = "org.freedesktop.DBus.ObjectManager" +DBUS_PROP_IFACE = "org.freedesktop.DBus.Properties" + +BLUEZ_ADAPTER_IFACE = "org.bluez.Adapter1" +BLUEZ_DEVICE_IFACE = "org.bluez.Device1" +GATT_SERVICE_IFACE = "org.bluez.GattService1" +GATT_CHRC_IFACE = "org.bluez.GattCharacteristic1" + +PLEJD_SVC_UUID = "31ba0001-6085-4726-be45-040c957391b5" +PLEJD_LIGHTLEVEL_UUID = "31ba0003-6085-4726-be45-040c957391b5" +PLEJD_DATA_UUID = "31ba0004-6085-4726-be45-040c957391b5" +PLEJD_LAST_DATA_UUID = "31ba0005-6085-4726-be45-040c957391b5" +PLEJD_AUTH_UUID = "31ba0009-6085-4726-be45-040c957391b5" +PLEJD_PING_UUID = "31ba000a-6085-4726-be45-040c957391b5" diff --git a/custom_components/plejd/light.py b/custom_components/plejd/light.py index 9e3677d..3c9f790 100644 --- a/custom_components/plejd/light.py +++ b/custom_components/plejd/light.py @@ -1,4 +1,5 @@ # Copyright 2019 Klas Lindfors +# Modified 2021 by Børge Nordli # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -11,489 +12,122 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. +"""The Plejd light platform.""" +import binascii import logging - -import voluptuous as vol - +from typing import Optional + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_ONOFF, + LightEntity, +) +from homeassistant.const import CONF_LIGHTS, STATE_ON from homeassistant.core import callback -from homeassistant.components.light import (ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, LightEntity) -from homeassistant.const import CONF_NAME, CONF_DEVICES, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_ON -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.restore_state import RestoreEntity -import homeassistant.util.dt as dt_util -from homeassistant.exceptions import PlatformNotReady - - -import asyncio - -import re -import binascii -import os -import struct -from datetime import timedelta, datetime, timezone - -CONF_CRYPTO_KEY = 'crypto_key' -CONF_DISCOVERY_TIMEOUT = 'discovery_timeout' -CONF_DBUS_ADDRESS = 'dbus_address' -CONF_OFFSET_MINUTES = 'offset_minutes' - -DEFAULT_DISCOVERY_TIMEOUT = 2 -DEFAULT_DBUS_PATH = 'unix:path=/run/dbus/system_bus_socket' -TIME_DELTA_SYNC = 60 # if delta is more than a minute, sync time -DATA_PLEJD = 'plejdObject' - -PLEJD_DEVICES = {} +from .const import DOMAIN +from .plejd_service import PlejdService _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_CRYPTO_KEY): cv.string, - vol.Required(CONF_DEVICES, default={}): { - cv.string: vol.Schema({ - vol.Required(CONF_NAME): cv.string - }) - }, - vol.Optional(CONF_DISCOVERY_TIMEOUT, default=DEFAULT_DISCOVERY_TIMEOUT): cv.positive_int, - vol.Optional(CONF_DBUS_ADDRESS, default=DEFAULT_DBUS_PATH): cv.string, - vol.Optional(CONF_OFFSET_MINUTES, default=0): int, - }) - - -BLUEZ_SERVICE_NAME = 'org.bluez' -DBUS_OM_IFACE = 'org.freedesktop.DBus.ObjectManager' -DBUS_PROP_IFACE = 'org.freedesktop.DBus.Properties' - -BLUEZ_ADAPTER_IFACE = 'org.bluez.Adapter1' -BLUEZ_DEVICE_IFACE = 'org.bluez.Device1' -GATT_SERVICE_IFACE = 'org.bluez.GattService1' -GATT_CHRC_IFACE = 'org.bluez.GattCharacteristic1' - -PLEJD_SVC_UUID = '31ba0001-6085-4726-be45-040c957391b5' -PLEJD_LIGHTLEVEL_UUID = '31ba0003-6085-4726-be45-040c957391b5' -PLEJD_DATA_UUID = '31ba0004-6085-4726-be45-040c957391b5' -PLEJD_LAST_DATA_UUID = '31ba0005-6085-4726-be45-040c957391b5' -PLEJD_AUTH_UUID = '31ba0009-6085-4726-be45-040c957391b5' -PLEJD_PING_UUID = '31ba000a-6085-4726-be45-040c957391b5' class PlejdLight(LightEntity, RestoreEntity): - def __init__(self, name, identity): - self._name = name - self._id = identity - self._brightness = None - - async def async_added_to_hass(self): + """Representation of a Plejd light.""" + + _attr_should_poll = False + _attr_assumed_state = False + _hex_id: str + _last_brightness: Optional[int] = None + + def __init__(self, name: str, identity: int, service: PlejdService) -> None: + """Initialize the light.""" + self._attr_name = name + self._attr_unique_id = str(identity) + self._hex_id = f"{identity:02x}" + self._service = service + + async def async_added_to_hass(self) -> None: + """Read the current state of the light when it is added to Home Assistant.""" await super().async_added_to_hass() old = await self.async_get_last_state() if old is not None: - self._state = old.state == STATE_ON + self._attr_is_on = old.state == STATE_ON if old.attributes.get(ATTR_BRIGHTNESS) is not None: - brightness = int(old.attributes[ATTR_BRIGHTNESS]) - self._brightness = brightness << 8 | brightness - else: - self._state = False - - @property - def should_poll(self): - return False - - @property - def name(self): - return self._name - - @property - def is_on(self): - return self._state - - @property - def assumed_state(self): - return True - - @property - def brightness(self): - if self._brightness: - return self._brightness >> 8 + self._attr_brightness = old.attributes[ATTR_BRIGHTNESS] + self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} + else: + self._attr_supported_color_modes = {COLOR_MODE_ONOFF} else: - return None - - @property - def supported_features(self): - return SUPPORT_BRIGHTNESS - - @property - def unique_id(self): - return self._id + self._attr_is_on = False @callback - def update_state(self, state, brightness=None): - self._state = state - self._brightness = brightness - if brightness: - _LOGGER.debug("%s(%02x) turned %r with brightness %04x" % (self._name, self._id, state, brightness)) + def update_state(self, state: bool, brightness: Optional[int] = None) -> None: + """Update the state of the light.""" + self._attr_is_on = state + if self._attr_brightness or ( + brightness and self._last_brightness and brightness != self._last_brightness + ): + brightness = brightness or 0 + _LOGGER.debug( + f"{self.name} ({self.unique_id}) turned {self.state} with brightness {brightness}" + ) + self._attr_brightness = brightness + self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} else: - _LOGGER.debug("%s(%02x) turned %r" % (self._name, self._id, state)) + if brightness: + _LOGGER.debug( + f"{self.name} ({self.unique_id}) turned {self.state} with (ignored) brightness {brightness}" + ) + else: + _LOGGER.debug(f"{self.name} ({self.unique_id}) turned {self.state}") + self._attr_supported_color_modes = {COLOR_MODE_ONOFF} + self._last_brightness = brightness self.async_schedule_update_ha_state() - async def async_turn_on(self, **kwargs): - pi = self.hass.data[DATA_PLEJD] - if "characteristics" not in pi: - _LOGGER.warning("Tried to turn on light when plejd is not connected") - return - + async def async_turn_on(self, **kwargs) -> None: + """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - if(brightness is None): - self._brightness = None - payload = binascii.a2b_hex("%02x0110009701" % (self._id)) + if self._attr_brightness: + brightness = brightness or 0 + # Plejd brightness is two bytes, but HA brightness is one byte. + payload = binascii.a2b_hex( + f"{self._hex_id}0110009801{brightness:02x}{brightness:02x}" + ) + _LOGGER.debug( + f"Turning on {self.name} ({self.unique_id}) with brightness {brightness}" + ) + self._attr_brightness = brightness else: - # since ha brightness is just one byte we shift it up and or it in to be able to get max val - self._brightness = brightness << 8 | brightness - payload = binascii.a2b_hex("%02x0110009801%04x" % (self._id, self._brightness)) + payload = binascii.a2b_hex(f"{self._hex_id}0110009701") + _LOGGER.debug(f"Turning on {self.name} ({self.unique_id})") + await self._service._write(payload) - _LOGGER.debug("Turning on %s(%02x) with brigtness %02x" % (self._name, self._id, brightness or 0)) - await plejd_write(pi, payload) + async def async_turn_off(self, **kwargs) -> None: + """Turn the light off.""" + payload = binascii.a2b_hex(f"{self._hex_id}0110009700") + _LOGGER.debug(f"Turning off {self.name} ({self.unique_id})") + await self._service._write(payload) - async def async_turn_off(self, **kwargs): - pi = self.hass.data[DATA_PLEJD] - if "characteristics" not in pi: - _LOGGER.warning("Tried to turn off light when plejd is not connected") - return - payload = binascii.a2b_hex("%02x0110009700" % (self._id)) - _LOGGER.debug("Turning off %s(%02x)" % (self._name, self._id)) - await plejd_write(pi, payload) - -async def connect(pi): - from dbus_next import Message, MessageType, BusType, Variant - from dbus_next.aio import MessageBus - from dbus_next.errors import DBusError - - pi["characteristics"] = None - - try: - bus = await MessageBus(bus_type=BusType.SYSTEM, bus_address=pi["dbus_address"]).connect() - except FileNotFoundError as e: - _LOGGER.error("Failed to connect the dbus messagebus at '%s', make sure that exists" % (pi["dbus_address"])) - return - - om_introspection = await bus.introspect(BLUEZ_SERVICE_NAME, '/') - om = bus.get_proxy_object(BLUEZ_SERVICE_NAME, '/', om_introspection).get_interface(DBUS_OM_IFACE) - - om_objects = await om.call_get_managed_objects() - for path, interfaces in om_objects.items(): - if BLUEZ_ADAPTER_IFACE in interfaces.keys(): - _LOGGER.debug("Discovered bluetooth adapter %s" % (path)) - adapter_introspection = await bus.introspect(BLUEZ_SERVICE_NAME, path) - adapter = bus.get_proxy_object(BLUEZ_SERVICE_NAME, path, adapter_introspection).get_interface(BLUEZ_ADAPTER_IFACE) - break - - if not adapter: - _LOGGER.error("No bluetooth adapter localized") - return - - for path, interfaces in om_objects.items(): - if BLUEZ_DEVICE_IFACE in interfaces.keys(): - device_introspection = await bus.introspect(BLUEZ_SERVICE_NAME, path) - dev = bus.get_proxy_object(BLUEZ_SERVICE_NAME, path, device_introspection).get_interface(BLUEZ_DEVICE_IFACE) - connected = await dev.get_connected() - if connected: - _LOGGER.debug("Disconnecting %s" % (path)) - await dev.call_disconnect() - await adapter.call_remove_device(path) - - plejds = [] - - @callback - def on_interfaces_added(path, interfaces): - if BLUEZ_DEVICE_IFACE in interfaces: - if PLEJD_SVC_UUID in interfaces[BLUEZ_DEVICE_IFACE]['UUIDs'].value: - plejds.append({'path': path}) - - om.on_interfaces_added(on_interfaces_added) - - scan_filter = { - "UUIDs": Variant('as', [PLEJD_SVC_UUID]), - "Transport": Variant('s', "le"), - } - await adapter.call_set_discovery_filter(scan_filter) - await adapter.call_start_discovery() - await asyncio.sleep(pi["discovery_timeout"]) - - for plejd in plejds: - device_introspection = await bus.introspect(BLUEZ_SERVICE_NAME, plejd['path']) - dev = bus.get_proxy_object(BLUEZ_SERVICE_NAME, plejd['path'], device_introspection).get_interface(BLUEZ_DEVICE_IFACE) - plejd['RSSI'] = await dev.get_rssi() - plejd['obj'] = dev - _LOGGER.debug("Discovered plejd %s with RSSI %d" % (plejd['path'], plejd['RSSI'])) - - if len(plejds) == 0: - _LOGGER.warning("No plejd devices found") +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Plejd light platform.""" + if discovery_info is None: return - plejds.sort(key = lambda a: a['RSSI'], reverse = True) - for plejd in plejds: - try: - _LOGGER.debug("Connecting to %s" % (plejd["path"])) - await plejd['obj'].call_connect() - break - except DBusError as e: - _LOGGER.warning("Error connecting to plejd: %s" % (str(e))) - - await asyncio.sleep(pi["discovery_timeout"]) - - objects = await om.call_get_managed_objects() - chrcs = [] + plejdinfo = hass.data[DOMAIN] + service: PlejdService = plejdinfo["service"] + lights = [] - for path, interfaces in objects.items(): - if GATT_CHRC_IFACE not in interfaces.keys(): + for id, light_name in plejdinfo["config"][CONF_LIGHTS].items(): + if id in plejdinfo["devices"]: + _LOGGER.warning(f"Found duplicate definition for Plejd device {id}.") continue - chrcs.append(path) - - - async def process_plejd_service(service_path, chrc_paths, bus): - service_introspection = await bus.introspect(BLUEZ_SERVICE_NAME, service_path) - service = bus.get_proxy_object(BLUEZ_SERVICE_NAME, service_path, service_introspection).get_interface(GATT_SERVICE_IFACE) - uuid = await service.get_uuid() - if uuid != PLEJD_SVC_UUID: - return None - - dev = await service.get_device() - x = re.search('dev_([0-9A-F_]+)$', dev) - addr = binascii.a2b_hex(x.group(1).replace("_", ""))[::-1] - - chars = {} - - # Process the characteristics. - for chrc_path in chrc_paths: - chrc_introspection = await bus.introspect(BLUEZ_SERVICE_NAME, chrc_path) - chrc_obj = bus.get_proxy_object(BLUEZ_SERVICE_NAME, chrc_path, chrc_introspection) - chrc = chrc_obj.get_interface(GATT_CHRC_IFACE) - chrc_prop = chrc_obj.get_interface(DBUS_PROP_IFACE) - - uuid = await chrc.get_uuid() - - if uuid == PLEJD_DATA_UUID: - chars["data"] = chrc - elif uuid == PLEJD_LAST_DATA_UUID: - chars["last_data"] = chrc - chars["last_data_prop"] = chrc_prop - elif uuid == PLEJD_AUTH_UUID: - chars["auth"] = chrc - elif uuid == PLEJD_PING_UUID: - chars["ping"] = chrc - elif uuid == PLEJD_LIGHTLEVEL_UUID: - chars["lightlevel"] = chrc - chars["lightlevel_prop"] = chrc_prop - - return (addr, chars) - - plejd_service = None - for path, interfaces in objects.items(): - if GATT_SERVICE_IFACE not in interfaces.keys(): - continue - - chrc_paths = [d for d in chrcs if d.startswith(path + "/")] - - plejd_service = await process_plejd_service(path, chrc_paths, bus) - if plejd_service: - break - - if not plejd_service: - _LOGGER.warning("Failed connecting to plejd service") - return - - if await plejd_auth(pi["key"], plejd_service[1]["auth"]) == False: - return - - pi["address"] = plejd_service[0] - pi["characteristics"] = plejd_service[1] - - @callback - def handle_notification_cb(iface, changed_props, invalidated_props): - if iface != GATT_CHRC_IFACE: - return - if not len(changed_props): - return - value = changed_props.get('Value', None) - if not value: - return - - dec = plejd_enc_dec(pi["key"], pi["address"], value.value) - # check if this is a device we care about - if dec[0] in PLEJD_DEVICES: - device = PLEJD_DEVICES[dec[0]] - elif dec[0] == 0x01 and dec[3:5] == b'\x00\x1b': - n = dt_util.now().replace(tzinfo=None) - time = datetime.fromtimestamp(struct.unpack_from(' TIME_DELTA_SYNC: - _LOGGER.info("Plejd time delta is %d seconds, setting time to '%s'.", s, n) - ntime = b"\x00\x01\x10\x00\x1b" - ntime += struct.pack(' +# Copyright 2021 Børge Nordli + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Plejd service code.""" + +import asyncio +import binascii +from datetime import datetime, timedelta +import logging +import os +import re +import struct +from typing import Any, Callable, Dict, Optional + +from dbus_next.aio.proxy_object import ProxyInterface + +from homeassistant.const import ( + ATTR_NAME, + ATTR_STATE, + EVENT_HOMEASSISTANT_STOP, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.event import async_track_point_in_utc_time +import homeassistant.util.dt as dt_util + +from .const import ( + BLUEZ_ADAPTER_IFACE, + BLUEZ_DEVICE_IFACE, + BLUEZ_SERVICE_NAME, + BUTTON_EVENT, + CONF_CRYPTO_KEY, + CONF_DBUS_ADDRESS, + CONF_DISCOVERY_TIMEOUT, + CONF_OFFSET_MINUTES, + DBUS_OM_IFACE, + DBUS_PROP_IFACE, + GATT_CHRC_IFACE, + GATT_SERVICE_IFACE, + PLEJD_AUTH_UUID, + PLEJD_DATA_UUID, + PLEJD_LAST_DATA_UUID, + PLEJD_LIGHTLEVEL_UUID, + PLEJD_PING_UUID, + PLEJD_SVC_UUID, + SCENE_EVENT, + TIME_DELTA_SYNC, +) + +_LOGGER = logging.getLogger(__name__) + + +class PlejdBus: + """Representation of the message bus connected to Plejd.""" + + _chars: Dict[str, ProxyInterface] = {} + + def __init__(self, address: str) -> None: + """Initialize the bus.""" + self._address = address + + async def write_data(self, char: str, data: bytes) -> None: + """Write data to one characteristic.""" + await self._chars[char].call_write_value(data, {}) + + async def read_data(self, char: str) -> bytes: + """Read data from one characteristic.""" + return await self._chars[char].call_read_value({}) + + async def add_callback(self, method: str, handler: Callable[[bytes], None]) -> None: + """Register a callback on a characteristic.""" + + @callback + def unwrap_value(iface: str, changed_props: dict, invalidated_props) -> None: + if iface != GATT_CHRC_IFACE: + return + if not len(changed_props): + return + value = changed_props.get("Value", None) + if not value: + return + handler(value.value) + + self._chars[method + "_prop"].on_properties_changed(unwrap_value) + await self._chars[method].call_start_notify() + + async def _get_interface(self, path: str, interface: str) -> ProxyInterface: + introspection = await self._bus.introspect(BLUEZ_SERVICE_NAME, path) + object = self._bus.get_proxy_object(BLUEZ_SERVICE_NAME, path, introspection) + return object.get_interface(interface) + + async def connect(self) -> bool: + """Connect to the message bus.""" + from dbus_next import BusType + from dbus_next.aio import MessageBus + + messageBus = MessageBus(bus_type=BusType.SYSTEM, bus_address=self._address) + try: + self._bus = await messageBus.connect() + except FileNotFoundError: + _LOGGER.error( + "Failed to connect to the dbus messagebus at '%s', make sure that it exists." + % (self._address) + ) + return False + self._om = await self._get_interface("/", DBUS_OM_IFACE) + self._adapter = await self._get_adapter() + if not self._adapter: + _LOGGER.error("No bluetooth adapter discovered") + return False + return True + + async def _get_adapter(self) -> ProxyInterface: + om_objects = await self._om.call_get_managed_objects() + for path, interfaces in om_objects.items(): + if BLUEZ_ADAPTER_IFACE in interfaces.keys(): + _LOGGER.debug(f"Discovered bluetooth adapter {path}") + return await self._get_interface(path, BLUEZ_ADAPTER_IFACE) + + async def connect_device(self, timeout: int) -> bool: + """Disconnect all currently connected devices and connect to the closest plejd device.""" + from dbus_next import Variant + from dbus_next.errors import DBusError + + om_objects = await self._om.call_get_managed_objects() + for path, interfaces in om_objects.items(): + if BLUEZ_DEVICE_IFACE in interfaces.keys(): + dev = await self._get_interface(path, BLUEZ_DEVICE_IFACE) + connected = await dev.get_connected() + if connected: + _LOGGER.debug(f"Disconnecting {path}") + await dev.call_disconnect() + _LOGGER.debug(f"Disconnected {path}") + await self._adapter.call_remove_device(path) + + plejds = [] + + @callback + def on_interfaces_added(path, interfaces): + if ( + BLUEZ_DEVICE_IFACE in interfaces + and PLEJD_SVC_UUID in interfaces[BLUEZ_DEVICE_IFACE]["UUIDs"].value + ): + plejds.append({"path": path}) + + self._om.on_interfaces_added(on_interfaces_added) + + scan_filter = { + "UUIDs": Variant("as", [PLEJD_SVC_UUID]), + "Transport": Variant("s", "le"), + } + await self._adapter.call_set_discovery_filter(scan_filter) + await self._adapter.call_start_discovery() + await asyncio.sleep(timeout) + + if len(plejds) == 0: + _LOGGER.warning("No plejd devices found") + return False + + _LOGGER.debug(f"Found {len(plejds)} plejd devices") + for plejd in plejds: + dev = await self._get_interface(plejd["path"], BLUEZ_DEVICE_IFACE) + plejd["RSSI"] = await dev.get_rssi() + plejd["obj"] = dev + _LOGGER.debug(f"Discovered plejd {plejd['path']} with RSSI {plejd['RSSI']}") + + plejds.sort(key=lambda a: a["RSSI"], reverse=True) + for plejd in plejds: + try: + _LOGGER.debug(f"Connecting to {plejd['path']}") + await plejd["obj"].call_connect() + _LOGGER.debug(f"Connected to {plejd['path']}") + break + except DBusError as e: + _LOGGER.warning(f"Error connecting to plejd: {e}") + await self._adapter.call_stop_discovery() + await asyncio.sleep(timeout) + return True + + async def get_plejd_address(self) -> Optional[bytes]: + """Get the plejd address and also collect characteristics.""" + om_objects = await self._om.call_get_managed_objects() + chrcs = [] + + for path, interfaces in om_objects.items(): + if GATT_CHRC_IFACE in interfaces.keys(): + chrcs.append(path) + + for path, interfaces in om_objects.items(): + if GATT_SERVICE_IFACE not in interfaces.keys(): + continue + + service = await self._get_interface(path, GATT_SERVICE_IFACE) + uuid = await service.get_uuid() + if uuid != PLEJD_SVC_UUID: + continue + + dev = await service.get_device() + x = re.search("dev_([0-9A-F_]+)$", dev) + if not x: + _LOGGER.error(f"Unsupported device address '{dev}'") + return None + addr = binascii.a2b_hex(x.group(1).replace("_", ""))[::-1] + + # Process the characteristics. + chrc_paths = [d for d in chrcs if d.startswith(path + "/")] + for chrc_path in chrc_paths: + chrc = await self._get_interface(chrc_path, GATT_CHRC_IFACE) + chrc_prop = await self._get_interface(chrc_path, DBUS_PROP_IFACE) + + uuid = await chrc.get_uuid() + + if uuid == PLEJD_DATA_UUID: + self._chars["data"] = chrc + elif uuid == PLEJD_LAST_DATA_UUID: + self._chars["last_data"] = chrc + self._chars["last_data_prop"] = chrc_prop + elif uuid == PLEJD_AUTH_UUID: + self._chars["auth"] = chrc + elif uuid == PLEJD_PING_UUID: + self._chars["ping"] = chrc + elif uuid == PLEJD_LIGHTLEVEL_UUID: + self._chars["lightlevel"] = chrc + self._chars["lightlevel_prop"] = chrc_prop + + return addr + + return None + + +class PlejdService: + """Representation of the Plejd service.""" + + _address: str + _key: bytes + _plejd_address: Optional[bytes] = None + _bus: Optional[PlejdBus] = None + + def __init__( + self, + hass: HomeAssistant, + config: Dict[str, Any], + devices: Dict[int, Any], + scenes: Dict[int, str], + ) -> None: + """Initialize the service.""" + self._hass = hass + self._config = config + self._address = config.get(CONF_DBUS_ADDRESS, "") + self._key = binascii.a2b_hex(config.get(CONF_CRYPTO_KEY, "").replace("-", "")) + self._devices = devices + self._scenes = scenes + self._remove_timer = lambda: None + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._stop_plejd) + + async def connect(self) -> bool: + """Connect to the Plejd service.""" + self._bus = PlejdBus(self._address) + if not await self._bus.connect(): + return False + if not await self._bus.connect_device( + self._config.get(CONF_DISCOVERY_TIMEOUT, 0) + ): + return False + + self._plejd_address = await self._bus.get_plejd_address() + if not self._plejd_address: + _LOGGER.warning("Failed connecting to plejd service") + return False + if not await self._authenticate(): + return False + + @callback + def handle_notification_cb(value: bytes) -> None: + if not self._plejd_address: + _LOGGER.warning("Tried to write to plejd when not connected") + return + dec = self._enc_dec(self._plejd_address, value) + _LOGGER.debug(f"Received message {dec.hex()}") + # Format + # 012345... + # i..ccdddd + # i = device_id + # 00: button broadcast + # 01: time broadcast + # 02: scene/timer broadcast + # c = command + # 001b: time + # 0016: button clicked, data = id + button + unknown + # 0021: scene triggered, data = scene id + # 0097: state update, data = state, dim + # 00c8, 0098: state + dim update + # d = data + id = dec[0] + command = dec[3:5] + if command == b"\x00\x1b": + # 001b: time + if id != 0x01: + # Disregard time updates sent from the app + return + n = dt_util.now().replace(tzinfo=None) + time = datetime.fromtimestamp(struct.unpack_from(" TIME_DELTA_SYNC: + _LOGGER.info( + f"Plejd time delta is {s} seconds, setting time to '{n}'." + ) + ntime = b"\x00\x01\x10\x00\x1b" + ntime += struct.pack(" None: + _LOGGER.debug(f"Received state {value.hex()}") + # One or two messages of format + # 0123456789 + # is???bb??? + # i = device_id + # s = state (0 or 1) + # b = brightness + if len(value) != 20 and len(value) != 10: + _LOGGER.warning( + f"Unknown length data received for state: '{value.hex()}'" + ) + return + + msgs = [value[0:10]] + if len(value) == 20: + msgs.append(value[10:20]) + + for m in msgs: + if m[0] not in self._devices: + continue + state = bool(m[1]) + # Plejd brightness is two bytes, but HA brightness is one byte, + # so we just take the most significant bit + brightness = m[6] + device = self._devices[m[0]] + if not brightness: + device.update_state(state) + else: + device.update_state(state, brightness) + + await self._bus.add_callback("last_data", handle_notification_cb) + await self._bus.add_callback("lightlevel", handle_state_cb) + + return True + + def trigger_scene(self, id: int) -> None: + """Trigger the scene with the specific id.""" + payload = binascii.a2b_hex(f"0201100021{id:02x}") + _LOGGER.debug(f"Trigger scene {id}") + self._hass.async_create_task(self._write(payload)) + + async def request_update(self) -> None: + """Request an update of all devices.""" + if not self._bus: + _LOGGER.warning("Tried to write to plejd when not connected") + return + await self._bus.write_data("lightlevel", b"\x01") + + async def check_connection(self, now=None) -> None: + """Send a ping and reconnect if it failed. Then schedule another check in the future.""" + if not await self._send_ping(): + await self.connect() + self._remove_timer = async_track_point_in_utc_time( + self._hass, self.check_connection, dt_util.utcnow() + timedelta(seconds=300) + ) + + async def _stop_plejd(self, event) -> None: + self._remove_timer() + + async def _authenticate(self) -> bool: + if not self._bus: + _LOGGER.warning("Tried to write to plejd when not connected") + return False + from dbus_next.errors import DBusError + + try: + await self._bus.write_data("auth", b"\x00") + challenge = await self._bus.read_data("auth") + await self._bus.write_data("auth", self._chalresp(challenge)) + except DBusError as e: + _LOGGER.warning(f"Plejd authentication error: {e}") + return False + return True + + async def _send_ping(self) -> bool: + if not self._bus: + _LOGGER.warning("Tried to ping plejd when not connected") + return False + from dbus_next.errors import DBusError + + ping = os.urandom(1) + try: + await self._bus.write_data("ping", ping) + pong = await self._bus.read_data("ping") + except DBusError as e: + _LOGGER.warning(f"Plejd ping error: {e}") + return False + if (ping[0] + 1) & 0xFF != pong[0]: + _LOGGER.warning(f"Plejd ping failed {ping[0]:02x} - {pong[0]:02x}") + return False + + _LOGGER.debug(f"Successfully pinged with {ping[0]:02x}") + return True + + async def _write(self, payload: bytes) -> None: + from dbus_next.errors import DBusError + + if not self._bus or not self._plejd_address: + _LOGGER.warning("Tried to write to plejd when not connected") + return + + try: + data = self._enc_dec(self._plejd_address, payload) + await self._bus.write_data("data", data) + except DBusError as e: + _LOGGER.warning(f"Write failed, reconnecting: '{e}'") + await self.connect() + data = self._enc_dec(self._plejd_address, payload) + await self._bus.write_data("data", data) + + def _chalresp(self, chal: bytes) -> bytes: + import hashlib + + k = int.from_bytes(self._key, "big") + c = int.from_bytes(chal, "big") + + intermediate = hashlib.sha256((k ^ c).to_bytes(16, "big")).digest() + part1 = int.from_bytes(intermediate[:16], "big") + part2 = int.from_bytes(intermediate[16:], "big") + resp = (part1 ^ part2).to_bytes(16, "big") + return resp + + def _enc_dec(self, address: bytes, data: bytes) -> bytes: + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + + buf = bytearray(address * 2) + buf += address[:4] + + ct = ( + Cipher(algorithms.AES(self._key), modes.ECB(), backend=default_backend()) + .encryptor() + .update(buf) + ) + + output = b"" + for i in range(len(data)): + output += struct.pack("B", data[i] ^ ct[i % 16]) + + return output diff --git a/custom_components/plejd/sensor.py b/custom_components/plejd/sensor.py new file mode 100644 index 0000000..bd072a7 --- /dev/null +++ b/custom_components/plejd/sensor.py @@ -0,0 +1,79 @@ +# Copyright 2021 Børge Nordli + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Plejd binary sensor platform.""" + +import logging + +from homeassistant.components.sensor import STATE_CLASS_MEASUREMENT, SensorEntity +from homeassistant.const import CONF_SENSORS, PERCENTAGE +from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DOMAIN +from .plejd_service import PlejdService + +_LOGGER = logging.getLogger(__name__) + + +class PlejdRotaryButton(SensorEntity, RestoreEntity): + """Representation of a Plejd rotaty button.""" + + _attr_assumed_state = False + _attr_should_poll = False + _attr_state_class = STATE_CLASS_MEASUREMENT + _attr_native_unit_of_measurement = PERCENTAGE + _attr_icon = "hass:radiobox-blank" + + def __init__(self, name: str, identity: int, service: PlejdService): + """Initialize the sensor.""" + self._attr_name = name + self._attr_unique_id = str(identity) + self._service = service + + async def async_added_to_hass(self) -> None: + """Read the current state of the button when it is added to Home Assistant.""" + await super().async_added_to_hass() + old = await self.async_get_last_state() + if old is not None: + self._attr_native_value = old.state + + @callback + def update_state(self, state: bool, brightness: int = 0) -> None: + """Update the state of the button.""" + self._attr_native_value = int(round(100 * (brightness / 0xFFFF))) + _LOGGER.debug( + f"{self.name} ({self.unique_id}) turned to brightness {self.state}" + ) + self.async_schedule_update_ha_state() + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Plejd sensor platform.""" + if discovery_info is None: + return + + plejdinfo = hass.data[DOMAIN] + service: PlejdService = plejdinfo["service"] + buttons = [] + + for id, sensor_name in plejdinfo["config"][CONF_SENSORS].items(): + if id in plejdinfo["devices"]: + _LOGGER.warning(f"Found duplicate definition for Plejd device {id}.") + continue + _LOGGER.debug(f"Adding sensor {id} ({sensor_name})") + button = PlejdRotaryButton(sensor_name, id, service) + plejdinfo["devices"][id] = button + buttons.append(button) + + add_entities(buttons) diff --git a/custom_components/plejd/services.yaml b/custom_components/plejd/services.yaml new file mode 100644 index 0000000..539fa1b --- /dev/null +++ b/custom_components/plejd/services.yaml @@ -0,0 +1,12 @@ +trigger_scene: + name: Trigger scene + description: Triggers a Plejd scene, either by id or by name. + fields: + id: + name: Internal Plejd id + description: The internal Plejd id of the scene + example: 2 + name: + name: Name of the scenario + description: The name of the scenario + example: All off diff --git a/custom_components/plejd/switch.py b/custom_components/plejd/switch.py new file mode 100644 index 0000000..dc1c1d0 --- /dev/null +++ b/custom_components/plejd/switch.py @@ -0,0 +1,92 @@ +# Copyright 2019 Klas Lindfors +# Copyright 2021 Børge Nordli + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""The Plejd switch platform.""" + +import binascii +import logging +from typing import Optional + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import CONF_SWITCHES, STATE_ON +from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity + +from .const import DOMAIN +from .plejd_service import PlejdService + +_LOGGER = logging.getLogger(__name__) + + +class PlejdSwitch(SwitchEntity, RestoreEntity): + """Representation of a Plejd switch.""" + + _attr_should_poll = False + _attr_assumed_state = False + _hex_id: str + _brightness: Optional[int] = None + + def __init__(self, name: str, identity: int, service: PlejdService): + """Initialize the switch.""" + self._attr_name = name + self._attr_unique_id = str(identity) + self._hex_id = f"{identity:02x}" + self._service = service + + async def async_added_to_hass(self) -> None: + """Read the current state of the switch when it is added to Home Assistant.""" + await super().async_added_to_hass() + old = await self.async_get_last_state() + if old is not None: + self._attr_is_on = old.state == STATE_ON + + @callback + def update_state(self, state: bool, brightness: Optional[int] = None) -> None: + """Update the state of the switch.""" + self._attr_is_on = state + _LOGGER.debug(f"{self.name} ({self.unique_id}) turned {self.state}") + self.async_schedule_update_ha_state() + + async def async_turn_on(self, **kwargs) -> None: + """Turn the switch on.""" + payload = binascii.a2b_hex(f"{self._hex_id}0110009701") + _LOGGER.debug(f"Turning on {self.name} ({self.unique_id})") + await self._service._write(payload) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the switch off.""" + payload = binascii.a2b_hex(f"{self._hex_id}0110009700") + _LOGGER.debug(f"Turning off {self.name} ({self.unique_id})") + await self._service._write(payload) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Plejd switch platform.""" + if discovery_info is None: + return + + plejdinfo = hass.data[DOMAIN] + service: PlejdService = plejdinfo["service"] + switches = [] + + for id, switch_name in plejdinfo["config"][CONF_SWITCHES].items(): + if id in plejdinfo["devices"]: + _LOGGER.warning(f"Found duplicate definition for Plejd device {id}.") + continue + _LOGGER.debug(f"Adding switch {id} ({switch_name})") + switch = PlejdSwitch(switch_name, id, service) + plejdinfo["devices"][id] = switch + switches.append(switch) + + add_entities(switches) diff --git a/upgrade_notes.md b/upgrade_notes.md new file mode 100644 index 0000000..889e09b --- /dev/null +++ b/upgrade_notes.md @@ -0,0 +1,65 @@ +# Upgrade notes + +Read this if you are upgrading this component between major versions. + +## Upgrading from version 1 to version 2 + +Example of an old configuration: +``` +light: + - platform: plejd + crypto_key: !secret plejd_crypto + devices: + 11: + name: bedroom + 13: + name: kitchen +``` +The corresponding new configuration: +``` +plejd: + crypto_key: !secret plejd_crypto + lights: + 11: bedroom + 13: kitchen +``` + +# Full configuration samples + +## Version 1 + +Version 1 of this component had only a light platform, and was configured this +way: + +``` +light: + - platform: plejd + crypto_key: !secret plejd_crypto + devices: + 11: + name: bedroom + 13: + name: kitchen +``` + +## Version 2 + +Version 2 is a complete component with support for more domains and is +configured this way: + +``` +plejd: + crypto_key: !secret plejd_crypto + lights: + 11: bedroom + 13: kitchen + switches: + 19: heater + binary_sensors: + 17: button bedroom left + 18: button bedroom right + sensors: + 21: bathroom rotary + scenes: + 1: night +``` From 4d713693f9d03b4679048e30b2ad14a9a24a05a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Thu, 16 Dec 2021 08:15:22 +0100 Subject: [PATCH 02/12] Add SPR-01 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6f1771d..a4ceeb2 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ The component will keep the time of the Plejd system up to date. | DIM-01-2P | 1x (dimmable) | | | | | | DIM-02 | 2x (dimmable) | | | | | | LED-10 | 1x (dimmable) | | | | No | +| SPR-01 | 1x | | | | No | | REL-01-2P | 1x | | | | No | | REL-02 | 2x | | | | | | RTR-01 * | | | 1x* | Yes* | | From 7ba77a4272cea25d28cfb1da44be4e93682cb107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Sat, 8 Jan 2022 12:50:09 +0100 Subject: [PATCH 03/12] Bugfix for dimmable status --- custom_components/plejd/light.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/custom_components/plejd/light.py b/custom_components/plejd/light.py index 3c9f790..8775443 100644 --- a/custom_components/plejd/light.py +++ b/custom_components/plejd/light.py @@ -20,6 +20,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_SUPPORTED_COLOR_MODES, COLOR_MODE_BRIGHTNESS, COLOR_MODE_ONOFF, LightEntity, @@ -55,19 +56,24 @@ async def async_added_to_hass(self) -> None: old = await self.async_get_last_state() if old is not None: self._attr_is_on = old.state == STATE_ON - if old.attributes.get(ATTR_BRIGHTNESS) is not None: - self._attr_brightness = old.attributes[ATTR_BRIGHTNESS] - self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} - else: - self._attr_supported_color_modes = {COLOR_MODE_ONOFF} + self._attr_supported_color_modes = old.attributes.get( + ATTR_SUPPORTED_COLOR_MODES + ) + self._attr_brightness = old.attributes.get(ATTR_BRIGHTNESS) else: self._attr_is_on = False + def _dimmable(self) -> bool: + return ( + self.supported_color_modes is not None + and COLOR_MODE_BRIGHTNESS in self.supported_color_modes + ) + @callback def update_state(self, state: bool, brightness: Optional[int] = None) -> None: """Update the state of the light.""" self._attr_is_on = state - if self._attr_brightness or ( + if self._dimmable or ( brightness and self._last_brightness and brightness != self._last_brightness ): brightness = brightness or 0 @@ -90,8 +96,7 @@ def update_state(self, state: bool, brightness: Optional[int] = None) -> None: async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - if self._attr_brightness: - brightness = brightness or 0 + if self._attr_brightness and brightness: # Plejd brightness is two bytes, but HA brightness is one byte. payload = binascii.a2b_hex( f"{self._hex_id}0110009801{brightness:02x}{brightness:02x}" From 3c3bd5c712bde79008d7f3f4351be9f038abd504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Tue, 25 Jan 2022 07:50:33 +0100 Subject: [PATCH 04/12] Manually set dimmable status --- README.md | 9 +++--- custom_components/plejd/const.py | 1 + custom_components/plejd/light.py | 47 ++++++++++++++++---------------- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a4ceeb2..421ae22 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,11 @@ on the attached puck, Home Assistant will not receive events from the button (only the controlled light), so it cannot be a separate `sensor`, and `plejd_button_event`s will not be triggered. -Home Assistant initially sets all `light`s to non-dimmable, but if it notices a -change in a light's brightness, the light will forever be set as dimmable. (To -revert this, go to Developer Tools for this entity, set `supported_color_modes` -to `- onoff` and remove the `brightness` line.) +All lights are by default set to dimmable in Home Assistant. To make a light +non-dimmable, add " (onoff)" or "*" to its name. This suffix will be removed +before added to Home Assistant. (If a light is set to dimmable by error, add a +suffix and then go to Developer Tools for this entity, set `supported_color_modes` +to `- onoff` and remove the `brightness` line, or restart Home Assistant.) ## Tested platforms This component has been tested on the following platforms: diff --git a/custom_components/plejd/const.py b/custom_components/plejd/const.py index ea2f86c..153830c 100644 --- a/custom_components/plejd/const.py +++ b/custom_components/plejd/const.py @@ -24,6 +24,7 @@ CONF_DBUS_ADDRESS = "dbus_address" CONF_OFFSET_MINUTES = "offset_minutes" CONF_SCENES = "scenes" +CONF_ONOFF = [" (onoff)", "*"] DEFAULT_DISCOVERY_TIMEOUT = 2 DEFAULT_DBUS_PATH = "unix:path=/run/dbus/system_bus_socket" diff --git a/custom_components/plejd/light.py b/custom_components/plejd/light.py index 8775443..894a13b 100644 --- a/custom_components/plejd/light.py +++ b/custom_components/plejd/light.py @@ -29,7 +29,7 @@ from homeassistant.core import callback from homeassistant.helpers.restore_state import RestoreEntity -from .const import DOMAIN +from .const import CONF_ONOFF, DOMAIN from .plejd_service import PlejdService _LOGGER = logging.getLogger(__name__) @@ -41,14 +41,20 @@ class PlejdLight(LightEntity, RestoreEntity): _attr_should_poll = False _attr_assumed_state = False _hex_id: str - _last_brightness: Optional[int] = None + _dimmable: bool - def __init__(self, name: str, identity: int, service: PlejdService) -> None: + def __init__( + self, name: str, identity: int, dimmable: bool, service: PlejdService + ) -> None: """Initialize the light.""" self._attr_name = name self._attr_unique_id = str(identity) self._hex_id = f"{identity:02x}" self._service = service + self._dimmable = dimmable + self._attr_supported_color_modes = { + COLOR_MODE_BRIGHTNESS if dimmable else COLOR_MODE_ONOFF + } async def async_added_to_hass(self) -> None: """Read the current state of the light when it is added to Home Assistant.""" @@ -63,40 +69,24 @@ async def async_added_to_hass(self) -> None: else: self._attr_is_on = False - def _dimmable(self) -> bool: - return ( - self.supported_color_modes is not None - and COLOR_MODE_BRIGHTNESS in self.supported_color_modes - ) - @callback def update_state(self, state: bool, brightness: Optional[int] = None) -> None: """Update the state of the light.""" self._attr_is_on = state - if self._dimmable or ( - brightness and self._last_brightness and brightness != self._last_brightness - ): + if self._dimmable: brightness = brightness or 0 _LOGGER.debug( f"{self.name} ({self.unique_id}) turned {self.state} with brightness {brightness}" ) self._attr_brightness = brightness - self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS} else: - if brightness: - _LOGGER.debug( - f"{self.name} ({self.unique_id}) turned {self.state} with (ignored) brightness {brightness}" - ) - else: - _LOGGER.debug(f"{self.name} ({self.unique_id}) turned {self.state}") - self._attr_supported_color_modes = {COLOR_MODE_ONOFF} - self._last_brightness = brightness + _LOGGER.debug(f"{self.name} ({self.unique_id}) turned {self.state}") self.async_schedule_update_ha_state() async def async_turn_on(self, **kwargs) -> None: """Turn the light on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - if self._attr_brightness and brightness: + if self._dimmable and brightness: # Plejd brightness is two bytes, but HA brightness is one byte. payload = binascii.a2b_hex( f"{self._hex_id}0110009801{brightness:02x}{brightness:02x}" @@ -130,8 +120,17 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if id in plejdinfo["devices"]: _LOGGER.warning(f"Found duplicate definition for Plejd device {id}.") continue - _LOGGER.debug(f"Adding light {id} ({light_name})") - light = PlejdLight(light_name, id, service) + dimmable = True + dimmable_text = "dimmable " + for oo in CONF_ONOFF: + if light_name.endswith(oo): + dimmable = False + dimmable_text = "" + light_name.removesuffix(oo) + break + + _LOGGER.debug(f"Adding {dimmable_text}light {id} ({light_name})") + light = PlejdLight(light_name, id, dimmable, service) plejdinfo["devices"][id] = light lights.append(light) From 2f648f013e3c7710383a58d67930df267b0feb23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Sat, 2 Apr 2022 17:54:54 +0200 Subject: [PATCH 05/12] More lenient requirements --- custom_components/plejd/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/plejd/manifest.json b/custom_components/plejd/manifest.json index 5224ed7..53b43c4 100644 --- a/custom_components/plejd/manifest.json +++ b/custom_components/plejd/manifest.json @@ -3,7 +3,7 @@ "name": "Plejd", "version": "2.0.0", "documentation": "https://github.com/klali/ha-plejd", - "requirements": [ "dbus-next==0.2.2", "cryptography==3.4.7" ], + "requirements": [ "dbus-next>=0.2.2", "cryptography>=3.4.7" ], "dependencies": [], "codeowners": [ "@klali", "@bnordli" ], "iot_class": "local_push" From cd8b6bdf91697318e168644a6de446f6a3668a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Fri, 15 Jul 2022 13:54:29 +0200 Subject: [PATCH 06/12] Correct light name --- custom_components/plejd/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/plejd/light.py b/custom_components/plejd/light.py index 894a13b..be8021b 100644 --- a/custom_components/plejd/light.py +++ b/custom_components/plejd/light.py @@ -126,7 +126,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if light_name.endswith(oo): dimmable = False dimmable_text = "" - light_name.removesuffix(oo) + light_name = light_name.removesuffix(oo) break _LOGGER.debug(f"Adding {dimmable_text}light {id} ({light_name})") From df2942c4fe8ba6165bc610e39fa3a71f9b725f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Thu, 8 Sep 2022 08:31:54 +0200 Subject: [PATCH 07/12] Update README.md SPR-01 tested --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 421ae22..96f77a0 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The component will keep the time of the Plejd system up to date. | DIM-01-2P | 1x (dimmable) | | | | | | DIM-02 | 2x (dimmable) | | | | | | LED-10 | 1x (dimmable) | | | | No | -| SPR-01 | 1x | | | | No | +| SPR-01 | 1x | | | | | | REL-01-2P | 1x | | | | No | | REL-02 | 2x | | | | | | RTR-01 * | | | 1x* | Yes* | | From b4107f3683603f572d6d9bbbb2635879d1f4c447 Mon Sep 17 00:00:00 2001 From: "B. Nordli" Date: Sun, 9 Apr 2023 19:19:02 +0200 Subject: [PATCH 08/12] Move config instructions --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 2778a3a..6e7e460 100644 --- a/README.md +++ b/README.md @@ -59,21 +59,6 @@ before added to Home Assistant. (If a light is set to dimmable by error, add a suffix and then go to Developer Tools for this entity, set `supported_color_modes` to `- onoff` and remove the `brightness` line, or restart Home Assistant.) -## Filtering devices - -If you know that there are a lot of plejd devices nearby that is not part of your installation you can specify which -plejd ids home assistant is allowed to connect to using `endpoints: ['AAAAAAAAAAAA', 'BBBBBBBBBBBB', ... ]`. You can -find the id for each plejd device in the ios/android app under devices->[plejd device]->about or in the extracted -site.json file under `_outputAddresses`. For example, the following config would only allow connections to -`A1B2C3D4E5F6`. - -``` -light: - - platform: plejd - crypto_key: !secret plejd - endpoints: ['A1B2C3D4E5F6'] -``` - ## Tested platforms This component has been tested on the following platforms: @@ -191,6 +176,21 @@ plejd: All dictionary items map from (integral) plejd ids to the name they should have in Home Assistant. +## Filtering devices + +If you know that there are a lot of plejd devices nearby that is not part of your installation you can specify which +plejd ids home assistant is allowed to connect to using `endpoints: ['AAAAAAAAAAAA', 'BBBBBBBBBBBB', ... ]`. You can +find the id for each plejd device in the ios/android app under devices->[plejd device]->about or in the extracted +site.json file under `_outputAddresses`. For example, the following config would only allow connections to +`A1B2C3D4E5F6`. + +``` +plejd: + crypto_key: !secret plejd_crypto + endpoints: ['A1B2C3D4E5F6'] + ... +``` + ## Restarting Home Assistant The last step is to restart Home Assistant service, in the Home Assistant web From 7c6b22643a1f37554ebe0dfd205b02b7e34e8d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Fri, 15 Jul 2022 13:54:29 +0200 Subject: [PATCH 09/12] Correct light name --- custom_components/plejd/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/plejd/light.py b/custom_components/plejd/light.py index 894a13b..be8021b 100644 --- a/custom_components/plejd/light.py +++ b/custom_components/plejd/light.py @@ -126,7 +126,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if light_name.endswith(oo): dimmable = False dimmable_text = "" - light_name.removesuffix(oo) + light_name = light_name.removesuffix(oo) break _LOGGER.debug(f"Adding {dimmable_text}light {id} ({light_name})") From dfc7da47f479f58e50e1f46cea1ce8ed49c0cf5d Mon Sep 17 00:00:00 2001 From: "B. Nordli" Date: Sun, 9 Apr 2023 19:29:55 +0200 Subject: [PATCH 10/12] Implement write_data --- custom_components/plejd/__init__.py | 10 ++++++++++ custom_components/plejd/const.py | 1 + custom_components/plejd/plejd_service.py | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/custom_components/plejd/__init__.py b/custom_components/plejd/__init__.py index 3bfcf00..c6ecb1b 100644 --- a/custom_components/plejd/__init__.py +++ b/custom_components/plejd/__init__.py @@ -44,6 +44,7 @@ DEFAULT_DISCOVERY_TIMEOUT, DOMAIN, SCENE_SERVICE, + WRITE_DATA_SERVICE, ) from .plejd_service import PlejdService @@ -122,6 +123,15 @@ def handle_scene_service(call: ServiceCall) -> None: hass.services.async_register( DOMAIN, SCENE_SERVICE, handle_scene_service, schema=SCENE_SERVICE_SCHEMA ) + + @callback + async def handle_write_data_service(call: ServiceCall) -> None: + data = call.data.get("data") + _LOGGER.debug("Sending service data: '%s'" % (data)) + await plejd_write(plejdinfo, binascii.a2b_hex(data)) + + hass.services.async_register(DOMAIN, WRITE_DATA_SERVICE, handle_write_data_service) + _LOGGER.debug("Plejd platform setup completed") hass.async_create_task(service.request_update()) return True diff --git a/custom_components/plejd/const.py b/custom_components/plejd/const.py index b64904e..147f33b 100644 --- a/custom_components/plejd/const.py +++ b/custom_components/plejd/const.py @@ -18,6 +18,7 @@ BUTTON_EVENT = DOMAIN + "_button_event" SCENE_EVENT = DOMAIN + "_scene_event" SCENE_SERVICE = "trigger_scene" +WRITE_DATA_SERVICE = "write_data" CONF_CRYPTO_KEY = "crypto_key" CONF_DISCOVERY_TIMEOUT = "discovery_timeout" diff --git a/custom_components/plejd/plejd_service.py b/custom_components/plejd/plejd_service.py index fd17526..4737784 100644 --- a/custom_components/plejd/plejd_service.py +++ b/custom_components/plejd/plejd_service.py @@ -424,6 +424,10 @@ def trigger_scene(self, id: int) -> None: _LOGGER.debug(f"Trigger scene {id}") self._hass.async_create_task(self._write(payload)) + async def write_data(self, data: str) -> None: + """Write data directly to the bus""" + await self._bus.write_data("data", binascii.a2b_hex(data)) + async def request_update(self) -> None: """Request an update of all devices.""" if not self._bus: From ae703d5a9cd08ed13aa8beee4b79aa585dbbf9e2 Mon Sep 17 00:00:00 2001 From: "B. Nordli" Date: Sun, 9 Apr 2023 21:13:01 +0200 Subject: [PATCH 11/12] Correct function name --- custom_components/plejd/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/plejd/__init__.py b/custom_components/plejd/__init__.py index c6ecb1b..02be5aa 100644 --- a/custom_components/plejd/__init__.py +++ b/custom_components/plejd/__init__.py @@ -128,7 +128,7 @@ def handle_scene_service(call: ServiceCall) -> None: async def handle_write_data_service(call: ServiceCall) -> None: data = call.data.get("data") _LOGGER.debug("Sending service data: '%s'" % (data)) - await plejd_write(plejdinfo, binascii.a2b_hex(data)) + await service.write_data(data) hass.services.async_register(DOMAIN, WRITE_DATA_SERVICE, handle_write_data_service) From 8f5028930bd9fb3f14946ec17b23968e33e4ec1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C3=B8rge=20Nordli?= Date: Tue, 9 Jan 2024 10:56:01 +0100 Subject: [PATCH 12/12] Deprecation notice --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6e7e460..223c87b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +## Deprecation notice + +This component has been deprecated in favor of the more user friendly +https://github.com/thomasloven/hass_plejd HACS component. + # Plejd component for Home Assistant This is a Plejd component for Home Assistant, interfacing with the Bluetooth LE