Home Assistant Unofficial Reference 2024.12.1
sensor.py
Go to the documentation of this file.
1 """Read temperature information from Eddystone beacons.
2 
3 Your beacons must be configured to transmit UID (for identification) and TLM
4 (for temperature) frames.
5 """
6 
7 from __future__ import annotations
8 
9 import logging
10 
11 from beacontools import BeaconScanner, EddystoneFilter, EddystoneTLMFrame
12 import voluptuous as vol
13 
15  PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
16  SensorDeviceClass,
17  SensorEntity,
18 )
19 from homeassistant.const import (
20  CONF_NAME,
21  EVENT_HOMEASSISTANT_START,
22  EVENT_HOMEASSISTANT_STOP,
23  STATE_UNKNOWN,
24  UnitOfTemperature,
25 )
26 from homeassistant.core import Event, HomeAssistant
28 from homeassistant.helpers.entity_platform import AddEntitiesCallback
29 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
30 
31 _LOGGER = logging.getLogger(__name__)
32 
33 CONF_BEACONS = "beacons"
34 CONF_BT_DEVICE_ID = "bt_device_id"
35 CONF_INSTANCE = "instance"
36 CONF_NAMESPACE = "namespace"
37 
38 BEACON_SCHEMA = vol.Schema(
39  {
40  vol.Required(CONF_NAMESPACE): cv.string,
41  vol.Required(CONF_INSTANCE): cv.string,
42  vol.Optional(CONF_NAME): cv.string,
43  }
44 )
45 
46 PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
47  {
48  vol.Optional(CONF_BT_DEVICE_ID, default=0): cv.positive_int,
49  vol.Required(CONF_BEACONS): vol.Schema({cv.string: BEACON_SCHEMA}),
50  }
51 )
52 
53 
55  hass: HomeAssistant,
56  config: ConfigType,
57  add_entities: AddEntitiesCallback,
58  discovery_info: DiscoveryInfoType | None = None,
59 ) -> None:
60  """Validate configuration, create devices and start monitoring thread."""
61  bt_device_id: int = config[CONF_BT_DEVICE_ID]
62 
63  beacons: dict[str, dict[str, str]] = config[CONF_BEACONS]
64  devices: list[EddystoneTemp] = []
65 
66  for dev_name, properties in beacons.items():
67  namespace = get_from_conf(properties, CONF_NAMESPACE, 20)
68  instance = get_from_conf(properties, CONF_INSTANCE, 12)
69  name = properties.get(CONF_NAME, dev_name)
70 
71  if instance is None or namespace is None:
72  _LOGGER.error("Skipping %s", dev_name)
73  continue
74 
75  devices.append(EddystoneTemp(name, namespace, instance))
76 
77  if devices:
78  mon = Monitor(hass, devices, bt_device_id)
79 
80  def monitor_stop(event: Event) -> None:
81  """Stop the monitor thread."""
82  _LOGGER.debug("Stopping scanner for Eddystone beacons")
83  mon.stop()
84 
85  def monitor_start(event: Event) -> None:
86  """Start the monitor thread."""
87  _LOGGER.debug("Starting scanner for Eddystone beacons")
88  mon.start()
89 
90  add_entities(devices)
91  mon.start()
92  hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, monitor_stop)
93  hass.bus.listen_once(EVENT_HOMEASSISTANT_START, monitor_start)
94  else:
95  _LOGGER.warning("No devices were added")
96 
97 
98 def get_from_conf(config: dict[str, str], config_key: str, length: int) -> str | None:
99  """Retrieve value from config and validate length."""
100  string = config[config_key]
101  if len(string) != length:
102  _LOGGER.error(
103  (
104  "Error in configuration parameter %s: Must be exactly %d "
105  "bytes. Device will not be added"
106  ),
107  config_key,
108  length / 2,
109  )
110  return None
111  return string
112 
113 
115  """Representation of a temperature sensor."""
116 
117  _attr_device_class = SensorDeviceClass.TEMPERATURE
118  _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
119  _attr_should_poll = False
120 
121  def __init__(self, name: str, namespace: str, instance: str) -> None:
122  """Initialize a sensor."""
123  self._attr_name_attr_name = name
124  self.namespacenamespace = namespace
125  self.instanceinstance = instance
126  self.bt_addrbt_addr = None
127  self.temperaturetemperature = STATE_UNKNOWN
128 
129  @property
130  def native_value(self):
131  """Return the state of the device."""
132  return self.temperaturetemperature
133 
134 
135 class Monitor:
136  """Continuously scan for BLE advertisements."""
137 
138  def __init__(
139  self, hass: HomeAssistant, devices: list[EddystoneTemp], bt_device_id: int
140  ) -> None:
141  """Construct interface object."""
142  self.hasshass = hass
143 
144  # List of beacons to monitor
145  self.devicesdevices = devices
146  # Number of the bt device (hciX)
147  self.bt_device_idbt_device_id = bt_device_id
148 
149  def callback(bt_addr, _, packet, additional_info):
150  """Handle new packets."""
151  self.process_packetprocess_packet(
152  additional_info["namespace"],
153  additional_info["instance"],
154  packet.temperature,
155  )
156 
157  device_filters = [EddystoneFilter(d.namespace, d.instance) for d in devices]
158 
159  self.scannerscanner = BeaconScanner(
160  callback, bt_device_id, device_filters, EddystoneTLMFrame
161  )
162  self.scanningscanning = False
163 
164  def start(self) -> None:
165  """Continuously scan for BLE advertisements."""
166  if not self.scanningscanning:
167  self.scannerscanner.start()
168  self.scanningscanning = True
169  else:
170  _LOGGER.debug("start() called, but scanner is already running")
171 
172  def process_packet(self, namespace, instance, temperature) -> None:
173  """Assign temperature to device."""
174  _LOGGER.debug(
175  "Received temperature for <%s,%s>: %d", namespace, instance, temperature
176  )
177 
178  for dev in self.devicesdevices:
179  if (
180  dev.namespace == namespace
181  and dev.instance == instance
182  and dev.temperature != temperature
183  ):
184  dev.temperature = temperature
185  dev.schedule_update_ha_state()
186 
187  def stop(self) -> None:
188  """Signal runner to stop and join thread."""
189  if self.scanningscanning:
190  _LOGGER.debug("Stopping")
191  self.scannerscanner.stop()
192  _LOGGER.debug("Stopped")
193  self.scanningscanning = False
194  else:
195  _LOGGER.debug("stop() called but scanner was not running")
None __init__(self, str name, str namespace, str instance)
Definition: sensor.py:121
None __init__(self, HomeAssistant hass, list[EddystoneTemp] devices, int bt_device_id)
Definition: sensor.py:140
None process_packet(self, namespace, instance, temperature)
Definition: sensor.py:172
str|None get_from_conf(dict[str, str] config, str config_key, int length)
Definition: sensor.py:98
None setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback add_entities, DiscoveryInfoType|None discovery_info=None)
Definition: sensor.py:59
def add_entities(account, async_add_entities, tracked)
Definition: sensor.py:40