Initial commit

This commit is contained in:
Erwin Boskma 2023-03-17 18:26:04 +01:00
commit db34a5c7d1
Signed by: erwin
SSH key fingerprint: SHA256:CyeNoWXd3kjX2Nwu6pDxxdS7OqmPVOy0NavA/KU/ntU
13 changed files with 900 additions and 0 deletions

212
.gitignore vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
"""Linak Bluetooth API"""
from .device import Device

View file

@ -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("<H", 255))
COMMAND_WAKEUP = bytearray(struct.pack("<H", 254))
# 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
def parse_height_speed(value: bytes) -> tuple[int, int]:
"""Parses and converts height and speed data"""
raw_height, raw_speed = struct.unpack("<Hh", value)
height = (raw_height / 10) + 620
speed = raw_speed / 100
return (height, speed)
class Device:
"""Bluetooth stuff"""
_ble_device: BLEDevice
_connection: BleakClient
_connection_lock = asyncio.Lock()
_is_connected: bool
_height: float
_speed: float
_name: str
def __init__(self, device: BLEDevice) -> 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("<H", target_height)
while True:
await self._write(UUID_REFERENCE_INPUT, encoded_height)
await asyncio.sleep(0.5)
_height, speed = parse_height_speed(await self._read(UUID_HEIGHT))
if speed == 0:
break
async def _read(self, uuid: str) -> 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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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": []
}

View file

@ -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

View file

@ -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 (<class 'custom_components.linak_desk.sensor.LinakSensorEntity'>) 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 (<class 'custom_components.linak_desk.sensor.LinakSensorEntity'>) 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)

View file

@ -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

View file

@ -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%]"
}
}
}

View file

@ -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?"
}
}
}
}