Compare commits

...

23 Commits

Author SHA1 Message Date
Franck Nijhof
6aaddad56b Bump version to 2025.10.0b2 2025-09-25 18:19:29 +00:00
Paul Bottein
a5af974209 Update frontend to 20250925.1 (#152985) 2025-09-25 18:18:47 +00:00
Luke Lashley
09e45f6f54 Fix incorrect Roborock test (#152980) 2025-09-25 18:18:46 +00:00
Joost Lekkerkerker
d857d8850c Bump pySmartThings to 3.3.0 (#152977) 2025-09-25 18:18:45 +00:00
J. Nick Koston
ccc50f2412 Bump aioesphomeapi to 41.10.0 (#152975)
Co-authored-by: Michael Hansen <mike@rhasspy.org>
2025-09-25 18:18:43 +00:00
Maciej Bieniek
3905723900 Bump accuweather to version 4.2.2 (#152965) 2025-09-25 18:18:42 +00:00
Simone Chemelli
cee88473a2 Remove deprecated sensors and update remaning for Alexa Devices (#151230) 2025-09-25 18:18:40 +00:00
Daniel Potthast
cdf613d3f8 Update mvglive component (#146479)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2025-09-25 18:18:39 +00:00
Franck Nijhof
156a0f1a3d Bump version to 2025.10.0b1 2025-09-25 09:37:33 +00:00
Paul Bottein
cc2a5b43dd Update frontend to 20250925.0 (#152945) 2025-09-25 09:37:03 +00:00
Erwin Douna
731064f7e9 Portainer fix unique entity (#152941)
Co-authored-by: Franck Nijhof <frenck@frenck.nl>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-25 08:19:21 +00:00
Sab44
2f75661c20 Bump librehardwaremonitor-api to version 1.4.0 (#152938) 2025-09-25 08:19:20 +00:00
Paulus Schoutsen
be6f056f30 Prevent common control calling async methods from thread (#152931)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-09-25 08:19:18 +00:00
Franck Nijhof
79599e1284 Add block Spook < 4.0.0 as breaking Home Assistant (#152930) 2025-09-25 08:19:17 +00:00
Paulus Schoutsen
a255585ab6 Remove some more domains from common controls (#152927) 2025-09-25 08:19:15 +00:00
J. Nick Koston
e9bde225fe Bump aioesphomeapi to 41.9.4 (#152923) 2025-09-25 08:19:14 +00:00
Franck Nijhof
d9521ac2a0 Bump to home-assistant/wheels@2025.09.0 (#152920) 2025-09-25 08:19:13 +00:00
J. Nick Koston
d8b24ccccd Bump aioesphomeapi to 41.9.3 to fix segfault (#152912) 2025-09-25 08:19:12 +00:00
J. Nick Koston
b4417a76d5 Fix ESPHome reauth not being triggered on incorrect password (#152911) 2025-09-25 08:19:10 +00:00
Simone Chemelli
274f6eb54a Update IQS to platinum for Comelit SimpleHome (#152906) 2025-09-25 08:19:09 +00:00
Simone Chemelli
21a5aaf35c Update IQS to platinum for Alexa Devices (#152905) 2025-09-25 08:19:07 +00:00
Luke Lashley
05820a49d0 Fix logical error when user has no Roborock maps (#152752) 2025-09-25 08:19:06 +00:00
Franck Nijhof
17b12d29af Bump version to 2025.10.0b0 2025-09-24 18:57:19 +00:00
45 changed files with 633 additions and 415 deletions

View File

@@ -160,7 +160,7 @@ jobs:
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: home-assistant/wheels@2025.07.0
uses: home-assistant/wheels@2025.09.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -221,7 +221,7 @@ jobs:
# home-assistant/wheels doesn't support sha pinning
- name: Build wheels
uses: home-assistant/wheels@2025.07.0
uses: home-assistant/wheels@2025.09.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
"requirements": ["accuweather==4.2.1"]
"requirements": ["accuweather==4.2.2"]
}

View File

@@ -10,6 +10,7 @@ from aioamazondevices.api import AmazonDevice
from aioamazondevices.const import SENSOR_STATE_OFF
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
@@ -20,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import async_update_unique_id
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -31,6 +33,7 @@ class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
is_on_fn: Callable[[AmazonDevice, str], bool]
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: True
BINARY_SENSORS: Final = (
@@ -41,46 +44,15 @@ BINARY_SENSORS: Final = (
is_on_fn=lambda device, _: device.online,
),
AmazonBinarySensorEntityDescription(
key="bluetooth",
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="bluetooth",
is_on_fn=lambda device, _: device.bluetooth_state,
),
AmazonBinarySensorEntityDescription(
key="babyCryDetectionState",
translation_key="baby_cry_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="beepingApplianceDetectionState",
translation_key="beeping_appliance_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="coughDetectionState",
translation_key="cough_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="dogBarkDetectionState",
translation_key="dog_bark_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="humanPresenceDetectionState",
key="detectionState",
device_class=BinarySensorDeviceClass.MOTION,
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_supported=lambda device, key: device.sensors.get(key) is not None,
),
AmazonBinarySensorEntityDescription(
key="waterSoundsDetectionState",
translation_key="water_sounds_detection",
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
is_on_fn=lambda device, key: bool(
device.sensors[key].value != SENSOR_STATE_OFF
),
is_supported=lambda device, key: device.sensors.get(key) is not None,
is_available_fn=lambda device, key: (
device.online and device.sensors[key].error is False
),
),
)
@@ -94,6 +66,22 @@ async def async_setup_entry(
coordinator = entry.runtime_data
# Replace unique id for "detectionState" binary sensor
await async_update_unique_id(
hass,
coordinator,
BINARY_SENSOR_DOMAIN,
"humanPresenceDetectionState",
"detectionState",
)
async_add_entities(
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
for sensor_desc in BINARY_SENSORS
for serial_num in coordinator.data
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
)
known_devices: set[str] = set()
def _check_device() -> None:
@@ -125,3 +113,13 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
return self.entity_description.is_on_fn(
self.device, self.entity_description.key
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
self.entity_description.is_available_fn(
self.device, self.entity_description.key
)
and super().available
)

View File

@@ -64,7 +64,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
data = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except (CannotAuthenticate, TypeError):
except CannotAuthenticate:
errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"
@@ -112,7 +112,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
)
except CannotConnect:
errors["base"] = "cannot_connect"
except (CannotAuthenticate, TypeError):
except CannotAuthenticate:
errors["base"] = "invalid_auth"
except CannotRetrieveData:
errors["base"] = "cannot_retrieve_data"

View File

@@ -68,7 +68,7 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
translation_key="cannot_retrieve_data_with_error",
translation_placeholders={"error": repr(err)},
) from err
except (CannotAuthenticate, TypeError) as err:
except CannotAuthenticate as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",

View File

@@ -60,7 +60,5 @@ def build_device_data(device: AmazonDevice) -> dict[str, Any]:
"online": device.online,
"serial number": device.serial_number,
"software version": device.software_version,
"do not disturb": device.do_not_disturb,
"response style": device.response_style,
"bluetooth state": device.bluetooth_state,
"sensors": device.sensors,
}

View File

@@ -1,44 +1,4 @@
{
"entity": {
"binary_sensor": {
"bluetooth": {
"default": "mdi:bluetooth-off",
"state": {
"on": "mdi:bluetooth"
}
},
"baby_cry_detection": {
"default": "mdi:account-voice-off",
"state": {
"on": "mdi:account-voice"
}
},
"beeping_appliance_detection": {
"default": "mdi:bell-off",
"state": {
"on": "mdi:bell-ring"
}
},
"cough_detection": {
"default": "mdi:blur-off",
"state": {
"on": "mdi:blur"
}
},
"dog_bark_detection": {
"default": "mdi:dog-side-off",
"state": {
"on": "mdi:dog-side"
}
},
"water_sounds_detection": {
"default": "mdi:water-pump-off",
"state": {
"on": "mdi:water-pump"
}
}
}
},
"services": {
"send_sound": {
"service": "mdi:cast-audio"

View File

@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "silver",
"requirements": ["aioamazondevices==6.0.0"]
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.2.6"]
}

View File

@@ -31,6 +31,9 @@ class AmazonSensorEntityDescription(SensorEntityDescription):
"""Amazon Devices sensor entity description."""
native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online and device.sensors[key].error is False
)
SENSORS: Final = (
@@ -99,3 +102,13 @@ class AmazonSensorEntity(AmazonEntity, SensorEntity):
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.device.sensors[self.entity_description.key].value
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
self.entity_description.is_available_fn(
self.device, self.entity_description.key
)
and super().available
)

View File

@@ -58,26 +58,6 @@
}
},
"entity": {
"binary_sensor": {
"bluetooth": {
"name": "Bluetooth"
},
"baby_cry_detection": {
"name": "Baby crying"
},
"beeping_appliance_detection": {
"name": "Beeping appliance"
},
"cough_detection": {
"name": "Coughing"
},
"dog_bark_detection": {
"name": "Dog barking"
},
"water_sounds_detection": {
"name": "Water sounds"
}
},
"notify": {
"speak": {
"name": "Speak"

View File

@@ -8,13 +8,17 @@ from typing import TYPE_CHECKING, Any, Final
from aioamazondevices.api import AmazonDevice
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.components.switch import (
DOMAIN as SWITCH_DOMAIN,
SwitchEntity,
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AmazonConfigEntry
from .entity import AmazonEntity
from .utils import alexa_api_call
from .utils import alexa_api_call, async_update_unique_id
PARALLEL_UPDATES = 1
@@ -24,16 +28,17 @@ class AmazonSwitchEntityDescription(SwitchEntityDescription):
"""Alexa Devices switch entity description."""
is_on_fn: Callable[[AmazonDevice], bool]
subkey: str
is_available_fn: Callable[[AmazonDevice, str], bool] = lambda device, key: (
device.online and device.sensors[key].error is False
)
method: str
SWITCHES: Final = (
AmazonSwitchEntityDescription(
key="do_not_disturb",
subkey="AUDIO_PLAYER",
key="dnd",
translation_key="do_not_disturb",
is_on_fn=lambda _device: _device.do_not_disturb,
is_on_fn=lambda device: bool(device.sensors["dnd"].value),
method="set_do_not_disturb",
),
)
@@ -48,6 +53,11 @@ async def async_setup_entry(
coordinator = entry.runtime_data
# Replace unique id for "DND" switch and remove from Speaker Group
await async_update_unique_id(
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
)
known_devices: set[str] = set()
def _check_device() -> None:
@@ -59,7 +69,7 @@ async def async_setup_entry(
AmazonSwitchEntity(coordinator, serial_num, switch_desc)
for switch_desc in SWITCHES
for serial_num in new_devices
if switch_desc.subkey in coordinator.data[serial_num].capabilities
if switch_desc.key in coordinator.data[serial_num].sensors
)
_check_device()
@@ -94,3 +104,13 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
def is_on(self) -> bool:
"""Return True if switch is on."""
return self.entity_description.is_on_fn(self.device)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
self.entity_description.is_available_fn(
self.device, self.entity_description.key
)
and super().available
)

View File

@@ -6,9 +6,12 @@ from typing import Any, Concatenate
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.entity_registry as er
from .const import DOMAIN
from .const import _LOGGER, DOMAIN
from .coordinator import AmazonDevicesCoordinator
from .entity import AmazonEntity
@@ -38,3 +41,23 @@ def alexa_api_call[_T: AmazonEntity, **_P](
) from err
return cmd_wrapper
async def async_update_unique_id(
hass: HomeAssistant,
coordinator: AmazonDevicesCoordinator,
domain: str,
old_key: str,
new_key: str,
) -> None:
"""Update unique id for entities created with old format."""
entity_registry = er.async_get(hass)
for serial_num in coordinator.data:
unique_id = f"{serial_num}-{old_key}"
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
_LOGGER.debug("Updating unique_id for %s", entity_id)
new_unique_id = unique_id.replace(old_key, new_key)
# Update the registry with the new unique_id
entity_registry.async_update_entity(entity_id, new_unique_id=new_unique_id)

View File

@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["aiocomelit==0.12.3"]
}

View File

@@ -57,6 +57,7 @@ from .manager import async_replace_device
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
ERROR_INVALID_PASSWORD_AUTH = "invalid_auth"
_LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
@@ -137,6 +138,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._password = ""
return await self._async_authenticate_or_add()
if error == ERROR_INVALID_PASSWORD_AUTH or (
error is None and self._device_info and self._device_info.uses_password
):
return await self.async_step_authenticate()
if error is None and entry_data.get(CONF_NOISE_PSK):
# Device was configured with encryption but now connects without it.
# Check if it's the same device before offering to remove encryption.
@@ -690,13 +696,15 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
cli = APIClient(
host,
port or DEFAULT_PORT,
"",
self._password or "",
zeroconf_instance=zeroconf_instance,
noise_psk=noise_psk,
)
try:
await cli.connect()
self._device_info = await cli.device_info()
except InvalidAuthAPIError:
return ERROR_INVALID_PASSWORD_AUTH
except RequiresEncryptionAPIError:
return ERROR_REQUIRES_ENCRYPTION_KEY
except InvalidEncryptionKeyAPIError as ex:

View File

@@ -372,6 +372,9 @@ class ESPHomeManager:
"""Subscribe to states and list entities on successful API login."""
try:
await self._on_connect()
except InvalidAuthAPIError as err:
_LOGGER.warning("Authentication failed for %s: %s", self.host, err)
await self._start_reauth_and_disconnect()
except APIConnectionError as err:
_LOGGER.warning(
"Error getting setting up connection for %s: %s", self.host, err
@@ -641,7 +644,14 @@ class ESPHomeManager:
if self.reconnect_logic:
await self.reconnect_logic.stop()
return
await self._start_reauth_and_disconnect()
async def _start_reauth_and_disconnect(self) -> None:
"""Start reauth flow and stop reconnection attempts."""
self.entry.async_start_reauth(self.hass)
await self.cli.disconnect()
if self.reconnect_logic:
await self.reconnect_logic.stop()
async def _handle_dynamic_encryption_key(
self, device_info: EsphomeDeviceInfo

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==41.9.0",
"aioesphomeapi==41.10.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.3.0"
],

View File

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250924.0"]
"requirements": ["home-assistant-frontend==20250925.1"]
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["librehardwaremonitor-api==1.3.1"]
"requirements": ["librehardwaremonitor-api==1.4.0"]
}

View File

@@ -2,10 +2,8 @@
"domain": "mvglive",
"name": "MVG",
"codeowners": [],
"disabled": "This integration is disabled because it uses non-open source code to operate.",
"documentation": "https://www.home-assistant.io/integrations/mvglive",
"iot_class": "cloud_polling",
"loggers": ["MVGLive"],
"quality_scale": "legacy",
"requirements": ["PyMVGLive==1.1.4"]
"loggers": ["MVG"],
"requirements": ["mvg==1.4.0"]
}

View File

@@ -1,13 +1,14 @@
"""Support for departure information for public transport in Munich."""
# mypy: ignore-errors
from __future__ import annotations
from collections.abc import Mapping
from copy import deepcopy
from datetime import timedelta
import logging
from typing import Any
import MVGLive
from mvg import MvgApi, MvgApiError, TransportType
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -19,6 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -44,53 +46,55 @@ ICONS = {
"SEV": "mdi:checkbox-blank-circle-outline",
"-": "mdi:clock",
}
ATTRIBUTION = "Data provided by MVG-live.de"
ATTRIBUTION = "Data provided by mvg.de"
SCAN_INTERVAL = timedelta(seconds=30)
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_NEXT_DEPARTURE): [
{
vol.Required(CONF_STATION): cv.string,
vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv,
vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv,
vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv,
vol.Optional(
CONF_PRODUCTS, default=DEFAULT_PRODUCT
): cv.ensure_list_csv,
vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int,
vol.Optional(CONF_NUMBER, default=1): cv.positive_int,
vol.Optional(CONF_NAME): cv.string,
}
]
}
PLATFORM_SCHEMA = vol.All(
cv.deprecated(CONF_DIRECTIONS),
SENSOR_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_NEXT_DEPARTURE): [
{
vol.Required(CONF_STATION): cv.string,
vol.Optional(CONF_DESTINATIONS, default=[""]): cv.ensure_list_csv,
vol.Optional(CONF_DIRECTIONS, default=[""]): cv.ensure_list_csv,
vol.Optional(CONF_LINES, default=[""]): cv.ensure_list_csv,
vol.Optional(
CONF_PRODUCTS, default=DEFAULT_PRODUCT
): cv.ensure_list_csv,
vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int,
vol.Optional(CONF_NUMBER, default=1): cv.positive_int,
vol.Optional(CONF_NAME): cv.string,
}
]
}
),
)
def setup_platform(
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the MVGLive sensor."""
add_entities(
(
MVGLiveSensor(
nextdeparture.get(CONF_STATION),
nextdeparture.get(CONF_DESTINATIONS),
nextdeparture.get(CONF_DIRECTIONS),
nextdeparture.get(CONF_LINES),
nextdeparture.get(CONF_PRODUCTS),
nextdeparture.get(CONF_TIMEOFFSET),
nextdeparture.get(CONF_NUMBER),
nextdeparture.get(CONF_NAME),
)
for nextdeparture in config[CONF_NEXT_DEPARTURE]
),
True,
)
sensors = [
MVGLiveSensor(
hass,
nextdeparture.get(CONF_STATION),
nextdeparture.get(CONF_DESTINATIONS),
nextdeparture.get(CONF_LINES),
nextdeparture.get(CONF_PRODUCTS),
nextdeparture.get(CONF_TIMEOFFSET),
nextdeparture.get(CONF_NUMBER),
nextdeparture.get(CONF_NAME),
)
for nextdeparture in config[CONF_NEXT_DEPARTURE]
]
add_entities(sensors, True)
class MVGLiveSensor(SensorEntity):
@@ -100,38 +104,38 @@ class MVGLiveSensor(SensorEntity):
def __init__(
self,
station,
hass: HomeAssistant,
station_name,
destinations,
directions,
lines,
products,
timeoffset,
number,
name,
):
) -> None:
"""Initialize the sensor."""
self._station = station
self._name = name
self._station_name = station_name
self.data = MVGLiveData(
station, destinations, directions, lines, products, timeoffset, number
hass, station_name, destinations, lines, products, timeoffset, number
)
self._state = None
self._icon = ICONS["-"]
@property
def name(self):
def name(self) -> str | None:
"""Return the name of the sensor."""
if self._name:
return self._name
return self._station
return self._station_name
@property
def native_value(self):
def native_value(self) -> str | None:
"""Return the next departure time."""
return self._state
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes."""
if not (dep := self.data.departures):
return None
@@ -140,88 +144,114 @@ class MVGLiveSensor(SensorEntity):
return attr
@property
def icon(self):
def icon(self) -> str | None:
"""Icon to use in the frontend, if any."""
return self._icon
@property
def native_unit_of_measurement(self):
def native_unit_of_measurement(self) -> str | None:
"""Return the unit this state is expressed in."""
return UnitOfTime.MINUTES
def update(self) -> None:
async def async_update(self) -> None:
"""Get the latest data and update the state."""
self.data.update()
await self.data.update()
if not self.data.departures:
self._state = "-"
self._state = None
self._icon = ICONS["-"]
else:
self._state = self.data.departures[0].get("time", "-")
self._icon = ICONS[self.data.departures[0].get("product", "-")]
self._state = self.data.departures[0].get("time_in_mins", "-")
self._icon = self.data.departures[0].get("icon", ICONS["-"])
def _get_minutes_until_departure(departure_time: int) -> int:
"""Calculate the time difference in minutes between the current time and a given departure time.
Args:
departure_time: Unix timestamp of the departure time, in seconds.
Returns:
The time difference in minutes, as an integer.
"""
current_time = dt_util.utcnow()
departure_datetime = dt_util.utc_from_timestamp(departure_time)
time_difference = (departure_datetime - current_time).total_seconds()
return int(time_difference / 60.0)
class MVGLiveData:
"""Pull data from the mvg-live.de web page."""
"""Pull data from the mvg.de web page."""
def __init__(
self, station, destinations, directions, lines, products, timeoffset, number
):
self,
hass: HomeAssistant,
station_name,
destinations,
lines,
products,
timeoffset,
number,
) -> None:
"""Initialize the sensor."""
self._station = station
self._hass = hass
self._station_name = station_name
self._station_id = None
self._destinations = destinations
self._directions = directions
self._lines = lines
self._products = products
self._timeoffset = timeoffset
self._number = number
self._include_ubahn = "U-Bahn" in self._products
self._include_tram = "Tram" in self._products
self._include_bus = "Bus" in self._products
self._include_sbahn = "S-Bahn" in self._products
self.mvg = MVGLive.MVGLive()
self.departures = []
self.departures: list[dict[str, Any]] = []
def update(self):
async def update(self):
"""Update the connection data."""
if self._station_id is None:
try:
station = await MvgApi.station_async(self._station_name)
self._station_id = station["id"]
except MvgApiError as err:
_LOGGER.error(
"Failed to resolve station %s: %s", self._station_name, err
)
self.departures = []
return
try:
_departures = self.mvg.getlivedata(
station=self._station,
timeoffset=self._timeoffset,
ubahn=self._include_ubahn,
tram=self._include_tram,
bus=self._include_bus,
sbahn=self._include_sbahn,
_departures = await MvgApi.departures_async(
station_id=self._station_id,
offset=self._timeoffset,
limit=self._number,
transport_types=[
transport_type
for transport_type in TransportType
if transport_type.value[0] in self._products
]
if self._products
else None,
)
except ValueError:
self.departures = []
_LOGGER.warning("Returned data not understood")
return
self.departures = []
for i, _departure in enumerate(_departures):
# find the first departure meeting the criteria
for _departure in _departures:
if (
"" not in self._destinations[:1]
and _departure["destination"] not in self._destinations
):
continue
if (
"" not in self._directions[:1]
and _departure["direction"] not in self._directions
):
if "" not in self._lines[:1] and _departure["line"] not in self._lines:
continue
if "" not in self._lines[:1] and _departure["linename"] not in self._lines:
time_to_departure = _get_minutes_until_departure(_departure["time"])
if time_to_departure < self._timeoffset:
continue
if _departure["time"] < self._timeoffset:
continue
# now select the relevant data
_nextdep = {}
for k in ("destination", "linename", "time", "direction", "product"):
for k in ("destination", "line", "type", "cancelled", "icon"):
_nextdep[k] = _departure.get(k, "")
_nextdep["time"] = int(_nextdep["time"])
_nextdep["time_in_mins"] = time_to_departure
self.departures.append(_nextdep)
if i == self._number - 1:
break

View File

@@ -131,7 +131,15 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}"
# Container ID's are ephemeral, so use the container name for the unique ID
# The first one, should always be unique, it's fine if users have aliases
# According to Docker's API docs, the first name is unique
device_identifier = (
self._device_info.names[0].replace("/", " ").strip()
if self._device_info.names
else None
)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_identifier}_{entity_description.key}"
@property
def available(self) -> bool:

View File

@@ -60,7 +60,7 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_id}")
(DOMAIN, f"{self.coordinator.config_entry.entry_id}_{device_name}")
},
manufacturer=DEFAULT_NAME,
model="Container",

View File

@@ -351,13 +351,9 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
def _set_current_map(self) -> None:
if (
self.roborock_device_info.props.status is not None
and self.roborock_device_info.props.status.map_status is not None
and self.roborock_device_info.props.status.current_map is not None
):
# The map status represents the map flag as flag * 4 + 3 -
# so we have to invert that in order to get the map flag that we can use to set the current map.
self.current_map = (
self.roborock_device_info.props.status.map_status - 3
) // 4
self.current_map = self.roborock_device_info.props.status.current_map
async def set_current_map_rooms(self) -> None:
"""Fetch all of the rooms for the current map and set on RoborockMapInfo."""
@@ -440,7 +436,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
# If either of these fail, we don't care, and we want to continue.
await asyncio.gather(*tasks, return_exceptions=True)
if len(self.maps) != 1:
if len(self.maps) > 1:
# Set the map back to the map the user previously had selected so that it
# does not change the end user's app.
# Only needs to happen when we changed maps above.

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.2.9"]
"requirements": ["pysmartthings==3.3.0"]
}

View File

@@ -3,13 +3,14 @@
from __future__ import annotations
from collections import Counter
from collections.abc import Callable
from collections.abc import Callable, Sequence
from datetime import datetime, timedelta
from functools import cache
import logging
from typing import Any, Literal, cast
from sqlalchemy import select
from sqlalchemy.engine.row import Row
from sqlalchemy.orm import Session
from homeassistant.components.recorder import get_instance
@@ -38,13 +39,11 @@ ALLOWED_DOMAINS = {
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.CALENDAR,
Platform.CAMERA,
Platform.CLIMATE,
Platform.COVER,
Platform.FAN,
Platform.HUMIDIFIER,
Platform.IMAGE,
Platform.LAWN_MOWER,
Platform.LIGHT,
Platform.LOCK,
@@ -55,7 +54,6 @@ ALLOWED_DOMAINS = {
Platform.SENSOR,
Platform.SIREN,
Platform.SWITCH,
Platform.TEXT,
Platform.VACUUM,
Platform.VALVE,
Platform.WATER_HEATER,
@@ -93,61 +91,32 @@ async def async_predict_common_control(
Args:
hass: Home Assistant instance
user_id: User ID to filter events by.
Returns:
Dictionary with time categories as keys and lists of most common entity IDs as values
"""
# Get the recorder instance to ensure it's ready
recorder = get_instance(hass)
ent_reg = er.async_get(hass)
# Execute the database operation in the recorder's executor
return await recorder.async_add_executor_job(
data = await recorder.async_add_executor_job(
_fetch_with_session, hass, _fetch_and_process_data, ent_reg, user_id
)
def _fetch_and_process_data(
session: Session, ent_reg: er.EntityRegistry, user_id: str
) -> EntityUsagePredictions:
"""Fetch and process service call events from the database."""
# Prepare a dictionary to track results
results: dict[str, Counter[str]] = {
time_cat: Counter() for time_cat in TIME_CATEGORIES
}
allowed_entities = set(hass.states.async_entity_ids(ALLOWED_DOMAINS))
hidden_entities: set[str] = set()
# Keep track of contexts that we processed so that we will only process
# the first service call in a context, and not subsequent calls.
context_processed: set[bytes] = set()
thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp()
user_id_bytes = uuid_hex_to_bytes_or_none(user_id)
if not user_id_bytes:
raise ValueError("Invalid user_id format")
# Build the main query for events with their data
query = (
select(
Events.context_id_bin,
Events.time_fired_ts,
EventData.shared_data,
)
.select_from(Events)
.outerjoin(EventData, Events.data_id == EventData.data_id)
.outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id)
.where(Events.time_fired_ts >= thirty_days_ago_ts)
.where(Events.context_user_id_bin == user_id_bytes)
.where(EventTypes.event_type == "call_service")
.order_by(Events.time_fired_ts)
)
# Execute the query
context_id: bytes
time_fired_ts: float
shared_data: str | None
local_time_zone = dt_util.get_default_time_zone()
for context_id, time_fired_ts, shared_data in (
session.connection().execute(query).all()
):
for context_id, time_fired_ts, shared_data in data:
# Skip if we have already processed an event that was part of this context
if context_id in context_processed:
continue
@@ -156,7 +125,7 @@ def _fetch_and_process_data(
context_processed.add(context_id)
# Parse the event data
if not shared_data:
if not time_fired_ts or not shared_data:
continue
try:
@@ -190,27 +159,26 @@ def _fetch_and_process_data(
if not isinstance(entity_ids, list):
entity_ids = [entity_ids]
# Filter out entity IDs that are not in allowed domains
entity_ids = [
entity_id
for entity_id in entity_ids
if entity_id.split(".")[0] in ALLOWED_DOMAINS
and ((entry := ent_reg.async_get(entity_id)) is None or not entry.hidden)
]
# Convert to local time for time category determination
period = time_category(
datetime.fromtimestamp(time_fired_ts, local_time_zone).hour
)
period_results = results[period]
if not entity_ids:
continue
# Count entity usage
for entity_id in entity_ids:
if entity_id not in allowed_entities or entity_id in hidden_entities:
continue
# Convert timestamp to datetime and determine time category
if time_fired_ts:
# Convert to local time for time category determination
period = time_category(
datetime.fromtimestamp(time_fired_ts, local_time_zone).hour
)
if (
entity_id not in period_results
and (entry := ent_reg.async_get(entity_id))
and entry.hidden
):
hidden_entities.add(entity_id)
continue
# Count entity usage
for entity_id in entity_ids:
results[period][entity_id] += 1
period_results[entity_id] += 1
return EntityUsagePredictions(
morning=[
@@ -229,11 +197,40 @@ def _fetch_and_process_data(
)
def _fetch_and_process_data(
session: Session, ent_reg: er.EntityRegistry, user_id: str
) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]:
"""Fetch and process service call events from the database."""
thirty_days_ago_ts = (dt_util.utcnow() - timedelta(days=30)).timestamp()
user_id_bytes = uuid_hex_to_bytes_or_none(user_id)
if not user_id_bytes:
raise ValueError("Invalid user_id format")
# Build the main query for events with their data
query = (
select(
Events.context_id_bin,
Events.time_fired_ts,
EventData.shared_data,
)
.select_from(Events)
.outerjoin(EventData, Events.data_id == EventData.data_id)
.outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id)
.where(Events.time_fired_ts >= thirty_days_ago_ts)
.where(Events.context_user_id_bin == user_id_bytes)
.where(EventTypes.event_type == "call_service")
.order_by(Events.time_fired_ts)
)
return session.connection().execute(query).all()
def _fetch_with_session(
hass: HomeAssistant,
fetch_func: Callable[[Session], EntityUsagePredictions],
fetch_func: Callable[
[Session], Sequence[Row[tuple[bytes | None, float | None, str | None]]]
],
*args: object,
) -> EntityUsagePredictions:
) -> Sequence[Row[tuple[bytes | None, float | None, str | None]]]:
"""Execute a fetch function with a database session."""
with session_scope(hass=hass, read_only=True) as session:
return fetch_func(session, *args)

View File

@@ -26,7 +26,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 10
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b2"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)

View File

@@ -121,6 +121,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = {
"variable": BlockedIntegration(
AwesomeVersion("3.4.4"), "prevents recorder from working"
),
# Added in 2025.10.0 because of
# https://github.com/frenck/spook/issues/1066
"spook": BlockedIntegration(AwesomeVersion("4.0.0"), "breaks the template engine"),
}
DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey(

View File

@@ -39,7 +39,7 @@ habluetooth==5.6.4
hass-nabucasa==1.1.1
hassil==3.2.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250924.0
home-assistant-frontend==20250925.1
home-assistant-intents==2025.9.24
httpx==0.28.1
ifaddr==0.2.0

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.10.0.dev0"
version = "2025.10.0b2"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."

15
requirements_all.txt generated
View File

@@ -131,7 +131,7 @@ TwitterAPI==2.7.12
WSDiscovery==2.1.2
# homeassistant.components.accuweather
accuweather==4.2.1
accuweather==4.2.2
# homeassistant.components.adax
adax==0.4.0
@@ -185,7 +185,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.1
# homeassistant.components.alexa_devices
aioamazondevices==6.0.0
aioamazondevices==6.2.6
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.9.0
aioesphomeapi==41.10.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -1186,7 +1186,7 @@ hole==0.9.0
holidays==0.81
# homeassistant.components.frontend
home-assistant-frontend==20250924.0
home-assistant-frontend==20250925.1
# homeassistant.components.conversation
home-assistant-intents==2025.9.24
@@ -1364,7 +1364,7 @@ libpyfoscamcgi==0.0.7
libpyvivotek==0.4.0
# homeassistant.components.libre_hardware_monitor
librehardwaremonitor-api==1.3.1
librehardwaremonitor-api==1.4.0
# homeassistant.components.mikrotik
librouteros==3.2.0
@@ -1499,6 +1499,9 @@ mutagen==1.47.0
# homeassistant.components.mutesync
mutesync==0.0.1
# homeassistant.components.mvglive
mvg==1.4.0
# homeassistant.components.permobil
mypermobil==0.1.8
@@ -2381,7 +2384,7 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2
# homeassistant.components.smartthings
pysmartthings==3.2.9
pysmartthings==3.3.0
# homeassistant.components.smarty
pysmarty2==0.10.3

View File

@@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0
WSDiscovery==2.1.2
# homeassistant.components.accuweather
accuweather==4.2.1
accuweather==4.2.2
# homeassistant.components.adax
adax==0.4.0
@@ -173,7 +173,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.1
# homeassistant.components.alexa_devices
aioamazondevices==6.0.0
aioamazondevices==6.2.6
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.9.0
aioesphomeapi==41.10.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -1035,7 +1035,7 @@ hole==0.9.0
holidays==0.81
# homeassistant.components.frontend
home-assistant-frontend==20250924.0
home-assistant-frontend==20250925.1
# homeassistant.components.conversation
home-assistant-intents==2025.9.24
@@ -1180,7 +1180,7 @@ letpot==0.6.2
libpyfoscamcgi==0.0.7
# homeassistant.components.libre_hardware_monitor
librehardwaremonitor-api==1.3.1
librehardwaremonitor-api==1.4.0
# homeassistant.components.mikrotik
librouteros==3.2.0
@@ -1987,7 +1987,7 @@ pysmappee==0.2.29
pysmarlaapi==0.9.2
# homeassistant.components.smartthings
pysmartthings==3.2.9
pysmartthings==3.3.0
# homeassistant.components.smarty
pysmarty2==0.10.3

View File

@@ -18,15 +18,13 @@ TEST_DEVICE_1 = AmazonDevice(
online=True,
serial_number=TEST_DEVICE_1_SN,
software_version="echo_test_software_version",
do_not_disturb=False,
response_style=None,
bluetooth_state=True,
entity_id="11111111-2222-3333-4444-555555555555",
appliance_id="G1234567890123456789012345678A",
endpoint_id="G1234567890123456789012345678A",
sensors={
"dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None),
"temperature": AmazonDeviceSensor(
name="temperature", value="22.5", scale="CELSIUS"
)
name="temperature", value="22.5", error=False, scale="CELSIUS"
),
},
)
@@ -42,14 +40,11 @@ TEST_DEVICE_2 = AmazonDevice(
online=True,
serial_number=TEST_DEVICE_2_SN,
software_version="echo_test_2_software_version",
do_not_disturb=False,
response_style=None,
bluetooth_state=True,
entity_id="11111111-2222-3333-4444-555555555555",
appliance_id="G1234567890123456789012345678A",
endpoint_id="G1234567890123456789012345678A",
sensors={
"temperature": AmazonDeviceSensor(
name="temperature", value="22.5", scale="CELSIUS"
name="temperature", value="22.5", error=False, scale="CELSIUS"
)
},
)

View File

@@ -1,52 +1,4 @@
# serializer version: 1
# name: test_all_entities[binary_sensor.echo_test_bluetooth-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.echo_test_bluetooth',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Bluetooth',
'platform': 'alexa_devices',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bluetooth',
'unique_id': 'echo_test_serial_number-bluetooth',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.echo_test_bluetooth-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Echo Test Bluetooth',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.echo_test_bluetooth',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[binary_sensor.echo_test_connectivity-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -2,7 +2,6 @@
# name: test_device_diagnostics
dict({
'account name': 'Echo Test',
'bluetooth state': True,
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
@@ -12,9 +11,17 @@
]),
'device family': 'mine',
'device type': 'echo',
'do not disturb': False,
'online': True,
'response style': None,
'sensors': dict({
'dnd': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)",
}),
'temperature': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')",
}),
}),
'serial number': 'echo_test_serial_number',
'software version': 'echo_test_software_version',
})
@@ -25,7 +32,6 @@
'devices': list([
dict({
'account name': 'Echo Test',
'bluetooth state': True,
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
@@ -35,9 +41,17 @@
]),
'device family': 'mine',
'device type': 'echo',
'do not disturb': False,
'online': True,
'response style': None,
'sensors': dict({
'dnd': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='dnd', value=False, error=False, scale=None)",
}),
'temperature': dict({
'__type': "<class 'aioamazondevices.api.AmazonDeviceSensor'>",
'repr': "AmazonDeviceSensor(name='temperature', value='22.5', error=False, scale='CELSIUS')",
}),
}),
'serial number': 'echo_test_serial_number',
'software version': 'echo_test_software_version',
}),

View File

@@ -4,8 +4,6 @@
tuple(
dict({
'account_name': 'Echo Test',
'appliance_id': 'G1234567890123456789012345678A',
'bluetooth_state': True,
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
@@ -16,12 +14,18 @@
'device_family': 'mine',
'device_owner_customer_id': 'amazon_ower_id',
'device_type': 'echo',
'do_not_disturb': False,
'endpoint_id': 'G1234567890123456789012345678A',
'entity_id': '11111111-2222-3333-4444-555555555555',
'online': True,
'response_style': None,
'sensors': dict({
'dnd': dict({
'error': False,
'name': 'dnd',
'scale': None,
'value': False,
}),
'temperature': dict({
'error': False,
'name': 'temperature',
'scale': 'CELSIUS',
'value': '22.5',
@@ -41,8 +45,6 @@
tuple(
dict({
'account_name': 'Echo Test',
'appliance_id': 'G1234567890123456789012345678A',
'bluetooth_state': True,
'capabilities': list([
'AUDIO_PLAYER',
'MICROPHONE',
@@ -53,12 +55,18 @@
'device_family': 'mine',
'device_owner_customer_id': 'amazon_ower_id',
'device_type': 'echo',
'do_not_disturb': False,
'endpoint_id': 'G1234567890123456789012345678A',
'entity_id': '11111111-2222-3333-4444-555555555555',
'online': True,
'response_style': None,
'sensors': dict({
'dnd': dict({
'error': False,
'name': 'dnd',
'scale': None,
'value': False,
}),
'temperature': dict({
'error': False,
'name': 'temperature',
'scale': 'CELSIUS',
'value': '22.5',

View File

@@ -30,7 +30,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'do_not_disturb',
'unique_id': 'echo_test_serial_number-do_not_disturb',
'unique_id': 'echo_test_serial_number-dnd',
'unit_of_measurement': None,
})
# ---

View File

@@ -134,10 +134,38 @@ async def test_unit_of_measurement(
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].sensors = {sensor: AmazonDeviceSensor(name=sensor, value=api_value, scale=scale)}
].sensors = {
sensor: AmazonDeviceSensor(
name=sensor, value=api_value, error=False, scale=scale
)
}
await setup_integration(hass, mock_config_entry)
assert (state := hass.states.get(entity_id))
assert state.state == state_value
assert state.attributes["unit_of_measurement"] == unit
async def test_sensor_unavailable(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test sensor is unavailable."""
entity_id = "sensor.echo_test_illuminance"
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].sensors = {
"illuminance": AmazonDeviceSensor(
name="illuminance", value="800", error=True, scale=None
)
}
await setup_integration(hass, mock_config_entry)
assert (state := hass.states.get(entity_id))
assert state.state == STATE_UNAVAILABLE

View File

@@ -1,7 +1,9 @@
"""Tests for the Alexa Devices switch platform."""
from copy import deepcopy
from unittest.mock import AsyncMock, patch
from aioamazondevices.api import AmazonDeviceSensor
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -23,10 +25,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import TEST_DEVICE_1_SN
from .conftest import TEST_DEVICE_1, TEST_DEVICE_1_SN
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
ENTITY_ID = "switch.echo_test_do_not_disturb"
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_entities(
@@ -52,48 +56,59 @@ async def test_switch_dnd(
"""Test switching DND."""
await setup_integration(hass, mock_config_entry)
entity_id = "switch.echo_test_do_not_disturb"
assert (state := hass.states.get(entity_id))
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_OFF
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_ON,
{ATTR_ENTITY_ID: entity_id},
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
assert mock_amazon_devices_client.set_do_not_disturb.call_count == 1
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].do_not_disturb = True
device_data = deepcopy(TEST_DEVICE_1)
device_data.sensors = {
"dnd": AmazonDeviceSensor(name="dnd", value=True, error=False, scale=None),
"temperature": AmazonDeviceSensor(
name="temperature", value="22.5", error=False, scale="CELSIUS"
),
}
mock_amazon_devices_client.get_devices_data.return_value = {
TEST_DEVICE_1_SN: device_data
}
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_ON
await hass.services.async_call(
SWITCH_DOMAIN,
SERVICE_TURN_OFF,
{ATTR_ENTITY_ID: entity_id},
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].do_not_disturb = False
device_data.sensors = {
"dnd": AmazonDeviceSensor(name="dnd", value=False, error=False, scale=None),
"temperature": AmazonDeviceSensor(
name="temperature", value="22.5", error=False, scale="CELSIUS"
),
}
mock_amazon_devices_client.get_devices_data.return_value = {
TEST_DEVICE_1_SN: device_data
}
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_amazon_devices_client.set_do_not_disturb.call_count == 2
assert (state := hass.states.get(entity_id))
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_OFF
@@ -104,16 +119,13 @@ async def test_offline_device(
mock_config_entry: MockConfigEntry,
) -> None:
"""Test offline device handling."""
entity_id = "switch.echo_test_do_not_disturb"
mock_amazon_devices_client.get_devices_data.return_value[
TEST_DEVICE_1_SN
].online = False
await setup_integration(hass, mock_config_entry)
assert (state := hass.states.get(entity_id))
assert (state := hass.states.get(ENTITY_ID))
assert state.state == STATE_UNAVAILABLE
mock_amazon_devices_client.get_devices_data.return_value[
@@ -124,5 +136,5 @@ async def test_offline_device(
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert (state := hass.states.get(ENTITY_ID))
assert state.state != STATE_UNAVAILABLE

View File

@@ -10,8 +10,10 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TUR
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import setup_integration
from .const import TEST_DEVICE_1_SN
from tests.common import MockConfigEntry
@@ -54,3 +56,41 @@ async def test_alexa_api_call_exceptions(
assert exc_info.value.translation_domain == DOMAIN
assert exc_info.value.translation_key == key
assert exc_info.value.translation_placeholders == {"error": error}
async def test_alexa_unique_id_migration(
hass: HomeAssistant,
mock_amazon_devices_client: AsyncMock,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test unique_id migration."""
mock_config_entry.add_to_hass(hass)
device = device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={(DOMAIN, mock_config_entry.entry_id)},
name=mock_config_entry.title,
manufacturer="Amazon",
model="Echo Dot",
entry_type=dr.DeviceEntryType.SERVICE,
)
entity = entity_registry.async_get_or_create(
SWITCH_DOMAIN,
DOMAIN,
unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
device_id=device.id,
config_entry=mock_config_entry,
has_entity_name=True,
)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
migrated_entity = entity_registry.async_get(entity.entity_id)
assert migrated_entity is not None
assert migrated_entity.config_entry_id == mock_config_entry.entry_id
assert migrated_entity.unique_id == f"{TEST_DEVICE_1_SN}-dnd"

View File

@@ -1184,6 +1184,42 @@ async def test_reauth_attempt_to_change_mac_aborts(
}
@pytest.mark.usefixtures("mock_zeroconf", "mock_setup_entry")
async def test_reauth_password_changed(
hass: HomeAssistant, mock_client: APIClient
) -> None:
"""Test reauth when password has changed."""
entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_HOST: "127.0.0.1", CONF_PORT: 6053, CONF_PASSWORD: "old_password"},
unique_id="11:22:33:44:55:aa",
)
entry.add_to_hass(hass)
mock_client.connect.side_effect = InvalidAuthAPIError("Invalid password")
result = await entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authenticate"
assert result["description_placeholders"] == {
"name": "Mock Title",
}
mock_client.connect.side_effect = None
mock_client.connect.return_value = None
mock_client.device_info.return_value = DeviceInfo(
uses_password=True, name="test", mac_address="11:22:33:44:55:aa"
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_PASSWORD: "new_password"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert entry.data[CONF_PASSWORD] == "new_password"
@pytest.mark.usefixtures("mock_setup_entry", "mock_zeroconf")
async def test_reauth_fixed_via_dashboard(
hass: HomeAssistant,
@@ -1239,7 +1275,7 @@ async def test_reauth_fixed_via_dashboard_add_encryption_remove_password(
) -> None:
"""Test reauth fixed automatically via dashboard with password removed."""
mock_client.device_info.side_effect = (
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:aa"),
)

View File

@@ -3,7 +3,7 @@
from typing import Any
from unittest.mock import patch
from aioesphomeapi import APIClient, DeviceInfo, InvalidAuthAPIError
from aioesphomeapi import APIClient, DeviceInfo, InvalidEncryptionKeyAPIError
import pytest
from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, dashboard
@@ -194,7 +194,7 @@ async def test_new_dashboard_fix_reauth(
) -> None:
"""Test config entries waiting for reauth are triggered."""
mock_client.device_info.side_effect = (
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError("Wrong key", "test"),
DeviceInfo(uses_password=False, name="test", mac_address="11:22:33:44:55:AA"),
)

View File

@@ -1455,6 +1455,37 @@ async def test_no_reauth_wrong_mac(
)
async def test_auth_error_during_on_connect_triggers_reauth(
hass: HomeAssistant,
mock_client: APIClient,
) -> None:
"""Test that InvalidAuthAPIError during on_connect triggers reauth."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="11:22:33:44:55:aa",
data={
CONF_HOST: "test.local",
CONF_PORT: 6053,
CONF_PASSWORD: "wrong_password",
},
)
entry.add_to_hass(hass)
mock_client.device_info_and_list_entities = AsyncMock(
side_effect=InvalidAuthAPIError("Invalid password!")
)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
await hass.async_block_till_done()
flows = hass.config_entries.flow.async_progress(DOMAIN)
assert len(flows) == 1
assert flows[0]["context"]["source"] == "reauth"
assert flows[0]["context"]["entry_id"] == entry.entry_id
assert mock_client.disconnect.call_count >= 1
async def test_entry_missing_unique_id(
hass: HomeAssistant,
mock_client: APIClient,

View File

@@ -30,7 +30,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'portainer_test_entry_123_dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status',
'unique_id': 'portainer_test_entry_123_focused_einstein_status',
'unit_of_measurement': None,
})
# ---
@@ -79,7 +79,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'portainer_test_entry_123_aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status',
'unique_id': 'portainer_test_entry_123_funny_chatelet_status',
'unit_of_measurement': None,
})
# ---
@@ -177,7 +177,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'portainer_test_entry_123_ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status',
'unique_id': 'portainer_test_entry_123_practical_morse_status',
'unit_of_measurement': None,
})
# ---
@@ -226,7 +226,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'portainer_test_entry_123_bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status',
'unique_id': 'portainer_test_entry_123_serene_banach_status',
'unit_of_measurement': None,
})
# ---
@@ -275,7 +275,7 @@
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'status',
'unique_id': 'portainer_test_entry_123_cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status',
'unique_id': 'portainer_test_entry_123_stoic_turing_status',
'unit_of_measurement': None,
})
# ---

View File

@@ -5,6 +5,7 @@ from datetime import timedelta
from unittest.mock import patch
import pytest
from roborock import MultiMapsList
from roborock.exceptions import RoborockException
from vacuum_map_parser_base.config.color import SupportedColor
@@ -135,3 +136,30 @@ async def test_dynamic_local_scan_interval(
async_fire_time_changed(hass, dt_util.utcnow() + interval)
assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "20"
async def test_no_maps(
hass: HomeAssistant,
mock_roborock_entry: MockConfigEntry,
bypass_api_fixture: None,
) -> None:
"""Test that a device with no maps is handled correctly."""
prop = copy.deepcopy(PROP)
prop.status.map_status = 252
with (
patch(
"homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop",
return_value=prop,
),
patch(
"homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_multi_maps_list",
return_value=MultiMapsList(
max_multi_map=1, max_bak_map=1, multi_map_count=0, map_info=[]
),
),
patch(
"homeassistant.components.roborock.RoborockMqttClientV1.load_multi_map"
) as load_map,
):
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
assert load_map.call_count == 0

View File

@@ -62,9 +62,15 @@ async def test_with_service_calls(hass: HomeAssistant) -> None:
"""Test function with actual service call events in database."""
user_id = str(uuid.uuid4())
hass.states.async_set("light.living_room", "off")
hass.states.async_set("light.kitchen", "off")
hass.states.async_set("climate.thermostat", "off")
hass.states.async_set("light.bedroom", "off")
hass.states.async_set("lock.front_door", "locked")
# Create service call events at different times of day
# Morning events - use separate service calls to get around context deduplication
with freeze_time("2023-07-01 07:00:00+00:00"): # Morning
with freeze_time("2023-07-01 07:00:00"): # Morning
hass.bus.async_fire(
EVENT_CALL_SERVICE,
{
@@ -77,7 +83,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
# Afternoon events
with freeze_time("2023-07-01 14:00:00+00:00"): # Afternoon
with freeze_time("2023-07-01 14:00:00"): # Afternoon
hass.bus.async_fire(
EVENT_CALL_SERVICE,
{
@@ -90,7 +96,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
# Evening events
with freeze_time("2023-07-01 19:00:00+00:00"): # Evening
with freeze_time("2023-07-01 19:00:00"): # Evening
hass.bus.async_fire(
EVENT_CALL_SERVICE,
{
@@ -103,7 +109,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
# Night events
with freeze_time("2023-07-01 23:00:00+00:00"): # Night
with freeze_time("2023-07-01 23:00:00"): # Night
hass.bus.async_fire(
EVENT_CALL_SERVICE,
{
@@ -119,7 +125,7 @@ async def test_with_service_calls(hass: HomeAssistant) -> None:
await async_wait_recording_done(hass)
# Get predictions - make sure we're still in a reasonable timeframe
with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent
with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent
results = await async_predict_common_control(hass, user_id)
# Verify results contain the expected entities in the correct time periods
@@ -151,7 +157,12 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None:
suggested_object_id="kitchen",
)
with freeze_time("2023-07-01 10:00:00+00:00"): # Morning
hass.states.async_set("light.living_room", "off")
hass.states.async_set("light.kitchen", "off")
hass.states.async_set("light.hallway", "off")
hass.states.async_set("not_allowed.domain", "off")
with freeze_time("2023-07-01 10:00:00"): # Morning
hass.bus.async_fire(
EVENT_CALL_SERVICE,
{
@@ -163,6 +174,7 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None:
"light.kitchen",
"light.hallway",
"not_allowed.domain",
"light.not_in_state_machine",
]
},
},
@@ -172,7 +184,7 @@ async def test_multiple_entities_in_one_call(hass: HomeAssistant) -> None:
await async_wait_recording_done(hass)
with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent
with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent
results = await async_predict_common_control(hass, user_id)
# Two lights should be counted (10:00 UTC = 02:00 local = night)
@@ -189,7 +201,10 @@ async def test_context_deduplication(hass: HomeAssistant) -> None:
user_id = str(uuid.uuid4())
context = Context(user_id=user_id)
with freeze_time("2023-07-01 10:00:00+00:00"): # Morning
hass.states.async_set("light.living_room", "off")
hass.states.async_set("switch.coffee_maker", "off")
with freeze_time("2023-07-01 10:00:00"): # Morning
# Fire multiple events with the same context
hass.bus.async_fire(
EVENT_CALL_SERVICE,
@@ -215,7 +230,7 @@ async def test_context_deduplication(hass: HomeAssistant) -> None:
await async_wait_recording_done(hass)
with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent
with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent
results = await async_predict_common_control(hass, user_id)
# Only the first event should be processed (10:00 UTC = 02:00 local = night)
@@ -232,8 +247,11 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None:
"""Test that events older than 30 days are excluded."""
user_id = str(uuid.uuid4())
hass.states.async_set("light.old_event", "off")
hass.states.async_set("light.recent_event", "off")
# Create an old event (35 days ago)
with freeze_time("2023-05-27 10:00:00+00:00"): # 35 days before July 1st
with freeze_time("2023-05-27 10:00:00"): # 35 days before July 1st
hass.bus.async_fire(
EVENT_CALL_SERVICE,
{
@@ -246,7 +264,7 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
# Create a recent event (5 days ago)
with freeze_time("2023-06-26 10:00:00+00:00"): # 5 days before July 1st
with freeze_time("2023-06-26 10:00:00"): # 5 days before July 1st
hass.bus.async_fire(
EVENT_CALL_SERVICE,
{
@@ -261,7 +279,7 @@ async def test_old_events_excluded(hass: HomeAssistant) -> None:
await async_wait_recording_done(hass)
# Query with current time
with freeze_time("2023-07-01 10:00:00+00:00"):
with freeze_time("2023-07-01 10:00:00"):
results = await async_predict_common_control(hass, user_id)
# Only recent event should be included (10:00 UTC = 02:00 local = night)
@@ -278,8 +296,16 @@ async def test_entities_limit(hass: HomeAssistant) -> None:
"""Test that only top entities are returned per time category."""
user_id = str(uuid.uuid4())
hass.states.async_set("light.most_used", "off")
hass.states.async_set("light.second", "off")
hass.states.async_set("light.third", "off")
hass.states.async_set("light.fourth", "off")
hass.states.async_set("light.fifth", "off")
hass.states.async_set("light.sixth", "off")
hass.states.async_set("light.seventh", "off")
# Create more than 5 different entities in morning
with freeze_time("2023-07-01 08:00:00+00:00"):
with freeze_time("2023-07-01 08:00:00"):
# Create entities with different frequencies
entities_with_counts = [
("light.most_used", 10),
@@ -308,7 +334,7 @@ async def test_entities_limit(hass: HomeAssistant) -> None:
await async_wait_recording_done(hass)
with (
freeze_time("2023-07-02 10:00:00+00:00"),
freeze_time("2023-07-02 10:00:00"),
patch(
"homeassistant.components.usage_prediction.common_control.RESULTS_TO_INCLUDE",
5,
@@ -335,7 +361,10 @@ async def test_different_users_separated(hass: HomeAssistant) -> None:
user_id_1 = str(uuid.uuid4())
user_id_2 = str(uuid.uuid4())
with freeze_time("2023-07-01 10:00:00+00:00"):
hass.states.async_set("light.user1_light", "off")
hass.states.async_set("light.user2_light", "off")
with freeze_time("2023-07-01 10:00:00"):
# User 1 events
hass.bus.async_fire(
EVENT_CALL_SERVICE,
@@ -363,7 +392,7 @@ async def test_different_users_separated(hass: HomeAssistant) -> None:
await async_wait_recording_done(hass)
# Get results for each user
with freeze_time("2023-07-02 10:00:00+00:00"): # Next day, so events are recent
with freeze_time("2023-07-02 10:00:00"): # Next day, so events are recent
results_user1 = await async_predict_common_control(hass, user_id_1)
results_user2 = await async_predict_common_control(hass, user_id_2)