mirror of
https://github.com/home-assistant/core.git
synced 2026-08-03 20:24:55 +02:00
Add Flic Button integration (event platform) (#165260)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
8109722e75
commit
aa0870069c
@@ -215,6 +215,7 @@ homeassistant.components.filter.*
|
||||
homeassistant.components.firefly_iii.*
|
||||
homeassistant.components.fitbit.*
|
||||
homeassistant.components.flexit_bacnet.*
|
||||
homeassistant.components.flic_button.*
|
||||
homeassistant.components.flux_led.*
|
||||
homeassistant.components.folder_watcher.*
|
||||
homeassistant.components.forecast_solar.*
|
||||
|
||||
Generated
+2
@@ -572,6 +572,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/fjaraskupan/ @elupus
|
||||
/homeassistant/components/flexit_bacnet/ @lellky @piotrbulinski
|
||||
/tests/components/flexit_bacnet/ @lellky @piotrbulinski
|
||||
/homeassistant/components/flic_button/ @50ButtonsEach
|
||||
/tests/components/flic_button/ @50ButtonsEach
|
||||
/homeassistant/components/flipr/ @cnico
|
||||
/tests/components/flipr/ @cnico
|
||||
/homeassistant/components/flo/ @dmulcahey
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""The Flic Button integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from bleak import BleakError
|
||||
from pyflic_ble import DeviceType, FlicClient, FlicProtocolError, PushTwistMode
|
||||
|
||||
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, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_PAIRING_ID,
|
||||
CONF_PAIRING_KEY,
|
||||
CONF_PUSH_TWIST_MODE,
|
||||
CONF_SERIAL_NUMBER,
|
||||
CONF_SIG_BITS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.EVENT,
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlicButtonData:
|
||||
"""Runtime data for a Flic Button config entry."""
|
||||
|
||||
client: FlicClient
|
||||
serial_number: str | None
|
||||
|
||||
|
||||
type FlicButtonConfigEntry = ConfigEntry[FlicButtonData]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FlicButtonConfigEntry) -> bool:
|
||||
"""Set up Flic Button from a config entry."""
|
||||
|
||||
address: str = entry.data[CONF_ADDRESS]
|
||||
ble_device = bluetooth.async_ble_device_from_address(
|
||||
hass, address.upper(), connectable=True
|
||||
)
|
||||
pairing_key = bytes.fromhex(entry.data[CONF_PAIRING_KEY])
|
||||
serial_number = entry.data.get(CONF_SERIAL_NUMBER)
|
||||
device_type = DeviceType(entry.data[CONF_DEVICE_TYPE])
|
||||
sig_bits = entry.data.get(CONF_SIG_BITS, 0)
|
||||
push_twist_mode = PushTwistMode(
|
||||
entry.options.get(CONF_PUSH_TWIST_MODE, PushTwistMode.DEFAULT)
|
||||
)
|
||||
|
||||
client = FlicClient(
|
||||
address=address,
|
||||
ble_device=ble_device,
|
||||
pairing_id=entry.data[CONF_PAIRING_ID],
|
||||
pairing_key=pairing_key,
|
||||
serial_number=serial_number,
|
||||
device_type=device_type,
|
||||
sig_bits=sig_bits,
|
||||
push_twist_mode=push_twist_mode,
|
||||
)
|
||||
|
||||
entry.runtime_data = FlicButtonData(
|
||||
client=client,
|
||||
serial_number=serial_number,
|
||||
)
|
||||
|
||||
if ble_device:
|
||||
try:
|
||||
await client.start()
|
||||
except (TimeoutError, BleakError, FlicProtocolError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"address": address},
|
||||
) from err
|
||||
|
||||
@callback
|
||||
def _async_bluetooth_callback(
|
||||
service_info: bluetooth.BluetoothServiceInfoBleak,
|
||||
change: bluetooth.BluetoothChange,
|
||||
) -> None:
|
||||
"""Handle Bluetooth updates for connection/reconnection."""
|
||||
client.set_ble_device(service_info.device)
|
||||
|
||||
entry.async_on_unload(
|
||||
bluetooth.async_register_callback(
|
||||
hass,
|
||||
_async_bluetooth_callback,
|
||||
BluetoothCallbackMatcher({CONF_ADDRESS: address}),
|
||||
bluetooth.BluetoothScanningMode.ACTIVE,
|
||||
)
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Reload entry when options change (e.g. push_twist_mode)
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _async_update_listener(
|
||||
hass: HomeAssistant, entry: FlicButtonConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FlicButtonConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
await entry.runtime_data.client.stop()
|
||||
|
||||
return unload_ok
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Config flow for Flic Button integration."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from bleak import BleakError
|
||||
from pyflic_ble import (
|
||||
DeviceType,
|
||||
FlicAuthenticationError,
|
||||
FlicClient,
|
||||
FlicPairingError,
|
||||
FlicProtocolError,
|
||||
PushTwistMode,
|
||||
)
|
||||
from pyflic_ble.const import FLIC_SERVICE_UUID, PAIRING_TIMEOUT, TWIST_SERVICE_UUID
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothScanningMode,
|
||||
BluetoothServiceInfoBleak,
|
||||
async_discovered_service_info,
|
||||
async_process_advertisements,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_PAIRING_ID,
|
||||
CONF_PAIRING_KEY,
|
||||
CONF_PUSH_TWIST_MODE,
|
||||
CONF_SERIAL_NUMBER,
|
||||
CONF_SIG_BITS,
|
||||
DEVICE_TYPE_MODEL_NAMES,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import FlicButtonConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FlicButtonConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Flic Button."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._discovery_info: BluetoothServiceInfoBleak | None = None
|
||||
self._client: FlicClient | None = None
|
||||
self._device_type: DeviceType = DeviceType.FLIC2
|
||||
self._discovery_task: asyncio.Task[BluetoothServiceInfoBleak] | None = None
|
||||
self._pairing_started: bool = False
|
||||
|
||||
@callback
|
||||
@override
|
||||
def async_remove(self) -> None:
|
||||
"""Clean up BLE client and discovery task when the flow is removed."""
|
||||
if self._discovery_task and not self._discovery_task.done():
|
||||
self._discovery_task.cancel()
|
||||
if self._client:
|
||||
client = self._client
|
||||
self._client = None
|
||||
self.hass.async_create_background_task(
|
||||
self._async_disconnect_client(client),
|
||||
name=f"{DOMAIN}_config_flow_cleanup",
|
||||
)
|
||||
|
||||
async def _async_disconnect_client(self, client: FlicClient) -> None:
|
||||
"""Disconnect a BLE client, logging any failure instead of discarding it."""
|
||||
try:
|
||||
await client.disconnect()
|
||||
except (BleakError, FlicProtocolError, TimeoutError) as err:
|
||||
_LOGGER.debug("Error disconnecting Flic client during cleanup: %s", err)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error disconnecting Flic client")
|
||||
|
||||
@classmethod
|
||||
@callback
|
||||
@override
|
||||
def async_supports_options_flow(cls, config_entry: FlicButtonConfigEntry) -> bool:
|
||||
"""Only show options for Twist devices."""
|
||||
return config_entry.data.get(CONF_DEVICE_TYPE) == DeviceType.TWIST.value
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
@override
|
||||
def async_get_options_flow(
|
||||
config_entry: FlicButtonConfigEntry,
|
||||
) -> OptionsFlow:
|
||||
"""Get the options flow for this handler."""
|
||||
return FlicButtonOptionsFlowHandler()
|
||||
|
||||
def _is_unconfigured_flic_device(
|
||||
self, service_info: BluetoothServiceInfoBleak
|
||||
) -> bool:
|
||||
"""Check if a discovered BLE device is a Flic button not yet configured."""
|
||||
service_uuids = [str(uuid).lower() for uuid in service_info.service_uuids]
|
||||
if (
|
||||
FLIC_SERVICE_UUID.lower() not in service_uuids
|
||||
and TWIST_SERVICE_UUID.lower() not in service_uuids
|
||||
):
|
||||
return False
|
||||
return service_info.address not in self._async_current_ids(include_ignore=False)
|
||||
|
||||
@override
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle user-initiated setup."""
|
||||
# If a discovery task is running or finished, handle it first
|
||||
if self._discovery_task:
|
||||
if not self._discovery_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="user",
|
||||
progress_action="wait_for_discovery",
|
||||
progress_task=self._discovery_task,
|
||||
)
|
||||
|
||||
try:
|
||||
self._discovery_info = self._discovery_task.result()
|
||||
except TimeoutError:
|
||||
self._discovery_task = None
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
finally:
|
||||
self._discovery_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="discovery_done")
|
||||
|
||||
# Already found a device — go straight to pairing
|
||||
if self._discovery_info is not None:
|
||||
return await self._async_set_device_and_pair(
|
||||
self._discovery_info, start_pairing=True
|
||||
)
|
||||
|
||||
# No device yet — start waiting for one to appear
|
||||
self._discovery_task = self.hass.async_create_task(
|
||||
self._async_wait_for_flic_device(), eager_start=False
|
||||
)
|
||||
|
||||
return self.async_show_progress(
|
||||
step_id="user",
|
||||
progress_action="wait_for_discovery",
|
||||
progress_task=self._discovery_task,
|
||||
)
|
||||
|
||||
async def async_step_discovery_done(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle transition after discovery progress completes."""
|
||||
if self._discovery_info is None:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
return await self._async_set_device_and_pair(
|
||||
self._discovery_info, start_pairing=True
|
||||
)
|
||||
|
||||
async def _async_wait_for_flic_device(self) -> BluetoothServiceInfoBleak:
|
||||
"""Wait for a Flic device to appear via Bluetooth advertisements."""
|
||||
return await async_process_advertisements(
|
||||
self.hass,
|
||||
self._is_unconfigured_flic_device,
|
||||
{"connectable": True},
|
||||
BluetoothScanningMode.ACTIVE,
|
||||
PAIRING_TIMEOUT,
|
||||
)
|
||||
|
||||
async def _async_set_device_and_pair(
|
||||
self,
|
||||
info: BluetoothServiceInfoBleak,
|
||||
*,
|
||||
start_pairing: bool = False,
|
||||
) -> ConfigFlowResult:
|
||||
"""Set discovery info from a found device and proceed to pairing."""
|
||||
self._discovery_info = info
|
||||
service_uuids = [str(uuid).lower() for uuid in info.service_uuids]
|
||||
|
||||
if TWIST_SERVICE_UUID.lower() in service_uuids:
|
||||
self._device_type = DeviceType.TWIST
|
||||
else:
|
||||
self._device_type = DeviceType.FLIC2
|
||||
|
||||
await self.async_set_unique_id(info.address, raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.context["title_placeholders"] = {"name": info.name or info.address}
|
||||
|
||||
# When start_pairing is True, skip showing the form and pair immediately
|
||||
return await self.async_step_pair({} if start_pairing else None)
|
||||
|
||||
@override
|
||||
async def async_step_bluetooth(
|
||||
self, discovery_info: BluetoothServiceInfoBleak
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle bluetooth discovery step."""
|
||||
await self.async_set_unique_id(discovery_info.address)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self._discovery_info = discovery_info
|
||||
service_uuids = [str(uuid).lower() for uuid in discovery_info.service_uuids]
|
||||
_LOGGER.debug(
|
||||
"Discovered Bluetooth device during config flow: %s, service_uuids=%s, connectable: %s",
|
||||
discovery_info.address,
|
||||
service_uuids,
|
||||
discovery_info.connectable,
|
||||
)
|
||||
if TWIST_SERVICE_UUID.lower() in service_uuids:
|
||||
self._device_type = DeviceType.TWIST
|
||||
else:
|
||||
self._device_type = DeviceType.FLIC2
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
"name": discovery_info.name or discovery_info.address
|
||||
}
|
||||
|
||||
return await self.async_step_bluetooth_confirm()
|
||||
|
||||
async def async_step_bluetooth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle bluetooth confirmation step."""
|
||||
if self._discovery_info is None:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if user_input is None:
|
||||
self._set_confirm_only()
|
||||
name = self._discovery_info.name or self._discovery_info.address
|
||||
return self.async_show_form(
|
||||
step_id="bluetooth_confirm",
|
||||
description_placeholders={"name": name},
|
||||
)
|
||||
|
||||
# Check if the device is still advertising
|
||||
|
||||
device_still_visible = any(
|
||||
info.address == self._discovery_info.address and info.connectable
|
||||
for info in async_discovered_service_info(self.hass)
|
||||
)
|
||||
if not device_still_visible:
|
||||
# Device no longer advertising — fall back to scanner flow
|
||||
self._discovery_info = None
|
||||
return await self.async_step_user()
|
||||
# Device still visible — pair immediately
|
||||
return await self.async_step_pair()
|
||||
|
||||
async def async_step_pair(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle pairing step."""
|
||||
if self._discovery_info is None:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
# Guard against duplicate form submissions — the flag is set
|
||||
# synchronously before the first await to prevent races.
|
||||
if self._pairing_started:
|
||||
_LOGGER.debug("Ignoring duplicate pair submission")
|
||||
return self.async_show_form(
|
||||
step_id="pair",
|
||||
description_placeholders={
|
||||
"name": self._discovery_info.name
|
||||
or self._discovery_info.address
|
||||
},
|
||||
)
|
||||
self._pairing_started = True
|
||||
|
||||
_LOGGER.debug(
|
||||
"Pairing form submitted for button %s (device_type=%s)",
|
||||
self._discovery_info.address,
|
||||
self._device_type.value,
|
||||
)
|
||||
# Create client with detected device type
|
||||
if not self._client:
|
||||
_LOGGER.debug(
|
||||
"Creating FlicClient for device %s (type=%s)",
|
||||
self._discovery_info.device,
|
||||
self._device_type.value,
|
||||
)
|
||||
self._client = FlicClient(
|
||||
address=self._discovery_info.device.address,
|
||||
ble_device=self._discovery_info.device,
|
||||
device_type=self._device_type,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._client.connect()
|
||||
(
|
||||
pairing_id,
|
||||
pairing_key,
|
||||
serial_number,
|
||||
_,
|
||||
sig_bits,
|
||||
_,
|
||||
_,
|
||||
) = await asyncio.wait_for(
|
||||
self._client.full_verify_pairing(),
|
||||
timeout=PAIRING_TIMEOUT,
|
||||
)
|
||||
except TimeoutError, BleakError, FlicProtocolError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except FlicPairingError:
|
||||
errors["base"] = "pairing_failed"
|
||||
except FlicAuthenticationError:
|
||||
errors["base"] = "invalid_signature"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during pairing")
|
||||
errors["base"] = "unknown"
|
||||
finally:
|
||||
if self._client:
|
||||
await self._async_disconnect_client(self._client)
|
||||
self._client = None
|
||||
|
||||
if not errors:
|
||||
final_device_type = (
|
||||
DeviceType.TWIST
|
||||
if self._device_type == DeviceType.TWIST
|
||||
else DeviceType.from_serial_number(serial_number)
|
||||
)
|
||||
model_name = DEVICE_TYPE_MODEL_NAMES[final_device_type]
|
||||
|
||||
return self.async_create_entry(
|
||||
title=f"{model_name} ({serial_number})",
|
||||
data={
|
||||
CONF_ADDRESS: self._discovery_info.address,
|
||||
CONF_PAIRING_ID: pairing_id,
|
||||
CONF_PAIRING_KEY: pairing_key.hex(),
|
||||
CONF_SERIAL_NUMBER: serial_number,
|
||||
CONF_DEVICE_TYPE: final_device_type.value,
|
||||
CONF_SIG_BITS: sig_bits,
|
||||
},
|
||||
)
|
||||
|
||||
# Allow the user to retry after an error
|
||||
self._pairing_started = False
|
||||
|
||||
# Show pairing form
|
||||
return self.async_show_form(
|
||||
step_id="pair",
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"name": self._discovery_info.name or self._discovery_info.address
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FlicButtonOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle options flow for Flic Button integration."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(data=user_input)
|
||||
|
||||
current_mode = self.config_entry.options.get(
|
||||
CONF_PUSH_TWIST_MODE, PushTwistMode.DEFAULT
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_PUSH_TWIST_MODE, default=current_mode
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[
|
||||
PushTwistMode.DEFAULT.value,
|
||||
PushTwistMode.CONTINUOUS.value,
|
||||
PushTwistMode.SELECTOR.value,
|
||||
],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=CONF_PUSH_TWIST_MODE,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Constants for the Flic Button integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from pyflic_ble import DeviceType
|
||||
|
||||
DOMAIN: Final = "flic_button"
|
||||
|
||||
DEVICE_TYPE_MODEL_NAMES: Final = {
|
||||
DeviceType.FLIC2: "Flic 2",
|
||||
DeviceType.DUO: "Flic Duo",
|
||||
DeviceType.TWIST: "Flic Twist",
|
||||
}
|
||||
|
||||
# Config entry data keys
|
||||
CONF_PAIRING_ID: Final = "pairing_id"
|
||||
CONF_PAIRING_KEY: Final = "pairing_key"
|
||||
CONF_SERIAL_NUMBER: Final = "serial_number"
|
||||
CONF_DEVICE_TYPE: Final = "device_type"
|
||||
CONF_SIG_BITS: Final = (
|
||||
"sig_bits" # Ed25519 signature variant (0-3) for Twist quick verify
|
||||
)
|
||||
|
||||
# Event classes
|
||||
EVENT_CLASS_BUTTON: Final = "button"
|
||||
|
||||
# Options constants
|
||||
CONF_PUSH_TWIST_MODE: Final = "push_twist_mode"
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Base entity for Flic Button integration."""
|
||||
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from pyflic_ble import FlicState
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import FlicButtonData
|
||||
from .const import DEVICE_TYPE_MODEL_NAMES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FlicButtonEntity(Entity):
|
||||
"""Base entity for Flic Button integration."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
_unavailable_logged: bool = False
|
||||
|
||||
def __init__(self, data: FlicButtonData) -> None:
|
||||
"""Initialize the Flic button entity."""
|
||||
client = data.client
|
||||
serial = data.serial_number
|
||||
model_name = DEVICE_TYPE_MODEL_NAMES[client.device_type]
|
||||
|
||||
fw = client.state.firmware_version
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, client.address)},
|
||||
connections={(CONNECTION_BLUETOOTH, client.address)},
|
||||
manufacturer="Shortcut Labs",
|
||||
model=model_name,
|
||||
serial_number=serial,
|
||||
sw_version=str(fw) if fw is not None else None,
|
||||
)
|
||||
self._client = client
|
||||
|
||||
@property
|
||||
@override
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return self._client.state.connected
|
||||
|
||||
@override
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register state callback when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
self.async_on_remove(
|
||||
self._client.register_state_callback(self._handle_state_update)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_state_update(self, state: FlicState) -> None:
|
||||
"""Handle state updates from the client."""
|
||||
is_available = state.connected
|
||||
|
||||
if not is_available and not self._unavailable_logged:
|
||||
_LOGGER.info("%s is unavailable", self._client.address)
|
||||
self._unavailable_logged = True
|
||||
elif is_available and self._unavailable_logged:
|
||||
_LOGGER.info("%s is back online", self._client.address)
|
||||
self._unavailable_logged = False
|
||||
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Event platform for Flic Button integration."""
|
||||
|
||||
from typing import Any, override
|
||||
|
||||
from pyflic_ble import PushTwistMode
|
||||
from pyflic_ble.const import (
|
||||
EVENT_TYPE_CLICK,
|
||||
EVENT_TYPE_DOUBLE_CLICK,
|
||||
EVENT_TYPE_DOWN,
|
||||
EVENT_TYPE_HOLD,
|
||||
EVENT_TYPE_PUSH_TWIST_DECREMENT,
|
||||
EVENT_TYPE_PUSH_TWIST_INCREMENT,
|
||||
EVENT_TYPE_ROTATE_CLOCKWISE,
|
||||
EVENT_TYPE_ROTATE_COUNTER_CLOCKWISE,
|
||||
EVENT_TYPE_SELECTOR_CHANGED,
|
||||
EVENT_TYPE_SWIPE_DOWN,
|
||||
EVENT_TYPE_SWIPE_LEFT,
|
||||
EVENT_TYPE_SWIPE_RIGHT,
|
||||
EVENT_TYPE_SWIPE_UP,
|
||||
EVENT_TYPE_TWIST_DECREMENT,
|
||||
EVENT_TYPE_TWIST_INCREMENT,
|
||||
EVENT_TYPE_UP,
|
||||
)
|
||||
|
||||
from homeassistant.components.event import (
|
||||
EventDeviceClass,
|
||||
EventEntity,
|
||||
EventEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FlicButtonConfigEntry, FlicButtonData
|
||||
from .const import CONF_PUSH_TWIST_MODE, EVENT_CLASS_BUTTON
|
||||
from .entity import FlicButtonEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
EVENT_DESCRIPTION = EventEntityDescription(
|
||||
key=EVENT_CLASS_BUTTON,
|
||||
translation_key=EVENT_CLASS_BUTTON,
|
||||
event_types=[
|
||||
EVENT_TYPE_UP,
|
||||
EVENT_TYPE_DOWN,
|
||||
EVENT_TYPE_CLICK,
|
||||
EVENT_TYPE_DOUBLE_CLICK,
|
||||
EVENT_TYPE_HOLD,
|
||||
],
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
)
|
||||
|
||||
# Duo button-specific descriptions with translation keys
|
||||
# Duo buttons support all standard events plus swipe gestures and rotation
|
||||
DUO_BUTTON_EVENT_TYPES: list[str] = [
|
||||
EVENT_TYPE_UP,
|
||||
EVENT_TYPE_DOWN,
|
||||
EVENT_TYPE_CLICK,
|
||||
EVENT_TYPE_DOUBLE_CLICK,
|
||||
EVENT_TYPE_HOLD,
|
||||
EVENT_TYPE_SWIPE_LEFT,
|
||||
EVENT_TYPE_SWIPE_RIGHT,
|
||||
EVENT_TYPE_SWIPE_UP,
|
||||
EVENT_TYPE_SWIPE_DOWN,
|
||||
EVENT_TYPE_ROTATE_CLOCKWISE,
|
||||
EVENT_TYPE_ROTATE_COUNTER_CLOCKWISE,
|
||||
]
|
||||
|
||||
DUO_SMALL_BUTTON_DESCRIPTION = EventEntityDescription(
|
||||
key=f"{EVENT_CLASS_BUTTON}_small",
|
||||
translation_key="button_small",
|
||||
event_types=DUO_BUTTON_EVENT_TYPES,
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
)
|
||||
|
||||
DUO_BIG_BUTTON_DESCRIPTION = EventEntityDescription(
|
||||
key=f"{EVENT_CLASS_BUTTON}_big",
|
||||
translation_key="button_big",
|
||||
event_types=DUO_BUTTON_EVENT_TYPES,
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
)
|
||||
|
||||
# Flic Twist description for SELECTOR mode - rotation and selector events
|
||||
TWIST_SELECTOR_BUTTON_DESCRIPTION = EventEntityDescription(
|
||||
key=f"{EVENT_CLASS_BUTTON}_twist",
|
||||
translation_key="button_twist",
|
||||
event_types=[
|
||||
EVENT_TYPE_UP,
|
||||
EVENT_TYPE_DOWN,
|
||||
EVENT_TYPE_CLICK,
|
||||
EVENT_TYPE_DOUBLE_CLICK,
|
||||
EVENT_TYPE_HOLD,
|
||||
EVENT_TYPE_ROTATE_CLOCKWISE,
|
||||
EVENT_TYPE_ROTATE_COUNTER_CLOCKWISE,
|
||||
EVENT_TYPE_SELECTOR_CHANGED,
|
||||
],
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
)
|
||||
|
||||
# Flic Twist description for DEFAULT mode - increment/decrement events
|
||||
TWIST_DEFAULT_BUTTON_DESCRIPTION = EventEntityDescription(
|
||||
key=f"{EVENT_CLASS_BUTTON}_twist",
|
||||
translation_key="button_twist_default",
|
||||
event_types=[
|
||||
EVENT_TYPE_UP,
|
||||
EVENT_TYPE_DOWN,
|
||||
EVENT_TYPE_CLICK,
|
||||
EVENT_TYPE_DOUBLE_CLICK,
|
||||
EVENT_TYPE_HOLD,
|
||||
EVENT_TYPE_TWIST_INCREMENT,
|
||||
EVENT_TYPE_TWIST_DECREMENT,
|
||||
EVENT_TYPE_PUSH_TWIST_INCREMENT,
|
||||
EVENT_TYPE_PUSH_TWIST_DECREMENT,
|
||||
],
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FlicButtonConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Flic Button event entity."""
|
||||
data = entry.runtime_data
|
||||
capabilities = data.client.capabilities
|
||||
entities: list[FlicButtonEventEntity] = []
|
||||
|
||||
push_twist_mode = PushTwistMode(
|
||||
entry.options.get(CONF_PUSH_TWIST_MODE, PushTwistMode.DEFAULT)
|
||||
)
|
||||
|
||||
if capabilities.has_selector and push_twist_mode == PushTwistMode.SELECTOR:
|
||||
entities.append(
|
||||
FlicButtonEventEntity(
|
||||
data, TWIST_SELECTOR_BUTTON_DESCRIPTION, is_twist=True
|
||||
)
|
||||
)
|
||||
elif capabilities.has_selector:
|
||||
entities.append(
|
||||
FlicButtonEventEntity(data, TWIST_DEFAULT_BUTTON_DESCRIPTION, is_twist=True)
|
||||
)
|
||||
elif capabilities.button_count == 1:
|
||||
entities.append(FlicButtonEventEntity(data, EVENT_DESCRIPTION))
|
||||
else:
|
||||
entities.append(
|
||||
FlicButtonEventEntity(data, DUO_BIG_BUTTON_DESCRIPTION, button_index=0)
|
||||
)
|
||||
entities.append(
|
||||
FlicButtonEventEntity(data, DUO_SMALL_BUTTON_DESCRIPTION, button_index=1)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class FlicButtonEventEntity(FlicButtonEntity, EventEntity):
|
||||
"""Representation of a Flic button event entity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: FlicButtonData,
|
||||
description: EventEntityDescription,
|
||||
button_index: int | None = None,
|
||||
is_twist: bool = False,
|
||||
) -> None:
|
||||
"""Initialize the event entity."""
|
||||
super().__init__(data)
|
||||
self.entity_description = description
|
||||
self._button_index = button_index
|
||||
self._is_twist = is_twist
|
||||
self._attr_unique_id = f"{self._client.address}-{description.key}"
|
||||
|
||||
@override
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register event callbacks when entity is added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# Subscribe to button events via direct callback
|
||||
self.async_on_remove(
|
||||
self._client.register_button_event_callback(
|
||||
self._async_handle_event,
|
||||
)
|
||||
)
|
||||
|
||||
# Subscribe to rotate events if device supports rotation
|
||||
if self._client.capabilities.has_rotation:
|
||||
self.async_on_remove(
|
||||
self._client.register_rotate_event_callback(
|
||||
self._async_handle_rotate_event,
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_handle_event(self, event_type: str, event_data: dict[str, Any]) -> None:
|
||||
"""Handle button event from client."""
|
||||
# Only trigger if the event type is in this entity's allowed event types
|
||||
if (
|
||||
self.entity_description.event_types is not None
|
||||
and event_type not in self.entity_description.event_types
|
||||
):
|
||||
return
|
||||
|
||||
# For Duo buttons, filter events by button_index
|
||||
if self._button_index is not None:
|
||||
event_button_index = event_data.get("button_index")
|
||||
if event_button_index != self._button_index:
|
||||
# This event is for a different button
|
||||
return
|
||||
|
||||
self._trigger_event(event_type, event_data)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_handle_rotate_event(
|
||||
self, event_type: str, event_data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle rotate event from client."""
|
||||
# Only trigger if the event type is in this entity's allowed event types
|
||||
if (
|
||||
self.entity_description.event_types is not None
|
||||
and event_type not in self.entity_description.event_types
|
||||
):
|
||||
return
|
||||
|
||||
# For Twist, accept all matching rotate events (no button_index filtering)
|
||||
if self._is_twist:
|
||||
self._trigger_event(event_type, event_data)
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
# Filter rotate events by button_index (pressed button during rotation)
|
||||
if self._button_index is not None:
|
||||
event_button_index = event_data.get("button_index")
|
||||
if event_button_index != self._button_index:
|
||||
# This rotate event is for a different button
|
||||
return
|
||||
|
||||
self._trigger_event(event_type, event_data)
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"entity": {
|
||||
"event": {
|
||||
"button": { "default": "mdi:gesture-tap-button" },
|
||||
"button_big": { "default": "mdi:gesture-tap-button" },
|
||||
"button_small": { "default": "mdi:gesture-tap-button" },
|
||||
"button_twist": { "default": "mdi:gesture-tap-button" },
|
||||
"button_twist_default": { "default": "mdi:gesture-tap-button" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"domain": "flic_button",
|
||||
"name": "Flic",
|
||||
"bluetooth": [
|
||||
{
|
||||
"connectable": true,
|
||||
"service_uuid": "00420000-8f59-4420-870d-84f3b617e493"
|
||||
},
|
||||
{
|
||||
"connectable": true,
|
||||
"service_uuid": "00c90000-2cbd-4f2a-a725-5ccd960ffb7d"
|
||||
}
|
||||
],
|
||||
"codeowners": ["@50ButtonsEach"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/flic_button",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyflic-ble==0.2.2"]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: Integration is push-based; button events are delivered via BLE callbacks.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
docs-conditions:
|
||||
status: exempt
|
||||
comment: This integration does not have any conditions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
docs-triggers:
|
||||
status: exempt
|
||||
comment: This integration does not have any triggers.
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: BLE devices may not be in range at startup; the integration registers a Bluetooth callback for deferred connection.
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: done
|
||||
docs-installation-parameters:
|
||||
status: exempt
|
||||
comment: Pairing uses discovery and does not require user-supplied setup parameters.
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Bluetooth pairing cannot be reauthenticated; requires physical re-pairing via discovery flow.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Single device per config entry, no network address to update.
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: Single fixed BLE device per config entry.
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: No user-configurable data fields to reconfigure; push-twist mode is handled via options flow.
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: No actionable repair scenarios; BLE connection issues are transient and handled by reconnection logic.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: Single device per config entry, cannot become stale.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: Event-only platform makes no external HTTP calls.
|
||||
strict-typing: done
|
||||
@@ -0,0 +1,150 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This Flic device is already configured.",
|
||||
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Failed to connect to the device. Make sure it is in range and try again.",
|
||||
"invalid_signature": "Invalid device signature during pairing.",
|
||||
"pairing_failed": "Pairing failed. Please ensure the device is in pairing mode (LED flashing) and try again.",
|
||||
"unknown": "An unexpected error occurred during pairing."
|
||||
},
|
||||
"progress": {
|
||||
"wait_for_discovery": "Push and hold your Flic until it connects. This should take no longer than 10 seconds\u2026"
|
||||
},
|
||||
"step": {
|
||||
"bluetooth_confirm": {
|
||||
"description": "Set up {name}?",
|
||||
"title": "Discovered Flic device"
|
||||
},
|
||||
"pair": {
|
||||
"description": "Push and hold your Flic until it connects. This should take no longer than 10 seconds. Then submit this form to complete pairing with {name}.",
|
||||
"title": "Pair with Flic device"
|
||||
},
|
||||
"user": {
|
||||
"title": "[%key:component::flic_button::config::step::pair::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"button": {
|
||||
"name": "Button",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"click": "Single push",
|
||||
"double_click": "Double push",
|
||||
"down": "Down",
|
||||
"hold": "Hold",
|
||||
"up": "Up"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"button_big": {
|
||||
"name": "Big button",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"click": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::click%]",
|
||||
"double_click": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::double_click%]",
|
||||
"down": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::down%]",
|
||||
"hold": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::hold%]",
|
||||
"rotate_clockwise": "Rotated clockwise",
|
||||
"rotate_counter_clockwise": "Rotated counter-clockwise",
|
||||
"swipe_down": "Swipe down",
|
||||
"swipe_left": "Swipe left",
|
||||
"swipe_right": "Swipe right",
|
||||
"swipe_up": "Swipe up",
|
||||
"up": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::up%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"button_small": {
|
||||
"name": "Small button",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"click": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::click%]",
|
||||
"double_click": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::double_click%]",
|
||||
"down": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::down%]",
|
||||
"hold": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::hold%]",
|
||||
"rotate_clockwise": "[%key:component::flic_button::entity::event::button_big::state_attributes::event_type::state::rotate_clockwise%]",
|
||||
"rotate_counter_clockwise": "[%key:component::flic_button::entity::event::button_big::state_attributes::event_type::state::rotate_counter_clockwise%]",
|
||||
"swipe_down": "[%key:component::flic_button::entity::event::button_big::state_attributes::event_type::state::swipe_down%]",
|
||||
"swipe_left": "[%key:component::flic_button::entity::event::button_big::state_attributes::event_type::state::swipe_left%]",
|
||||
"swipe_right": "[%key:component::flic_button::entity::event::button_big::state_attributes::event_type::state::swipe_right%]",
|
||||
"swipe_up": "[%key:component::flic_button::entity::event::button_big::state_attributes::event_type::state::swipe_up%]",
|
||||
"up": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::up%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"button_twist": {
|
||||
"name": "[%key:component::flic_button::entity::event::button::name%]",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"click": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::click%]",
|
||||
"double_click": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::double_click%]",
|
||||
"down": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::down%]",
|
||||
"hold": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::hold%]",
|
||||
"rotate_clockwise": "[%key:component::flic_button::entity::event::button_big::state_attributes::event_type::state::rotate_clockwise%]",
|
||||
"rotate_counter_clockwise": "[%key:component::flic_button::entity::event::button_big::state_attributes::event_type::state::rotate_counter_clockwise%]",
|
||||
"selector_changed": "Selector changed",
|
||||
"up": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::up%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"button_twist_default": {
|
||||
"name": "[%key:component::flic_button::entity::event::button::name%]",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"click": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::click%]",
|
||||
"double_click": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::double_click%]",
|
||||
"down": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::down%]",
|
||||
"hold": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::hold%]",
|
||||
"push_twist_decrement": "Push-twist decremented",
|
||||
"push_twist_increment": "Push-twist incremented",
|
||||
"twist_decrement": "Twist decremented",
|
||||
"twist_increment": "Twist incremented",
|
||||
"up": "[%key:component::flic_button::entity::event::button::state_attributes::event_type::state::up%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Failed to connect to {address}. Make sure it is in range and try again."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"push_twist_mode": "Push twist mode"
|
||||
},
|
||||
"data_description": {
|
||||
"push_twist_mode": "Controls the behavior when holding the button and twisting. Default mode fires increment and decrement events that clamp at boundaries. Continuous mode is like default but wraps around instead of clamping. Selector mode fires rotation and selector changed events."
|
||||
},
|
||||
"title": "Flic Twist options"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"push_twist_mode": {
|
||||
"options": {
|
||||
"continuous": "Continuous",
|
||||
"default": "Default",
|
||||
"selector": "Selector mode"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10
@@ -163,6 +163,16 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
|
||||
"domain": "fjaraskupan",
|
||||
"service_uuid": "77a2bd49-1e5a-4961-bba1-21f34fa4bc7b",
|
||||
},
|
||||
{
|
||||
"connectable": True,
|
||||
"domain": "flic_button",
|
||||
"service_uuid": "00420000-8f59-4420-870d-84f3b617e493",
|
||||
},
|
||||
{
|
||||
"connectable": True,
|
||||
"domain": "flic_button",
|
||||
"service_uuid": "00c90000-2cbd-4f2a-a725-5ccd960ffb7d",
|
||||
},
|
||||
{
|
||||
"connectable": True,
|
||||
"domain": "gardena_bluetooth",
|
||||
|
||||
Generated
+1
@@ -239,6 +239,7 @@ FLOWS = {
|
||||
"fivem",
|
||||
"fjaraskupan",
|
||||
"flexit_bacnet",
|
||||
"flic_button",
|
||||
"flipr",
|
||||
"flo",
|
||||
"flume",
|
||||
|
||||
@@ -2147,6 +2147,12 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"flic_button": {
|
||||
"name": "Flic",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"flipr": {
|
||||
"name": "Flipr",
|
||||
"integration_type": "hub",
|
||||
|
||||
@@ -1907,6 +1907,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.flic_button.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.flux_led.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
Generated
+3
@@ -2190,6 +2190,9 @@ pyfirefly==0.1.12
|
||||
# homeassistant.components.fireservicerota
|
||||
pyfireservicerota==0.0.49
|
||||
|
||||
# homeassistant.components.flic_button
|
||||
pyflic-ble==0.2.2
|
||||
|
||||
# homeassistant.components.flic
|
||||
pyflic==2.0.4
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for the Flic Button integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyflic_ble import DeviceType
|
||||
from pyflic_ble.const import FLIC_SERVICE_UUID, TWIST_SERVICE_UUID
|
||||
|
||||
from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.bluetooth import generate_advertisement_data, generate_ble_device
|
||||
|
||||
# Test Bluetooth addresses (one per supported device type)
|
||||
FLIC2_ADDRESS = "AA:BB:CC:DD:EE:F0"
|
||||
DUO_ADDRESS = "AA:BB:CC:DD:EE:F1"
|
||||
TWIST_ADDRESS = "AA:BB:CC:DD:EE:F2"
|
||||
|
||||
# Test serial numbers (prefix determines device type)
|
||||
FLIC2_SERIAL = "B12345"
|
||||
DUO_SERIAL = "D12345"
|
||||
TWIST_SERIAL = "T12345"
|
||||
|
||||
ADDRESS_FOR_DEVICE_TYPE: dict[DeviceType, str] = {
|
||||
DeviceType.FLIC2: FLIC2_ADDRESS,
|
||||
DeviceType.DUO: DUO_ADDRESS,
|
||||
DeviceType.TWIST: TWIST_ADDRESS,
|
||||
}
|
||||
|
||||
SERIAL_FOR_DEVICE_TYPE: dict[DeviceType, str] = {
|
||||
DeviceType.FLIC2: FLIC2_SERIAL,
|
||||
DeviceType.DUO: DUO_SERIAL,
|
||||
DeviceType.TWIST: TWIST_SERIAL,
|
||||
}
|
||||
|
||||
MODEL_NAME_FOR_DEVICE_TYPE: dict[DeviceType, str] = {
|
||||
DeviceType.FLIC2: "Flic 2",
|
||||
DeviceType.DUO: "Flic Duo",
|
||||
DeviceType.TWIST: "Flic Twist",
|
||||
}
|
||||
|
||||
# Test pairing credentials
|
||||
TEST_PAIRING_ID = 12345
|
||||
TEST_PAIRING_KEY = bytes(16) # 16 zero bytes
|
||||
TEST_SIG_BITS = 0
|
||||
TEST_BATTERY_LEVEL = 800 # Raw battery level (0-1024)
|
||||
TEST_BUTTON_UUID = bytes(16) # 16 zero bytes for testing
|
||||
|
||||
|
||||
def _service_info(
|
||||
name: str, address: str, service_uuid: str
|
||||
) -> BluetoothServiceInfoBleak:
|
||||
"""Build a BluetoothServiceInfoBleak for a Flic device."""
|
||||
return BluetoothServiceInfoBleak(
|
||||
name=name,
|
||||
address=address,
|
||||
device=generate_ble_device(address=address, name=name),
|
||||
rssi=-60,
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[service_uuid],
|
||||
source="local",
|
||||
advertisement=generate_advertisement_data(
|
||||
local_name=name,
|
||||
service_uuids=[service_uuid],
|
||||
),
|
||||
connectable=True,
|
||||
time=0,
|
||||
tx_power=-127,
|
||||
)
|
||||
|
||||
|
||||
def create_flic2_service_info() -> BluetoothServiceInfoBleak:
|
||||
"""Create a Flic 2 BluetoothServiceInfoBleak for testing."""
|
||||
return _service_info("Flic 2", FLIC2_ADDRESS, FLIC_SERVICE_UUID)
|
||||
|
||||
|
||||
def create_duo_service_info() -> BluetoothServiceInfoBleak:
|
||||
"""Create a Flic Duo BluetoothServiceInfoBleak for testing."""
|
||||
return _service_info("Flic Duo", DUO_ADDRESS, FLIC_SERVICE_UUID)
|
||||
|
||||
|
||||
def create_twist_service_info() -> BluetoothServiceInfoBleak:
|
||||
"""Create a Flic Twist BluetoothServiceInfoBleak for testing."""
|
||||
return _service_info("Flic Twist", TWIST_ADDRESS, TWIST_SERVICE_UUID)
|
||||
|
||||
|
||||
def service_info_for_device_type(device_type: DeviceType) -> BluetoothServiceInfoBleak:
|
||||
"""Return service info matching a device type."""
|
||||
if device_type is DeviceType.TWIST:
|
||||
return create_twist_service_info()
|
||||
if device_type is DeviceType.DUO:
|
||||
return create_duo_service_info()
|
||||
return create_flic2_service_info()
|
||||
|
||||
|
||||
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Set up the Flic Button integration for tests."""
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Flic Button test fixtures."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from pyflic_ble import DeviceType
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.flic_button.const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_PAIRING_ID,
|
||||
CONF_PAIRING_KEY,
|
||||
CONF_SERIAL_NUMBER,
|
||||
CONF_SIG_BITS,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
|
||||
from . import (
|
||||
ADDRESS_FOR_DEVICE_TYPE,
|
||||
MODEL_NAME_FOR_DEVICE_TYPE,
|
||||
SERIAL_FOR_DEVICE_TYPE,
|
||||
TEST_BATTERY_LEVEL,
|
||||
TEST_PAIRING_ID,
|
||||
TEST_PAIRING_KEY,
|
||||
TEST_SIG_BITS,
|
||||
service_info_for_device_type,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_bluetooth(enable_bluetooth: None) -> None:
|
||||
"""Auto mock bluetooth."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_type() -> DeviceType:
|
||||
"""Return the device type under test (override via parametrization)."""
|
||||
return DeviceType.FLIC2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry(device_type: DeviceType) -> MockConfigEntry:
|
||||
"""Return a mock Flic Button config entry."""
|
||||
address = ADDRESS_FOR_DEVICE_TYPE[device_type]
|
||||
serial = SERIAL_FOR_DEVICE_TYPE[device_type]
|
||||
model = MODEL_NAME_FOR_DEVICE_TYPE[device_type]
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title=f"{model} ({serial})",
|
||||
unique_id=address,
|
||||
data={
|
||||
CONF_ADDRESS: address,
|
||||
CONF_PAIRING_ID: TEST_PAIRING_ID,
|
||||
CONF_PAIRING_KEY: TEST_PAIRING_KEY.hex(),
|
||||
CONF_SERIAL_NUMBER: serial,
|
||||
CONF_DEVICE_TYPE: device_type.value,
|
||||
CONF_SIG_BITS: TEST_SIG_BITS,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.flic_button.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup:
|
||||
yield mock_setup
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_flic_client(device_type: DeviceType) -> Generator[MagicMock]:
|
||||
"""Mock FlicClient for the runtime integration and the config flow."""
|
||||
address = ADDRESS_FOR_DEVICE_TYPE[device_type]
|
||||
serial = SERIAL_FOR_DEVICE_TYPE[device_type]
|
||||
service_info = service_info_for_device_type(device_type)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.flic_button.FlicClient", autospec=True
|
||||
) as mock_client_class,
|
||||
patch(
|
||||
"homeassistant.components.flic_button.config_flow.FlicClient",
|
||||
new=mock_client_class,
|
||||
),
|
||||
):
|
||||
mock_client = mock_client_class.return_value
|
||||
mock_client.address = address
|
||||
mock_client.is_connected = True
|
||||
mock_client.is_duo = device_type is DeviceType.DUO
|
||||
mock_client.is_twist = device_type is DeviceType.TWIST
|
||||
mock_client.ble_device = service_info.device
|
||||
mock_client.device_type = device_type
|
||||
|
||||
mock_capabilities = MagicMock()
|
||||
mock_capabilities.button_count = 2 if device_type is DeviceType.DUO else 1
|
||||
mock_capabilities.has_rotation = device_type in (
|
||||
DeviceType.DUO,
|
||||
DeviceType.TWIST,
|
||||
)
|
||||
mock_capabilities.has_selector = device_type is DeviceType.TWIST
|
||||
mock_capabilities.has_frame_header = device_type is not DeviceType.TWIST
|
||||
mock_client.capabilities = mock_capabilities
|
||||
mock_client.handler = MagicMock(capabilities=mock_capabilities)
|
||||
|
||||
mock_state = MagicMock()
|
||||
mock_state.connected = True
|
||||
mock_state.battery_voltage = TEST_BATTERY_LEVEL * 3.6 / 1024.0
|
||||
mock_state.firmware_version = 10
|
||||
mock_state.device_name = None
|
||||
mock_client.state = mock_state
|
||||
|
||||
mock_client.full_verify_pairing.return_value = (
|
||||
TEST_PAIRING_ID,
|
||||
TEST_PAIRING_KEY,
|
||||
serial,
|
||||
TEST_BATTERY_LEVEL,
|
||||
TEST_SIG_BITS,
|
||||
None,
|
||||
10,
|
||||
)
|
||||
mock_client.get_firmware_version.return_value = 10
|
||||
mock_client.get_battery_level.return_value = TEST_BATTERY_LEVEL
|
||||
mock_client.get_battery_voltage.return_value = TEST_BATTERY_LEVEL * 3.6 / 1024.0
|
||||
mock_client.get_name.return_value = ("", 0)
|
||||
mock_client.set_name.return_value = ("", 0)
|
||||
|
||||
mock_client.register_button_event_callback.return_value = lambda: None
|
||||
mock_client.register_rotate_event_callback.return_value = lambda: None
|
||||
mock_client.register_state_callback.return_value = lambda: None
|
||||
|
||||
yield mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ble_device_from_address(
|
||||
device_type: DeviceType,
|
||||
) -> Generator[MagicMock]:
|
||||
"""Mock async_ble_device_from_address to return a matching BLE device."""
|
||||
service_info = service_info_for_device_type(device_type)
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_ble_device_from_address",
|
||||
return_value=service_info.device,
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_no_ble_device_from_address() -> Generator[MagicMock]:
|
||||
"""Mock async_ble_device_from_address to return None."""
|
||||
with patch(
|
||||
"homeassistant.components.bluetooth.async_ble_device_from_address",
|
||||
return_value=None,
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bluetooth_register_callback() -> Generator[MagicMock]:
|
||||
"""Mock bluetooth.async_register_callback to capture and return a no-op."""
|
||||
with patch(
|
||||
"homeassistant.components.flic_button.bluetooth.async_register_callback",
|
||||
return_value=lambda: None,
|
||||
) as mock:
|
||||
yield mock
|
||||
@@ -0,0 +1,374 @@
|
||||
# serializer version: 1
|
||||
# name: test_event_entity_setup[duo][event.flic_duo_d12345_big_button-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
'swipe_left',
|
||||
'swipe_right',
|
||||
'swipe_up',
|
||||
'swipe_down',
|
||||
'rotate_clockwise',
|
||||
'rotate_counter_clockwise',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.flic_duo_d12345_big_button',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Big button',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Big button',
|
||||
'platform': 'flic_button',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'button_big',
|
||||
'unique_id': 'AA:BB:CC:DD:EE:F1-button_big',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entity_setup[duo][event.flic_duo_d12345_big_button-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'button',
|
||||
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
'swipe_left',
|
||||
'swipe_right',
|
||||
'swipe_up',
|
||||
'swipe_down',
|
||||
'rotate_clockwise',
|
||||
'rotate_counter_clockwise',
|
||||
]),
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Flic Duo (D12345) Big button',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.flic_duo_d12345_big_button',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entity_setup[duo][event.flic_duo_d12345_small_button-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
'swipe_left',
|
||||
'swipe_right',
|
||||
'swipe_up',
|
||||
'swipe_down',
|
||||
'rotate_clockwise',
|
||||
'rotate_counter_clockwise',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.flic_duo_d12345_small_button',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Small button',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Small button',
|
||||
'platform': 'flic_button',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'button_small',
|
||||
'unique_id': 'AA:BB:CC:DD:EE:F1-button_small',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entity_setup[duo][event.flic_duo_d12345_small_button-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'button',
|
||||
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
'swipe_left',
|
||||
'swipe_right',
|
||||
'swipe_up',
|
||||
'swipe_down',
|
||||
'rotate_clockwise',
|
||||
'rotate_counter_clockwise',
|
||||
]),
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Flic Duo (D12345) Small button',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.flic_duo_d12345_small_button',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entity_setup[flic2][event.flic_2_b12345_button-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.flic_2_b12345_button',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Button',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Button',
|
||||
'platform': 'flic_button',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'button',
|
||||
'unique_id': 'AA:BB:CC:DD:EE:F0-button',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entity_setup[flic2][event.flic_2_b12345_button-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'button',
|
||||
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
]),
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Flic 2 (B12345) Button',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.flic_2_b12345_button',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entity_setup[twist][event.flic_twist_t12345_button-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
'twist_increment',
|
||||
'twist_decrement',
|
||||
'push_twist_increment',
|
||||
'push_twist_decrement',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.flic_twist_t12345_button',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Button',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Button',
|
||||
'platform': 'flic_button',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'button_twist_default',
|
||||
'unique_id': 'AA:BB:CC:DD:EE:F2-button_twist',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_entity_setup[twist][event.flic_twist_t12345_button-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'button',
|
||||
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
'twist_increment',
|
||||
'twist_decrement',
|
||||
'push_twist_increment',
|
||||
'push_twist_decrement',
|
||||
]),
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Flic Twist (T12345) Button',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.flic_twist_t12345_button',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_twist_event_entity_selector_mode[twist][event.flic_twist_t12345_button-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
'rotate_clockwise',
|
||||
'rotate_counter_clockwise',
|
||||
'selector_changed',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.flic_twist_t12345_button',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Button',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Button',
|
||||
'platform': 'flic_button',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'button_twist',
|
||||
'unique_id': 'AA:BB:CC:DD:EE:F2-button_twist',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_twist_event_entity_selector_mode[twist][event.flic_twist_t12345_button-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'button',
|
||||
<EventEntityStateAttribute.EVENT_TYPE: 'event_type'>: None,
|
||||
<EventEntityCapabilityAttribute.EVENT_TYPES: 'event_types'>: list([
|
||||
'up',
|
||||
'down',
|
||||
'click',
|
||||
'double_click',
|
||||
'hold',
|
||||
'rotate_clockwise',
|
||||
'rotate_counter_clockwise',
|
||||
'selector_changed',
|
||||
]),
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Flic Twist (T12345) Button',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.flic_twist_t12345_button',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Test the Flic Button config flow."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from bleak import BleakError
|
||||
from pyflic_ble import (
|
||||
DeviceType,
|
||||
FlicAuthenticationError,
|
||||
FlicPairingError,
|
||||
PushTwistMode,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.flic_button.config_flow import FlicButtonConfigFlow
|
||||
from homeassistant.components.flic_button.const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_PUSH_TWIST_MODE,
|
||||
CONF_SERIAL_NUMBER,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import (
|
||||
ADDRESS_FOR_DEVICE_TYPE,
|
||||
DUO_SERIAL,
|
||||
FLIC2_SERIAL,
|
||||
MODEL_NAME_FOR_DEVICE_TYPE,
|
||||
TEST_BATTERY_LEVEL,
|
||||
TEST_BUTTON_UUID,
|
||||
TEST_PAIRING_ID,
|
||||
TEST_PAIRING_KEY,
|
||||
TEST_SIG_BITS,
|
||||
create_flic2_service_info,
|
||||
service_info_for_device_type,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_discovered_service_info(
|
||||
device_type: DeviceType,
|
||||
) -> Generator[MagicMock]:
|
||||
"""Patch async_discovered_service_info to return the matching device."""
|
||||
service_info = service_info_for_device_type(device_type)
|
||||
with patch(
|
||||
"homeassistant.components.flic_button.config_flow.async_discovered_service_info",
|
||||
return_value=[service_info],
|
||||
) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
async def _init_bt_flow(hass: HomeAssistant, device_type: DeviceType) -> dict:
|
||||
"""Start a bluetooth flow and advance past the discovery confirmation."""
|
||||
service_info = service_info_for_device_type(device_type)
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=service_info,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
return await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
|
||||
async def test_user_flow_shows_discovery_progress(hass: HomeAssistant) -> None:
|
||||
"""Test user-initiated flow starts discovery and stops it on abort."""
|
||||
started = asyncio.Event()
|
||||
cancelled = asyncio.Event()
|
||||
|
||||
async def _fake_process_advertisements(*args, **kwargs):
|
||||
started.set()
|
||||
try:
|
||||
await asyncio.Event().wait() # block until cancelled
|
||||
except asyncio.CancelledError:
|
||||
cancelled.set()
|
||||
raise
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.flic_button.config_flow.async_process_advertisements",
|
||||
side_effect=_fake_process_advertisements,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
assert result["progress_action"] == "wait_for_discovery"
|
||||
|
||||
# Let the background discovery task start before aborting
|
||||
await started.wait()
|
||||
|
||||
# Aborting the flow must cancel the background discovery task
|
||||
hass.config_entries.flow.async_abort(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert cancelled.is_set()
|
||||
assert not hass.config_entries.flow.async_progress(include_uninitialized=True)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_discovered_service_info")
|
||||
@pytest.mark.parametrize(
|
||||
("device_type", "serial"),
|
||||
[
|
||||
(DeviceType.FLIC2, FLIC2_SERIAL),
|
||||
(DeviceType.DUO, DUO_SERIAL),
|
||||
(DeviceType.TWIST, "T12345"),
|
||||
],
|
||||
)
|
||||
async def test_pairing_success(
|
||||
hass: HomeAssistant,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
device_type: DeviceType,
|
||||
serial: str,
|
||||
) -> None:
|
||||
"""Test successful pairing flow for each supported device type."""
|
||||
address = ADDRESS_FOR_DEVICE_TYPE[device_type]
|
||||
model = MODEL_NAME_FOR_DEVICE_TYPE[device_type]
|
||||
# Override default fixture serial for the chosen device
|
||||
mock_flic_client.full_verify_pairing.return_value = (
|
||||
TEST_PAIRING_ID,
|
||||
TEST_PAIRING_KEY,
|
||||
serial,
|
||||
TEST_BATTERY_LEVEL,
|
||||
TEST_SIG_BITS,
|
||||
TEST_BUTTON_UUID if device_type is DeviceType.TWIST else None,
|
||||
10,
|
||||
)
|
||||
|
||||
result = await _init_bt_flow(hass, device_type)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "pair"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == f"{model} ({serial})"
|
||||
assert result["result"].unique_id == address
|
||||
assert result["data"][CONF_ADDRESS] == address
|
||||
assert result["data"][CONF_DEVICE_TYPE] == device_type.value
|
||||
assert result["data"][CONF_SERIAL_NUMBER] == serial
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_discovered_service_info")
|
||||
async def test_bluetooth_discovery_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Bluetooth discovery when device is already configured."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
# Aborts on the unique-id check before the confirmation form is shown
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=service_info_for_device_type(DeviceType.FLIC2),
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_discovered_service_info")
|
||||
@pytest.mark.parametrize(
|
||||
("connect_side_effect", "pairing_side_effect", "error"),
|
||||
[
|
||||
(BleakError("Connection failed"), None, "cannot_connect"),
|
||||
(TimeoutError(), None, "cannot_connect"),
|
||||
(None, FlicPairingError("Pairing failed"), "pairing_failed"),
|
||||
(None, FlicAuthenticationError("Invalid signature"), "invalid_signature"),
|
||||
(None, RuntimeError("Unexpected error"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_pairing_errors_recover(
|
||||
hass: HomeAssistant,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_setup_entry: AsyncMock,
|
||||
connect_side_effect: Exception | None,
|
||||
pairing_side_effect: Exception | None,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Each pairing error path falls back to the form and recovers to CREATE_ENTRY."""
|
||||
mock_flic_client.connect.side_effect = connect_side_effect
|
||||
mock_flic_client.full_verify_pairing.side_effect = pairing_side_effect
|
||||
|
||||
result = await _init_bt_flow(hass, DeviceType.FLIC2)
|
||||
assert result["step_id"] == "pair"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "pair"
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
# Recover and complete the flow
|
||||
mock_flic_client.connect.side_effect = None
|
||||
mock_flic_client.full_verify_pairing.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_type", [DeviceType.TWIST])
|
||||
async def test_options_flow_twist_device(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test options flow for Flic Twist device."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_PUSH_TWIST_MODE: PushTwistMode.SELECTOR},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {CONF_PUSH_TWIST_MODE: PushTwistMode.SELECTOR}
|
||||
|
||||
|
||||
async def test_options_flow_not_supported_for_non_twist(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test options flow is not supported for non-Twist devices."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert not FlicButtonConfigFlow.async_supports_options_flow(mock_config_entry)
|
||||
|
||||
|
||||
async def test_bluetooth_confirm_device_no_longer_advertising(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test Bluetooth confirmation falls back to scanner when the device disappears."""
|
||||
service_info = create_flic2_service_info()
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_BLUETOOTH},
|
||||
data=service_info,
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "bluetooth_confirm"
|
||||
|
||||
# Confirm while the device is no longer advertising — fall back to the scanner
|
||||
with patch(
|
||||
"homeassistant.components.flic_button.config_flow.async_discovered_service_info",
|
||||
return_value=[],
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input={}
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.SHOW_PROGRESS
|
||||
assert result["progress_action"] == "wait_for_discovery"
|
||||
|
||||
hass.config_entries.flow.async_abort(result["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
@@ -0,0 +1,264 @@
|
||||
"""Test the Flic Button event platform."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pyflic_ble import DeviceType, PushTwistMode
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.flic_button.const import CONF_PUSH_TWIST_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_flic_client",
|
||||
"mock_no_ble_device_from_address",
|
||||
"mock_bluetooth_register_callback",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"device_type", [DeviceType.FLIC2, DeviceType.DUO, DeviceType.TWIST]
|
||||
)
|
||||
async def test_event_entity_setup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test event entities are created for each device type."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_flic_client",
|
||||
"mock_no_ble_device_from_address",
|
||||
"mock_bluetooth_register_callback",
|
||||
)
|
||||
@pytest.mark.parametrize("device_type", [DeviceType.TWIST])
|
||||
async def test_twist_event_entity_selector_mode(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Flic Twist SELECTOR mode event entity has rotate and selector events."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry,
|
||||
options={CONF_PUSH_TWIST_MODE: PushTwistMode.SELECTOR},
|
||||
)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_no_ble_device_from_address", "mock_bluetooth_register_callback"
|
||||
)
|
||||
async def test_flic2_button_event_triggers_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Flic 2 button click event triggers entity state update."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
# The entity registers a button event callback during setup; fetch it from the mock
|
||||
entity_cb = mock_flic_client.register_button_event_callback.call_args[0][0]
|
||||
entity_cb("click", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
event_entities = [e for e in entities if e.domain == "event"]
|
||||
state = hass.states.get(event_entities[0].entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes.get("event_type") == "click"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_no_ble_device_from_address", "mock_bluetooth_register_callback"
|
||||
)
|
||||
@pytest.mark.parametrize("device_type", [DeviceType.DUO])
|
||||
async def test_duo_button_event_filters_by_index(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Duo button events are filtered by button_index."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
event_entities = [e for e in entities if e.domain == "event"]
|
||||
assert len(event_entities) == 2
|
||||
|
||||
entity_cbs = [
|
||||
call.args[0]
|
||||
for call in mock_flic_client.register_button_event_callback.call_args_list
|
||||
]
|
||||
|
||||
# Fire event for button_index 0 (big button)
|
||||
for cb in entity_cbs:
|
||||
cb("click", {"button_index": 0})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
big_entity = next(e for e in event_entities if e.unique_id.endswith("_big"))
|
||||
small_entity = next(e for e in event_entities if e.unique_id.endswith("_small"))
|
||||
|
||||
big_state = hass.states.get(big_entity.entity_id)
|
||||
small_state = hass.states.get(small_entity.entity_id)
|
||||
|
||||
assert big_state is not None
|
||||
assert big_state.attributes.get("event_type") == "click"
|
||||
assert small_state is not None
|
||||
assert small_state.attributes.get("event_type") is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_no_ble_device_from_address", "mock_bluetooth_register_callback"
|
||||
)
|
||||
@pytest.mark.parametrize("device_type", [DeviceType.TWIST])
|
||||
async def test_twist_rotate_event_triggers_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Flic Twist rotate event triggers entity state update."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry,
|
||||
options={CONF_PUSH_TWIST_MODE: PushTwistMode.SELECTOR},
|
||||
)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_rotate_cb = mock_flic_client.register_rotate_event_callback.call_args[0][0]
|
||||
entity_rotate_cb("rotate_clockwise", {"value": 5})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
event_entities = [e for e in entities if e.domain == "event"]
|
||||
state = hass.states.get(event_entities[0].entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes.get("event_type") == "rotate_clockwise"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_no_ble_device_from_address", "mock_bluetooth_register_callback"
|
||||
)
|
||||
@pytest.mark.parametrize("device_type", [DeviceType.TWIST])
|
||||
async def test_twist_rotate_event_filtered_by_event_types(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Twist rotate events are filtered if not in entity's event_types."""
|
||||
# DEFAULT mode does NOT include rotate_clockwise
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
entity_rotate_cb = mock_flic_client.register_rotate_event_callback.call_args[0][0]
|
||||
entity_rotate_cb("rotate_clockwise", {"value": 5})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
event_entities = [e for e in entities if e.domain == "event"]
|
||||
state = hass.states.get(event_entities[0].entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes.get("event_type") is None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_no_ble_device_from_address", "mock_bluetooth_register_callback"
|
||||
)
|
||||
@pytest.mark.parametrize("device_type", [DeviceType.DUO])
|
||||
async def test_duo_rotate_event_filters_by_button_index(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test Duo rotate events are filtered by button_index."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
event_entities = [e for e in entities if e.domain == "event"]
|
||||
assert len(event_entities) == 2
|
||||
|
||||
entity_rotate_cbs = [
|
||||
call.args[0]
|
||||
for call in mock_flic_client.register_rotate_event_callback.call_args_list
|
||||
]
|
||||
for cb in entity_rotate_cbs:
|
||||
cb("rotate_clockwise", {"button_index": 1, "value": 3})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
big_entity = next(e for e in event_entities if e.unique_id.endswith("_big"))
|
||||
small_entity = next(e for e in event_entities if e.unique_id.endswith("_small"))
|
||||
|
||||
big_state = hass.states.get(big_entity.entity_id)
|
||||
small_state = hass.states.get(small_entity.entity_id)
|
||||
|
||||
assert big_state is not None
|
||||
assert big_state.attributes.get("event_type") is None
|
||||
assert small_state is not None
|
||||
assert small_state.attributes.get("event_type") == "rotate_clockwise"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_no_ble_device_from_address", "mock_bluetooth_register_callback"
|
||||
)
|
||||
async def test_entity_availability_transitions(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test entity availability changes when connection state changes."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state_callbacks = [
|
||||
call.args[0] for call in mock_flic_client.register_state_callback.call_args_list
|
||||
]
|
||||
|
||||
entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
event_entities = [e for e in entities if e.domain == "event"]
|
||||
entity_id = event_entities[0].entity_id
|
||||
|
||||
# Initially connected -> available
|
||||
assert hass.states.get(entity_id).state != "unavailable"
|
||||
|
||||
# Simulate disconnection
|
||||
mock_flic_client.state.connected = False
|
||||
for cb in state_callbacks:
|
||||
cb(mock_flic_client.state)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id).state == "unavailable"
|
||||
|
||||
# Simulate reconnection
|
||||
mock_flic_client.state.connected = True
|
||||
for cb in state_callbacks:
|
||||
cb(mock_flic_client.state)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id).state != "unavailable"
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Test the Flic Button integration init."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bleak import BleakError
|
||||
from pyflic_ble import DeviceType
|
||||
import pytest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import create_flic2_service_info, setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_setup_entry_success(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_ble_device_from_address: MagicMock,
|
||||
mock_bluetooth_register_callback: MagicMock,
|
||||
) -> None:
|
||||
"""Test successful setup entry."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_flic_client.start.assert_called_once()
|
||||
|
||||
|
||||
async def test_setup_entry_device_not_available(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_no_ble_device_from_address: MagicMock,
|
||||
mock_bluetooth_register_callback: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup entry when device is not available (BLE device not found)."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
# Entry should still load (device will connect when available)
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
# start() should not be called when no BLE device available
|
||||
mock_flic_client.start.assert_not_called()
|
||||
|
||||
|
||||
async def test_setup_entry_initial_connection_fails(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_ble_device_from_address: MagicMock,
|
||||
mock_bluetooth_register_callback: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup entry raises ConfigEntryNotReady when connection fails."""
|
||||
mock_flic_client.start.side_effect = BleakError("Connection failed")
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_ble_device_from_address: MagicMock,
|
||||
mock_bluetooth_register_callback: MagicMock,
|
||||
) -> None:
|
||||
"""Test unloading entry."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
mock_flic_client.stop.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_type", [DeviceType.TWIST])
|
||||
async def test_setup_entry_with_twist_device(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_no_ble_device_from_address: MagicMock,
|
||||
mock_bluetooth_register_callback: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup entry with Twist device type."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_bluetooth_callback_sets_ble_device(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_flic_client: MagicMock,
|
||||
mock_no_ble_device_from_address: MagicMock,
|
||||
mock_bluetooth_register_callback: MagicMock,
|
||||
) -> None:
|
||||
"""Test Bluetooth callback updates BLE device on the client."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
# The bluetooth callback is the second positional arg passed to register
|
||||
bt_callback = mock_bluetooth_register_callback.call_args[0][1]
|
||||
|
||||
service_info = create_flic2_service_info()
|
||||
bt_callback(service_info, MagicMock())
|
||||
|
||||
mock_flic_client.set_ble_device.assert_called_once_with(service_info.device)
|
||||
Reference in New Issue
Block a user