Add Flic Button integration (event platform) (#165260)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alexander Hiamchan
2026-07-22 15:44:36 +02:00
committed by GitHub
parent 8109722e75
commit aa0870069c
22 changed files with 2455 additions and 0 deletions
+1
View File
@@ -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
View File
@@ -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"
}
}
}
}
+10
View File
@@ -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",
+1
View File
@@ -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",
Generated
+10
View File
@@ -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
+3
View File
@@ -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
+101
View File
@@ -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()
+171
View File
@@ -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()
+264
View File
@@ -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"
+110
View File
@@ -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)