1 """Extend the basic Accessory and Bridge functions."""
3 from __future__
import annotations
6 from typing
import Any, cast
9 from pyhap.accessory
import Accessory, Bridge
10 from pyhap.accessory_driver
import AccessoryDriver
11 from pyhap.characteristic
import Characteristic
12 from pyhap.const
import CATEGORY_OTHER
13 from pyhap.iid_manager
import IIDManager
14 from pyhap.service
import Service
15 from pyhap.util
import callback
as pyhap_callback
23 ATTR_BATTERY_CHARGING,
31 ATTR_SUPPORTED_FEATURES,
33 ATTR_UNIT_OF_MEASUREMENT,
48 EventStateChangedData,
52 callback
as ha_callback,
67 CHAR_HARDWARE_REVISION,
68 CHAR_STATUS_LOW_BATTERY,
70 CONF_LINKED_BATTERY_CHARGING_SENSOR,
71 CONF_LINKED_BATTERY_SENSOR,
72 CONF_LOW_BATTERY_THRESHOLD,
73 DEFAULT_LOW_BATTERY_THRESHOLD,
75 EVENT_HOMEKIT_CHANGED,
80 MAX_MANUFACTURER_LENGTH,
86 SIGNAL_RELOAD_ENTITIES,
94 from .iidmanager
import AccessoryIIDStorage
96 accessory_friendly_name,
97 async_dismiss_setup_message,
98 async_show_setup_message,
99 cleanup_name_for_homekit,
102 validate_media_player_features,
105 _LOGGER = logging.getLogger(__name__)
107 TYPE_FAUCET:
"ValveSwitch",
108 TYPE_OUTLET:
"Outlet",
109 TYPE_SHOWER:
"ValveSwitch",
110 TYPE_SPRINKLER:
"ValveSwitch",
111 TYPE_SWITCH:
"Switch",
112 TYPE_VALVE:
"ValveSwitch",
114 TYPES: Registry[str, type[HomeAccessory]] =
Registry()
116 RELOAD_ON_CHANGE_ATTRS = (
117 ATTR_SUPPORTED_FEATURES,
119 ATTR_UNIT_OF_MEASUREMENT,
124 hass: HomeAssistant, driver: HomeDriver, state: State, aid: int |
None, config: dict
125 ) -> HomeAccessory |
None:
126 """Take state and return an accessory object if supported."""
130 'The entity "%s" is not supported, since it '
131 "generates an invalid aid, please change it"
138 name = config.get(CONF_NAME, state.name)
139 features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
141 if state.domain ==
"alarm_control_panel":
142 a_type =
"SecuritySystem"
144 elif state.domain
in (
"binary_sensor",
"device_tracker",
"person"):
145 a_type =
"BinarySensor"
147 elif state.domain ==
"climate":
148 a_type =
"Thermostat"
150 elif state.domain ==
"cover":
151 device_class = state.attributes.get(ATTR_DEVICE_CLASS)
154 CoverDeviceClass.GARAGE,
155 CoverDeviceClass.GATE,
156 )
and features & (CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE):
157 a_type =
"GarageDoorOpener"
159 device_class == CoverDeviceClass.WINDOW
160 and features & CoverEntityFeature.SET_POSITION
164 device_class == CoverDeviceClass.DOOR
165 and features & CoverEntityFeature.SET_POSITION
168 elif features & CoverEntityFeature.SET_POSITION:
169 a_type =
"WindowCovering"
170 elif features & (CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE):
171 a_type =
"WindowCoveringBasic"
172 elif features & CoverEntityFeature.SET_TILT_POSITION:
177 a_type =
"WindowCovering"
179 elif state.domain ==
"fan":
182 elif state.domain ==
"humidifier":
183 a_type =
"HumidifierDehumidifier"
185 elif state.domain ==
"light":
188 elif state.domain ==
"lock":
191 elif state.domain ==
"media_player":
192 device_class = state.attributes.get(ATTR_DEVICE_CLASS)
193 feature_list = config.get(CONF_FEATURE_LIST, [])
195 if device_class == MediaPlayerDeviceClass.RECEIVER:
196 a_type =
"ReceiverMediaPlayer"
197 elif device_class == MediaPlayerDeviceClass.TV:
198 a_type =
"TelevisionMediaPlayer"
199 elif validate_media_player_features(state, feature_list):
200 a_type =
"MediaPlayer"
202 elif state.domain ==
"sensor":
203 device_class = state.attributes.get(ATTR_DEVICE_CLASS)
204 unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
206 if device_class == SensorDeviceClass.TEMPERATURE
or unit
in (
207 UnitOfTemperature.CELSIUS,
208 UnitOfTemperature.FAHRENHEIT,
210 a_type =
"TemperatureSensor"
211 elif device_class == SensorDeviceClass.HUMIDITY
and unit == PERCENTAGE:
212 a_type =
"HumiditySensor"
214 device_class == SensorDeviceClass.PM10
215 or SensorDeviceClass.PM10
in state.entity_id
217 a_type =
"PM10Sensor"
219 device_class == SensorDeviceClass.PM25
220 or SensorDeviceClass.PM25
in state.entity_id
222 a_type =
"PM25Sensor"
223 elif device_class == SensorDeviceClass.NITROGEN_DIOXIDE:
224 a_type =
"NitrogenDioxideSensor"
225 elif device_class == SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS:
226 a_type =
"VolatileOrganicCompoundsSensor"
228 device_class == SensorDeviceClass.GAS
229 or SensorDeviceClass.GAS
in state.entity_id
231 a_type =
"AirQualitySensor"
232 elif device_class == SensorDeviceClass.CO:
233 a_type =
"CarbonMonoxideSensor"
234 elif device_class == SensorDeviceClass.CO2
or "co2" in state.entity_id:
235 a_type =
"CarbonDioxideSensor"
236 elif device_class == SensorDeviceClass.ILLUMINANCE
or unit == LIGHT_LUX:
237 a_type =
"LightSensor"
239 elif state.domain ==
"switch":
240 if switch_type := config.get(CONF_TYPE):
241 a_type = SWITCH_TYPES[switch_type]
242 elif state.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET:
247 elif state.domain ==
"valve":
250 elif state.domain ==
"vacuum":
253 elif state.domain ==
"remote" and features & RemoteEntityFeature.ACTIVITY:
254 a_type =
"ActivityRemote"
256 elif state.domain
in (
267 elif state.domain
in (
"input_select",
"select"):
268 a_type =
"SelectSwitch"
270 elif state.domain ==
"water_heater":
271 a_type =
"WaterHeater"
273 elif state.domain ==
"camera":
279 _LOGGER.debug(
'Add "%s" as "%s"', state.entity_id, a_type)
280 return TYPES[a_type](hass, driver, name, state.entity_id, aid, config)
284 """Adapter class for Accessory."""
295 config: dict[str, Any],
297 category: int = CATEGORY_OTHER,
298 device_id: str |
None =
None,
301 """Initialize a Accessory object."""
304 display_name=cleanup_name_for_homekit(name),
313 self.
device_iddevice_id: str |
None = device_id
314 serial_number = device_id
318 serial_number = entity_id
321 if self.
configconfig.
get(ATTR_MANUFACTURER)
is not None:
322 manufacturer =
str(self.
configconfig[ATTR_MANUFACTURER])
323 elif self.
configconfig.
get(ATTR_INTEGRATION)
is not None:
324 manufacturer = self.
configconfig[ATTR_INTEGRATION].replace(
"_",
" ").title()
326 manufacturer = f
"{MANUFACTURER} {domain}".title()
328 manufacturer = MANUFACTURER
329 if self.
configconfig.
get(ATTR_MODEL)
is not None:
330 model =
str(self.
configconfig[ATTR_MODEL])
332 model = domain.title()
336 if self.
configconfig.
get(ATTR_SW_VERSION)
is not None:
337 sw_version = format_version(self.
configconfig[ATTR_SW_VERSION])
338 if sw_version
is None:
339 sw_version = format_version(__version__)
340 assert sw_version
is not None
342 if self.
configconfig.
get(ATTR_HW_VERSION)
is not None:
343 hw_version = format_version(self.
configconfig[ATTR_HW_VERSION])
345 self.set_info_service(
346 manufacturer=manufacturer[:MAX_MANUFACTURER_LENGTH],
347 model=model[:MAX_MODEL_LENGTH],
348 serial_number=serial_number[:MAX_SERIAL_LENGTH],
349 firmware_revision=sw_version[:MAX_VERSION_LENGTH],
352 serv_info = self.get_service(SERV_ACCESSORY_INFO)
353 char = self.driver.loader.get_char(CHAR_HARDWARE_REVISION)
354 serv_info.add_characteristic(char)
355 serv_info.configure_char(
356 CHAR_HARDWARE_REVISION, value=hw_version[:MAX_VERSION_LENGTH]
359 self.iid_manager.assign(char)
364 self._subscriptions: list[CALLBACK_TYPE] = []
374 CONF_LINKED_BATTERY_CHARGING_SENSOR
377 CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD
380 """Add battery service if available"""
383 assert state
is not None
384 entity_attributes = state.attributes
385 battery_found = entity_attributes.get(ATTR_BATTERY_LEVEL)
389 if state
is not None:
390 battery_found = state.state
393 "%s: Battery sensor state missing: %s",
399 if not battery_found:
402 _LOGGER.debug(
"%s: Found battery level", self.
entity_identity_id)
409 "%s: Battery charging binary_sensor state missing: %s",
414 _LOGGER.debug(
"%s: Found battery charging", self.
entity_identity_id)
416 serv_battery = self.add_preload_service(SERV_BATTERY_SERVICE)
417 self.
_char_battery_char_battery = serv_battery.configure_char(CHAR_BATTERY_LEVEL, value=0)
419 CHAR_CHARGING_STATE, value=HK_NOT_CHARGABLE
422 CHAR_STATUS_LOW_BATTERY, value=0
426 """Update the available property based on the state."""
427 self.
_available_available = new_state
is not None and new_state.state != STATE_UNAVAILABLE
431 """Return if accessory is available."""
437 """Handle accessory driver started event."""
438 if state := self.
hasshass.states.get(self.
entity_identity_id):
441 self._subscriptions.append(
446 job_type=HassJobType.Callback,
450 battery_charging_state =
None
453 linked_battery_sensor_state := self.
hasshass.states.get(
457 battery_state = linked_battery_sensor_state.state
458 battery_charging_state = linked_battery_sensor_state.attributes.get(
459 ATTR_BATTERY_CHARGING
461 self._subscriptions.append(
466 job_type=HassJobType.Callback,
469 elif state
is not None:
470 battery_state = state.attributes.get(ATTR_BATTERY_LEVEL)
473 battery_charging_state = state
and state.state == STATE_ON
474 self._subscriptions.append(
479 job_type=HassJobType.Callback,
482 elif battery_charging_state
is None and state
is not None:
483 battery_charging_state = state.attributes.get(ATTR_BATTERY_CHARGING)
485 if battery_state
is not None or battery_charging_state
is not None:
490 self, event: Event[EventStateChangedData]
492 """Handle state change event listener callback."""
493 new_state = event.data[
"new_state"]
494 old_state = event.data[
"old_state"]
499 and STATE_UNAVAILABLE
not in (old_state.state, new_state.state)
501 old_attributes = old_state.attributes
502 new_attributes = new_state.attributes
504 if old_attributes.get(attr) != new_attributes.get(attr):
506 "%s: Reloading HomeKit accessory since %s has changed from %s -> %s",
509 old_attributes.get(attr),
510 new_attributes.get(attr),
518 """Handle state change listener callback."""
519 _LOGGER.debug(
"New_state: %s", new_state)
522 if new_state
is None or new_state.state
in (STATE_UNAVAILABLE, STATE_UNKNOWN):
525 battery_charging_state =
None
528 and ATTR_BATTERY_LEVEL
in new_state.attributes
530 battery_state = new_state.attributes.get(ATTR_BATTERY_LEVEL)
533 and ATTR_BATTERY_CHARGING
in new_state.attributes
535 battery_charging_state = new_state.attributes.get(ATTR_BATTERY_CHARGING)
536 if battery_state
is not None or battery_charging_state
is not None:
542 self, event: Event[EventStateChangedData]
544 """Handle linked battery sensor state change listener callback."""
545 if (new_state := event.data[
"new_state"])
is None:
548 battery_charging_state =
None
550 battery_charging_state = new_state.attributes.get(ATTR_BATTERY_CHARGING)
555 self, event: Event[EventStateChangedData]
557 """Handle linked battery charging sensor state change listener callback."""
558 if (new_state := event.data[
"new_state"])
is None:
564 """Update battery service if available.
566 Only call this function if self._support_battery_level is True.
572 battery_level = convert_to_float(battery_level)
573 if battery_level
is not None:
580 "%s: Updated battery level to %d", self.
entity_identity_id, battery_level
584 if battery_charging
is None or not self.
_char_charging_char_charging:
587 hk_charging = HK_CHARGING
if battery_charging
else HK_NOT_CHARGING
591 "%s: Updated battery charging to %d", self.
entity_identity_id, hk_charging
596 """Handle state change to update HomeKit value.
598 Overridden by accessory types.
600 raise NotImplementedError
607 service_data: dict[str, Any] |
None,
608 value: Any |
None =
None,
610 """Fire event and call service for changes from HomeKit."""
613 ATTR_DISPLAY_NAME: self.display_name,
614 ATTR_SERVICE: service,
619 self.
hasshass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data, context=context)
620 self.
hasshass.async_create_task(
621 self.
hasshass.services.async_call(
622 domain, service, service_data, context=context
629 """Reload and recreate an accessory and update the c# value in the mDNS record."""
632 SIGNAL_RELOAD_ENTITIES.format(self.driver.entry_id),
638 """Cancel any subscriptions when the bridge is stopped."""
639 while self._subscriptions:
640 self._subscriptions.pop(0)()
643 """Stop the accessory.
645 This is overrides the parent class to call async_stop
646 since pyhap will call this function to stop the accessory
647 but we want to use our async_stop method since we need
648 it to be a callback to avoid races in reloading accessories.
654 """Adapter class for Bridge."""
656 def __init__(self, hass: HomeAssistant, driver: HomeDriver, name: str) ->
None:
657 """Initialize a Bridge object."""
659 self.set_info_service(
660 firmware_revision=format_version(__version__),
661 manufacturer=MANUFACTURER,
663 serial_number=BRIDGE_SERIAL_NUMBER,
668 """Prevent print of pyhap setup message to terminal."""
671 """Get snapshot from accessory if supported."""
672 if (acc := self.accessories.
get(info[
"aid"]))
is None:
673 raise ValueError(
"Requested snapshot for missing accessory")
674 if not hasattr(acc,
"async_get_snapshot"):
676 "Got a request for snapshot, but the Accessory "
677 'does not define a "async_get_snapshot" method'
679 return cast(bytes, await acc.async_get_snapshot(info))
683 """Adapter class for AccessoryDriver."""
691 iid_storage: AccessoryIIDStorage,
694 """Initialize a AccessoryDriver object."""
697 super().
__init__(**kwargs, mac=EMPTY_MAC)
706 self, client_username_bytes: bytes, client_public: str, client_permissions: int
708 """Override super function to dismiss setup message if paired."""
709 success = super().
pair(client_username_bytes, client_public, client_permissions)
711 async_dismiss_setup_message(self.
hasshass, self.
entry_identry_id)
712 return cast(bool, success)
715 def unpair(self, client_uuid: UUID) ->
None:
716 """Override super function to show setup message if unpaired."""
717 super().
unpair(client_uuid)
719 if self.state.paired:
722 async_show_setup_message(
725 accessory_friendly_name(self.
_entry_title_entry_title, self.accessory),
727 self.accessory.xhm_uri(),
732 """IID Manager that remembers IIDs between restarts."""
734 def __init__(self, iid_storage: AccessoryIIDStorage) ->
None:
735 """Initialize a IIDManager object."""
740 """Get IID for object."""
742 if isinstance(obj, Characteristic):
743 service: Service = obj.service
744 iid = self.
_iid_storage_iid_storage.get_or_allocate_iid(
745 aid, service.type_id, service.unique_id, obj.type_id, obj.unique_id
748 iid = self.
_iid_storage_iid_storage.get_or_allocate_iid(
749 aid, obj.type_id, obj.unique_id,
None,
None
753 f
"Cannot assign IID {iid} to {obj} as it is already in use by:"
None _update_available_from_state(self, State|None new_state)
None async_call_service(self, str domain, str service, dict[str, Any]|None service_data, Any|None value=None)
None async_update_event_state_callback(self, Event[EventStateChangedData] event)
linked_battery_charging_sensor
None async_update_linked_battery_charging_callback(self, Event[EventStateChangedData] event)
None async_update_linked_battery_callback(self, Event[EventStateChangedData] event)
None __init__(self, HomeAssistant hass, HomeDriver driver, str name, str entity_id, int aid, dict[str, Any] config, *Any args, int category=CATEGORY_OTHER, str|None device_id=None, **Any kwargs)
None async_update_state_callback(self, State|None new_state)
None async_update_battery(self, Any battery_level, Any battery_charging)
None async_update_state(self, State new_state)
None __init__(self, HomeAssistant hass, HomeDriver driver, str name)
bytes async_get_snapshot(self, dict info)
bool pair(self, bytes client_username_bytes, str client_public, int client_permissions)
None unpair(self, UUID client_uuid)
None __init__(self, HomeAssistant hass, str entry_id, str bridge_name, str entry_title, AccessoryIIDStorage iid_storage, **Any kwargs)
int get_iid_for_obj(self, Characteristic|Service obj)
None __init__(self, AccessoryIIDStorage iid_storage)
web.Response get(self, web.Request request, str config_key)
HomeAccessory|None get_accessory(HomeAssistant hass, HomeDriver driver, State state, int|None aid, dict config)
tuple[str, str] split_entity_id(str entity_id)
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
CALLBACK_TYPE async_track_state_change_event(HomeAssistant hass, str|Iterable[str] entity_ids, Callable[[Event[EventStateChangedData]], Any] action, HassJobType|None job_type=None)