diff --git a/.strict-typing b/.strict-typing index a4152b78ca0..d483d04f702 100644 --- a/.strict-typing +++ b/.strict-typing @@ -443,6 +443,7 @@ homeassistant.components.rituals_perfume_genie.* homeassistant.components.roborock.* homeassistant.components.roku.* homeassistant.components.romy.* +homeassistant.components.route_b_smart_meter.* homeassistant.components.rpi_power.* homeassistant.components.rss_feed_template.* homeassistant.components.russound_rio.* diff --git a/CODEOWNERS b/CODEOWNERS index 59b72f3550b..c68c96f4f24 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1332,6 +1332,8 @@ build.json @home-assistant/supervisor /tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Orhideous /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni +/homeassistant/components/route_b_smart_meter/ @SeraphicRav +/tests/components/route_b_smart_meter/ @SeraphicRav /homeassistant/components/rpi_power/ @shenxn @swetoast /tests/components/rpi_power/ @shenxn @swetoast /homeassistant/components/rss_feed_template/ @home-assistant/core diff --git a/homeassistant/components/route_b_smart_meter/__init__.py b/homeassistant/components/route_b_smart_meter/__init__.py new file mode 100644 index 00000000000..5e8a941c73e --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/__init__.py @@ -0,0 +1,28 @@ +"""The Smart Meter B Route integration.""" + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import BRouteConfigEntry, BRouteUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: BRouteConfigEntry) -> bool: + """Set up Smart Meter B Route from a config entry.""" + + coordinator = BRouteUpdateCoordinator(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: BRouteConfigEntry) -> bool: + """Unload a config entry.""" + await hass.async_add_executor_job(entry.runtime_data.api.close) + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/route_b_smart_meter/config_flow.py b/homeassistant/components/route_b_smart_meter/config_flow.py new file mode 100644 index 00000000000..1cbeeab4c4e --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Smart Meter B Route integration.""" + +import logging +from typing import Any + +from momonga import Momonga, MomongaSkJoinFailure, MomongaSkScanFailure +from serial.tools.list_ports import comports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant.components.usb import get_serial_by_id, human_readable_device_name +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.helpers.service_info.usb import UsbServiceInfo + +from .const import DOMAIN, ENTRY_TITLE + +_LOGGER = logging.getLogger(__name__) + + +def _validate_input(device: str, id: str, password: str) -> None: + """Validate the user input allows us to connect.""" + with Momonga(dev=device, rbid=id, pwd=password): + pass + + +def _human_readable_device_name(port: UsbServiceInfo | ListPortInfo) -> str: + return human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + str(port.vid) if port.vid else None, + str(port.pid) if port.pid else None, + ) + + +class BRouteConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Smart Meter B Route.""" + + VERSION = 1 + + device: UsbServiceInfo | None = None + + @callback + def _get_discovered_device_id_and_name( + self, device_options: dict[str, ListPortInfo] + ) -> tuple[str | None, str | None]: + discovered_device_id = ( + get_serial_by_id(self.device.device) if self.device else None + ) + discovered_device = ( + device_options.get(discovered_device_id) if discovered_device_id else None + ) + discovered_device_name = ( + _human_readable_device_name(discovered_device) + if discovered_device + else None + ) + return discovered_device_id, discovered_device_name + + async def _get_usb_devices(self) -> dict[str, ListPortInfo]: + """Return a list of available USB devices.""" + devices = await self.hass.async_add_executor_job(comports) + return {get_serial_by_id(port.device): port for port in devices} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + device_options = await self._get_usb_devices() + if user_input is not None: + try: + await self.hass.async_add_executor_job( + _validate_input, + user_input[CONF_DEVICE], + user_input[CONF_ID], + user_input[CONF_PASSWORD], + ) + except MomongaSkScanFailure: + errors["base"] = "cannot_connect" + except MomongaSkJoinFailure: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_ID], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=ENTRY_TITLE, data=user_input) + + discovered_device_id, discovered_device_name = ( + self._get_discovered_device_id_and_name(device_options) + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE, default=discovered_device_id): vol.In( + {discovered_device_id: discovered_device_name} + if discovered_device_id and discovered_device_name + else { + name: _human_readable_device_name(device) + for name, device in device_options.items() + } + ), + vol.Required(CONF_ID): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/route_b_smart_meter/const.py b/homeassistant/components/route_b_smart_meter/const.py new file mode 100644 index 00000000000..ecd3fc48bfc --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/const.py @@ -0,0 +1,12 @@ +"""Constants for the Smart Meter B Route integration.""" + +from datetime import timedelta + +DOMAIN = "route_b_smart_meter" +ENTRY_TITLE = "Route B Smart Meter" +DEFAULT_SCAN_INTERVAL = timedelta(seconds=300) + +ATTR_API_INSTANTANEOUS_POWER = "instantaneous_power" +ATTR_API_TOTAL_CONSUMPTION = "total_consumption" +ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE = "instantaneous_current_t_phase" +ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE = "instantaneous_current_r_phase" diff --git a/homeassistant/components/route_b_smart_meter/coordinator.py b/homeassistant/components/route_b_smart_meter/coordinator.py new file mode 100644 index 00000000000..7cfa2810b5b --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/coordinator.py @@ -0,0 +1,75 @@ +"""DataUpdateCoordinator for the Smart Meter B-route integration.""" + +from dataclasses import dataclass +import logging + +from momonga import Momonga, MomongaError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BRouteData: + """Class for data of the B Route.""" + + instantaneous_current_r_phase: float + instantaneous_current_t_phase: float + instantaneous_power: float + total_consumption: float + + +type BRouteConfigEntry = ConfigEntry[BRouteUpdateCoordinator] + + +class BRouteUpdateCoordinator(DataUpdateCoordinator[BRouteData]): + """The B Route update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + entry: BRouteConfigEntry, + ) -> None: + """Initialize.""" + + self.device = entry.data[CONF_DEVICE] + self.bid = entry.data[CONF_ID] + password = entry.data[CONF_PASSWORD] + + self.api = Momonga(dev=self.device, rbid=self.bid, pwd=password) + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + config_entry=entry, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + + async def _async_setup(self) -> None: + await self.hass.async_add_executor_job( + self.api.open, + ) + + def _get_data(self) -> BRouteData: + """Get the data from API.""" + current = self.api.get_instantaneous_current() + return BRouteData( + instantaneous_current_r_phase=current["r phase current"], + instantaneous_current_t_phase=current["t phase current"], + instantaneous_power=self.api.get_instantaneous_power(), + total_consumption=self.api.get_measured_cumulative_energy(), + ) + + async def _async_update_data(self) -> BRouteData: + """Update data.""" + try: + return await self.hass.async_add_executor_job(self._get_data) + except MomongaError as error: + raise UpdateFailed(error) from error diff --git a/homeassistant/components/route_b_smart_meter/manifest.json b/homeassistant/components/route_b_smart_meter/manifest.json new file mode 100644 index 00000000000..d1189d0a542 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "route_b_smart_meter", + "name": "Smart Meter B Route", + "codeowners": ["@SeraphicRav"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/route_b_smart_meter", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": [ + "momonga.momonga", + "momonga.momonga_session_manager", + "momonga.sk_wrapper_logger" + ], + "quality_scale": "bronze", + "requirements": ["pyserial==3.5", "momonga==0.1.5"] +} diff --git a/homeassistant/components/route_b_smart_meter/quality_scale.yaml b/homeassistant/components/route_b_smart_meter/quality_scale.yaml new file mode 100644 index 00000000000..f6123b6e4c9 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/quality_scale.yaml @@ -0,0 +1,82 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + The integration does not provide any additional actions. + appropriate-polling: + status: done + brands: + status: exempt + comment: | + The integration is not specific to a single brand, it does not have a logo. + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + The integration does not provide any additional actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + The integration does not use 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: + status: exempt + comment: | + The integration does not provide any additional actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: + status: exempt + comment: | + The manufacturer does not use unique identifiers for devices. + 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: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: | + The integration does not use HTTP. + strict-typing: todo diff --git a/homeassistant/components/route_b_smart_meter/sensor.py b/homeassistant/components/route_b_smart_meter/sensor.py new file mode 100644 index 00000000000..c8034528f5a --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/sensor.py @@ -0,0 +1,109 @@ +"""Smart Meter B Route.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfElectricCurrent, UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import BRouteConfigEntry +from .const import ( + ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + ATTR_API_INSTANTANEOUS_POWER, + ATTR_API_TOTAL_CONSUMPTION, + DOMAIN, +) +from .coordinator import BRouteData, BRouteUpdateCoordinator + + +@dataclass(frozen=True, kw_only=True) +class SensorEntityDescriptionWithValueAccessor(SensorEntityDescription): + """Sensor entity description with data accessor.""" + + value_accessor: Callable[[BRouteData], StateType] + + +SENSOR_DESCRIPTIONS = ( + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + translation_key=ATTR_API_INSTANTANEOUS_CURRENT_R_PHASE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_accessor=lambda data: data.instantaneous_current_r_phase, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + translation_key=ATTR_API_INSTANTANEOUS_CURRENT_T_PHASE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value_accessor=lambda data: data.instantaneous_current_t_phase, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_INSTANTANEOUS_POWER, + translation_key=ATTR_API_INSTANTANEOUS_POWER, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_accessor=lambda data: data.instantaneous_power, + ), + SensorEntityDescriptionWithValueAccessor( + key=ATTR_API_TOTAL_CONSUMPTION, + translation_key=ATTR_API_TOTAL_CONSUMPTION, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value_accessor=lambda data: data.total_consumption, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: BRouteConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Smart Meter B-route entry.""" + coordinator = entry.runtime_data + + async_add_entities( + SmartMeterBRouteSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class SmartMeterBRouteSensor(CoordinatorEntity[BRouteUpdateCoordinator], SensorEntity): + """Representation of a Smart Meter B-route sensor entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: BRouteUpdateCoordinator, + description: SensorEntityDescriptionWithValueAccessor, + ) -> None: + """Initialize Smart Meter B-route sensor entity.""" + super().__init__(coordinator) + self.entity_description: SensorEntityDescriptionWithValueAccessor = description + self._attr_unique_id = f"{coordinator.bid}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.bid)}, + name=f"Route B Smart Meter {coordinator.bid}", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_accessor(self.coordinator.data) diff --git a/homeassistant/components/route_b_smart_meter/strings.json b/homeassistant/components/route_b_smart_meter/strings.json new file mode 100644 index 00000000000..382ff6edaa0 --- /dev/null +++ b/homeassistant/components/route_b_smart_meter/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "step": { + "user": { + "data_description": { + "device": "[%key:common::config_flow::data::device%]", + "id": "B Route ID", + "password": "[%key:common::config_flow::data::password%]" + }, + "data": { + "device": "[%key:common::config_flow::data::device%]", + "id": "B Route ID", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "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%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "instantaneous_power": { + "name": "Instantaneous power" + }, + "total_consumption": { + "name": "Total consumption" + }, + "instantaneous_current_t_phase": { + "name": "Instantaneous current T phase" + }, + "instantaneous_current_r_phase": { + "name": "Instantaneous current R phase" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5cdff221957..711c9f793e2 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -552,6 +552,7 @@ FLOWS = { "romy", "roomba", "roon", + "route_b_smart_meter", "rova", "rpi_power", "ruckus_unleashed", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fb4d3a19921..d188c31d81f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5637,6 +5637,12 @@ } } }, + "route_b_smart_meter": { + "name": "Smart Meter B Route", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "rova": { "name": "ROVA", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 4bfe2a10063..dcf71efe898 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4186,6 +4186,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.route_b_smart_meter.*] +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.rpi_power.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 5500f3385a3..d5a89bc7e6b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1466,6 +1466,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 +# homeassistant.components.route_b_smart_meter +momonga==0.1.5 + # homeassistant.components.monzo monzopy==1.5.1 @@ -2345,6 +2348,7 @@ pyserial-asyncio-fast==0.16 # homeassistant.components.acer_projector # homeassistant.components.crownstone +# homeassistant.components.route_b_smart_meter # homeassistant.components.usb # homeassistant.components.zwave_js pyserial==3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f0bf24d867..567484e9f22 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1258,6 +1258,9 @@ moat-ble==0.1.1 # homeassistant.components.moehlenhoff_alpha2 moehlenhoff-alpha2==1.4.0 +# homeassistant.components.route_b_smart_meter +momonga==0.1.5 + # homeassistant.components.monzo monzopy==1.5.1 @@ -1957,6 +1960,7 @@ pysensibo==1.2.1 # homeassistant.components.acer_projector # homeassistant.components.crownstone +# homeassistant.components.route_b_smart_meter # homeassistant.components.usb # homeassistant.components.zwave_js pyserial==3.5 diff --git a/tests/components/route_b_smart_meter/__init__.py b/tests/components/route_b_smart_meter/__init__.py new file mode 100644 index 00000000000..7b998b1f4bd --- /dev/null +++ b/tests/components/route_b_smart_meter/__init__.py @@ -0,0 +1 @@ +"""Tests for the Smart Meter B-route integration.""" diff --git a/tests/components/route_b_smart_meter/conftest.py b/tests/components/route_b_smart_meter/conftest.py new file mode 100644 index 00000000000..f0a84c252a0 --- /dev/null +++ b/tests/components/route_b_smart_meter/conftest.py @@ -0,0 +1,72 @@ +"""Common fixtures for the Smart Meter B-route tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.route_b_smart_meter.const import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.route_b_smart_meter.async_setup_entry", + return_value=True, + ) as mock: + yield mock + + +@pytest.fixture +def mock_momonga(exception=None) -> Generator[Mock]: + """Mock for Momonga class.""" + + with ( + patch( + "homeassistant.components.route_b_smart_meter.coordinator.Momonga", + ) as mock_momonga, + patch( + "homeassistant.components.route_b_smart_meter.config_flow.Momonga", + new=mock_momonga, + ), + ): + client = mock_momonga.return_value + client.__enter__.return_value = client + client.__exit__.return_value = None + client.get_instantaneous_current.return_value = { + "r phase current": 1, + "t phase current": 2, + } + client.get_instantaneous_power.return_value = 3 + client.get_measured_cumulative_energy.return_value = 4 + yield mock_momonga + + +@pytest.fixture +def user_input() -> dict[str, str]: + """Return test user input data.""" + return { + CONF_DEVICE: "/dev/ttyUSB42", + CONF_ID: "01234567890123456789012345F789", + CONF_PASSWORD: "B_ROUTE_PASSWORD", + } + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, user_input: dict[str, str] +) -> MockConfigEntry: + """Create a mock config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=user_input, + entry_id="01234567890123456789012345F789", + unique_id="123456", + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr b/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..552e46aa687 --- /dev/null +++ b/tests/components/route_b_smart_meter/snapshots/test_sensor.ambr @@ -0,0 +1,225 @@ +# serializer version: 1 +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase-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': None, + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous current R phase', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_current_r_phase', + 'unique_id': '01234567890123456789012345F789_instantaneous_current_r_phase', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous current R phase', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase-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': None, + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous current T phase', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_current_t_phase', + 'unique_id': '01234567890123456789012345F789_instantaneous_current_t_phase', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous current T phase', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_t_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power-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': None, + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Instantaneous power', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'instantaneous_power', + 'unique_id': '01234567890123456789012345F789_instantaneous_power', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Instantaneous power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption-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': None, + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total consumption', + 'platform': 'route_b_smart_meter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_consumption', + 'unique_id': '01234567890123456789012345F789_total_consumption', + 'unit_of_measurement': , + }) +# --- +# name: test_route_b_smart_meter_sensor_update[sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Route B Smart Meter 01234567890123456789012345F789 Total consumption', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.route_b_smart_meter_01234567890123456789012345f789_total_consumption', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4', + }) +# --- diff --git a/tests/components/route_b_smart_meter/test_config_flow.py b/tests/components/route_b_smart_meter/test_config_flow.py new file mode 100644 index 00000000000..d7dc84a9999 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_config_flow.py @@ -0,0 +1,111 @@ +"""Test the Smart Meter B-route config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from momonga import MomongaSkJoinFailure, MomongaSkScanFailure +import pytest +from serial.tools.list_ports_linux import SysFS + +from homeassistant.components.route_b_smart_meter.const import DOMAIN, ENTRY_TITLE +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_ID, CONF_PASSWORD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +def mock_comports() -> Generator[AsyncMock]: + """Override comports.""" + device = SysFS("/dev/ttyUSB42") + device.vid = 0x1234 + device.pid = 0x5678 + device.serial_number = "123456" + device.manufacturer = "Test" + device.description = "Test Device" + + with patch( + "homeassistant.components.route_b_smart_meter.config_flow.comports", + return_value=[SysFS("/dev/ttyUSB41"), device], + ) as mock: + yield mock + + +async def test_step_user_form( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_comports: AsyncMock, + mock_momonga: Mock, + user_input: dict[str, str], +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ENTRY_TITLE + assert result["data"] == user_input + assert result["result"].unique_id == user_input[CONF_ID] + mock_setup_entry.assert_called_once() + mock_comports.assert_called() + mock_momonga.assert_called_once_with( + dev=user_input[CONF_DEVICE], + rbid=user_input[CONF_ID], + pwd=user_input[CONF_PASSWORD], + ) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (MomongaSkJoinFailure, "invalid_auth"), + (MomongaSkScanFailure, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_step_user_form_errors( + hass: HomeAssistant, + error: Exception, + message: str, + mock_setup_entry: AsyncMock, + mock_comports: AsyncMock, + mock_momonga: AsyncMock, + user_input: dict[str, str], +) -> None: + """Test we handle error.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_momonga.side_effect = error + result_configure = await hass.config_entries.flow.async_configure( + result_init["flow_id"], + user_input, + ) + + assert result_configure["type"] is FlowResultType.FORM + assert result_configure["errors"] == {"base": message} + await hass.async_block_till_done() + mock_comports.assert_called() + mock_momonga.assert_called_once_with( + dev=user_input[CONF_DEVICE], + rbid=user_input[CONF_ID], + pwd=user_input[CONF_PASSWORD], + ) + + mock_momonga.side_effect = None + result = await hass.config_entries.flow.async_configure( + result_configure["flow_id"], + user_input, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == ENTRY_TITLE + assert result["data"] == user_input diff --git a/tests/components/route_b_smart_meter/test_init.py b/tests/components/route_b_smart_meter/test_init.py new file mode 100644 index 00000000000..644fda84886 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_init.py @@ -0,0 +1,19 @@ +"""Tests for the Smart Meter B Route integration init.""" + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry_success( + hass: HomeAssistant, mock_momonga, mock_config_entry: MockConfigEntry +) -> None: + """Test successful setup of entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert 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 diff --git a/tests/components/route_b_smart_meter/test_sensor.py b/tests/components/route_b_smart_meter/test_sensor.py new file mode 100644 index 00000000000..63d9cac0449 --- /dev/null +++ b/tests/components/route_b_smart_meter/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests for the Smart Meter B-Route sensor.""" + +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory +from momonga import MomongaError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.route_b_smart_meter.const import DEFAULT_SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_route_b_smart_meter_sensor_update( + hass: HomeAssistant, + mock_momonga: Mock, + freezer: FrozenDateTimeFactory, + entity_registry: EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the BRouteUpdateCoordinator successful behavior.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_route_b_smart_meter_sensor_no_update( + hass: HomeAssistant, + mock_momonga: Mock, + freezer: FrozenDateTimeFactory, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the BRouteUpdateCoordinator when failing.""" + + entity_id = "sensor.route_b_smart_meter_01234567890123456789012345f789_instantaneous_current_r_phase" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.states.get(entity_id) + assert entity.state == "1" + + mock_momonga.return_value.get_instantaneous_current.side_effect = MomongaError + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + entity = hass.states.get(entity_id) + assert entity.state is STATE_UNAVAILABLE