From db34a5c7d1cb9a01bb4ca1b28bb51368c2483dc5 Mon Sep 17 00:00:00 2001 From: Erwin Boskma Date: Fri, 17 Mar 2023 18:26:04 +0100 Subject: [PATCH] Initial commit --- .gitignore | 212 +++++++++++++++++ custom_components/linak_desk/__init__.py | 78 ++++++ custom_components/linak_desk/api/__init__.py | 3 + custom_components/linak_desk/api/device.py | 222 ++++++++++++++++++ custom_components/linak_desk/api/lock.py | 23 ++ custom_components/linak_desk/config_flow.py | 108 +++++++++ custom_components/linak_desk/const.py | 34 +++ custom_components/linak_desk/manifest.json | 16 ++ custom_components/linak_desk/model.py | 72 ++++++ custom_components/linak_desk/sensor.py | 97 ++++++++ custom_components/linak_desk/services.yaml | 9 + custom_components/linak_desk/strings.json | 13 + .../linak_desk/translations/en.json | 13 + 13 files changed, 900 insertions(+) create mode 100644 .gitignore create mode 100644 custom_components/linak_desk/__init__.py create mode 100644 custom_components/linak_desk/api/__init__.py create mode 100644 custom_components/linak_desk/api/device.py create mode 100644 custom_components/linak_desk/api/lock.py create mode 100644 custom_components/linak_desk/config_flow.py create mode 100644 custom_components/linak_desk/const.py create mode 100644 custom_components/linak_desk/manifest.json create mode 100644 custom_components/linak_desk/model.py create mode 100644 custom_components/linak_desk/sensor.py create mode 100644 custom_components/linak_desk/services.yaml create mode 100644 custom_components/linak_desk/strings.json create mode 100644 custom_components/linak_desk/translations/en.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e435436 --- /dev/null +++ b/.gitignore @@ -0,0 +1,212 @@ +# Created by https://www.toptal.com/developers/gitignore/api/python,homeassistant +# Edit at https://www.toptal.com/developers/gitignore?templates=python,homeassistant + +### HomeAssistant ### +# Files with personal details +*.crt +*.csr +*.key +.google.token +.uuid +icloud/ +google_calendars.yaml +harmony_media_room.conf +home-assistant.db +home-assistant_v2.db +home-assistant_v2.db-* +html5_push_registrations.conf +ip_bans.yaml +known_devices.yaml +phue.conf +plex.conf +pyozw.sqlite +secrets.yaml +tradfri.conf + +# Temporary files +*.db-journal +*.pid +tts + +# automatically downloaded dependencies +deps +lib +www + +# Log files +home-assistant.log +ozw_log.txt + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/python,homeassistant diff --git a/custom_components/linak_desk/__init__.py b/custom_components/linak_desk/__init__.py new file mode 100644 index 0000000..382052d --- /dev/null +++ b/custom_components/linak_desk/__init__.py @@ -0,0 +1,78 @@ +"""The linak integration.""" +from __future__ import annotations +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, Platform +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .api import Device +from .model import LinakDataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up linak from a config entry.""" + + hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) + + ble_device = bluetooth.async_ble_device_from_address(hass, entry.data[CONF_ADDRESS]) + + if not ble_device: + raise ConfigEntryNotReady( + f"Could not find a nearby device for address: {entry.data[CONF_ADDRESS]}" + ) + + device = Device(ble_device) + await device.connect(retry_attempts=4) + + if not device.is_connected: + raise ConfigEntryNotReady(f"Failed to connect to: {device.mac}") + + @callback + def _async_update_ble( + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update from a ble callback""" + device.update_ble_device(service_info.device) + + @callback + async def handle_move_to_height(call: ServiceCall): + target_height: float = call.data.get("target_height") + await device.move_to_height(target_height) + + bluetooth.async_register_callback( + hass, + _async_update_ble, + BluetoothCallbackMatcher(address=device.mac), + bluetooth.BluetoothScanningMode.PASSIVE, + ) + + coordinator = LinakDataUpdateCoordinator(hass, device) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + hass.services.async_register(DOMAIN, "move_to_height", handle_move_to_height) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + device: Device = hass.data[DOMAIN][entry.entry_id].data + + await device.disconnect() + + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/custom_components/linak_desk/api/__init__.py b/custom_components/linak_desk/api/__init__.py new file mode 100644 index 0000000..a11ef5e --- /dev/null +++ b/custom_components/linak_desk/api/__init__.py @@ -0,0 +1,3 @@ +"""Linak Bluetooth API""" + +from .device import Device diff --git a/custom_components/linak_desk/api/device.py b/custom_components/linak_desk/api/device.py new file mode 100644 index 0000000..1ef45f6 --- /dev/null +++ b/custom_components/linak_desk/api/device.py @@ -0,0 +1,222 @@ +"""Linak BLE device""" + +import asyncio +import logging +import struct + +from bleak import BleakClient +from bleak.backends.device import BLEDevice +from bleak.exc import BleakError +from bleak_retry_connector import establish_connection + + +from .lock import ble_lock, GLOBAL_BLE_LOCK + +_LOGGER = logging.getLogger(__name__) + +UUID_DEVICE_NAME: str = "00002a00-0000-1000-8000-00805f9b34fb" +UUID_HEIGHT: str = "99fa0021-338a-1024-8a49-009c0215f78a" +UUID_COMMAND: str = "99fa0002-338a-1024-8a49-009c0215f78a" +UUID_REFERENCE_INPUT: str = "99fa0031-338a-1024-8a49-009c0215f78a" +UUID_ADV_SERVICE: str = "99fa0001-338a-1024-8a49-009c0215f78a" + +COMMAND_STOP = bytearray(struct.pack(" tuple[int, int]: + """Parses and converts height and speed data""" + raw_height, raw_speed = struct.unpack(" None: + self._ble_device = device + self._is_connected = False + self._mac = device.address + self._height = 620 + self._speed = 0 + + def disconnected_callback(self, client): + """Callback for when the device is disconnected""" + + _LOGGER.warning("Disconnected from %s", self._mac) + self._is_connected = False + + async def _read_model(self): + """Does something""" + + manufacturer_data = await self._connection.read_gatt_char(UUID_DEVICE_NAME) + name = manufacturer_data.decode("utf-8") + + _LOGGER.debug("Read manufacturer data: %s", name) + + self._name = name + + @ble_lock + async def connect(self, retry_attempts=4) -> None: + """Connect to the device""" + + if self._is_connected or self._connection_lock.locked(): + return + + async with self._connection_lock: + try: + _LOGGER.debug("Connecting to %s", self._mac) + + self._connection = await establish_connection( + client_class=BleakClient, + device=self._ble_device, + name=self._mac, + disconnected_callback=self.disconnected_callback, + max_attempts=retry_attempts, + use_services_cache=True, + ) + + _LOGGER.debug("Listing device data") + for service in self._connection.services: + _LOGGER.debug("Service found: %s", service.uuid) + for characteristic in service.characteristics: + _LOGGER.debug( + " Characteristing found: %s", characteristic.uuid + ) + + await self._read_model() + + self._is_connected = True + + _LOGGER.debug("Connected to %s", self._mac) + + except BleakError as error: + _LOGGER.error("Failed to connect to %s: %s", self._mac, error) + self._is_connected = False + + @ble_lock + async def disconnect(self) -> None: + """Disconnect the device""" + await self._write(UUID_COMMAND, COMMAND_STOP) + await self._connection.disconnect() + + async def fetch_state(self) -> None: + """Update the state of the device""" + + if not self._is_connected: + await self.connect(retry_attempts=1) + + async with GLOBAL_BLE_LOCK: + uuids = [UUID_HEIGHT] + + try: + bytes_array: list[bytes] = await asyncio.gather( + *[self._read(uuid) for uuid in uuids], return_exceptions=True + ) + + for i, some_bytes in enumerate(bytes_array): + uuid = uuids[i] + # _LOGGER.debug("Received %s", some_bytes) + + if isinstance(some_bytes, BleakError): + error: BleakError = BleakError(some_bytes) + + _LOGGER.error( + "Error reading UUID %s: (%s) %s", + uuid, + type(some_bytes), + error, + ) + elif uuid == UUID_HEIGHT: + self._height, self._speed = parse_height_speed(some_bytes) + + except BleakError as error: + if self._is_connected: + raise error + + async def move_to_height(self, height: float) -> None: + """Move to a specific height""" + if height == self._height: + _LOGGER.debug("Already at target height") + return + + _LOGGER.debug("Moving to %f", height) + + await self._write(UUID_COMMAND, COMMAND_WAKEUP) + await self._write(UUID_COMMAND, COMMAND_STOP) + + target_height = int((height - 620) * 10) + encoded_height = struct.pack(" bytes: + return await self._connection.read_gatt_char(uuid) + + async def _write(self, uuid: str, data: bytes) -> None: + await self._connection.write_gatt_char(uuid, data) + + @property + def is_connected(self) -> bool: + """Returns whether the device is connected""" + return self._is_connected + + @property + def mac(self) -> str: + """Return the MAC address of the device""" + return self._mac + + @property + def name(self) -> str: + """Return device name""" + return self._name + + @property + def height(self) -> float: + """Return current height in mm""" + return self._height + + @property + def speed(self) -> float: + """Return current movement speed in mm/s""" + return self._speed + + def update_ble_device(self, ble_device: BLEDevice) -> None: + """Updates the cached BLEDevice for the device""" + self._ble_device = ble_device diff --git a/custom_components/linak_desk/api/lock.py b/custom_components/linak_desk/api/lock.py new file mode 100644 index 0000000..01d6c11 --- /dev/null +++ b/custom_components/linak_desk/api/lock.py @@ -0,0 +1,23 @@ +"""Locking for BLE ops""" + +import asyncio +from functools import wraps +from typing import Any, TypeVar +from collections.abc import Callable, Coroutine + +GLOBAL_BLE_LOCK: asyncio.Lock = asyncio.Lock() + +RT = TypeVar("RT") + + +def ble_lock( + func: Callable[..., Coroutine[Any, Any, RT]] +) -> Callable[..., Coroutine[Any, Any, RT]]: + """Decorator to lock BLE operations""" + + @wraps(func) + async def wrapper(*args, **kwargs) -> RT: + async with GLOBAL_BLE_LOCK: + return await func(*args, **kwargs) + + return wrapper diff --git a/custom_components/linak_desk/config_flow.py b/custom_components/linak_desk/config_flow.py new file mode 100644 index 0000000..a7ec0cc --- /dev/null +++ b/custom_components/linak_desk/config_flow.py @@ -0,0 +1,108 @@ +"""Config flow for linak integration.""" +from __future__ import annotations + +from typing import Any +from home_assistant_bluetooth import BluetoothServiceInfoBleak + +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.bluetooth.api import async_discovered_service_info +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, UUID_ADV_SERVICE + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for linak.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize config flow""" + self._discovered_address: str + self._discovered_addresses: list[str] = [] + + def _create_entry(self, address: str) -> FlowResult: + """Create an entry for a discovered device""" + + return self.async_create_entry( + title=address, + data={ + CONF_ADDRESS: address, + }, + ) + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user-configuration of discovered device.""" + + if user_input is not None: + return self._create_entry(self._discovered_address) + + return self.async_show_form( + step_id='bluetooth_confirm', + description_placeholders={"name": self._discovered_address}, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> data_entry_flow.FlowResult: + """Handle config initiated by discovery""" + + address = discovery_info.address + + await self.async_set_unique_id(address) + self._abort_if_unique_id_configured(updates={CONF_ADDRESS: address}) + + self._discovered_address = address + + self.context['title_placeholders'] = {"name": address} + return await self.async_step_bluetooth_confirm() + + async def async_step_pick_device( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle pick discovered device""" + + if user_input is not None: + address = user_input[CONF_ADDRESS] + + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self._create_entry(address) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info( + self.hass, connectable=True + ): + if UUID_ADV_SERVICE in discovery_info.service_uuids: + address = discovery_info.address + if ( + address not in current_addresses + and address not in self._discovered_addresses + ): + self._discovered_addresses.append(address) + + addresses = { + address + for address in self._discovered_addresses + if address not in current_addresses + } + + if not addresses: + return self.async_abort(reason="no_devices_found") + + return self.async_show_form( + step_id='pick_device', + data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(addresses)}), + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + return await self.async_step_pick_device() diff --git a/custom_components/linak_desk/const.py b/custom_components/linak_desk/const.py new file mode 100644 index 0000000..d5e6d5a --- /dev/null +++ b/custom_components/linak_desk/const.py @@ -0,0 +1,34 @@ +"""Constants for the linak integration.""" + +DOMAIN = "linak_desk" + +UUID_HEIGHT: str = "99fa0021-338a-1024-8a49-009c0215f78a" +UUID_COMMAND: str = "99fa0002-338a-1024-8a49-009c0215f78a" +UUID_REFERENCE_INPUT: str = "99fa0031-338a-1024-8a49-009c0215f78a" +UUID_ADV_SERVICE: str = "99fa0001-338a-1024-8a49-009c0215f78a" + +COMMAND_REFERENCE_INPUT_STOP: bytearray = bytearray([0x01, 0x80]) +COMMAND_STOP: bytearray = bytearray([0xFF, 0x00]) +COMMAND_WAKEUP: bytearray = bytearray([0xFE, 0x00]) +COMMAND_UP: bytearray = bytearray([0x47, 0x00]) +COMMAND_DOWN: bytearray = bytearray([0x46, 0x00]) + +# Listing device data +# Service found: 00001800-0000-1000-8000-00805f9b34fb +# Characteristing found: 00002a00-0000-1000-8000-00805f9b34fb +# Characteristing found: 00002a01-0000-1000-8000-00805f9b34fb +# Characteristing found: 00002a04-0000-1000-8000-00805f9b34fb +# Characteristing found: 00002aa6-0000-1000-8000-00805f9b34fb +# Service found: 00001801-0000-1000-8000-00805f9b34fb +# Characteristing found: 00002a05-0000-1000-8000-00805f9b34fb +# Service found: 99fa0001-338a-1024-8a49-009c0215f78a +# Characteristing found: 99fa0002-338a-1024-8a49-009c0215f78a +# Characteristing found: 99fa0003-338a-1024-8a49-009c0215f78a +# Service found: 99fa0010-338a-1024-8a49-009c0215f78a +# Characteristing found: 99fa0011-338a-1024-8a49-009c0215f78a +# Service found: 99fa0020-338a-1024-8a49-009c0215f78a +# Characteristing found: 99fa0021-338a-1024-8a49-009c0215f78a +# Characteristing found: 99fa0029-338a-1024-8a49-009c0215f78a +# Characteristing found: 99fa002a-338a-1024-8a49-009c0215f78a +# Service found: 99fa0030-338a-1024-8a49-009c0215f78a +# Characteristing found: 99fa0031-338a-1024-8a49-009c0215f78a diff --git a/custom_components/linak_desk/manifest.json b/custom_components/linak_desk/manifest.json new file mode 100644 index 0000000..4ce5bae --- /dev/null +++ b/custom_components/linak_desk/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "linak_desk", + "name": "Linak", + "bluetooth": [ + { + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a" + } + ], + "version": "0.1.0", + "codeowners": ["@eboskma"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://github.com/eboskma/ha-linak-desk", + "iot_class": "local_push", + "requirements": [] +} diff --git a/custom_components/linak_desk/model.py b/custom_components/linak_desk/model.py new file mode 100644 index 0000000..3be0584 --- /dev/null +++ b/custom_components/linak_desk/model.py @@ -0,0 +1,72 @@ +"""Linak Bluetooth data coordinator""" + +from datetime import timedelta +import logging + +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.core import HomeAssistant, callback + +from .api import Device + + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LinakDataUpdateCoordinator(DataUpdateCoordinator[Device]): + """Linak data update coordinator""" + + _device: Device + + def __init__(self, hass: HomeAssistant, device: Device) -> None: + """Initialize coordinator""" + super().__init__( + hass, + _LOGGER, + name="Linak Bluetooth", + update_interval=timedelta(milliseconds=250), + ) + self._device = device + + async def _async_update_data(self): + """Update device state""" + + await self._device.fetch_state() + return self._device + + +class LinakBluetoothEntity(CoordinatorEntity[LinakDataUpdateCoordinator]): + """Base class for Linak entities""" + + _device: Device + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LinakDataUpdateCoordinator, + ) -> None: + """Initialize Linak base entity""" + super().__init__(coordinator) + + self._device = coordinator.data + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device.mac)}, + manufacturer="Linak", + name=self._device.name, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator""" + self._device = self.coordinator.data + self.async_write_ha_state() + + @property + def available(self) -> bool: + return self._device.is_connected diff --git a/custom_components/linak_desk/sensor.py b/custom_components/linak_desk/sensor.py new file mode 100644 index 0000000..db99a31 --- /dev/null +++ b/custom_components/linak_desk/sensor.py @@ -0,0 +1,97 @@ +"""Linak Sensors""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from collections.abc import Callable + +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorDeviceClass, +) +from homeassistant.components.sensor import SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfSpeed, UnitOfLength +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .model import LinakBluetoothEntity, LinakDataUpdateCoordinator +from .api import Device +from .const import DOMAIN + + +@dataclass +class LinakSensorEntityDescriptionMixin: + """Mixin for required keys""" + + state_fn: Callable[[Device], Any] + + +@dataclass +class LinakSensorEntityDescription( + SensorEntityDescription, LinakSensorEntityDescriptionMixin +): + """Dsescribes Linak sensor entity""" + + +# Entity sensor.bert_height () is using state class 'SensorStateClass.MEASUREMENT' which is impossible considering device class ('distance') it is using; expected None or one of 'total', 'total_increasing', 'measurement'; Please update your configuration if your entity is manually configured, otherwise report it to the custom integration author. +# Entity sensor.bert_speed () is using state class 'SensorStateClass.MEASUREMENT' which is impossible considering device class ('speed') it is using; expected None or one of 'measurement'; Please update your configuration if your entity is manually configured, otherwise report it to the custom integration author. + + +DEVICE_ENTITY_DESCRIPTIONS: list[LinakSensorEntityDescription] = [ + LinakSensorEntityDescription( + device_class=SensorDeviceClass.DISTANCE, + key="height", + name="Height", + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + state_fn=lambda device: device.height, + ), + LinakSensorEntityDescription( + device_class=SensorDeviceClass.SPEED, + key="speed", + name="Speed", + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + state_class=SensorStateClass.MEASUREMENT, + state_fn=lambda device: device.speed / 1000, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform""" + + coordinator: LinakDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + LinakSensorEntity(coordinator, description) + for description in DEVICE_ENTITY_DESCRIPTIONS + ) + + +class LinakSensorEntity(LinakBluetoothEntity, SensorEntity): + """Linak desk sensor""" + + entity_description: LinakSensorEntityDescription + + def __init__( + self, + coordinator: LinakDataUpdateCoordinator, + entity_description: LinakSensorEntityDescription, + ) -> None: + super().__init__(coordinator) + + self._attr_unique_id = f"{self._device.mac}-{entity_description.key}" + + self.entity_description = entity_description + + @property + def native_value(self) -> StateType: + return self.entity_description.state_fn(self._device) diff --git a/custom_components/linak_desk/services.yaml b/custom_components/linak_desk/services.yaml new file mode 100644 index 0000000..bf759a1 --- /dev/null +++ b/custom_components/linak_desk/services.yaml @@ -0,0 +1,9 @@ +move_to_height: + name: Move to height + description: Moves the desk to the specified height + target: + fields: + target_height: + name: Target height + description: The height in millimeters to move the desk to + required: true \ No newline at end of file diff --git a/custom_components/linak_desk/strings.json b/custom_components/linak_desk/strings.json new file mode 100644 index 0000000..ad8f0f4 --- /dev/null +++ b/custom_components/linak_desk/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/custom_components/linak_desk/translations/en.json b/custom_components/linak_desk/translations/en.json new file mode 100644 index 0000000..1f858b1 --- /dev/null +++ b/custom_components/linak_desk/translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "no_devices_found": "No devices found on the network", + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "confirm": { + "description": "Do you want to start setup?" + } + } + } +} \ No newline at end of file