Merge branch 'dev' into mqtt-subentry-light

This commit is contained in:
Jan Bouwhuis 2025-04-13 22:11:16 +02:00 committed by GitHub
commit 1d6ae8f03f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 2488 additions and 57 deletions

2
CODEOWNERS generated
View File

@ -937,6 +937,8 @@ build.json @home-assistant/supervisor
/tests/components/metoffice/ @MrHarcombe @avee87 /tests/components/metoffice/ @MrHarcombe @avee87
/homeassistant/components/microbees/ @microBeesTech /homeassistant/components/microbees/ @microBeesTech
/tests/components/microbees/ @microBeesTech /tests/components/microbees/ @microBeesTech
/homeassistant/components/miele/ @astrandb
/tests/components/miele/ @astrandb
/homeassistant/components/mikrotik/ @engrbm87 /homeassistant/components/mikrotik/ @engrbm87
/tests/components/mikrotik/ @engrbm87 /tests/components/mikrotik/ @engrbm87
/homeassistant/components/mill/ @danielhiversen /homeassistant/components/mill/ @danielhiversen

View File

@ -16,7 +16,7 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"], "loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"], "mqtt": ["esphome/discover/#"],
"requirements": [ "requirements": [
"aioesphomeapi==29.9.0", "aioesphomeapi==29.10.0",
"esphome-dashboard-api==1.2.3", "esphome-dashboard-api==1.2.3",
"bleak-esphome==2.13.1" "bleak-esphome==2.13.1"
], ],

View File

@ -40,5 +40,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/inkbird", "documentation": "https://www.home-assistant.io/integrations/inkbird",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["inkbird-ble==0.10.1"] "requirements": ["inkbird-ble==0.11.0"]
} }

View File

@ -0,0 +1,70 @@
"""The Miele integration."""
from __future__ import annotations
from aiohttp import ClientError, ClientResponseError
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from .api import AsyncConfigEntryAuth
from .const import DOMAIN
from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool:
"""Set up Miele from a config entry."""
implementation = await async_get_config_entry_implementation(hass, entry)
session = OAuth2Session(hass, entry, implementation)
auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session)
try:
await auth.async_get_access_token()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="config_entry_auth_failed",
) from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
) from err
except ClientError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="config_entry_not_ready",
) from err
# Setup MieleAPI and coordinator for data fetch
coordinator = MieleDataUpdateCoordinator(hass, auth)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
entry.async_create_background_task(
hass,
coordinator.api.listen_events(
data_callback=coordinator.callback_update_data,
actions_callback=coordinator.callback_update_actions,
),
"pymiele event listener",
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -0,0 +1,27 @@
"""API for Miele bound to Home Assistant OAuth."""
from typing import cast
from aiohttp import ClientSession
from pymiele import MIELE_API, AbstractAuth
from homeassistant.helpers import config_entry_oauth2_flow
class AsyncConfigEntryAuth(AbstractAuth):
"""Provide Miele authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: config_entry_oauth2_flow.OAuth2Session,
) -> None:
"""Initialize Miele auth."""
super().__init__(websession, MIELE_API)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])

View File

@ -0,0 +1,21 @@
"""Application credentials platform for the Miele integration."""
from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server."""
return AuthorizationServer(
authorize_url=OAUTH2_AUTHORIZE,
token_url=OAUTH2_TOKEN,
)
async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
"""Return description placeholders for the credentials dialog."""
return {
"register_url": "https://www.miele.com/f/com/en/register_api.aspx",
}

View File

@ -0,0 +1,73 @@
"""Config flow for Miele."""
from collections.abc import Mapping
import logging
from typing import Any
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlowResult,
)
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
class OAuth2FlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
"""Config flow to handle Miele OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
# "vg" is mandatory but the value doesn't seem to matter
return {
"vg": "sv-SE",
}
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)
return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: Mapping[str, Any] | None = None
) -> ConfigFlowResult:
"""User initiated reconfiguration."""
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create or update the config entry."""
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
if self.source == SOURCE_RECONFIGURE:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data=data
)
return await super().async_oauth_create_entry(data)

View File

@ -0,0 +1,154 @@
"""Constants for the Miele integration."""
from enum import IntEnum
DOMAIN = "miele"
MANUFACTURER = "Miele"
ACTIONS = "actions"
POWER_ON = "powerOn"
POWER_OFF = "powerOff"
PROCESS_ACTION = "processAction"
class MieleAppliance(IntEnum):
"""Define appliance types."""
WASHING_MACHINE = 1
TUMBLE_DRYER = 2
WASHING_MACHINE_SEMI_PROFESSIONAL = 3
TUMBLE_DRYER_SEMI_PROFESSIONAL = 4
WASHING_MACHINE_PROFESSIONAL = 5
DRYER_PROFESSIONAL = 6
DISHWASHER = 7
DISHWASHER_SEMI_PROFESSIONAL = 8
DISHWASHER_PROFESSIONAL = 9
OVEN = 12
OVEN_MICROWAVE = 13
HOB_HIGHLIGHT = 14
STEAM_OVEN = 15
MICROWAVE = 16
COFFEE_SYSTEM = 17
HOOD = 18
FRIDGE = 19
FREEZER = 20
FRIDGE_FREEZER = 21
ROBOT_VACUUM_CLEANER = 23
WASHER_DRYER = 24
DISH_WARMER = 25
HOB_INDUCTION = 27
STEAM_OVEN_COMBI = 31
WINE_CABINET = 32
WINE_CONDITIONING_UNIT = 33
WINE_STORAGE_CONDITIONING_UNIT = 34
STEAM_OVEN_MICRO = 45
DIALOG_OVEN = 67
WINE_CABINET_FREEZER = 68
STEAM_OVEN_MK2 = 73
HOB_INDUCT_EXTR = 74
DEVICE_TYPE_TAGS = {
MieleAppliance.WASHING_MACHINE: "washing_machine",
MieleAppliance.TUMBLE_DRYER: "tumble_dryer",
MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: "washing_machine",
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "tumble_dryer",
MieleAppliance.WASHING_MACHINE_PROFESSIONAL: "washing_machine",
MieleAppliance.DRYER_PROFESSIONAL: "tumble_dryer",
MieleAppliance.DISHWASHER: "dishwasher",
MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: "dishwasher",
MieleAppliance.DISHWASHER_PROFESSIONAL: "dishwasher",
MieleAppliance.OVEN: "oven",
MieleAppliance.OVEN_MICROWAVE: "oven_microwave",
MieleAppliance.HOB_HIGHLIGHT: "hob",
MieleAppliance.STEAM_OVEN: "steam_oven",
MieleAppliance.MICROWAVE: "microwave",
MieleAppliance.COFFEE_SYSTEM: "coffee_system",
MieleAppliance.HOOD: "hood",
MieleAppliance.FRIDGE: "refrigerator",
MieleAppliance.FREEZER: "freezer",
MieleAppliance.FRIDGE_FREEZER: "fridge_freezer",
MieleAppliance.ROBOT_VACUUM_CLEANER: "robot_vacuum_cleaner",
MieleAppliance.WASHER_DRYER: "washer_dryer",
MieleAppliance.DISH_WARMER: "warming_drawer",
MieleAppliance.HOB_INDUCTION: "hob",
MieleAppliance.STEAM_OVEN_COMBI: "steam_oven_combi",
MieleAppliance.WINE_CABINET: "wine_cabinet",
MieleAppliance.WINE_CONDITIONING_UNIT: "wine_conditioning_unit",
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "wine_unit",
MieleAppliance.STEAM_OVEN_MICRO: "steam_oven_micro",
MieleAppliance.DIALOG_OVEN: "dialog_oven",
MieleAppliance.WINE_CABINET_FREEZER: "wine_cabinet_freezer",
MieleAppliance.STEAM_OVEN_MK2: "steam_oven",
MieleAppliance.HOB_INDUCT_EXTR: "hob_extraction",
}
class StateStatus(IntEnum):
"""Define appliance states."""
RESERVED = 0
OFF = 1
ON = 2
PROGRAMMED = 3
WAITING_TO_START = 4
RUNNING = 5
PAUSE = 6
PROGRAM_ENDED = 7
FAILURE = 8
PROGRAM_INTERRUPTED = 9
IDLE = 10
RINSE_HOLD = 11
SERVICE = 12
SUPERFREEZING = 13
SUPERCOOLING = 14
SUPERHEATING = 15
SUPERCOOLING_SUPERFREEZING = 146
AUTOCLEANING = 147
NOT_CONNECTED = 255
STATE_STATUS_TAGS = {
StateStatus.OFF: "off",
StateStatus.ON: "on",
StateStatus.PROGRAMMED: "programmed",
StateStatus.WAITING_TO_START: "waiting_to_start",
StateStatus.RUNNING: "running",
StateStatus.PAUSE: "pause",
StateStatus.PROGRAM_ENDED: "program_ended",
StateStatus.FAILURE: "failure",
StateStatus.PROGRAM_INTERRUPTED: "program_interrupted",
StateStatus.IDLE: "idle",
StateStatus.RINSE_HOLD: "rinse_hold",
StateStatus.SERVICE: "service",
StateStatus.SUPERFREEZING: "superfreezing",
StateStatus.SUPERCOOLING: "supercooling",
StateStatus.SUPERHEATING: "superheating",
StateStatus.SUPERCOOLING_SUPERFREEZING: "supercooling_superfreezing",
StateStatus.AUTOCLEANING: "autocleaning",
StateStatus.NOT_CONNECTED: "not_connected",
}
class MieleActions(IntEnum):
"""Define appliance actions."""
START = 1
STOP = 2
PAUSE = 3
START_SUPERFREEZE = 4
STOP_SUPERFREEZE = 5
START_SUPERCOOL = 6
STOP_SUPERCOOL = 7
# Possible actions
PROCESS_ACTIONS = {
"start": MieleActions.START,
"stop": MieleActions.STOP,
"pause": MieleActions.PAUSE,
"start_superfreezing": MieleActions.START_SUPERFREEZE,
"stop_superfreezing": MieleActions.STOP_SUPERFREEZE,
"start_supercooling": MieleActions.START_SUPERCOOL,
"stop_supercooling": MieleActions.STOP_SUPERCOOL,
}

View File

@ -0,0 +1,87 @@
"""Coordinator module for Miele integration."""
from __future__ import annotations
import asyncio.timeouts
from dataclasses import dataclass
from datetime import timedelta
import logging
from pymiele import MieleAction, MieleDevice
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .api import AsyncConfigEntryAuth
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type MieleConfigEntry = ConfigEntry[MieleDataUpdateCoordinator]
@dataclass
class MieleCoordinatorData:
"""Data class for storing coordinator data."""
devices: dict[str, MieleDevice]
actions: dict[str, MieleAction]
class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
"""Coordinator for Miele data."""
def __init__(
self,
hass: HomeAssistant,
api: AsyncConfigEntryAuth,
) -> None:
"""Initialize the Miele data coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=timedelta(seconds=120),
)
self.api = api
async def _async_update_data(self) -> MieleCoordinatorData:
"""Fetch data from the Miele API."""
async with asyncio.timeout(10):
# Get devices
devices_json = await self.api.get_devices()
devices = {
device_id: MieleDevice(device)
for device_id, device in devices_json.items()
}
actions = {}
for device_id in devices:
actions_json = await self.api.get_actions(device_id)
actions[device_id] = MieleAction(actions_json)
return MieleCoordinatorData(devices=devices, actions=actions)
async def callback_update_data(self, devices_json: dict[str, dict]) -> None:
"""Handle data update from the API."""
devices = {
device_id: MieleDevice(device) for device_id, device in devices_json.items()
}
self.async_set_updated_data(
MieleCoordinatorData(
devices=devices,
actions=self.data.actions,
)
)
async def callback_update_actions(self, actions_json: dict[str, dict]) -> None:
"""Handle data update from the API."""
actions = {
device_id: MieleAction(action) for device_id, action in actions_json.items()
}
self.async_set_updated_data(
MieleCoordinatorData(
devices=self.data.devices,
actions=actions,
)
)

View File

@ -0,0 +1,56 @@
"""Entity base class for the Miele integration."""
from pymiele import MieleDevice
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEVICE_TYPE_TAGS, DOMAIN, MANUFACTURER, MieleAppliance, StateStatus
from .coordinator import MieleDataUpdateCoordinator
class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]):
"""Base class for Miele entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._device_id = device_id
self.entity_description = description
self._attr_unique_id = f"{device_id}-{description.key}"
device = self.device
appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type))
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device_id)},
serial_number=device_id,
name=appliance_type or device.tech_type,
translation_key=appliance_type,
manufacturer=MANUFACTURER,
model=device.tech_type,
hw_version=device.xkm_tech_type,
sw_version=device.xkm_release_version,
)
@property
def device(self) -> MieleDevice:
"""Return the device object."""
return self.coordinator.data.devices[self._device_id]
@property
def available(self) -> bool:
"""Return the availability of the entity."""
return (
super().available
and self._device_id in self.coordinator.data.devices
and (self.device.state_status is not StateStatus.NOT_CONNECTED)
)

View File

@ -0,0 +1,13 @@
{
"domain": "miele",
"name": "Miele",
"codeowners": ["@astrandb"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/miele",
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "bronze",
"requirements": ["pymiele==0.3.4"],
"single_config_entry": true
}

View File

@ -0,0 +1,76 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
No custom actions are defined.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
No custom actions are defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
No explicit event subscriptions.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry:
status: done
comment: |
Handled by a setting in manifest.json as there is no account information in API
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No configuration parameters
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: exempt
comment: Handled by coordinator
reauthentication-flow: done
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@ -0,0 +1,211 @@
"""Sensor platform for Miele integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Final, cast
from pymiele import MieleDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import STATE_STATUS_TAGS, MieleAppliance, StateStatus
from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
from .entity import MieleEntity
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class MieleSensorDescription(SensorEntityDescription):
"""Class describing Miele sensor entities."""
value_fn: Callable[[MieleDevice], StateType]
zone: int | None = None
@dataclass
class MieleSensorDefinition:
"""Class for defining sensor entities."""
types: tuple[MieleAppliance, ...]
description: MieleSensorDescription
SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
MieleSensorDefinition(
types=(
MieleAppliance.WASHING_MACHINE,
MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL,
MieleAppliance.TUMBLE_DRYER,
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
MieleAppliance.DISHWASHER,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.HOB_HIGHLIGHT,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.COFFEE_SYSTEM,
MieleAppliance.HOOD,
MieleAppliance.FRIDGE,
MieleAppliance.FREEZER,
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.ROBOT_VACUUM_CLEANER,
MieleAppliance.WASHER_DRYER,
MieleAppliance.DISH_WARMER,
MieleAppliance.HOB_INDUCTION,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.WINE_CABINET_FREEZER,
MieleAppliance.STEAM_OVEN_MK2,
MieleAppliance.HOB_INDUCT_EXTR,
),
description=MieleSensorDescription(
key="state_status",
translation_key="status",
value_fn=lambda value: value.state_status,
device_class=SensorDeviceClass.ENUM,
options=list(STATE_STATUS_TAGS.values()),
),
),
MieleSensorDefinition(
types=(
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
MieleAppliance.OVEN,
MieleAppliance.OVEN_MICROWAVE,
MieleAppliance.DISH_WARMER,
MieleAppliance.STEAM_OVEN,
MieleAppliance.MICROWAVE,
MieleAppliance.FRIDGE,
MieleAppliance.FREEZER,
MieleAppliance.FRIDGE_FREEZER,
MieleAppliance.STEAM_OVEN_COMBI,
MieleAppliance.WINE_CABINET,
MieleAppliance.WINE_CONDITIONING_UNIT,
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
MieleAppliance.STEAM_OVEN_MICRO,
MieleAppliance.DIALOG_OVEN,
MieleAppliance.WINE_CABINET_FREEZER,
MieleAppliance.STEAM_OVEN_MK2,
),
description=MieleSensorDescription(
key="state_temperature_1",
zone=1,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: cast(int, value.state_temperatures[0].temperature)
/ 100.0,
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MieleConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
entities: list = []
entity_class: type[MieleSensor]
for device_id, device in coordinator.data.devices.items():
for definition in SENSOR_TYPES:
if device.device_type in definition.types:
match definition.description.key:
case "state_status":
entity_class = MieleStatusSensor
case _:
entity_class = MieleSensor
entities.append(
entity_class(coordinator, device_id, definition.description)
)
async_add_entities(entities)
APPLIANCE_ICONS = {
MieleAppliance.WASHING_MACHINE: "mdi:washing-machine",
MieleAppliance.TUMBLE_DRYER: "mdi:tumble-dryer",
MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "mdi:tumble-dryer",
MieleAppliance.DISHWASHER: "mdi:dishwasher",
MieleAppliance.OVEN: "mdi:chef-hat",
MieleAppliance.OVEN_MICROWAVE: "mdi:chef-hat",
MieleAppliance.HOB_HIGHLIGHT: "mdi:pot-steam-outline",
MieleAppliance.STEAM_OVEN: "mdi:chef-hat",
MieleAppliance.MICROWAVE: "mdi:microwave",
MieleAppliance.COFFEE_SYSTEM: "mdi:coffee-maker",
MieleAppliance.HOOD: "mdi:turbine",
MieleAppliance.FRIDGE: "mdi:fridge-industrial-outline",
MieleAppliance.FREEZER: "mdi:fridge-industrial-outline",
MieleAppliance.FRIDGE_FREEZER: "mdi:fridge-outline",
MieleAppliance.ROBOT_VACUUM_CLEANER: "mdi:robot-vacuum",
MieleAppliance.WASHER_DRYER: "mdi:washing-machine",
MieleAppliance.DISH_WARMER: "mdi:heat-wave",
MieleAppliance.HOB_INDUCTION: "mdi:pot-steam-outline",
MieleAppliance.STEAM_OVEN_COMBI: "mdi:chef-hat",
MieleAppliance.WINE_CABINET: "mdi:glass-wine",
MieleAppliance.WINE_CONDITIONING_UNIT: "mdi:glass-wine",
MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "mdi:glass-wine",
MieleAppliance.STEAM_OVEN_MICRO: "mdi:chef-hat",
MieleAppliance.DIALOG_OVEN: "mdi:chef-hat",
MieleAppliance.WINE_CABINET_FREEZER: "mdi:glass-wine",
MieleAppliance.HOB_INDUCT_EXTR: "mdi:pot-steam-outline",
}
class MieleSensor(MieleEntity, SensorEntity):
"""Representation of a Sensor."""
entity_description: MieleSensorDescription
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.device)
class MieleStatusSensor(MieleSensor):
"""Representation of the status sensor."""
def __init__(
self,
coordinator: MieleDataUpdateCoordinator,
device_id: str,
description: MieleSensorDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, device_id, description)
self._attr_name = None
self._attr_icon = APPLIANCE_ICONS.get(
MieleAppliance(self.device.device_type),
"mdi:state-machine",
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return STATE_STATUS_TAGS.get(StateStatus(self.device.state_status))
@property
def available(self) -> bool:
"""Return the availability of the entity."""
# This sensor should always be available
return True

View File

@ -0,0 +1,154 @@
{
"application_credentials": {
"description": "Navigate to [\"Get involved\" at Miele developer site]({register_url}) to request credentials then enter them below."
},
"config": {
"step": {
"confirm": {
"description": "[%key:common::config_flow::description::confirm_setup%]"
},
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Miele integration needs to re-authenticate your account"
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"account_mismatch": "The used account does not match the original account",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
}
},
"device": {
"coffee_system": {
"name": "Coffee system"
},
"dishwasher": {
"name": "Dishwasher"
},
"tumble_dryer": {
"name": "Tumble dryer"
},
"fridge_freezer": {
"name": "Fridge freezer"
},
"induction_hob": {
"name": "Induction hob"
},
"oven": {
"name": "Oven"
},
"oven_microwave": {
"name": "Oven microwave"
},
"hob_highlight": {
"name": "Hob highlight"
},
"steam_oven": {
"name": "Steam oven"
},
"microwave": {
"name": "Microwave"
},
"hood": {
"name": "Hood"
},
"warming_drawer": {
"name": "Warming drawer"
},
"steam_oven_combi": {
"name": "Steam oven combi"
},
"wine_cabinet": {
"name": "Wine cabinet"
},
"wine_conditioning_unit": {
"name": "Wine conditioning unit"
},
"wine_unit": {
"name": "Wine unit"
},
"refrigerator": {
"name": "Refrigerator"
},
"freezer": {
"name": "Freezer"
},
"robot_vacuum_cleander": {
"name": "Robot vacuum cleaner"
},
"steam_oven_microwave": {
"name": "Steam oven micro"
},
"dialog_oven": {
"name": "Dialog oven"
},
"wine_cabinet_freezer": {
"name": "Wine cabinet freezer"
},
"hob_extraction": {
"name": "How with extraction"
},
"washer_dryer": {
"name": "Washer dryer"
},
"washing_machine": {
"name": "Washing machine"
}
},
"entity": {
"sensor": {
"status": {
"name": "Status",
"state": {
"autocleaning": "Automatic cleaning",
"failure": "Failure",
"idle": "[%key:common::state::idle%]",
"not_connected": "Not connected",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"pause": "Pause",
"program_ended": "Program ended",
"program_interrupted": "Program interrupted",
"programmed": "Programmed",
"rinse_hold": "Rinse hold",
"running": "Running",
"service": "Service",
"supercooling": "Supercooling",
"supercooling_superfreezing": "Supercooling/superfreezing",
"superfreezing": "Superfreezing",
"superheating": "Superheating",
"waiting_to_start": "Waiting to start"
}
}
}
},
"exceptions": {
"config_entry_auth_failed": {
"message": "Authentication failed. Please log in again."
},
"config_entry_not_ready": {
"message": "Error while loading the integration."
},
"set_switch_error": {
"message": "Failed to set state for {entity}."
}
}
}

View File

@ -56,6 +56,7 @@ ABBREVIATIONS = {
"ent_pic": "entity_picture", "ent_pic": "entity_picture",
"evt_typ": "event_types", "evt_typ": "event_types",
"fanspd_lst": "fan_speed_list", "fanspd_lst": "fan_speed_list",
"flsh": "flash",
"flsh_tlng": "flash_time_long", "flsh_tlng": "flash_time_long",
"flsh_tsht": "flash_time_short", "flsh_tsht": "flash_time_short",
"fx_cmd_tpl": "effect_command_template", "fx_cmd_tpl": "effect_command_template",
@ -253,6 +254,7 @@ ABBREVIATIONS = {
"tilt_status_tpl": "tilt_status_template", "tilt_status_tpl": "tilt_status_template",
"tit": "title", "tit": "title",
"t": "topic", "t": "topic",
"trns": "transition",
"uniq_id": "unique_id", "uniq_id": "unique_id",
"unit_of_meas": "unit_of_measurement", "unit_of_meas": "unit_of_measurement",
"url_t": "url_topic", "url_t": "url_topic",

View File

@ -87,6 +87,7 @@ CONF_EFFECT_TEMPLATE = "effect_template"
CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template"
CONF_ENTITY_PICTURE = "entity_picture" CONF_ENTITY_PICTURE = "entity_picture"
CONF_EXPIRE_AFTER = "expire_after" CONF_EXPIRE_AFTER = "expire_after"
CONF_FLASH = "flash"
CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_LONG = "flash_time_long"
CONF_FLASH_TIME_SHORT = "flash_time_short" CONF_FLASH_TIME_SHORT = "flash_time_short"
CONF_GREEN_TEMPLATE = "green_template" CONF_GREEN_TEMPLATE = "green_template"
@ -139,6 +140,7 @@ CONF_TEMP_STATE_TOPIC = "temperature_state_topic"
CONF_TEMP_INITIAL = "initial" CONF_TEMP_INITIAL = "initial"
CONF_TEMP_MAX = "max_temp" CONF_TEMP_MAX = "max_temp"
CONF_TEMP_MIN = "min_temp" CONF_TEMP_MIN = "min_temp"
CONF_TRANSITION = "transition"
CONF_XY_COMMAND_TEMPLATE = "xy_command_template" CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
CONF_XY_COMMAND_TOPIC = "xy_command_topic" CONF_XY_COMMAND_TOPIC = "xy_command_topic"
CONF_XY_STATE_TOPIC = "xy_state_topic" CONF_XY_STATE_TOPIC = "xy_state_topic"

View File

@ -59,6 +59,7 @@ from ..const import (
CONF_COLOR_TEMP_KELVIN, CONF_COLOR_TEMP_KELVIN,
CONF_COMMAND_TOPIC, CONF_COMMAND_TOPIC,
CONF_EFFECT_LIST, CONF_EFFECT_LIST,
CONF_FLASH,
CONF_FLASH_TIME_LONG, CONF_FLASH_TIME_LONG,
CONF_FLASH_TIME_SHORT, CONF_FLASH_TIME_SHORT,
CONF_MAX_KELVIN, CONF_MAX_KELVIN,
@ -69,6 +70,7 @@ from ..const import (
CONF_RETAIN, CONF_RETAIN,
CONF_STATE_TOPIC, CONF_STATE_TOPIC,
CONF_SUPPORTED_COLOR_MODES, CONF_SUPPORTED_COLOR_MODES,
CONF_TRANSITION,
DEFAULT_BRIGHTNESS, DEFAULT_BRIGHTNESS,
DEFAULT_BRIGHTNESS_SCALE, DEFAULT_BRIGHTNESS_SCALE,
DEFAULT_EFFECT, DEFAULT_EFFECT,
@ -93,6 +95,9 @@ DOMAIN = "mqtt_json"
DEFAULT_NAME = "MQTT JSON Light" DEFAULT_NAME = "MQTT JSON Light"
DEFAULT_FLASH = True
DEFAULT_TRANSITION = True
_PLATFORM_SCHEMA_BASE = ( _PLATFORM_SCHEMA_BASE = (
MQTT_RW_SCHEMA.extend( MQTT_RW_SCHEMA.extend(
{ {
@ -103,6 +108,7 @@ _PLATFORM_SCHEMA_BASE = (
vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean, vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean,
vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean, vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean,
vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_FLASH, default=DEFAULT_FLASH): cv.boolean,
vol.Optional( vol.Optional(
CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG
): cv.positive_int, ): cv.positive_int,
@ -125,6 +131,7 @@ _PLATFORM_SCHEMA_BASE = (
vol.Unique(), vol.Unique(),
valid_supported_color_modes, valid_supported_color_modes,
), ),
vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.boolean,
vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All( vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All(
vol.Coerce(int), vol.Range(min=1) vol.Coerce(int), vol.Range(min=1)
), ),
@ -199,12 +206,13 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
for key in (CONF_FLASH_TIME_SHORT, CONF_FLASH_TIME_LONG) for key in (CONF_FLASH_TIME_SHORT, CONF_FLASH_TIME_LONG)
} }
self._attr_supported_features = (
LightEntityFeature.TRANSITION | LightEntityFeature.FLASH
)
self._attr_supported_features |= ( self._attr_supported_features |= (
config[CONF_EFFECT] and LightEntityFeature.EFFECT config[CONF_EFFECT] and LightEntityFeature.EFFECT
) )
self._attr_supported_features |= config[CONF_FLASH] and LightEntityFeature.FLASH
self._attr_supported_features |= (
config[CONF_TRANSITION] and LightEntityFeature.TRANSITION
)
if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES): if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES):
self._attr_supported_color_modes = supported_color_modes self._attr_supported_color_modes = supported_color_modes
if self.supported_color_modes and len(self.supported_color_modes) == 1: if self.supported_color_modes and len(self.supported_color_modes) == 1:

View File

@ -679,9 +679,9 @@
"platform": { "platform": {
"options": { "options": {
"light": "[%key:component::light::title%]", "light": "[%key:component::light::title%]",
"notify": "Notify", "notify": "[%key:component::notify::title%]",
"sensor": "Sensor", "sensor": "[%key:component::sensor::title%]",
"switch": "Switch" "switch": "[%key:component::switch::title%]"
} }
}, },
"set_ca_cert": { "set_ca_cert": {

View File

@ -4,17 +4,16 @@ from __future__ import annotations
from pyuptimerobot import UptimeRobot from pyuptimerobot import UptimeRobot
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, PLATFORMS from .const import DOMAIN, PLATFORMS
from .coordinator import UptimeRobotDataUpdateCoordinator from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) -> bool:
"""Set up UptimeRobot from a config entry.""" """Set up UptimeRobot from a config entry."""
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
key: str = entry.data[CONF_API_KEY] key: str = entry.data[CONF_API_KEY]
@ -37,7 +36,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(
hass: HomeAssistant, entry: UptimeRobotConfigEntry
) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok: if unload_ok:

View File

@ -7,18 +7,17 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import UptimeRobotDataUpdateCoordinator from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator
from .entity import UptimeRobotEntity from .entity import UptimeRobotEntity
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: UptimeRobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the UptimeRobot binary_sensors.""" """Set up the UptimeRobot binary_sensors."""

View File

@ -17,16 +17,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER
type UptimeRobotConfigEntry = ConfigEntry[UptimeRobotDataUpdateCoordinator]
class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]):
"""Data update coordinator for UptimeRobot.""" """Data update coordinator for UptimeRobot."""
config_entry: ConfigEntry config_entry: UptimeRobotConfigEntry
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: UptimeRobotConfigEntry,
api: UptimeRobot, api: UptimeRobot,
) -> None: ) -> None:
"""Initialize coordinator.""" """Initialize coordinator."""

View File

@ -6,16 +6,15 @@ from typing import Any
from pyuptimerobot import UptimeRobotException from pyuptimerobot import UptimeRobotException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import DOMAIN from .const import DOMAIN
from .coordinator import UptimeRobotDataUpdateCoordinator from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator
async def async_get_config_entry_diagnostics( async def async_get_config_entry_diagnostics(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: UptimeRobotConfigEntry,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]

View File

@ -7,13 +7,12 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorEntityDescription, SensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN from .const import DOMAIN
from .coordinator import UptimeRobotDataUpdateCoordinator from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator
from .entity import UptimeRobotEntity from .entity import UptimeRobotEntity
SENSORS_INFO = { SENSORS_INFO = {
@ -27,7 +26,7 @@ SENSORS_INFO = {
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: UptimeRobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the UptimeRobot sensors.""" """Set up the UptimeRobot sensors."""

View File

@ -11,18 +11,17 @@ from homeassistant.components.switch import (
SwitchEntity, SwitchEntity,
SwitchEntityDescription, SwitchEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import API_ATTR_OK, DOMAIN, LOGGER from .const import API_ATTR_OK, DOMAIN, LOGGER
from .coordinator import UptimeRobotDataUpdateCoordinator from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator
from .entity import UptimeRobotEntity from .entity import UptimeRobotEntity
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: ConfigEntry, entry: UptimeRobotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the UptimeRobot switches.""" """Set up the UptimeRobot switches."""

View File

@ -156,8 +156,6 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {} errors: dict[str, str] = {}
errors = {}
try: try:
await validate_input(self.hass, user_input) await validate_input(self.hass, user_input)
except aiovodafone_exceptions.AlreadyLogged: except aiovodafone_exceptions.AlreadyLogged:

View File

@ -21,6 +21,7 @@ APPLICATION_CREDENTIALS = [
"lyric", "lyric",
"mcp", "mcp",
"microbees", "microbees",
"miele",
"monzo", "monzo",
"myuplink", "myuplink",
"neato", "neato",

View File

@ -378,6 +378,7 @@ FLOWS = {
"meteoclimatic", "meteoclimatic",
"metoffice", "metoffice",
"microbees", "microbees",
"miele",
"mikrotik", "mikrotik",
"mill", "mill",
"minecraft_server", "minecraft_server",

View File

@ -3937,6 +3937,13 @@
} }
} }
}, },
"miele": {
"name": "Miele",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_push",
"single_config_entry": true
},
"mijndomein_energie": { "mijndomein_energie": {
"name": "Mijndomein Energie", "name": "Mijndomein Energie",
"integration_type": "virtual", "integration_type": "virtual",

View File

@ -29,7 +29,7 @@ from homeassistant.helpers.check_config import async_check_ha_config_file
# mypy: allow-untyped-calls, allow-untyped-defs # mypy: allow-untyped-calls, allow-untyped-defs
REQUIREMENTS = ("colorlog==6.8.2",) REQUIREMENTS = ("colorlog==6.9.0",)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
MOCKS: dict[str, tuple[str, Callable]] = { MOCKS: dict[str, tuple[str, Callable]] = {

9
requirements_all.txt generated
View File

@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5 aioemonitor==1.0.5
# homeassistant.components.esphome # homeassistant.components.esphome
aioesphomeapi==29.9.0 aioesphomeapi==29.10.0
# homeassistant.components.flo # homeassistant.components.flo
aioflo==2021.11.0 aioflo==2021.11.0
@ -709,7 +709,7 @@ coinbase-advanced-py==1.2.2
coinbase==2.1.0 coinbase==2.1.0
# homeassistant.scripts.check_config # homeassistant.scripts.check_config
colorlog==6.8.2 colorlog==6.9.0
# homeassistant.components.color_extractor # homeassistant.components.color_extractor
colorthief==0.2.1 colorthief==0.2.1
@ -1235,7 +1235,7 @@ influxdb-client==1.24.0
influxdb==5.3.1 influxdb==5.3.1
# homeassistant.components.inkbird # homeassistant.components.inkbird
inkbird-ble==0.10.1 inkbird-ble==0.11.0
# homeassistant.components.insteon # homeassistant.components.insteon
insteon-frontend-home-assistant==0.5.0 insteon-frontend-home-assistant==0.5.0
@ -2133,6 +2133,9 @@ pymeteoclimatic==0.1.0
# homeassistant.components.assist_pipeline # homeassistant.components.assist_pipeline
pymicro-vad==1.0.1 pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.3.4
# homeassistant.components.xiaomi_tv # homeassistant.components.xiaomi_tv
pymitv==1.4.3 pymitv==1.4.3

View File

@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5 aioemonitor==1.0.5
# homeassistant.components.esphome # homeassistant.components.esphome
aioesphomeapi==29.9.0 aioesphomeapi==29.10.0
# homeassistant.components.flo # homeassistant.components.flo
aioflo==2021.11.0 aioflo==2021.11.0
@ -612,7 +612,7 @@ coinbase-advanced-py==1.2.2
coinbase==2.1.0 coinbase==2.1.0
# homeassistant.scripts.check_config # homeassistant.scripts.check_config
colorlog==6.8.2 colorlog==6.9.0
# homeassistant.components.color_extractor # homeassistant.components.color_extractor
colorthief==0.2.1 colorthief==0.2.1
@ -1050,7 +1050,7 @@ influxdb-client==1.24.0
influxdb==5.3.1 influxdb==5.3.1
# homeassistant.components.inkbird # homeassistant.components.inkbird
inkbird-ble==0.10.1 inkbird-ble==0.11.0
# homeassistant.components.insteon # homeassistant.components.insteon
insteon-frontend-home-assistant==0.5.0 insteon-frontend-home-assistant==0.5.0
@ -1745,6 +1745,9 @@ pymeteoclimatic==0.1.0
# homeassistant.components.assist_pipeline # homeassistant.components.assist_pipeline
pymicro-vad==1.0.1 pymicro-vad==1.0.1
# homeassistant.components.miele
pymiele==0.3.4
# homeassistant.components.mochad # homeassistant.components.mochad
pymochad==0.2.0 pymochad==0.2.0

View File

@ -0,0 +1,13 @@
"""Tests for the Miele integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -0,0 +1,145 @@
"""Test helpers for Miele."""
from collections.abc import AsyncGenerator, Generator
import time
from unittest.mock import AsyncMock, MagicMock, patch
from pymiele import MieleAction, MieleDevices
import pytest
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.miele.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from .const import CLIENT_ID, CLIENT_SECRET
from tests.common import MockConfigEntry, load_json_object_fixture
@pytest.fixture(name="expires_at")
def mock_expires_at() -> float:
"""Fixture to set the oauth token expiration time."""
return time.time() + 3600
@pytest.fixture
def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry:
"""Return the default mocked config entry."""
config_entry = MockConfigEntry(
minor_version=1,
domain=DOMAIN,
title="Miele test",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": "Fake_token",
"expires_in": 86399,
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
"token_type": "Bearer",
"expires_at": expires_at,
},
},
entry_id="miele_test",
)
config_entry.add_to_hass(hass)
return config_entry
@pytest.fixture(autouse=True)
async def setup_credentials(hass: HomeAssistant) -> None:
"""Fixture to setup credentials."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential(
CLIENT_ID,
CLIENT_SECRET,
),
DOMAIN,
)
# Fixture group for device API endpoint.
@pytest.fixture(scope="package")
def load_device_file() -> str:
"""Fixture for loading device file."""
return "3_devices.json"
@pytest.fixture
def device_fixture(load_device_file: str) -> MieleDevices:
"""Fixture for device."""
return load_json_object_fixture(load_device_file, DOMAIN)
@pytest.fixture(scope="package")
def load_action_file() -> str:
"""Fixture for loading action file."""
return "action_washing_machine.json"
@pytest.fixture
def action_fixture(load_action_file: str) -> MieleAction:
"""Fixture for action."""
return load_json_object_fixture(load_action_file, DOMAIN)
@pytest.fixture
def mock_miele_client(
device_fixture,
action_fixture,
) -> Generator[MagicMock]:
"""Mock a Miele client."""
with patch(
"homeassistant.components.miele.AsyncConfigEntryAuth",
autospec=True,
) as mock_client:
client = mock_client.return_value
client.get_devices.return_value = device_fixture
client.get_actions.return_value = action_fixture
yield client
@pytest.fixture
def platforms() -> list[str]:
"""Fixture for platforms."""
return []
@pytest.fixture
async def setup_platform(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
platforms,
) -> AsyncGenerator[None]:
"""Set up one or all platforms."""
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
yield
@pytest.fixture
async def access_token(hass: HomeAssistant) -> str:
"""Return a valid access token."""
return "mock-access-token"
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.miele.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@ -0,0 +1,5 @@
"""Constants for miele tests."""
CLIENT_ID = "12345"
CLIENT_SECRET = "67890"
UNIQUE_ID = "uid"

View File

@ -0,0 +1,359 @@
{
"Dummy_Appliance_1": {
"ident": {
"type": {
"key_localized": "Device type",
"value_raw": 20,
"value_localized": "Freezer"
},
"deviceName": "",
"protocolVersion": 201,
"deviceIdentLabel": {
"fabNumber": "Dummy_Appliance_1",
"fabIndex": "21",
"techType": "FNS 28463 E ed/",
"matNumber": "10805070",
"swids": ["4497"]
},
"xkmIdentLabel": {
"techType": "EK042",
"releaseVersion": "31.17"
}
},
"state": {
"ProgramID": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Program name"
},
"status": {
"value_raw": 5,
"value_localized": "In use",
"key_localized": "status"
},
"programType": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Program type"
},
"programPhase": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Program phase"
},
"remainingTime": [0, 0],
"startTime": [0, 0],
"targetTemperature": [
{
"value_raw": -1800,
"value_localized": -18,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
}
],
"coreTargetTemperature": [],
"temperature": [
{
"value_raw": -1800,
"value_localized": -18,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
}
],
"coreTemperature": [],
"signalInfo": false,
"signalFailure": false,
"signalDoor": false,
"remoteEnable": {
"fullRemoteControl": true,
"smartGrid": false,
"mobileStart": false
},
"ambientLight": null,
"light": null,
"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
}
},
"Dummy_Appliance_2": {
"ident": {
"type": {
"key_localized": "Device type",
"value_raw": 19,
"value_localized": "Refrigerator"
},
"deviceName": "",
"protocolVersion": 201,
"deviceIdentLabel": {
"fabNumber": "Dummy_Appliance_2",
"fabIndex": "17",
"techType": "KS 28423 D ed/c",
"matNumber": "10804770",
"swids": ["4497"]
},
"xkmIdentLabel": {
"techType": "EK042",
"releaseVersion": "31.17"
}
},
"state": {
"ProgramID": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Program name"
},
"status": {
"value_raw": 5,
"value_localized": "In use",
"key_localized": "status"
},
"programType": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Program type"
},
"programPhase": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Program phase"
},
"remainingTime": [0, 0],
"startTime": [0, 0],
"targetTemperature": [
{
"value_raw": 400,
"value_localized": 4,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
}
],
"coreTargetTemperature": [],
"temperature": [
{
"value_raw": 400,
"value_localized": 4,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
},
{
"value_raw": -32768,
"value_localized": null,
"unit": "Celsius"
}
],
"coreTemperature": [],
"signalInfo": false,
"signalFailure": false,
"signalDoor": false,
"remoteEnable": {
"fullRemoteControl": true,
"smartGrid": false,
"mobileStart": false
},
"ambientLight": null,
"light": null,
"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
}
},
"Dummy_Appliance_3": {
"ident": {
"type": {
"key_localized": "Device type",
"value_raw": 1,
"value_localized": "Washing machine"
},
"deviceName": "",
"protocolVersion": 4,
"deviceIdentLabel": {
"fabNumber": "Dummy_Appliance_3",
"fabIndex": "44",
"techType": "WCI870",
"matNumber": "11387290",
"swids": [
"5975",
"20456",
"25213",
"25191",
"25446",
"25205",
"25447",
"25319"
]
},
"xkmIdentLabel": {
"techType": "EK057",
"releaseVersion": "08.32"
}
},
"state": {
"ProgramID": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Program name"
},
"status": {
"value_raw": 1,
"value_localized": "Off",
"key_localized": "status"
},
"programType": {
"value_raw": 0,
"value_localized": "",
"key_localized": "Program type"
},
"programPhase": {
"value_raw": 0,
"value_localized": "",
"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": true,
"remoteEnable": {
"fullRemoteControl": true,
"smartGrid": false,
"mobileStart": false
},
"ambientLight": null,
"light": null,
"elapsedTime": [0, 0],
"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
}
}
}

View File

@ -0,0 +1,21 @@
{
"processAction": [6],
"light": [],
"ambientLight": [],
"startTime": [],
"ventilationStep": [],
"programId": [],
"targetTemperature": [
{
"zone": 1,
"min": 1,
"max": 9
}
],
"deviceName": true,
"powerOn": false,
"powerOff": false,
"colors": [],
"modes": [1],
"runOnTime": []
}

View File

@ -0,0 +1,21 @@
{
"processAction": [4],
"light": [],
"ambientLight": [],
"startTime": [],
"ventilationStep": [],
"programId": [],
"targetTemperature": [
{
"zone": 1,
"min": -28,
"max": -14
}
],
"deviceName": true,
"powerOn": false,
"powerOff": false,
"colors": [],
"modes": [1],
"runOnTime": []
}

View File

@ -0,0 +1,15 @@
{
"processAction": [],
"light": [],
"ambientLight": [],
"startTime": [],
"ventilationStep": [],
"programId": [],
"targetTemperature": [],
"deviceName": true,
"powerOn": true,
"powerOff": false,
"colors": [],
"modes": [],
"runOnTime": []
}

View File

@ -0,0 +1,34 @@
# serializer version: 1
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': 'EK042',
'id': <ANY>,
'identifiers': set({
tuple(
'miele',
'Dummy_Appliance_1',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Miele',
'model': 'FNS 28463 E ed/',
'model_id': None,
'name': 'Freezer',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': 'Dummy_Appliance_1',
'suggested_area': None,
'sw_version': '31.17',
'via_device_id': None,
})
# ---

View File

@ -0,0 +1,375 @@
# serializer version: 1
# name: test_sensor_states[platforms0][sensor.freezer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'off',
'on',
'programmed',
'waiting_to_start',
'running',
'pause',
'program_ended',
'failure',
'program_interrupted',
'idle',
'rinse_hold',
'service',
'superfreezing',
'supercooling',
'superheating',
'supercooling_superfreezing',
'autocleaning',
'not_connected',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.freezer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': 'mdi:fridge-industrial-outline',
'original_name': None,
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'Dummy_Appliance_1-state_status',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states[platforms0][sensor.freezer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Freezer',
'icon': 'mdi:fridge-industrial-outline',
'options': list([
'off',
'on',
'programmed',
'waiting_to_start',
'running',
'pause',
'program_ended',
'failure',
'program_interrupted',
'idle',
'rinse_hold',
'service',
'superfreezing',
'supercooling',
'superheating',
'supercooling_superfreezing',
'autocleaning',
'not_connected',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.freezer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'running',
})
# ---
# name: test_sensor_states[platforms0][sensor.freezer_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.freezer_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'Dummy_Appliance_1-state_temperature_1',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_states[platforms0][sensor.freezer_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Freezer Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.freezer_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-18.0',
})
# ---
# name: test_sensor_states[platforms0][sensor.refrigerator-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'off',
'on',
'programmed',
'waiting_to_start',
'running',
'pause',
'program_ended',
'failure',
'program_interrupted',
'idle',
'rinse_hold',
'service',
'superfreezing',
'supercooling',
'superheating',
'supercooling_superfreezing',
'autocleaning',
'not_connected',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.refrigerator',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': 'mdi:fridge-industrial-outline',
'original_name': None,
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'Dummy_Appliance_2-state_status',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states[platforms0][sensor.refrigerator-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Refrigerator',
'icon': 'mdi:fridge-industrial-outline',
'options': list([
'off',
'on',
'programmed',
'waiting_to_start',
'running',
'pause',
'program_ended',
'failure',
'program_interrupted',
'idle',
'rinse_hold',
'service',
'superfreezing',
'supercooling',
'superheating',
'supercooling_superfreezing',
'autocleaning',
'not_connected',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.refrigerator',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'running',
})
# ---
# name: test_sensor_states[platforms0][sensor.refrigerator_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.refrigerator_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'Dummy_Appliance_2-state_temperature_1',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_states[platforms0][sensor.refrigerator_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Refrigerator Temperature',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.refrigerator_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '4.0',
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'off',
'on',
'programmed',
'waiting_to_start',
'running',
'pause',
'program_ended',
'failure',
'program_interrupted',
'idle',
'rinse_hold',
'service',
'superfreezing',
'supercooling',
'superheating',
'supercooling_superfreezing',
'autocleaning',
'not_connected',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.washing_machine',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': 'mdi:washing-machine',
'original_name': None,
'platform': 'miele',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'Dummy_Appliance_3-state_status',
'unit_of_measurement': None,
})
# ---
# name: test_sensor_states[platforms0][sensor.washing_machine-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Washing machine',
'icon': 'mdi:washing-machine',
'options': list([
'off',
'on',
'programmed',
'waiting_to_start',
'running',
'pause',
'program_ended',
'failure',
'program_interrupted',
'idle',
'rinse_hold',
'service',
'superfreezing',
'supercooling',
'superheating',
'supercooling_superfreezing',
'autocleaning',
'not_connected',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.washing_machine',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@ -0,0 +1,214 @@
"""Test the Miele config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock
from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
import pytest
from homeassistant.components.miele.const import DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from .const import CLIENT_ID
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
REDIRECT_URL = "https://example.com/auth/external/callback"
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.usefixtures("current_request_with_host")
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_setup_entry: Generator[AsyncMock],
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT_URL,
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URL}"
f"&state={state}"
"&vg=sv-SE"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"type": "Bearer",
"expires_in": 60,
},
)
await hass.config_entries.flow.async_configure(result["flow_id"])
assert result.get("type") is FlowResultType.EXTERNAL_STEP
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("current_request_with_host")
async def test_flow_reauth_abort(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
access_token: str,
expires_at: float,
) -> None:
"""Test reauth step with correct params."""
CURRENT_TOKEN = {
"auth_implementation": DOMAIN,
"token": {
"access_token": access_token,
"expires_in": 86399,
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
"token_type": "Bearer",
"expires_at": expires_at,
},
}
assert hass.config_entries.async_update_entry(
mock_config_entry,
data=CURRENT_TOKEN,
)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
result = await mock_config_entry.start_reauth_flow(hass)
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={}
)
assert result["step_id"] == "auth"
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT_URL,
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URL}"
f"&state={state}"
"&vg=sv-SE"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "updated-refresh-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": "60",
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reauth_successful"
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@pytest.mark.usefixtures("current_request_with_host")
async def test_flow_reconfigure_abort(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
access_token: str,
expires_at: float,
) -> None:
"""Test reauth step with correct params and mismatches."""
CURRENT_TOKEN = {
"auth_implementation": DOMAIN,
"token": {
"access_token": access_token,
"expires_in": 86399,
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
"token_type": "Bearer",
"expires_at": expires_at,
},
}
assert hass.config_entries.async_update_entry(
mock_config_entry,
data=CURRENT_TOKEN,
)
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["step_id"] == "auth"
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": REDIRECT_URL,
},
)
assert result["url"] == (
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
f"&redirect_uri={REDIRECT_URL}"
f"&state={state}"
"&vg=sv-SE"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
OAUTH2_TOKEN,
json={
"refresh_token": "updated-refresh-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": "60",
},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
await hass.async_block_till_done()
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reconfigure_successful"
assert len(hass.config_entries.async_entries(DOMAIN)) == 1

View File

@ -0,0 +1,120 @@
"""Tests for init module."""
import http
import time
from unittest.mock import MagicMock
from aiohttp import ClientConnectionError
from pymiele import OAUTH2_TOKEN
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.miele.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
async def test_load_unload_entry(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test load and unload entry."""
await setup_integration(hass, mock_config_entry)
entry = mock_config_entry
assert entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("expires_at", "status", "expected_state"),
[
(
time.time() - 3600,
http.HTTPStatus.UNAUTHORIZED,
ConfigEntryState.SETUP_ERROR,
),
(
time.time() - 3600,
http.HTTPStatus.INTERNAL_SERVER_ERROR,
ConfigEntryState.SETUP_RETRY,
),
],
ids=["unauthorized", "internal_server_error"],
)
async def test_expired_token_refresh_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
status: http.HTTPStatus,
expected_state: ConfigEntryState,
) -> None:
"""Test failure while refreshing token with a transient error."""
aioclient_mock.clear_requests()
aioclient_mock.post(
OAUTH2_TOKEN,
status=status,
)
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is expected_state
@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"])
async def test_expired_token_refresh_connection_failure(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test failure while refreshing token with a ClientError."""
aioclient_mock.clear_requests()
aioclient_mock.post(
OAUTH2_TOKEN,
exc=ClientConnectionError(),
)
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_devices_multiple_created_count(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that multiple devices are created."""
await setup_integration(hass, mock_config_entry)
assert len(device_registry.devices) == 3
async def test_device_info(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device registry integration."""
await setup_integration(hass, mock_config_entry)
device_entry = device_registry.async_get_device(
identifiers={(DOMAIN, "Dummy_Appliance_1")}
)
assert device_entry is not None
assert device_entry == snapshot

View File

@ -0,0 +1,27 @@
"""Tests for miele sensor module."""
from unittest.mock import MagicMock
import pytest
from syrupy import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.parametrize("platforms", [(Platform.SENSOR,)])
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_states(
hass: HomeAssistant,
mock_miele_client: MagicMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
setup_platform: None,
) -> None:
"""Test sensor state."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

View File

@ -330,7 +330,9 @@ async def test_no_color_brightness_color_temp_if_no_topics(
state = hass.states.get("light.test") state = hass.states.get("light.test")
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION expected_features = (
light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
)
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features
assert state.attributes.get("rgb_color") is None assert state.attributes.get("rgb_color") is None
assert state.attributes.get("brightness") is None assert state.attributes.get("brightness") is None
@ -581,6 +583,104 @@ async def test_controlling_state_color_temp_kelvin(
assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color assert state.attributes.get("hs_color") == (44.098, 2.43) # temp converted to color
@pytest.mark.parametrize(
("hass_config", "expected_features"),
[
(
{
mqtt.DOMAIN: {
light.DOMAIN: {
"schema": "json",
"name": "test",
"state_topic": "test_light_rgb",
"command_topic": "test_light_rgb/set",
}
}
},
light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION,
),
(
{
mqtt.DOMAIN: {
light.DOMAIN: {
"schema": "json",
"name": "test",
"state_topic": "test_light_rgb",
"command_topic": "test_light_rgb/set",
"flash": True,
"transition": True,
}
}
},
light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION,
),
(
{
mqtt.DOMAIN: {
light.DOMAIN: {
"schema": "json",
"name": "test",
"state_topic": "test_light_rgb",
"command_topic": "test_light_rgb/set",
"flash": True,
"transition": False,
}
}
},
light.LightEntityFeature.FLASH,
),
(
{
mqtt.DOMAIN: {
light.DOMAIN: {
"schema": "json",
"name": "test",
"state_topic": "test_light_rgb",
"command_topic": "test_light_rgb/set",
"flash": False,
"transition": True,
}
}
},
light.LightEntityFeature.TRANSITION,
),
(
{
mqtt.DOMAIN: {
light.DOMAIN: {
"schema": "json",
"name": "test",
"state_topic": "test_light_rgb",
"command_topic": "test_light_rgb/set",
"flash": False,
"transition": False,
}
}
},
light.LightEntityFeature(0),
),
],
ids=[
"default",
"explicit_on",
"flash_only",
"transition_only",
"no_flash_not_transition",
],
)
async def test_flash_and_transition_feature_flags(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
expected_features: light.LightEntityFeature,
) -> None:
"""Test for no RGB, brightness, color temp, effector XY."""
await mqtt_mock_entry()
state = hass.states.get("light.test")
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features
@pytest.mark.parametrize( @pytest.mark.parametrize(
"hass_config", "hass_config",
[ [
@ -601,9 +701,11 @@ async def test_controlling_state_via_topic(
state = hass.states.get("light.test") state = hass.states.get("light.test")
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
expected_features = ( expected_features = (
light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION light.LightEntityFeature.EFFECT
| light.LightEntityFeature.FLASH
| light.LightEntityFeature.TRANSITION
) )
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features
assert state.attributes.get("brightness") is None assert state.attributes.get("brightness") is None
assert state.attributes.get("color_mode") is None assert state.attributes.get("color_mode") is None
assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("color_temp_kelvin") is None
@ -799,9 +901,11 @@ async def test_sending_mqtt_commands_and_optimistic(
state = hass.states.get("light.test") state = hass.states.get("light.test")
assert state.state == STATE_ON assert state.state == STATE_ON
expected_features = ( expected_features = (
light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION light.LightEntityFeature.EFFECT
| light.LightEntityFeature.FLASH
| light.LightEntityFeature.TRANSITION
) )
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features
assert state.attributes.get("brightness") == 95 assert state.attributes.get("brightness") == 95
assert state.attributes.get("color_mode") == "rgb" assert state.attributes.get("color_mode") == "rgb"
assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("color_temp_kelvin") is None
@ -1457,9 +1561,11 @@ async def test_effect(
state = hass.states.get("light.test") state = hass.states.get("light.test")
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
expected_features = ( expected_features = (
light.SUPPORT_EFFECT | light.SUPPORT_FLASH | light.SUPPORT_TRANSITION light.LightEntityFeature.EFFECT
| light.LightEntityFeature.FLASH
| light.LightEntityFeature.TRANSITION
) )
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features
await common.async_turn_on(hass, "light.test") await common.async_turn_on(hass, "light.test")
@ -1523,8 +1629,10 @@ async def test_flash_short_and_long(
state = hass.states.get("light.test") state = hass.states.get("light.test")
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION expected_features = (
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
)
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features
await common.async_turn_on(hass, "light.test", flash="short") await common.async_turn_on(hass, "light.test", flash="short")
@ -1586,8 +1694,10 @@ async def test_transition(
state = hass.states.get("light.test") state = hass.states.get("light.test")
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION expected_features = (
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
)
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features
await common.async_turn_on(hass, "light.test", transition=15) await common.async_turn_on(hass, "light.test", transition=15)
mqtt_mock.async_publish.assert_called_once_with( mqtt_mock.async_publish.assert_called_once_with(
@ -1766,8 +1876,10 @@ async def test_invalid_values(
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS] color_modes = [light.ColorMode.COLOR_TEMP, light.ColorMode.HS]
assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes
expected_features = light.SUPPORT_FLASH | light.SUPPORT_TRANSITION expected_features = (
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features light.LightEntityFeature.FLASH | light.LightEntityFeature.TRANSITION
)
assert state.attributes.get(ATTR_SUPPORTED_FEATURES) is expected_features
assert state.attributes.get("rgb_color") is None assert state.attributes.get("rgb_color") is None
assert state.attributes.get("brightness") is None assert state.attributes.get("brightness") is None
assert state.attributes.get("color_temp_kelvin") is None assert state.attributes.get("color_temp_kelvin") is None

View File

@ -246,12 +246,14 @@ async def test_reconfigure_successful(
# original entry # original entry
assert mock_config_entry.data["host"] == "fake_host" assert mock_config_entry.data["host"] == "fake_host"
new_host = "192.168.100.60"
reconfigure_result = await hass.config_entries.flow.async_configure( reconfigure_result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ user_input={
"host": "192.168.100.60", CONF_HOST: new_host,
"password": "fake_password", CONF_PASSWORD: "fake_password",
"username": "fake_username", CONF_USERNAME: "fake_username",
}, },
) )
@ -259,7 +261,7 @@ async def test_reconfigure_successful(
assert reconfigure_result["reason"] == "reconfigure_successful" assert reconfigure_result["reason"] == "reconfigure_successful"
# changed entry # changed entry
assert mock_config_entry.data["host"] == "192.168.100.60" assert mock_config_entry.data["host"] == new_host
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -290,10 +292,10 @@ async def test_reconfigure_fails(
reconfigure_result = await hass.config_entries.flow.async_configure( reconfigure_result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
{ user_input={
"host": "192.168.100.60", CONF_HOST: "192.168.100.60",
"password": "fake_password", CONF_PASSWORD: "fake_password",
"username": "fake_username", CONF_USERNAME: "fake_username",
}, },
) )