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