diff --git a/.strict-typing b/.strict-typing index 6e6e44cdd05..09954a3b27c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -570,6 +570,7 @@ homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.transmission.* homeassistant.components.trend.* +homeassistant.components.trmnl.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* diff --git a/CODEOWNERS b/CODEOWNERS index 4cc77b3079e..3afbced28e0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1770,6 +1770,8 @@ build.json @home-assistant/supervisor /tests/components/trend/ @jpbede /homeassistant/components/triggercmd/ @rvmey /tests/components/triggercmd/ @rvmey +/homeassistant/components/trmnl/ @joostlek +/tests/components/trmnl/ @joostlek /homeassistant/components/tts/ @home-assistant/core /tests/components/tts/ @home-assistant/core /homeassistant/components/tuya/ @Tuya @zlinoliver diff --git a/homeassistant/components/trmnl/__init__.py b/homeassistant/components/trmnl/__init__.py new file mode 100644 index 00000000000..74b11bcda55 --- /dev/null +++ b/homeassistant/components/trmnl/__init__.py @@ -0,0 +1,29 @@ +"""The TRMNL integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import TRMNLConfigEntry, TRMNLCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: TRMNLConfigEntry) -> bool: + """Set up TRMNL from a config entry.""" + + coordinator = TRMNLCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TRMNLConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trmnl/config_flow.py b/homeassistant/components/trmnl/config_flow.py new file mode 100644 index 00000000000..38f90458433 --- /dev/null +++ b/homeassistant/components/trmnl/config_flow.py @@ -0,0 +1,49 @@ +"""Config flow for TRMNL.""" + +from __future__ import annotations + +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.const import CONF_API_KEY +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER + + +class TRMNLConfigFlow(ConfigFlow, domain=DOMAIN): + """TRMNL config flow.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input: + session = async_get_clientsession(self.hass) + client = TRMNLClient(token=user_input[CONF_API_KEY], session=session) + try: + user = await client.get_me() + except TRMNLAuthenticationError: + errors["base"] = "invalid_auth" + except TRMNLError: + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(str(user.identifier)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user.name, + data={CONF_API_KEY: user_input[CONF_API_KEY]}, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), + errors=errors, + ) diff --git a/homeassistant/components/trmnl/const.py b/homeassistant/components/trmnl/const.py new file mode 100644 index 00000000000..15124feba0e --- /dev/null +++ b/homeassistant/components/trmnl/const.py @@ -0,0 +1,7 @@ +"""Constants for the TRMNL integration.""" + +import logging + +DOMAIN = "trmnl" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/trmnl/coordinator.py b/homeassistant/components/trmnl/coordinator.py new file mode 100644 index 00000000000..130dbd5331b --- /dev/null +++ b/homeassistant/components/trmnl/coordinator.py @@ -0,0 +1,57 @@ +"""Define an object to manage fetching TRMNL data.""" + +from __future__ import annotations + +from datetime import timedelta + +from trmnl import TRMNLClient +from trmnl.exceptions import TRMNLAuthenticationError, TRMNLError +from trmnl.models import Device + +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.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +type TRMNLConfigEntry = ConfigEntry[TRMNLCoordinator] + + +class TRMNLCoordinator(DataUpdateCoordinator[dict[int, Device]]): + """Class to manage fetching TRMNL data.""" + + config_entry: TRMNLConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: TRMNLConfigEntry) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + logger=LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(hours=1), + ) + self.client = TRMNLClient( + token=config_entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> dict[int, Device]: + """Fetch data from TRMNL.""" + try: + devices = await self.client.get_devices() + except TRMNLAuthenticationError as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from err + except TRMNLError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(err)}, + ) from err + return {device.identifier: device for device in devices} diff --git a/homeassistant/components/trmnl/entity.py b/homeassistant/components/trmnl/entity.py new file mode 100644 index 00000000000..25c363700b6 --- /dev/null +++ b/homeassistant/components/trmnl/entity.py @@ -0,0 +1,37 @@ +"""Base class for TRMNL entities.""" + +from __future__ import annotations + +from trmnl.models import Device + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import TRMNLCoordinator + + +class TRMNLEntity(CoordinatorEntity[TRMNLCoordinator]): + """Defines a base TRMNL entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: TRMNLCoordinator, device_id: int) -> None: + """Initialize TRMNL entity.""" + super().__init__(coordinator) + self._device_id = device_id + device = self._device + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, device.mac_address)}, + name=device.name, + manufacturer="TRMNL", + ) + + @property + def _device(self) -> Device: + """Return the device from coordinator data.""" + return self.coordinator.data[self._device_id] + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self._device_id in self.coordinator.data diff --git a/homeassistant/components/trmnl/manifest.json b/homeassistant/components/trmnl/manifest.json new file mode 100644 index 00000000000..81c49a87463 --- /dev/null +++ b/homeassistant/components/trmnl/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "trmnl", + "name": "TRMNL", + "codeowners": ["@joostlek"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/trmnl", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["trmnl==0.1.0"] +} diff --git a/homeassistant/components/trmnl/quality_scale.yaml b/homeassistant/components/trmnl/quality_scale.yaml new file mode 100644 index 00000000000..ad751dd2337 --- /dev/null +++ b/homeassistant/components/trmnl/quality_scale.yaml @@ -0,0 +1,74 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide additional actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: There are no configuration parameters + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Uses the cloud API + discovery: + status: exempt + comment: Can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: There are no repairable issues + stale-devices: todo + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/trmnl/sensor.py b/homeassistant/components/trmnl/sensor.py new file mode 100644 index 00000000000..ff72e3da6a7 --- /dev/null +++ b/homeassistant/components/trmnl/sensor.py @@ -0,0 +1,92 @@ +"""Support for TRMNL sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from trmnl.models import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + 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 TRMNLSensorEntityDescription(SensorEntityDescription): + """Describes a TRMNL sensor entity.""" + + value_fn: Callable[[Device], int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[TRMNLSensorEntityDescription, ...] = ( + TRMNLSensorEntityDescription( + key="battery", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.percent_charged, + ), + TRMNLSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda device: device.rssi, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TRMNLConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up TRMNL sensor entities based on a config entry.""" + coordinator = entry.runtime_data + async_add_entities( + TRMNLSensor(coordinator, device_id, description) + for device_id in coordinator.data + for description in SENSOR_DESCRIPTIONS + ) + + +class TRMNLSensor(TRMNLEntity, SensorEntity): + """Defines a TRMNL sensor.""" + + entity_description: TRMNLSensorEntityDescription + + def __init__( + self, + coordinator: TRMNLCoordinator, + device_id: int, + description: TRMNLSensorEntityDescription, + ) -> None: + """Initialize TRMNL sensor.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + def native_value(self) -> int | float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self._device) diff --git a/homeassistant/components/trmnl/strings.json b/homeassistant/components/trmnl/strings.json new file mode 100644 index 00000000000..386e03c4bdf --- /dev/null +++ b/homeassistant/components/trmnl/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "The API key for your TRMNL account." + } + } + } + }, + "exceptions": { + "authentication_error": { + "message": "Authentication failed. Please check your API key." + }, + "update_error": { + "message": "An error occurred while communicating with TRMNL: {error}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6ae80eb80df..fc2f9e01738 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -743,6 +743,7 @@ FLOWS = { "trane", "transmission", "triggercmd", + "trmnl", "tuya", "twentemilieu", "twilio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f4333f78e3e..037d1ed3bfe 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7244,6 +7244,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "trmnl": { + "name": "TRMNL", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tuya": { "name": "Tuya", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 704f2eab120..0e48a0bb8c4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5458,6 +5458,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trmnl.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tts.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index e0d1bdcae73..b1dc378d997 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3129,6 +3129,9 @@ transmission-rpc==7.0.3 # homeassistant.components.triggercmd triggercmd==0.0.36 +# homeassistant.components.trmnl +trmnl==0.1.0 + # homeassistant.components.twinkly ttls==1.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f09c742db8b..e376deb1092 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2632,6 +2632,9 @@ transmission-rpc==7.0.3 # homeassistant.components.triggercmd triggercmd==0.0.36 +# homeassistant.components.trmnl +trmnl==0.1.0 + # homeassistant.components.twinkly ttls==1.8.3 diff --git a/tests/components/trmnl/__init__.py b/tests/components/trmnl/__init__.py new file mode 100644 index 00000000000..99ca06067be --- /dev/null +++ b/tests/components/trmnl/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the TRMNL integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the TRMNL 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() diff --git a/tests/components/trmnl/conftest.py b/tests/components/trmnl/conftest.py new file mode 100644 index 00000000000..d1cd9fd9cb7 --- /dev/null +++ b/tests/components/trmnl/conftest.py @@ -0,0 +1,55 @@ +"""Common fixtures for the TRMNL tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from trmnl.models import DevicesResponse, UserResponse + +from homeassistant.components.trmnl.const import DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.trmnl.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Test", + unique_id="30561", + data={CONF_API_KEY: "user_aaaaaaaaaa"}, + ) + + +@pytest.fixture +def mock_trmnl_client() -> Generator[AsyncMock]: + """Mock TRMNL client.""" + with ( + patch( + "homeassistant.components.trmnl.coordinator.TRMNLClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.trmnl.config_flow.TRMNLClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_me.return_value = UserResponse.from_json( + load_fixture("me.json", DOMAIN) + ).data + client.get_devices.return_value = DevicesResponse.from_json( + load_fixture("devices.json", DOMAIN) + ).data + yield client diff --git a/tests/components/trmnl/fixtures/devices.json b/tests/components/trmnl/fixtures/devices.json new file mode 100644 index 00000000000..255e39eb032 --- /dev/null +++ b/tests/components/trmnl/fixtures/devices.json @@ -0,0 +1,17 @@ +{ + "data": [ + { + "id": 42793, + "name": "Test TRMNL", + "friendly_id": "1RJXS4", + "mac_address": "B0:A6:04:AA:BB:CC", + "battery_voltage": 3.87, + "rssi": -64, + "sleep_mode_enabled": false, + "sleep_start_time": 1320, + "sleep_end_time": 480, + "percent_charged": 72.5, + "wifi_strength": 50 + } + ] +} diff --git a/tests/components/trmnl/fixtures/me.json b/tests/components/trmnl/fixtures/me.json new file mode 100644 index 00000000000..714a99750a4 --- /dev/null +++ b/tests/components/trmnl/fixtures/me.json @@ -0,0 +1,14 @@ +{ + "data": { + "id": 30561, + "name": "Test", + "email": "test@outlook.com", + "first_name": "test", + "last_name": "test", + "locale": "en", + "time_zone": "Amsterdam", + "time_zone_iana": "Europe/Amsterdam", + "utc_offset": 3600, + "api_key": "user_aaaaaaaaaa" + } +} diff --git a/tests/components/trmnl/snapshots/test_init.ambr b/tests/components/trmnl/snapshots/test_init.ambr new file mode 100644 index 00000000000..9f30eb34d6f --- /dev/null +++ b/tests/components/trmnl/snapshots/test_init.ambr @@ -0,0 +1,32 @@ +# serializer version: 1 +# name: test_device + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'b0:a6:04:aa:bb:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + }), + 'labels': set({ + }), + 'manufacturer': 'TRMNL', + 'model': None, + 'model_id': None, + 'name': 'Test TRMNL', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- diff --git a/tests/components/trmnl/snapshots/test_sensor.ambr b/tests/components/trmnl/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f6009becda6 --- /dev/null +++ b/tests/components/trmnl/snapshots/test_sensor.ambr @@ -0,0 +1,109 @@ +# serializer version: 1 +# name: test_all_entities[sensor.test_trmnl_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_trmnl_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'trmnl', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '42793_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.test_trmnl_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test TRMNL Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_trmnl_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '72.5', + }) +# --- +# name: test_all_entities[sensor.test_trmnl_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_trmnl_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'trmnl', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '42793_rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.test_trmnl_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Test TRMNL Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_trmnl_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-64', + }) +# --- diff --git a/tests/components/trmnl/test_config_flow.py b/tests/components/trmnl/test_config_flow.py new file mode 100644 index 00000000000..ac04e67ee85 --- /dev/null +++ b/tests/components/trmnl/test_config_flow.py @@ -0,0 +1,92 @@ +"""Test the TRMNL config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from trmnl.exceptions import TRMNLAuthenticationError, TRMNLError + +from homeassistant.components.trmnl.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_trmnl_client") +async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test the full config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "user_aaaaaaaaaa"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Test" + assert result["data"] == {CONF_API_KEY: "user_aaaaaaaaaa"} + assert result["result"].unique_id == "30561" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (TRMNLAuthenticationError, "invalid_auth"), + (TRMNLError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_trmnl_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: type[Exception], + error: str, +) -> None: + """Test we handle form errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_trmnl_client.get_me.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "user_aaaaaaaaaa"} + ) + + 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_aaaaaaaaaa"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_trmnl_client") +async def test_duplicate_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test we handle duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "user_aaaaaaaaaa"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/trmnl/test_init.py b/tests/components/trmnl/test_init.py new file mode 100644 index 00000000000..22b9acf8b56 --- /dev/null +++ b/tests/components/trmnl/test_init.py @@ -0,0 +1,47 @@ +"""Test the TRMNL initialization.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_trmnl_client: AsyncMock, +) -> None: + """Test loading and unloading a config entry.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_trmnl_client: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the TRMNL device.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + connections={(CONNECTION_NETWORK_MAC, "B0:A6:04:AA:BB:CC")} + ) + assert device + assert device == snapshot diff --git a/tests/components/trmnl/test_sensor.py b/tests/components/trmnl/test_sensor.py new file mode 100644 index 00000000000..0c89227b66e --- /dev/null +++ b/tests/components/trmnl/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the TRMNL sensor.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import 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 + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_trmnl_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all sensor entities.""" + with patch("homeassistant.components.trmnl.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)