Convert fjäråskupan to built in bluetooth (#75380)
* Add bluetooth discovery * Use home assistant standard api * Fixup manufacture data * Adjust config flow to use standard features * Fixup tests * Mock bluetooth * Simplify device check * Fix missing typing Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
f2da46d99b
commit
2dd62b14b6
7 changed files with 109 additions and 132 deletions
|
@ -5,14 +5,20 @@ from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from bleak import BleakScanner
|
from fjaraskupan import Device, State
|
||||||
from fjaraskupan import Device, State, device_filter
|
|
||||||
|
|
||||||
|
from homeassistant.components.bluetooth import (
|
||||||
|
BluetoothCallbackMatcher,
|
||||||
|
BluetoothChange,
|
||||||
|
BluetoothScanningMode,
|
||||||
|
BluetoothServiceInfoBleak,
|
||||||
|
async_address_present,
|
||||||
|
async_register_callback,
|
||||||
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import Event, HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.dispatcher import (
|
from homeassistant.helpers.dispatcher import (
|
||||||
async_dispatcher_connect,
|
async_dispatcher_connect,
|
||||||
async_dispatcher_send,
|
async_dispatcher_send,
|
||||||
|
@ -23,11 +29,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||||
|
|
||||||
from .const import DISPATCH_DETECTION, DOMAIN
|
from .const import DISPATCH_DETECTION, DOMAIN
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from bleak.backends.device import BLEDevice
|
|
||||||
from bleak.backends.scanner import AdvertisementData
|
|
||||||
|
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.FAN,
|
Platform.FAN,
|
||||||
|
@ -70,16 +71,18 @@ class Coordinator(DataUpdateCoordinator[State]):
|
||||||
async def _async_update_data(self) -> State:
|
async def _async_update_data(self) -> State:
|
||||||
"""Handle an explicit update request."""
|
"""Handle an explicit update request."""
|
||||||
if self._refresh_was_scheduled:
|
if self._refresh_was_scheduled:
|
||||||
raise UpdateFailed("No data received within schedule.")
|
if async_address_present(self.hass, self.device.address):
|
||||||
|
return self.device.state
|
||||||
|
raise UpdateFailed(
|
||||||
|
"No data received within schedule, and device is no longer present"
|
||||||
|
)
|
||||||
|
|
||||||
await self.device.update()
|
await self.device.update()
|
||||||
return self.device.state
|
return self.device.state
|
||||||
|
|
||||||
def detection_callback(
|
def detection_callback(self, service_info: BluetoothServiceInfoBleak) -> None:
|
||||||
self, ble_device: BLEDevice, advertisement_data: AdvertisementData
|
|
||||||
) -> None:
|
|
||||||
"""Handle a new announcement of data."""
|
"""Handle a new announcement of data."""
|
||||||
self.device.detection_callback(ble_device, advertisement_data)
|
self.device.detection_callback(service_info.device, service_info.advertisement)
|
||||||
self.async_set_updated_data(self.device.state)
|
self.async_set_updated_data(self.device.state)
|
||||||
|
|
||||||
|
|
||||||
|
@ -87,59 +90,52 @@ class Coordinator(DataUpdateCoordinator[State]):
|
||||||
class EntryState:
|
class EntryState:
|
||||||
"""Store state of config entry."""
|
"""Store state of config entry."""
|
||||||
|
|
||||||
scanner: BleakScanner
|
|
||||||
coordinators: dict[str, Coordinator]
|
coordinators: dict[str, Coordinator]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Fjäråskupan from a config entry."""
|
"""Set up Fjäråskupan from a config entry."""
|
||||||
|
|
||||||
scanner = BleakScanner(filters={"DuplicateData": True})
|
state = EntryState({})
|
||||||
|
|
||||||
state = EntryState(scanner, {})
|
|
||||||
hass.data.setdefault(DOMAIN, {})
|
hass.data.setdefault(DOMAIN, {})
|
||||||
hass.data[DOMAIN][entry.entry_id] = state
|
hass.data[DOMAIN][entry.entry_id] = state
|
||||||
|
|
||||||
async def detection_callback(
|
def detection_callback(
|
||||||
ble_device: BLEDevice, advertisement_data: AdvertisementData
|
service_info: BluetoothServiceInfoBleak, change: BluetoothChange
|
||||||
) -> None:
|
) -> None:
|
||||||
if data := state.coordinators.get(ble_device.address):
|
if change != BluetoothChange.ADVERTISEMENT:
|
||||||
_LOGGER.debug(
|
|
||||||
"Update: %s %s - %s", ble_device.name, ble_device, advertisement_data
|
|
||||||
)
|
|
||||||
|
|
||||||
data.detection_callback(ble_device, advertisement_data)
|
|
||||||
else:
|
|
||||||
if not device_filter(ble_device, advertisement_data):
|
|
||||||
return
|
return
|
||||||
|
if data := state.coordinators.get(service_info.address):
|
||||||
|
_LOGGER.debug("Update: %s", service_info)
|
||||||
|
data.detection_callback(service_info)
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("Detected: %s", service_info)
|
||||||
|
|
||||||
_LOGGER.debug(
|
device = Device(service_info.device)
|
||||||
"Detected: %s %s - %s", ble_device.name, ble_device, advertisement_data
|
|
||||||
)
|
|
||||||
|
|
||||||
device = Device(ble_device)
|
|
||||||
device_info = DeviceInfo(
|
device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, ble_device.address)},
|
identifiers={(DOMAIN, service_info.address)},
|
||||||
manufacturer="Fjäråskupan",
|
manufacturer="Fjäråskupan",
|
||||||
name="Fjäråskupan",
|
name="Fjäråskupan",
|
||||||
)
|
)
|
||||||
|
|
||||||
coordinator: Coordinator = Coordinator(hass, device, device_info)
|
coordinator: Coordinator = Coordinator(hass, device, device_info)
|
||||||
coordinator.detection_callback(ble_device, advertisement_data)
|
coordinator.detection_callback(service_info)
|
||||||
|
|
||||||
state.coordinators[ble_device.address] = coordinator
|
state.coordinators[service_info.address] = coordinator
|
||||||
async_dispatcher_send(
|
async_dispatcher_send(
|
||||||
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
|
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
|
||||||
)
|
)
|
||||||
|
|
||||||
scanner.register_detection_callback(detection_callback)
|
|
||||||
await scanner.start()
|
|
||||||
|
|
||||||
async def on_hass_stop(event: Event) -> None:
|
|
||||||
await scanner.stop()
|
|
||||||
|
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
|
async_register_callback(
|
||||||
|
hass,
|
||||||
|
detection_callback,
|
||||||
|
BluetoothCallbackMatcher(
|
||||||
|
manufacturer_id=20296,
|
||||||
|
manufacturer_data_start=[79, 68, 70, 74, 65, 82],
|
||||||
|
),
|
||||||
|
BluetoothScanningMode.ACTIVE,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
@ -177,7 +173,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
entry_state: EntryState = hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
await entry_state.scanner.stop()
|
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
|
@ -1,42 +1,25 @@
|
||||||
"""Config flow for Fjäråskupan integration."""
|
"""Config flow for Fjäråskupan integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import async_timeout
|
|
||||||
from bleak import BleakScanner
|
|
||||||
from bleak.backends.device import BLEDevice
|
|
||||||
from bleak.backends.scanner import AdvertisementData
|
|
||||||
from fjaraskupan import device_filter
|
from fjaraskupan import device_filter
|
||||||
|
|
||||||
|
from homeassistant.components.bluetooth import async_discovered_service_info
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.config_entry_flow import register_discovery_flow
|
from homeassistant.helpers.config_entry_flow import register_discovery_flow
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
CONST_WAIT_TIME = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
async def _async_has_devices(hass: HomeAssistant) -> bool:
|
||||||
"""Return if there are devices that can be discovered."""
|
"""Return if there are devices that can be discovered."""
|
||||||
|
|
||||||
event = asyncio.Event()
|
service_infos = async_discovered_service_info(hass)
|
||||||
|
|
||||||
def detection(device: BLEDevice, advertisement_data: AdvertisementData):
|
|
||||||
if device_filter(device, advertisement_data):
|
|
||||||
event.set()
|
|
||||||
|
|
||||||
async with BleakScanner(
|
|
||||||
detection_callback=detection,
|
|
||||||
filters={"DuplicateData": True},
|
|
||||||
):
|
|
||||||
try:
|
|
||||||
async with async_timeout.timeout(CONST_WAIT_TIME):
|
|
||||||
await event.wait()
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
for service_info in service_infos:
|
||||||
|
if device_filter(service_info.device, service_info.advertisement):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
register_discovery_flow(DOMAIN, "Fjäråskupan", _async_has_devices)
|
register_discovery_flow(DOMAIN, "Fjäråskupan", _async_has_devices)
|
||||||
|
|
|
@ -6,5 +6,12 @@
|
||||||
"requirements": ["fjaraskupan==1.0.2"],
|
"requirements": ["fjaraskupan==1.0.2"],
|
||||||
"codeowners": ["@elupus"],
|
"codeowners": ["@elupus"],
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["bleak", "fjaraskupan"]
|
"loggers": ["bleak", "fjaraskupan"],
|
||||||
|
"dependencies": ["bluetooth"],
|
||||||
|
"bluetooth": [
|
||||||
|
{
|
||||||
|
"manufacturer_id": 20296,
|
||||||
|
"manufacturer_data_start": [79, 68, 70, 74, 65, 82]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,18 @@ from __future__ import annotations
|
||||||
# fmt: off
|
# fmt: off
|
||||||
|
|
||||||
BLUETOOTH: list[dict[str, str | int | list[int]]] = [
|
BLUETOOTH: list[dict[str, str | int | list[int]]] = [
|
||||||
|
{
|
||||||
|
"domain": "fjaraskupan",
|
||||||
|
"manufacturer_id": 20296,
|
||||||
|
"manufacturer_data_start": [
|
||||||
|
79,
|
||||||
|
68,
|
||||||
|
70,
|
||||||
|
74,
|
||||||
|
65,
|
||||||
|
82
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"domain": "govee_ble",
|
"domain": "govee_ble",
|
||||||
"local_name": "Govee*"
|
"local_name": "Govee*"
|
||||||
|
|
|
@ -1 +1,11 @@
|
||||||
"""Tests for the Fjäråskupan integration."""
|
"""Tests for the Fjäråskupan integration."""
|
||||||
|
|
||||||
|
|
||||||
|
from bleak.backends.device import BLEDevice
|
||||||
|
from bleak.backends.scanner import AdvertisementData
|
||||||
|
|
||||||
|
from homeassistant.components.bluetooth import SOURCE_LOCAL, BluetoothServiceInfoBleak
|
||||||
|
|
||||||
|
COOKER_SERVICE_INFO = BluetoothServiceInfoBleak.from_advertisement(
|
||||||
|
BLEDevice("1.1.1.1", "COOKERHOOD_FJAR"), AdvertisementData(), source=SOURCE_LOCAL
|
||||||
|
)
|
||||||
|
|
|
@ -1,47 +1,9 @@
|
||||||
"""Standard fixtures for the Fjäråskupan integration."""
|
"""Standard fixtures for the Fjäråskupan integration."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import patch
|
import pytest
|
||||||
|
|
||||||
from bleak.backends.device import BLEDevice
|
|
||||||
from bleak.backends.scanner import AdvertisementData, BaseBleakScanner
|
|
||||||
from pytest import fixture
|
|
||||||
|
|
||||||
|
|
||||||
@fixture(name="scanner", autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fixture_scanner(hass):
|
def mock_bluetooth(enable_bluetooth):
|
||||||
"""Fixture for scanner."""
|
"""Auto mock bluetooth."""
|
||||||
|
|
||||||
devices = [BLEDevice("1.1.1.1", "COOKERHOOD_FJAR")]
|
|
||||||
|
|
||||||
class MockScanner(BaseBleakScanner):
|
|
||||||
"""Mock Scanner."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
|
||||||
"""Initialize the scanner."""
|
|
||||||
super().__init__(
|
|
||||||
detection_callback=kwargs.pop("detection_callback"), service_uuids=[]
|
|
||||||
)
|
|
||||||
|
|
||||||
async def start(self):
|
|
||||||
"""Start scanning for devices."""
|
|
||||||
for device in devices:
|
|
||||||
self._callback(device, AdvertisementData())
|
|
||||||
|
|
||||||
async def stop(self):
|
|
||||||
"""Stop scanning for devices."""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def discovered_devices(self) -> list[BLEDevice]:
|
|
||||||
"""Return discovered devices."""
|
|
||||||
return devices
|
|
||||||
|
|
||||||
def set_scanning_filter(self, **kwargs):
|
|
||||||
"""Set the scanning filter."""
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"homeassistant.components.fjaraskupan.config_flow.BleakScanner", new=MockScanner
|
|
||||||
), patch(
|
|
||||||
"homeassistant.components.fjaraskupan.config_flow.CONST_WAIT_TIME", new=0.01
|
|
||||||
):
|
|
||||||
yield devices
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from bleak.backends.device import BLEDevice
|
|
||||||
from pytest import fixture
|
from pytest import fixture
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
|
@ -11,6 +10,8 @@ from homeassistant.components.fjaraskupan.const import DOMAIN
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.data_entry_flow import FlowResultType
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
|
|
||||||
|
from . import COOKER_SERVICE_INFO
|
||||||
|
|
||||||
|
|
||||||
@fixture(name="mock_setup_entry", autouse=True)
|
@fixture(name="mock_setup_entry", autouse=True)
|
||||||
async def fixture_mock_setup_entry(hass):
|
async def fixture_mock_setup_entry(hass):
|
||||||
|
@ -24,6 +25,10 @@ async def fixture_mock_setup_entry(hass):
|
||||||
|
|
||||||
async def test_configure(hass: HomeAssistant, mock_setup_entry) -> None:
|
async def test_configure(hass: HomeAssistant, mock_setup_entry) -> None:
|
||||||
"""Test we get the form."""
|
"""Test we get the form."""
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.fjaraskupan.config_flow.async_discovered_service_info",
|
||||||
|
return_value=[COOKER_SERVICE_INFO],
|
||||||
|
):
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
@ -39,10 +44,13 @@ async def test_configure(hass: HomeAssistant, mock_setup_entry) -> None:
|
||||||
assert len(mock_setup_entry.mock_calls) == 1
|
assert len(mock_setup_entry.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_scan_no_devices(hass: HomeAssistant, scanner: list[BLEDevice]) -> None:
|
async def test_scan_no_devices(hass: HomeAssistant) -> None:
|
||||||
"""Test we get the form."""
|
"""Test we get the form."""
|
||||||
scanner.clear()
|
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.fjaraskupan.config_flow.async_discovered_service_info",
|
||||||
|
return_value=[],
|
||||||
|
):
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue