Initial commit
This commit is contained in:
commit
db34a5c7d1
13 changed files with 900 additions and 0 deletions
212
.gitignore
vendored
Normal file
212
.gitignore
vendored
Normal 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
|
78
custom_components/linak_desk/__init__.py
Normal file
78
custom_components/linak_desk/__init__.py
Normal 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
|
3
custom_components/linak_desk/api/__init__.py
Normal file
3
custom_components/linak_desk/api/__init__.py
Normal file
|
@ -0,0 +1,3 @@
|
|||
"""Linak Bluetooth API"""
|
||||
|
||||
from .device import Device
|
222
custom_components/linak_desk/api/device.py
Normal file
222
custom_components/linak_desk/api/device.py
Normal 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
|
23
custom_components/linak_desk/api/lock.py
Normal file
23
custom_components/linak_desk/api/lock.py
Normal 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
|
108
custom_components/linak_desk/config_flow.py
Normal file
108
custom_components/linak_desk/config_flow.py
Normal 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()
|
34
custom_components/linak_desk/const.py
Normal file
34
custom_components/linak_desk/const.py
Normal 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
|
16
custom_components/linak_desk/manifest.json
Normal file
16
custom_components/linak_desk/manifest.json
Normal 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": []
|
||||
}
|
72
custom_components/linak_desk/model.py
Normal file
72
custom_components/linak_desk/model.py
Normal 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
|
97
custom_components/linak_desk/sensor.py
Normal file
97
custom_components/linak_desk/sensor.py
Normal 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)
|
9
custom_components/linak_desk/services.yaml
Normal file
9
custom_components/linak_desk/services.yaml
Normal 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
|
13
custom_components/linak_desk/strings.json
Normal file
13
custom_components/linak_desk/strings.json
Normal 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%]"
|
||||
}
|
||||
}
|
||||
}
|
13
custom_components/linak_desk/translations/en.json
Normal file
13
custom_components/linak_desk/translations/en.json
Normal 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?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue