From 32413f4a7018ec58960bf8ac6c8d569bba2fd989 Mon Sep 17 00:00:00 2001 From: Jamie Magee Date: Fri, 18 Sep 2026 06:54:26 -0700 Subject: [PATCH] Bump pysnooz to 0.10.0 (#182225) --- homeassistant/components/snooz/__init__.py | 11 +++- homeassistant/components/snooz/config_flow.py | 44 +++++++------- homeassistant/components/snooz/fan.py | 4 +- homeassistant/components/snooz/manifest.json | 2 +- requirements_all.txt | 2 +- tests/components/snooz/__init__.py | 52 ++++++++-------- tests/components/snooz/test_fan.py | 60 +++++++++---------- tests/components/snooz/test_init.py | 9 +-- 8 files changed, 97 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/snooz/__init__.py b/homeassistant/components/snooz/__init__.py index d485723844f7..2aada56a8c4d 100644 --- a/homeassistant/components/snooz/__init__.py +++ b/homeassistant/components/snooz/__init__.py @@ -1,13 +1,15 @@ """The Snooz component.""" +from dataclasses import replace import logging +from pysnooz import parse_snooz_advertisement from pysnooz.device import SnoozDevice from homeassistant.components.bluetooth import ( BluetoothReachabilityIntent, async_address_reachability_diagnostics, - async_ble_device_from_address, + async_last_service_info, ) from homeassistant.const import CONF_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant @@ -25,7 +27,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> boo # transitions info logs are verbose. Only enable warnings logging.getLogger("transitions.core").setLevel(logging.WARNING) - if not (ble_device := async_ble_device_from_address(hass, address)): + if (service_info := async_last_service_info(hass, address)) is None or ( + advertisement := parse_snooz_advertisement(service_info) + ) is None: raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="device_not_found", @@ -39,7 +43,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SnoozConfigEntry) -> boo }, ) - device = SnoozDevice(ble_device, token) + ble_device = service_info.device + device = SnoozDevice(ble_device, replace(advertisement, password=token)) entry.runtime_data = SnoozConfigurationData(ble_device, device, entry.title) diff --git a/homeassistant/components/snooz/config_flow.py b/homeassistant/components/snooz/config_flow.py index ad3d8fb2718a..a3443bc0b9e9 100644 --- a/homeassistant/components/snooz/config_flow.py +++ b/homeassistant/components/snooz/config_flow.py @@ -5,7 +5,11 @@ from dataclasses import dataclass from typing import Any, override import probatio -from pysnooz.advertisement import SnoozAdvertisementData +from pysnooz import ( + SnoozAdvertisementData, + get_device_display_name, + parse_snooz_advertisement, +) from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -30,6 +34,11 @@ class DiscoveredSnooz: info: BluetoothServiceInfo device: SnoozAdvertisementData + @property + def display_name(self) -> str: + """Return the display name.""" + return get_device_display_name(self.info.name, self.info.address) + class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Snooz.""" @@ -49,8 +58,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the bluetooth discovery step.""" await self.async_set_unique_id(discovery_info.address) self._abort_if_unique_id_configured() - device = SnoozAdvertisementData() - if not device.supported(discovery_info): + if (device := parse_snooz_advertisement(discovery_info)) is None: return self.async_abort(reason="not_supported") self._discovery = DiscoveredSnooz(discovery_info, device) return await self.async_step_bluetooth_confirm() @@ -68,8 +76,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): return self._create_snooz_entry(self._discovery) self._set_confirm_only() - assert self._discovery.device.display_name - placeholders = {"name": self._discovery.device.display_name} + placeholders = {"name": self._discovery.display_name} self.context["title_placeholders"] = placeholders return self.async_show_form( step_id="bluetooth_confirm", description_placeholders=placeholders @@ -104,12 +111,9 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): address = info.address if address in configured_addresses: continue - device = SnoozAdvertisementData() - if device.supported(info): - assert device.display_name - self._discovered_devices[device.display_name] = DiscoveredSnooz( - info, device - ) + if (device := parse_snooz_advertisement(info)) is not None: + discovered = DiscoveredSnooz(info, device) + self._discovered_devices[discovered.display_name] = discovered if not self._discovered_devices: return self.async_abort(reason="no_devices_found") @@ -121,10 +125,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): # Name field is no longer allowed in config flow schemas # pylint: disable-next=home-assistant-config-flow-name-field probatio.Required(CONF_NAME): probatio.In( - [ - d.device.display_name - for d in self._discovered_devices.values() - ] + [d.display_name for d in self._discovered_devices.values()] ) } ), @@ -179,24 +180,27 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="pairing_timeout") def _create_snooz_entry(self, discovery: DiscoveredSnooz) -> ConfigFlowResult: - assert discovery.device.display_name + assert discovery.device.password is not None return self.async_create_entry( - title=discovery.device.display_name, + title=discovery.display_name, data={ CONF_ADDRESS: discovery.info.address, - CONF_TOKEN: discovery.device.pairing_token, + CONF_TOKEN: discovery.device.password, }, ) async def _async_wait_for_pairing_mode(self) -> None: """Process advertisements until pairing mode is detected.""" assert self._discovery - device = self._discovery.device def is_device_in_pairing_mode( service_info: BluetoothServiceInfo, ) -> bool: - return device.supported(service_info) and device.is_pairing + device = parse_snooz_advertisement(service_info) + if device is None or not device.is_pairing: + return False + self._discovery = DiscoveredSnooz(service_info, device) + return True await async_process_advertisements( self.hass, diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index afcea0779574..7da93242ea5d 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -5,7 +5,7 @@ from datetime import timedelta from typing import Any, override import probatio -from pysnooz.api import UnknownSnoozState +from pysnooz import UnknownSnoozState from pysnooz.commands import ( SnoozCommandData, SnoozCommandResultStatus, @@ -139,7 +139,7 @@ class SnoozFan(FanEntity, RestoreEntity): @override def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" - return not self._device.is_connected or self._device.state is UnknownSnoozState + return not self._device.is_connected or self._device.state == UnknownSnoozState @override async def async_turn_on( diff --git a/homeassistant/components/snooz/manifest.json b/homeassistant/components/snooz/manifest.json index a0a10f23a6f4..5bd9d72eb00f 100644 --- a/homeassistant/components/snooz/manifest.json +++ b/homeassistant/components/snooz/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/snooz", "integration_type": "device", "iot_class": "local_push", - "requirements": ["pysnooz==0.8.6"] + "requirements": ["pysnooz==0.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 41a558ad2dc5..e51dbc87caaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2663,7 +2663,7 @@ pysmlight==0.5.3 pysnmp==7.1.27 # homeassistant.components.snooz -pysnooz==0.8.6 +pysnooz==0.10.0 # homeassistant.components.soma pysoma==0.0.12 diff --git a/tests/components/snooz/__init__.py b/tests/components/snooz/__init__.py index 2ea719665c0a..a74b8c4c5f9b 100644 --- a/tests/components/snooz/__init__.py +++ b/tests/components/snooz/__init__.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from unittest.mock import patch -from pysnooz.commands import SnoozCommandData +from pysnooz import SnoozDeviceState, parse_snooz_advertisement, turn_off from pysnooz.device import DisconnectionReason, SnoozConnectionStatus from pysnooz.testing import MockSnoozDevice as ParentMockSnoozDevice @@ -13,12 +13,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from tests.common import MockConfigEntry -from tests.components.bluetooth import generate_ble_device +from tests.components.bluetooth import ( + generate_ble_device, + inject_bluetooth_service_info, +) TEST_ADDRESS = "00:00:00:00:AB:CD" TEST_SNOOZ_LOCAL_NAME = "Snooz-ABCD" TEST_SNOOZ_DISPLAY_NAME = "Snooz ABCD" -TEST_PAIRING_TOKEN = "deadbeef" +TEST_PAIRING_TOKEN = "deadbeefdeadbeef" NOT_SNOOZ_SERVICE_INFO = BluetoothServiceInfo( name="Definitely not snooz", @@ -34,7 +37,7 @@ SNOOZ_SERVICE_INFO_PAIRING = BluetoothServiceInfo( name=TEST_SNOOZ_LOCAL_NAME, address=TEST_ADDRESS, rssi=-63, - manufacturer_data={65552: bytes([4]) + bytes.fromhex(TEST_PAIRING_TOKEN)}, + manufacturer_data={65552: bytes([5]) + bytes.fromhex(TEST_PAIRING_TOKEN)}, service_uuids=[ "80c37f00-cc16-11e4-8830-0800200c9a66", "90759319-1668-44da-9ef3-492d593bd1e5", @@ -73,7 +76,7 @@ class MockSnoozDevice(ParentMockSnoozDevice): async def async_disconnect(self) -> None: """Disconnect from the device.""" - self._is_manually_disconnecting = True + self._expected_disconnect = True try: self._cancel_current_command() if ( @@ -92,21 +95,23 @@ class MockSnoozDevice(ParentMockSnoozDevice): self._machine.device_disconnected(reason=DisconnectionReason.USER) finally: - self._is_manually_disconnecting = False + self._expected_disconnect = False async def create_mock_snooz( connected: bool = True, - initial_state: SnoozCommandData = SnoozCommandData(on=False, volume=0), + initial_state: SnoozDeviceState = SnoozDeviceState(on=False, volume=0), ) -> MockSnoozDevice: """Create a mock device.""" - ble_device = SNOOZ_SERVICE_INFO_NOT_PAIRING - device = MockSnoozDevice(ble_device, initial_state=initial_state) + ble_device = generate_ble_device(TEST_ADDRESS, TEST_SNOOZ_LOCAL_NAME) + advertisement = parse_snooz_advertisement(SNOOZ_SERVICE_INFO_PAIRING) + assert advertisement is not None + device = MockSnoozDevice(ble_device, advertisement, initial_state=initial_state) # execute a command to initiate the connection if connected is True: - await device.async_execute_command(initial_state) + await device.async_execute_command(turn_off()) return device @@ -116,21 +121,20 @@ async def create_mock_snooz_config_entry( ) -> MockConfigEntry: """Create a mock config entry.""" - with ( - patch("homeassistant.components.snooz.SnoozDevice", return_value=device), - patch( - "homeassistant.components.snooz.async_ble_device_from_address", - return_value=generate_ble_device(device.address, device.name), - ), - ): - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_ADDRESS, - data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN}, - ) - entry.add_to_hass(hass) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_ADDRESS, + data={CONF_ADDRESS: TEST_ADDRESS, CONF_TOKEN: TEST_PAIRING_TOKEN}, + ) + entry.add_to_hass(hass) + inject_bluetooth_service_info(hass, SNOOZ_SERVICE_INFO_NOT_PAIRING) + with patch( + "homeassistant.components.snooz.SnoozDevice", return_value=device + ) as mock_snooz_device: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - return entry + assert mock_snooz_device.call_args.args[1].password == TEST_PAIRING_TOKEN + + return entry diff --git a/tests/components/snooz/test_fan.py b/tests/components/snooz/test_fan.py index a31b83fb9ccc..b581cad9c9a6 100644 --- a/tests/components/snooz/test_fan.py +++ b/tests/components/snooz/test_fan.py @@ -1,9 +1,9 @@ """Test Snooz fan entity.""" from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch -from pysnooz.api import SnoozDeviceState, UnknownSnoozState +from pysnooz import SnoozDeviceState, UnknownSnoozState from pysnooz.commands import SnoozCommandResult, SnoozCommandResultStatus from pysnooz.testing import MockSnoozDevice import pytest @@ -30,8 +30,6 @@ from homeassistant.helpers import entity_registry as er from . import SnoozFixture, create_mock_snooz, create_mock_snooz_config_entry -from tests.components.bluetooth import generate_ble_device - async def test_turn_on(hass: HomeAssistant, snooz_fan_entity_id: str) -> None: """Test turning on the device.""" @@ -200,13 +198,7 @@ async def test_restore_state( assert state.state == STATE_UNAVAILABLE # reload entry - with ( - patch("homeassistant.components.snooz.SnoozDevice", return_value=device), - patch( - "homeassistant.components.snooz.async_ble_device_from_address", - return_value=generate_ble_device(device.address, device.name), - ), - ): + with patch("homeassistant.components.snooz.SnoozDevice", return_value=device): await hass.config_entries.async_setup(entry.entry_id) # should match last known state @@ -232,13 +224,7 @@ async def test_restore_unknown_state( assert state.state == STATE_UNAVAILABLE # reload entry - with ( - patch("homeassistant.components.snooz.SnoozDevice", return_value=device), - patch( - "homeassistant.components.snooz.async_ble_device_from_address", - return_value=generate_ble_device(device.address, device.name), - ), - ): + with patch("homeassistant.components.snooz.SnoozDevice", return_value=device): await hass.config_entries.async_setup(entry.entry_id) # should match last known state @@ -257,14 +243,19 @@ async def test_command_results( mock_execute.return_value = SnoozCommandResult( SnoozCommandResultStatus.SUCCESSFUL, timedelta() ) - mock_connected_snooz.device.state = SnoozDeviceState(on=True, volume=56) - await hass.services.async_call( - fan.DOMAIN, - fan.SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, - blocking=True, - ) + with patch.object( + MockSnoozDevice, + "state", + new_callable=PropertyMock, + return_value=SnoozDeviceState(on=True, volume=56), + ): + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) state = hass.states.get(snooz_fan_entity_id) assert state.state == STATE_ON @@ -273,14 +264,19 @@ async def test_command_results( mock_execute.return_value = SnoozCommandResult( SnoozCommandResultStatus.CANCELLED, timedelta() ) - mock_connected_snooz.device.state = SnoozDeviceState(on=False, volume=15) - await hass.services.async_call( - fan.DOMAIN, - fan.SERVICE_TURN_ON, - {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, - blocking=True, - ) + with patch.object( + MockSnoozDevice, + "state", + new_callable=PropertyMock, + return_value=SnoozDeviceState(on=False, volume=15), + ): + await hass.services.async_call( + fan.DOMAIN, + fan.SERVICE_TURN_ON, + {ATTR_ENTITY_ID: [snooz_fan_entity_id]}, + blocking=True, + ) # the device state shouldn't be written when cancelled state = hass.states.get(snooz_fan_entity_id) diff --git a/tests/components/snooz/test_init.py b/tests/components/snooz/test_init.py index 4a03dc22cd48..050096d304a6 100644 --- a/tests/components/snooz/test_init.py +++ b/tests/components/snooz/test_init.py @@ -16,11 +16,11 @@ from . import TEST_ADDRESS, TEST_PAIRING_TOKEN, SnoozFixture from tests.common import MockConfigEntry -async def test_setup_retries_when_device_not_found( +async def test_setup_retries_when_service_info_not_found( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: - """Test setup is retried with a diagnostic reason when the device is missing.""" + """Test setup is retried when Bluetooth service info is missing.""" entry = MockConfigEntry( domain=DOMAIN, unique_id=TEST_ADDRESS, @@ -30,9 +30,9 @@ async def test_setup_retries_when_device_not_found( with ( patch( - "homeassistant.components.snooz.async_ble_device_from_address", + "homeassistant.components.snooz.async_last_service_info", return_value=None, - ), + ) as mock_last_service_info, patch( "homeassistant.components.snooz.async_address_reachability_diagnostics", return_value="mock reachability reason", @@ -41,6 +41,7 @@ async def test_setup_retries_when_device_not_found( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + mock_last_service_info.assert_called_once_with(hass, TEST_ADDRESS) assert entry.state is ConfigEntryState.SETUP_RETRY assert ( f"Could not find Snooz with address {TEST_ADDRESS}: mock reachability reason"