diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2116834364e9ce..c4707d0b0247bf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 5 + CACHE_VERSION: 6 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.9" @@ -1341,7 +1341,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v5.5.0 with: fail_ci_if_error: true flags: full-suite @@ -1491,7 +1491,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v5.4.3 + uses: codecov/codecov-action@v5.5.0 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8673c5f4b87fd0..5706da163a2992 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.9 + uses: github/codeql-action/init@v3.29.10 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.9 + uses: github/codeql-action/analyze@v3.29.10 with: category: "/language:python" diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 8b276021d38593..b1641e8dd48c61 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -449,5 +449,6 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: return { "version": "home-assistant:1", + "home_assistant": HA_VERSION, "devices": devices, } diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index b5042d07b8207b..6e33f3a0b43ba6 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -15,6 +15,7 @@ from asusrouter.modules.client import AsusClient from asusrouter.modules.data import AsusData from asusrouter.modules.homeassistant import convert_to_ha_data, convert_to_ha_sensors +from asusrouter.tools.connection import get_cookie_jar from homeassistant.const import ( CONF_HOST, @@ -25,7 +26,7 @@ CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.update_coordinator import UpdateFailed @@ -109,7 +110,10 @@ def get_bridge( ) -> AsusWrtBridge: """Get Bridge instance.""" if conf[CONF_PROTOCOL] in (PROTOCOL_HTTPS, PROTOCOL_HTTP): - session = async_get_clientsession(hass) + session = async_create_clientsession( + hass, + cookie_jar=get_cookie_jar(), + ) return AsusWrtHttpBridge(conf, session) return AsusWrtLegacyBridge(conf, options) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 6c8b0536e2ffb4..61c00e8dc8dc7b 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -14,7 +14,7 @@ "documentation": "https://www.home-assistant.io/integrations/cast", "iot_class": "local_polling", "loggers": ["casttube", "pychromecast"], - "requirements": ["PyChromecast==14.0.7"], + "requirements": ["PyChromecast==14.0.9"], "single_config_entry": true, "zeroconf": ["_googlecast._tcp.local."] } diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index fd4521549a22c6..4537dec0e287d9 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from automower_ble.mower import Mower +from automower_ble.protocol import ResponseResult from bleak import BleakError from bleak_retry_connector import close_stale_connections_by_address, get_device @@ -37,12 +38,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> device = bluetooth.async_ble_device_from_address( hass, address, connectable=True ) or await get_device(address) - if not await mower.connect(device): - raise ConfigEntryNotReady + response_result = await mower.connect(device) + if response_result != ResponseResult.OK: + raise ConfigEntryNotReady( + f"Unable to connect to device {address}, mower returned {response_result}" + ) except (TimeoutError, BleakError) as exception: raise ConfigEntryNotReady( f"Unable to connect to device {address} due to {exception}" ) from exception + LOGGER.debug("connected and paired") model = await mower.get_model() diff --git a/homeassistant/components/husqvarna_automower_ble/coordinator.py b/homeassistant/components/husqvarna_automower_ble/coordinator.py index ef9ccfa5a4732d..cd5b4e06005eca 100644 --- a/homeassistant/components/husqvarna_automower_ble/coordinator.py +++ b/homeassistant/components/husqvarna_automower_ble/coordinator.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from automower_ble.mower import Mower +from automower_ble.protocol import ResponseResult from bleak import BleakError from bleak_retry_connector import close_stale_connections_by_address @@ -62,7 +63,7 @@ async def _async_find_device(self): ) try: - if not await self.mower.connect(device): + if await self.mower.connect(device) is not ResponseResult.OK: raise UpdateFailed("Failed to connect") except BleakError as err: raise UpdateFailed("Failed to connect") from err diff --git a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py index 4b4a16ba1dbea2..78d39ddd96a629 100644 --- a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py @@ -2,7 +2,7 @@ from __future__ import annotations -from automower_ble.protocol import MowerActivity, MowerState +from automower_ble.protocol import MowerActivity, MowerState, ResponseResult from homeassistant.components import bluetooth from homeassistant.components.lawn_mower import ( @@ -107,7 +107,7 @@ async def async_start_mowing(self) -> None: device = bluetooth.async_ble_device_from_address( self.coordinator.hass, self.coordinator.address, connectable=True ) - if not await self.coordinator.mower.connect(device): + if await self.coordinator.mower.connect(device) is not ResponseResult.OK: return await self.coordinator.mower.mower_resume() @@ -126,7 +126,7 @@ async def async_dock(self) -> None: device = bluetooth.async_ble_device_from_address( self.coordinator.hass, self.coordinator.address, connectable=True ) - if not await self.coordinator.mower.connect(device): + if await self.coordinator.mower.connect(device) is not ResponseResult.OK: return await self.coordinator.mower.mower_park() @@ -143,7 +143,7 @@ async def async_pause(self) -> None: device = bluetooth.async_ble_device_from_address( self.coordinator.hass, self.coordinator.address, connectable=True ) - if not await self.coordinator.mower.connect(device): + if await self.coordinator.mower.connect(device) is not ResponseResult.OK: return await self.coordinator.mower.mower_pause() diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 6eb618cbb04cbe..50430c2a9fadf5 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.1"] + "requirements": ["automower-ble==0.2.7"] } diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 3b5b13398a58aa..fb5e04fbff0a35 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -850,6 +850,14 @@ class StateDryingStep(MieleEnum): 24813: "appliance_settings", # modify profile name } +COFFEE_SYSTEM_PROFILE: dict[range, str] = { + range(24000, 24032): "profile_1", + range(24032, 24064): "profile_2", + range(24064, 24096): "profile_3", + range(24096, 24128): "profile_4", + range(24128, 24160): "profile_5", +} + STEAM_OVEN_MICRO_PROGRAM_ID: dict[int, str] = { 8: "steam_cooking", 19: "microwave", diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index cc108841aae989..982a1198dab362 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass import logging -from typing import Final, cast +from typing import Any, Final, cast from pymiele import MieleDevice, MieleTemperature @@ -30,6 +30,7 @@ from homeassistant.helpers.typing import StateType from .const import ( + COFFEE_SYSTEM_PROFILE, DISABLED_TEMP_ENTITIES, DOMAIN, STATE_PROGRAM_ID, @@ -61,6 +62,8 @@ "KMX": 6, } +ATTRIBUTE_PROFILE = "profile" + def _get_plate_count(tech_type: str) -> int: """Get number of zones for hob.""" @@ -88,11 +91,21 @@ def _convert_temperature( return raw_value +def _get_coffee_profile(value: MieleDevice) -> str | None: + """Get coffee profile from value.""" + if value.state_program_id is not None: + for key_range, profile in COFFEE_SYSTEM_PROFILE.items(): + if value.state_program_id in key_range: + return profile + return None + + @dataclass(frozen=True, kw_only=True) class MieleSensorDescription(SensorEntityDescription): """Class describing Miele sensor entities.""" value_fn: Callable[[MieleDevice], StateType] + extra_attributes: dict[str, Callable[[MieleDevice], StateType]] | None = None zone: int | None = None unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None @@ -157,7 +170,6 @@ class MieleSensorDefinition: MieleAppliance.OVEN_MICROWAVE, MieleAppliance.STEAM_OVEN, MieleAppliance.MICROWAVE, - MieleAppliance.COFFEE_SYSTEM, MieleAppliance.ROBOT_VACUUM_CLEANER, MieleAppliance.WASHER_DRYER, MieleAppliance.STEAM_OVEN_COMBI, @@ -172,6 +184,18 @@ class MieleSensorDefinition: value_fn=lambda value: value.state_program_id, ), ), + MieleSensorDefinition( + types=(MieleAppliance.COFFEE_SYSTEM,), + description=MieleSensorDescription( + key="state_program_id", + translation_key="program_id", + device_class=SensorDeviceClass.ENUM, + value_fn=lambda value: value.state_program_id, + extra_attributes={ + ATTRIBUTE_PROFILE: _get_coffee_profile, + }, + ), + ), MieleSensorDefinition( types=( MieleAppliance.WASHING_MACHINE, @@ -710,6 +734,16 @@ def native_value(self) -> StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.device) + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return extra_state_attributes.""" + if self.entity_description.extra_attributes is None: + return None + attr = {} + for key, value in self.entity_description.extra_attributes.items(): + attr[key] = value(self.device) + return attr + class MielePlateSensor(MieleSensor): """Representation of a Sensor.""" @@ -792,6 +826,8 @@ def options(self) -> list[str]: class MieleProgramIdSensor(MieleSensor): """Representation of the program id sensor.""" + _unrecorded_attributes = frozenset({ATTRIBUTE_PROFILE}) + @property def native_value(self) -> StateType: """Return the state of the sensor.""" diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index cb9861e0246ce9..4f0fa48e724783 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -991,6 +991,18 @@ "yom_tov": "Yom tov", "yorkshire_pudding": "Yorkshire pudding", "zander_fillet": "Zander (fillet)" + }, + "state_attributes": { + "profile": { + "name": "Profile", + "state": { + "profile_1": "Profile 1", + "profile_2": "Profile 2", + "profile_3": "Profile 3", + "profile_4": "Profile 4", + "profile_5": "Profile 5" + } + } } }, "spin_speed": { diff --git a/requirements_all.txt b/requirements_all.txt index d90d2ce32191f8..3cf08cf15a81b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -48,7 +48,7 @@ ProgettiHWSW==0.1.3 # PyBluez==0.22 # homeassistant.components.cast -PyChromecast==14.0.7 +PyChromecast==14.0.9 # homeassistant.components.flick_electric PyFlick==1.1.3 @@ -563,7 +563,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.1 +automower-ble==0.2.7 # homeassistant.components.generic # homeassistant.components.stream diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ca1f2cd18f425f..8b87b683899acd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ PlexAPI==4.15.16 ProgettiHWSW==0.1.3 # homeassistant.components.cast -PyChromecast==14.0.7 +PyChromecast==14.0.9 # homeassistant.components.flick_electric PyFlick==1.1.3 @@ -518,7 +518,7 @@ aurorapy==0.2.7 autarco==3.1.0 # homeassistant.components.husqvarna_automower_ble -automower-ble==0.2.1 +automower-ble==0.2.7 # homeassistant.components.generic # homeassistant.components.stream diff --git a/script/licenses.py b/script/licenses.py index d7819cba5362fd..ef62d4970dd1ca 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -202,6 +202,7 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: "pysabnzbd", # https://github.com/jeradM/pysabnzbd/pull/6 "sharp_aquos_rc", # https://github.com/jmoore987/sharp_aquos_rc/pull/14 "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 + "ujson", # https://github.com/ultrajson/ultrajson/blob/main/LICENSE.txt } # fmt: off diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 1ade8eed37ec86..51579177e7e0e8 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -985,6 +985,7 @@ async def test_devices_payload( assert await async_setup_component(hass, "analytics", {}) assert await async_devices_payload(hass) == { "version": "home-assistant:1", + "home_assistant": MOCK_VERSION, "devices": [], } @@ -1052,6 +1053,7 @@ async def test_devices_payload( assert await async_devices_payload(hass) == { "version": "home-assistant:1", + "home_assistant": MOCK_VERSION, "devices": [ { "manufacturer": "test-manufacturer", diff --git a/tests/components/husqvarna_automower_ble/conftest.py b/tests/components/husqvarna_automower_ble/conftest.py index 3a8e881aba0b27..1081db014e3fe6 100644 --- a/tests/components/husqvarna_automower_ble/conftest.py +++ b/tests/components/husqvarna_automower_ble/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch +from automower_ble.protocol import ResponseResult import pytest from homeassistant.components.husqvarna_automower_ble.const import DOMAIN @@ -37,7 +38,7 @@ def mock_automower_client(enable_bluetooth: None) -> Generator[AsyncMock]: ), ): client = mock_client.return_value - client.connect.return_value = True + client.connect.return_value = ResponseResult.OK client.is_connected.return_value = True client.get_model.return_value = "305" client.battery_level.return_value = 100 diff --git a/tests/components/husqvarna_automower_ble/test_init.py b/tests/components/husqvarna_automower_ble/test_init.py index 3cb4338eca4095..95a0a1f203710e 100644 --- a/tests/components/husqvarna_automower_ble/test_init.py +++ b/tests/components/husqvarna_automower_ble/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import Mock -from bleak import BleakError +from automower_ble.protocol import ResponseResult import pytest from syrupy.assertion import SnapshotAssertion @@ -46,7 +46,7 @@ async def test_setup_retry_connect( ) -> None: """Test setup creates expected devices.""" - mock_automower_client.connect.return_value = False + mock_automower_client.connect.side_effect = TimeoutError mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) @@ -55,14 +55,13 @@ async def test_setup_retry_connect( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY -async def test_setup_failed_connect( +async def test_setup_unknown_error( hass: HomeAssistant, mock_automower_client: Mock, mock_config_entry: MockConfigEntry, ) -> None: - """Test setup creates expected devices.""" - - mock_automower_client.connect.side_effect = BleakError + """Test setup fails when we receive an error from the device.""" + mock_automower_client.connect.return_value = ResponseResult.UNKNOWN_ERROR mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/husqvarna_automower_ble/test_lawn_mower.py b/tests/components/husqvarna_automower_ble/test_lawn_mower.py index 3f00d3dbff0060..2a127c785d9726 100644 --- a/tests/components/husqvarna_automower_ble/test_lawn_mower.py +++ b/tests/components/husqvarna_automower_ble/test_lawn_mower.py @@ -3,10 +3,12 @@ from datetime import timedelta from unittest.mock import Mock +from automower_ble.protocol import MowerActivity, MowerState from bleak import BleakError from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.lawn_mower import LawnMowerActivity from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -124,3 +126,83 @@ async def test_bleak_error_data_update( await hass.async_block_till_done() assert hass.states.get("lawn_mower.husqvarna_automower").state == STATE_UNAVAILABLE + + +OPERATIONAL_STATES = [ + MowerState.IN_OPERATION, + MowerState.PENDING_START, + MowerState.RESTRICTED, +] + + +@pytest.mark.parametrize( + ("mower_states", "mower_activities", "expected_state"), + [ + # MowerState ERROR, FATAL_ERROR, OFF, STOPPED, WAIT_FOR_SAFETYPIN -> Mapped to + # LawnMowerActivity.ERROR + ( + [ + MowerState.ERROR, + MowerState.FATAL_ERROR, + MowerState.OFF, + MowerState.STOPPED, + MowerState.WAIT_FOR_SAFETYPIN, + ], + list(MowerActivity), + LawnMowerActivity.ERROR, + ), + # MowerState PAUSED -> Mapped to LawnMowerActivity.PAUSED + ([MowerState.PAUSED], list(MowerActivity), LawnMowerActivity.PAUSED), + # Operational states are mapped according to the activity + ( + OPERATIONAL_STATES, + [MowerActivity.CHARGING, MowerActivity.NONE, MowerActivity.PARKED], + LawnMowerActivity.DOCKED, + ), + ( + OPERATIONAL_STATES, + [MowerActivity.GOING_OUT, MowerActivity.MOWING], + LawnMowerActivity.MOWING, + ), + ( + OPERATIONAL_STATES, + [MowerActivity.GOING_HOME], + LawnMowerActivity.RETURNING, + ), + ( + OPERATIONAL_STATES, + [MowerActivity.STOPPED_IN_GARDEN], + LawnMowerActivity.ERROR, + ), + ], +) +async def test_mower_activity_mapping( + hass: HomeAssistant, + mock_automower_client: Mock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mower_states: list[MowerState], + mower_activities: list[MowerActivity], + expected_state: str, +) -> None: + """Test mower state and activity mapping to LawnMowerActivity states.""" + + 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 ConfigEntryState.LOADED + + for mower_state in mower_states: + for mower_activity in mower_activities: + mock_automower_client.mower_state.return_value = mower_state + mock_automower_client.mower_activity.return_value = mower_activity + + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("lawn_mower.husqvarna_automower").state + == expected_state + ) diff --git a/tests/components/miele/fixtures/coffee_system.json b/tests/components/miele/fixtures/coffee_system.json new file mode 100644 index 00000000000000..36039e7be7f07a --- /dev/null +++ b/tests/components/miele/fixtures/coffee_system.json @@ -0,0 +1,126 @@ +{ + "DummyAppliance_CoffeeSystem": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 17, + "value_localized": "Coffee machine" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "11", + "techType": "CM6160", + "matNumber": "11488670", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK037", + "releaseVersion": "04.05" + } + }, + "state": { + "ProgramID": { + "value_raw": 24001, + "value_localized": "espresso", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 4353, + "value_localized": "Espresso", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": 1, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 5d941550f419be..33ec2013936bc3 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1,4 +1,354 @@ # serializer version: 1 +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.coffee_system-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.coffee_system', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:coffee-maker', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_CoffeeSystem-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.coffee_system-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Coffee system', + 'icon': 'mdi:coffee-maker', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.coffee_system', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.coffee_system_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'appliance_rinse', + 'appliance_settings', + 'barista_assistant', + 'black_tea', + 'brewing_unit_degrease', + 'cafe_au_lait', + 'caffe_latte', + 'cappuccino', + 'cappuccino_italiano', + 'check_appliance', + 'coffee', + 'coffee_pot', + 'descaling', + 'espresso', + 'espresso_macchiato', + 'flat_white', + 'fruit_tea', + 'green_tea', + 'herbal_tea', + 'hot_milk', + 'hot_water', + 'japanese_tea', + 'latte_macchiato', + 'long_coffee', + 'milk_foam', + 'milk_pipework_clean', + 'milk_pipework_rinse', + 'no_program', + 'ristretto', + 'very_hot_water', + 'white_tea', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.coffee_system_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_CoffeeSystem-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.coffee_system_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Coffee system Program', + 'options': list([ + 'appliance_rinse', + 'appliance_settings', + 'barista_assistant', + 'black_tea', + 'brewing_unit_degrease', + 'cafe_au_lait', + 'caffe_latte', + 'cappuccino', + 'cappuccino_italiano', + 'check_appliance', + 'coffee', + 'coffee_pot', + 'descaling', + 'espresso', + 'espresso_macchiato', + 'flat_white', + 'fruit_tea', + 'green_tea', + 'herbal_tea', + 'hot_milk', + 'hot_water', + 'japanese_tea', + 'latte_macchiato', + 'long_coffee', + 'milk_foam', + 'milk_pipework_clean', + 'milk_pipework_rinse', + 'no_program', + 'ristretto', + 'very_hot_water', + 'white_tea', + ]), + 'profile': 'profile_1', + }), + 'context': , + 'entity_id': 'sensor.coffee_system_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'espresso', + }) +# --- +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.coffee_system_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '2nd_espresso', + '2nd_grinding', + '2nd_pre_brewing', + 'dispensing', + 'espresso', + 'grinding', + 'heating_up', + 'hot_milk', + 'milk_foam', + 'not_running', + 'pre_brewing', + 'rinse', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.coffee_system_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_CoffeeSystem-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.coffee_system_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Coffee system Program phase', + 'options': list([ + '2nd_espresso', + '2nd_grinding', + '2nd_pre_brewing', + 'dispensing', + 'espresso', + 'grinding', + 'heating_up', + 'hot_milk', + 'milk_foam', + 'not_running', + 'pre_brewing', + 'rinse', + ]), + }), + 'context': , + 'entity_id': 'sensor.coffee_system_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'espresso', + }) +# --- +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.coffee_system_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.coffee_system_program_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_CoffeeSystem-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_coffee_system_sensor_states[platforms0-coffee_system.json][sensor.coffee_system_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Coffee system Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.coffee_system_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- # name: test_fan_hob_sensor_states[platforms0-fan_devices.json][sensor.hob_with_extraction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index e5051a683c9a71..0fc7a891509990 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -271,3 +271,18 @@ async def test_fan_hob_sensor_states( """Test robot fan / hob sensor state.""" await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["coffee_system.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_coffee_system_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test coffee system sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) diff --git a/tests/conftest.py b/tests/conftest.py index acb50b0029cf8c..130ce74dd5b229 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1846,19 +1846,30 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: # Late imports to avoid loading bleak unless we need it - from habluetooth import scanner as bluetooth_scanner # noqa: PLC0415 + from habluetooth import ( # noqa: PLC0415 + manager as bluetooth_manager, + scanner as bluetooth_scanner, + ) # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called # when EVENT_HOMEASSISTANT_STOP is fired. # pylint: disable-next=c-extension-no-member bluetooth_scanner.OriginalBleakScanner.stop = AsyncMock() # type: ignore[assignment] + + # Mock BlueZ management controller + mock_mgmt_bluetooth_ctl = Mock() + mock_mgmt_bluetooth_ctl.setup = AsyncMock(side_effect=OSError("Mocked error")) + with ( patch.object( bluetooth_scanner.OriginalBleakScanner, # pylint: disable=c-extension-no-member "start", ) as mock_bleak_scanner_start, patch.object(bluetooth_scanner, "HaScanner"), + patch.object( + bluetooth_manager, "MGMTBluetoothCtl", return_value=mock_mgmt_bluetooth_ctl + ), ): yield mock_bleak_scanner_start