Home Assistant Unofficial Reference 2024.12.1
binary_sensor.py
Go to the documentation of this file.
1 """Support to use flic buttons as a binary sensor."""
2 
3 from __future__ import annotations
4 
5 import logging
6 import threading
7 
8 import pyflic
9 import voluptuous as vol
10 
12  PLATFORM_SCHEMA as BINARY_SENSOR_PLATFORM_SCHEMA,
13  BinarySensorEntity,
14 )
15 from homeassistant.const import (
16  CONF_DISCOVERY,
17  CONF_HOST,
18  CONF_PORT,
19  CONF_TIMEOUT,
20  EVENT_HOMEASSISTANT_STOP,
21 )
22 from homeassistant.core import HomeAssistant
24 from homeassistant.helpers.device_registry import format_mac
25 from homeassistant.helpers.entity_platform import AddEntitiesCallback
26 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
27 
28 _LOGGER = logging.getLogger(__name__)
29 
30 DEFAULT_TIMEOUT = 3
31 
32 CLICK_TYPE_SINGLE = "single"
33 CLICK_TYPE_DOUBLE = "double"
34 CLICK_TYPE_HOLD = "hold"
35 CLICK_TYPES = [CLICK_TYPE_SINGLE, CLICK_TYPE_DOUBLE, CLICK_TYPE_HOLD]
36 
37 CONF_IGNORED_CLICK_TYPES = "ignored_click_types"
38 
39 DEFAULT_HOST = "localhost"
40 DEFAULT_PORT = 5551
41 
42 EVENT_NAME = "flic_click"
43 EVENT_DATA_NAME = "button_name"
44 EVENT_DATA_ADDRESS = "button_address"
45 EVENT_DATA_TYPE = "click_type"
46 EVENT_DATA_QUEUED_TIME = "queued_time"
47 
48 PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
49  {
50  vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
51  vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
52  vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
53  vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
54  vol.Optional(CONF_IGNORED_CLICK_TYPES): vol.All(
55  cv.ensure_list, [vol.In(CLICK_TYPES)]
56  ),
57  }
58 )
59 
60 
62  hass: HomeAssistant,
63  config: ConfigType,
64  add_entities: AddEntitiesCallback,
65  discovery_info: DiscoveryInfoType | None = None,
66 ) -> None:
67  """Set up the flic platform."""
68 
69  # Initialize flic client responsible for
70  # connecting to buttons and retrieving events
71  host = config.get(CONF_HOST)
72  port = config.get(CONF_PORT)
73  discovery = config.get(CONF_DISCOVERY)
74 
75  try:
76  client = pyflic.FlicClient(host, port)
77  except ConnectionRefusedError:
78  _LOGGER.error("Failed to connect to flic server")
79  return
80 
81  def new_button_callback(address):
82  """Set up newly verified button as device in Home Assistant."""
83  setup_button(hass, config, add_entities, client, address)
84 
85  client.on_new_verified_button = new_button_callback
86  if discovery:
87  start_scanning(config, add_entities, client)
88 
89  hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lambda event: client.close())
90 
91  # Start the pyflic event handling thread
92  threading.Thread(target=client.handle_events).start()
93 
94  def get_info_callback(items):
95  """Add entities for already verified buttons."""
96  addresses = items["bd_addr_of_verified_buttons"] or []
97  for address in addresses:
98  setup_button(hass, config, add_entities, client, address)
99 
100  # Get addresses of already verified buttons
101  client.get_info(get_info_callback)
102 
103 
104 def start_scanning(config, add_entities, client):
105  """Start a new flic client for scanning and connecting to new buttons."""
106  scan_wizard = pyflic.ScanWizard()
107 
108  def scan_completed_callback(scan_wizard, result, address, name):
109  """Restart scan wizard to constantly check for new buttons."""
110  if result == pyflic.ScanWizardResult.WizardSuccess:
111  _LOGGER.debug("Found new button %s", address)
112  elif result != pyflic.ScanWizardResult.WizardFailedTimeout:
113  _LOGGER.warning(
114  "Failed to connect to button %s. Reason: %s", address, result
115  )
116 
117  # Restart scan wizard
118  start_scanning(config, add_entities, client)
119 
120  scan_wizard.on_completed = scan_completed_callback
121  client.add_scan_wizard(scan_wizard)
122 
123 
125  hass: HomeAssistant,
126  config: ConfigType,
127  add_entities: AddEntitiesCallback,
128  client,
129  address,
130 ) -> None:
131  """Set up a single button device."""
132  timeout: int = config[CONF_TIMEOUT]
133  ignored_click_types: list[str] | None = config.get(CONF_IGNORED_CLICK_TYPES)
134  button = FlicButton(hass, client, address, timeout, ignored_click_types)
135  _LOGGER.debug("Connected to button %s", address)
136 
137  add_entities([button])
138 
139 
141  """Representation of a flic button."""
142 
143  _attr_should_poll = False
144 
145  def __init__(
146  self,
147  hass: HomeAssistant,
148  client: pyflic.FlicClient,
149  address: str,
150  timeout: int,
151  ignored_click_types: list[str] | None,
152  ) -> None:
153  """Initialize the flic button."""
154 
155  self._attr_extra_state_attributes_attr_extra_state_attributes = {"address": address}
156  self._attr_name_attr_name = f"flic_{address.replace(':', '')}"
157  self._attr_unique_id_attr_unique_id = format_mac(address)
158  self._hass_hass = hass
159  self._address_address = address
160  self._timeout_timeout = timeout
161  self._attr_is_on_attr_is_on = True
162  self._ignored_click_types_ignored_click_types = ignored_click_types or []
163  self._hass_click_types_hass_click_types = {
164  pyflic.ClickType.ButtonClick: CLICK_TYPE_SINGLE,
165  pyflic.ClickType.ButtonSingleClick: CLICK_TYPE_SINGLE,
166  pyflic.ClickType.ButtonDoubleClick: CLICK_TYPE_DOUBLE,
167  pyflic.ClickType.ButtonHold: CLICK_TYPE_HOLD,
168  }
169 
170  self._channel_channel = self._create_channel_create_channel()
171  client.add_connection_channel(self._channel_channel)
172 
173  def _create_channel(self) -> pyflic.ButtonConnectionChannel:
174  """Create a new connection channel to the button."""
175  channel = pyflic.ButtonConnectionChannel(self._address_address)
176  channel.on_button_up_or_down = self._on_up_down_on_up_down
177 
178  # If all types of clicks should be ignored, skip registering callbacks
179  if set(self._ignored_click_types_ignored_click_types) == set(CLICK_TYPES):
180  return channel
181 
182  if CLICK_TYPE_DOUBLE in self._ignored_click_types_ignored_click_types:
183  # Listen to all but double click type events
184  channel.on_button_click_or_hold = self._on_click_on_click
185  elif CLICK_TYPE_HOLD in self._ignored_click_types_ignored_click_types:
186  # Listen to all but hold click type events
187  channel.on_button_single_or_double_click = self._on_click_on_click
188  else:
189  # Listen to all click type events
190  channel.on_button_single_or_double_click_or_hold = self._on_click_on_click
191 
192  return channel
193 
194  def _queued_event_check(self, click_type, time_diff):
195  """Generate a log message and returns true if timeout exceeded."""
196  time_string = f"{time_diff:d} {'second' if time_diff == 1 else 'seconds'}"
197 
198  if time_diff > self._timeout_timeout:
199  _LOGGER.warning(
200  "Queued %s dropped for %s. Time in queue was %s",
201  click_type,
202  self._address_address,
203  time_string,
204  )
205  return True
206  _LOGGER.debug(
207  "Queued %s allowed for %s. Time in queue was %s",
208  click_type,
209  self._address_address,
210  time_string,
211  )
212  return False
213 
214  def _on_up_down(self, channel, click_type, was_queued, time_diff):
215  """Update device state, if event was not queued."""
216  if was_queued and self._queued_event_check_queued_event_check(click_type, time_diff):
217  return
218 
219  self._attr_is_on_attr_is_on = click_type != pyflic.ClickType.ButtonDown
220  self.schedule_update_ha_stateschedule_update_ha_state()
221 
222  def _on_click(self, channel, click_type, was_queued, time_diff):
223  """Fire click event, if event was not queued."""
224  # Return if click event was queued beyond allowed timeout
225  if was_queued and self._queued_event_check_queued_event_check(click_type, time_diff):
226  return
227 
228  # Return if click event is in ignored click types
229  hass_click_type = self._hass_click_types_hass_click_types[click_type]
230  if hass_click_type in self._ignored_click_types_ignored_click_types:
231  return
232 
233  self._hass_hass.bus.fire(
234  EVENT_NAME,
235  {
236  EVENT_DATA_NAME: self.namename,
237  EVENT_DATA_ADDRESS: self._address_address,
238  EVENT_DATA_QUEUED_TIME: time_diff,
239  EVENT_DATA_TYPE: hass_click_type,
240  },
241  )
242 
243  def _connection_status_changed(self, channel, connection_status, disconnect_reason):
244  """Remove device, if button disconnects."""
245  if connection_status == pyflic.ConnectionStatus.Disconnected:
246  _LOGGER.warning(
247  "Button (%s) disconnected. Reason: %s", self._address_address, disconnect_reason
248  )
pyflic.ButtonConnectionChannel _create_channel(self)
def _connection_status_changed(self, channel, connection_status, disconnect_reason)
def _on_up_down(self, channel, click_type, was_queued, time_diff)
None __init__(self, HomeAssistant hass, pyflic.FlicClient client, str address, int timeout, list[str]|None ignored_click_types)
def _queued_event_check(self, click_type, time_diff)
def _on_click(self, channel, click_type, was_queued, time_diff)
None schedule_update_ha_state(self, bool force_refresh=False)
Definition: entity.py:1244
str|UndefinedType|None name(self)
Definition: entity.py:738
None add_entities(AsusWrtRouter router, AddEntitiesCallback async_add_entities, set[str] tracked)
None setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, DiscoveryInfoType|None discovery_info=None)
def start_scanning(config, add_entities, client)
None setup_button(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, client, address)