From 8a8ffa1c0844106a8827dd28b1d42792b366c5ee Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 4 Jan 2022 11:45:40 -0600 Subject: [PATCH] Add support for Sonos microphone binary_sensor (#63097) Co-authored-by: J. Nick Koston --- .../components/sonos/binary_sensor.py | 39 +++++++++++++++-- homeassistant/components/sonos/const.py | 1 + homeassistant/components/sonos/speaker.py | 42 ++++++++++++------- tests/components/sonos/conftest.py | 7 ++-- tests/components/sonos/test_sensor.py | 35 ++++++++++++---- 5 files changed, 93 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index 5c299c07562..008e0b2cf57 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -14,7 +14,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import SONOS_CREATE_BATTERY +from .const import SONOS_CREATE_BATTERY, SONOS_CREATE_MIC_SENSOR from .entity import SonosEntity from .speaker import SonosSpeaker @@ -30,13 +30,25 @@ async def async_setup_entry( ) -> None: """Set up Sonos from a config entry.""" - async def _async_create_entity(speaker: SonosSpeaker) -> None: + async def _async_create_battery_entity(speaker: SonosSpeaker) -> None: _LOGGER.debug("Creating battery binary_sensor on %s", speaker.zone_name) entity = SonosPowerEntity(speaker) async_add_entities([entity]) + async def _async_create_mic_entity(speaker: SonosSpeaker) -> None: + _LOGGER.debug("Creating microphone binary_sensor on %s", speaker.zone_name) + async_add_entities([SonosMicrophoneSensorEntity(speaker)]) + config_entry.async_on_unload( - async_dispatcher_connect(hass, SONOS_CREATE_BATTERY, _async_create_entity) + async_dispatcher_connect( + hass, SONOS_CREATE_BATTERY, _async_create_battery_entity + ) + ) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, SONOS_CREATE_MIC_SENSOR, _async_create_mic_entity + ) ) @@ -72,3 +84,24 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity): def available(self) -> bool: """Return whether this device is available.""" return self.speaker.available and (self.speaker.charging is not None) + + +class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity): + """Representation of a Sonos microphone sensor entity.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:microphone" + + def __init__(self, speaker: SonosSpeaker) -> None: + """Initialize the microphone binary sensor entity.""" + super().__init__(speaker) + self._attr_unique_id = f"{self.soco.uid}-microphone" + self._attr_name = f"{self.speaker.zone_name} Microphone" + + async def _async_poll(self) -> None: + """Stub for abstract class implementation. Not a pollable attribute.""" + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + return self.speaker.mic_enabled diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index bffc6425928..e9ea02b335c 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -146,6 +146,7 @@ SONOS_CHECK_ACTIVITY = "sonos_check_activity" SONOS_CREATE_ALARM = "sonos_create_alarm" SONOS_CREATE_AUDIO_FORMAT_SENSOR = "sonos_create_audio_format_sensor" SONOS_CREATE_BATTERY = "sonos_create_battery" +SONOS_CREATE_MIC_SENSOR = "sonos_create_mic_sensor" SONOS_CREATE_SWITCHES = "sonos_create_switches" SONOS_CREATE_LEVELS = "sonos_create_levels" SONOS_CREATE_MEDIA_PLAYER = "sonos_create_media_player" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 385af8224a6..d0a16bd10e6 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -46,6 +46,7 @@ from .const import ( SONOS_CREATE_BATTERY, SONOS_CREATE_LEVELS, SONOS_CREATE_MEDIA_PLAYER, + SONOS_CREATE_MIC_SENSOR, SONOS_CREATE_SWITCHES, SONOS_POLL_UPDATE, SONOS_REBOOTED, @@ -194,6 +195,7 @@ class SonosSpeaker: # Misc features self.buttons_enabled: bool | None = None + self.mic_enabled: bool | None = None self.status_light: bool | None = None # Grouping @@ -435,21 +437,15 @@ class SonosSpeaker: async def async_update_device_properties(self, event: SonosEvent) -> None: """Update device properties from an event.""" + if "mic_enabled" in event.variables: + mic_exists = self.mic_enabled is not None + self.mic_enabled = bool(int(event.variables["mic_enabled"])) + if not mic_exists: + async_dispatcher_send(self.hass, SONOS_CREATE_MIC_SENSOR, self) + if more_info := event.variables.get("more_info"): - battery_dict = dict(x.split(":") for x in more_info.split(",")) - for unused in UNUSED_DEVICE_KEYS: - battery_dict.pop(unused, None) - if not battery_dict: - return - if "BattChg" not in battery_dict: - _LOGGER.debug( - "Unknown device properties update for %s (%s), please report an issue: '%s'", - self.zone_name, - self.model_name, - more_info, - ) - return - await self.async_update_battery_info(battery_dict) + await self.async_update_battery_info(more_info) + self.async_write_entity_states() @callback @@ -559,8 +555,22 @@ class SonosSpeaker: # # Battery management # - async def async_update_battery_info(self, battery_dict: dict[str, Any]) -> None: - """Update battery info using the decoded SonosEvent.""" + async def async_update_battery_info(self, more_info: str) -> None: + """Update battery info using a SonosEvent payload value.""" + battery_dict = dict(x.split(":") for x in more_info.split(",")) + for unused in UNUSED_DEVICE_KEYS: + battery_dict.pop(unused, None) + if not battery_dict: + return + if "BattChg" not in battery_dict: + _LOGGER.debug( + "Unknown device properties update for %s (%s), please report an issue: '%s'", + self.zone_name, + self.model_name, + more_info, + ) + return + self._last_battery_event = dt_util.utcnow() is_charging = EVENT_CHARGING[battery_dict["BattChg"]] diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index e6b48ff9a26..01c7a8948af 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -222,11 +222,12 @@ def battery_info_fixture(): } -@pytest.fixture(name="battery_event") -def battery_event_fixture(soco): - """Create battery_event fixture.""" +@pytest.fixture(name="device_properties_event") +def device_properties_event_fixture(soco): + """Create device_properties_event fixture.""" variables = { "zone_name": "Zone A", + "mic_enabled": "1", "more_info": "BattChg:NOT_CHARGING,RawBattPct:100,BattPct:100,BattTmp:25", } return SonosMockEvent(soco, soco.deviceProperties, variables) diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 633bf750962..8fb75789149 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -45,7 +45,7 @@ async def test_battery_attributes(hass, async_autosetup_sonos, soco): ) -async def test_battery_on_S1(hass, async_setup_sonos, soco, battery_event): +async def test_battery_on_s1(hass, async_setup_sonos, soco, device_properties_event): """Test battery state updates on a Sonos S1 device.""" soco.get_battery_info.return_value = {} @@ -60,7 +60,7 @@ async def test_battery_on_S1(hass, async_setup_sonos, soco, battery_event): assert "binary_sensor.zone_a_power" not in entity_registry.entities # Update the speaker with a callback event - sub_callback(battery_event) + sub_callback(device_properties_event) await hass.async_block_till_done() battery = entity_registry.entities["sensor.zone_a_battery"] @@ -74,7 +74,7 @@ async def test_battery_on_S1(hass, async_setup_sonos, soco, battery_event): async def test_device_payload_without_battery( - hass, async_setup_sonos, soco, battery_event, caplog + hass, async_setup_sonos, soco, device_properties_event, caplog ): """Test device properties event update without battery info.""" soco.get_battery_info.return_value = None @@ -85,16 +85,16 @@ async def test_device_payload_without_battery( sub_callback = subscription.callback bad_payload = "BadKey:BadValue" - battery_event.variables["more_info"] = bad_payload + device_properties_event.variables["more_info"] = bad_payload - sub_callback(battery_event) + sub_callback(device_properties_event) await hass.async_block_till_done() assert bad_payload in caplog.text async def test_device_payload_without_battery_and_ignored_keys( - hass, async_setup_sonos, soco, battery_event, caplog + hass, async_setup_sonos, soco, device_properties_event, caplog ): """Test device properties event update without battery info and ignored keys.""" soco.get_battery_info.return_value = None @@ -105,18 +105,35 @@ async def test_device_payload_without_battery_and_ignored_keys( sub_callback = subscription.callback ignored_payload = "SPID:InCeiling,TargetRoomName:Bouncy House" - battery_event.variables["more_info"] = ignored_payload + device_properties_event.variables["more_info"] = ignored_payload - sub_callback(battery_event) + sub_callback(device_properties_event) await hass.async_block_till_done() assert ignored_payload not in caplog.text async def test_audio_input_sensor(hass, async_autosetup_sonos, soco): - """Test sonos device with battery state.""" + """Test audio input sensor.""" entity_registry = ent_reg.async_get(hass) audio_input_sensor = entity_registry.entities["sensor.zone_a_audio_input_format"] audio_input_state = hass.states.get(audio_input_sensor.entity_id) assert audio_input_state.state == "Dolby 5.1" + + +async def test_microphone_binary_sensor( + hass, async_autosetup_sonos, soco, device_properties_event +): + """Test microphone binary sensor.""" + entity_registry = ent_reg.async_get(hass) + assert "binary_sensor.zone_a_microphone" not in entity_registry.entities + + # Update the speaker with a callback event + subscription = soco.deviceProperties.subscribe.return_value + subscription.callback(device_properties_event) + await hass.async_block_till_done() + + mic_binary_sensor = entity_registry.entities["binary_sensor.zone_a_microphone"] + mic_binary_sensor_state = hass.states.get(mic_binary_sensor.entity_id) + assert mic_binary_sensor_state.state == STATE_ON