Coverage for custom_components/supernotify/binary_sensor.py: 100%
151 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-09-25 21:14 +0000
1"""Binary sensor platform: scenario and recipient state as real Home Assistant entities.
3Forwarded to from async_setup_entry in __init__.py once the SupernotifyEngine (entry.
4runtime_data) is fully initialized - scenario_registry.scenarios and people_registry.people are
5already populated by then, mirroring notify.py's own async_setup_entry for recipient notify
6entities.
8These replace the raw entity registry and hass.states.async_set() writes previously used
9for these binary_sensors (see upstream issue #175, "Part B"): real
10BinarySensorEntity objects grouped under a single SuperNotify device, instead of a bare
11entity_registry entry with a hand-written state and no Entity object behind it.
13Both are read-only: enabling and disabling a scenario or recipient is done by its switch entity
14(switch.py). The scenario binary_sensor is the only place a scenario's state - whether its
15conditions currently hold, as opposed to whether it is enabled - is exposed, so is kept for
16everyone, for any scenario that hasn't opted out with expose_state. For a scenario with conditions
17it is read-only, while one without conditions has a manual binary_sensor, which is the control for
18whether it applies, set from outside Supernotify. The recipient binary_sensor only mirrors the switch, so is deprecated, and only kept
19for an existing install that already has it, never created for a new one. A one-off repair tells
20anyone with it enabled that it will be removed in a future version (see repairs.py).
22The delivery and transport binary_sensors are deprecated in the same way: enabling and disabling
23moved to their switches, so these only mirror whether each is enabled, are kept only for an
24existing install that already has them, and only for a delivery or transport that is loaded. A
25one-off repair tells anyone with one enabled that they will be removed in a future version.
27entity_id and unique_id are chosen deliberately to line up with the raw state writes these
28entities replaced (binary_sensor.supernotify_scenario_<name> / _recipient_<name>, unique_id
29"scenario_<name>" / "recipient_<name>" / "delivery_<name>" / "transport_<name>" with no
30config-entry prefix) so that upgrading an existing installation adopts the same registry entry
31and history instead of creating a duplicate.
32"""
34from __future__ import annotations
36import logging
37from typing import TYPE_CHECKING
39from homeassistant.components.binary_sensor import BinarySensorEntity
40from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory, Platform
41from homeassistant.core import callback
42from homeassistant.helpers import entity_registry as er
43from homeassistant.helpers.event import async_track_state_change_event
44from homeassistant.helpers.restore_state import RestoreEntity
46from . import DOMAIN
47from .common import sanitize
48from .const import (
49 DELIVERY_UNRECORDED_ATTRIBUTES,
50 OVERRIDE_KIND_DELIVERY,
51 OVERRIDE_KIND_TRANSPORT,
52 TRANSPORT_UNRECORDED_ATTRIBUTES,
53)
54from .hass_api import ha_device_info
55from .repairs import (
56 async_create_delivery_transport_binary_sensor_deprecated_issue,
57 async_create_recipient_binary_sensor_deprecated_issue,
58)
60_LOGGER = logging.getLogger(__name__)
62if TYPE_CHECKING:
63 from homeassistant.core import Event, HomeAssistant
64 from homeassistant.helpers.device_registry import DeviceInfo
65 from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
66 from homeassistant.helpers.event import EventStateChangedData
68 from . import SupernotifyConfigEntry
69 from .delivery import Delivery, DeliveryRegistry
70 from .people import PeopleRegistry, Recipient
71 from .scenario import Scenario, ScenarioRegistry
72 from .transport import Transport
75async def async_setup_entry(
76 hass: HomeAssistant,
77 entry: SupernotifyConfigEntry,
78 async_add_entities: AddConfigEntryEntitiesCallback,
79) -> None:
80 """Expose the state of each scenario, and keep the deprecated recipient, delivery and
81 transport binary_sensors for an install that has them."""
82 service = entry.runtime_data
83 device_info = ha_device_info(entry.entry_id)
85 entity_registry = er.async_get(hass)
86 entities: list[BinarySensorEntity] = []
87 for scenario in service.context.scenario_registry.scenarios.values():
88 if service.context.scenario_registry.scenario_has_state(scenario):
89 sensor_class = SupernotifyScenarioManualBinarySensor if scenario.is_manual else SupernotifyScenarioBinarySensor
90 entities.append(sensor_class(scenario, service.context.scenario_registry, device_info))
91 elif entity_id := entity_registry.async_get_entity_id(Platform.BINARY_SENSOR, DOMAIN, f"scenario_{scenario.name}"):
92 # an install from before that has since opted out with expose_state - remove it
93 # rather than leave it as an entity that's no longer provided
94 _LOGGER.info("SUPERNOTIFY Removing binary_sensor for scenario %s, it has no state to show", scenario.name)
95 entity_registry.async_remove(entity_id)
96 hass.states.async_remove(entity_id)
98 # The recipient, delivery and transport binary_sensors are deprecated, so only kept for an
99 # existing install that already has them, and never published for anyone new - which
100 # includes a recipient, delivery or transport added to an existing install. Whether an entity
101 # exists is known from its registry entry, which is only created by adding the entity.
102 in_use = False
103 for recipient in service.context.people_registry.people.values():
104 registry_entry = _existing_row(entity_registry, f"recipient_{recipient.name}")
105 if registry_entry is not None:
106 entities.append(SupernotifyRecipientBinarySensor(recipient, service.context.people_registry, device_info))
107 # only worth a warning if somebody could still be relying on it
108 in_use = in_use or registry_entry.disabled_by is None
110 delivery_registry = service.context.delivery_registry
111 legacy_in_use = False
112 legacy_sensors: list[SupernotifyLegacyBinarySensor] = [
113 SupernotifyTransportBinarySensor(transport, delivery_registry, device_info)
114 for transport in delivery_registry.transports.values()
115 ]
116 legacy_sensors.extend(
117 SupernotifyDeliveryBinarySensor(delivery, delivery_registry, device_info)
118 for delivery in delivery_registry.deliveries.values()
119 )
120 for legacy_sensor in legacy_sensors:
121 registry_entry = _existing_row(entity_registry, legacy_sensor.unique_id or "")
122 if registry_entry is not None:
123 entities.append(legacy_sensor)
124 legacy_in_use = legacy_in_use or registry_entry.disabled_by is None
126 async_add_entities(entities)
127 if in_use:
128 async_create_recipient_binary_sensor_deprecated_issue(hass)
129 if legacy_in_use:
130 async_create_delivery_transport_binary_sensor_deprecated_issue(hass)
133def _existing_row(entity_registry: er.EntityRegistry, unique_id: str) -> er.RegistryEntry | None:
134 """The registry entry of one of our binary_sensors, if an earlier version already created it."""
135 entity_id = entity_registry.async_get_entity_id(Platform.BINARY_SENSOR, DOMAIN, unique_id)
136 return entity_registry.async_get(entity_id) if entity_id else None
139class SupernotifyScenarioBinarySensor(BinarySensorEntity):
140 """A scenario's evaluated condition state - whether it currently applies, as opposed to
141 whether it is enabled, which is the scenario switch. A scenario with no conditions to
142 evaluate gets SupernotifyScenarioManualBinarySensor instead.
144 Read-only: writing its state does not enable or disable the scenario.
146 See ScenarioRegistry._scenario_state()/scenario_is_on() for how ON/OFF/unknown is derived,
147 and async_refresh_scenario_states() for what triggers a re-read of this entity's state.
148 """
150 _attr_has_entity_name = True
151 _attr_entity_category = EntityCategory.DIAGNOSTIC
152 _attr_translation_key = "scenario"
153 _attr_should_poll = False
155 def __init__(self, scenario: Scenario, registry: ScenarioRegistry, device_info: DeviceInfo) -> None:
156 self._scenario = scenario
157 self._registry = registry
158 self._attr_unique_id = f"scenario_{scenario.name}"
159 self._attr_device_info = device_info
160 self._attr_translation_placeholders = {"scenario": scenario.alias or scenario.name}
161 # Setting entity_id directly is not preferred Home Assistant practice - see
162 # SupernotifyScenarioSwitch for why it's done anyway, and why it can't change the
163 # entity_id of an existing install.
164 self.entity_id = f"binary_sensor.{DOMAIN}_scenario_{scenario.name}"
166 @property
167 def is_on(self) -> bool | None:
168 return self._registry.scenario_is_on(self._scenario)
170 @property
171 def extra_state_attributes(self) -> dict[str, object]:
172 return sanitize(self._scenario.attributes(include_condition=False))
174 async def async_added_to_hass(self) -> None:
175 await super().async_added_to_hass()
176 self._registry.register_entity(self._scenario.name, self)
178 async def async_will_remove_from_hass(self) -> None:
179 self._registry.unregister_entity(self._scenario.name)
180 await super().async_will_remove_from_hass()
183class SupernotifyScenarioManualBinarySensor(SupernotifyScenarioBinarySensor, RestoreEntity):
184 """The state of a scenario that has no conditions of its own to evaluate, set from outside
185 Supernotify - typically a script or automation writing its state, or calling a service on it.
187 Unlike the condition binary_sensor, this is the control: whatever it is set to is applied
188 back to the scenario, which then evaluates true while it is on, in the same way as one whose
189 conditions hold. It is restored across a restart. Whether the scenario is enabled is still
190 the scenario switch, and a disabled scenario never applies whatever this is set to.
191 """
193 _attr_translation_key = "scenario_manual"
195 async def async_added_to_hass(self) -> None:
196 await super().async_added_to_hass()
197 last_state = await self.async_get_last_state()
198 if last_state is not None and last_state.state in (STATE_ON, STATE_OFF):
199 self._scenario.manual_active = last_state.state == STATE_ON
200 self.async_on_remove(async_track_state_change_event(self.hass, [self.entity_id], self._async_state_changed))
202 @callback
203 def _async_state_changed(self, event: Event[EventStateChangedData]) -> None:
204 """Apply a state written to this entity from outside back to the scenario"""
205 new_state = event.data["new_state"]
206 if new_state is None or new_state.state not in (STATE_ON, STATE_OFF):
207 return
208 active = new_state.state == STATE_ON
209 if active != self._scenario.manual_active:
210 _LOGGER.info("SUPERNOTIFY Scenario %s manually set %s", self._scenario.name, new_state.state)
211 self._scenario.manual_active = active
212 # put back the entity's own attributes, which a plain state write would have replaced
213 self.async_write_ha_state()
216class SupernotifyRecipientBinarySensor(BinarySensorEntity):
217 """Whether a recipient is currently enabled for delivery. Deprecated, see the recipient switch.
219 Read-only: writing its state no longer enables or disables the recipient.
220 """
222 _attr_has_entity_name = True
223 _attr_entity_category = EntityCategory.DIAGNOSTIC
224 # No device_class: CONNECTIVITY (a previous version of this class) is semantically wrong
225 # here - it means online/offline device reachability, not "enabled for delivery", and made
226 # a disabled recipient show as "Disconnected" in the dashboard. Icon (see icons.json) and
227 # translation_key below already carry the meaning without borrowing a misleading one.
228 _attr_translation_key = "recipient"
229 _attr_should_poll = False
231 def __init__(self, recipient: Recipient, registry: PeopleRegistry, device_info: DeviceInfo) -> None:
232 self._recipient = recipient
233 self._registry = registry
234 self._attr_unique_id = f"recipient_{recipient.name}"
235 self._attr_device_info = device_info
236 self._attr_translation_placeholders = {"recipient": recipient.alias or recipient.name}
237 # Setting entity_id directly is not preferred Home Assistant practice - see
238 # SupernotifyScenarioSwitch for why it's done anyway, and why it can't change the
239 # entity_id of an existing install.
240 self.entity_id = f"binary_sensor.{DOMAIN}_recipient_{recipient.name}"
242 @property
243 def is_on(self) -> bool:
244 return self._recipient.enabled
246 @property
247 def extra_state_attributes(self) -> dict[str, object]:
248 return sanitize(self._recipient.attributes())
250 async def async_added_to_hass(self) -> None:
251 await super().async_added_to_hass()
252 self._registry.register_entity(self._recipient.name, self)
254 async def async_will_remove_from_hass(self) -> None:
255 self._registry.unregister_entity(self._recipient.name)
256 await super().async_will_remove_from_hass()
259class SupernotifyLegacyBinarySensor(BinarySensorEntity):
260 """Whether a delivery or transport is currently enabled. Deprecated, see its switch.
262 Read-only: writing its state no longer enables or disables anything. No entity_id is set,
263 as none is needed: an existing registry entry, found by unique_id, always provides it.
264 """
266 _attr_has_entity_name = True
267 _attr_entity_category = EntityCategory.DIAGNOSTIC
268 _attr_entity_registry_enabled_default = False
269 _attr_should_poll = False
271 def __init__(self, kind: str, model: Delivery | Transport, registry: DeliveryRegistry, device_info: DeviceInfo) -> None:
272 self._model = model
273 self._registry = registry
274 self._key = f"{kind}_{model.name}"
275 self._attr_unique_id = self._key
276 self._attr_device_info = device_info
277 self._attr_translation_placeholders = {kind: model.alias or model.name}
279 @property
280 def is_on(self) -> bool:
281 return bool(self._model.enabled)
283 @property
284 def extra_state_attributes(self) -> dict[str, object]:
285 return sanitize(self._model.attributes())
287 async def async_added_to_hass(self) -> None:
288 await super().async_added_to_hass()
289 self._registry.register_entity(self._key, self)
291 async def async_will_remove_from_hass(self) -> None:
292 self._registry.unregister_entity(self._key)
293 await super().async_will_remove_from_hass()
296class SupernotifyDeliveryBinarySensor(SupernotifyLegacyBinarySensor):
297 """Whether a delivery is currently enabled. Deprecated, see the delivery switch."""
299 _attr_translation_key = "delivery"
300 _unrecorded_attributes = DELIVERY_UNRECORDED_ATTRIBUTES
302 def __init__(self, delivery: Delivery, registry: DeliveryRegistry, device_info: DeviceInfo) -> None:
303 super().__init__(OVERRIDE_KIND_DELIVERY, delivery, registry, device_info)
306class SupernotifyTransportBinarySensor(SupernotifyLegacyBinarySensor):
307 """Whether a transport is currently enabled. Deprecated, see the transport switch."""
309 _attr_translation_key = "transport"
310 _unrecorded_attributes = TRANSPORT_UNRECORDED_ATTRIBUTES
312 def __init__(self, transport: Transport, registry: DeliveryRegistry, device_info: DeviceInfo) -> None:
313 super().__init__(OVERRIDE_KIND_TRANSPORT, transport, registry, device_info)