From e585b3abd1e152805320445434fe4e2b4486f75f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 8 Aug 2025 23:33:55 +0200 Subject: [PATCH 1/8] Fix missing sentence-casing of "AC failure" in `bosch_alarm` (#150279) --- homeassistant/components/bosch_alarm/strings.json | 2 +- .../bosch_alarm/snapshots/test_binary_sensor.ambr | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json index 76c15a0a5c7539..3adccda2ee5f8e 100644 --- a/homeassistant/components/bosch_alarm/strings.json +++ b/homeassistant/components/bosch_alarm/strings.json @@ -95,7 +95,7 @@ "name": "Battery missing" }, "panel_fault_ac_fail": { - "name": "AC Failure" + "name": "AC failure" }, "panel_fault_parameter_crc_fail_in_pif": { "name": "CRC failure in panel configuration" diff --git a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr index e3444777ff025b..7e1604127e2841 100644 --- a/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr +++ b/tests/components/bosch_alarm/snapshots/test_binary_sensor.ambr @@ -168,7 +168,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -182,7 +182,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch AMAX 3000 AC Failure', + 'friendly_name': 'Bosch AMAX 3000 AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_amax_3000_ac_failure', @@ -1187,7 +1187,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1201,7 +1201,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch B5512 (US1B) AC Failure', + 'friendly_name': 'Bosch B5512 (US1B) AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_b5512_us1b_ac_failure', @@ -2206,7 +2206,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'AC Failure', + 'original_name': 'AC failure', 'platform': 'bosch_alarm', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2220,7 +2220,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'problem', - 'friendly_name': 'Bosch Solution 3000 AC Failure', + 'friendly_name': 'Bosch Solution 3000 AC failure', }), 'context': , 'entity_id': 'binary_sensor.bosch_solution_3000_ac_failure', From b41a9575af97b3213a9880f6da36da3a3c4659c6 Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Fri, 8 Aug 2025 23:58:19 +0200 Subject: [PATCH 2/8] Add protected call for data retrieval (#150035) --- homeassistant/components/bsblan/__init__.py | 43 +++++++++++++++++--- homeassistant/components/bsblan/strings.json | 14 +++++++ tests/components/bsblan/test_init.py | 38 ++++++++++++++++- 3 files changed, 88 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bsblan/__init__.py b/homeassistant/components/bsblan/__init__.py index 623bfbfef565d3..a7beb4f8d445c7 100644 --- a/homeassistant/components/bsblan/__init__.py +++ b/homeassistant/components/bsblan/__init__.py @@ -2,7 +2,16 @@ import dataclasses -from bsblan import BSBLAN, BSBLANConfig, Device, Info, StaticState +from bsblan import ( + BSBLAN, + BSBLANAuthError, + BSBLANConfig, + BSBLANConnectionError, + BSBLANError, + Device, + Info, + StaticState, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -13,9 +22,14 @@ Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_PASSKEY +from .const import CONF_PASSKEY, DOMAIN from .coordinator import BSBLanUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] @@ -54,10 +68,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: BSBLanConfigEntry) -> bo coordinator = BSBLanUpdateCoordinator(hass, entry, bsblan) await coordinator.async_config_entry_first_refresh() - # Fetch all required data concurrently - device = await bsblan.device() - info = await bsblan.info() - static = await bsblan.static_values() + try: + # Fetch all required data sequentially + device = await bsblan.device() + info = await bsblan.info() + static = await bsblan.static_values() + except BSBLANConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="setup_connection_error", + translation_placeholders={"host": entry.data[CONF_HOST]}, + ) from err + except BSBLANAuthError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="setup_auth_error", + ) from err + except BSBLANError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="setup_general_error", + ) from err entry.runtime_data = BSBLanData( client=bsblan, diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 86e52e76f41c00..b27be62e052c1d 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -41,6 +41,11 @@ "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]", + "username": "[%key:component::bsblan::config::step::user::data_description::username%]", + "password": "[%key:component::bsblan::config::step::user::data_description::password%]" } } }, @@ -66,6 +71,15 @@ }, "set_operation_mode_error": { "message": "An error occurred while setting the operation mode" + }, + "setup_connection_error": { + "message": "Failed to retrieve static device data from BSB-Lan device at {host}" + }, + "setup_auth_error": { + "message": "Authentication failed while retrieving static device data" + }, + "setup_general_error": { + "message": "An unknown error occurred while retrieving static device data" } }, "entity": { diff --git a/tests/components/bsblan/test_init.py b/tests/components/bsblan/test_init.py index cc52799d28b392..10945a2487803f 100644 --- a/tests/components/bsblan/test_init.py +++ b/tests/components/bsblan/test_init.py @@ -2,8 +2,9 @@ from unittest.mock import MagicMock -from bsblan import BSBLANAuthError, BSBLANConnectionError +from bsblan import BSBLANAuthError, BSBLANConnectionError, BSBLANError from freezegun.api import FrozenDateTimeFactory +import pytest from homeassistant.components.bsblan.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -75,3 +76,38 @@ async def test_config_entry_auth_failed_triggers_reauth( assert len(flows) == 1 assert flows[0]["context"]["source"] == "reauth" assert flows[0]["context"]["entry_id"] == mock_config_entry.entry_id + + +@pytest.mark.parametrize( + ("method", "exception", "expected_state"), + [ + ( + "device", + BSBLANConnectionError("Connection failed"), + ConfigEntryState.SETUP_RETRY, + ), + ( + "info", + BSBLANAuthError("Authentication failed"), + ConfigEntryState.SETUP_ERROR, + ), + ("static_values", BSBLANError("General error"), ConfigEntryState.SETUP_ERROR), + ], +) +async def test_config_entry_static_data_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_bsblan: MagicMock, + method: str, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test various errors during static data fetching trigger appropriate config entry states.""" + # Mock the specified method to raise the exception + getattr(mock_bsblan, method).side_effect = exception + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state From c0bef5156360b8981be9f591ab923d09f23bf683 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Sat, 9 Aug 2025 00:01:39 +0200 Subject: [PATCH 3/8] Refactor airq tests to mock the API class in a fixture (#149712) --- tests/components/airq/conftest.py | 26 +++++++++++ tests/components/airq/test_config_flow.py | 56 +++++++++++------------ tests/components/airq/test_coordinator.py | 38 +++++++-------- 3 files changed, 68 insertions(+), 52 deletions(-) diff --git a/tests/components/airq/conftest.py b/tests/components/airq/conftest.py index a132153a76fdd6..52d7fc77eb44f4 100644 --- a/tests/components/airq/conftest.py +++ b/tests/components/airq/conftest.py @@ -5,6 +5,8 @@ import pytest +from .common import TEST_DEVICE_DATA, TEST_DEVICE_INFO + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +15,27 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.airq.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_airq(): + """Mock the aioairq.AirQ object. + + The integration imports it in two places: in coordinator and config_flow. + """ + + with ( + patch( + "homeassistant.components.airq.coordinator.AirQ", + autospec=True, + ) as mock_airq_class, + patch( + "homeassistant.components.airq.config_flow.AirQ", + new=mock_airq_class, + ), + ): + airq = mock_airq_class.return_value + # Pre-configure default mock values for setup + airq.fetch_device_info = AsyncMock(return_value=TEST_DEVICE_INFO) + airq.get_latest_data = AsyncMock(return_value=TEST_DEVICE_DATA) + yield airq diff --git a/tests/components/airq/test_config_flow.py b/tests/components/airq/test_config_flow.py index 95c22cb12c8a95..66cacecdaaa0b7 100644 --- a/tests/components/airq/test_config_flow.py +++ b/tests/components/airq/test_config_flow.py @@ -1,7 +1,7 @@ """Test the air-Q config flow.""" import logging -from unittest.mock import patch +from unittest.mock import AsyncMock from aioairq import InvalidAuth from aiohttp.client_exceptions import ClientConnectionError @@ -29,7 +29,11 @@ } -async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: +async def test_form( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, +) -> None: """Test we get the form.""" caplog.set_level(logging.DEBUG) result = await hass.config_entries.flow.async_init( @@ -38,53 +42,49 @@ async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> No assert result["type"] is FlowResultType.FORM assert result["errors"] is None - with ( - patch("aioairq.AirQ.validate"), - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_USER_DATA, - ) - await hass.async_block_till_done() - assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_USER_DATA, + ) + await hass.async_block_till_done() + assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_DEVICE_INFO["name"] assert result2["data"] == TEST_USER_DATA -async def test_form_invalid_auth(hass: HomeAssistant) -> None: +async def test_form_invalid_auth(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test we handle invalid auth.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioairq.AirQ.validate", side_effect=InvalidAuth): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA | {CONF_PASSWORD: "wrong_password"} - ) + mock_airq.validate.side_effect = InvalidAuth + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA | {CONF_PASSWORD: "wrong_password"} + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_cannot_connect(hass: HomeAssistant) -> None: +async def test_form_cannot_connect(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with patch("aioairq.AirQ.validate", side_effect=ClientConnectionError): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA - ) + mock_airq.validate.side_effect = ClientConnectionError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_duplicate_error(hass: HomeAssistant) -> None: +async def test_duplicate_error(hass: HomeAssistant, mock_airq: AsyncMock) -> None: """Test that errors are shown when duplicates are added.""" MockConfigEntry( data=TEST_USER_DATA, @@ -96,13 +96,9 @@ async def test_duplicate_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch("aioairq.AirQ.validate"), - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_USER_DATA - ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], TEST_USER_DATA + ) assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" diff --git a/tests/components/airq/test_coordinator.py b/tests/components/airq/test_coordinator.py index 6512d60ddbe28c..f45986df61d46a 100644 --- a/tests/components/airq/test_coordinator.py +++ b/tests/components/airq/test_coordinator.py @@ -1,7 +1,7 @@ """Test the air-Q coordinator.""" import logging -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest @@ -32,7 +32,9 @@ async def test_logging_in_coordinator_first_update_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that the first AirQCoordinator._async_update_data call logs necessary setup. @@ -48,11 +50,7 @@ async def test_logging_in_coordinator_first_update_data( assert "name" not in coordinator.device_info # First call: fetch missing device info - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), - ): - await coordinator._async_update_data() + await coordinator._async_update_data() # check that the missing name is logged... assert ( @@ -71,7 +69,9 @@ async def test_logging_in_coordinator_first_update_data( async def test_logging_in_coordinator_subsequent_update_data( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that the second AirQCoordinator._async_update_data call has nothing to log. @@ -83,11 +83,7 @@ async def test_logging_in_coordinator_subsequent_update_data( coordinator = AirQCoordinator(hass, MOCKED_ENTRY) coordinator.device_info.update(DeviceInfo(**TEST_DEVICE_INFO)) - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA), - ): - await coordinator._async_update_data() + await coordinator._async_update_data() # check that the name _is not_ missing assert "name" in coordinator.device_info # and that nothing of the kind is logged @@ -102,19 +98,17 @@ async def test_logging_in_coordinator_subsequent_update_data( async def test_logging_when_warming_up_sensor_present( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_airq: AsyncMock, ) -> None: """Test that warming up sensors are logged.""" caplog.set_level(logging.DEBUG) coordinator = AirQCoordinator(hass, MOCKED_ENTRY) - with ( - patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO), - patch( - "aioairq.AirQ.get_latest_data", - return_value=TEST_DEVICE_DATA | {"Status": STATUS_WARMUP}, - ), - ): - await coordinator._async_update_data() + mock_airq.get_latest_data.return_value = TEST_DEVICE_DATA | { + "Status": STATUS_WARMUP + } + await coordinator._async_update_data() assert ( f"Following sensors are still warming up: {set(STATUS_WARMUP.keys())}" in caplog.text From f9e1c07c04332095f187547e911620fbbbb8e120 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:07:47 +0200 Subject: [PATCH 4/8] Add event platform to Husqvarna Automower (#148212) Co-authored-by: G Johansson --- .../husqvarna_automower/__init__.py | 1 + .../components/husqvarna_automower/const.py | 125 ++++++++ .../components/husqvarna_automower/event.py | 108 +++++++ .../components/husqvarna_automower/icons.json | 5 + .../components/husqvarna_automower/sensor.py | 128 +------- .../husqvarna_automower/strings.json | 152 +++++++++ .../snapshots/test_event.ambr | 303 ++++++++++++++++++ .../husqvarna_automower/test_event.py | 206 ++++++++++++ 8 files changed, 901 insertions(+), 127 deletions(-) create mode 100644 homeassistant/components/husqvarna_automower/event.py create mode 100644 tests/components/husqvarna_automower/snapshots/test_event.ambr create mode 100644 tests/components/husqvarna_automower/test_event.py diff --git a/homeassistant/components/husqvarna_automower/__init__.py b/homeassistant/components/husqvarna_automower/__init__.py index 1945647a706eb6..02adbc4adb691d 100644 --- a/homeassistant/components/husqvarna_automower/__init__.py +++ b/homeassistant/components/husqvarna_automower/__init__.py @@ -21,6 +21,7 @@ Platform.BUTTON, Platform.CALENDAR, Platform.DEVICE_TRACKER, + Platform.EVENT, Platform.LAWN_MOWER, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py index d91fea2969808c..f50c03e1b539f5 100644 --- a/homeassistant/components/husqvarna_automower/const.py +++ b/homeassistant/components/husqvarna_automower/const.py @@ -17,3 +17,128 @@ MowerStates.WAIT_POWER_UP, MowerStates.WAIT_UPDATING, ] + +ERROR_KEYS = [ + "alarm_mower_in_motion", + "alarm_mower_lifted", + "alarm_mower_stopped", + "alarm_mower_switched_off", + "alarm_mower_tilted", + "alarm_outside_geofence", + "angular_sensor_problem", + "battery_problem", + "battery_restriction_due_to_ambient_temperature", + "can_error", + "charging_current_too_high", + "charging_station_blocked", + "charging_system_problem", + "collision_sensor_defect", + "collision_sensor_error", + "collision_sensor_problem_front", + "collision_sensor_problem_rear", + "com_board_not_available", + "communication_circuit_board_sw_must_be_updated", + "complex_working_area", + "connection_changed", + "connection_not_changed", + "connectivity_problem", + "connectivity_settings_restored", + "cutting_drive_motor_1_defect", + "cutting_drive_motor_2_defect", + "cutting_drive_motor_3_defect", + "cutting_height_blocked", + "cutting_height_problem", + "cutting_height_problem_curr", + "cutting_height_problem_dir", + "cutting_height_problem_drive", + "cutting_motor_problem", + "cutting_stopped_slope_too_steep", + "cutting_system_blocked", + "cutting_system_imbalance_warning", + "cutting_system_major_imbalance", + "destination_not_reachable", + "difficult_finding_home", + "docking_sensor_defect", + "electronic_problem", + "empty_battery", + "folding_cutting_deck_sensor_defect", + "folding_sensor_activated", + "geofence_problem", + "gps_navigation_problem", + "guide_1_not_found", + "guide_2_not_found", + "guide_3_not_found", + "guide_calibration_accomplished", + "guide_calibration_failed", + "high_charging_power_loss", + "high_internal_power_loss", + "high_internal_temperature", + "internal_voltage_error", + "invalid_battery_combination_invalid_combination_of_different_battery_types", + "invalid_sub_device_combination", + "invalid_system_configuration", + "left_brush_motor_overloaded", + "lift_sensor_defect", + "lifted", + "limited_cutting_height_range", + "loop_sensor_defect", + "loop_sensor_problem_front", + "loop_sensor_problem_left", + "loop_sensor_problem_rear", + "loop_sensor_problem_right", + "low_battery", + "memory_circuit_problem", + "mower_lifted", + "mower_tilted", + "no_accurate_position_from_satellites", + "no_confirmed_position", + "no_drive", + "no_loop_signal", + "no_power_in_charging_station", + "no_response_from_charger", + "outside_working_area", + "poor_signal_quality", + "reference_station_communication_problem", + "right_brush_motor_overloaded", + "safety_function_faulty", + "settings_restored", + "sim_card_locked", + "sim_card_not_found", + "sim_card_requires_pin", + "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", + "slope_too_steep", + "sms_could_not_be_sent", + "stop_button_problem", + "stuck_in_charging_station", + "switch_cord_problem", + "temporary_battery_problem", + "tilt_sensor_problem", + "too_high_discharge_current", + "too_high_internal_current", + "trapped", + "ultrasonic_problem", + "ultrasonic_sensor_1_defect", + "ultrasonic_sensor_2_defect", + "ultrasonic_sensor_3_defect", + "ultrasonic_sensor_4_defect", + "unexpected_cutting_height_adj", + "unexpected_error", + "upside_down", + "weak_gps_signal", + "wheel_drive_problem_left", + "wheel_drive_problem_rear_left", + "wheel_drive_problem_rear_right", + "wheel_drive_problem_right", + "wheel_motor_blocked_left", + "wheel_motor_blocked_rear_left", + "wheel_motor_blocked_rear_right", + "wheel_motor_blocked_right", + "wheel_motor_overloaded_left", + "wheel_motor_overloaded_rear_left", + "wheel_motor_overloaded_rear_right", + "wheel_motor_overloaded_right", + "work_area_not_valid", + "wrong_loop_signal", + "wrong_pin_code", + "zone_generator_problem", +] diff --git a/homeassistant/components/husqvarna_automower/event.py b/homeassistant/components/husqvarna_automower/event.py new file mode 100644 index 00000000000000..8e2e48b940d5d5 --- /dev/null +++ b/homeassistant/components/husqvarna_automower/event.py @@ -0,0 +1,108 @@ +"""Creates the event entities for supported mowers.""" + +from collections.abc import Callable + +from aioautomower.model import SingleMessageData + +from homeassistant.components.event import ( + DOMAIN as EVENT_DOMAIN, + EventEntity, + EventEntityDescription, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import AutomowerConfigEntry +from .const import ERROR_KEYS +from .coordinator import AutomowerDataUpdateCoordinator +from .entity import AutomowerBaseEntity + +PARALLEL_UPDATES = 1 + +ATTR_SEVERITY = "severity" +ATTR_LATITUDE = "latitude" +ATTR_LONGITUDE = "longitude" +ATTR_DATE_TIME = "date_time" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AutomowerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Automower message event entities. + + Entities are created dynamically based on messages received from the API, + but only for mowers that support message events. + """ + coordinator = config_entry.runtime_data + entity_registry = er.async_get(hass) + + restored_mowers = { + entry.unique_id.removesuffix("_message") + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + if entry.domain == EVENT_DOMAIN + } + + async_add_entities( + AutomowerMessageEventEntity(mower_id, coordinator) + for mower_id in restored_mowers + if mower_id in coordinator.data + ) + + @callback + def _handle_message(msg: SingleMessageData) -> None: + if msg.id in restored_mowers: + return + + restored_mowers.add(msg.id) + async_add_entities([AutomowerMessageEventEntity(msg.id, coordinator)]) + + coordinator.api.register_single_message_callback(_handle_message) + + +class AutomowerMessageEventEntity(AutomowerBaseEntity, EventEntity): + """EventEntity for Automower message events.""" + + entity_description: EventEntityDescription + _message_cb: Callable[[SingleMessageData], None] + _attr_translation_key = "message" + _attr_event_types = ERROR_KEYS + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + ) -> None: + """Initialize Automower message event entity.""" + super().__init__(mower_id, coordinator) + self._attr_unique_id = f"{mower_id}_message" + + @callback + def _handle(self, msg: SingleMessageData) -> None: + """Handle a message event from the API and trigger the event entity if it matches the entity's mower ID.""" + if msg.id != self.mower_id: + return + message = msg.attributes.message + self._trigger_event( + message.code, + { + ATTR_SEVERITY: message.severity, + ATTR_LATITUDE: message.latitude, + ATTR_LONGITUDE: message.longitude, + ATTR_DATE_TIME: message.time, + }, + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callback when entity is added to hass.""" + await super().async_added_to_hass() + self.coordinator.api.register_single_message_callback(self._handle) + + async def async_will_remove_from_hass(self) -> None: + """Unregister WebSocket callback when entity is removed.""" + self.coordinator.api.unregister_single_message_callback(self._handle) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index 5ff5940bdf42ad..ba9bc82f156de2 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -13,6 +13,11 @@ "default": "mdi:saw-blade" } }, + "event": { + "message": { + "default": "mdi:alert-circle-check-outline" + } + }, "number": { "cutting_height": { "default": "mdi:grass" diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index c5af18c63873bd..50be89e9d429dd 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers.typing import StateType from . import AutomowerConfigEntry -from .const import ERROR_STATES +from .const import ERROR_KEYS, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerBaseEntity, @@ -42,132 +42,6 @@ ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment" -ERROR_KEYS = [ - "alarm_mower_in_motion", - "alarm_mower_lifted", - "alarm_mower_stopped", - "alarm_mower_switched_off", - "alarm_mower_tilted", - "alarm_outside_geofence", - "angular_sensor_problem", - "battery_problem", - "battery_restriction_due_to_ambient_temperature", - "can_error", - "charging_current_too_high", - "charging_station_blocked", - "charging_system_problem", - "collision_sensor_defect", - "collision_sensor_error", - "collision_sensor_problem_front", - "collision_sensor_problem_rear", - "com_board_not_available", - "communication_circuit_board_sw_must_be_updated", - "complex_working_area", - "connection_changed", - "connection_not_changed", - "connectivity_problem", - "connectivity_settings_restored", - "cutting_drive_motor_1_defect", - "cutting_drive_motor_2_defect", - "cutting_drive_motor_3_defect", - "cutting_height_blocked", - "cutting_height_problem", - "cutting_height_problem_curr", - "cutting_height_problem_dir", - "cutting_height_problem_drive", - "cutting_motor_problem", - "cutting_stopped_slope_too_steep", - "cutting_system_blocked", - "cutting_system_imbalance_warning", - "cutting_system_major_imbalance", - "destination_not_reachable", - "difficult_finding_home", - "docking_sensor_defect", - "electronic_problem", - "empty_battery", - "folding_cutting_deck_sensor_defect", - "folding_sensor_activated", - "geofence_problem", - "gps_navigation_problem", - "guide_1_not_found", - "guide_2_not_found", - "guide_3_not_found", - "guide_calibration_accomplished", - "guide_calibration_failed", - "high_charging_power_loss", - "high_internal_power_loss", - "high_internal_temperature", - "internal_voltage_error", - "invalid_battery_combination_invalid_combination_of_different_battery_types", - "invalid_sub_device_combination", - "invalid_system_configuration", - "left_brush_motor_overloaded", - "lift_sensor_defect", - "lifted", - "limited_cutting_height_range", - "loop_sensor_defect", - "loop_sensor_problem_front", - "loop_sensor_problem_left", - "loop_sensor_problem_rear", - "loop_sensor_problem_right", - "low_battery", - "memory_circuit_problem", - "mower_lifted", - "mower_tilted", - "no_accurate_position_from_satellites", - "no_confirmed_position", - "no_drive", - "no_loop_signal", - "no_power_in_charging_station", - "no_response_from_charger", - "outside_working_area", - "poor_signal_quality", - "reference_station_communication_problem", - "right_brush_motor_overloaded", - "safety_function_faulty", - "settings_restored", - "sim_card_locked", - "sim_card_not_found", - "sim_card_requires_pin", - "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern", - "slope_too_steep", - "sms_could_not_be_sent", - "stop_button_problem", - "stuck_in_charging_station", - "switch_cord_problem", - "temporary_battery_problem", - "tilt_sensor_problem", - "too_high_discharge_current", - "too_high_internal_current", - "trapped", - "ultrasonic_problem", - "ultrasonic_sensor_1_defect", - "ultrasonic_sensor_2_defect", - "ultrasonic_sensor_3_defect", - "ultrasonic_sensor_4_defect", - "unexpected_cutting_height_adj", - "unexpected_error", - "upside_down", - "weak_gps_signal", - "wheel_drive_problem_left", - "wheel_drive_problem_rear_left", - "wheel_drive_problem_rear_right", - "wheel_drive_problem_right", - "wheel_motor_blocked_left", - "wheel_motor_blocked_rear_left", - "wheel_motor_blocked_rear_right", - "wheel_motor_blocked_right", - "wheel_motor_overloaded_left", - "wheel_motor_overloaded_rear_left", - "wheel_motor_overloaded_rear_right", - "wheel_motor_overloaded_right", - "work_area_not_valid", - "wrong_loop_signal", - "wrong_pin_code", - "zone_generator_problem", -] - - ERROR_KEY_LIST = sorted( set(ERROR_KEYS) | {state.lower() for state in ERROR_STATES} | {"no_error"} ) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index bd8a934655268c..c10e56ec7c8ee7 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -58,6 +58,158 @@ "name": "Reset cutting blade usage time" } }, + "event": { + "message": { + "name": "Message", + "state_attributes": { + "event_type": { + "state": { + "alarm_mower_in_motion": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_in_motion%]", + "alarm_mower_lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_lifted%]", + "alarm_mower_stopped": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_stopped%]", + "alarm_mower_switched_off": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_switched_off%]", + "alarm_mower_tilted": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_mower_tilted%]", + "alarm_outside_geofence": "[%key:component::husqvarna_automower::entity::sensor::error::state::alarm_outside_geofence%]", + "angular_sensor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::angular_sensor_problem%]", + "battery_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::battery_problem%]", + "battery_restriction_due_to_ambient_temperature": "[%key:component::husqvarna_automower::entity::sensor::error::state::battery_restriction_due_to_ambient_temperature%]", + "can_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::can_error%]", + "charging_current_too_high": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_current_too_high%]", + "charging_station_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_station_blocked%]", + "charging_system_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::charging_system_problem%]", + "collision_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_defect%]", + "collision_sensor_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_error%]", + "collision_sensor_problem_front": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_problem_front%]", + "collision_sensor_problem_rear": "[%key:component::husqvarna_automower::entity::sensor::error::state::collision_sensor_problem_rear%]", + "com_board_not_available": "[%key:component::husqvarna_automower::entity::sensor::error::state::com_board_not_available%]", + "communication_circuit_board_sw_must_be_updated": "[%key:component::husqvarna_automower::entity::sensor::error::state::communication_circuit_board_sw_must_be_updated%]", + "complex_working_area": "[%key:component::husqvarna_automower::entity::sensor::error::state::complex_working_area%]", + "connection_changed": "[%key:component::husqvarna_automower::entity::sensor::error::state::connection_changed%]", + "connection_not_changed": "[%key:component::husqvarna_automower::entity::sensor::error::state::connection_not_changed%]", + "connectivity_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::connectivity_problem%]", + "connectivity_settings_restored": "[%key:component::husqvarna_automower::entity::sensor::error::state::connectivity_settings_restored%]", + "cutting_drive_motor_1_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_1_defect%]", + "cutting_drive_motor_2_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_2_defect%]", + "cutting_drive_motor_3_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_drive_motor_3_defect%]", + "cutting_height_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_blocked%]", + "cutting_height_problem_curr": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_curr%]", + "cutting_height_problem_dir": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_dir%]", + "cutting_height_problem_drive": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem_drive%]", + "cutting_height_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_height_problem%]", + "cutting_motor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_motor_problem%]", + "cutting_stopped_slope_too_steep": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_stopped_slope_too_steep%]", + "cutting_system_blocked": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_blocked%]", + "cutting_system_imbalance_warning": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_imbalance_warning%]", + "cutting_system_major_imbalance": "[%key:component::husqvarna_automower::entity::sensor::error::state::cutting_system_major_imbalance%]", + "destination_not_reachable": "[%key:component::husqvarna_automower::entity::sensor::error::state::destination_not_reachable%]", + "difficult_finding_home": "[%key:component::husqvarna_automower::entity::sensor::error::state::difficult_finding_home%]", + "docking_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::docking_sensor_defect%]", + "electronic_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::electronic_problem%]", + "empty_battery": "[%key:component::husqvarna_automower::entity::sensor::error::state::empty_battery%]", + "error_at_power_up": "[%key:component::husqvarna_automower::entity::sensor::error::state::error_at_power_up%]", + "error": "[%key:common::state::error%]", + "fatal_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::fatal_error%]", + "folding_cutting_deck_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_cutting_deck_sensor_defect%]", + "folding_sensor_activated": "[%key:component::husqvarna_automower::entity::sensor::error::state::folding_sensor_activated%]", + "geofence_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::geofence_problem%]", + "gps_navigation_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::gps_navigation_problem%]", + "guide_1_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_1_not_found%]", + "guide_2_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_2_not_found%]", + "guide_3_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_3_not_found%]", + "guide_calibration_accomplished": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_calibration_accomplished%]", + "guide_calibration_failed": "[%key:component::husqvarna_automower::entity::sensor::error::state::guide_calibration_failed%]", + "high_charging_power_loss": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_charging_power_loss%]", + "high_internal_power_loss": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_internal_power_loss%]", + "high_internal_temperature": "[%key:component::husqvarna_automower::entity::sensor::error::state::high_internal_temperature%]", + "internal_voltage_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::internal_voltage_error%]", + "invalid_battery_combination_invalid_combination_of_different_battery_types": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_battery_combination_invalid_combination_of_different_battery_types%]", + "invalid_sub_device_combination": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_sub_device_combination%]", + "invalid_system_configuration": "[%key:component::husqvarna_automower::entity::sensor::error::state::invalid_system_configuration%]", + "left_brush_motor_overloaded": "[%key:component::husqvarna_automower::entity::sensor::error::state::left_brush_motor_overloaded%]", + "lift_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::lift_sensor_defect%]", + "lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::lifted%]", + "limited_cutting_height_range": "[%key:component::husqvarna_automower::entity::sensor::error::state::limited_cutting_height_range%]", + "loop_sensor_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_defect%]", + "loop_sensor_problem_front": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_front%]", + "loop_sensor_problem_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_left%]", + "loop_sensor_problem_rear": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_rear%]", + "loop_sensor_problem_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::loop_sensor_problem_right%]", + "low_battery": "[%key:component::husqvarna_automower::entity::sensor::error::state::low_battery%]", + "memory_circuit_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::memory_circuit_problem%]", + "mower_lifted": "[%key:component::husqvarna_automower::entity::sensor::error::state::mower_lifted%]", + "mower_tilted": "[%key:component::husqvarna_automower::entity::sensor::error::state::mower_tilted%]", + "no_accurate_position_from_satellites": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_accurate_position_from_satellites%]", + "no_confirmed_position": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_confirmed_position%]", + "no_drive": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_drive%]", + "no_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_error%]", + "no_loop_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_loop_signal%]", + "no_power_in_charging_station": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_power_in_charging_station%]", + "no_response_from_charger": "[%key:component::husqvarna_automower::entity::sensor::error::state::no_response_from_charger%]", + "off": "[%key:common::state::off%]", + "outside_working_area": "[%key:component::husqvarna_automower::entity::sensor::error::state::outside_working_area%]", + "poor_signal_quality": "[%key:component::husqvarna_automower::entity::sensor::error::state::poor_signal_quality%]", + "reference_station_communication_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::reference_station_communication_problem%]", + "right_brush_motor_overloaded": "[%key:component::husqvarna_automower::entity::sensor::error::state::right_brush_motor_overloaded%]", + "safety_function_faulty": "[%key:component::husqvarna_automower::entity::sensor::error::state::safety_function_faulty%]", + "settings_restored": "[%key:component::husqvarna_automower::entity::sensor::error::state::settings_restored%]", + "sim_card_locked": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_locked%]", + "sim_card_not_found": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_not_found%]", + "sim_card_requires_pin": "[%key:component::husqvarna_automower::entity::sensor::error::state::sim_card_requires_pin%]", + "slipped_mower_has_slipped_situation_not_solved_with_moving_pattern": "[%key:component::husqvarna_automower::entity::sensor::error::state::slipped_mower_has_slipped_situation_not_solved_with_moving_pattern%]", + "slope_too_steep": "[%key:component::husqvarna_automower::entity::sensor::error::state::slope_too_steep%]", + "sms_could_not_be_sent": "[%key:component::husqvarna_automower::entity::sensor::error::state::sms_could_not_be_sent%]", + "stop_button_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::stop_button_problem%]", + "stopped": "[%key:common::state::stopped%]", + "stuck_in_charging_station": "[%key:component::husqvarna_automower::entity::sensor::error::state::stuck_in_charging_station%]", + "switch_cord_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::switch_cord_problem%]", + "temporary_battery_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::temporary_battery_problem%]", + "tilt_sensor_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::tilt_sensor_problem%]", + "too_high_discharge_current": "[%key:component::husqvarna_automower::entity::sensor::error::state::too_high_discharge_current%]", + "too_high_internal_current": "[%key:component::husqvarna_automower::entity::sensor::error::state::too_high_internal_current%]", + "trapped": "[%key:component::husqvarna_automower::entity::sensor::error::state::trapped%]", + "ultrasonic_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_problem%]", + "ultrasonic_sensor_1_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_1_defect%]", + "ultrasonic_sensor_2_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_2_defect%]", + "ultrasonic_sensor_3_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_3_defect%]", + "ultrasonic_sensor_4_defect": "[%key:component::husqvarna_automower::entity::sensor::error::state::ultrasonic_sensor_4_defect%]", + "unexpected_cutting_height_adj": "[%key:component::husqvarna_automower::entity::sensor::error::state::unexpected_cutting_height_adj%]", + "unexpected_error": "[%key:component::husqvarna_automower::entity::sensor::error::state::unexpected_error%]", + "upside_down": "[%key:component::husqvarna_automower::entity::sensor::error::state::upside_down%]", + "wait_power_up": "[%key:component::husqvarna_automower::entity::sensor::error::state::wait_power_up%]", + "wait_updating": "[%key:component::husqvarna_automower::entity::sensor::error::state::wait_updating%]", + "weak_gps_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::weak_gps_signal%]", + "wheel_drive_problem_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_left%]", + "wheel_drive_problem_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_rear_left%]", + "wheel_drive_problem_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_rear_right%]", + "wheel_drive_problem_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_drive_problem_right%]", + "wheel_motor_blocked_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_left%]", + "wheel_motor_blocked_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_rear_left%]", + "wheel_motor_blocked_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_rear_right%]", + "wheel_motor_blocked_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_blocked_right%]", + "wheel_motor_overloaded_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_left%]", + "wheel_motor_overloaded_rear_left": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_rear_left%]", + "wheel_motor_overloaded_rear_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_rear_right%]", + "wheel_motor_overloaded_right": "[%key:component::husqvarna_automower::entity::sensor::error::state::wheel_motor_overloaded_right%]", + "work_area_not_valid": "[%key:component::husqvarna_automower::entity::sensor::error::state::work_area_not_valid%]", + "wrong_loop_signal": "[%key:component::husqvarna_automower::entity::sensor::error::state::wrong_loop_signal%]", + "wrong_pin_code": "[%key:component::husqvarna_automower::entity::sensor::error::state::wrong_pin_code%]", + "zone_generator_problem": "[%key:component::husqvarna_automower::entity::sensor::error::state::zone_generator_problem%]" + } + }, + "severity": { + "state": { + "fatal": "Fatal", + "error": "[%key:common::state::error%]", + "warning": "Warning", + "info": "Info", + "debug": "Debug", + "sw": "Software", + "unknown": "Unknown" + } + } + } + } + }, "number": { "cutting_height": { "name": "Cutting height" diff --git a/tests/components/husqvarna_automower/snapshots/test_event.ambr b/tests/components/husqvarna_automower/snapshots/test_event.ambr new file mode 100644 index 00000000000000..e01f8d04f2c014 --- /dev/null +++ b/tests/components/husqvarna_automower/snapshots/test_event.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_event_snapshot[event.test_mower_1_message-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.test_mower_1_message', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Message', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'message', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.test_mower_1_message-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'date_time': HAFakeDatetime(2025, 7, 13, 15, 30, tzinfo=datetime.timezone.utc), + 'event_type': 'wheel_motor_overloaded_rear_left', + 'event_types': list([ + 'alarm_mower_in_motion', + 'alarm_mower_lifted', + 'alarm_mower_stopped', + 'alarm_mower_switched_off', + 'alarm_mower_tilted', + 'alarm_outside_geofence', + 'angular_sensor_problem', + 'battery_problem', + 'battery_restriction_due_to_ambient_temperature', + 'can_error', + 'charging_current_too_high', + 'charging_station_blocked', + 'charging_system_problem', + 'collision_sensor_defect', + 'collision_sensor_error', + 'collision_sensor_problem_front', + 'collision_sensor_problem_rear', + 'com_board_not_available', + 'communication_circuit_board_sw_must_be_updated', + 'complex_working_area', + 'connection_changed', + 'connection_not_changed', + 'connectivity_problem', + 'connectivity_settings_restored', + 'cutting_drive_motor_1_defect', + 'cutting_drive_motor_2_defect', + 'cutting_drive_motor_3_defect', + 'cutting_height_blocked', + 'cutting_height_problem', + 'cutting_height_problem_curr', + 'cutting_height_problem_dir', + 'cutting_height_problem_drive', + 'cutting_motor_problem', + 'cutting_stopped_slope_too_steep', + 'cutting_system_blocked', + 'cutting_system_imbalance_warning', + 'cutting_system_major_imbalance', + 'destination_not_reachable', + 'difficult_finding_home', + 'docking_sensor_defect', + 'electronic_problem', + 'empty_battery', + 'folding_cutting_deck_sensor_defect', + 'folding_sensor_activated', + 'geofence_problem', + 'gps_navigation_problem', + 'guide_1_not_found', + 'guide_2_not_found', + 'guide_3_not_found', + 'guide_calibration_accomplished', + 'guide_calibration_failed', + 'high_charging_power_loss', + 'high_internal_power_loss', + 'high_internal_temperature', + 'internal_voltage_error', + 'invalid_battery_combination_invalid_combination_of_different_battery_types', + 'invalid_sub_device_combination', + 'invalid_system_configuration', + 'left_brush_motor_overloaded', + 'lift_sensor_defect', + 'lifted', + 'limited_cutting_height_range', + 'loop_sensor_defect', + 'loop_sensor_problem_front', + 'loop_sensor_problem_left', + 'loop_sensor_problem_rear', + 'loop_sensor_problem_right', + 'low_battery', + 'memory_circuit_problem', + 'mower_lifted', + 'mower_tilted', + 'no_accurate_position_from_satellites', + 'no_confirmed_position', + 'no_drive', + 'no_loop_signal', + 'no_power_in_charging_station', + 'no_response_from_charger', + 'outside_working_area', + 'poor_signal_quality', + 'reference_station_communication_problem', + 'right_brush_motor_overloaded', + 'safety_function_faulty', + 'settings_restored', + 'sim_card_locked', + 'sim_card_not_found', + 'sim_card_requires_pin', + 'slipped_mower_has_slipped_situation_not_solved_with_moving_pattern', + 'slope_too_steep', + 'sms_could_not_be_sent', + 'stop_button_problem', + 'stuck_in_charging_station', + 'switch_cord_problem', + 'temporary_battery_problem', + 'tilt_sensor_problem', + 'too_high_discharge_current', + 'too_high_internal_current', + 'trapped', + 'ultrasonic_problem', + 'ultrasonic_sensor_1_defect', + 'ultrasonic_sensor_2_defect', + 'ultrasonic_sensor_3_defect', + 'ultrasonic_sensor_4_defect', + 'unexpected_cutting_height_adj', + 'unexpected_error', + 'upside_down', + 'weak_gps_signal', + 'wheel_drive_problem_left', + 'wheel_drive_problem_rear_left', + 'wheel_drive_problem_rear_right', + 'wheel_drive_problem_right', + 'wheel_motor_blocked_left', + 'wheel_motor_blocked_rear_left', + 'wheel_motor_blocked_rear_right', + 'wheel_motor_blocked_right', + 'wheel_motor_overloaded_left', + 'wheel_motor_overloaded_rear_left', + 'wheel_motor_overloaded_rear_right', + 'wheel_motor_overloaded_right', + 'work_area_not_valid', + 'wrong_loop_signal', + 'wrong_pin_code', + 'zone_generator_problem', + ]), + 'friendly_name': 'Test Mower 1 Message', + 'latitude': 49.0, + 'longitude': 10.0, + 'severity': , + }), + 'context': , + 'entity_id': 'event.test_mower_1_message', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-06-05T12:00:00.000+00:00', + }) +# --- diff --git a/tests/components/husqvarna_automower/test_event.py b/tests/components/husqvarna_automower/test_event.py new file mode 100644 index 00000000000000..6cbfa10297607b --- /dev/null +++ b/tests/components/husqvarna_automower/test_event.py @@ -0,0 +1,206 @@ +"""Tests for init module.""" + +from collections.abc import Callable +from copy import deepcopy +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from aioautomower.model import MowerAttributes, SingleMessageData +from aioautomower.model.model_message import Message, Severity, SingleMessageAttributes +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry + +from . import setup_integration +from .const import TEST_MOWER_ID + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.freeze_time(datetime(2023, 6, 5, 12)) +async def test_event( + hass: HomeAssistant, + entity_registry: EntityRegistry, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + values: dict[str, MowerAttributes], +) -> None: + """Test that a new message arriving over the websocket creates and updates the sensor.""" + callbacks: list[Callable[[SingleMessageData], None]] = [] + + @callback + def fake_register_websocket_response( + cb: Callable[[SingleMessageData], None], + ) -> None: + callbacks.append(cb) + + mock_automower_client.register_single_message_callback.side_effect = ( + fake_register_websocket_response + ) + + # Set up integration + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Ensure callback was registered for the test mower + assert mock_automower_client.register_single_message_callback.called + + # Check initial state (event entity not available yet) + state = hass.states.get("event.test_mower_1_message") + assert state is None + + # Simulate a new message for this mower and check entity creation + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 15, 30, tzinfo=UTC), + code="wheel_motor_overloaded_rear_left", + severity=Severity.ERROR, + latitude=49.0, + longitude=10.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" + + # Reload the config entry to ensure the entity is created again + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "wheel_motor_overloaded_rear_left" + + # Check updating event with a new message + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 16, 00, tzinfo=UTC), + code="alarm_mower_lifted", + severity=Severity.ERROR, + latitude=48.0, + longitude=11.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_1_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" + + # Check message for another mower, creates an new entity and dont + # change the state of the first entity + message = SingleMessageData( + type="messages", + id="1234", + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 16, 00, tzinfo=UTC), + code="battery_problem", + severity=Severity.ERROR, + latitude=48.0, + longitude=11.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + entry = entity_registry.async_get("event.test_mower_1_message") + assert entry is not None + assert state.attributes[ATTR_EVENT_TYPE] == "alarm_mower_lifted" + state = hass.states.get("event.test_mower_2_message") + assert state is not None + assert state.attributes[ATTR_EVENT_TYPE] == "battery_problem" + + # Check event entity is removed, when the mower is removed + values_copy = deepcopy(values) + values_copy.pop("1234") + mock_automower_client.get_status.return_value = values_copy + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + state = hass.states.get("event.test_mower_2_message") + assert state is None + entry = entity_registry.async_get("event.test_mower_2_message") + assert entry is None + + +@pytest.mark.freeze_time(datetime(2023, 6, 5, 12)) +async def test_event_snapshot( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that a new message arriving over the websocket updates the sensor.""" + with patch( + "homeassistant.components.husqvarna_automower.PLATFORMS", + [Platform.EVENT], + ): + callbacks: list[Callable[[SingleMessageData], None]] = [] + + @callback + def fake_register_websocket_response( + cb: Callable[[SingleMessageData], None], + ) -> None: + callbacks.append(cb) + + mock_automower_client.register_single_message_callback.side_effect = ( + fake_register_websocket_response + ) + + # Set up integration + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + # Ensure callback was registered for the test mower + assert mock_automower_client.register_single_message_callback.called + + # Simulate a new message for this mower + message = SingleMessageData( + type="messages", + id=TEST_MOWER_ID, + attributes=SingleMessageAttributes( + message=Message( + time=datetime(2025, 7, 13, 15, 30, tzinfo=UTC), + code="wheel_motor_overloaded_rear_left", + severity=Severity.ERROR, + latitude=49.0, + longitude=10.0, + ) + ), + ) + + for cb in callbacks: + cb(message) + await hass.async_block_till_done() + + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) From e9d39a826e7c52d0213ea4f6414a9a02b5e63c10 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Aug 2025 00:24:38 +0200 Subject: [PATCH 5/8] Remove deprecated horizontal vane select from Sensibo (#150108) --- homeassistant/components/sensibo/select.py | 62 +------------ homeassistant/components/sensibo/strings.json | 78 ++++++---------- tests/components/sensibo/test_select.py | 93 +------------------ 3 files changed, 33 insertions(+), 200 deletions(-) diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 5a0546b1aa2984..1ed9a1bbefc96a 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -8,24 +8,11 @@ from pysensibo.model import SensiboDevice -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity -from homeassistant.components.select import ( - DOMAIN as SELECT_DOMAIN, - SelectEntity, - SelectEntityDescription, -) +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from . import SensiboConfigEntry -from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call @@ -42,16 +29,6 @@ class SensiboSelectEntityDescription(SelectEntityDescription): transformation: Callable[[SensiboDevice], dict | None] -HORIZONTAL_SWING_MODE_TYPE = SensiboSelectEntityDescription( - key="horizontalSwing", - data_key="horizontal_swing_mode", - value_fn=lambda data: data.horizontal_swing_mode, - options_fn=lambda data: data.horizontal_swing_modes, - translation_key="horizontalswing", - transformation=lambda data: data.horizontal_swing_modes_translated, - entity_registry_enabled_default=False, -) - DEVICE_SELECT_TYPES = ( SensiboSelectEntityDescription( key="light", @@ -73,43 +50,6 @@ async def async_setup_entry( coordinator = entry.runtime_data - entities: list[SensiboSelect] = [] - - entity_registry = er.async_get(hass) - for device_id, device_data in coordinator.data.parsed.items(): - if entity_id := entity_registry.async_get_entity_id( - SELECT_DOMAIN, DOMAIN, f"{device_id}-horizontalSwing" - ): - entity = entity_registry.async_get(entity_id) - if entity and entity.disabled: - entity_registry.async_remove(entity_id) - async_delete_issue( - hass, - DOMAIN, - "deprecated_entity_horizontalswing", - ) - elif entity and HORIZONTAL_SWING_MODE_TYPE.key in device_data.full_features: - entities.append( - SensiboSelect(coordinator, device_id, HORIZONTAL_SWING_MODE_TYPE) - ) - if automations_with_entity(hass, entity_id) or scripts_with_entity( - hass, entity_id - ): - async_create_issue( - hass, - DOMAIN, - "deprecated_entity_horizontalswing", - breaks_in_ha_version="2025.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity_horizontalswing", - translation_placeholders={ - "name": str(entity.name or entity.original_name), - "entity": entity_id, - }, - ) - async_add_entities(entities) - added_devices: set[str] = set() def _add_remove_devices() -> None: diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 4dce104d1c7891..1071a7739f67ee 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -77,22 +77,6 @@ } }, "select": { - "horizontalswing": { - "name": "Horizontal swing", - "state": { - "stopped": "[%key:common::state::off%]", - "fixedleft": "Fixed left", - "fixedcenterleft": "Fixed center left", - "fixedcenter": "Fixed center", - "fixedcenterright": "Fixed center right", - "fixedright": "Fixed right", - "fixedleftright": "Fixed left right", - "rangecenter": "Range center", - "rangefull": "Range full", - "rangeleft": "Range left", - "rangeright": "Range right" - } - }, "light": { "name": "[%key:component::light::title%]", "state": { @@ -153,14 +137,16 @@ "name": "Horizontal swing", "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]" + "fixedleft": "Fixed left", + "fixedcenterleft": "Fixed center left", + "fixedcenter": "Fixed center", + "fixedcenterright": "Fixed center right", + "fixedright": "Fixed right", + "fixedleftright": "Fixed left right", + "rangecenter": "Range center", + "rangefull": "Range full", + "rangeleft": "Range left", + "rangeright": "Range right" } }, "light": { @@ -239,14 +225,14 @@ "name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::name%]", "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]" + "fixedleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]" } }, "light": { @@ -383,7 +369,7 @@ "rangetop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangetop%]", "rangemiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangemiddle%]", "rangebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangebottom%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]", "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" } @@ -391,16 +377,16 @@ "swing_horizontal_mode": { "state": { "stopped": "[%key:common::state::off%]", - "fixedleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleft%]", - "fixedcenterleft": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterleft%]", - "fixedcenter": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenter%]", - "fixedcenterright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedcenterright%]", - "fixedright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedright%]", - "fixedleftright": "[%key:component::sensibo::entity::select::horizontalswing::state::fixedleftright%]", - "rangecenter": "[%key:component::sensibo::entity::select::horizontalswing::state::rangecenter%]", - "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", - "rangeleft": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeleft%]", - "rangeright": "[%key:component::sensibo::entity::select::horizontalswing::state::rangeright%]" + "fixedleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleft%]", + "fixedcenterleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterleft%]", + "fixedcenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenter%]", + "fixedcenterright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedcenterright%]", + "fixedright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedright%]", + "fixedleftright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::fixedleftright%]", + "rangecenter": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangecenter%]", + "rangefull": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangefull%]", + "rangeleft": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangeleft%]", + "rangeright": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::horizontalswing::state::rangeright%]" } } } @@ -590,11 +576,5 @@ "mode_not_exist": { "message": "The entity does not support the chosen mode" } - }, - "issues": { - "deprecated_entity_horizontalswing": { - "title": "The Sensibo {name} entity is deprecated", - "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\nDisable `{entity}` and reload the config entry or restart Home Assistant to fix this issue." - } } } diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index 75dbdc88840f88..05a4fb731d1f57 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -14,16 +14,13 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er -from . import ENTRY_CONFIG - -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import async_fire_time_changed, snapshot_platform @pytest.mark.parametrize( @@ -154,87 +151,3 @@ async def test_select_set_option( state = hass.states.get("select.kitchen_light") assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize( - "load_platforms", - [[Platform.SELECT]], -) -async def test_deprecated_horizontal_swing_select( - hass: HomeAssistant, - load_platforms: list[Platform], - mock_client: MagicMock, - entity_registry: er.EntityRegistry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test the deprecated horizontal swing select entity.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data=ENTRY_CONFIG, - entry_id="1", - unique_id="firstnamelastname", - version=2, - ) - - config_entry.add_to_hass(hass) - - entity_registry.async_get_or_create( - SELECT_DOMAIN, - DOMAIN, - "ABC999111-horizontalSwing", - config_entry=config_entry, - disabled_by=None, - has_entity_name=True, - suggested_object_id="hallway_horizontal_swing", - ) - - with patch("homeassistant.components.sensibo.PLATFORMS", load_platforms): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("select.hallway_horizontal_swing") - assert state.state == "stopped" - - # No issue created without automation or script - assert issue_registry.issues == {} - - with ( - patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), - patch( - # Patch check for automation, that one exist - "homeassistant.components.sensibo.select.automations_with_entity", - return_value=["automation.test"], - ), - ): - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done(True) - - # Issue is created when entity is enabled and automation/script exist - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") - assert issue - assert issue.translation_key == "deprecated_entity_horizontalswing" - assert hass.states.get("select.hallway_horizontal_swing") - assert entity_registry.async_is_registered("select.hallway_horizontal_swing") - - # Disabling the entity should remove the entity and remove the issue - # once the integration is reloaded - entity_registry.async_update_entity( - state.entity_id, disabled_by=er.RegistryEntryDisabler.USER - ) - - with ( - patch("homeassistant.components.sensibo.PLATFORMS", load_platforms), - patch( - "homeassistant.components.sensibo.select.automations_with_entity", - return_value=["automation.test"], - ), - ): - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done(True) - - # Disabling the entity and reloading has removed the entity and issue - assert not hass.states.get("select.hallway_horizontal_swing") - assert not entity_registry.async_is_registered("select.hallway_horizontal_swing") - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_entity_horizontalswing") - assert not issue From c876bed33f8a1b91c5290e8912c2cefdc66bf9d2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 9 Aug 2025 00:24:54 +0200 Subject: [PATCH 6/8] Add ToGrill integration (#150075) Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/components/togrill/__init__.py | 33 + .../components/togrill/config_flow.py | 136 ++++ homeassistant/components/togrill/const.py | 8 + .../components/togrill/coordinator.py | 148 ++++ homeassistant/components/togrill/entity.py | 18 + .../components/togrill/manifest.json | 18 + .../components/togrill/quality_scale.yaml | 68 ++ homeassistant/components/togrill/sensor.py | 127 ++++ homeassistant/components/togrill/strings.json | 32 + homeassistant/generated/bluetooth.py | 6 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/togrill/__init__.py | 40 ++ tests/components/togrill/conftest.py | 96 +++ .../togrill/snapshots/test_sensor.ambr | 673 ++++++++++++++++++ tests/components/togrill/test_config_flow.py | 155 ++++ tests/components/togrill/test_init.py | 60 ++ tests/components/togrill/test_sensor.py | 59 ++ 21 files changed, 1692 insertions(+) create mode 100644 homeassistant/components/togrill/__init__.py create mode 100644 homeassistant/components/togrill/config_flow.py create mode 100644 homeassistant/components/togrill/const.py create mode 100644 homeassistant/components/togrill/coordinator.py create mode 100644 homeassistant/components/togrill/entity.py create mode 100644 homeassistant/components/togrill/manifest.json create mode 100644 homeassistant/components/togrill/quality_scale.yaml create mode 100644 homeassistant/components/togrill/sensor.py create mode 100644 homeassistant/components/togrill/strings.json create mode 100644 tests/components/togrill/__init__.py create mode 100644 tests/components/togrill/conftest.py create mode 100644 tests/components/togrill/snapshots/test_sensor.ambr create mode 100644 tests/components/togrill/test_config_flow.py create mode 100644 tests/components/togrill/test_init.py create mode 100644 tests/components/togrill/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index d52349d49e816a..9a7b961748cccf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1597,6 +1597,8 @@ build.json @home-assistant/supervisor /tests/components/todo/ @home-assistant/core /homeassistant/components/todoist/ @boralyl /tests/components/todoist/ @boralyl +/homeassistant/components/togrill/ @elupus +/tests/components/togrill/ @elupus /homeassistant/components/tolo/ @MatthiasLohr /tests/components/tolo/ @MatthiasLohr /homeassistant/components/tomorrowio/ @raman325 @lymanepp diff --git a/homeassistant/components/togrill/__init__.py b/homeassistant/components/togrill/__init__.py new file mode 100644 index 00000000000000..e938c56b9ee79c --- /dev/null +++ b/homeassistant/components/togrill/__init__.py @@ -0,0 +1,33 @@ +"""The ToGrill integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .coordinator import DeviceNotFound, ToGrillConfigEntry, ToGrillCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: + """Set up ToGrill Bluetooth from a config entry.""" + + coordinator = ToGrillCoordinator(hass, entry) + try: + await coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady as exc: + if not isinstance(exc.__cause__, DeviceNotFound): + raise + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ToGrillConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/togrill/config_flow.py b/homeassistant/components/togrill/config_flow.py new file mode 100644 index 00000000000000..29d930e796198f --- /dev/null +++ b/homeassistant/components/togrill/config_flow.py @@ -0,0 +1,136 @@ +"""Config flow for the ToGrill integration.""" + +from __future__ import annotations + +from typing import Any + +from bleak.exc import BleakError +from togrill_bluetooth import SUPPORTED_DEVICES +from togrill_bluetooth.client import Client +from togrill_bluetooth.packets import PacketA0Notify +import voluptuous as vol + +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_ADDRESS, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow + +from .const import CONF_PROBE_COUNT, DOMAIN +from .coordinator import LOGGER + +_TIMEOUT = 10 + + +async def read_config_data( + hass: HomeAssistant, info: BluetoothServiceInfoBleak +) -> dict[str, Any]: + """Read config from device.""" + + try: + client = await Client.connect(info.device) + except BleakError as exc: + LOGGER.debug("Failed to connect", exc_info=True) + raise AbortFlow("failed_to_read_config") from exc + + try: + packet_a0 = await client.read(PacketA0Notify) + except BleakError as exc: + LOGGER.debug("Failed to read data", exc_info=True) + raise AbortFlow("failed_to_read_config") from exc + finally: + await client.disconnect() + + return { + CONF_MODEL: info.name, + CONF_ADDRESS: info.address, + CONF_PROBE_COUNT: packet_a0.probe_count, + } + + +class ToGrillBluetoothConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for ToGrillBluetooth.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovery_infos: dict[str, BluetoothServiceInfoBleak] = {} + + async def _async_create_entry_internal( + self, info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + config_data = await read_config_data(self.hass, info) + + return self.async_create_entry( + title=config_data[CONF_MODEL], + data=config_data, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> ConfigFlowResult: + """Handle the bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + + if discovery_info.name not in SUPPORTED_DEVICES: + return self.async_abort(reason="not_supported") + + self._discovery_info = discovery_info + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + assert self._discovery_info is not None + discovery_info = self._discovery_info + + if user_input is not None: + return await self._async_create_entry_internal(discovery_info) + + self._set_confirm_only() + placeholders = {"name": discovery_info.name} + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="bluetooth_confirm", description_placeholders=placeholders + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user step to pick discovered device.""" + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return await self._async_create_entry_internal( + self._discovery_infos[address] + ) + + current_addresses = self._async_current_ids() + for discovery_info in async_discovered_service_info(self.hass, True): + address = discovery_info.address + if ( + address in current_addresses + or address in self._discovery_infos + or discovery_info.name not in SUPPORTED_DEVICES + ): + continue + self._discovery_infos[address] = discovery_info + + if not self._discovery_infos: + return self.async_abort(reason="no_devices_found") + + addresses = {info.address: info.name for info in self._discovery_infos.values()} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_ADDRESS): vol.In(addresses)}), + ) diff --git a/homeassistant/components/togrill/const.py b/homeassistant/components/togrill/const.py new file mode 100644 index 00000000000000..dd2fe8209192b5 --- /dev/null +++ b/homeassistant/components/togrill/const.py @@ -0,0 +1,8 @@ +"""Constants for the ToGrill integration.""" + +DOMAIN = "togrill" + +MAX_PROBE_COUNT = 6 + +CONF_PROBE_COUNT = "probe_count" +CONF_VERSION = "version" diff --git a/homeassistant/components/togrill/coordinator.py b/homeassistant/components/togrill/coordinator.py new file mode 100644 index 00000000000000..b79e4350e1e7c1 --- /dev/null +++ b/homeassistant/components/togrill/coordinator.py @@ -0,0 +1,148 @@ +"""Coordinator for the ToGrill Bluetooth integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from bleak.exc import BleakError +from togrill_bluetooth.client import Client +from togrill_bluetooth.exceptions import DecodeError +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth import ( + BluetoothCallbackMatcher, + BluetoothChange, + BluetoothScanningMode, + BluetoothServiceInfoBleak, + async_register_callback, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_MODEL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +type ToGrillConfigEntry = ConfigEntry[ToGrillCoordinator] + +SCAN_INTERVAL = timedelta(seconds=30) +LOGGER = logging.getLogger(__name__) + + +def get_version_string(packet: PacketA0Notify) -> str: + """Construct a version string from packet data.""" + return f"{packet.version_major}.{packet.version_minor}" + + +class DeviceNotFound(UpdateFailed): + """Update failed due to device disconnected.""" + + +class DeviceFailed(UpdateFailed): + """Update failed due to device disconnected.""" + + +class ToGrillCoordinator(DataUpdateCoordinator[dict[int, Packet]]): + """Class to manage fetching data.""" + + config_entry: ToGrillConfigEntry + client: Client | None = None + + def __init__( + self, + hass: HomeAssistant, + config_entry: ToGrillConfigEntry, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass=hass, + logger=LOGGER, + config_entry=config_entry, + name="ToGrill", + update_interval=SCAN_INTERVAL, + ) + self.address = config_entry.data[CONF_ADDRESS] + self.data = {} + self.device_info = DeviceInfo( + connections={(CONNECTION_BLUETOOTH, self.address)} + ) + + config_entry.async_on_unload( + async_register_callback( + hass, + self._async_handle_bluetooth_event, + BluetoothCallbackMatcher(address=self.address, connectable=True), + BluetoothScanningMode.ACTIVE, + ) + ) + + async def _connect_and_update_registry(self) -> Client: + """Update device registry data.""" + device = bluetooth.async_ble_device_from_address( + self.hass, self.address, connectable=True + ) + if not device: + raise DeviceNotFound("Unable to find device") + + client = await Client.connect(device, self._notify_callback) + try: + packet_a0 = await client.read(PacketA0Notify) + except (BleakError, DecodeError) as exc: + await client.disconnect() + raise DeviceFailed(f"Device failed {exc}") from exc + + config_entry = self.config_entry + + device_registry = dr.async_get(self.hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(CONNECTION_BLUETOOTH, self.address)}, + name=config_entry.data[CONF_MODEL], + model=config_entry.data[CONF_MODEL], + sw_version=get_version_string(packet_a0), + ) + + return client + + async def async_shutdown(self) -> None: + """Shutdown coordinator and disconnect from device.""" + await super().async_shutdown() + if self.client: + await self.client.disconnect() + self.client = None + + async def _get_connected_client(self) -> Client: + if self.client and not self.client.is_connected: + await self.client.disconnect() + self.client = None + if self.client: + return self.client + + self.client = await self._connect_and_update_registry() + return self.client + + def _notify_callback(self, packet: Packet): + self.data[packet.type] = packet + self.async_update_listeners() + + async def _async_update_data(self) -> dict[int, Packet]: + """Poll the device.""" + client = await self._get_connected_client() + try: + await client.request(PacketA0Notify) + await client.request(PacketA1Notify) + except BleakError as exc: + raise DeviceFailed(f"Device failed {exc}") from exc + return self.data + + @callback + def _async_handle_bluetooth_event( + self, + service_info: BluetoothServiceInfoBleak, + change: BluetoothChange, + ) -> None: + """Handle a Bluetooth event.""" + if not self.client and isinstance(self.last_exception, DeviceNotFound): + self.hass.async_create_task(self.async_refresh()) diff --git a/homeassistant/components/togrill/entity.py b/homeassistant/components/togrill/entity.py new file mode 100644 index 00000000000000..c1a254557c5d2b --- /dev/null +++ b/homeassistant/components/togrill/entity.py @@ -0,0 +1,18 @@ +"""Provides the base entities.""" + +from __future__ import annotations + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import ToGrillCoordinator + + +class ToGrillEntity(CoordinatorEntity[ToGrillCoordinator]): + """Coordinator entity for Gardena Bluetooth.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: ToGrillCoordinator) -> None: + """Initialize coordinator entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info diff --git a/homeassistant/components/togrill/manifest.json b/homeassistant/components/togrill/manifest.json new file mode 100644 index 00000000000000..7d777b8ae67d6c --- /dev/null +++ b/homeassistant/components/togrill/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "togrill", + "name": "ToGrill", + "bluetooth": [ + { + "manufacturer_id": 34714, + "service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb", + "connectable": true + } + ], + "codeowners": ["@elupus"], + "config_flow": true, + "dependencies": ["bluetooth"], + "documentation": "https://www.home-assistant.io/integrations/togrill", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["togrill-bluetooth==0.4.0"] +} diff --git a/homeassistant/components/togrill/quality_scale.yaml b/homeassistant/components/togrill/quality_scale.yaml new file mode 100644 index 00000000000000..6dd44090f80e12 --- /dev/null +++ b/homeassistant/components/togrill/quality_scale.yaml @@ -0,0 +1,68 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not require authentication. + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration only has a single device. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration only has a single device. + + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: This integration does not need any websession + strict-typing: todo diff --git a/homeassistant/components/togrill/sensor.py b/homeassistant/components/togrill/sensor.py new file mode 100644 index 00000000000000..7298e4b971b48b --- /dev/null +++ b/homeassistant/components/togrill/sensor.py @@ -0,0 +1,127 @@ +"""Support for sensor entities.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, cast + +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketA1Notify + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import ToGrillConfigEntry +from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT +from .coordinator import ToGrillCoordinator +from .entity import ToGrillEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class ToGrillSensorEntityDescription(SensorEntityDescription): + """Description of entity.""" + + packet_type: int + packet_extract: Callable[[Packet], StateType] + entity_supported: Callable[[Mapping[str, Any]], bool] = lambda _: True + + +def _get_temperature_description(probe_number: int): + def _get(packet: Packet) -> StateType: + assert isinstance(packet, PacketA1Notify) + if len(packet.temperatures) < probe_number: + return None + temperature = packet.temperatures[probe_number - 1] + if temperature is None: + return None + return temperature + + def _supported(config: Mapping[str, Any]): + return probe_number <= config[CONF_PROBE_COUNT] + + return ToGrillSensorEntityDescription( + key=f"temperature_{probe_number}", + translation_key="temperature", + translation_placeholders={"probe_number": f"{probe_number}"}, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + packet_type=PacketA1Notify.type, + packet_extract=_get, + entity_supported=_supported, + ) + + +ENTITY_DESCRIPTIONS = ( + ToGrillSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + packet_type=PacketA0Notify.type, + packet_extract=lambda packet: cast(PacketA0Notify, packet).battery, + ), + *[ + _get_temperature_description(probe_number) + for probe_number in range(1, MAX_PROBE_COUNT + 1) + ], +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ToGrillConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + ToGrillSensor(coordinator, entity_description) + for entity_description in ENTITY_DESCRIPTIONS + if entity_description.entity_supported(entry.data) + ) + + +class ToGrillSensor(ToGrillEntity, SensorEntity): + """Representation of a sensor.""" + + entity_description: ToGrillSensorEntityDescription + + def __init__( + self, + coordinator: ToGrillCoordinator, + entity_description: ToGrillSensorEntityDescription, + ) -> None: + """Initialize sensor.""" + + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = f"{coordinator.address}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.native_value is not None + + @property + def native_value(self) -> StateType: + """Get current value.""" + if packet := self.coordinator.data.get(self.entity_description.packet_type): + return self.entity_description.packet_extract(packet) + return None diff --git a/homeassistant/components/togrill/strings.json b/homeassistant/components/togrill/strings.json new file mode 100644 index 00000000000000..1b75e3872218bd --- /dev/null +++ b/homeassistant/components/togrill/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + }, + "data_description": { + "address": "Select the device to add." + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "failed_to_read_config": "Failed to read config from device" + } + }, + "entity": { + "sensor": { + "temperature": { + "name": "Probe {probe_number}" + } + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index da6cab4bc22d7e..fcaa824ff39022 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -834,6 +834,12 @@ ], "manufacturer_id": 76, }, + { + "connectable": True, + "domain": "togrill", + "manufacturer_id": 34714, + "service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb", + }, { "connectable": False, "domain": "xiaomi_ble", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 8de75b21bba9f2..823bd339d512ae 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -653,6 +653,7 @@ "tilt_pi", "time_date", "todoist", + "togrill", "tolo", "tomorrowio", "toon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e9a8f46a496392..d40f882240b477 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6790,6 +6790,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "togrill": { + "name": "ToGrill", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "tolo": { "name": "TOLO Sauna", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 2cdb87b5ad2c1b..a876b41b8dbaf8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2955,6 +2955,9 @@ tmb==0.0.4 # homeassistant.components.todoist todoist-api-python==2.1.7 +# homeassistant.components.togrill +togrill-bluetooth==0.4.0 + # homeassistant.components.tolo tololib==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9dfd138977c221..7059e5691ab847 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2432,6 +2432,9 @@ tilt-pi==0.2.1 # homeassistant.components.todoist todoist-api-python==2.1.7 +# homeassistant.components.togrill +togrill-bluetooth==0.4.0 + # homeassistant.components.tolo tololib==1.2.2 diff --git a/tests/components/togrill/__init__.py b/tests/components/togrill/__init__.py new file mode 100644 index 00000000000000..9e0d164ae2a6b2 --- /dev/null +++ b/tests/components/togrill/__init__.py @@ -0,0 +1,40 @@ +"""Tests for the ToGrill Bluetooth integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.common import MockConfigEntry + +TOGRILL_SERVICE_INFO = BluetoothServiceInfo( + name="Pro-05", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={34714: b"\xd9\xe3\xbe\xf3\x00"}, + service_uuids=["0000cee0-0000-1000-8000-00805f9b34fb"], + source="local", +) + +TOGRILL_SERVICE_INFO_NO_NAME = BluetoothServiceInfo( + name="", + address="00000000-0000-0000-0000-000000000002", + rssi=-63, + service_data={}, + manufacturer_data={34714: b"\xd9\xe3\xbe\xf3\x00"}, + service_uuids=["0000cee0-0000-1000-8000-00805f9b34fb"], + source="local", +) + + +async def setup_entry( + hass: HomeAssistant, mock_entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Make sure the device is available.""" + + with patch("homeassistant.components.togrill._PLATFORMS", platforms): + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/togrill/conftest.py b/tests/components/togrill/conftest.py new file mode 100644 index 00000000000000..6b028ca52700f4 --- /dev/null +++ b/tests/components/togrill/conftest.py @@ -0,0 +1,96 @@ +"""Common fixtures for the ToGrill tests.""" + +from collections.abc import Callable, Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from togrill_bluetooth.client import Client +from togrill_bluetooth.packets import Packet, PacketA0Notify, PacketNotify + +from homeassistant.components.togrill.const import CONF_PROBE_COUNT, DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_MODEL + +from . import TOGRILL_SERVICE_INFO + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_entry() -> MockConfigEntry: + """Create hass config fixture.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: TOGRILL_SERVICE_INFO.address, + CONF_MODEL: "Pro-05", + CONF_PROBE_COUNT: 2, + }, + unique_id=TOGRILL_SERVICE_INFO.address, + ) + + +@pytest.fixture(scope="module") +def mock_unload_entry() -> Generator[AsyncMock]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.togrill.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture(scope="module") +def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.togrill.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(autouse=True) +def mock_client(enable_bluetooth: None, mock_client_class: Mock) -> Generator[Mock]: + """Auto mock bluetooth.""" + + client_object = Mock(spec=Client) + client_object.mocked_notify = None + + async def _connect( + address: str, callback: Callable[[Packet], None] | None = None + ) -> Mock: + client_object.mocked_notify = callback + return client_object + + async def _disconnect() -> None: + pass + + async def _request(packet_type: type[Packet]) -> None: + if packet_type is PacketA0Notify: + client_object.mocked_notify(PacketA0Notify(0, 0, 0, 0, 0, False, 0, False)) + + async def _read(packet_type: type[PacketNotify]) -> PacketNotify: + if packet_type is PacketA0Notify: + return PacketA0Notify(0, 0, 0, 0, 0, False, 0, False) + raise NotImplementedError + + mock_client_class.connect.side_effect = _connect + client_object.request.side_effect = _request + client_object.read.side_effect = _read + client_object.disconnect.side_effect = _disconnect + client_object.is_connected = True + + return client_object + + +@pytest.fixture(autouse=True) +def mock_client_class() -> Generator[Mock]: + """Auto mock bluetooth.""" + + with ( + patch( + "homeassistant.components.togrill.config_flow.Client", autospec=True + ) as client_class, + patch("homeassistant.components.togrill.coordinator.Client", new=client_class), + ): + yield client_class diff --git a/tests/components/togrill/snapshots/test_sensor.ambr b/tests/components/togrill/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..bc55d8315004cf --- /dev/null +++ b/tests/components/togrill/snapshots/test_sensor.ambr @@ -0,0 +1,673 @@ +# serializer version: 1 +# name: test_setup[battery][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[battery][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '45', + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[battery][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[no_data][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pro_05_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000-0000-0000-0000-000000000001_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Pro-05 Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.pro_05_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 1', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 1', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pro_05_probe_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe 2', + 'platform': 'togrill', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '00000000-0000-0000-0000-000000000001_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[temp_data_missing_probe][sensor.pro_05_probe_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Pro-05 Probe 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pro_05_probe_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/togrill/test_config_flow.py b/tests/components/togrill/test_config_flow.py new file mode 100644 index 00000000000000..2620a88f7f29b3 --- /dev/null +++ b/tests/components/togrill/test_config_flow.py @@ -0,0 +1,155 @@ +"""Test the ToGrill config flow.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +import pytest + +from homeassistant import config_entries +from homeassistant.components.togrill.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import TOGRILL_SERVICE_INFO, TOGRILL_SERVICE_INFO_NO_NAME, setup_entry + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_user_selection( + hass: HomeAssistant, +) -> None: + """Test we can select a device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO_NO_NAME) + await hass.async_block_till_done(wait_background_tasks=True) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "address": TOGRILL_SERVICE_INFO.address, + "model": "Pro-05", + "probe_count": 0, + } + assert result["title"] == "Pro-05" + assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address + + +async def test_failed_connect( + hass: HomeAssistant, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test failure to connect result.""" + + mock_client_class.connect.side_effect = BleakError("Failed to connect") + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "failed_to_read_config" + + +async def test_failed_read( + hass: HomeAssistant, + mock_client: Mock, +) -> None: + """Test failure to read from device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_client.read.side_effect = BleakError("something went wrong") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"address": TOGRILL_SERVICE_INFO.address}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "failed_to_read_config" + + +async def test_no_devices( + hass: HomeAssistant, +) -> None: + """Test missing device.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO_NO_NAME) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_duplicate_setup( + hass: HomeAssistant, + mock_entry: MockConfigEntry, +) -> None: + """Test we can not setup a device again.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + await setup_entry(hass, mock_entry, []) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_bluetooth( + hass: HomeAssistant, +) -> None: + """Test bluetooth device discovery.""" + + # Inject the service info will trigger the flow to start + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + await hass.async_block_till_done(wait_background_tasks=True) + + result = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN))) + + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "address": TOGRILL_SERVICE_INFO.address, + "model": "Pro-05", + "probe_count": 0, + } + assert result["title"] == "Pro-05" + assert result["result"].unique_id == TOGRILL_SERVICE_INFO.address diff --git a/tests/components/togrill/test_init.py b/tests/components/togrill/test_init.py new file mode 100644 index 00000000000000..24f19ba367e829 --- /dev/null +++ b/tests/components/togrill/test_init.py @@ -0,0 +1,60 @@ +"""Test for initialization of ToGrill integration.""" + +from unittest.mock import Mock + +from bleak.exc import BleakError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry +from tests.components.bluetooth import inject_bluetooth_service_info + + +async def test_setup_device_present( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup works with device present.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.LOADED + + +async def test_setup_device_not_present( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup succeeds if device is missing.""" + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.LOADED + + +async def test_setup_device_failing( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + mock_client_class: Mock, +) -> None: + """Test that setup fails if device is not responding.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + mock_client.is_connected = False + mock_client.read.side_effect = BleakError("Failed to read data") + + await setup_entry(hass, mock_entry, []) + assert mock_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/togrill/test_sensor.py b/tests/components/togrill/test_sensor.py new file mode 100644 index 00000000000000..d7662d483af5a6 --- /dev/null +++ b/tests/components/togrill/test_sensor.py @@ -0,0 +1,59 @@ +"""Test sensors for ToGrill integration.""" + +from unittest.mock import Mock + +import pytest +from syrupy.assertion import SnapshotAssertion +from togrill_bluetooth.packets import PacketA0Notify, PacketA1Notify + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import TOGRILL_SERVICE_INFO, setup_entry + +from tests.common import MockConfigEntry, snapshot_platform +from tests.components.bluetooth import inject_bluetooth_service_info + + +@pytest.mark.parametrize( + "packets", + [ + pytest.param([], id="no_data"), + pytest.param( + [ + PacketA0Notify( + battery=45, + version_major=1, + version_minor=5, + function_type=1, + probe_count=2, + ambient=False, + alarm_interval=5, + alarm_sound=True, + ) + ], + id="battery", + ), + pytest.param([PacketA1Notify([10, None])], id="temp_data"), + pytest.param([PacketA1Notify([10])], id="temp_data_missing_probe"), + ], +) +async def test_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_entry: MockConfigEntry, + mock_client: Mock, + packets, +) -> None: + """Test the sensors.""" + + inject_bluetooth_service_info(hass, TOGRILL_SERVICE_INFO) + + await setup_entry(hass, mock_entry, [Platform.SENSOR]) + + for packet in packets: + mock_client.mocked_notify(packet) + + await snapshot_platform(hass, entity_registry, snapshot, mock_entry.entry_id) From 1af0282091a53c2ecb3c890f07944f219fff9bea Mon Sep 17 00:00:00 2001 From: MB901 <80067777+MB901@users.noreply.github.com> Date: Sat, 9 Aug 2025 00:54:53 +0200 Subject: [PATCH 7/8] Add hardware version to FreeboxRouter device info (#150004) --- homeassistant/components/freebox/router.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index d6c45cd178b50e..8ba7d88d938440 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -117,6 +117,7 @@ def __init__( self.name: str = freebox_config["model_info"]["pretty_name"] self.mac: str = freebox_config["mac"] self._sw_v: str = freebox_config["firmware_version"] + self._hw_v: str | None = freebox_config.get("board_name") self._attrs: dict[str, Any] = {} self.supports_hosts = True @@ -282,7 +283,9 @@ def device_info(self) -> DeviceInfo: identifiers={(DOMAIN, self.mac)}, manufacturer="Freebox SAS", name=self.name, + model=self.name, sw_version=self._sw_v, + hw_version=self._hw_v, ) @property From 775701133d340df2bd7b3643600e3f21a9231ed1 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Sat, 9 Aug 2025 00:17:48 +0100 Subject: [PATCH 8/8] Remove deprecated notify platform from Mastodon (#149735) --- homeassistant/components/mastodon/__init__.py | 21 +-- homeassistant/components/mastodon/notify.py | 152 ------------------ .../components/mastodon/quality_scale.yaml | 16 +- .../components/mastodon/strings.json | 6 - tests/components/mastodon/test_notify.py | 65 -------- 5 files changed, 7 insertions(+), 253 deletions(-) delete mode 100644 homeassistant/components/mastodon/notify.py delete mode 100644 tests/components/mastodon/test_notify.py diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py index 17b8614a2e9d0f..b6e0d863471b6b 100644 --- a/homeassistant/components/mastodon/__init__.py +++ b/homeassistant/components/mastodon/__init__.py @@ -8,12 +8,11 @@ CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET, - CONF_NAME, Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify @@ -22,7 +21,7 @@ from .services import setup_services from .utils import construct_mastodon_username, create_mastodon_client -PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @@ -53,26 +52,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> entry.runtime_data = MastodonData(client, instance, account, coordinator) - await discovery.async_load_platform( - hass, - Platform.NOTIFY, - DOMAIN, - {CONF_NAME: entry.title, "client": client}, - {}, - ) - - await hass.config_entries.async_forward_entry_setups( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms( - entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] - ) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -> bool: diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py deleted file mode 100644 index 149ef1f6a48566..00000000000000 --- a/homeassistant/components/mastodon/notify.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Mastodon platform for notify component.""" - -from __future__ import annotations - -from typing import Any, cast - -from mastodon import Mastodon -from mastodon.Mastodon import MastodonAPIError, MediaAttachment -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_DATA, - PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, - BaseNotificationService, -) -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv, issue_registry as ir -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -from .const import ( - ATTR_CONTENT_WARNING, - ATTR_MEDIA_WARNING, - CONF_BASE_URL, - DEFAULT_URL, - DOMAIN, -) -from .utils import get_media_type - -ATTR_MEDIA = "media" -ATTR_TARGET = "target" - -PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_BASE_URL, default=DEFAULT_URL): cv.string, - } -) - -INTEGRATION_TITLE = "Mastodon" - - -async def async_get_service( - hass: HomeAssistant, - config: ConfigType, - discovery_info: DiscoveryInfoType | None = None, -) -> MastodonNotificationService | None: - """Get the Mastodon notification service.""" - if discovery_info is None: - return None - - client = cast(Mastodon, discovery_info.get("client")) - - return MastodonNotificationService(hass, client) - - -class MastodonNotificationService(BaseNotificationService): - """Implement the notification service for Mastodon.""" - - def __init__( - self, - hass: HomeAssistant, - client: Mastodon, - ) -> None: - """Initialize the service.""" - - self.client = client - - def send_message(self, message: str = "", **kwargs: Any) -> None: - """Toot a message, with media perhaps.""" - - ir.create_issue( - self.hass, - DOMAIN, - "deprecated_notify_action_mastodon", - breaks_in_ha_version="2025.9.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_notify_action", - ) - - target = None - if (target_list := kwargs.get(ATTR_TARGET)) is not None: - target = cast(list[str], target_list)[0] - - data = kwargs.get(ATTR_DATA) - - media = None - mediadata = None - sensitive = False - content_warning = None - - if data: - media = data.get(ATTR_MEDIA) - if media: - if not self.hass.config.is_allowed_path(media): - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="not_whitelisted_directory", - translation_placeholders={"media": media}, - ) - mediadata = self._upload_media(media) - - sensitive = data.get(ATTR_MEDIA_WARNING) - content_warning = data.get(ATTR_CONTENT_WARNING) - - if mediadata: - try: - self.client.status_post( - message, - visibility=target, - spoiler_text=content_warning, - media_ids=mediadata.id, - sensitive=sensitive, - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_send_message", - ) from err - - else: - try: - self.client.status_post( - message, visibility=target, spoiler_text=content_warning - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_send_message", - ) from err - - def _upload_media(self, media_path: Any = None) -> MediaAttachment: - """Upload media.""" - with open(media_path, "rb"): - media_type = get_media_type(media_path) - try: - mediadata: MediaAttachment = self.client.media_post( - media_path, mime_type=media_type - ) - except MastodonAPIError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="unable_to_upload_image", - translation_placeholders={"media_path": media_path}, - ) from err - - return mediadata diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml index f07f7e0a8adfd2..c5a928bac59be6 100644 --- a/homeassistant/components/mastodon/quality_scale.yaml +++ b/homeassistant/components/mastodon/quality_scale.yaml @@ -26,10 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: - status: todo - comment: | - Awaiting legacy Notify deprecation. + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt @@ -39,19 +36,12 @@ rules: entity-unavailable: done integration-owner: done log-when-unavailable: done - parallel-updates: - status: todo - comment: | - Awaiting legacy Notify deprecation. + parallel-updates: done reauthentication-flow: status: todo comment: | Waiting to move to oAuth. - test-coverage: - status: todo - comment: | - Awaiting legacy Notify deprecation. - + test-coverage: done # Gold devices: done diagnostics: done diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json index 9e6cf6db6bfbef..c37f9b2e94161e 100644 --- a/homeassistant/components/mastodon/strings.json +++ b/homeassistant/components/mastodon/strings.json @@ -42,12 +42,6 @@ "message": "{media} is not a whitelisted directory." } }, - "issues": { - "deprecated_notify_action": { - "title": "Deprecated Notify action used for Mastodon", - "description": "The Notify action for Mastodon is deprecated.\n\nUse the `mastodon.post` action instead." - } - }, "entity": { "sensor": { "followers": { diff --git a/tests/components/mastodon/test_notify.py b/tests/components/mastodon/test_notify.py deleted file mode 100644 index 4242f88d34a2e5..00000000000000 --- a/tests/components/mastodon/test_notify.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Tests for the Mastodon notify platform.""" - -from unittest.mock import AsyncMock - -from mastodon.Mastodon import MastodonAPIError -import pytest -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er - -from . import setup_integration - -from tests.common import MockConfigEntry - - -async def test_notify( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - entity_registry: er.EntityRegistry, - mock_mastodon_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test sending a message.""" - await setup_integration(hass, mock_config_entry) - - assert hass.services.has_service(NOTIFY_DOMAIN, "trwnh_mastodon_social") - - await hass.services.async_call( - NOTIFY_DOMAIN, - "trwnh_mastodon_social", - { - "message": "test toot", - }, - blocking=True, - return_response=False, - ) - - assert mock_mastodon_client.status_post.assert_called_once - - -async def test_notify_failed( - hass: HomeAssistant, - mock_mastodon_client: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test the notify raising an error.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_mastodon_client.status_post.side_effect = MastodonAPIError - - with pytest.raises(HomeAssistantError, match="Unable to send message"): - await hass.services.async_call( - NOTIFY_DOMAIN, - "trwnh_mastodon_social", - { - "message": "test toot", - }, - blocking=True, - return_response=False, - )