diff --git a/README.md b/README.md index 6a26618..223c87b 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,90 @@ +## 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 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). + +## 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. -## Getting started +## 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 | +| SPR-01 | 1x | | | | | +| 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. + +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: - - 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 +100,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,34 +122,66 @@ $ 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: +### Hassbian -Make sure the homeassistant user has permissions to use bluetooth, this might -require putting it in the bluetooth group. +Make sure the Home Assistant user has permissions to use Bluetooth, this might +require putting it in the Bluetooth group. -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: +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 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): ``` -light: - - platform: plejd - crypto_key: !secret plejd - devices: - 11: - name: bedroom - 13: - name: kitchen_1 - 14: - name: kitchen_2 - 16: - name: bathroom +mkdir -p /usr/share/hassio/homeassistant/custom_components/plejd ``` +Checkout the git repo and rename folder +``` +cd /usr/share/hassio/homeassistant/custom_components/plejd +git clone https://github.com/klali/ha-plejd.git +mv custom_components/plejd/* . +``` + +## 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 +``` + +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 @@ -96,55 +190,23 @@ site.json file under `_outputAddresses`. For example, the following config would `A1B2C3D4E5F6`. ``` -light: - - platform: plejd - crypto_key: !secret plejd - endpoints: ['A1B2C3D4E5F6'] - devices: - 11: - name: bedroom - 13: - name: kitchen_1 - 14: - name: kitchen_2 - 16: - name: bathroom +plejd: + crypto_key: !secret plejd_crypto + endpoints: ['A1B2C3D4E5F6'] + ... ``` -### HASS.IO Docker container +## Restarting Home Assistant -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 -``` -Checkout the git repo and rename folder -``` -cd /usr/share/hassio/homeassistant/custom_components -git clone https://github.com/klali/ha-plejd.git -mv ha-plejd plejd -``` -Update your configuration.yaml file -``` -light: - - platform: plejd - crypto_key: !secret plejd - devices: - 11: - name: bedroom - 13: - name: kitchen_1 - 14: - name: kitchen_2 - 16: - name: bathroom - -``` -Last step is to restart homeassistant service, in the homeassistant web ui, go to Configuration -> General -> Server management and hit restart. +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: @@ -156,6 +218,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..02be5aa 100644 --- a/custom_components/plejd/__init__.py +++ b/custom_components/plejd/__init__.py @@ -1 +1,137 @@ -"""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_ENDPOINTS, + CONF_OFFSET_MINUTES, + CONF_SCENES, + DEFAULT_DBUS_PATH, + DEFAULT_DISCOVERY_TIMEOUT, + DOMAIN, + SCENE_SERVICE, + WRITE_DATA_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_ENDPOINTS, default=[]): vol.All(cv.ensure_list, [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 + ) + + @callback + async def handle_write_data_service(call: ServiceCall) -> None: + data = call.data.get("data") + _LOGGER.debug("Sending service data: '%s'" % (data)) + await service.write_data(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/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..147f33b --- /dev/null +++ b/custom_components/plejd/const.py @@ -0,0 +1,49 @@ +# 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" +WRITE_DATA_SERVICE = "write_data" + +CONF_CRYPTO_KEY = "crypto_key" +CONF_DISCOVERY_TIMEOUT = "discovery_timeout" +CONF_DBUS_ADDRESS = "dbus_address" +CONF_ENDPOINTS = "endpoints" +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" +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 b89ff99..be8021b 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,510 +12,126 @@ # 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, + ATTR_SUPPORTED_COLOR_MODES, + 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' -CONF_ENDPOINTS = 'endpoints' - -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 CONF_ONOFF, 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, - vol.Optional(CONF_ENDPOINTS, default=[]): vol.All(cv.ensure_list, [cv.string]), - }) - - -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 + _dimmable: bool + + 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.""" await super().async_added_to_hass() old = await self.async_get_last_state() if old is not None: - self._state = old.state == STATE_ON - if old.attributes.get(ATTR_BRIGHTNESS) is not None: - brightness = int(old.attributes[ATTR_BRIGHTNESS]) - self._brightness = brightness << 8 | brightness + self._attr_is_on = old.state == STATE_ON + self._attr_supported_color_modes = old.attributes.get( + ATTR_SUPPORTED_COLOR_MODES + ) + self._attr_brightness = old.attributes.get(ATTR_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 - 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._dimmable: + brightness = brightness or 0 + _LOGGER.debug( + f"{self.name} ({self.unique_id}) turned {self.state} with brightness {brightness}" + ) + self._attr_brightness = brightness else: - _LOGGER.debug("%s(%02x) turned %r" % (self._name, self._id, 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): - 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._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}" + ) + _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)) - - _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): - 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(f"{self._hex_id}0110009701") + _LOGGER.debug(f"Turning on {self.name} ({self.unique_id})") + await self._service._write(payload) - payload = binascii.a2b_hex("%02x0110009700" % (self._id)) - _LOGGER.debug("Turning off %s(%02x)" % (self._name, self._id)) - 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 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"])) +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Plejd light platform.""" + if discovery_info is None: 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 + plejdinfo = hass.data[DOMAIN] + service: PlejdService = plejdinfo["service"] + lights = [] - 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'])) - - # Filter list of plejds if we are interested in specific endpoints - if len(pi['endpoints']) > 0: - _LOGGER.debug("Ignoring any device that is not one of %s" % (str(pi['endpoints']))) - plejds = [plejd for plejd in plejds if plejd['path'].split('/dev_')[1].replace('_','') in pi['endpoints']] - - if len(plejds) == 0: - _LOGGER.warning("No plejd devices found") - 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 = [] - - 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, List, 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_ENDPOINTS, + 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, endpoints: List[str]) -> 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']}") + + # Filter list of plejds if we are interested in specific endpoints + if len(endpoints) > 0: + _LOGGER.debug("Ignoring any device that is not one of %s" % (str(endpoints))) + plejds = [plejd for plejd in plejds if plejd['path'].split('/dev_')[1].replace('_','') in endpoints] + + 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), + self._config.get(CONF_ENDPOINTS, []) + ): + 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 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: + _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 + async def _retry(now): + await self._write(payload) + + 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: '{e}'") + if str(e) == "In Progress": + _LOGGER.debug("Postponing write") + async_track_point_in_utc_time(self._hass, _retry, dt_util.utcnow() + timedelta(seconds = 5)) + else: + _LOGGER.warning(f"Reconnecting") + 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 index 8a73ee8..76b4464 100644 --- a/custom_components/plejd/services.yaml +++ b/custom_components/plejd/services.yaml @@ -1,3 +1,15 @@ +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 write_data: name: Write Plejd data description: Write custom data to the Plejd mesh 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 +```