From 0a9b373edb5dbfd2a9c6550ba0359d641feafc82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 May 2020 09:09:48 -0500 Subject: [PATCH] Show device details in homekit accessory service info (#35100) * Show device info in homekit accessory service info * fix conflict --- homeassistant/components/homekit/__init__.py | 59 ++++++++++++++----- .../components/homekit/accessories.py | 29 +++++++-- homeassistant/components/homekit/const.py | 5 ++ .../components/homekit/type_cameras.py | 39 +++++++++--- homeassistant/components/homekit/util.py | 3 +- tests/components/homekit/test_accessories.py | 52 +++++++++++++--- .../homekit/test_get_accessories.py | 9 ++- tests/components/homekit/test_homekit.py | 16 +++-- 8 files changed, 168 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 45d4171d7af..26ac841b802 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -34,6 +34,7 @@ from homeassistant.helpers.entityfilter import ( CONF_INCLUDE_ENTITIES, convert_filter, ) +from homeassistant.loader import async_get_integration from homeassistant.util import get_local_ip from .accessories import get_accessory @@ -41,6 +42,10 @@ from .aidmanager import AccessoryAidStorage from .const import ( AID_STORAGE, ATTR_DISPLAY_NAME, + ATTR_INTERGRATION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SOFTWARE_VERSION, ATTR_VALUE, BRIDGE_NAME, CONF_ADVERTISE_IP, @@ -200,7 +205,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): aid_storage = AccessoryAidStorage(hass, entry.entry_id) await aid_storage.async_initialize() - # These are yaml only + # ip_address and advertise_ip are yaml only ip_address = conf.get(CONF_IP_ADDRESS) advertise_ip = conf.get(CONF_ADVERTISE_IP) @@ -494,6 +499,7 @@ class HomeKit: self.status = STATUS_WAIT ent_reg = await entity_registry.async_get_registry(self.hass) + dev_reg = await device_registry.async_get_registry(self.hass) device_lookup = ent_reg.async_get_device_class_lookup( { @@ -507,16 +513,24 @@ class HomeKit: if not self._filter(state.entity_id): continue - self._async_configure_linked_battery_sensors(ent_reg, device_lookup, state) + ent_reg_ent = ent_reg.async_get(state.entity_id) + if ent_reg_ent: + await self._async_set_device_info_attributes( + ent_reg_ent, dev_reg, state.entity_id + ) + self._async_configure_linked_battery_sensors( + ent_reg_ent, device_lookup, state + ) + bridged_states.append(state) + self._async_register_bridge(dev_reg) await self.hass.async_add_executor_job(self._start, bridged_states) - await self._async_register_bridge() - async def _async_register_bridge(self): + @callback + def _async_register_bridge(self, dev_reg): """Register the bridge as a device so homekit_controller and exclude it from discovery.""" - registry = await device_registry.async_get_registry(self.hass) - registry.async_get_or_create( + dev_reg.async_get_or_create( config_entry_id=self._entry_id, connections={ (device_registry.CONNECTION_NETWORK_MAC, self.driver.state.mac) @@ -567,21 +581,21 @@ class HomeKit: self.hass.add_job(self.driver.stop) @callback - def _async_configure_linked_battery_sensors(self, ent_reg, device_lookup, state): - entry = ent_reg.async_get(state.entity_id) - + def _async_configure_linked_battery_sensors( + self, ent_reg_ent, device_lookup, state + ): if ( - entry is None - or entry.device_id is None - or entry.device_id not in device_lookup - or entry.device_class + ent_reg_ent is None + or ent_reg_ent.device_id is None + or ent_reg_ent.device_id not in device_lookup + or ent_reg_ent.device_class in (DEVICE_CLASS_BATTERY_CHARGING, DEVICE_CLASS_BATTERY) ): return if ATTR_BATTERY_CHARGING not in state.attributes: battery_charging_binary_sensor_entity_id = device_lookup[ - entry.device_id + ent_reg_ent.device_id ].get(("binary_sensor", DEVICE_CLASS_BATTERY_CHARGING)) if battery_charging_binary_sensor_entity_id: self._config.setdefault(state.entity_id, {}).setdefault( @@ -590,7 +604,7 @@ class HomeKit: ) if ATTR_BATTERY_LEVEL not in state.attributes: - battery_sensor_entity_id = device_lookup[entry.device_id].get( + battery_sensor_entity_id = device_lookup[ent_reg_ent.device_id].get( ("sensor", DEVICE_CLASS_BATTERY) ) if battery_sensor_entity_id: @@ -598,6 +612,21 @@ class HomeKit: CONF_LINKED_BATTERY_SENSOR, battery_sensor_entity_id ) + async def _async_set_device_info_attributes(self, ent_reg_ent, dev_reg, entity_id): + """Set attributes that will be used for homekit device info.""" + ent_cfg = self._config.setdefault(entity_id, {}) + if ent_reg_ent.device_id: + dev_reg_ent = dev_reg.async_get(ent_reg_ent.device_id) + if dev_reg_ent.manufacturer: + ent_cfg[ATTR_MANUFACTURER] = dev_reg_ent.manufacturer + if dev_reg_ent.model: + ent_cfg[ATTR_MODEL] = dev_reg_ent.model + if dev_reg_ent.sw_version: + ent_cfg[ATTR_SOFTWARE_VERSION] = dev_reg_ent.sw_version + if ATTR_MANUFACTURER not in ent_cfg: + integration = await async_get_integration(self.hass, ent_reg_ent.platform) + ent_cfg[ATTR_INTERGRATION] = integration.name + class HomeKitPairingQRView(HomeAssistantView): """Display the homekit pairing code at a protected url.""" diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 1cf549437b8..367f43d9560 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -40,6 +40,10 @@ from homeassistant.util.decorator import Registry from .const import ( ATTR_DISPLAY_NAME, + ATTR_INTERGRATION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SOFTWARE_VERSION, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, @@ -235,15 +239,32 @@ class HomeAccessory(Accessory): ): """Initialize a Accessory object.""" super().__init__(driver=driver, display_name=name, aid=aid, *args, **kwargs) - model = split_entity_id(entity_id)[0].replace("_", " ").title() + self.config = config or {} + domain = split_entity_id(entity_id)[0].replace("_", " ") + + if ATTR_MANUFACTURER in self.config: + manufacturer = self.config[ATTR_MANUFACTURER] + elif ATTR_INTERGRATION in self.config: + manufacturer = self.config[ATTR_INTERGRATION].replace("_", " ").title() + else: + manufacturer = f"{MANUFACTURER} {domain}".title() + if ATTR_MODEL in self.config: + model = self.config[ATTR_MODEL] + else: + model = domain.title() + if ATTR_SOFTWARE_VERSION in self.config: + sw_version = self.config[ATTR_SOFTWARE_VERSION] + else: + sw_version = __version__ + self.set_info_service( - firmware_revision=__version__, - manufacturer=MANUFACTURER, + manufacturer=manufacturer, model=model, serial_number=entity_id, + firmware_revision=sw_version, ) + self.category = category - self.config = config or {} self.entity_id = entity_id self.hass = hass self.debounce = {} diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 7d6ce40e85a..3291fab7a30 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -23,6 +23,10 @@ AUDIO_CODEC_COPY = "copy" # #### Attributes #### ATTR_DISPLAY_NAME = "display_name" ATTR_VALUE = "value" +ATTR_INTERGRATION = "platform" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MODEL = "model" +ATTR_SOFTWARE_VERSION = "sw_version" # #### Config #### CONF_ADVERTISE_IP = "advertise_ip" @@ -50,6 +54,7 @@ CONF_VIDEO_MAP = "video_map" CONF_VIDEO_PACKET_SIZE = "video_packet_size" # #### Config Defaults #### +DEFAULT_SUPPORT_AUDIO = False DEFAULT_AUDIO_CODEC = AUDIO_CODEC_OPUS DEFAULT_AUDIO_MAP = "0:a:0" DEFAULT_AUDIO_PACKET_SIZE = 188 diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index a3ce5fb1400..c4e52f07832 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -33,10 +33,20 @@ from .const import ( CONF_VIDEO_CODEC, CONF_VIDEO_MAP, CONF_VIDEO_PACKET_SIZE, + DEFAULT_AUDIO_CODEC, + DEFAULT_AUDIO_MAP, + DEFAULT_AUDIO_PACKET_SIZE, + DEFAULT_MAX_FPS, + DEFAULT_MAX_HEIGHT, + DEFAULT_MAX_WIDTH, + DEFAULT_SUPPORT_AUDIO, + DEFAULT_VIDEO_CODEC, + DEFAULT_VIDEO_MAP, + DEFAULT_VIDEO_PACKET_SIZE, SERV_CAMERA_RTP_STREAM_MANAGEMENT, ) from .img_util import scale_jpeg_camera_image -from .util import CAMERA_SCHEMA, pid_is_alive +from .util import pid_is_alive _LOGGER = logging.getLogger(__name__) @@ -94,6 +104,19 @@ FFMPEG_WATCHER = "ffmpeg_watcher" FFMPEG_PID = "ffmpeg_pid" SESSION_ID = "session_id" +CONFIG_DEFAULTS = { + CONF_SUPPORT_AUDIO: DEFAULT_SUPPORT_AUDIO, + CONF_MAX_WIDTH: DEFAULT_MAX_WIDTH, + CONF_MAX_HEIGHT: DEFAULT_MAX_HEIGHT, + CONF_MAX_FPS: DEFAULT_MAX_FPS, + CONF_AUDIO_CODEC: DEFAULT_AUDIO_CODEC, + CONF_AUDIO_MAP: DEFAULT_AUDIO_MAP, + CONF_VIDEO_MAP: DEFAULT_VIDEO_MAP, + CONF_VIDEO_CODEC: DEFAULT_VIDEO_CODEC, + CONF_AUDIO_PACKET_SIZE: DEFAULT_AUDIO_PACKET_SIZE, + CONF_VIDEO_PACKET_SIZE: DEFAULT_VIDEO_PACKET_SIZE, +} + @TYPES.register("Camera") class Camera(HomeAccessory, PyhapCamera): @@ -104,11 +127,13 @@ class Camera(HomeAccessory, PyhapCamera): self._ffmpeg = hass.data[DATA_FFMPEG] self._cur_session = None self._camera = hass.data[DOMAIN_CAMERA] - config_w_defaults = CAMERA_SCHEMA(config) + for config_key in CONFIG_DEFAULTS: + if config_key not in config: + config[config_key] = CONFIG_DEFAULTS[config_key] - max_fps = config_w_defaults[CONF_MAX_FPS] - max_width = config_w_defaults[CONF_MAX_WIDTH] - max_height = config_w_defaults[CONF_MAX_HEIGHT] + max_fps = config[CONF_MAX_FPS] + max_width = config[CONF_MAX_WIDTH] + max_height = config[CONF_MAX_HEIGHT] resolutions = [ (w, h, fps) for w, h, fps in SLOW_RESOLUTIONS @@ -136,7 +161,7 @@ class Camera(HomeAccessory, PyhapCamera): } audio_options = {"codecs": [{"type": "OPUS", "samplerate": 24}]} - stream_address = config_w_defaults.get(CONF_STREAM_ADDRESS, get_local_ip()) + stream_address = config.get(CONF_STREAM_ADDRESS, get_local_ip()) options = { "video": video_options, @@ -151,7 +176,7 @@ class Camera(HomeAccessory, PyhapCamera): name, entity_id, aid, - config_w_defaults, + config, category=CATEGORY_CAMERA, options=options, ) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index f4943cd603f..aac0e211975 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -49,6 +49,7 @@ from .const import ( DEFAULT_MAX_FPS, DEFAULT_MAX_HEIGHT, DEFAULT_MAX_WIDTH, + DEFAULT_SUPPORT_AUDIO, DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_PACKET_SIZE, @@ -98,7 +99,7 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend( vol.Optional(CONF_AUDIO_CODEC, default=DEFAULT_AUDIO_CODEC): vol.In( VALID_AUDIO_CODECS ), - vol.Optional(CONF_SUPPORT_AUDIO, default=False): cv.boolean, + vol.Optional(CONF_SUPPORT_AUDIO, default=DEFAULT_SUPPORT_AUDIO): cv.boolean, vol.Optional(CONF_MAX_WIDTH, default=DEFAULT_MAX_WIDTH): cv.positive_int, vol.Optional(CONF_MAX_HEIGHT, default=DEFAULT_MAX_HEIGHT): cv.positive_int, vol.Optional(CONF_MAX_FPS, default=DEFAULT_MAX_FPS): cv.positive_int, diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 036d30b0edd..092c68a5480 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -14,6 +14,10 @@ from homeassistant.components.homekit.accessories import ( ) from homeassistant.components.homekit.const import ( ATTR_DISPLAY_NAME, + ATTR_INTERGRATION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SOFTWARE_VERSION, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_NAME, @@ -80,11 +84,17 @@ async def test_debounce(hass): async def test_home_accessory(hass, hk_driver): """Test HomeAccessory class.""" - entity_id = "homekit.accessory" + entity_id = "sensor.accessory" + entity_id2 = "light.accessory" + hass.states.async_set(entity_id, None) + hass.states.async_set(entity_id2, None) + await hass.async_block_till_done() - acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, None) + acc = HomeAccessory( + hass, hk_driver, "Home Accessory", entity_id, 2, {"platform": "isy994"} + ) assert acc.hass == hass assert acc.display_name == "Home Accessory" assert acc.aid == 2 @@ -93,9 +103,35 @@ async def test_home_accessory(hass, hk_driver): serv = acc.services[0] # SERV_ACCESSORY_INFO assert serv.display_name == SERV_ACCESSORY_INFO assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" - assert serv.get_characteristic(CHAR_MANUFACTURER).value == MANUFACTURER - assert serv.get_characteristic(CHAR_MODEL).value == "Homekit" - assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "homekit.accessory" + assert serv.get_characteristic(CHAR_MANUFACTURER).value == "Isy994" + assert serv.get_characteristic(CHAR_MODEL).value == "Sensor" + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "sensor.accessory" + + acc2 = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id2, 3, {}) + serv = acc2.services[0] # SERV_ACCESSORY_INFO + assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" + assert serv.get_characteristic(CHAR_MANUFACTURER).value == f"{MANUFACTURER} Light" + assert serv.get_characteristic(CHAR_MODEL).value == "Light" + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "light.accessory" + + acc3 = HomeAccessory( + hass, + hk_driver, + "Home Accessory", + entity_id2, + 3, + { + ATTR_MODEL: "Awesome", + ATTR_MANUFACTURER: "Lux Brands", + ATTR_SOFTWARE_VERSION: "0.4.3", + ATTR_INTERGRATION: "luxe", + }, + ) + serv = acc3.services[0] # SERV_ACCESSORY_INFO + assert serv.get_characteristic(CHAR_NAME).value == "Home Accessory" + assert serv.get_characteristic(CHAR_MANUFACTURER).value == "Lux Brands" + assert serv.get_characteristic(CHAR_MODEL).value == "Awesome" + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == "light.accessory" hass.states.async_set(entity_id, "on") await hass.async_block_till_done() @@ -441,9 +477,7 @@ async def test_battery_appears_after_startup(hass, hk_driver, caplog): hass.states.async_set(entity_id, None, {}) await hass.async_block_till_done() - acc = HomeAccessory( - hass, hk_driver, "Accessory without battery", entity_id, 2, None - ) + acc = HomeAccessory(hass, hk_driver, "Accessory without battery", entity_id, 2, {}) assert acc._char_battery is None with patch( @@ -469,7 +503,7 @@ async def test_call_service(hass, hk_driver, events): hass.states.async_set(entity_id, None) await hass.async_block_till_done() - acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, None) + acc = HomeAccessory(hass, hk_driver, "Home Accessory", entity_id, 2, {}) call_service = async_mock_service(hass, "cover", "open_cover") test_domain = "cover" diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index e2433d51065..11827c2ce4f 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -5,6 +5,7 @@ import homeassistant.components.climate as climate import homeassistant.components.cover as cover from homeassistant.components.homekit.accessories import TYPES, get_accessory from homeassistant.components.homekit.const import ( + ATTR_INTERGRATION, CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_FAUCET, @@ -61,10 +62,12 @@ def test_not_supported_media_player(): def test_customize_options(config, name): """Test with customized options.""" mock_type = Mock() + conf = config.copy() + conf[ATTR_INTERGRATION] = "platform_name" with patch.dict(TYPES, {"Light": mock_type}): entity_state = State("light.demo", "on") - get_accessory(None, None, entity_state, 2, config) - mock_type.assert_called_with(None, None, name, "light.demo", 2, config) + get_accessory(None, None, entity_state, 2, conf) + mock_type.assert_called_with(None, None, name, "light.demo", 2, conf) @pytest.mark.parametrize( @@ -254,7 +257,7 @@ def test_type_switches(type_name, entity_id, state, attrs, config): | vacuum.SUPPORT_RETURN_HOME }, ), - ("Switch", "vacuum.basic_vacuum", "off", {},), + ("Switch", "vacuum.basic_vacuum", "off", {}), ], ) def test_type_vacuum(type_name, entity_id, state, attrs): diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 7bdfa0b14bd..ac955418d33 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -703,25 +703,28 @@ async def test_homekit_finds_linked_batteries( config_entry.add_to_hass(hass) device_entry = device_reg.async_get_or_create( config_entry_id=config_entry.entry_id, + sw_version="0.16.0", + model="Powerwall 2", + manufacturer="Tesla", connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) binary_charging_sensor = entity_reg.async_get_or_create( "binary_sensor", - "light", + "powerwall", "battery_charging", device_id=device_entry.id, device_class=DEVICE_CLASS_BATTERY_CHARGING, ) battery_sensor = entity_reg.async_get_or_create( "sensor", - "light", + "powerwall", "battery", device_id=device_entry.id, device_class=DEVICE_CLASS_BATTERY, ) light = entity_reg.async_get_or_create( - "light", "light", "demo", device_id=device_entry.id + "light", "powerwall", "demo", device_id=device_entry.id ) hass.states.async_set( @@ -751,8 +754,11 @@ async def test_homekit_finds_linked_batteries( ANY, ANY, { - "linked_battery_charging_sensor": "binary_sensor.light_battery_charging", - "linked_battery_sensor": "sensor.light_battery", + "manufacturer": "Tesla", + "model": "Powerwall 2", + "sw_version": "0.16.0", + "linked_battery_charging_sensor": "binary_sensor.powerwall_battery_charging", + "linked_battery_sensor": "sensor.powerwall_battery", }, )