Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Sensor to monitor incoming/outgoing phone calls on a Fritz!Box router."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Mapping
6 from datetime import datetime, timedelta
7 from enum import StrEnum
8 import logging
9 import queue
10 from threading import Event as ThreadingEvent, Thread
11 from time import sleep
12 from typing import Any, cast
13 
14 from fritzconnection.core.fritzmonitor import FritzMonitor
15 
16 from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
17 from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
18 from homeassistant.core import Event, HomeAssistant
19 from homeassistant.helpers.device_registry import DeviceInfo
20 from homeassistant.helpers.entity_platform import AddEntitiesCallback
21 
22 from . import FritzBoxCallMonitorConfigEntry
23 from .base import FritzBoxPhonebook
24 from .const import (
25  ATTR_PREFIXES,
26  CONF_PHONEBOOK,
27  CONF_PREFIXES,
28  DOMAIN,
29  MANUFACTURER,
30  SERIAL_NUMBER,
31  FritzState,
32 )
33 
34 _LOGGER = logging.getLogger(__name__)
35 
36 SCAN_INTERVAL = timedelta(hours=3)
37 
38 
39 class CallState(StrEnum):
40  """Fritz sensor call states."""
41 
42  RINGING = "ringing"
43  DIALING = "dialing"
44  TALKING = "talking"
45  IDLE = "idle"
46 
47 
49  hass: HomeAssistant,
50  config_entry: FritzBoxCallMonitorConfigEntry,
51  async_add_entities: AddEntitiesCallback,
52 ) -> None:
53  """Set up the fritzbox_callmonitor sensor from config_entry."""
54  fritzbox_phonebook = config_entry.runtime_data
55 
56  phonebook_id: int = config_entry.data[CONF_PHONEBOOK]
57  prefixes: list[str] | None = config_entry.options.get(CONF_PREFIXES)
58  serial_number: str = config_entry.data[SERIAL_NUMBER]
59  host: str = config_entry.data[CONF_HOST]
60  port: int = config_entry.data[CONF_PORT]
61 
62  unique_id = f"{serial_number}-{phonebook_id}"
63 
64  sensor = FritzBoxCallSensor(
65  phonebook_name=config_entry.title,
66  unique_id=unique_id,
67  fritzbox_phonebook=fritzbox_phonebook,
68  prefixes=prefixes,
69  host=host,
70  port=port,
71  )
72 
73  async_add_entities([sensor])
74 
75 
77  """Implementation of a Fritz!Box call monitor."""
78 
79  _attr_has_entity_name = True
80  _attr_translation_key = DOMAIN
81  _attr_device_class = SensorDeviceClass.ENUM
82  _attr_options = list(CallState)
83 
84  def __init__(
85  self,
86  phonebook_name: str,
87  unique_id: str,
88  fritzbox_phonebook: FritzBoxPhonebook,
89  prefixes: list[str] | None,
90  host: str,
91  port: int,
92  ) -> None:
93  """Initialize the sensor."""
94  self._fritzbox_phonebook_fritzbox_phonebook = fritzbox_phonebook
95  self._prefixes_prefixes = prefixes
96  self._host_host = host
97  self._port_port = port
98  self._monitor_monitor: FritzBoxCallMonitor | None = None
99  self._attributes_attributes: dict[str, str | list[str]] = {}
100 
101  self._attr_translation_placeholders_attr_translation_placeholders = {"phonebook_name": phonebook_name}
102  self._attr_unique_id_attr_unique_id = unique_id
103  self._attr_native_value_attr_native_value = CallState.IDLE
104  self._attr_device_info_attr_device_info = DeviceInfo(
105  configuration_url=self._fritzbox_phonebook_fritzbox_phonebook.fph.fc.address,
106  identifiers={(DOMAIN, unique_id)},
107  manufacturer=MANUFACTURER,
108  model=self._fritzbox_phonebook_fritzbox_phonebook.fph.modelname,
109  name=self._fritzbox_phonebook_fritzbox_phonebook.fph.modelname,
110  sw_version=self._fritzbox_phonebook_fritzbox_phonebook.fph.fc.system_version,
111  )
112 
113  async def async_added_to_hass(self) -> None:
114  """Connect to FRITZ!Box to monitor its call state."""
115  await super().async_added_to_hass()
116  await self.hasshass.async_add_executor_job(self._start_call_monitor_start_call_monitor)
117  self.async_on_removeasync_on_remove(
118  self.hasshass.bus.async_listen_once(
119  EVENT_HOMEASSISTANT_STOP, self._stop_call_monitor_stop_call_monitor
120  )
121  )
122 
123  async def async_will_remove_from_hass(self) -> None:
124  """Disconnect from FRITZ!Box by stopping monitor."""
125  await super().async_will_remove_from_hass()
126  await self.hasshass.async_add_executor_job(self._stop_call_monitor_stop_call_monitor)
127 
128  def _start_call_monitor(self) -> None:
129  """Check connection and start callmonitor thread."""
130  _LOGGER.debug("Starting monitor for: %s", self.entity_identity_id)
132  host=self._host_host,
133  port=self._port_port,
134  sensor=self,
135  )
136  self._monitor_monitor.connect()
137 
138  def _stop_call_monitor(self, event: Event | None = None) -> None:
139  """Stop callmonitor thread."""
140  if (
141  self._monitor_monitor
142  and self._monitor_monitor.stopped
143  and not self._monitor_monitor.stopped.is_set()
144  and self._monitor_monitor.connection
145  and self._monitor_monitor.connection.is_alive
146  ):
147  self._monitor_monitor.stopped.set()
148  self._monitor_monitor.connection.stop()
149  _LOGGER.debug("Stopped monitor for: %s", self.entity_identity_id)
150 
151  def set_state(self, state: CallState) -> None:
152  """Set the state."""
153  self._attr_native_value_attr_native_value = state
154 
155  def set_attributes(self, attributes: Mapping[str, str]) -> None:
156  """Set the state attributes."""
157  self._attributes_attributes = {**attributes}
158 
159  @property
160  def extra_state_attributes(self) -> dict[str, str | list[str]]:
161  """Return the state attributes."""
162  if self._prefixes_prefixes:
163  self._attributes_attributes[ATTR_PREFIXES] = self._prefixes_prefixes
164  return self._attributes_attributes
165 
166  def number_to_name(self, number: str) -> str:
167  """Return a name for a given phone number."""
168  return self._fritzbox_phonebook_fritzbox_phonebook.get_name(number)
169 
170  def update(self) -> None:
171  """Update the phonebook if it is defined."""
172  self._fritzbox_phonebook_fritzbox_phonebook.update_phonebook()
173 
174 
176  """Event listener to monitor calls on the Fritz!Box."""
177 
178  def __init__(self, host: str, port: int, sensor: FritzBoxCallSensor) -> None:
179  """Initialize Fritz!Box monitor instance."""
180  self.hosthost = host
181  self.portport = port
182  self.connectionconnection: FritzMonitor | None = None
183  self.stoppedstopped = ThreadingEvent()
184  self._sensor_sensor = sensor
185 
186  def connect(self) -> None:
187  """Connect to the Fritz!Box."""
188  _LOGGER.debug("Setting up socket connection")
189  try:
190  self.connectionconnection = FritzMonitor(address=self.hosthost, port=self.portport)
191  kwargs: dict[str, Any] = {
192  "event_queue": self.connectionconnection.start(
193  reconnect_tries=50, reconnect_delay=120
194  )
195  }
196  Thread(target=self._process_events_process_events, kwargs=kwargs).start()
197  except OSError as err:
198  self.connectionconnection = None
199  _LOGGER.error(
200  "Cannot connect to %s on port %s: %s", self.hosthost, self.portport, err
201  )
202 
203  def _process_events(self, event_queue: queue.Queue[str]) -> None:
204  """Listen to incoming or outgoing calls."""
205  _LOGGER.debug("Connection established, waiting for events")
206  while not self.stoppedstopped.is_set():
207  try:
208  event = event_queue.get(timeout=10)
209  except queue.Empty:
210  if (
211  not cast(FritzMonitor, self.connectionconnection).is_alive
212  and not self.stoppedstopped.is_set()
213  ):
214  _LOGGER.error("Connection has abruptly ended")
215  _LOGGER.debug("Empty event queue")
216  continue
217  else:
218  _LOGGER.debug("Received event: %s", event)
219  self._parse_parse(event)
220  sleep(1)
221 
222  def _parse(self, event: str) -> None:
223  """Parse the call information and set the sensor states."""
224  line = event.split(";")
225  df_in = "%d.%m.%y %H:%M:%S"
226  df_out = "%Y-%m-%dT%H:%M:%S"
227  isotime = datetime.strptime(line[0], df_in).strftime(df_out)
228  if line[1] == FritzState.RING:
229  self._sensor_sensor.set_state(CallState.RINGING)
230  att = {
231  "type": "incoming",
232  "from": line[3],
233  "to": line[4],
234  "device": line[5],
235  "initiated": isotime,
236  "from_name": self._sensor_sensor.number_to_name(line[3]),
237  }
238  self._sensor_sensor.set_attributes(att)
239  elif line[1] == FritzState.CALL:
240  self._sensor_sensor.set_state(CallState.DIALING)
241  att = {
242  "type": "outgoing",
243  "from": line[4],
244  "to": line[5],
245  "device": line[6],
246  "initiated": isotime,
247  "to_name": self._sensor_sensor.number_to_name(line[5]),
248  }
249  self._sensor_sensor.set_attributes(att)
250  elif line[1] == FritzState.CONNECT:
251  self._sensor_sensor.set_state(CallState.TALKING)
252  att = {
253  "with": line[4],
254  "device": line[3],
255  "accepted": isotime,
256  "with_name": self._sensor_sensor.number_to_name(line[4]),
257  }
258  self._sensor_sensor.set_attributes(att)
259  elif line[1] == FritzState.DISCONNECT:
260  self._sensor_sensor.set_state(CallState.IDLE)
261  att = {"duration": line[3], "closed": isotime}
262  self._sensor_sensor.set_attributes(att)
263  self._sensor_sensor.schedule_update_ha_state()
None _process_events(self, queue.Queue[str] event_queue)
Definition: sensor.py:203
None __init__(self, str host, int port, FritzBoxCallSensor sensor)
Definition: sensor.py:178
None set_attributes(self, Mapping[str, str] attributes)
Definition: sensor.py:155
None __init__(self, str phonebook_name, str unique_id, FritzBoxPhonebook fritzbox_phonebook, list[str]|None prefixes, str host, int port)
Definition: sensor.py:92
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
None async_setup_entry(HomeAssistant hass, FritzBoxCallMonitorConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: sensor.py:52