This commit is contained in:
Franck Nijhof
2025-10-24 22:02:18 +02:00
committed by GitHub
78 changed files with 526 additions and 155 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 8
CACHE_VERSION: 1
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.10"
+5
View File
@@ -34,6 +34,9 @@ INPUT_FIELD_CODE = "code"
DUMMY_SECRET = "FPPTH34D4E3MI2HG"
GOOGLE_AUTHENTICATOR_URL = "https://support.google.com/accounts/answer/1066447"
AUTHY_URL = "https://authy.com/"
def _generate_qr_code(data: str) -> str:
"""Generate a base64 PNG string represent QR Code image of data."""
@@ -229,6 +232,8 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]):
"code": self._ota_secret,
"url": self._url,
"qr_code": self._image,
"google_authenticator_url": GOOGLE_AUTHENTICATOR_URL,
"authy_url": AUTHY_URL,
},
errors=errors,
)
@@ -26,6 +26,10 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Documentation URL for API key generation
_API_KEY_URL = "https://docs.airnowapi.org/account/request/"
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
"""Validate the user input allows us to connect.
@@ -114,6 +118,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN):
),
}
),
description_placeholders={"api_key_url": _API_KEY_URL},
errors=errors,
)
+1 -1
View File
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"description": "To generate API key go to https://docs.airnowapi.org/account/request/",
"description": "To generate API key go to {api_key_url}",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==1.0.1"]
"requirements": ["aioairzone==1.0.2"]
}
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==6.4.4"]
"requirements": ["aioamazondevices==6.4.6"]
}
+1 -1
View File
@@ -5,7 +5,7 @@
"step": {
"init": {
"title": "Set up two-factor authentication using TOTP",
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
"description": "To activate two-factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator]({google_authenticator_url}) or [Authy]({authy_url}).\n\n{qr_code}\n\nAfter scanning the code, enter the six-digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
}
},
"error": {
+1 -1
View File
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
"quality_scale": "platinum",
"requirements": ["bring-api==1.1.0"]
"requirements": ["bring-api==1.1.1"]
}
+1 -1
View File
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["pycync==0.4.1"]
"requirements": ["pycync==0.4.2"]
}
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/droplet",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["pydroplet==2.3.3"],
"requirements": ["pydroplet==2.3.4"],
"zeroconf": ["_droplet._tcp.local."]
}
@@ -111,7 +111,12 @@ class FlumeConfigFlow(ConfigFlow, domain=DOMAIN):
errors[CONF_PASSWORD] = "invalid_auth"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
description_placeholders={
"api_url": "https://portal.flumetech.com/settings#token"
},
)
async def async_step_reauth(
+1 -1
View File
@@ -7,7 +7,7 @@
},
"step": {
"user": {
"description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token",
"description": "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at {api_url}",
"title": "Connect to your Flume account",
"data": {
"username": "[%key:common::config_flow::data::username%]",
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
"requirements": ["holidays==0.82", "babel==2.15.0"]
"requirements": ["holidays==0.83", "babel==2.15.0"]
}
@@ -182,14 +182,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
"Failed to listen to websocket. Trying to reconnect: %s",
err,
)
if not hass.is_stopping:
await asyncio.sleep(self.reconnect_time)
self.reconnect_time = min(self.reconnect_time * 2, MAX_WS_RECONNECT_TIME)
entry.async_create_background_task(
hass,
self.client_listen(hass, entry, automower_client),
"reconnect_task",
)
def _should_poll(self) -> bool:
"""Return True if at least one mower is connected and at least one is not OFF."""
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.2.1"]
"requirements": ["aioautomower==2.3.1"]
}
+3 -2
View File
@@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
from .entity import HuumBaseEntity
@@ -55,12 +56,12 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
@property
def min_temp(self) -> int:
"""Return configured minimal temperature."""
return self.coordinator.data.sauna_config.min_temp
return self.coordinator.data.sauna_config.min_temp or CONFIG_DEFAULT_MIN_TEMP
@property
def max_temp(self) -> int:
"""Return configured maximum temperature."""
return self.coordinator.data.sauna_config.max_temp
return self.coordinator.data.sauna_config.max_temp or CONFIG_DEFAULT_MAX_TEMP
@property
def hvac_mode(self) -> HVACMode:
+3
View File
@@ -9,3 +9,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.LIGHT, Platform.
CONFIG_STEAMER = 1
CONFIG_LIGHT = 2
CONFIG_STEAMER_AND_LIGHT = 3
CONFIG_DEFAULT_MIN_TEMP = 40
CONFIG_DEFAULT_MAX_TEMP = 110
@@ -48,6 +48,8 @@ from homeassistant.util.network import is_link_local
from .const import DOMAIN, LOGGER
DEVICES_URL = "https://developer.lametric.com/user/devices"
class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a LaMetric config flow."""
@@ -164,6 +166,9 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
return self.async_show_form(
step_id="manual_entry",
data_schema=vol.Schema(schema),
description_placeholders={
"devices_url": DEVICES_URL,
},
errors=errors,
)
@@ -24,7 +24,7 @@
},
"data_description": {
"host": "The IP address or hostname of your LaMetric TIME on your network.",
"api_key": "You can find this API key in the [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)."
"api_key": "You can find this API key in the [devices page in your LaMetric developer account]({devices_url})."
}
},
"cloud_select_device": {
+3
View File
@@ -18,6 +18,7 @@ from homeassistant.const import (
CONF_SOURCE,
CONF_UNIT_OF_MEASUREMENT,
LIGHT_LUX,
PERCENTAGE,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfSpeed,
@@ -50,6 +51,7 @@ DEVICE_CLASS_MAPPING = {
pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE,
pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT,
pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2,
pypck.lcn_defs.VarUnit.PERCENT: SensorDeviceClass.HUMIDITY,
}
UNIT_OF_MEASUREMENT_MAPPING = {
@@ -62,6 +64,7 @@ UNIT_OF_MEASUREMENT_MAPPING = {
pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT,
pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE,
pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION,
pypck.lcn_defs.VarUnit.PERCENT: PERCENTAGE,
}
@@ -202,5 +202,10 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
step_id="connect", data_schema=self._config_settings, errors=errors
step_id="connect",
data_schema=self._config_settings,
errors=errors,
description_placeholders={
"documentation_url": "https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key",
},
)
@@ -9,7 +9,7 @@
}
},
"connect": {
"description": "You will need the 16 character API key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key for instructions",
"description": "You will need the 16 character API key, see {documentation_url} for instructions",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
@@ -63,7 +63,12 @@ class NightscoutConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
description_placeholders={
"example_url": "https://myhomeassistant.duckdns.org:5423",
},
)
@@ -3,10 +3,13 @@
"step": {
"user": {
"title": "Enter your Nightscout server information.",
"description": "- URL: the address of your nightscout instance. I.e.: https://myhomeassistant.duckdns.org:5423\n- API Key (optional): Only use if your instance is protected (auth_default_roles != readable).",
"data": {
"url": "[%key:common::config_flow::data::url%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"url": "The address of your Nightscout instance. For example: {example_url}.",
"api_key": "Optional; only use it if your instance is protected (auth_default_roles != readable)."
}
}
},
@@ -110,6 +110,8 @@ async def _create_webhook(
translation_placeholders={
"base_url": hass_url,
"network_link": "https://my.home-assistant.io/redirect/network/",
"sample_ip": "192.168.1.10",
"sample_url": "http://192.168.1.10:8123",
},
)
else:
@@ -177,4 +177,5 @@ class NukiConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=self.add_suggested_values_to_schema(data_schema, user_input),
errors=errors,
description_placeholders={"sample_ip": "192.168.1.25"},
)
+2 -2
View File
@@ -9,7 +9,7 @@
"encrypt_token": "Use an encrypted token for authentication."
},
"data_description": {
"host": "The hostname or IP address of your Nuki bridge. For example: 192.168.1.25."
"host": "The hostname or IP address of your Nuki bridge. For example: {sample_ip}."
}
},
"reauth_confirm": {
@@ -34,7 +34,7 @@
"issues": {
"https_webhook": {
"title": "Nuki webhook URL uses HTTPS (SSL)",
"description": "The Nuki bridge can not push events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `http://192.168.1.10:8123` where `192.168.1.10` is the IP of the Home Assistant device"
"description": "The Nuki bridge cannot push events to an HTTPS address (SSL), please configure a (local) HTTP address under \"Home Assistant URL\" in the [network settings]({network_link}). The current (local) address is: `{base_url}`, a valid address could, for example, be `{sample_url}` where `{sample_ip}` is the IP of the Home Assistant device"
}
},
"entity": {
@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"requirements": ["opower==0.15.7"]
"requirements": ["opower==0.15.8"]
}
@@ -16,6 +16,9 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_SCHEMA_STEP_USER = vol.Schema({vol.Required(CONF_API_KEY): str})
CONF_PORTAL_URL = "portal_url"
OSOENERGY_PORTAL_URL = "https://portal.osoenergy.no/"
class OSOEnergyFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a OSO Energy config flow."""
@@ -45,6 +48,7 @@ class OSOEnergyFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=_SCHEMA_STEP_USER,
errors=errors,
description_placeholders={CONF_PORTAL_URL: OSOENERGY_PORTAL_URL},
)
async def get_user_email(self, subscription_key: str) -> str | None:
@@ -66,4 +70,5 @@ class OSOEnergyFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=self.add_suggested_values_to_schema(
_SCHEMA_STEP_USER, self._get_reauth_entry().data
),
description_placeholders={CONF_PORTAL_URL: OSOENERGY_PORTAL_URL},
)
@@ -3,14 +3,14 @@
"step": {
"user": {
"title": "OSO Energy auth",
"description": "Enter the 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'",
"description": "Enter the 'Subscription key' for your account generated at {portal_url}",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
},
"reauth": {
"title": "OSO Energy auth",
"description": "Enter a new 'Subscription key' for your account generated at 'https://portal.osoenergy.no/'.",
"description": "Enter a new 'Subscription key' for your account generated at {portal_url}",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
@@ -171,9 +171,27 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
except TooManyAttemptsBannedException:
errors["base"] = "too_many_attempts"
except UnknownUserException:
# If the user has no supported CozyTouch devices on
# the Overkiz API server. Login will return unknown user.
if user_input[CONF_HUB] in {
Server.ATLANTIC_COZYTOUCH,
Server.SAUTER_COZYTOUCH,
Server.THERMOR_COZYTOUCH,
}:
description_placeholders["unsupported_device"] = "CozyTouch"
# Somfy Protect accounts are not supported since they don't use
# the Overkiz API server. Login will return unknown user.
description_placeholders["unsupported_device"] = "Somfy Protect"
elif user_input[CONF_HUB] in {
Server.SOMFY_AMERICA,
Server.SOMFY_DEVELOPER_MODE,
Server.SOMFY_EUROPE,
Server.SOMFY_OCEANIA,
}:
description_placeholders["unsupported_device"] = "Somfy Protect"
# Fallback for other unknown devices
else:
description_placeholders["unsupported_device"] = "Unknown"
errors["base"] = "unsupported_hardware"
except Exception: # noqa: BLE001
errors["base"] = "unknown"
@@ -55,7 +55,7 @@
"too_many_attempts": "Too many attempts with an invalid token, temporarily banned",
"too_many_requests": "Too many requests, try again later",
"unknown": "[%key:common::config_flow::error::unknown%]",
"unsupported_hardware": "Your {unsupported_device} hardware is not supported by this integration."
"unsupported_hardware": "Your {unsupported_device} hardware is not using the Overkiz platform and can't be supported by this integration."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
@@ -8,11 +8,15 @@ import logging
from pyprobeplus import ProbePlusDevice
from pyprobeplus.exceptions import ProbePlusDeviceNotFound, ProbePlusError
from homeassistant.components import bluetooth
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
type ProbePlusConfigEntry = ConfigEntry[ProbePlusDataUpdateCoordinator]
_LOGGER = logging.getLogger(__name__)
@@ -39,8 +43,17 @@ class ProbePlusDataUpdateCoordinator(DataUpdateCoordinator[None]):
config_entry=entry,
)
available_scanners = bluetooth.async_scanner_count(hass, connectable=True)
if available_scanners == 0:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="no_bleak_scanner",
)
self.device: ProbePlusDevice = ProbePlusDevice(
address_or_ble_device=entry.data[CONF_ADDRESS],
scanner=bluetooth.async_get_scanner(hass),
name=entry.title,
notify_callback=self.async_update_listeners,
)
@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["pyprobeplus==1.1.0"]
"requirements": ["pyprobeplus==1.1.2"]
}
@@ -45,5 +45,10 @@
"name": "Relay voltage"
}
}
},
"exceptions": {
"no_bleak_scanner": {
"message": "No compatible Bluetooth scanner found."
}
}
}
@@ -1,5 +1,7 @@
"""Base entity for the Pterodactyl integration."""
from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.helpers.device_registry import DeviceInfo
@@ -33,7 +35,9 @@ class PterodactylEntity(CoordinatorEntity[PterodactylCoordinator]):
name=self.game_server_data.name,
model=self.game_server_data.name,
model_id=self.game_server_data.uuid,
configuration_url=f"{config_entry.data[CONF_URL]}/server/{identifier}",
configuration_url=str(
URL(config_entry.data[CONF_URL]) / "server" / identifier
),
)
@property
@@ -91,7 +91,12 @@ class RachioConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
step_id="user",
data_schema=DATA_SCHEMA,
errors=errors,
description_placeholders={
"api_key_url": "https://app.rach.io/",
},
)
async def async_step_homekit(
+1 -1
View File
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "Connect to your Rachio device",
"description": "You will need the API key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.",
"description": "You will need the API key from {api_key_url}. Go to Settings, then select 'GET API KEY'.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
+1 -1
View File
@@ -48,7 +48,7 @@ from .const import (
DEFAULT_OFF_DELAY = 2.0
CONNECT_TIMEOUT = 30.0
CONNECT_TIMEOUT = 60.0
_LOGGER = logging.getLogger(__name__)
@@ -61,4 +61,7 @@ class SensorPushCloudConfigFlow(ConfigFlow, domain=DOMAIN):
}
),
errors=errors,
description_placeholders={
"dashboard_url": "https://dashboard.sensorpush.com/",
},
)
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"description": "To activate API access, log in to the [Gateway Cloud Dashboard](https://dashboard.sensorpush.com/) and agree to the terms of service. Devices are not available until activated with the SensorPush app on iOS or Android.",
"description": "To activate API access, log in to the [Gateway Cloud Dashboard]({dashboard_url}) and agree to the terms of service. Devices are not available until activated with the SensorPush app on iOS or Android.",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
+4 -8
View File
@@ -1554,8 +1554,7 @@ RPC_SENSORS: Final = {
"number_energy_charge": RpcSensorDescription(
key="number",
sub_key="value",
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL,
@@ -1599,8 +1598,7 @@ RPC_SENSORS: Final = {
key="object",
sub_key="value",
value=lambda status, _: float(status["counter"]["total"]),
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -1611,8 +1609,7 @@ RPC_SENSORS: Final = {
sub_key="value",
name="Total Active Energy",
value=lambda status, _: float(status["total_act_energy"]),
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=2,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -1623,8 +1620,7 @@ RPC_SENSORS: Final = {
sub_key="value",
name="Total Power",
value=lambda status, _: float(status["total_power"]),
native_unit_of_measurement=UnitOfPower.WATT,
suggested_unit_of_measurement=UnitOfPower.KILO_WATT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
suggested_display_precision=2,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
@@ -27,6 +27,10 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv
from .const import DOMAIN, LOGGER
CONF_AUTH_CODE = "auth_code"
CONF_DOCUMENTATION_URL = "documentation_url"
DOCUMENTATION_URL = (
"https://home-assistant.io/integrations/simplisafe#getting-an-authorization-code"
)
STEP_USER_SCHEMA = vol.Schema(
{
@@ -84,7 +88,10 @@ class SimpliSafeFlowHandler(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_SCHEMA,
description_placeholders={CONF_URL: self._oauth_values.auth_url},
description_placeholders={
CONF_URL: self._oauth_values.auth_url,
CONF_DOCUMENTATION_URL: DOCUMENTATION_URL,
},
)
auth_code = user_input[CONF_AUTH_CODE]
@@ -102,7 +109,10 @@ class SimpliSafeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors={CONF_AUTH_CODE: "invalid_auth_code_length"},
description_placeholders={CONF_URL: self._oauth_values.auth_url},
description_placeholders={
CONF_URL: self._oauth_values.auth_url,
CONF_DOCUMENTATION_URL: DOCUMENTATION_URL,
},
)
errors = {}
@@ -124,7 +134,10 @@ class SimpliSafeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors=errors,
description_placeholders={CONF_URL: self._oauth_values.auth_url},
description_placeholders={
CONF_URL: self._oauth_values.auth_url,
CONF_DOCUMENTATION_URL: DOCUMENTATION_URL,
},
)
simplisafe_user_id = str(simplisafe.user_id)
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation](http://home-assistant.io/integrations/simplisafe#getting-an-authorization-code) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. If you've already logged into SimpliSafe in your browser, you may want to open a new tab, then copy/paste the above URL into that tab.\n\nWhen the process is complete, return here and input the authorization code from the `com.simplisafe.mobile` URL.",
"description": "SimpliSafe authenticates users via its web app. Due to technical limitations, there is a manual step at the end of this process; please ensure that you read the [documentation]({documentation_url}) before starting.\n\nWhen you are ready, click [here]({url}) to open the SimpliSafe web app and input your credentials. If you've already logged into SimpliSafe in your browser, you may want to open a new tab, then copy/paste the above URL into that tab.\n\nWhen the process is complete, return here and input the authorization code from the `com.simplisafe.mobile` URL.",
"data": {
"auth_code": "Authorization Code"
}
@@ -117,6 +117,9 @@ class StarlineFlowHandler(ConfigFlow, domain=DOMAIN):
}
),
errors=errors,
description_placeholders={
"developer_account_url": "https://my.starline.ru/developer",
},
)
@callback
@@ -3,7 +3,7 @@
"step": {
"auth_app": {
"title": "Application credentials",
"description": "Application ID and secret code from [StarLine developer account](https://my.starline.ru/developer)",
"description": "Application ID and secret code from [StarLine developer account]({developer_account_url})",
"data": {
"app_id": "App ID",
"app_secret": "Secret"
@@ -24,7 +24,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DATA_WAIT_TIMEOUT, DOMAIN
from .const import DATA_WAIT_TIMEOUT, DOMAIN, SYNTAX_KEYS_DOCUMENTATION_URL
_LOGGER = logging.getLogger(__name__)
@@ -132,7 +132,11 @@ class SystemBridgeConfigFlow(
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
description_placeholders={
"syntax_keys_documentation_url": SYNTAX_KEYS_DOCUMENTATION_URL
},
)
errors, info = await _async_get_info(self.hass, user_input)
@@ -144,7 +148,12 @@ class SystemBridgeConfigFlow(
return self.async_create_entry(title=info["hostname"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
description_placeholders={
"syntax_keys_documentation_url": SYNTAX_KEYS_DOCUMENTATION_URL
},
)
async def async_step_authenticate(
@@ -174,7 +183,10 @@ class SystemBridgeConfigFlow(
return self.async_show_form(
step_id="authenticate",
data_schema=STEP_AUTHENTICATE_DATA_SCHEMA,
description_placeholders={"name": self._name},
description_placeholders={
"name": self._name,
"syntax_keys_documentation_url": SYNTAX_KEYS_DOCUMENTATION_URL,
},
errors=errors,
)
@@ -4,6 +4,8 @@ from typing import Final
from systembridgemodels.modules import Module
SYNTAX_KEYS_DOCUMENTATION_URL = "http://robotjs.io/docs/syntax#keys"
DOMAIN = "system_bridge"
MODULES: Final[list[Module]] = [
@@ -194,7 +194,7 @@
},
"key": {
"name": "Key",
"description": "Key to press. List available here: http://robotjs.io/docs/syntax#keys."
"description": "Key to press. List available here: {syntax_keys_documentation_url}."
}
}
},
@@ -134,7 +134,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
api = tesla.vehicles.createSigned(vin)
else:
api = tesla.vehicles.createFleet(vin)
coordinator = TeslaFleetVehicleDataCoordinator(hass, entry, api, product)
coordinator = TeslaFleetVehicleDataCoordinator(
hass, entry, api, product, Scope.VEHICLE_LOCATION in scopes
)
await coordinator.async_config_entry_first_refresh()
@@ -39,9 +39,9 @@ ENDPOINTS = [
VehicleDataEndpoint.CHARGE_STATE,
VehicleDataEndpoint.CLIMATE_STATE,
VehicleDataEndpoint.DRIVE_STATE,
VehicleDataEndpoint.LOCATION_DATA,
VehicleDataEndpoint.VEHICLE_STATE,
VehicleDataEndpoint.VEHICLE_CONFIG,
VehicleDataEndpoint.LOCATION_DATA,
]
@@ -65,6 +65,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
updated_once: bool
pre2021: bool
last_active: datetime
endpoints: list[VehicleDataEndpoint]
def __init__(
self,
@@ -72,6 +73,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
config_entry: TeslaFleetConfigEntry,
api: VehicleFleet,
product: dict,
location: bool,
) -> None:
"""Initialize TeslaFleet Vehicle Update Coordinator."""
super().__init__(
@@ -85,6 +87,11 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.data = flatten(product)
self.updated_once = False
self.last_active = datetime.now()
self.endpoints = (
ENDPOINTS
if location
else [ep for ep in ENDPOINTS if ep != VehicleDataEndpoint.LOCATION_DATA]
)
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using TeslaFleet API."""
@@ -97,7 +104,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
if self.data["state"] != TeslaFleetState.ONLINE:
return self.data
response = await self.api.vehicle_data(endpoints=ENDPOINTS)
response = await self.api.vehicle_data(endpoints=self.endpoints)
data = response["response"]
except VehicleOffline:
@@ -251,11 +258,14 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
raise UpdateFailed("Received invalid data")
# Add all time periods together
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None)
for period in data.get("time_series", []):
for key in ENERGY_HISTORY_FIELDS:
if key in period:
output[key] += period[key]
if output[key] is None:
output[key] = period[key]
else:
output[key] += period[key]
return output
@@ -199,10 +199,13 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise UpdateFailed("Received invalid data")
# Add all time periods together
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
for period in data["time_series"]:
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None)
for period in data.get("time_series", []):
for key in ENERGY_HISTORY_FIELDS:
if key in period:
output[key] += period[key]
if output[key] is None:
output[key] = period[key]
else:
output[key] += period[key]
return output
@@ -82,7 +82,14 @@ class TTNFlowHandler(ConfigFlow, domain=DOMAIN):
),
user_input,
)
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=errors,
description_placeholders={
"instructions_url": "https://www.thethingsindustries.com/docs/integrations/adding-applications/"
},
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "Connect to The Things Network v3",
"description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions](https://www.thethingsindustries.com/docs/integrations/adding-applications/) on how to register your application and create an API key.",
"description": "Enter the API hostname, application ID and API key to use with Home Assistant.\n\n[Read the instructions]({instructions_url}) on how to register your application and create an API key.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"app_id": "Application ID",
@@ -172,4 +172,7 @@ class TomorrowioConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=_get_config_schema(self.hass, self.source, user_input),
errors=errors,
description_placeholders={
"signup_link": "[Tomorrow.io](https://app.tomorrow.io/signup)"
},
)
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"description": "To get an API key, sign up at [Tomorrow.io](https://app.tomorrow.io/signup).",
"description": "To get an API key, sign up at {signup_link}.",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -89,7 +89,8 @@ def async_migrate_entities_unique_ids(
"""Migrate unique_ids in the entity registry after updating Uptime Kuma."""
if (
coordinator.version is coordinator.api.version
coordinator.version is None
or coordinator.version.version == coordinator.api.version.version
or int(coordinator.api.version.major) < 2
):
return
@@ -116,6 +117,32 @@ def async_migrate_entities_unique_ids(
new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}",
)
# migrate device identifiers and update version
device_reg = dr.async_get(hass)
for monitor in metrics.values():
if device := device_reg.async_get_device(
{(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor.monitor_name!s}")}
):
new_identifier = {
(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor.monitor_id!s}")
}
device_reg.async_update_device(
device.id,
new_identifiers=new_identifier,
sw_version=coordinator.api.version.version,
)
if device := device_reg.async_get_device(
{(DOMAIN, f"{coordinator.config_entry.entry_id}_update")}
):
device_reg.async_update_device(
device.id,
sw_version=coordinator.api.version.version,
)
hass.async_create_task(
hass.config_entries.async_reload(coordinator.config_entry.entry_id)
)
class UptimeKumaSoftwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]):
"""Uptime Kuma coordinator for retrieving update information."""
+5 -2
View File
@@ -100,7 +100,10 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
"""Return the currently set speed."""
current_level = self.device.state.fan_level
if self.device.state.mode == VS_FAN_MODE_MANUAL and current_level is not None:
if (
self.device.state.mode in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL)
and current_level is not None
):
if current_level == 0:
return 0
return ordered_list_item_to_percentage(
@@ -182,7 +185,7 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
)
# Switch to manual mode if not already set
if self.device.state.mode != VS_FAN_MODE_MANUAL:
if self.device.state.mode not in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL):
if not await self.device.set_manual_mode():
raise HomeAssistantError(
"An error occurred while setting manual mode."
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.82"]
"requirements": ["holidays==0.83"]
}
@@ -11,6 +11,7 @@ from aiomusiccast.features import ZoneFeature
from homeassistant.components import media_source
from homeassistant.components.media_player import (
BrowseError,
BrowseMedia,
MediaClass,
MediaPlayerEntity,
@@ -372,7 +373,7 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity):
]
if add_media_source:
with contextlib.suppress(media_source.BrowseError):
with contextlib.suppress(BrowseError):
item = await media_source.async_browse_media(
self.hass,
None,
+6 -1
View File
@@ -26,7 +26,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.typing import ConfigType
from . import api
from .const import ATTR_LORA_INFO, DOMAIN, YOLINK_EVENT
from .const import ATTR_LORA_INFO, DOMAIN, SUPPORTED_REMOTERS, YOLINK_EVENT
from .coordinator import YoLinkCoordinator
from .device_trigger import CONF_LONG_PRESS, CONF_SHORT_PRESS
from .services import async_setup_services
@@ -151,6 +151,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device_pairing_mapping[parent_id] = device.device_id
for device in yolink_home.get_devices():
if (
device.device_type == ATTR_DEVICE_SMART_REMOTER
and device.device_model_name not in SUPPORTED_REMOTERS
):
continue
paried_device: YoLinkDevice | None = None
if (
paried_device_id := device_pairing_mapping.get(device.device_id)
+7
View File
@@ -44,3 +44,10 @@ DEV_MODEL_LEAK_STOP_YS5009 = "YS5009"
DEV_MODEL_LEAK_STOP_YS5029 = "YS5029"
DEV_MODEL_WATER_METER_YS5018_EC = "YS5018-EC"
DEV_MODEL_WATER_METER_YS5018_UC = "YS5018-UC"
SUPPORTED_REMOTERS = [
DEV_MODEL_FLEX_FOB_YS3604_EC,
DEV_MODEL_FLEX_FOB_YS3604_UC,
DEV_MODEL_FLEX_FOB_YS3614_EC,
DEV_MODEL_FLEX_FOB_YS3614_UC,
]
+1 -1
View File
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
MINOR_VERSION: Final = 10
PATCH_VERSION: Final = "3"
PATCH_VERSION: Final = "4"
__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)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2025.10.3"
version = "2025.10.4"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
+9 -9
View File
@@ -182,10 +182,10 @@ aioairq==0.4.7
aioairzone-cloud==0.7.2
# homeassistant.components.airzone
aioairzone==1.0.1
aioairzone==1.0.2
# homeassistant.components.alexa_devices
aioamazondevices==6.4.4
aioamazondevices==6.4.6
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -204,7 +204,7 @@ aioaseko==1.0.0
aioasuswrt==1.5.1
# homeassistant.components.husqvarna_automower
aioautomower==2.2.1
aioautomower==2.3.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -682,7 +682,7 @@ boto3==1.37.1
botocore==1.37.1
# homeassistant.components.bring
bring-api==1.1.0
bring-api==1.1.1
# homeassistant.components.broadlink
broadlink==0.19.0
@@ -1183,7 +1183,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.82
holidays==0.83
# homeassistant.components.frontend
home-assistant-frontend==20251001.4
@@ -1649,7 +1649,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
opower==0.15.7
opower==0.15.8
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1933,7 +1933,7 @@ pycsspeechtts==1.0.8
# pycups==2.0.4
# homeassistant.components.cync
pycync==0.4.1
pycync==0.4.2
# homeassistant.components.daikin
pydaikin==2.17.1
@@ -1966,7 +1966,7 @@ pydrawise==2025.9.0
pydroid-ipcam==3.0.0
# homeassistant.components.droplet
pydroplet==2.3.3
pydroplet==2.3.4
# homeassistant.components.ebox
pyebox==1.1.4
@@ -2290,7 +2290,7 @@ pypoint==3.0.0
pyportainer==1.0.3
# homeassistant.components.probe_plus
pyprobeplus==1.1.0
pyprobeplus==1.1.2
# homeassistant.components.profiler
pyprof2calltree==1.4.5
+9 -9
View File
@@ -170,10 +170,10 @@ aioairq==0.4.7
aioairzone-cloud==0.7.2
# homeassistant.components.airzone
aioairzone==1.0.1
aioairzone==1.0.2
# homeassistant.components.alexa_devices
aioamazondevices==6.4.4
aioamazondevices==6.4.6
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -192,7 +192,7 @@ aioaseko==1.0.0
aioasuswrt==1.5.1
# homeassistant.components.husqvarna_automower
aioautomower==2.2.1
aioautomower==2.3.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -609,7 +609,7 @@ boschshcpy==0.2.107
botocore==1.37.1
# homeassistant.components.bring
bring-api==1.1.0
bring-api==1.1.1
# homeassistant.components.broadlink
broadlink==0.19.0
@@ -1032,7 +1032,7 @@ hole==0.9.0
# homeassistant.components.holiday
# homeassistant.components.workday
holidays==0.82
holidays==0.83
# homeassistant.components.frontend
home-assistant-frontend==20251001.4
@@ -1405,7 +1405,7 @@ openhomedevice==2.2.0
openwebifpy==4.3.1
# homeassistant.components.opower
opower==0.15.7
opower==0.15.8
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1623,7 +1623,7 @@ pycsspeechtts==1.0.8
# pycups==2.0.4
# homeassistant.components.cync
pycync==0.4.1
pycync==0.4.2
# homeassistant.components.daikin
pydaikin==2.17.1
@@ -1647,7 +1647,7 @@ pydrawise==2025.9.0
pydroid-ipcam==3.0.0
# homeassistant.components.droplet
pydroplet==2.3.3
pydroplet==2.3.4
# homeassistant.components.ecoforest
pyecoforest==0.4.0
@@ -1914,7 +1914,7 @@ pypoint==3.0.0
pyportainer==1.0.3
# homeassistant.components.probe_plus
pyprobeplus==1.1.0
pyprobeplus==1.1.2
# homeassistant.components.profiler
pyprof2calltree==1.4.5
-7
View File
@@ -237,11 +237,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = {
# pyopnsense > pbr > setuptools
"pbr": {"setuptools"}
},
"opower": {
# https://github.com/arrow-py/arrow/issues/1169 (fixed not yet released)
# opower > arrow > types-python-dateutil
"arrow": {"types-python-dateutil"}
},
"pvpc_hourly_pricing": {"aiopvpc": {"async-timeout"}},
"remote_rpi_gpio": {
# https://github.com/waveform80/colorzero/issues/9
@@ -322,8 +317,6 @@ FORBIDDEN_PACKAGE_FILES_EXCEPTIONS = {
"lyric": {"homeassistant": {"aiolyric"}},
# https://github.com/microBeesTech/pythonSDK/
"microbees": {"homeassistant": {"microbeespy"}},
# https://github.com/tiagocoutinho/async_modbus
"nibe_heatpump": {"nibe": {"async-modbus"}},
# https://github.com/ejpenney/pyobihai
"obihai": {"homeassistant": {"pyobihai"}},
# https://github.com/iamkubi/pydactyl
@@ -25,6 +25,9 @@ async def test_form(
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
assert result["description_placeholders"] == {
"api_key_url": "https://docs.airnowapi.org/account/request/"
}
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], config)
assert result2["type"] is FlowResultType.CREATE_ENTRY
@@ -328,65 +328,64 @@
'firmware': '3.31',
'full-name': 'Airzone [1] System',
'id': 1,
'master-system-zone': '1:1',
'master-zone': 1,
'mode': 3,
'model': 'C6',
'modes': list([
'masters': list([
1,
4,
2,
3,
5,
]),
'masters-slaves': dict({
'1': list([
2,
3,
4,
5,
]),
}),
'model': 'C6',
'problems': False,
'q-adapt': 0,
'slaves': list([
2,
3,
4,
5,
]),
}),
'2': dict({
'available': True,
'full-name': 'Airzone [2] System',
'id': 2,
'master-system-zone': '2:1',
'master-zone': 1,
'mode': 7,
'modes': list([
7,
'masters': list([
1,
]),
'masters-slaves': dict({
'1': list([
]),
}),
'problems': False,
}),
'3': dict({
'available': True,
'full-name': 'Airzone [3] System',
'id': 3,
'master-system-zone': '3:1',
'master-zone': 1,
'mode': 7,
'modes': list([
4,
2,
3,
5,
7,
'masters': list([
1,
]),
'masters-slaves': dict({
'1': list([
]),
}),
'problems': False,
}),
'4': dict({
'available': True,
'full-name': 'Airzone [4] System',
'id': 4,
'master-system-zone': '4:1',
'master-zone': 1,
'mode': 6,
'modes': list([
'masters': list([
1,
2,
3,
4,
5,
6,
]),
'masters-slaves': dict({
'1': list([
]),
}),
'problems': False,
}),
}),
@@ -192,17 +192,11 @@ async def test_websocket_not_available(
await hass.async_block_till_done()
assert f"{error_msg} Trying to reconnect: Boom" in caplog.text
# Simulate a successful connection
caplog.clear()
await mock_called.wait()
mock_called.clear()
await hass.async_block_till_done()
assert mock.call_count == 2
assert "Trying to reconnect: Boom" not in caplog.text
# Simulate hass shutting down
await hass.async_stop()
assert mock.call_count == 2
assert mock.call_count == 1
async def test_device_info(
+40
View File
@@ -11,6 +11,10 @@ from homeassistant.components.climate import (
SERVICE_SET_TEMPERATURE,
HVACMode,
)
from homeassistant.components.huum.const import (
CONFIG_DEFAULT_MAX_TEMP,
CONFIG_DEFAULT_MIN_TEMP,
)
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@@ -76,3 +80,39 @@ async def test_set_temperature(
)
mock_huum.turn_on.assert_called_once_with(60)
async def test_temperature_range(
hass: HomeAssistant,
mock_huum: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the temperature range."""
await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE])
# API response.
state = hass.states.get(ENTITY_ID)
assert state.attributes["min_temp"] == 40
assert state.attributes["max_temp"] == 110
# Empty/unconfigured API response should return default values.
mock_huum.sauna_config.min_temp = 0
mock_huum.sauna_config.max_temp = 0
await mock_config_entry.runtime_data.async_refresh()
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.attributes["min_temp"] == CONFIG_DEFAULT_MIN_TEMP
assert state.attributes["max_temp"] == CONFIG_DEFAULT_MAX_TEMP
# Custom configured API response.
mock_huum.sauna_config.min_temp = 50
mock_huum.sauna_config.max_temp = 80
await mock_config_entry.runtime_data.async_refresh()
await hass.async_block_till_done()
state = hass.states.get(ENTITY_ID)
assert state.attributes["min_temp"] == 50
assert state.attributes["max_temp"] == 80
@@ -255,6 +255,96 @@ async def test_form_invalid_auth_cloud(
assert result["errors"] == {"base": error}
@pytest.mark.parametrize(
("side_effect", "description_placeholder", "server"),
[
(UnknownUserException, "CozyTouch", TEST_SERVER_COZYTOUCH),
(UnknownUserException, "Unknown", TEST_SERVER2),
],
)
async def test_form_invalid_hardware_cloud(
hass: HomeAssistant,
side_effect: Exception,
description_placeholder: str,
server: str,
) -> None:
"""Test we handle unsupported hardware (cloud)."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"hub": server},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud"
with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": TEST_EMAIL, "password": TEST_PASSWORD},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unsupported_hardware"}
assert result["description_placeholders"] == {
"unsupported_device": description_placeholder
}
@pytest.mark.parametrize(
("side_effect", "description_placeholder", "server"),
[
(UnknownUserException, "Somfy Protect", TEST_SERVER),
],
)
async def test_form_invalid_hardware_cloud_local(
hass: HomeAssistant,
side_effect: Exception,
description_placeholder: str,
server: str,
) -> None:
"""Test we handle unsupported hardware (cloud and local)."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"hub": server},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"api_type": "cloud"},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "cloud"
with patch("pyoverkiz.client.OverkizClient.login", side_effect=side_effect):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"username": TEST_EMAIL, "password": TEST_PASSWORD},
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "unsupported_hardware"}
assert result["description_placeholders"] == {
"unsupported_device": description_placeholder
}
@pytest.mark.parametrize(
("side_effect", "error"),
[
@@ -307,9 +307,6 @@
'sensor': dict({
'suggested_display_precision': 2,
}),
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfEnergy.KILO_WATT_HOUR: 'kWh'>,
}),
}),
'original_device_class': <SensorDeviceClass.ENERGY: 'energy'>,
'original_icon': None,
+2 -2
View File
@@ -1714,7 +1714,7 @@ async def test_rpc_shelly_ev_sensors(
}
config["number:201"] = {
"name": "Session energy",
"meta": {"ui": {"unit": "Wh", "view": "label"}},
"meta": {"ui": {"unit": "kWh", "view": "label"}},
"role": "energy_charge",
}
config["number:202"] = {
@@ -1726,7 +1726,7 @@ async def test_rpc_shelly_ev_sensors(
status = deepcopy(mock_rpc_device.status)
status["number:200"] = {"value": "charger_charging"}
status["number:201"] = {"value": 5000}
status["number:201"] = {"value": 5.0}
status["number:202"] = {"value": 60}
monkeypatch.setattr(mock_rpc_device, "status", status)
@@ -71,7 +71,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.energy_site_battery_discharged-entry]
@@ -146,7 +146,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.energy_site_battery_exported-entry]
@@ -1121,7 +1121,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.energy_site_grid_exported_from_battery-entry]
@@ -1881,7 +1881,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.energy_site_load_power-entry]
@@ -2178,7 +2178,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.energy_site_solar_power-entry]
+64
View File
@@ -9,6 +9,7 @@ from aiohttp.client_exceptions import ClientResponseError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from tesla_fleet_api.const import Scope, VehicleDataEndpoint
from tesla_fleet_api.exceptions import (
InvalidRegion,
InvalidToken,
@@ -36,6 +37,7 @@ from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr
from . import setup_platform
from .conftest import create_config_entry
from .const import VEHICLE_ASLEEP, VEHICLE_DATA_ALT
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -497,3 +499,65 @@ async def test_bad_implementation(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
assert not result["errors"]
async def test_vehicle_without_location_scope(
hass: HomeAssistant,
expires_at: int,
mock_vehicle_data: AsyncMock,
) -> None:
"""Test vehicle setup without VEHICLE_LOCATION scope excludes location endpoint."""
# Create config entry without VEHICLE_LOCATION scope
config_entry = create_config_entry(
expires_at,
[
Scope.OPENID,
Scope.OFFLINE_ACCESS,
Scope.VEHICLE_DEVICE_DATA,
# Deliberately exclude Scope.VEHICLE_LOCATION
],
)
await setup_platform(hass, config_entry)
assert config_entry.state is ConfigEntryState.LOADED
# Verify that vehicle_data was called without LOCATION_DATA endpoint
mock_vehicle_data.assert_called()
call_args = mock_vehicle_data.call_args
endpoints = call_args.kwargs.get("endpoints", [])
# Should not include LOCATION_DATA endpoint
assert VehicleDataEndpoint.LOCATION_DATA not in endpoints
# Should include other endpoints
assert VehicleDataEndpoint.CHARGE_STATE in endpoints
assert VehicleDataEndpoint.CLIMATE_STATE in endpoints
assert VehicleDataEndpoint.DRIVE_STATE in endpoints
assert VehicleDataEndpoint.VEHICLE_STATE in endpoints
assert VehicleDataEndpoint.VEHICLE_CONFIG in endpoints
async def test_vehicle_with_location_scope(
hass: HomeAssistant,
normal_config_entry: MockConfigEntry,
mock_vehicle_data: AsyncMock,
) -> None:
"""Test vehicle setup with VEHICLE_LOCATION scope includes location endpoint."""
await setup_platform(hass, normal_config_entry)
assert normal_config_entry.state is ConfigEntryState.LOADED
# Verify that vehicle_data was called with LOCATION_DATA endpoint
mock_vehicle_data.assert_called()
call_args = mock_vehicle_data.call_args
endpoints = call_args.kwargs.get("endpoints", [])
# Should include LOCATION_DATA endpoint when scope is present
assert VehicleDataEndpoint.LOCATION_DATA in endpoints
# Should include all other endpoints
assert VehicleDataEndpoint.CHARGE_STATE in endpoints
assert VehicleDataEndpoint.CLIMATE_STATE in endpoints
assert VehicleDataEndpoint.DRIVE_STATE in endpoints
assert VehicleDataEndpoint.VEHICLE_STATE in endpoints
assert VehicleDataEndpoint.VEHICLE_CONFIG in endpoints
+11 -2
View File
@@ -9,10 +9,11 @@ import pytest
from pythonkuma import MonitorStatus, UptimeKumaMonitor, UptimeKumaVersion
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.uptime_kuma.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import device_registry as dr, entity_registry as er
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@@ -53,6 +54,7 @@ async def test_migrate_unique_id(
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
freezer: FrozenDateTimeFactory,
device_registry: dr.DeviceRegistry,
) -> None:
"""Snapshot test states of sensor platform."""
mock_pythonkuma.metrics.return_value = {
@@ -87,7 +89,7 @@ async def test_migrate_unique_id(
)
}
mock_pythonkuma.version = UptimeKumaVersion(
version="2.0.0-beta.3", major="2", minor="0", patch="0-beta.3"
version="2.0.2", major="2", minor="0", patch="2"
)
freezer.tick(timedelta(seconds=30))
async_fire_time_changed(hass)
@@ -95,3 +97,10 @@ async def test_migrate_unique_id(
assert (entity := entity_registry.async_get("sensor.monitor_status"))
assert entity.unique_id == "123456789_1_status"
assert (
device := device_registry.async_get_device(
identifiers={(DOMAIN, f"{entity.config_entry_id}_1")}
)
)
assert device.sw_version == "2.0.2"
@@ -670,7 +670,7 @@
'display_status': 'off',
'friendly_name': 'SmartTowerFan',
'mode': 'normal',
'percentage': None,
'percentage': 0,
'percentage_step': 8.333333333333334,
'preset_mode': 'normal',
'preset_modes': list([