Add support for Sonos microphone binary_sensor (#63097)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
jjlawren 2022-01-04 11:45:40 -06:00 committed by GitHub
parent ec75b0caf0
commit 8a8ffa1c08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 93 additions and 31 deletions

View file

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

View file

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

View file

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

View file

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

View file

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