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

1"""Binary sensor platform: scenario and recipient state as real Home Assistant entities. 

2 

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. 

7 

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. 

12 

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). 

21 

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. 

26 

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""" 

33 

34from __future__ import annotations 

35 

36import logging 

37from typing import TYPE_CHECKING 

38 

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 

45 

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) 

59 

60_LOGGER = logging.getLogger(__name__) 

61 

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 

67 

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 

73 

74 

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) 

84 

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) 

97 

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 

109 

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 

125 

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) 

131 

132 

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 

137 

138 

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. 

143 

144 Read-only: writing its state does not enable or disable the scenario. 

145 

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 """ 

149 

150 _attr_has_entity_name = True 

151 _attr_entity_category = EntityCategory.DIAGNOSTIC 

152 _attr_translation_key = "scenario" 

153 _attr_should_poll = False 

154 

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}" 

165 

166 @property 

167 def is_on(self) -> bool | None: 

168 return self._registry.scenario_is_on(self._scenario) 

169 

170 @property 

171 def extra_state_attributes(self) -> dict[str, object]: 

172 return sanitize(self._scenario.attributes(include_condition=False)) 

173 

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) 

177 

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() 

181 

182 

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. 

186 

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 """ 

192 

193 _attr_translation_key = "scenario_manual" 

194 

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)) 

201 

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() 

214 

215 

216class SupernotifyRecipientBinarySensor(BinarySensorEntity): 

217 """Whether a recipient is currently enabled for delivery. Deprecated, see the recipient switch. 

218 

219 Read-only: writing its state no longer enables or disables the recipient. 

220 """ 

221 

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 

230 

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}" 

241 

242 @property 

243 def is_on(self) -> bool: 

244 return self._recipient.enabled 

245 

246 @property 

247 def extra_state_attributes(self) -> dict[str, object]: 

248 return sanitize(self._recipient.attributes()) 

249 

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) 

253 

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() 

257 

258 

259class SupernotifyLegacyBinarySensor(BinarySensorEntity): 

260 """Whether a delivery or transport is currently enabled. Deprecated, see its switch. 

261 

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 """ 

265 

266 _attr_has_entity_name = True 

267 _attr_entity_category = EntityCategory.DIAGNOSTIC 

268 _attr_entity_registry_enabled_default = False 

269 _attr_should_poll = False 

270 

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} 

278 

279 @property 

280 def is_on(self) -> bool: 

281 return bool(self._model.enabled) 

282 

283 @property 

284 def extra_state_attributes(self) -> dict[str, object]: 

285 return sanitize(self._model.attributes()) 

286 

287 async def async_added_to_hass(self) -> None: 

288 await super().async_added_to_hass() 

289 self._registry.register_entity(self._key, self) 

290 

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() 

294 

295 

296class SupernotifyDeliveryBinarySensor(SupernotifyLegacyBinarySensor): 

297 """Whether a delivery is currently enabled. Deprecated, see the delivery switch.""" 

298 

299 _attr_translation_key = "delivery" 

300 _unrecorded_attributes = DELIVERY_UNRECORDED_ATTRIBUTES 

301 

302 def __init__(self, delivery: Delivery, registry: DeliveryRegistry, device_info: DeviceInfo) -> None: 

303 super().__init__(OVERRIDE_KIND_DELIVERY, delivery, registry, device_info) 

304 

305 

306class SupernotifyTransportBinarySensor(SupernotifyLegacyBinarySensor): 

307 """Whether a transport is currently enabled. Deprecated, see the transport switch.""" 

308 

309 _attr_translation_key = "transport" 

310 _unrecorded_attributes = TRANSPORT_UNRECORDED_ATTRIBUTES 

311 

312 def __init__(self, transport: Transport, registry: DeliveryRegistry, device_info: DeviceInfo) -> None: 

313 super().__init__(OVERRIDE_KIND_TRANSPORT, transport, registry, device_info)