Compare commits

..

8 Commits

Author SHA1 Message Date
Andres Ruiz
56aa96a00c Add re-auth flow for Waterfurnace (#165406) 2026-03-15 07:09:35 +01:00
Anis Kadri
99c6cdbe44 Bump py-unifi-access to 1.1.0 (#165576) 2026-03-15 06:58:27 +01:00
J. Diego Rodríguez Royo
1fd30b73e7 Add fan speed percentage to service schema (#165557) 2026-03-15 06:57:38 +01:00
Joost Lekkerkerker
14aace0c00 Add stale device handling to TRMNL (#165550) 2026-03-15 06:56:05 +01:00
Joost Lekkerkerker
6eed18623b Add reauthentication to TRMNL (#165546) 2026-03-15 06:54:26 +01:00
Joost Lekkerkerker
66ca7d5782 Add switch platform to TRMNL (#165539) 2026-03-15 06:49:09 +01:00
Joost Lekkerkerker
a7436cbdc3 Add diagnostics to TRMNL (#165544) 2026-03-15 06:48:13 +01:00
Joost Lekkerkerker
5e57b0272d Add diagnostics to Chess.com (#165563) 2026-03-15 06:47:37 +01:00
32 changed files with 755 additions and 27 deletions

View File

@@ -0,0 +1,22 @@
"""Diagnostics support for Chess.com."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.core import HomeAssistant
from .coordinator import ChessConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ChessConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"player": asdict(coordinator.data.player),
"stats": asdict(coordinator.data.stats),
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Can't detect a game

View File

@@ -46,10 +46,12 @@ PROGRAM_OPTIONS = {
value,
)
for key, value in {
OptionKey.BSH_COMMON_DURATION: int,
OptionKey.BSH_COMMON_START_IN_RELATIVE: int,
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int,
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int,
OptionKey.BSH_COMMON_DURATION: vol.All(int, vol.Range(min=0)),
OptionKey.BSH_COMMON_START_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: vol.All(int, vol.Range(min=0)),
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: vol.All(
int, vol.Range(min=0)
),
OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
@@ -60,7 +62,10 @@ PROGRAM_OPTIONS = {
OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int,
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE: vol.All(
int, vol.Range(min=1, max=100)
),
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)),
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,

View File

@@ -10,7 +10,7 @@
"loggers": ["pyhap"],
"requirements": [
"HAP-python==5.0.0",
"fnv-hash-fast==2.0.0",
"fnv-hash-fast==1.6.0",
"PyQRCode==1.2.1",
"base36==0.1.1"
],

View File

@@ -8,7 +8,7 @@
"quality_scale": "internal",
"requirements": [
"SQLAlchemy==2.0.41",
"fnv-hash-fast==2.0.0",
"fnv-hash-fast==1.6.0",
"psutil-home-assistant==0.0.1"
]
}

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import TRMNLConfigEntry, TRMNLCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: TRMNLConfigEntry) -> bool:

View File

@@ -2,18 +2,21 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from trmnl import TRMNLClient
from trmnl.exceptions import TRMNLAuthenticationError, TRMNLError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
"""TRMNL config flow."""
@@ -21,7 +24,7 @@ class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
"""Handle a flow initialized by the user or reauth."""
errors: dict[str, str] = {}
if user_input:
session = async_get_clientsession(self.hass)
@@ -37,6 +40,12 @@ class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
else:
await self.async_set_unique_id(str(user.identifier))
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch()
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={CONF_API_KEY: user_input[CONF_API_KEY]},
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user.name,
@@ -44,6 +53,12 @@ class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
data_schema=STEP_USER_SCHEMA,
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_user()

View File

@@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -54,4 +55,15 @@ class TRMNLCoordinator(DataUpdateCoordinator[dict[int, Device]]):
translation_key="update_error",
translation_placeholders={"error": str(err)},
) from err
return {device.identifier: device for device in devices}
new_data = {device.identifier: device for device in devices}
if self.data is not None:
device_registry = dr.async_get(self.hass)
for device_id in set(self.data) - set(new_data):
if entry := device_registry.async_get_device(
identifiers={(DOMAIN, str(device_id))}
):
device_registry.async_update_device(
device_id=entry.id,
remove_config_entry_id=self.config_entry.entry_id,
)
return new_data

View File

@@ -0,0 +1,25 @@
"""Diagnostics support for TRMNL."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import TRMNLConfigEntry
TO_REDACT = {"mac_address"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: TRMNLConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"data": [
async_redact_data(asdict(device), TO_REDACT)
for device in entry.runtime_data.data.values()
],
}

View File

@@ -7,6 +7,7 @@ from trmnl.models import Device
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TRMNLCoordinator
@@ -22,6 +23,7 @@ class TRMNLEntity(CoordinatorEntity[TRMNLCoordinator]):
device = self._device
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, device.mac_address)},
identifiers={(DOMAIN, str(device_id))},
name=device.name,
manufacturer="TRMNL",
)

View File

@@ -0,0 +1,12 @@
{
"entity": {
"switch": {
"sleep_mode": {
"default": "mdi:sleep-off",
"state": {
"on": "mdi:sleep"
}
}
}
}
}

View File

@@ -36,12 +36,12 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: todo
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: Uses the cloud API
@@ -66,7 +66,7 @@ rules:
repair-issues:
status: exempt
comment: There are no repairable issues
stale-devices: todo
stale-devices: done
# Platinum
async-dependency: done

View File

@@ -1,7 +1,9 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unique_id_mismatch": "The API key belongs to a different account. Please use the API key for the original account."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -19,6 +21,13 @@
}
}
},
"entity": {
"switch": {
"sleep_mode": {
"name": "Sleep mode"
}
}
},
"exceptions": {
"authentication_error": {
"message": "Authentication failed. Please check your API key."

View File

@@ -0,0 +1,91 @@
"""Support for TRMNL switch entities."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from trmnl.models import Device
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TRMNLConfigEntry
from .coordinator import TRMNLCoordinator
from .entity import TRMNLEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class TRMNLSwitchEntityDescription(SwitchEntityDescription):
"""Describes a TRMNL switch entity."""
value_fn: Callable[[Device], bool]
set_value_fn: Callable[[TRMNLCoordinator, int, bool], Coroutine[Any, Any, None]]
SWITCH_DESCRIPTIONS: tuple[TRMNLSwitchEntityDescription, ...] = (
TRMNLSwitchEntityDescription(
key="sleep_mode",
translation_key="sleep_mode",
entity_category=EntityCategory.CONFIG,
value_fn=lambda device: device.sleep_mode_enabled,
set_value_fn=lambda coordinator, device_id, value: (
coordinator.client.update_device(device_id, sleep_mode_enabled=value)
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: TRMNLConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TRMNL switch entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
TRMNLSwitchEntity(coordinator, device_id, description)
for device_id in coordinator.data
for description in SWITCH_DESCRIPTIONS
)
class TRMNLSwitchEntity(TRMNLEntity, SwitchEntity):
"""Defines a TRMNL switch entity."""
entity_description: TRMNLSwitchEntityDescription
def __init__(
self,
coordinator: TRMNLCoordinator,
device_id: int,
description: TRMNLSwitchEntityDescription,
) -> None:
"""Initialize TRMNL switch entity."""
super().__init__(coordinator, device_id)
self.entity_description = description
self._attr_unique_id = f"{device_id}_{description.key}"
@property
def is_on(self) -> bool:
"""Return if sleep mode is enabled."""
return self.entity_description.value_fn(self._device)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Enable sleep mode."""
await self.entity_description.set_value_fn(
self.coordinator, self._device_id, True
)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Disable sleep mode."""
await self.entity_description.set_value_fn(
self.coordinator, self._device_id, False
)
await self.coordinator.async_request_refresh()

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["unifi_access_api"],
"quality_scale": "bronze",
"requirements": ["py-unifi-access==1.0.0"]
"requirements": ["py-unifi-access==1.1.0"]
}

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -72,6 +73,58 @@ class WaterFurnaceConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
username = user_input[CONF_USERNAME]
password = user_input[CONF_PASSWORD]
client = WaterFurnace(username, password)
try:
await self.hass.async_add_executor_job(client.login)
except WFCredentialError:
errors["base"] = "invalid_auth"
except WFException:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected error during reauthentication")
errors["base"] = "unknown"
# Treat no gwid as a connection failure
if not errors and not client.gwid:
errors["base"] = "cannot_connect"
if not errors:
await self.async_set_unique_id(client.gwid)
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
reauth_entry,
title=f"WaterFurnace {username}",
data_updates={**reauth_entry.data, **user_input},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
{CONF_USERNAME: reauth_entry.data[CONF_USERNAME]},
),
errors=errors,
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML configuration."""
username = import_data[CONF_USERNAME]

View File

@@ -4,7 +4,9 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"cannot_connect": "Please verify your credentials.",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "Unexpected error, please try again."
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "Unexpected error, please try again.",
"wrong_account": "You must reauthenticate with the same WaterFurnace account that was originally configured."
},
"error": {
"cannot_connect": "Failed to connect to WaterFurnace service",
@@ -12,6 +14,18 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::waterfurnace::config::step::user::data_description::password%]",
"username": "[%key:component::waterfurnace::config::step::user::data_description::username%]"
},
"description": "Please re-enter your WaterFurnace Symphony account credentials.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -33,7 +33,7 @@ cronsim==2.7
cryptography==46.0.5
dbus-fast==3.1.2
file-read-backwards==2.0.0
fnv-hash-fast==2.0.0
fnv-hash-fast==1.6.0
go2rtc-client==0.4.0
ha-ffmpeg==3.2.2
habluetooth==5.9.1

View File

@@ -48,7 +48,7 @@ dependencies = [
"certifi>=2021.5.30",
"ciso8601==2.3.3",
"cronsim==2.7",
"fnv-hash-fast==2.0.0",
"fnv-hash-fast==1.6.0",
# hass-nabucasa is imported by helpers which don't depend on the cloud
# integration
"hass-nabucasa==2.0.0",

2
requirements.txt generated
View File

@@ -23,7 +23,7 @@ certifi>=2021.5.30
ciso8601==2.3.3
cronsim==2.7
cryptography==46.0.5
fnv-hash-fast==2.0.0
fnv-hash-fast==1.6.0
ha-ffmpeg==3.2.2
hass-nabucasa==2.0.0
hassil==3.5.0

4
requirements_all.txt generated
View File

@@ -997,7 +997,7 @@ flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
fnv-hash-fast==2.0.0
fnv-hash-fast==1.6.0
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -1883,7 +1883,7 @@ py-sucks==0.9.11
py-synologydsm-api==2.7.3
# homeassistant.components.unifi_access
py-unifi-access==1.0.0
py-unifi-access==1.1.0
# homeassistant.components.atome
pyAtome==0.1.1

View File

@@ -882,7 +882,7 @@ flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
fnv-hash-fast==2.0.0
fnv-hash-fast==1.6.0
# homeassistant.components.foobot
foobot_async==1.0.0
@@ -1632,7 +1632,7 @@ py-sucks==0.9.11
py-synologydsm-api==2.7.3
# homeassistant.components.unifi_access
py-unifi-access==1.0.0
py-unifi-access==1.1.0
# homeassistant.components.hdmi_cec
pyCEC==0.5.2

View File

@@ -0,0 +1,59 @@
# serializer version: 1
# name: test_diagnostics
dict({
'player': dict({
'_country': None,
'avatar': 'https://images.chesscomfiles.com/uploads/v1/user/532748851.d5fefa92.200x200o.da2274e46acd.jpg',
'country_url': 'https://api.chess.com/pub/country/NL',
'fide': None,
'followers': 2,
'is_streamer': False,
'joined': '2026-02-20T10:48:14',
'last_online': '2026-03-06T12:32:59',
'location': 'Utrecht',
'name': 'Joost',
'player_id': 532748851,
'status': 'basic',
'title': None,
'twitch_url': None,
'username': 'joostlek',
}),
'stats': dict({
'chess960_daily': None,
'chess_blitz': None,
'chess_bullet': None,
'chess_daily': dict({
'last': dict({
'date': 1772800350,
'rating': 495,
'rd': 196,
}),
'record': dict({
'draw': 0,
'loss': 4,
'time_per_move': 6974,
'timeout_percent': 0,
'win': 0,
}),
}),
'chess_rapid': None,
'lessons': None,
'puzzle_rush': dict({
'best': dict({
'score': 8,
'total_attempts': 11,
}),
}),
'tactics': dict({
'highest': dict({
'date': 1772782351,
'rating': 764,
}),
'lowest': dict({
'date': 1771584762,
'rating': 400,
}),
}),
}),
})
# ---

View File

@@ -0,0 +1,29 @@
"""Tests for the Chess.com diagnostics."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_chess_client: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await setup_integration(hass, mock_config_entry)
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)

View File

@@ -0,0 +1,20 @@
# serializer version: 1
# name: test_diagnostics
dict({
'data': list([
dict({
'battery_voltage': 3.87,
'friendly_id': '1RJXS4',
'identifier': 42793,
'mac_address': '**REDACTED**',
'name': 'Test TRMNL',
'percent_charged': 72.5,
'rssi': -64,
'sleep_end_time': 480,
'sleep_mode_enabled': False,
'sleep_start_time': 1320,
'wifi_strength': 50,
}),
]),
})
# ---

View File

@@ -16,6 +16,10 @@
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'trmnl',
'42793',
),
}),
'labels': set({
}),

View File

@@ -0,0 +1,50 @@
# serializer version: 1
# name: test_all_entities[switch.test_trmnl_sleep_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.test_trmnl_sleep_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Sleep mode',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Sleep mode',
'platform': 'trmnl',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'sleep_mode',
'unique_id': '42793_sleep_mode',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[switch.test_trmnl_sleep_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Test TRMNL Sleep mode',
}),
'context': <ANY>,
'entity_id': 'switch.test_trmnl_sleep_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@@ -90,3 +90,83 @@ async def test_duplicate_entry(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reauth_flow(
hass: HomeAssistant,
mock_trmnl_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the reauth flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "user_bbbbbbbbbb"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.data == {CONF_API_KEY: "user_bbbbbbbbbb"}
@pytest.mark.parametrize(
("exception", "error"),
[
(TRMNLAuthenticationError, "invalid_auth"),
(TRMNLError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_reauth_flow_errors(
hass: HomeAssistant,
mock_trmnl_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: type[Exception],
error: str,
) -> None:
"""Test reauth flow error handling."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
mock_trmnl_client.get_me.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "user_bbbbbbbbbb"}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_trmnl_client.get_me.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "user_bbbbbbbbbb"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
async def test_reauth_flow_wrong_account(
hass: HomeAssistant,
mock_trmnl_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reauth aborts when the API key belongs to a different account."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
mock_trmnl_client.get_me.return_value.identifier = 99999
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_API_KEY: "user_cccccccccc"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unique_id_mismatch"

View File

@@ -0,0 +1,29 @@
"""Tests for the TRMNL diagnostics."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
mock_trmnl_client: AsyncMock,
mock_config_entry: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics."""
await setup_integration(hass, mock_config_entry)
assert (
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
== snapshot
)

View File

@@ -1,7 +1,9 @@
"""Test the TRMNL initialization."""
from datetime import timedelta
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
@@ -11,7 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
from . import setup_integration
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_load_unload_entry(
@@ -45,3 +47,27 @@ async def test_device(
)
assert device
assert device == snapshot
async def test_stale_device_removed(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_trmnl_client: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that a device is removed from the device registry when it disappears."""
await setup_integration(hass, mock_config_entry)
assert device_registry.async_get_device(
connections={(CONNECTION_NETWORK_MAC, "B0:A6:04:AA:BB:CC")}
)
mock_trmnl_client.get_devices.return_value = []
freezer.tick(timedelta(hours=1))
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert not device_registry.async_get_device(
connections={(CONNECTION_NETWORK_MAC, "B0:A6:04:AA:BB:CC")}
)

View File

@@ -0,0 +1,60 @@
"""Tests for the TRMNL switch platform."""
from unittest.mock import AsyncMock, patch
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_trmnl_client: AsyncMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all switch entities."""
with patch("homeassistant.components.trmnl.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("service", "expected_value"),
[
(SERVICE_TURN_ON, True),
(SERVICE_TURN_OFF, False),
],
)
async def test_set_switch(
hass: HomeAssistant,
mock_trmnl_client: AsyncMock,
mock_config_entry: MockConfigEntry,
service: str,
expected_value: bool,
) -> None:
"""Test turning the sleep mode switch on and off."""
with patch("homeassistant.components.trmnl.PLATFORMS", [Platform.SWITCH]):
await setup_integration(hass, mock_config_entry)
await hass.services.async_call(
"switch",
service,
{ATTR_ENTITY_ID: "switch.test_trmnl_sleep_mode"},
blocking=True,
)
mock_trmnl_client.update_device.assert_called_once_with(
42793, sleep_mode_enabled=expected_value
)
assert mock_trmnl_client.get_devices.call_count == 2

View File

@@ -222,3 +222,114 @@ async def test_import_flow_no_gwid(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_reauth_flow_success(
hass: HomeAssistant,
mock_waterfurnace_client: Mock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test successful reauth flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "new_user", CONF_PASSWORD: "new_password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
assert mock_config_entry.title == "WaterFurnace new_user"
assert mock_config_entry.data[CONF_USERNAME] == "new_user"
assert mock_config_entry.data[CONF_PASSWORD] == "new_password"
@pytest.mark.parametrize(
("exception", "error"),
[
(WFCredentialError("Invalid credentials"), "invalid_auth"),
(WFException("Connection failed"), "cannot_connect"),
(Exception("Unexpected error"), "unknown"),
],
)
async def test_reauth_flow_exceptions(
hass: HomeAssistant,
mock_waterfurnace_client: Mock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test reauth flow with errors and recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
mock_waterfurnace_client.login.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test_user", CONF_PASSWORD: "bad_password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_waterfurnace_client.login.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test_user", CONF_PASSWORD: "new_password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
async def test_reauth_flow_wrong_account(
hass: HomeAssistant,
mock_waterfurnace_client: Mock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test reauth flow aborts when a different account is used."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
mock_waterfurnace_client.gwid = "DIFFERENT_GWID"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "other_user", CONF_PASSWORD: "other_password"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_account"
async def test_reauth_flow_no_gwid(
hass: HomeAssistant,
mock_waterfurnace_client: Mock,
mock_config_entry: MockConfigEntry,
mock_setup_entry: AsyncMock,
) -> None:
"""Test reauth flow when no GWID is returned."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reauth_flow(hass)
mock_waterfurnace_client.gwid = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_USERNAME: "test_user", CONF_PASSWORD: "new_password"},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}