mirror of
https://github.com/home-assistant/core.git
synced 2026-05-29 20:23:24 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d2f0793d7 | |||
| 14fcb6c2d6 | |||
| 5763829b4b | |||
| 7dfec6ef3d | |||
| efe55f247a | |||
| 85f3141776 | |||
| a175c7c4be | |||
| 03c83091ab | |||
| accebd7f38 | |||
| 9d3bb346e9 | |||
| d13721980e | |||
| ac6b5a5850 | |||
| 16dfa99673 | |||
| f51a02bbda | |||
| 6a51b21242 | |||
| 5eb502851c | |||
| ef20418c76 | |||
| 94ca34fd0c | |||
| 8634c22a53 | |||
| 5681ba40f1 | |||
| 8a9a1c5fed | |||
| c587e101af | |||
| 6eeeac46f3 | |||
| 86542b8ad0 | |||
| 7e07e7062c | |||
| d7c13fee27 | |||
| a0a44f7a25 |
@@ -344,13 +344,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -380,7 +380,7 @@ jobs:
|
||||
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
|
||||
- name: Generate Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ matrix.registry }}/home-assistant
|
||||
sep-tags: ","
|
||||
@@ -394,7 +394,7 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v3.7.1
|
||||
|
||||
- name: Copy architecture images to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
@@ -523,7 +523,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.6.4",
|
||||
"bluetooth-data-tools==1.29.18",
|
||||
"dbus-fast==5.0.16",
|
||||
"habluetooth==6.7.9"
|
||||
"habluetooth==6.8.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Sony Bravia TV",
|
||||
"codeowners": ["@bieniu", "@Drafteed"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/braviatv",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"light": {
|
||||
"hue_grouped_light": {
|
||||
"default": "mdi:lightbulb-group",
|
||||
"state": {
|
||||
"off": "mdi:lightbulb-group-off"
|
||||
}
|
||||
},
|
||||
"hue_light": {
|
||||
"state_attributes": {
|
||||
"effect": {
|
||||
|
||||
@@ -85,7 +85,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity):
|
||||
|
||||
entity_description = LightEntityDescription(
|
||||
key="hue_grouped_light",
|
||||
icon="mdi:lightbulb-group",
|
||||
has_entity_name=True,
|
||||
name=None,
|
||||
)
|
||||
|
||||
@@ -67,7 +67,7 @@ async def _async_upload_file(service_call: ServiceCall) -> None:
|
||||
await coordinator.api.albums.async_add_assets_to_album(
|
||||
target_album, [upload_result.asset_id]
|
||||
)
|
||||
except ImmichError as ex:
|
||||
except (ImmichError, FileNotFoundError) as ex:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="upload_failed",
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_DISCOVERY,
|
||||
CONF_PLATFORM,
|
||||
CONF_PORT,
|
||||
CONF_PROTOCOL,
|
||||
SERVICE_RELOAD,
|
||||
)
|
||||
@@ -50,6 +51,7 @@ from .client import (
|
||||
async_subscribe_internal,
|
||||
publish,
|
||||
subscribe,
|
||||
try_connection,
|
||||
)
|
||||
from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA
|
||||
from .config_integration import CONFIG_SCHEMA_BASE
|
||||
@@ -79,14 +81,15 @@ from .const import (
|
||||
CONFIG_ENTRY_VERSION,
|
||||
DEFAULT_DISCOVERY,
|
||||
DEFAULT_ENCODING,
|
||||
DEFAULT_PORT,
|
||||
DEFAULT_PREFIX,
|
||||
DEFAULT_PROTOCOL,
|
||||
DEFAULT_QOS,
|
||||
DEFAULT_RETAIN,
|
||||
DOMAIN,
|
||||
ENTITY_PLATFORMS,
|
||||
ENTRY_OPTION_FIELDS,
|
||||
MQTT_CONNECTION_STATE,
|
||||
PROTOCOL_5,
|
||||
PROTOCOL_311,
|
||||
TEMPLATE_ERRORS,
|
||||
Platform,
|
||||
@@ -496,25 +499,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Load a config entry."""
|
||||
mqtt_data: MqttData
|
||||
|
||||
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != DEFAULT_PROTOCOL:
|
||||
broker: str = entry.data[CONF_BROKER]
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"protocol_5_migration",
|
||||
issue_domain=DOMAIN,
|
||||
is_fixable=True,
|
||||
breaks_in_ha_version="2027.1.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/mqtt/#mqtt-protocol",
|
||||
data={
|
||||
"entry_id": entry.entry_id,
|
||||
"broker": broker,
|
||||
"protocol": protocol,
|
||||
},
|
||||
translation_placeholders={"broker": broker, "protocol": protocol},
|
||||
translation_key="protocol_5_migration",
|
||||
)
|
||||
if (protocol := entry.data.get(CONF_PROTOCOL, PROTOCOL_311)) != PROTOCOL_5:
|
||||
# Automatically migrate the broker protocol to v5 if possible
|
||||
# Can be removed with HA Core 2027.1
|
||||
new_entry_data = entry.data.copy()
|
||||
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
|
||||
# Try the connection with protocol version 5
|
||||
# And update the protocol if successful
|
||||
if await hass.async_add_executor_job(
|
||||
try_connection,
|
||||
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
|
||||
):
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=new_entry_data,
|
||||
)
|
||||
ir.async_delete_issue(hass, DOMAIN, "protocol_5_migration")
|
||||
_LOGGER.info(
|
||||
"The MQTT protocol version was successfully updated to version 5"
|
||||
)
|
||||
else:
|
||||
broker: str = entry.data[CONF_BROKER]
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"protocol_5_migration",
|
||||
issue_domain=DOMAIN,
|
||||
is_fixable=False,
|
||||
breaks_in_ha_version="2027.1.0",
|
||||
severity=IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
|
||||
"#mqtt-protocol",
|
||||
data={
|
||||
"entry_id": entry.entry_id,
|
||||
"broker": broker,
|
||||
"protocol": protocol,
|
||||
},
|
||||
translation_placeholders={"broker": broker, "protocol": protocol},
|
||||
translation_key="protocol_5_migration",
|
||||
)
|
||||
|
||||
async def _setup_client() -> tuple[MqttData, dict[str, Any]]:
|
||||
"""Set up the MQTT client."""
|
||||
|
||||
@@ -9,6 +9,7 @@ from functools import lru_cache, partial
|
||||
from itertools import chain, groupby
|
||||
import logging
|
||||
from operator import attrgetter
|
||||
import queue
|
||||
import socket
|
||||
import ssl
|
||||
import time
|
||||
@@ -92,6 +93,8 @@ from .util import EnsureJobAfterCooldown, get_file_path, mqtt_config_entry_enabl
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MQTT_TIMEOUT = 5
|
||||
|
||||
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
|
||||
PREFERRED_BUFFER_SIZE = 8 * 1024 * 1024 # Set receive buffer size to 8MiB
|
||||
|
||||
@@ -433,6 +436,40 @@ class MqttClientSetup:
|
||||
return self._client
|
||||
|
||||
|
||||
def try_connection(
|
||||
user_input: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
mqtt_client_setup = MqttClientSetup(user_input)
|
||||
mqtt_client_setup.setup()
|
||||
client = mqtt_client_setup.client
|
||||
|
||||
result: queue.Queue[bool] = queue.Queue(maxsize=1)
|
||||
|
||||
def on_connect(
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_connect_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Handle connection result."""
|
||||
result.put(not reason_code.is_failure)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
return result.get(timeout=MQTT_TIMEOUT)
|
||||
except queue.Empty:
|
||||
return False
|
||||
finally:
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
class MQTT:
|
||||
"""Home Assistant MQTT client."""
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
@@ -22,7 +21,6 @@ from cryptography.hazmat.primitives.serialization import (
|
||||
load_pem_private_key,
|
||||
)
|
||||
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
|
||||
import paho.mqtt.client as mqtt
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
@@ -143,7 +141,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from .addon import get_addon_manager
|
||||
from .client import MqttClientSetup
|
||||
from .client import try_connection
|
||||
from .const import (
|
||||
ALARM_CONTROL_PANEL_SUPPORTED_FEATURES,
|
||||
ATTR_PAYLOAD,
|
||||
@@ -444,8 +442,6 @@ ADDON_SETUP_TIMEOUT_ROUNDS = 5
|
||||
|
||||
CONF_CLIENT_KEY_PASSWORD = "client_key_password"
|
||||
|
||||
MQTT_TIMEOUT = 5
|
||||
|
||||
ADVANCED_OPTIONS = "advanced_options"
|
||||
SET_CA_CERT = "set_ca_cert"
|
||||
SET_CLIENT_CERT = "set_client_cert"
|
||||
@@ -5581,40 +5577,6 @@ async def async_get_broker_settings(
|
||||
return False
|
||||
|
||||
|
||||
def try_connection(
|
||||
user_input: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Test if we can connect to an MQTT broker."""
|
||||
mqtt_client_setup = MqttClientSetup(user_input)
|
||||
mqtt_client_setup.setup()
|
||||
client = mqtt_client_setup.client
|
||||
|
||||
result: queue.Queue[bool] = queue.Queue(maxsize=1)
|
||||
|
||||
def on_connect(
|
||||
_mqttc: mqtt.Client,
|
||||
_userdata: None,
|
||||
_connect_flags: mqtt.ConnectFlags,
|
||||
reason_code: mqtt.ReasonCode,
|
||||
_properties: mqtt.Properties | None = None,
|
||||
) -> None:
|
||||
"""Handle connection result."""
|
||||
result.put(not reason_code.is_failure)
|
||||
|
||||
client.on_connect = on_connect
|
||||
|
||||
client.connect_async(user_input[CONF_BROKER], user_input[CONF_PORT])
|
||||
client.loop_start()
|
||||
|
||||
try:
|
||||
return result.get(timeout=MQTT_TIMEOUT)
|
||||
except queue.Empty:
|
||||
return False
|
||||
finally:
|
||||
client.disconnect()
|
||||
client.loop_stop()
|
||||
|
||||
|
||||
def check_certicate_chain() -> str | None:
|
||||
"""Check the MQTT certificates."""
|
||||
if client_certificate := get_file_path(CONF_CLIENT_CERT):
|
||||
|
||||
@@ -5,12 +5,10 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.repairs import RepairsFlow, RepairsFlowResult
|
||||
from homeassistant.const import CONF_PORT, CONF_PROTOCOL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .config_flow import try_connection
|
||||
from .const import DEFAULT_PORT, DOMAIN, PROTOCOL_5
|
||||
from .const import DOMAIN
|
||||
|
||||
URL_MQTT_BROKER_CONFIGURATION = (
|
||||
"https://www.home-assistant.io/integrations/mqtt/#broker-configuration"
|
||||
@@ -55,55 +53,6 @@ class MQTTDeviceEntryMigration(RepairsFlow):
|
||||
)
|
||||
|
||||
|
||||
class MQTTProtocolV5Migration(RepairsFlow):
|
||||
"""Handler to migrate to MQTT protocol version 5."""
|
||||
|
||||
def __init__(self, entry_id: str, broker: str, protocol: str) -> None:
|
||||
"""Initialize the flow."""
|
||||
self.entry_id = entry_id
|
||||
self.broker = broker
|
||||
self.protocol = protocol
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> RepairsFlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
entry = self.hass.config_entries.async_get_entry(self.entry_id)
|
||||
if TYPE_CHECKING:
|
||||
assert entry is not None
|
||||
new_entry_data = entry.data.copy()
|
||||
new_entry_data[CONF_PROTOCOL] = PROTOCOL_5
|
||||
# Try the connection with protocol version 5
|
||||
if await self.hass.async_add_executor_job(
|
||||
try_connection,
|
||||
{CONF_PORT: DEFAULT_PORT} | new_entry_data,
|
||||
):
|
||||
self.hass.config_entries.async_update_entry(entry, data=new_entry_data)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
return self.async_abort(
|
||||
reason="mqtt_broker_migration_to_v5_failed",
|
||||
description_placeholders={
|
||||
"broker": self.broker,
|
||||
"protocol": self.protocol,
|
||||
"url_mqtt_broker_configuration": URL_MQTT_BROKER_CONFIGURATION,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders={"broker": self.broker, "protocol": self.protocol},
|
||||
)
|
||||
|
||||
|
||||
async def async_create_fix_flow(
|
||||
hass: HomeAssistant,
|
||||
issue_id: str,
|
||||
@@ -113,10 +62,6 @@ async def async_create_fix_flow(
|
||||
if TYPE_CHECKING:
|
||||
assert data is not None
|
||||
entry_id: str = data["entry_id"] # type: ignore[assignment]
|
||||
if issue_id == "protocol_5_migration":
|
||||
broker: str = data["broker"] # type: ignore[assignment]
|
||||
protocol: str = data["protocol"] # type: ignore[assignment]
|
||||
return MQTTProtocolV5Migration(entry_id, broker, protocol)
|
||||
subentry_id: str = data["subentry_id"] # type: ignore[assignment]
|
||||
name: str = data["name"] # type: ignore[assignment]
|
||||
return MQTTDeviceEntryMigration(
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"keepalive": "A value less than 90 seconds is advised.",
|
||||
"password": "The password to log in to your MQTT broker.",
|
||||
"port": "The port your MQTT broker listens to. For example 1883.",
|
||||
"protocol": "The MQTT protocol your broker operates at. For example 3.1.1.",
|
||||
"protocol": "The MQTT protocol version that is used. Note that Home Assistant will silently change to version 5 if your broker supports it.",
|
||||
"set_ca_cert": "Select **Auto** for automatic CA validation, or **Custom** and select **Next** to set a custom CA certificate, to allow validating your MQTT broker's certificate.",
|
||||
"set_client_cert": "Enable and select **Next** to set a client certificate and private key to authenticate against your MQTT broker.",
|
||||
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
|
||||
@@ -1134,18 +1134,8 @@
|
||||
"title": "Invalid config found for MQTT {domain} item"
|
||||
},
|
||||
"protocol_5_migration": {
|
||||
"fix_flow": {
|
||||
"abort": {
|
||||
"mqtt_broker_migration_to_v5_failed": "Migrating the broker ({broker}) protocol version from {protocol} to 5 failed, and the migration has been aborted.\n\nYour broker may not support MQTT protocol version 5.\n\nPlease [reconfigure your MQTT broker settings]({url_mqtt_broker_configuration}) or upgrade your broker to support MQTT protocol version 5 to fix this issue."
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "Home Assistant needs to migrate to MQTT protocol version 5. The currently configured protocol version for broker {broker} is {protocol}. This protocol version is deprecated, and support for it will be removed.\n\nSubmitting this form will attempt to migrate your MQTT broker configuration to use protocol version 5 to fix this issue. If the broker cannot be reached using MQTT protocol version 5, for example because it does not support it, the migration will be aborted.",
|
||||
"title": "MQTT protocol change required"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Deprecated MQTT protocol {protocol} in use"
|
||||
"description": "The automatic migration to MQTT protocol version 5 failed. The currently configured protocol version for MQTT broker {broker} is {protocol}, but this protocol version is deprecated, and support for it will be removed.\n\nMake sure your broker supports protocol version 5. Update your MQTT broker's connection settings, and restart Home Assistant to fix this issue.",
|
||||
"title": "MQTT protocol migration failed"
|
||||
},
|
||||
"subentry_migration_discovery": {
|
||||
"fix_flow": {
|
||||
|
||||
+1
@@ -57,6 +57,7 @@ OVERKIZ_TO_HVAC_MODE: dict[str, HVACMode] = {
|
||||
OverkizCommandParam.STANDBY: HVACMode.OFF, # main command
|
||||
OverkizCommandParam.AUTO: HVACMode.AUTO,
|
||||
OverkizCommandParam.EXTERNAL: HVACMode.AUTO,
|
||||
OverkizCommandParam.PROG: HVACMode.AUTO,
|
||||
OverkizCommandParam.INTERNAL: HVACMode.AUTO, # main command
|
||||
}
|
||||
|
||||
|
||||
@@ -73,20 +73,26 @@ async def _get_endpoint_id(
|
||||
device_reg = dr.async_get(call.hass)
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
device = device_reg.async_get(device_id)
|
||||
assert device
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
endpoint_data = None
|
||||
for data in coordinator.data.values():
|
||||
if (
|
||||
DOMAIN,
|
||||
f"{config_entry.entry_id}_{data.endpoint.id}",
|
||||
) in device.identifiers:
|
||||
endpoint_data = data
|
||||
break
|
||||
return data.endpoint.id
|
||||
|
||||
assert endpoint_data
|
||||
return endpoint_data.endpoint.id
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_target",
|
||||
)
|
||||
|
||||
|
||||
async def _get_container_and_endpoint_ids(
|
||||
@@ -95,6 +101,7 @@ async def _get_container_and_endpoint_ids(
|
||||
"""Get config entry, endpoint ID and container ID from the container device ID."""
|
||||
device_reg = dr.async_get(call.hass)
|
||||
device = device_reg.async_get(call.data[ATTR_CONTAINER_DEVICE_ID])
|
||||
|
||||
if device is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_LOCALE, DOMAIN, PLATFORMS
|
||||
from .const import DOMAIN, PLATFORMS, RenaultConfigurationKeys
|
||||
from .renault_hub import RenaultHub
|
||||
from .services import async_setup_services
|
||||
|
||||
@@ -28,7 +28,7 @@ async def async_setup_entry(
|
||||
hass: HomeAssistant, config_entry: RenaultConfigEntry
|
||||
) -> bool:
|
||||
"""Load a config entry."""
|
||||
renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE])
|
||||
renault_hub = RenaultHub(hass, config_entry.data[RenaultConfigurationKeys.LOCALE])
|
||||
try:
|
||||
await renault_hub.async_initialise(config_entry)
|
||||
except NotAuthenticatedException as exc:
|
||||
|
||||
@@ -14,21 +14,22 @@ from homeassistant.config_entries import (
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, CONF_LOGIN_TOKEN, DOMAIN
|
||||
from .const import DOMAIN, RenaultConfigurationKeys
|
||||
from .renault_hub import RenaultHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Required(RenaultConfigurationKeys.LOCALE.value): vol.In(
|
||||
AVAILABLE_LOCALES.keys()
|
||||
),
|
||||
vol.Required(RenaultConfigurationKeys.USERNAME.value): str,
|
||||
vol.Required(RenaultConfigurationKeys.PASSWORD.value): str,
|
||||
}
|
||||
)
|
||||
REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
|
||||
REAUTH_SCHEMA = vol.Schema({vol.Required(RenaultConfigurationKeys.PASSWORD.value): str})
|
||||
|
||||
|
||||
class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -50,13 +51,14 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
suggested_values: Mapping[str, Any] | None = None
|
||||
if user_input:
|
||||
locale = user_input[CONF_LOCALE]
|
||||
locale = user_input[RenaultConfigurationKeys.LOCALE]
|
||||
self.renault_config.update(user_input)
|
||||
self.renault_config.update(AVAILABLE_LOCALES[locale])
|
||||
self.renault_hub = RenaultHub(self.hass, locale)
|
||||
try:
|
||||
login_success = await self.renault_hub.attempt_login(
|
||||
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
user_input[RenaultConfigurationKeys.USERNAME],
|
||||
user_input[RenaultConfigurationKeys.PASSWORD],
|
||||
)
|
||||
except aiohttp.ClientConnectionError, GigyaException:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -67,7 +69,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if login_success:
|
||||
if TYPE_CHECKING:
|
||||
assert self.renault_hub.login_token
|
||||
self.renault_config[CONF_LOGIN_TOKEN] = self.renault_hub.login_token
|
||||
self.renault_config[RenaultConfigurationKeys.LOGIN_TOKEN] = (
|
||||
self.renault_hub.login_token
|
||||
)
|
||||
return await self.async_step_kamereon()
|
||||
errors["base"] = "invalid_credentials"
|
||||
suggested_values = user_input
|
||||
@@ -87,7 +91,9 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Select Kamereon account."""
|
||||
if user_input:
|
||||
await self.async_set_unique_id(user_input[CONF_KAMEREON_ACCOUNT_ID])
|
||||
await self.async_set_unique_id(
|
||||
user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
|
||||
)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_mismatch()
|
||||
self.renault_config.update(user_input)
|
||||
@@ -100,7 +106,8 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self.renault_config.update(user_input)
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_KAMEREON_ACCOUNT_ID], data=self.renault_config
|
||||
title=user_input[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID],
|
||||
data=self.renault_config,
|
||||
)
|
||||
|
||||
accounts = await self.renault_hub.get_account_ids()
|
||||
@@ -108,13 +115,17 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_abort(reason="kamereon_no_account")
|
||||
if len(accounts) == 1:
|
||||
return await self.async_step_kamereon(
|
||||
user_input={CONF_KAMEREON_ACCOUNT_ID: accounts[0]}
|
||||
user_input={RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: accounts[0]}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="kamereon",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_KAMEREON_ACCOUNT_ID): vol.In(accounts)}
|
||||
{
|
||||
vol.Required(
|
||||
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID.value
|
||||
): vol.In(accounts)
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -132,17 +143,22 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input:
|
||||
# Check credentials
|
||||
self.renault_hub = RenaultHub(self.hass, reauth_entry.data[CONF_LOCALE])
|
||||
self.renault_hub = RenaultHub(
|
||||
self.hass, reauth_entry.data[RenaultConfigurationKeys.LOCALE]
|
||||
)
|
||||
if await self.renault_hub.attempt_login(
|
||||
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
|
||||
reauth_entry.data[RenaultConfigurationKeys.USERNAME],
|
||||
user_input[RenaultConfigurationKeys.PASSWORD],
|
||||
):
|
||||
if TYPE_CHECKING:
|
||||
assert self.renault_hub.login_token
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_LOGIN_TOKEN: self.renault_hub.login_token,
|
||||
RenaultConfigurationKeys.PASSWORD: user_input[
|
||||
RenaultConfigurationKeys.PASSWORD
|
||||
],
|
||||
RenaultConfigurationKeys.LOGIN_TOKEN: self.renault_hub.login_token,
|
||||
},
|
||||
)
|
||||
errors = {"base": "invalid_credentials"}
|
||||
@@ -151,7 +167,11 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
step_id="reauth_confirm",
|
||||
data_schema=REAUTH_SCHEMA,
|
||||
errors=errors,
|
||||
description_placeholders={CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
|
||||
description_placeholders={
|
||||
RenaultConfigurationKeys.USERNAME: reauth_entry.data[
|
||||
RenaultConfigurationKeys.USERNAME
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"""Constants for the Renault component."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "renault"
|
||||
|
||||
CONF_LOCALE = "locale"
|
||||
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
|
||||
CONF_LOGIN_TOKEN = "login_token"
|
||||
|
||||
class RenaultConfigurationKeys(StrEnum):
|
||||
"""Configuration keys."""
|
||||
|
||||
LOCALE = "locale"
|
||||
KAMEREON_ACCOUNT_ID = "kamereon_account_id"
|
||||
LOGIN_TOKEN = "login_token"
|
||||
USERNAME = "username"
|
||||
PASSWORD = "password"
|
||||
|
||||
|
||||
# normal number of allowed calls per hour to the API
|
||||
# for a single car and the 7 coordinator, it is a scan every 7mn
|
||||
|
||||
@@ -3,19 +3,18 @@
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from . import RenaultConfigEntry
|
||||
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOGIN_TOKEN
|
||||
from .const import RenaultConfigurationKeys
|
||||
from .renault_vehicle import RenaultVehicleProxy
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOGIN_TOKEN,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID,
|
||||
RenaultConfigurationKeys.LOGIN_TOKEN,
|
||||
RenaultConfigurationKeys.PASSWORD,
|
||||
RenaultConfigurationKeys.USERNAME,
|
||||
"radioCode",
|
||||
"registrationNumber",
|
||||
"vin",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from time import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from renault_api.exceptions import NotAuthenticatedException
|
||||
@@ -17,27 +18,22 @@ from homeassistant.const import (
|
||||
ATTR_MODEL,
|
||||
ATTR_MODEL_ID,
|
||||
ATTR_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import RenaultConfigEntry
|
||||
|
||||
from time import time
|
||||
|
||||
from .const import (
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOGIN_TOKEN,
|
||||
COOLING_UPDATES_SECONDS,
|
||||
MAX_CALLS_PER_HOURS,
|
||||
RenaultConfigurationKeys,
|
||||
)
|
||||
from .renault_vehicle import COORDINATORS, RenaultVehicleProxy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import RenaultConfigEntry
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -106,20 +102,26 @@ class RenaultHub:
|
||||
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
|
||||
"""Set up proxy."""
|
||||
# Reuse the stored login token, or fall back to a password login.
|
||||
if login_token := config_entry.data.get(CONF_LOGIN_TOKEN):
|
||||
if login_token := config_entry.data.get(RenaultConfigurationKeys.LOGIN_TOKEN):
|
||||
self._client.session.set_login_token(login_token)
|
||||
elif await self.attempt_login(
|
||||
config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD]
|
||||
config_entry.data[RenaultConfigurationKeys.USERNAME],
|
||||
config_entry.data[RenaultConfigurationKeys.PASSWORD],
|
||||
):
|
||||
# Persist the login token so the next setup can skip the password.
|
||||
self._hass.config_entries.async_update_entry(
|
||||
config_entry,
|
||||
data={**config_entry.data, CONF_LOGIN_TOKEN: self.login_token},
|
||||
data={
|
||||
**config_entry.data,
|
||||
RenaultConfigurationKeys.LOGIN_TOKEN: self.login_token,
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise NotAuthenticatedException
|
||||
|
||||
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
|
||||
account_id: str = config_entry.data[
|
||||
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID
|
||||
]
|
||||
|
||||
self._account = await self._client.get_api_account(account_id)
|
||||
vehicle_links = await _get_filtered_vehicles(self._account)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Support for Renault services."""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
@@ -19,24 +19,30 @@ if TYPE_CHECKING:
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_SCHEDULES = "schedules"
|
||||
ATTR_VEHICLE = "vehicle"
|
||||
ATTR_WHEN = "when"
|
||||
|
||||
class RenaultServiceArgument(StrEnum):
|
||||
"""Service argument names."""
|
||||
|
||||
SCHEDULES = "schedules"
|
||||
TEMPERATURE = "temperature"
|
||||
VEHICLE = "vehicle"
|
||||
WHEN = "when"
|
||||
|
||||
|
||||
SERVICE_VEHICLE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_VEHICLE): cv.string,
|
||||
vol.Required(RenaultServiceArgument.VEHICLE.value): cv.string,
|
||||
}
|
||||
)
|
||||
SERVICE_AC_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_TEMPERATURE): cv.positive_float,
|
||||
vol.Optional(ATTR_WHEN): cv.datetime,
|
||||
vol.Required(RenaultServiceArgument.TEMPERATURE.value): cv.positive_float,
|
||||
vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime,
|
||||
}
|
||||
)
|
||||
SERVICE_CHARGE_START_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Optional(ATTR_WHEN): cv.datetime,
|
||||
vol.Optional(RenaultServiceArgument.WHEN.value): cv.datetime,
|
||||
}
|
||||
)
|
||||
SERVICE_CHARGE_SET_SCHEDULE_DAY_SCHEMA = vol.Schema(
|
||||
@@ -62,7 +68,7 @@ SERVICE_CHARGE_SET_SCHEDULE_SCHEMA = vol.Schema(
|
||||
)
|
||||
SERVICE_CHARGE_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_SCHEDULES): vol.All(
|
||||
vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All(
|
||||
cv.ensure_list, [SERVICE_CHARGE_SET_SCHEDULE_SCHEMA]
|
||||
),
|
||||
}
|
||||
@@ -89,7 +95,7 @@ SERVICE_AC_SET_SCHEDULE_SCHEMA = vol.Schema(
|
||||
)
|
||||
SERVICE_AC_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(ATTR_SCHEDULES): vol.All(
|
||||
vol.Required(RenaultServiceArgument.SCHEDULES.value): vol.All(
|
||||
cv.ensure_list, [SERVICE_AC_SET_SCHEDULE_SCHEMA]
|
||||
),
|
||||
}
|
||||
@@ -107,8 +113,8 @@ async def ac_cancel(service_call: ServiceCall) -> None:
|
||||
|
||||
async def ac_start(service_call: ServiceCall) -> None:
|
||||
"""Start A/C."""
|
||||
temperature: float = service_call.data[ATTR_TEMPERATURE]
|
||||
when: datetime | None = service_call.data.get(ATTR_WHEN)
|
||||
temperature: float = service_call.data[RenaultServiceArgument.TEMPERATURE]
|
||||
when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN)
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
|
||||
LOGGER.debug("A/C start attempt: %s / %s", temperature, when)
|
||||
@@ -118,7 +124,7 @@ async def ac_start(service_call: ServiceCall) -> None:
|
||||
|
||||
async def charge_start(service_call: ServiceCall) -> None:
|
||||
"""Start Charging with optional delay."""
|
||||
when: datetime | None = service_call.data.get(ATTR_WHEN)
|
||||
when: datetime | None = service_call.data.get(RenaultServiceArgument.WHEN)
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
|
||||
LOGGER.debug("Charge start attempt, when: %s", when)
|
||||
@@ -128,7 +134,9 @@ async def charge_start(service_call: ServiceCall) -> None:
|
||||
|
||||
async def charge_set_schedules(service_call: ServiceCall) -> None:
|
||||
"""Set charge schedules."""
|
||||
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
|
||||
schedules: list[dict[str, Any]] = service_call.data[
|
||||
RenaultServiceArgument.SCHEDULES
|
||||
]
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
charge_schedules = await proxy.get_charging_settings()
|
||||
for schedule in schedules:
|
||||
@@ -147,7 +155,9 @@ async def charge_set_schedules(service_call: ServiceCall) -> None:
|
||||
|
||||
async def ac_set_schedules(service_call: ServiceCall) -> None:
|
||||
"""Set A/C schedules."""
|
||||
schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
|
||||
schedules: list[dict[str, Any]] = service_call.data[
|
||||
RenaultServiceArgument.SCHEDULES
|
||||
]
|
||||
proxy = get_vehicle_proxy(service_call)
|
||||
hvac_schedules = await proxy.get_hvac_settings()
|
||||
|
||||
@@ -168,7 +178,7 @@ async def ac_set_schedules(service_call: ServiceCall) -> None:
|
||||
def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
|
||||
"""Get vehicle from service_call data."""
|
||||
device_registry = dr.async_get(service_call.hass)
|
||||
device_id = service_call.data[ATTR_VEHICLE]
|
||||
device_id = service_call.data[RenaultServiceArgument.VEHICLE]
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError(
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from roborock import (
|
||||
RoborockException,
|
||||
RoborockInvalidCredentials,
|
||||
@@ -120,6 +121,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="home_data_fail",
|
||||
) from err
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
_LOGGER.debug("Network error setting up Roborock: %s", err)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="network_error",
|
||||
) from err
|
||||
|
||||
async def shutdown_roborock(_: Event | None = None) -> None:
|
||||
await asyncio.gather(device_manager.close(), cache.flush())
|
||||
|
||||
@@ -677,6 +677,9 @@
|
||||
"mqtt_unauthorized": {
|
||||
"message": "Roborock MQTT servers rejected the connection due to rate limiting or invalid credentials. You may either attempt to reauthenticate or wait and reload the integration."
|
||||
},
|
||||
"network_error": {
|
||||
"message": "Network error connecting to Roborock servers. Check your internet connection and the Roborock service status."
|
||||
},
|
||||
"no_coordinators": {
|
||||
"message": "No devices were able to successfully setup"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,11 @@ class IRobotEntity(Entity):
|
||||
model=self.vacuum_state.get("sku"),
|
||||
name=str(self.vacuum_state.get("name")),
|
||||
sw_version=self.vacuum_state.get("softwareVer"),
|
||||
hw_version=self.vacuum_state.get("hardwareRev"),
|
||||
hw_version=(
|
||||
str(hw_rev)
|
||||
if (hw_rev := self.vacuum_state.get("hardwareRev")) is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
if mac_address := self.vacuum_state.get("hwPartsRev", {}).get(
|
||||
|
||||
@@ -132,6 +132,9 @@ def async_manage_outbound_websocket_incorrectly_enabled_issue(
|
||||
|
||||
device = entry.runtime_data.rpc.device
|
||||
|
||||
if not device.initialized:
|
||||
return
|
||||
|
||||
if (
|
||||
(ws_config := device.config.get("ws"))
|
||||
and ws_config["enable"]
|
||||
@@ -169,6 +172,9 @@ def async_manage_open_wifi_ap_issue(
|
||||
|
||||
device = entry.runtime_data.rpc.device
|
||||
|
||||
if not device.initialized:
|
||||
return
|
||||
|
||||
# Check if WiFi AP is enabled and is open (no password)
|
||||
if (
|
||||
(wifi_config := device.config.get("wifi"))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from homeassistant.components.device_tracker import TrackerEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -83,11 +82,3 @@ class TeslaFleetDeviceTrackerRouteEntity(TeslaFleetDeviceTrackerEntity):
|
||||
self.get("drive_state_active_route_longitude", False) is None
|
||||
or self.get("drive_state_active_route_latitude", False) is None
|
||||
)
|
||||
|
||||
@property
|
||||
def location_name(self) -> str | None:
|
||||
"""Return a location name for the current location of the device."""
|
||||
location = self.get("drive_state_active_route_destination")
|
||||
if location == "Home":
|
||||
return STATE_HOME
|
||||
return location
|
||||
|
||||
@@ -280,6 +280,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSensorEntityDescription, ...] = (
|
||||
native_unit_of_measurement=UnitOfLength.MILES,
|
||||
device_class=SensorDeviceClass.DISTANCE,
|
||||
),
|
||||
TeslaFleetSensorEntityDescription(
|
||||
key="drive_state_active_route_destination",
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.components.device_tracker import (
|
||||
TrackerEntity,
|
||||
TrackerEntityDescription,
|
||||
)
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
@@ -31,12 +30,6 @@ class TeslemetryDeviceTrackerEntityDescription(TrackerEntityDescription):
|
||||
[TeslemetryStreamVehicle, Callable[[TeslaLocation | None], None]],
|
||||
Callable[[], None],
|
||||
]
|
||||
name_listener: (
|
||||
Callable[
|
||||
[TeslemetryStreamVehicle, Callable[[str | None], None]], Callable[[], None]
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
streaming_firmware: str
|
||||
polling_prefix: str | None = None
|
||||
|
||||
@@ -54,9 +47,6 @@ DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = (
|
||||
value_listener=lambda vehicle, callback: vehicle.listen_DestinationLocation(
|
||||
callback
|
||||
),
|
||||
name_listener=lambda vehicle, callback: vehicle.listen_DestinationName(
|
||||
callback
|
||||
),
|
||||
streaming_firmware="2024.26",
|
||||
),
|
||||
TeslemetryDeviceTrackerEntityDescription(
|
||||
@@ -126,11 +116,6 @@ class TeslemetryVehiclePollingDeviceTrackerEntity(
|
||||
self._attr_longitude = self.get(
|
||||
f"{self.entity_description.polling_prefix}_longitude"
|
||||
)
|
||||
self._attr_location_name = self.get(
|
||||
f"{self.entity_description.polling_prefix}_destination"
|
||||
)
|
||||
if self._attr_location_name == "Home":
|
||||
self._attr_location_name = STATE_HOME
|
||||
self._attr_available = (
|
||||
self._attr_latitude is not None and self._attr_longitude is not None
|
||||
)
|
||||
@@ -158,28 +143,14 @@ class TeslemetryStreamingDeviceTrackerEntity(
|
||||
if (state := await self.async_get_last_state()) is not None:
|
||||
self._attr_latitude = state.attributes.get("latitude")
|
||||
self._attr_longitude = state.attributes.get("longitude")
|
||||
self._attr_location_name = state.attributes.get("location_name")
|
||||
self.async_on_remove(
|
||||
self.entity_description.value_listener(
|
||||
self.vehicle.stream_vehicle, self._location_callback
|
||||
)
|
||||
)
|
||||
if self.entity_description.name_listener:
|
||||
self.async_on_remove(
|
||||
self.entity_description.name_listener(
|
||||
self.vehicle.stream_vehicle, self._name_callback
|
||||
)
|
||||
)
|
||||
|
||||
def _location_callback(self, location: TeslaLocation | None) -> None:
|
||||
"""Update the value of the entity."""
|
||||
self._attr_latitude = None if location is None else location.latitude
|
||||
self._attr_longitude = None if location is None else location.longitude
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _name_callback(self, name: str | None) -> None:
|
||||
"""Update the value of the entity."""
|
||||
self._attr_location_name = name
|
||||
if self._attr_location_name == "Home":
|
||||
self._attr_location_name = STATE_HOME
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -510,6 +510,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
TeslemetryVehicleSensorEntityDescription(
|
||||
key="drive_state_active_route_destination",
|
||||
polling=True,
|
||||
streaming_listener=lambda vehicle, callback: vehicle.listen_DestinationName(
|
||||
callback
|
||||
),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
TeslemetryVehicleSensorEntityDescription(
|
||||
key="drive_state_active_route_traffic_minutes_delay",
|
||||
polling=True,
|
||||
|
||||
@@ -75,6 +75,10 @@ class VolvoLock(VolvoEntity, LockEntity):
|
||||
|
||||
def _update_state(self, api_field: VolvoCarsApiBaseModel | None) -> None:
|
||||
"""Update the state of the entity."""
|
||||
if api_field is None:
|
||||
self._attr_is_locked = None
|
||||
return
|
||||
|
||||
assert isinstance(api_field, VolvoCarsValue)
|
||||
self._attr_is_locked = api_field.value == "LOCKED"
|
||||
|
||||
|
||||
@@ -468,7 +468,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
|
||||
|
||||
async def on_restart(self) -> None:
|
||||
"""Block until pipeline loop will be restarted."""
|
||||
_LOGGER.warning(
|
||||
_LOGGER.debug(
|
||||
"Satellite has been disconnected. Reconnecting in %s second(s)",
|
||||
_RECONNECT_SECONDS,
|
||||
)
|
||||
|
||||
@@ -6,8 +6,6 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
LocalOAuth2ImplementationWithPkce,
|
||||
)
|
||||
|
||||
from .const import YOTO_AUDIENCE, YOTO_SCOPES
|
||||
|
||||
AUTHORIZE_URL = "https://login.yotoplay.com/authorize"
|
||||
TOKEN_URL = "https://login.yotoplay.com/oauth/token"
|
||||
|
||||
@@ -16,9 +14,9 @@ async def async_get_auth_implementation(
|
||||
hass: HomeAssistant,
|
||||
auth_domain: str,
|
||||
credential: ClientCredential,
|
||||
) -> YotoOAuth2Implementation:
|
||||
"""Return a Yoto OAuth2 implementation backed by the user's credential."""
|
||||
return YotoOAuth2Implementation(
|
||||
) -> LocalOAuth2ImplementationWithPkce:
|
||||
"""Return a Yoto OAuth2 implementation with PKCE."""
|
||||
return LocalOAuth2ImplementationWithPkce(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential.client_id,
|
||||
@@ -26,15 +24,3 @@ async def async_get_auth_implementation(
|
||||
TOKEN_URL,
|
||||
credential.client_secret,
|
||||
)
|
||||
|
||||
|
||||
class YotoOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
|
||||
"""Yoto OAuth2 implementation with PKCE, audience and scopes."""
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Append Yoto's audience and scopes to every authorize URL."""
|
||||
return super().extra_authorize_data | {
|
||||
"audience": YOTO_AUDIENCE,
|
||||
"scope": " ".join(YOTO_SCOPES),
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from yoto_api import YotoError, get_account_id
|
||||
from homeassistant.config_entries import ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import _LOGGER, DOMAIN
|
||||
from .const import _LOGGER, DOMAIN, YOTO_AUDIENCE, YOTO_SCOPES
|
||||
|
||||
|
||||
class YotoOAuth2FlowHandler(
|
||||
@@ -23,6 +23,14 @@ class YotoOAuth2FlowHandler(
|
||||
"""Return the logger used for the OAuth2 flow."""
|
||||
return _LOGGER
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Append Yoto's audience and scopes to the authorize URL."""
|
||||
return {
|
||||
"audience": YOTO_AUDIENCE,
|
||||
"scope": " ".join(YOTO_SCOPES),
|
||||
}
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Identify the Yoto account from the access token."""
|
||||
try:
|
||||
|
||||
@@ -8,7 +8,7 @@ import zeversolar
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -35,4 +35,7 @@ class ZeversolarCoordinator(DataUpdateCoordinator[zeversolar.ZeverSolarData]):
|
||||
|
||||
async def _async_update_data(self) -> zeversolar.ZeverSolarData:
|
||||
"""Fetch the latest data from the source."""
|
||||
return await self.hass.async_add_executor_job(self._client.get_data)
|
||||
try:
|
||||
return await self.hass.async_add_executor_job(self._client.get_data)
|
||||
except zeversolar.ZeverSolarError as err:
|
||||
raise UpdateFailed(err) from err
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.const import UnitOfEnergy, UnitOfPower
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -29,10 +29,8 @@ class ZeversolarEntityDescription(SensorEntityDescription):
|
||||
SENSOR_TYPES = (
|
||||
ZeversolarEntityDescription(
|
||||
key="pac",
|
||||
translation_key="pac",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
value_fn=lambda data: data.pac,
|
||||
),
|
||||
|
||||
@@ -138,6 +138,8 @@ SAVE_DELAY = 1
|
||||
|
||||
DISCOVERY_COOLDOWN = 1
|
||||
|
||||
SETUP_RETRY_MAX_WAIT = 600 # 10 minutes
|
||||
|
||||
ISSUE_UNIQUE_ID_COLLISION = "config_entry_unique_id_collision"
|
||||
UNIQUE_ID_COLLISION_TITLE_LIMIT = 5
|
||||
|
||||
@@ -836,7 +838,7 @@ class ConfigEntry[_DataT = Any]:
|
||||
error_reason_translation_key,
|
||||
error_reason_translation_placeholders,
|
||||
)
|
||||
wait_time = 2 ** min(self._tries, 4) * 5 + (
|
||||
wait_time = min(2**self._tries * 5, SETUP_RETRY_MAX_WAIT) + (
|
||||
randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000
|
||||
)
|
||||
self._tries += 1
|
||||
|
||||
@@ -35,7 +35,7 @@ file-read-backwards==2.0.0
|
||||
fnv-hash-fast==2.0.3
|
||||
go2rtc-client==0.4.0
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==6.7.9
|
||||
habluetooth==6.8.0
|
||||
hass-nabucasa==2.2.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==2.0.0
|
||||
|
||||
Generated
+1
-1
@@ -1213,7 +1213,7 @@ ha-xthings-cloud==1.0.5
|
||||
habiticalib==0.4.7
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
habluetooth==6.7.9
|
||||
habluetooth==6.8.0
|
||||
|
||||
# homeassistant.components.hanna
|
||||
hanna-cloud==0.0.7
|
||||
|
||||
@@ -6,11 +6,10 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
BASE_COMPONENT = "notify"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_log_level():
|
||||
@@ -26,25 +25,25 @@ async def test_apprise_config_load_fail01(hass: HomeAssistant) -> None:
|
||||
"""Test apprise configuration failures 1."""
|
||||
|
||||
config = {
|
||||
BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"}
|
||||
NOTIFY_DOMAIN: {"name": "test", "platform": "apprise", "config": "/path/"}
|
||||
}
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.apprise.notify.apprise.AppriseConfig.add",
|
||||
return_value=False,
|
||||
):
|
||||
assert await async_setup_component(hass, BASE_COMPONENT, config)
|
||||
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test that our service failed to load
|
||||
assert not hass.services.has_service(BASE_COMPONENT, "test")
|
||||
assert not hass.services.has_service(NOTIFY_DOMAIN, "test")
|
||||
|
||||
|
||||
async def test_apprise_config_load_fail02(hass: HomeAssistant) -> None:
|
||||
"""Test apprise configuration failures 2."""
|
||||
|
||||
config = {
|
||||
BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": "/path/"}
|
||||
NOTIFY_DOMAIN: {"name": "test", "platform": "apprise", "config": "/path/"}
|
||||
}
|
||||
|
||||
with (
|
||||
@@ -57,11 +56,11 @@ async def test_apprise_config_load_fail02(hass: HomeAssistant) -> None:
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
assert await async_setup_component(hass, BASE_COMPONENT, config)
|
||||
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test that our service failed to load
|
||||
assert not hass.services.has_service(BASE_COMPONENT, "test")
|
||||
assert not hass.services.has_service(NOTIFY_DOMAIN, "test")
|
||||
|
||||
|
||||
async def test_apprise_config_load_okay(hass: HomeAssistant, tmp_path: Path) -> None:
|
||||
@@ -73,20 +72,20 @@ async def test_apprise_config_load_okay(hass: HomeAssistant, tmp_path: Path) ->
|
||||
f = d / "apprise"
|
||||
f.write_text("mailto://user:pass@example.com/")
|
||||
|
||||
config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}}
|
||||
config = {NOTIFY_DOMAIN: {"name": "test", "platform": "apprise", "config": str(f)}}
|
||||
|
||||
assert await async_setup_component(hass, BASE_COMPONENT, config)
|
||||
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Valid configuration was loaded; our service is good
|
||||
assert hass.services.has_service(BASE_COMPONENT, "test")
|
||||
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
|
||||
|
||||
|
||||
async def test_apprise_url_load_fail(hass: HomeAssistant) -> None:
|
||||
"""Test apprise url failure."""
|
||||
|
||||
config = {
|
||||
BASE_COMPONENT: {
|
||||
NOTIFY_DOMAIN: {
|
||||
"name": "test",
|
||||
"platform": "apprise",
|
||||
"url": "mailto://user:pass@example.com",
|
||||
@@ -96,18 +95,18 @@ async def test_apprise_url_load_fail(hass: HomeAssistant) -> None:
|
||||
"homeassistant.components.apprise.notify.apprise.Apprise.add",
|
||||
return_value=False,
|
||||
):
|
||||
assert await async_setup_component(hass, BASE_COMPONENT, config)
|
||||
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test that our service failed to load
|
||||
assert not hass.services.has_service(BASE_COMPONENT, "test")
|
||||
assert not hass.services.has_service(NOTIFY_DOMAIN, "test")
|
||||
|
||||
|
||||
async def test_apprise_notification(hass: HomeAssistant) -> None:
|
||||
"""Test apprise notification."""
|
||||
|
||||
config = {
|
||||
BASE_COMPONENT: {
|
||||
NOTIFY_DOMAIN: {
|
||||
"name": "test",
|
||||
"platform": "apprise",
|
||||
"url": "mailto://user:pass@example.com",
|
||||
@@ -124,18 +123,18 @@ async def test_apprise_notification(hass: HomeAssistant) -> None:
|
||||
obj.add.return_value = True
|
||||
obj.notify.return_value = True
|
||||
mock_apprise.return_value = obj
|
||||
assert await async_setup_component(hass, BASE_COMPONENT, config)
|
||||
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test the existence of our service
|
||||
assert hass.services.has_service(BASE_COMPONENT, "test")
|
||||
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
|
||||
|
||||
# Test the call to our underlining notify() call
|
||||
await hass.services.async_call(BASE_COMPONENT, "test", data)
|
||||
await hass.services.async_call(NOTIFY_DOMAIN, "test", data)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Validate calls were made under the hood correctly
|
||||
obj.add.assert_called_once_with(config[BASE_COMPONENT]["url"])
|
||||
obj.add.assert_called_once_with(config[NOTIFY_DOMAIN]["url"])
|
||||
obj.notify.assert_called_once_with(
|
||||
body=data["message"], title=data["title"], tag=None
|
||||
)
|
||||
@@ -145,7 +144,7 @@ async def test_apprise_multiple_notification(hass: HomeAssistant) -> None:
|
||||
"""Test apprise notification."""
|
||||
|
||||
config = {
|
||||
BASE_COMPONENT: {
|
||||
NOTIFY_DOMAIN: {
|
||||
"name": "test",
|
||||
"platform": "apprise",
|
||||
"url": [
|
||||
@@ -165,14 +164,14 @@ async def test_apprise_multiple_notification(hass: HomeAssistant) -> None:
|
||||
obj.add.return_value = True
|
||||
obj.notify.return_value = True
|
||||
mock_apprise.return_value = obj
|
||||
assert await async_setup_component(hass, BASE_COMPONENT, config)
|
||||
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test the existence of our service
|
||||
assert hass.services.has_service(BASE_COMPONENT, "test")
|
||||
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
|
||||
|
||||
# Test the call to our underlining notify() call
|
||||
await hass.services.async_call(BASE_COMPONENT, "test", data)
|
||||
await hass.services.async_call(NOTIFY_DOMAIN, "test", data)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Validate 2 calls were made under the hood
|
||||
@@ -196,7 +195,7 @@ async def test_apprise_notification_with_target(
|
||||
f.write_text("devops=mailto://user:pass@example.com/\r\n")
|
||||
f.write_text("system,alert=syslog://\r\n")
|
||||
|
||||
config = {BASE_COMPONENT: {"name": "test", "platform": "apprise", "config": str(f)}}
|
||||
config = {NOTIFY_DOMAIN: {"name": "test", "platform": "apprise", "config": str(f)}}
|
||||
|
||||
# Our Message, only notify the services tagged with "devops"
|
||||
data = {"title": "Test Title", "message": "Test Message", "target": ["devops"]}
|
||||
@@ -208,14 +207,14 @@ async def test_apprise_notification_with_target(
|
||||
apprise_obj.add.return_value = True
|
||||
apprise_obj.notify.return_value = True
|
||||
mock_apprise.return_value = apprise_obj
|
||||
assert await async_setup_component(hass, BASE_COMPONENT, config)
|
||||
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Test the existence of our service
|
||||
assert hass.services.has_service(BASE_COMPONENT, "test")
|
||||
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
|
||||
|
||||
# Test the call to our underlining notify() call
|
||||
await hass.services.async_call(BASE_COMPONENT, "test", data)
|
||||
await hass.services.async_call(NOTIFY_DOMAIN, "test", data)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Validate calls were made under the hood correctly
|
||||
|
||||
@@ -6,6 +6,23 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def silent_ssdp_scanner() -> Generator[None]:
|
||||
"""Start SSDP component and get Scanner, prevent actual SSDP traffic."""
|
||||
with (
|
||||
patch("homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"),
|
||||
patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"),
|
||||
patch("homeassistant.components.ssdp.Scanner.async_scan"),
|
||||
patch(
|
||||
"homeassistant.components.ssdp.Server._async_start_upnp_servers",
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.ssdp.Server._async_stop_upnp_servers",
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
|
||||
@@ -1 +1,33 @@
|
||||
"""Tests for the Duco integration."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def setup_integration(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the full Duco integration for testing."""
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return config_entry
|
||||
|
||||
|
||||
async def setup_platform_integration(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
platforms: Sequence[Platform],
|
||||
) -> MockConfigEntry:
|
||||
"""Set up selected Duco platforms for testing."""
|
||||
config_entry.add_to_hass(hass)
|
||||
with patch("homeassistant.components.duco.PLATFORMS", list(platforms)):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return config_entry
|
||||
|
||||
@@ -23,6 +23,8 @@ from homeassistant.components.duco.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_array_fixture
|
||||
|
||||
TEST_HOST = "192.168.1.100"
|
||||
@@ -159,112 +161,7 @@ def mock_lan_info() -> LanInfo:
|
||||
@pytest.fixture
|
||||
def mock_nodes() -> list[Node]:
|
||||
"""Return a list of nodes covering all supported types."""
|
||||
return [
|
||||
Node(
|
||||
node_id=1,
|
||||
general=NodeGeneralInfo(
|
||||
node_type="BOX",
|
||||
sub_type=1,
|
||||
network_type="VIRT",
|
||||
parent=0,
|
||||
asso=0,
|
||||
name="Living",
|
||||
identify=0,
|
||||
),
|
||||
ventilation=NodeVentilationInfo(
|
||||
state="AUTO",
|
||||
time_state_remain=0,
|
||||
time_state_end=0,
|
||||
mode="AUTO",
|
||||
flow_lvl_tgt=0,
|
||||
),
|
||||
sensor=NodeSensorInfo(
|
||||
co2=None,
|
||||
iaq_co2=None,
|
||||
rh=None,
|
||||
iaq_rh=None,
|
||||
temp=27.9,
|
||||
),
|
||||
),
|
||||
Node(
|
||||
node_id=2,
|
||||
general=NodeGeneralInfo(
|
||||
node_type="UCCO2",
|
||||
sub_type=0,
|
||||
network_type="RF",
|
||||
parent=1,
|
||||
asso=1,
|
||||
name="Office CO2",
|
||||
identify=0,
|
||||
),
|
||||
ventilation=NodeVentilationInfo(
|
||||
state="AUTO",
|
||||
time_state_remain=0,
|
||||
time_state_end=0,
|
||||
mode="-",
|
||||
flow_lvl_tgt=None,
|
||||
),
|
||||
sensor=NodeSensorInfo(
|
||||
co2=405,
|
||||
iaq_co2=80,
|
||||
rh=None,
|
||||
iaq_rh=None,
|
||||
temp=19.8,
|
||||
),
|
||||
),
|
||||
Node(
|
||||
node_id=113,
|
||||
general=NodeGeneralInfo(
|
||||
node_type="BSRH",
|
||||
sub_type=0,
|
||||
network_type="RF",
|
||||
parent=1,
|
||||
asso=1,
|
||||
name="Bathroom RH",
|
||||
identify=0,
|
||||
),
|
||||
ventilation=NodeVentilationInfo(
|
||||
state="AUTO",
|
||||
time_state_remain=0,
|
||||
time_state_end=0,
|
||||
mode="-",
|
||||
flow_lvl_tgt=None,
|
||||
),
|
||||
sensor=NodeSensorInfo(
|
||||
co2=None,
|
||||
iaq_co2=None,
|
||||
rh=42.0,
|
||||
iaq_rh=85,
|
||||
temp=27.9,
|
||||
),
|
||||
),
|
||||
Node(
|
||||
node_id=50,
|
||||
general=NodeGeneralInfo(
|
||||
node_type="UCRH",
|
||||
sub_type=0,
|
||||
network_type="RF",
|
||||
parent=1,
|
||||
asso=1,
|
||||
name="Kitchen RH",
|
||||
identify=0,
|
||||
),
|
||||
ventilation=NodeVentilationInfo(
|
||||
state="AUTO",
|
||||
time_state_remain=0,
|
||||
time_state_end=0,
|
||||
mode="-",
|
||||
flow_lvl_tgt=None,
|
||||
),
|
||||
sensor=NodeSensorInfo(
|
||||
co2=None,
|
||||
iaq_co2=None,
|
||||
rh=61.0,
|
||||
iaq_rh=90,
|
||||
temp=22.5,
|
||||
),
|
||||
),
|
||||
]
|
||||
return load_nodes_fixture("nodes.json")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -327,7 +224,4 @@ async def init_integration(
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Duco integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
return await setup_integration(hass, mock_config_entry)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
[
|
||||
{
|
||||
"node_id": 1,
|
||||
"general": {
|
||||
"node_type": "BOX",
|
||||
"sub_type": 1,
|
||||
"network_type": "VIRT",
|
||||
"parent": 0,
|
||||
"asso": 0,
|
||||
"name": "Living",
|
||||
"identify": 0
|
||||
},
|
||||
"ventilation": {
|
||||
"state": "AUTO",
|
||||
"time_state_remain": 0,
|
||||
"time_state_end": 0,
|
||||
"mode": "AUTO",
|
||||
"flow_lvl_tgt": 0
|
||||
},
|
||||
"sensor": {
|
||||
"co2": null,
|
||||
"iaq_co2": null,
|
||||
"rh": null,
|
||||
"iaq_rh": null,
|
||||
"temp": 27.9
|
||||
}
|
||||
},
|
||||
{
|
||||
"node_id": 2,
|
||||
"general": {
|
||||
"node_type": "UCCO2",
|
||||
"sub_type": 0,
|
||||
"network_type": "RF",
|
||||
"parent": 1,
|
||||
"asso": 1,
|
||||
"name": "Office CO2",
|
||||
"identify": 0
|
||||
},
|
||||
"ventilation": {
|
||||
"state": "AUTO",
|
||||
"time_state_remain": 0,
|
||||
"time_state_end": 0,
|
||||
"mode": "-",
|
||||
"flow_lvl_tgt": null
|
||||
},
|
||||
"sensor": {
|
||||
"co2": 405,
|
||||
"iaq_co2": 80,
|
||||
"rh": null,
|
||||
"iaq_rh": null,
|
||||
"temp": 19.8
|
||||
}
|
||||
},
|
||||
{
|
||||
"node_id": 113,
|
||||
"general": {
|
||||
"node_type": "BSRH",
|
||||
"sub_type": 0,
|
||||
"network_type": "RF",
|
||||
"parent": 1,
|
||||
"asso": 1,
|
||||
"name": "Bathroom RH",
|
||||
"identify": 0
|
||||
},
|
||||
"ventilation": {
|
||||
"state": "AUTO",
|
||||
"time_state_remain": 0,
|
||||
"time_state_end": 0,
|
||||
"mode": "-",
|
||||
"flow_lvl_tgt": null
|
||||
},
|
||||
"sensor": {
|
||||
"co2": null,
|
||||
"iaq_co2": null,
|
||||
"rh": 42.0,
|
||||
"iaq_rh": 85,
|
||||
"temp": 27.9
|
||||
}
|
||||
},
|
||||
{
|
||||
"node_id": 50,
|
||||
"general": {
|
||||
"node_type": "UCRH",
|
||||
"sub_type": 0,
|
||||
"network_type": "RF",
|
||||
"parent": 1,
|
||||
"asso": 1,
|
||||
"name": "Kitchen RH",
|
||||
"identify": 0
|
||||
},
|
||||
"ventilation": {
|
||||
"state": "AUTO",
|
||||
"time_state_remain": 0,
|
||||
"time_state_end": 0,
|
||||
"mode": "-",
|
||||
"flow_lvl_tgt": null
|
||||
},
|
||||
"sensor": {
|
||||
"co2": null,
|
||||
"iaq_co2": null,
|
||||
"rh": 61.0,
|
||||
"iaq_rh": 90,
|
||||
"temp": 22.5
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the Duco fan platform."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from duco_connectivity import DucoConnectionError, DucoError, DucoRateLimitError
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
@@ -21,6 +21,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_platform_integration
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
_FAN_ENTITY = "fan.living"
|
||||
@@ -33,11 +35,7 @@ async def init_integration(
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up only the fan platform for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
with patch("homeassistant.components.duco.PLATFORMS", [Platform.FAN]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
return await setup_platform_integration(hass, mock_config_entry, [Platform.FAN])
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the Duco sensor platform."""
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from duco_connectivity import (
|
||||
DucoConnectionError,
|
||||
@@ -22,6 +22,8 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import setup_platform_integration
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
@@ -34,11 +36,7 @@ async def init_integration(
|
||||
) -> MockConfigEntry:
|
||||
"""Set up only the sensor platform for testing."""
|
||||
mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
with patch("homeassistant.components.duco.PLATFORMS", [Platform.SENSOR]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
return await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR])
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test the Immich services."""
|
||||
|
||||
import re
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from aioimmich.exceptions import ImmichError, ImmichNotFoundError
|
||||
@@ -210,8 +211,30 @@ async def test_upload_file_album_not_found(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_err_message"),
|
||||
[
|
||||
(
|
||||
ImmichError(
|
||||
{
|
||||
"message": "Boom! Upload failed",
|
||||
"error": "Bad Request",
|
||||
"statusCode": 400,
|
||||
"correlationId": "nyzxjkno",
|
||||
}
|
||||
),
|
||||
"Boom! Upload failed (error: 'Bad Request' code: '400' correlation_id: 'nyzxjkno')",
|
||||
),
|
||||
(
|
||||
FileNotFoundError(2, "No such file or directory", "/media/screenshot.jpg"),
|
||||
"[Errno 2] No such file or directory: '/media/screenshot.jpg'",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_upload_file_upload_failed(
|
||||
hass: HomeAssistant,
|
||||
side_effect: Exception,
|
||||
expected_err_message: str,
|
||||
mock_immich: Mock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_media_source: Mock,
|
||||
@@ -219,16 +242,12 @@ async def test_upload_file_upload_failed(
|
||||
"""Test upload_file service raising upload_failed."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_immich.assets.async_upload_asset.side_effect = ImmichError(
|
||||
{
|
||||
"message": "Boom! Upload failed",
|
||||
"error": "Bad Request",
|
||||
"statusCode": 400,
|
||||
"correlationId": "nyzxjkno",
|
||||
}
|
||||
)
|
||||
mock_immich.assets.async_upload_asset.side_effect = side_effect
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match="Upload of file `/media/screenshot.jpg` failed"
|
||||
ServiceValidationError,
|
||||
match=re.escape(
|
||||
f"Upload of file `/media/screenshot.jpg` failed ({expected_err_message})"
|
||||
),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
|
||||
@@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator, Generator
|
||||
from pathlib import Path
|
||||
from random import getrandbits
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -33,6 +33,24 @@ def patch_hass_config(mock_hass_config: None) -> None:
|
||||
"""Patch configuration.yaml."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_v5_protocol_check() -> bool:
|
||||
"""Fixture to mock a v5 protocol test result."""
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_try_connection_protocol_check(
|
||||
hass: HomeAssistant, mock_v5_protocol_check: bool
|
||||
) -> Generator[MagicMock]:
|
||||
"""Patch try_connection."""
|
||||
with patch(
|
||||
"homeassistant.components.mqtt.try_connection",
|
||||
return_value=mock_v5_protocol_check,
|
||||
) as mock_try_connection:
|
||||
yield mock_try_connection
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_dir_prefix() -> str:
|
||||
"""Set an alternate temp dir prefix."""
|
||||
|
||||
@@ -230,6 +230,7 @@ async def test_publish(
|
||||
assert publish_mock.call_args[0][4].json() == {"MessageExpiryInterval": 60}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
|
||||
@pytest.mark.parametrize(
|
||||
("mqtt_config_entry_options", "mqtt_config_entry_data", "protocol"),
|
||||
[
|
||||
@@ -1257,7 +1258,12 @@ async def test_restore_subscriptions_on_reconnect(
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mqtt_config_entry_data", "mqtt_config_entry_options"),
|
||||
[({mqtt.CONF_BROKER: "mock-broker"}, {mqtt.CONF_DISCOVERY: False})],
|
||||
[
|
||||
(
|
||||
{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_PROTOCOL: "5"},
|
||||
{mqtt.CONF_DISCOVERY: False},
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_restore_all_active_subscriptions_on_reconnect(
|
||||
hass: HomeAssistant,
|
||||
@@ -1277,7 +1283,7 @@ async def test_restore_all_active_subscriptions_on_reconnect(
|
||||
|
||||
# the subscription with the highest QoS should survive
|
||||
expected = [
|
||||
call([("test/state", 2)], properties=None),
|
||||
call([("test/state", 2)], properties=ANY),
|
||||
]
|
||||
assert mqtt_client_mock.subscribe.mock_calls == expected
|
||||
|
||||
@@ -1291,7 +1297,7 @@ async def test_restore_all_active_subscriptions_on_reconnect(
|
||||
# wait for cooldown
|
||||
await mock_debouncer.wait()
|
||||
|
||||
expected.append(call([("test/state", 1)], properties=None))
|
||||
expected.append(call([("test/state", 1)], properties=ANY))
|
||||
for expected_call in expected:
|
||||
assert mqtt_client_mock.subscribe.hass_call(expected_call)
|
||||
|
||||
@@ -1549,6 +1555,7 @@ async def test_handle_message_callback(
|
||||
assert callbacks[0].payload == "test-payload"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
|
||||
@pytest.mark.parametrize(
|
||||
("mqtt_config_entry_data", "protocol", "clean_session"),
|
||||
[
|
||||
@@ -1582,7 +1589,6 @@ async def test_handle_message_callback(
|
||||
async def test_setup_mqtt_client_clean_session_and_protocol(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
mqtt_client_mock: MqttMockPahoClient,
|
||||
protocol: int,
|
||||
clean_session: bool | None,
|
||||
) -> None:
|
||||
@@ -1597,6 +1603,7 @@ async def test_setup_mqtt_client_clean_session_and_protocol(
|
||||
assert mock_client.call_args[1]["protocol"] == protocol
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
|
||||
@pytest.mark.parametrize(
|
||||
("mqtt_config_entry_data", "connect_args"),
|
||||
[
|
||||
|
||||
@@ -130,6 +130,7 @@ MOCK_ENCRYPTED_CLIENT_KEY_DER = b"## mock DER formatted encrypted key file ##\n"
|
||||
|
||||
MOCK_ENTRY_DATA = {
|
||||
mqtt.CONF_BROKER: "test-broker",
|
||||
CONF_PROTOCOL: "5",
|
||||
CONF_PORT: 1234,
|
||||
CONF_USERNAME: "user",
|
||||
CONF_PASSWORD: "pass",
|
||||
@@ -273,7 +274,7 @@ def mock_try_connection_time_out() -> Generator[MagicMock]:
|
||||
# Patch prevent waiting 5 sec for a timeout
|
||||
with (
|
||||
patch("homeassistant.components.mqtt.client.AsyncMQTTClient") as mock_client,
|
||||
patch("homeassistant.components.mqtt.config_flow.MQTT_TIMEOUT", 0),
|
||||
patch("homeassistant.components.mqtt.client.MQTT_TIMEOUT", 0),
|
||||
):
|
||||
mock_client().loop_start = lambda *args: 1
|
||||
yield mock_client()
|
||||
@@ -1756,6 +1757,7 @@ async def test_step_hassio_reauth(
|
||||
mock_try_connection.assert_called_once_with(
|
||||
{
|
||||
"broker": "core-mosquitto",
|
||||
CONF_PROTOCOL: "5",
|
||||
"port": 1883,
|
||||
"username": "mock-user",
|
||||
"password": "mock-pass",
|
||||
|
||||
@@ -5,6 +5,7 @@ from copy import deepcopy
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, TypedDict
|
||||
from unittest.mock import ANY, MagicMock, Mock, mock_open, patch
|
||||
@@ -28,6 +29,7 @@ from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState
|
||||
from homeassistant.const import (
|
||||
ATTR_ASSUMED_STATE,
|
||||
CONF_PORT,
|
||||
CONF_PROTOCOL,
|
||||
SERVICE_RELOAD,
|
||||
STATE_UNAVAILABLE,
|
||||
@@ -53,6 +55,7 @@ from tests.common import (
|
||||
MockEntity,
|
||||
MockEntityPlatform,
|
||||
MockMqttReasonCode,
|
||||
async_capture_events,
|
||||
async_fire_mqtt_message,
|
||||
async_fire_time_changed,
|
||||
mock_restore_cache,
|
||||
@@ -2470,3 +2473,125 @@ async def test_yaml_config_with_active_mqtt_config_entry(
|
||||
state = hass.states.get("sensor.mqtt_sensor")
|
||||
assert state is not None
|
||||
assert issue is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mqtt_config_entry_data",
|
||||
[
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
},
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
],
|
||||
ids=[
|
||||
"entry_without_protocol_without_port",
|
||||
"entry_without_protocol_with_port",
|
||||
"entry_with_protocol_3.1.1",
|
||||
"entry_with_protocol_3.1",
|
||||
],
|
||||
)
|
||||
async def test_mqtt_protocol_successful_migration_to_v5(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Test the silent MQTT protocol migration is successful."""
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
|
||||
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
await mqtt_mock_entry()
|
||||
assert len(events) == 0
|
||||
assert (
|
||||
"The MQTT protocol version was successfully updated to version 5"
|
||||
in caplog.text
|
||||
)
|
||||
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
|
||||
assert entry.data[mqtt.CONF_PROTOCOL] == mqtt.PROTOCOL_5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mock_v5_protocol_check", [False])
|
||||
@pytest.mark.parametrize(
|
||||
("mqtt_config_entry_data", "current_protocol"),
|
||||
[
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1",
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"entry_without_protocol_without_port",
|
||||
"entry_without_protocol_with_port",
|
||||
"entry_with_protocol_3.1.1",
|
||||
"entry_with_protocol_3.1",
|
||||
],
|
||||
)
|
||||
async def test_mqtt_protocol_failed_migration_to_v5(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
current_protocol: str,
|
||||
) -> None:
|
||||
"""Test failed silent MQTT protocol migration creates a repair issue."""
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
|
||||
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
|
||||
|
||||
await mqtt_mock_entry()
|
||||
assert len(events) == 1
|
||||
assert events[0].data["issue_id"] == "protocol_5_migration"
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert len(issue_registry.issues) == 1
|
||||
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
|
||||
assert issue is not None
|
||||
assert issue.translation_key == "protocol_5_migration"
|
||||
assert issue.translation_placeholders == {
|
||||
"broker": "mock-broker",
|
||||
"protocol": current_protocol,
|
||||
}
|
||||
assert (
|
||||
"The MQTT protocol version was successfully updated to version 5"
|
||||
not in caplog.text
|
||||
)
|
||||
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
|
||||
assert entry.data.get(mqtt.CONF_PROTOCOL, mqtt.PROTOCOL_311) == current_protocol
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"""Test repairs for MQTT."""
|
||||
|
||||
from collections.abc import Coroutine, Generator
|
||||
from collections.abc import Coroutine
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import mqtt
|
||||
from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData
|
||||
from homeassistant.const import CONF_PORT, CONF_PROTOCOL, SERVICE_RELOAD
|
||||
from homeassistant.const import SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.yaml import parse_yaml
|
||||
|
||||
from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message
|
||||
@@ -28,13 +27,6 @@ from tests.conftest import ClientSessionGenerator
|
||||
from tests.typing import MqttMockHAClientGenerator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_try_connection() -> Generator[MagicMock]:
|
||||
"""Mock the try connection method."""
|
||||
with patch("homeassistant.components.mqtt.repairs.try_connection") as mock_try:
|
||||
yield mock_try
|
||||
|
||||
|
||||
async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None:
|
||||
"""Help to set up an exported MQTT device via YAML."""
|
||||
with patch(
|
||||
@@ -185,183 +177,3 @@ async def test_subentry_reconfigure_export_settings(
|
||||
device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)})
|
||||
assert device.config_entries_subentries[config_entry.entry_id] == {None}
|
||||
assert device is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mqtt_config_entry_data", "current_protocol"),
|
||||
[
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1",
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"entry_without_protocol_without_port",
|
||||
"entry_without_protocol_with_port",
|
||||
"entry_with_protocol_3.1.1",
|
||||
"entry_with_protocol_3.1",
|
||||
],
|
||||
)
|
||||
async def test_mqtt_protocol_successful_migration_to_v5(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mqtt_config_entry_data: dict[str, Any],
|
||||
current_protocol: str,
|
||||
mock_try_connection: MagicMock,
|
||||
) -> None:
|
||||
"""Test the MQTT protocol migration repair flow is successful."""
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
|
||||
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
|
||||
|
||||
await mqtt_mock_entry()
|
||||
assert len(events) == 1
|
||||
assert events[0].data["issue_id"] == "protocol_5_migration"
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert len(issue_registry.issues) == 1
|
||||
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
|
||||
assert issue is not None
|
||||
assert issue.translation_key == "protocol_5_migration"
|
||||
assert issue.translation_placeholders == {
|
||||
"broker": "mock-broker",
|
||||
"protocol": current_protocol,
|
||||
}
|
||||
|
||||
await async_process_repairs_platforms(hass)
|
||||
client = await hass_client()
|
||||
|
||||
data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration")
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data["description_placeholders"] == {
|
||||
"broker": "mock-broker",
|
||||
"protocol": current_protocol,
|
||||
}
|
||||
assert data["step_id"] == "confirm"
|
||||
|
||||
mock_try_connection.side_effect = lambda x: True
|
||||
data = await process_repair_fix_flow(client, flow_id)
|
||||
assert data["type"] == "create_entry"
|
||||
expected_entry_data: dict[str, Any] = mqtt_config_entry_data | {CONF_PROTOCOL: "5"}
|
||||
mock_try_connection.assert_called_once_with(expected_entry_data | {CONF_PORT: 1883})
|
||||
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
|
||||
assert entry.data == expected_entry_data
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mqtt_config_entry_data", "current_protocol"),
|
||||
[
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1.1",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1.1",
|
||||
),
|
||||
(
|
||||
{
|
||||
mqtt.CONF_BROKER: "mock-broker",
|
||||
CONF_PROTOCOL: "3.1",
|
||||
CONF_PORT: 1883,
|
||||
},
|
||||
"3.1",
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"entry_without_protocol_without_port",
|
||||
"entry_without_protocol_with_port",
|
||||
"entry_with_protocol_3.1.1",
|
||||
"entry_with_protocol_3.1",
|
||||
],
|
||||
)
|
||||
async def test_mqtt_protocol_failed_migration_to_v5(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
hass_client: ClientSessionGenerator,
|
||||
current_protocol: str,
|
||||
mock_try_connection: MagicMock,
|
||||
) -> None:
|
||||
"""Test the MQTT protocol migration repair flow fails."""
|
||||
assert await async_setup_component(hass, "repairs", {})
|
||||
|
||||
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
|
||||
|
||||
await mqtt_mock_entry()
|
||||
assert len(events) == 1
|
||||
assert events[0].data["issue_id"] == "protocol_5_migration"
|
||||
|
||||
issue_registry = ir.async_get(hass)
|
||||
assert len(issue_registry.issues) == 1
|
||||
issue = issue_registry.async_get_issue(mqtt.DOMAIN, "protocol_5_migration")
|
||||
assert issue is not None
|
||||
assert issue.translation_key == "protocol_5_migration"
|
||||
assert issue.translation_placeholders == {
|
||||
"broker": "mock-broker",
|
||||
"protocol": current_protocol,
|
||||
}
|
||||
|
||||
await async_process_repairs_platforms(hass)
|
||||
client = await hass_client()
|
||||
|
||||
data = await start_repair_fix_flow(client, mqtt.DOMAIN, "protocol_5_migration")
|
||||
|
||||
flow_id = data["flow_id"]
|
||||
assert data["description_placeholders"] == {
|
||||
"broker": "mock-broker",
|
||||
"protocol": current_protocol,
|
||||
}
|
||||
assert data["step_id"] == "confirm"
|
||||
|
||||
mock_try_connection.side_effect = lambda x: False
|
||||
data = await process_repair_fix_flow(client, flow_id)
|
||||
assert data["type"] == "abort"
|
||||
assert data["reason"] == "mqtt_broker_migration_to_v5_failed"
|
||||
assert data["description_placeholders"] == {
|
||||
"broker": "mock-broker",
|
||||
"protocol": current_protocol,
|
||||
"url_mqtt_broker_configuration": "https://www.home-assistant.io/integrations/mqtt/#broker-configuration",
|
||||
}
|
||||
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Test for Portainer services."""
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from pyportainer import (
|
||||
PortainerAuthenticationError,
|
||||
@@ -20,6 +20,7 @@ from homeassistant.components.portainer.services import (
|
||||
ATTR_TIMEOUT,
|
||||
SERVICE_PRUNE_IMAGES,
|
||||
SERVICE_RECREATE_CONTAINER,
|
||||
_get_endpoint_id,
|
||||
)
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -281,6 +282,34 @@ async def test_service_validation_errors(
|
||||
)
|
||||
mock_portainer_client.container_recreate.assert_not_called()
|
||||
|
||||
with pytest.raises(ServiceValidationError, match="Invalid device targeted"):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_PRUNE_IMAGES,
|
||||
{ATTR_DEVICE_ID: container.id},
|
||||
blocking=True,
|
||||
)
|
||||
mock_portainer_client.images_prune.assert_not_called()
|
||||
|
||||
|
||||
async def test_service_prune_images_device_gone(
|
||||
hass: HomeAssistant,
|
||||
mock_portainer_client: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test _get_endpoint_id raises when the device ID no longer exists in the registry."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
loaded_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
|
||||
assert loaded_entry is not None
|
||||
|
||||
mock_call = MagicMock()
|
||||
mock_call.hass = hass
|
||||
mock_call.data = {ATTR_DEVICE_ID: "nonexistent_device_id"}
|
||||
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await _get_endpoint_id(mock_call, loaded_entry)
|
||||
mock_portainer_client.images_prune.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "message"),
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
"""Constants for the Renault integration tests."""
|
||||
|
||||
from homeassistant.components.renault.const import (
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOCALE,
|
||||
CONF_LOGIN_TOKEN,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.components.renault.const import RenaultConfigurationKeys
|
||||
|
||||
MOCK_ACCOUNT_ID = "account_id_1"
|
||||
MOCK_LOGIN_TOKEN = "sample-login-token"
|
||||
|
||||
# Mock config data to be used across multiple tests
|
||||
MOCK_CONFIG = {
|
||||
CONF_USERNAME: "email@test.com",
|
||||
CONF_PASSWORD: "test",
|
||||
CONF_LOGIN_TOKEN: MOCK_LOGIN_TOKEN,
|
||||
CONF_KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
|
||||
CONF_LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test",
|
||||
RenaultConfigurationKeys.LOGIN_TOKEN: MOCK_LOGIN_TOKEN,
|
||||
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
}
|
||||
|
||||
MOCK_VEHICLES = {
|
||||
|
||||
@@ -10,13 +10,7 @@ from renault_api.renault_account import RenaultAccount
|
||||
from renault_api.renault_session import RenaultSession
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.renault.const import (
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOCALE,
|
||||
CONF_LOGIN_TOKEN,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.components.renault.const import DOMAIN, RenaultConfigurationKeys
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
@@ -66,9 +60,9 @@ async def test_config_flow_single_account(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_LOCALE: "fr_FR",
|
||||
CONF_USERNAME: "email@test.com",
|
||||
CONF_PASSWORD: "test",
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -77,9 +71,18 @@ async def test_config_flow_single_account(
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
data_schema = result["data_schema"].schema
|
||||
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
|
||||
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
|
||||
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
|
||||
== "fr_FR"
|
||||
)
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
|
||||
== "email@test.com"
|
||||
)
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
|
||||
== "test"
|
||||
)
|
||||
|
||||
renault_account = AsyncMock()
|
||||
type(renault_account).account_id = PropertyMock(return_value="account_id_1")
|
||||
@@ -104,19 +107,21 @@ async def test_config_flow_single_account(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_LOCALE: "fr_FR",
|
||||
CONF_USERNAME: "email@test.com",
|
||||
CONF_PASSWORD: "test",
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "account_id_1"
|
||||
assert result["data"][CONF_USERNAME] == "email@test.com"
|
||||
assert result["data"][CONF_PASSWORD] == "test"
|
||||
assert result["data"][CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
|
||||
assert result["data"][CONF_LOCALE] == "fr_FR"
|
||||
assert result["data"][RenaultConfigurationKeys.USERNAME] == "email@test.com"
|
||||
assert result["data"][RenaultConfigurationKeys.PASSWORD] == "test"
|
||||
assert result["data"][RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert (
|
||||
result["data"][RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID] == "account_id_1"
|
||||
)
|
||||
assert result["data"][RenaultConfigurationKeys.LOCALE] == "fr_FR"
|
||||
assert result["context"]["unique_id"] == "account_id_1"
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
@@ -147,9 +152,9 @@ async def test_config_flow_no_account(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_LOCALE: "fr_FR",
|
||||
CONF_USERNAME: "email@test.com",
|
||||
CONF_PASSWORD: "test",
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -200,9 +205,9 @@ async def test_config_flow_multiple_accounts(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_LOCALE: "fr_FR",
|
||||
CONF_USERNAME: "email@test.com",
|
||||
CONF_PASSWORD: "test",
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -212,15 +217,17 @@ async def test_config_flow_multiple_accounts(
|
||||
# Account selected
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_KAMEREON_ACCOUNT_ID: "account_id_2"},
|
||||
user_input={RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: "account_id_2"},
|
||||
)
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "account_id_2"
|
||||
assert result["data"][CONF_USERNAME] == "email@test.com"
|
||||
assert result["data"][CONF_PASSWORD] == "test"
|
||||
assert result["data"][CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert result["data"][CONF_KAMEREON_ACCOUNT_ID] == "account_id_2"
|
||||
assert result["data"][CONF_LOCALE] == "fr_FR"
|
||||
assert result["data"][RenaultConfigurationKeys.USERNAME] == "email@test.com"
|
||||
assert result["data"][RenaultConfigurationKeys.PASSWORD] == "test"
|
||||
assert result["data"][RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert (
|
||||
result["data"][RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID] == "account_id_2"
|
||||
)
|
||||
assert result["data"][RenaultConfigurationKeys.LOCALE] == "fr_FR"
|
||||
assert result["context"]["unique_id"] == "account_id_2"
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
@@ -264,9 +271,9 @@ async def test_config_flow_duplicate(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_LOCALE: "fr_FR",
|
||||
CONF_USERNAME: "email@test.com",
|
||||
CONF_PASSWORD: "test",
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -285,8 +292,8 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["description_placeholders"] == {
|
||||
CONF_NAME: "Mock Title",
|
||||
CONF_USERNAME: "email@test.com",
|
||||
"name": "Mock Title",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
}
|
||||
assert result["errors"] == {}
|
||||
|
||||
@@ -297,13 +304,13 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
|
||||
):
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_PASSWORD: "any"},
|
||||
user_input={RenaultConfigurationKeys.PASSWORD: "any"},
|
||||
)
|
||||
|
||||
assert result2["type"] is FlowResultType.FORM
|
||||
assert result2["description_placeholders"] == {
|
||||
CONF_NAME: "Mock Title",
|
||||
CONF_USERNAME: "email@test.com",
|
||||
"name": "Mock Title",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
}
|
||||
assert result2["errors"] == {"base": "invalid_credentials"}
|
||||
|
||||
@@ -315,15 +322,15 @@ async def test_reauth(hass: HomeAssistant, config_entry: MockConfigEntry) -> Non
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={CONF_PASSWORD: "any"},
|
||||
user_input={RenaultConfigurationKeys.PASSWORD: "any"},
|
||||
)
|
||||
|
||||
assert result3["type"] is FlowResultType.ABORT
|
||||
assert result3["reason"] == "reauth_successful"
|
||||
|
||||
assert config_entry.data[CONF_USERNAME] == "email@test.com"
|
||||
assert config_entry.data[CONF_PASSWORD] == "any"
|
||||
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email@test.com"
|
||||
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "any"
|
||||
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
|
||||
|
||||
async def test_reconfigure(
|
||||
@@ -338,9 +345,18 @@ async def test_reconfigure(
|
||||
assert not result["errors"]
|
||||
|
||||
data_schema = result["data_schema"].schema
|
||||
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
|
||||
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
|
||||
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
|
||||
== "fr_FR"
|
||||
)
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
|
||||
== "email@test.com"
|
||||
)
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
|
||||
== "test"
|
||||
)
|
||||
|
||||
renault_account = AsyncMock()
|
||||
type(renault_account).account_id = PropertyMock(return_value="account_id_1")
|
||||
@@ -365,20 +381,23 @@ async def test_reconfigure(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_LOCALE: "fr_FR",
|
||||
CONF_USERNAME: "email2@test.com",
|
||||
CONF_PASSWORD: "test2",
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email2@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test2",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
|
||||
assert config_entry.data[CONF_USERNAME] == "email2@test.com"
|
||||
assert config_entry.data[CONF_PASSWORD] == "test2"
|
||||
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
|
||||
assert config_entry.data[CONF_LOCALE] == "fr_FR"
|
||||
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email2@test.com"
|
||||
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "test2"
|
||||
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert (
|
||||
config_entry.data[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
|
||||
== "account_id_1"
|
||||
)
|
||||
assert config_entry.data[RenaultConfigurationKeys.LOCALE] == "fr_FR"
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -395,9 +414,18 @@ async def test_reconfigure_mismatch(
|
||||
assert not result["errors"]
|
||||
|
||||
data_schema = result["data_schema"].schema
|
||||
assert get_schema_suggested_value(data_schema, CONF_LOCALE) == "fr_FR"
|
||||
assert get_schema_suggested_value(data_schema, CONF_USERNAME) == "email@test.com"
|
||||
assert get_schema_suggested_value(data_schema, CONF_PASSWORD) == "test"
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.LOCALE)
|
||||
== "fr_FR"
|
||||
)
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.USERNAME)
|
||||
== "email@test.com"
|
||||
)
|
||||
assert (
|
||||
get_schema_suggested_value(data_schema, RenaultConfigurationKeys.PASSWORD)
|
||||
== "test"
|
||||
)
|
||||
|
||||
renault_account = AsyncMock()
|
||||
type(renault_account).account_id = PropertyMock(return_value="account_id_other")
|
||||
@@ -422,9 +450,9 @@ async def test_reconfigure_mismatch(
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_LOCALE: "fr_FR",
|
||||
CONF_USERNAME: "email2@test.com",
|
||||
CONF_PASSWORD: "test2",
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email2@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test2",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -432,10 +460,13 @@ async def test_reconfigure_mismatch(
|
||||
assert result["reason"] == "unique_id_mismatch"
|
||||
|
||||
# Unchanged values
|
||||
assert config_entry.data[CONF_USERNAME] == "email@test.com"
|
||||
assert config_entry.data[CONF_PASSWORD] == "test"
|
||||
assert config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert config_entry.data[CONF_KAMEREON_ACCOUNT_ID] == "account_id_1"
|
||||
assert config_entry.data[CONF_LOCALE] == "fr_FR"
|
||||
assert config_entry.data[RenaultConfigurationKeys.USERNAME] == "email@test.com"
|
||||
assert config_entry.data[RenaultConfigurationKeys.PASSWORD] == "test"
|
||||
assert config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert (
|
||||
config_entry.data[RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID]
|
||||
== "account_id_1"
|
||||
)
|
||||
assert config_entry.data[RenaultConfigurationKeys.LOCALE] == "fr_FR"
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 0
|
||||
|
||||
@@ -10,19 +10,13 @@ from renault_api.gigya.exceptions import GigyaException, InvalidCredentialsExcep
|
||||
from renault_api.renault_session import RenaultSession
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.renault.const import (
|
||||
CONF_KAMEREON_ACCOUNT_ID,
|
||||
CONF_LOCALE,
|
||||
CONF_LOGIN_TOKEN,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.components.renault.const import DOMAIN, RenaultConfigurationKeys
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_USER,
|
||||
ConfigEntry,
|
||||
ConfigEntryState,
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -34,10 +28,10 @@ from tests.typing import WebSocketGenerator
|
||||
|
||||
# Config data of an entry created before the login token was stored.
|
||||
MOCK_CONFIG_NO_TOKEN = {
|
||||
CONF_USERNAME: "email@test.com",
|
||||
CONF_PASSWORD: "test",
|
||||
CONF_KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
|
||||
CONF_LOCALE: "fr_FR",
|
||||
RenaultConfigurationKeys.USERNAME: "email@test.com",
|
||||
RenaultConfigurationKeys.PASSWORD: "test",
|
||||
RenaultConfigurationKeys.KAMEREON_ACCOUNT_ID: MOCK_ACCOUNT_ID,
|
||||
RenaultConfigurationKeys.LOCALE: "fr_FR",
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +96,10 @@ async def test_setup_entry_password_login(
|
||||
assert mock_login.called
|
||||
assert legacy_config_entry.state is ConfigEntryState.LOADED
|
||||
# The obtained login token is persisted so future setups skip the password.
|
||||
assert legacy_config_entry.data[CONF_LOGIN_TOKEN] == MOCK_LOGIN_TOKEN
|
||||
assert (
|
||||
legacy_config_entry.data[RenaultConfigurationKeys.LOGIN_TOKEN]
|
||||
== MOCK_LOGIN_TOKEN
|
||||
)
|
||||
|
||||
|
||||
async def test_setup_entry_bad_password(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from enum import StrEnum
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -11,13 +12,8 @@ from renault_api.kamereon.models import ChargeSchedule, HvacSchedule
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.renault.const import DOMAIN
|
||||
from homeassistant.components.renault.services import (
|
||||
ATTR_SCHEDULES,
|
||||
ATTR_VEHICLE,
|
||||
ATTR_WHEN,
|
||||
)
|
||||
from homeassistant.components.renault.services import RenaultServiceArgument
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -27,6 +23,16 @@ from tests.common import async_load_fixture
|
||||
pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles")
|
||||
|
||||
|
||||
class RenaultService(StrEnum):
|
||||
"""Renault service names."""
|
||||
|
||||
AC_CANCEL = "ac_cancel"
|
||||
AC_SET_SCHEDULES = "ac_set_schedules"
|
||||
AC_START = "ac_start"
|
||||
CHARGE_SET_SCHEDULES = "charge_set_schedules"
|
||||
CHARGE_START = "charge_start"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def override_platforms() -> Generator[None]:
|
||||
"""Override PLATFORMS."""
|
||||
@@ -56,7 +62,7 @@ async def test_service_set_ac_cancel(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
}
|
||||
|
||||
with patch(
|
||||
@@ -68,7 +74,7 @@ async def test_service_set_ac_cancel(
|
||||
),
|
||||
) as mock_action:
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "ac_cancel", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
assert mock_action.mock_calls[0][1] == ()
|
||||
@@ -83,8 +89,8 @@ async def test_service_set_ac_start_simple(
|
||||
|
||||
temperature = 13.5
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
ATTR_TEMPERATURE: temperature,
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.TEMPERATURE: temperature,
|
||||
}
|
||||
|
||||
with patch(
|
||||
@@ -96,7 +102,7 @@ async def test_service_set_ac_start_simple(
|
||||
),
|
||||
) as mock_action:
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "ac_start", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.AC_START, service_data=data, blocking=True
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
assert mock_action.mock_calls[0][1] == (temperature, None)
|
||||
@@ -112,9 +118,9 @@ async def test_service_set_ac_start_with_date(
|
||||
temperature = 13.5
|
||||
when = datetime(2025, 8, 23, 17, 12, 45)
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
ATTR_TEMPERATURE: temperature,
|
||||
ATTR_WHEN: when,
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.TEMPERATURE: temperature,
|
||||
RenaultServiceArgument.WHEN: when,
|
||||
}
|
||||
|
||||
with patch(
|
||||
@@ -126,7 +132,7 @@ async def test_service_set_ac_start_with_date(
|
||||
),
|
||||
) as mock_action:
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "ac_start", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.AC_START, service_data=data, blocking=True
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
assert mock_action.mock_calls[0][1] == (temperature, when)
|
||||
@@ -140,7 +146,7 @@ async def test_service_charge_start_simple(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
}
|
||||
|
||||
with patch(
|
||||
@@ -152,7 +158,7 @@ async def test_service_charge_start_simple(
|
||||
),
|
||||
) as mock_action:
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "charge_start", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.CHARGE_START, service_data=data, blocking=True
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
assert mock_action.mock_calls[0][1] == (None,)
|
||||
@@ -167,8 +173,8 @@ async def test_service_charge_start_with_date(
|
||||
|
||||
when = datetime(2025, 8, 23, 17, 12, 45)
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
ATTR_WHEN: when,
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.WHEN: when,
|
||||
}
|
||||
|
||||
with patch(
|
||||
@@ -180,7 +186,7 @@ async def test_service_charge_start_with_date(
|
||||
),
|
||||
) as mock_action:
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "charge_start", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.CHARGE_START, service_data=data, blocking=True
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
assert mock_action.mock_calls[0][1] == (when,)
|
||||
@@ -195,8 +201,8 @@ async def test_service_set_charge_schedule(
|
||||
|
||||
schedules = {"id": 2}
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
ATTR_SCHEDULES: schedules,
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.SCHEDULES: schedules,
|
||||
}
|
||||
|
||||
with (
|
||||
@@ -219,7 +225,10 @@ async def test_service_set_charge_schedule(
|
||||
) as mock_action,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "charge_set_schedules", service_data=data, blocking=True
|
||||
DOMAIN,
|
||||
RenaultService.CHARGE_SET_SCHEDULES,
|
||||
service_data=data,
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
|
||||
@@ -247,8 +256,8 @@ async def test_service_set_charge_schedule_multi(
|
||||
{"id": 3},
|
||||
]
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
ATTR_SCHEDULES: schedules,
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.SCHEDULES: schedules,
|
||||
}
|
||||
|
||||
with (
|
||||
@@ -271,7 +280,10 @@ async def test_service_set_charge_schedule_multi(
|
||||
) as mock_action,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "charge_set_schedules", service_data=data, blocking=True
|
||||
DOMAIN,
|
||||
RenaultService.CHARGE_SET_SCHEDULES,
|
||||
service_data=data,
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
|
||||
@@ -296,8 +308,8 @@ async def test_service_set_ac_schedule(
|
||||
|
||||
schedules = {"id": 2}
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
ATTR_SCHEDULES: schedules,
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.SCHEDULES: schedules,
|
||||
}
|
||||
|
||||
with (
|
||||
@@ -319,7 +331,7 @@ async def test_service_set_ac_schedule(
|
||||
) as mock_action,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "ac_set_schedules", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.AC_SET_SCHEDULES, service_data=data, blocking=True
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
mock_call_data: list[ChargeSchedule] = mock_action.mock_calls[0][1][0]
|
||||
@@ -347,8 +359,8 @@ async def test_service_set_ac_schedule_multi(
|
||||
{"id": 4},
|
||||
]
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
ATTR_SCHEDULES: schedules,
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.SCHEDULES: schedules,
|
||||
}
|
||||
|
||||
with (
|
||||
@@ -370,7 +382,7 @@ async def test_service_set_ac_schedule_multi(
|
||||
) as mock_action,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "ac_set_schedules", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.AC_SET_SCHEDULES, service_data=data, blocking=True
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
mock_call_data: list[HvacSchedule] = mock_action.mock_calls[0][1][0]
|
||||
@@ -393,11 +405,11 @@ async def test_service_invalid_device_id(
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
data = {ATTR_VEHICLE: "some_random_id"}
|
||||
data = {RenaultServiceArgument.VEHICLE: "some_random_id"}
|
||||
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "ac_cancel", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
|
||||
)
|
||||
assert err.value.translation_key == "invalid_device_id"
|
||||
assert err.value.translation_placeholders == {"device_id": "some_random_id"}
|
||||
@@ -421,11 +433,11 @@ async def test_service_invalid_device_id2(
|
||||
identifiers={(DOMAIN, "VF1AAAAA111222333")},
|
||||
).id
|
||||
|
||||
data = {ATTR_VEHICLE: device_id}
|
||||
data = {RenaultServiceArgument.VEHICLE: device_id}
|
||||
|
||||
with pytest.raises(ServiceValidationError) as err:
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "ac_cancel", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
|
||||
)
|
||||
assert err.value.translation_key == "no_config_entry_for_device"
|
||||
assert err.value.translation_placeholders == {"device_id": "REG-NUMBER"}
|
||||
@@ -439,7 +451,7 @@ async def test_service_exception(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
data = {
|
||||
ATTR_VEHICLE: get_device_id(hass),
|
||||
RenaultServiceArgument.VEHICLE: get_device_id(hass),
|
||||
}
|
||||
|
||||
with (
|
||||
@@ -450,7 +462,7 @@ async def test_service_exception(
|
||||
pytest.raises(HomeAssistantError, match="Didn't work"),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
DOMAIN, "ac_cancel", service_data=data, blocking=True
|
||||
DOMAIN, RenaultService.AC_CANCEL, service_data=data, blocking=True
|
||||
)
|
||||
assert len(mock_action.mock_calls) == 1
|
||||
assert mock_action.mock_calls[0][1] == ()
|
||||
|
||||
@@ -5,6 +5,7 @@ import pathlib
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiohttp
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from roborock import (
|
||||
@@ -256,6 +257,26 @@ async def test_no_user_agreement(
|
||||
assert mock_roborock_entry.error_reason_translation_key == "no_user_agreement"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect",
|
||||
[aiohttp.ClientError(), TimeoutError()],
|
||||
ids=["client_error", "timeout"],
|
||||
)
|
||||
async def test_network_error_during_setup(
|
||||
hass: HomeAssistant,
|
||||
mock_roborock_entry: MockConfigEntry,
|
||||
side_effect: Exception,
|
||||
) -> None:
|
||||
"""Test that network errors during setup trigger retry, not terminal failure."""
|
||||
with patch(
|
||||
"homeassistant.components.roborock.create_device_manager",
|
||||
side_effect=side_effect,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_roborock_entry.entry_id)
|
||||
assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
assert mock_roborock_entry.error_reason_translation_key == "network_error"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platforms", [[Platform.SENSOR]])
|
||||
async def test_stale_device(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from aioshelly.const import MODEL_PLUG, MODEL_WALL_DISPLAY
|
||||
from aioshelly.exceptions import DeviceConnectionError, RpcCallError
|
||||
from aioshelly.exceptions import DeviceConnectionError, NotInitialized, RpcCallError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.shelly.const import (
|
||||
@@ -179,6 +179,22 @@ async def test_outbound_websocket_incorrectly_enabled_issue(
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
|
||||
async def test_repairs_skipped_when_device_not_initialized(
|
||||
hass: HomeAssistant,
|
||||
mock_rpc_device: Mock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test repair checks are skipped when the RPC device is not initialized."""
|
||||
mock_rpc_device.initialized = False
|
||||
type(mock_rpc_device).config = property(
|
||||
lambda self: (_ for _ in ()).throw(NotInitialized)
|
||||
)
|
||||
|
||||
await init_integration(hass, 2)
|
||||
|
||||
assert len(issue_registry.issues) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")]
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from requests_mock.mocker import Mocker
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@@ -25,13 +26,11 @@ from .conftest import (
|
||||
SignalNotificationService,
|
||||
)
|
||||
|
||||
BASE_COMPONENT = "notify"
|
||||
|
||||
|
||||
async def test_signal_messenger_init(hass: HomeAssistant) -> None:
|
||||
"""Test that service loads successfully."""
|
||||
config = {
|
||||
BASE_COMPONENT: {
|
||||
NOTIFY_DOMAIN: {
|
||||
"name": "test",
|
||||
"platform": "signal_messenger",
|
||||
"url": "http://127.0.0.1:8080",
|
||||
@@ -41,10 +40,10 @@ async def test_signal_messenger_init(hass: HomeAssistant) -> None:
|
||||
}
|
||||
|
||||
with patch("pysignalclirestapi.SignalCliRestApi.send_message", return_value=None):
|
||||
assert await async_setup_component(hass, BASE_COMPONENT, config)
|
||||
assert await async_setup_component(hass, NOTIFY_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.services.has_service(BASE_COMPONENT, "test")
|
||||
assert hass.services.has_service(NOTIFY_DOMAIN, "test")
|
||||
|
||||
|
||||
def test_send_message(
|
||||
|
||||
@@ -241,7 +241,6 @@ async def test_show_progress_polling(
|
||||
event = threading.Event()
|
||||
|
||||
def mock_tado_api_device_activation() -> None:
|
||||
"""Create a wait event."""
|
||||
event.wait(timeout=5)
|
||||
|
||||
mock_tado_api.device_activation = mock_tado_api_device_activation
|
||||
|
||||
@@ -108,6 +108,6 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'home',
|
||||
'state': 'not_home',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -3216,6 +3216,69 @@
|
||||
'state': 'stopped',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_destination-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.test_destination',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Destination',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Destination',
|
||||
'platform': 'tesla_fleet',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'drive_state_active_route_destination',
|
||||
'unique_id': 'LRWXF7EK4KC700000-drive_state_active_route_destination',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_destination-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Destination',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_destination',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'Home',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_destination-statealt]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Destination',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_destination',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_distance_to_arrival-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'home',
|
||||
'state': 'not_home',
|
||||
})
|
||||
# ---
|
||||
# name: test_device_tracker_alt[device_tracker.test_location-statealt]
|
||||
|
||||
@@ -3228,6 +3228,69 @@
|
||||
'state': 'stopped',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_destination-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.test_destination',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Destination',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Destination',
|
||||
'platform': 'teslemetry',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'drive_state_active_route_destination',
|
||||
'unique_id': 'LRW3F7EK4NC700000-drive_state_active_route_destination',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_destination-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Destination',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_destination',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'Home',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_destination-statealt]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Test Destination',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.test_destination',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unavailable',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.test_distance_to_arrival-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -81,7 +81,6 @@ async def test_device_tracker_streaming(
|
||||
"latitude": 3.0,
|
||||
"longitude": 4.0,
|
||||
},
|
||||
Signal.DESTINATION_NAME: "Home",
|
||||
Signal.ORIGIN_LOCATION: None,
|
||||
},
|
||||
"createdAt": "2024-10-04T10:45:17.537Z",
|
||||
@@ -91,7 +90,7 @@ async def test_device_tracker_streaming(
|
||||
|
||||
# Assert the entities have correct state values
|
||||
assert hass.states.get("device_tracker.test_location").state == "not_home"
|
||||
assert hass.states.get("device_tracker.test_route").state == "home"
|
||||
assert hass.states.get("device_tracker.test_route").state == "not_home"
|
||||
assert hass.states.get("device_tracker.test_origin").state == "unknown"
|
||||
|
||||
# Reload the entry
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Test Volvo locks."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
@@ -14,7 +16,8 @@ from homeassistant.components.lock import (
|
||||
SERVICE_UNLOCK,
|
||||
LockState,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.components.volvo.coordinator import FAST_INTERVAL
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -22,7 +25,7 @@ from homeassistant.helpers import entity_registry as er
|
||||
from . import configure_mock
|
||||
from .const import DEFAULT_VIN
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_api", "full_model")
|
||||
@@ -134,3 +137,28 @@ async def test_unlock_failure(
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(entity_id).state == LockState.LOCKED
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2025-05-31T10:00:00+00:00")
|
||||
@pytest.mark.usefixtures("full_model")
|
||||
async def test_lock_unavailable_when_api_field_missing(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
setup_integration: Callable[[], Awaitable[bool]],
|
||||
mock_api: VolvoCarsApi,
|
||||
) -> None:
|
||||
"""Test lock becomes unavailable when centralLock is missing from API response."""
|
||||
|
||||
with patch("homeassistant.components.volvo.PLATFORMS", [Platform.LOCK]):
|
||||
assert await setup_integration()
|
||||
|
||||
entity_id = "lock.volvo_xc40_lock"
|
||||
assert hass.states.get(entity_id).state == LockState.LOCKED
|
||||
|
||||
# Simulate API returning doors data without centralLock
|
||||
configure_mock(mock_api.async_get_doors_status, return_value={})
|
||||
freezer.tick(timedelta(minutes=FAST_INTERVAL))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.zeversolar_sensor_power',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
@@ -94,7 +94,7 @@
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'pac',
|
||||
'translation_key': None,
|
||||
'unique_id': '123456778_pac',
|
||||
'unit_of_measurement': <UnitOfPower.WATT: 'W'>,
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"""Test the sensor classes."""
|
||||
|
||||
from unittest.mock import patch
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from zeversolar.exceptions import ZeverSolarError
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.const import STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
async def test_sensors(
|
||||
@@ -25,3 +28,23 @@ async def test_sensors(
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, init_integration.entry_id
|
||||
)
|
||||
|
||||
|
||||
async def test_sensor_update_failed(
|
||||
hass: HomeAssistant,
|
||||
init_integration: MockConfigEntry,
|
||||
mock_zeversolar_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test entities become unavailable after a failed coordinator update."""
|
||||
assert hass.states.get("sensor.zeversolar_sensor_energy_today").state is not None
|
||||
|
||||
mock_zeversolar_client.get_data.side_effect = ZeverSolarError
|
||||
freezer.tick(timedelta(minutes=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
hass.states.get("sensor.zeversolar_sensor_energy_today").state
|
||||
== STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
@@ -1702,6 +1702,44 @@ async def test_setup_raise_not_ready(
|
||||
assert entry.reason is None
|
||||
|
||||
|
||||
async def test_setup_not_ready_exponential_backoff(
|
||||
hass: HomeAssistant,
|
||||
manager: config_entries.ConfigEntries,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test setup retry uses exponential backoff capped at 10 minutes."""
|
||||
entry = MockConfigEntry(domain="test")
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
attempts = 0
|
||||
|
||||
async def _mock_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
mock_integration(hass, MockModule("test", async_setup_entry=_mock_setup_entry))
|
||||
mock_platform(hass, "test.config_flow", None)
|
||||
|
||||
await manager.async_setup(entry.entry_id)
|
||||
assert attempts == 1
|
||||
|
||||
expected_waits = [5, 10, 20, 40, 80, 160, 320, 600, 600]
|
||||
for i, wait in enumerate(expected_waits):
|
||||
# Advance to just before the retry should fire
|
||||
freezer.tick(wait - 1)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert attempts == i + 1, f"Retry {i + 1} fired too early"
|
||||
|
||||
# Advance past the retry point (+ 1s for jitter)
|
||||
freezer.tick(2)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert attempts == i + 2, f"Retry {i + 1} did not fire"
|
||||
assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_raise_not_ready_from_exception(
|
||||
hass: HomeAssistant,
|
||||
manager: config_entries.ConfigEntries,
|
||||
|
||||
Reference in New Issue
Block a user