diff --git a/.strict-typing b/.strict-typing index e46f439e4ca..be595c52a23 100644 --- a/.strict-typing +++ b/.strict-typing @@ -308,6 +308,7 @@ homeassistant.components.pushbullet.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.radarr.* +homeassistant.components.rainforest_raven.* homeassistant.components.rainmachine.* homeassistant.components.raspberry_pi.* homeassistant.components.rdw.* diff --git a/CODEOWNERS b/CODEOWNERS index 21d692d2942..04dd08841a1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1047,6 +1047,8 @@ build.json @home-assistant/supervisor /homeassistant/components/raincloud/ @vanstinator /homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin /tests/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin +/homeassistant/components/rainforest_raven/ @cottsay +/tests/components/rainforest_raven/ @cottsay /homeassistant/components/rainmachine/ @bachya /tests/components/rainmachine/ @bachya /homeassistant/components/random/ @fabaff diff --git a/homeassistant/brands/rainforest.json b/homeassistant/brands/rainforest.json new file mode 100644 index 00000000000..6d04a4bf2d1 --- /dev/null +++ b/homeassistant/brands/rainforest.json @@ -0,0 +1,5 @@ +{ + "domain": "rainforest_automation", + "name": "Rainforest Automation", + "integrations": ["rainforest_eagle", "rainforest_raven"] +} diff --git a/homeassistant/components/rainforest_raven/__init__.py b/homeassistant/components/rainforest_raven/__init__.py new file mode 100644 index 00000000000..d72b12f68c6 --- /dev/null +++ b/homeassistant/components/rainforest_raven/__init__.py @@ -0,0 +1,29 @@ +"""Integration for Rainforest RAVEn devices.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RAVEnDataCoordinator + +PLATFORMS = (Platform.SENSOR,) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Rainforest RAVEn device from a config entry.""" + coordinator = RAVEnDataCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/rainforest_raven/config_flow.py b/homeassistant/components/rainforest_raven/config_flow.py new file mode 100644 index 00000000000..cd8ce68c7e7 --- /dev/null +++ b/homeassistant/components/rainforest_raven/config_flow.py @@ -0,0 +1,158 @@ +"""Config flow for Rainforest RAVEn devices.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from aioraven.data import MeterType +from aioraven.device import RAVEnConnectionError +from aioraven.serial import RAVEnSerialDevice +import serial.tools.list_ports +from serial.tools.list_ports_common import ListPortInfo +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import usb +from homeassistant.const import CONF_DEVICE, CONF_MAC, CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DEFAULT_NAME, DOMAIN + + +def _format_id(value: str | int) -> str: + if isinstance(value, str): + return value + return f"{value or 0:04X}" + + +def _generate_unique_id(info: ListPortInfo | usb.UsbServiceInfo) -> str: + """Generate unique id from usb attributes.""" + return ( + f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}" + f"_{info.manufacturer}_{info.description}" + ) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Rainforest RAVEn devices.""" + + def __init__(self) -> None: + """Set up flow instance.""" + self._dev_path: str | None = None + self._meter_macs: set[str] = set() + + async def _validate_device(self, dev_path: str) -> None: + self._abort_if_unique_id_configured(updates={CONF_DEVICE: dev_path}) + async with ( + asyncio.timeout(5), + RAVEnSerialDevice(dev_path) as raven_device, + ): + await raven_device.synchronize() + meters = await raven_device.get_meter_list() + if meters: + for meter in meters.meter_mac_ids or (): + meter_info = await raven_device.get_meter_info(meter=meter) + if meter_info and ( + meter_info.meter_type is None + or meter_info.meter_type == MeterType.ELECTRIC + ): + self._meter_macs.add(meter.hex()) + self._dev_path = dev_path + + async def async_step_meters( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Connect to device and discover meters.""" + errors: dict[str, str] = {} + if user_input is not None: + meter_macs = [] + for raw_mac in user_input.get(CONF_MAC, ()): + mac = bytes.fromhex(raw_mac).hex() + if mac not in meter_macs: + meter_macs.append(mac) + if meter_macs and not errors: + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={ + CONF_DEVICE: self._dev_path, + CONF_MAC: meter_macs, + }, + ) + + schema = vol.Schema( + { + vol.Required(CONF_MAC): SelectSelector( + SelectSelectorConfig( + options=sorted(self._meter_macs), + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + translation_key=CONF_MAC, + ) + ), + } + ) + return self.async_show_form(step_id="meters", data_schema=schema, errors=errors) + + async def async_step_usb(self, discovery_info: usb.UsbServiceInfo) -> FlowResult: + """Handle USB Discovery.""" + device = discovery_info.device + dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device) + unique_id = _generate_unique_id(discovery_info) + await self.async_set_unique_id(unique_id) + try: + await self._validate_device(dev_path) + except asyncio.TimeoutError: + return self.async_abort(reason="timeout_connect") + except RAVEnConnectionError: + return self.async_abort(reason="cannot_connect") + return await self.async_step_meters() + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + if self._async_in_progress(): + return self.async_abort(reason="already_in_progress") + ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports) + existing_devices = [ + entry.data[CONF_DEVICE] for entry in self._async_current_entries() + ] + unused_ports = [ + usb.human_readable_device_name( + port.device, + port.serial_number, + port.manufacturer, + port.description, + port.vid, + port.pid, + ) + for port in ports + if port.device not in existing_devices + ] + if not unused_ports: + return self.async_abort(reason="no_devices_found") + + errors = {} + if user_input is not None and user_input.get(CONF_DEVICE, "").strip(): + port = ports[unused_ports.index(str(user_input[CONF_DEVICE]))] + dev_path = await self.hass.async_add_executor_job( + usb.get_serial_by_id, port.device + ) + unique_id = _generate_unique_id(port) + await self.async_set_unique_id(unique_id) + try: + await self._validate_device(dev_path) + except asyncio.TimeoutError: + errors[CONF_DEVICE] = "timeout_connect" + except RAVEnConnectionError: + errors[CONF_DEVICE] = "cannot_connect" + else: + return await self.async_step_meters() + + schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)}) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/rainforest_raven/const.py b/homeassistant/components/rainforest_raven/const.py new file mode 100644 index 00000000000..a5269ddbc26 --- /dev/null +++ b/homeassistant/components/rainforest_raven/const.py @@ -0,0 +1,3 @@ +"""Constants for the Rainforest RAVEn integration.""" +DEFAULT_NAME = "Rainforest RAVEn" +DOMAIN = "rainforest_raven" diff --git a/homeassistant/components/rainforest_raven/coordinator.py b/homeassistant/components/rainforest_raven/coordinator.py new file mode 100644 index 00000000000..edae4f11433 --- /dev/null +++ b/homeassistant/components/rainforest_raven/coordinator.py @@ -0,0 +1,163 @@ +"""Data update coordination for Rainforest RAVEn devices.""" +from __future__ import annotations + +import asyncio +from dataclasses import asdict +from datetime import timedelta +import logging +from typing import Any + +from aioraven.data import DeviceInfo as RAVEnDeviceInfo +from aioraven.device import RAVEnConnectionError +from aioraven.serial import RAVEnSerialDevice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def _get_meter_data( + device: RAVEnSerialDevice, meter: bytes +) -> dict[str, dict[str, Any]]: + data = {} + + sum_info = await device.get_current_summation_delivered(meter=meter) + demand_info = await device.get_instantaneous_demand(meter=meter) + price_info = await device.get_current_price(meter=meter) + + if sum_info and sum_info.meter_mac_id == meter: + data["CurrentSummationDelivered"] = asdict(sum_info) + + if demand_info and demand_info.meter_mac_id == meter: + data["InstantaneousDemand"] = asdict(demand_info) + + if price_info and price_info.meter_mac_id == meter: + data["PriceCluster"] = asdict(price_info) + + return data + + +async def _get_all_data( + device: RAVEnSerialDevice, meter_macs: list[str] +) -> dict[str, dict[str, Any]]: + data: dict[str, dict[str, Any]] = {"Meters": {}} + + for meter_mac in meter_macs: + data["Meters"][meter_mac] = await _get_meter_data( + device, bytes.fromhex(meter_mac) + ) + + network_info = await device.get_network_info() + + if network_info and network_info.link_strength: + data["NetworkInfo"] = asdict(network_info) + + return data + + +class RAVEnDataCoordinator(DataUpdateCoordinator): + """Communication coordinator for a Rainforest RAVEn device.""" + + _raven_device: RAVEnSerialDevice | None = None + _device_info: RAVEnDeviceInfo | None = None + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + self.entry = entry + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + @property + def device_fw_version(self) -> str | None: + """Return the firmware version of the device.""" + if self._device_info: + return self._device_info.fw_version + return None + + @property + def device_hw_version(self) -> str | None: + """Return the hardware version of the device.""" + if self._device_info: + return self._device_info.hw_version + return None + + @property + def device_mac_address(self) -> str | None: + """Return the MAC address of the device.""" + if self._device_info and self._device_info.device_mac_id: + return self._device_info.device_mac_id.hex() + return None + + @property + def device_manufacturer(self) -> str | None: + """Return the manufacturer of the device.""" + if self._device_info: + return self._device_info.manufacturer + return None + + @property + def device_model(self) -> str | None: + """Return the model of the device.""" + if self._device_info: + return self._device_info.model_id + return None + + @property + def device_name(self) -> str: + """Return the product name of the device.""" + return "RAVEn Device" + + @property + def device_info(self) -> DeviceInfo | None: + """Return device info.""" + if self._device_info and self.device_mac_address: + return DeviceInfo( + identifiers={(DOMAIN, self.device_mac_address)}, + manufacturer=self.device_manufacturer, + model=self.device_model, + name=self.device_name, + sw_version=self.device_fw_version, + hw_version=self.device_hw_version, + ) + return None + + async def _async_update_data(self) -> dict[str, Any]: + try: + device = await self._get_device() + async with asyncio.timeout(5): + return await _get_all_data(device, self.entry.data[CONF_MAC]) + except RAVEnConnectionError as err: + if self._raven_device: + await self._raven_device.close() + self._raven_device = None + raise UpdateFailed(f"RAVEnConnectionError: {err}") from err + + async def _get_device(self) -> RAVEnSerialDevice: + if self._raven_device is not None: + return self._raven_device + + device = RAVEnSerialDevice(self.entry.data[CONF_DEVICE]) + + async with asyncio.timeout(5): + await device.open() + + try: + await device.synchronize() + self._device_info = await device.get_device_info() + except Exception: + await device.close() + raise + + self._raven_device = device + return device diff --git a/homeassistant/components/rainforest_raven/diagnostics.py b/homeassistant/components/rainforest_raven/diagnostics.py new file mode 100644 index 00000000000..970915888ec --- /dev/null +++ b/homeassistant/components/rainforest_raven/diagnostics.py @@ -0,0 +1,43 @@ +"""Diagnostics support for a Rainforest RAVEn device.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN +from .coordinator import RAVEnDataCoordinator + +TO_REDACT_CONFIG = {CONF_MAC} +TO_REDACT_DATA = {"device_mac_id", "meter_mac_id"} + + +@callback +def async_redact_meter_macs(data: dict) -> dict: + """Redact meter MAC addresses from mapping keys.""" + if not data.get("Meters"): + return data + + redacted = {**data, "Meters": {}} + for idx, mac_id in enumerate(data["Meters"]): + redacted["Meters"][f"**REDACTED{idx}**"] = data["Meters"][mac_id] + + return redacted + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> Mapping[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: RAVEnDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + "config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT_CONFIG), + "data": async_redact_meter_macs( + async_redact_data(coordinator.data, TO_REDACT_DATA) + ), + } diff --git a/homeassistant/components/rainforest_raven/manifest.json b/homeassistant/components/rainforest_raven/manifest.json new file mode 100644 index 00000000000..900c947821d --- /dev/null +++ b/homeassistant/components/rainforest_raven/manifest.json @@ -0,0 +1,26 @@ +{ + "domain": "rainforest_raven", + "name": "Rainforest RAVEn", + "codeowners": ["@cottsay"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/rainforest_raven", + "iot_class": "local_polling", + "requirements": ["aioraven==0.5.0"], + "usb": [ + { + "vid": "0403", + "pid": "8A28", + "manufacturer": "*rainforest*", + "description": "*raven*", + "known_devices": ["Rainforest RAVEn"] + }, + { + "vid": "04B4", + "pid": "0003", + "manufacturer": "*rainforest*", + "description": "*emu-2*", + "known_devices": ["Rainforest EMU-2"] + } + ] +} diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py new file mode 100644 index 00000000000..731b511fe90 --- /dev/null +++ b/homeassistant/components/rainforest_raven/sensor.py @@ -0,0 +1,186 @@ +"""Sensor entity for a Rainforest RAVEn device.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, + StateType, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_MAC, + PERCENTAGE, + EntityCategory, + UnitOfEnergy, + UnitOfPower, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import RAVEnDataCoordinator + + +@dataclass(frozen=True) +class RAVEnSensorEntityDescription(SensorEntityDescription): + """A class that describes RAVEn sensor entities.""" + + message_key: str | None = None + attribute_keys: list[str] | None = None + + +SENSORS = ( + RAVEnSensorEntityDescription( + message_key="CurrentSummationDelivered", + translation_key="total_energy_delivered", + key="summation_delivered", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + RAVEnSensorEntityDescription( + message_key="CurrentSummationDelivered", + translation_key="total_energy_received", + key="summation_received", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + RAVEnSensorEntityDescription( + message_key="InstantaneousDemand", + translation_key="power_demand", + key="demand", + native_unit_of_measurement=UnitOfPower.KILO_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +DIAGNOSTICS = ( + RAVEnSensorEntityDescription( + message_key="NetworkInfo", + translation_key="signal_strength", + key="link_strength", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:wifi", + entity_category=EntityCategory.DIAGNOSTIC, + attribute_keys=[ + "channel", + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + entities: list[RAVEnSensor] = [ + RAVEnSensor(coordinator, description) for description in DIAGNOSTICS + ] + + for meter_mac_addr in entry.data[CONF_MAC]: + entities.extend( + RAVEnMeterSensor(coordinator, description, meter_mac_addr) + for description in SENSORS + ) + + meter_data = coordinator.data.get("Meters", {}).get(meter_mac_addr) or {} + if meter_data.get("PriceCluster", {}).get("currency"): + entities.append( + RAVEnMeterSensor( + coordinator, + RAVEnSensorEntityDescription( + message_key="PriceCluster", + translation_key="meter_price", + key="price", + native_unit_of_measurement=f"{meter_data['PriceCluster']['currency'].value}/{UnitOfEnergy.KILO_WATT_HOUR}", + icon="mdi:cash", + state_class=SensorStateClass.MEASUREMENT, + attribute_keys=[ + "tier", + "rate_label", + ], + ), + meter_mac_addr, + ) + ) + + async_add_entities(entities) + + +class RAVEnSensor(CoordinatorEntity[RAVEnDataCoordinator], SensorEntity): + """Rainforest RAVEn Sensor.""" + + _attr_has_entity_name = True + entity_description: RAVEnSensorEntityDescription + + def __init__( + self, + coordinator: RAVEnDataCoordinator, + entity_description: RAVEnSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_device_info = coordinator.device_info + self._attr_unique_id = ( + f"{self.coordinator.device_mac_address}" + f".{self.entity_description.message_key}.{self.entity_description.key}" + ) + + @property + def _data(self) -> Any: + """Return the raw sensor data from the source.""" + return self.coordinator.data.get(self.entity_description.message_key, {}) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return entity specific state attributes.""" + if self.entity_description.attribute_keys: + return { + key: self._data.get(key) + for key in self.entity_description.attribute_keys + } + return None + + @property + def native_value(self) -> StateType: + """Return native value of the sensor.""" + return str(self._data.get(self.entity_description.key)) + + +class RAVEnMeterSensor(RAVEnSensor): + """Rainforest RAVEn Meter Sensor.""" + + def __init__( + self, + coordinator: RAVEnDataCoordinator, + entity_description: RAVEnSensorEntityDescription, + meter_mac_addr: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, entity_description) + self._meter_mac_addr = meter_mac_addr + self._attr_unique_id = ( + f"{self._meter_mac_addr}" + f".{self.entity_description.message_key}.{self.entity_description.key}" + ) + + @property + def _data(self) -> Any: + """Return the raw sensor data from the source.""" + return ( + self.coordinator.data.get("Meters", {}) + .get(self._meter_mac_addr, {}) + .get(self.entity_description.message_key, {}) + ) diff --git a/homeassistant/components/rainforest_raven/strings.json b/homeassistant/components/rainforest_raven/strings.json new file mode 100644 index 00000000000..fb667d64d3f --- /dev/null +++ b/homeassistant/components/rainforest_raven/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "no_devices_found": "No compatible devices found" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" + }, + "step": { + "meters": { + "data": { + "mac": "Meter MAC Addresses" + } + }, + "user": { + "data": { + "device": "[%key:common::config_flow::data::device%]" + } + } + } + }, + "entity": { + "sensor": { + "meter_price": { + "name": "Meter price", + "state_attributes": { + "rate_label": { "name": "Rate" }, + "tier": { "name": "Tier" } + } + }, + "power_demand": { + "name": "Meter power demand" + }, + "signal_strength": { + "name": "Meter signal strength", + "state_attributes": { + "channel": { "name": "Channel" } + } + }, + "total_energy_delivered": { + "name": "Total meter energy delivered" + }, + "total_energy_received": { + "name": "Total meter energy received" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 254a3ad0df3..cd9b3c6a982 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -399,6 +399,7 @@ FLOWS = { "radiotherm", "rainbird", "rainforest_eagle", + "rainforest_raven", "rainmachine", "rapt_ble", "rdw", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6df3dc5cbd6..f9427b8f336 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4688,11 +4688,22 @@ "config_flow": true, "iot_class": "local_polling" }, - "rainforest_eagle": { - "name": "Rainforest Eagle", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "rainforest": { + "name": "Rainforest Automation", + "integrations": { + "rainforest_eagle": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Rainforest Eagle" + }, + "rainforest_raven": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Rainforest RAVEn" + } + } }, "rainmachine": { "name": "RainMachine", diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 2fdd032c2dd..ce40f481d96 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -19,6 +19,20 @@ USB = [ "pid": "1340", "vid": "0572", }, + { + "description": "*raven*", + "domain": "rainforest_raven", + "manufacturer": "*rainforest*", + "pid": "8A28", + "vid": "0403", + }, + { + "description": "*emu-2*", + "domain": "rainforest_raven", + "manufacturer": "*rainforest*", + "pid": "0003", + "vid": "04B4", + }, { "domain": "velbus", "pid": "0B1B", diff --git a/mypy.ini b/mypy.ini index 53f5b0715ce..6e2630813e6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2841,6 +2841,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.rainforest_raven.*] +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.rainmachine.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 00206ed2bf2..37c220b576c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -340,6 +340,9 @@ aiopyarr==23.4.0 # homeassistant.components.qnap_qsw aioqsw==0.3.5 +# homeassistant.components.rainforest_raven +aioraven==0.5.0 + # homeassistant.components.recollect_waste aiorecollect==2023.09.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95c2ebfd704..c8a873370ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -313,6 +313,9 @@ aiopyarr==23.4.0 # homeassistant.components.qnap_qsw aioqsw==0.3.5 +# homeassistant.components.rainforest_raven +aioraven==0.5.0 + # homeassistant.components.recollect_waste aiorecollect==2023.09.0 diff --git a/tests/components/rainforest_raven/__init__.py b/tests/components/rainforest_raven/__init__.py new file mode 100644 index 00000000000..0269e4cf0f4 --- /dev/null +++ b/tests/components/rainforest_raven/__init__.py @@ -0,0 +1,44 @@ +"""Tests for the Rainforest RAVEn component.""" + +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.const import CONF_DEVICE, CONF_MAC + +from .const import ( + DEMAND, + DEVICE_INFO, + DISCOVERY_INFO, + METER_INFO, + METER_LIST, + NETWORK_INFO, + PRICE_CLUSTER, + SUMMATION, +) + +from tests.common import AsyncMock, MockConfigEntry + + +def create_mock_device(): + """Create a mock instance of RAVEnStreamDevice.""" + device = AsyncMock() + + device.__aenter__.return_value = device + device.get_current_price.return_value = PRICE_CLUSTER + device.get_current_summation_delivered.return_value = SUMMATION + device.get_device_info.return_value = DEVICE_INFO + device.get_instantaneous_demand.return_value = DEMAND + device.get_meter_list.return_value = METER_LIST + device.get_meter_info.side_effect = lambda meter: METER_INFO.get(meter) + device.get_network_info.return_value = NETWORK_INFO + + return device + + +def create_mock_entry(no_meters=False): + """Create a mock config entry for a RAVEn device.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_DEVICE: DISCOVERY_INFO.device, + CONF_MAC: [] if no_meters else [METER_INFO[None].meter_mac_id.hex()], + }, + ) diff --git a/tests/components/rainforest_raven/const.py b/tests/components/rainforest_raven/const.py new file mode 100644 index 00000000000..7e75440c30d --- /dev/null +++ b/tests/components/rainforest_raven/const.py @@ -0,0 +1,132 @@ +"""Constants for the Rainforest RAVEn tests.""" + +from aioraven.data import ( + CurrentSummationDelivered, + DeviceInfo, + InstantaneousDemand, + MeterInfo, + MeterList, + MeterType, + NetworkInfo, + PriceCluster, +) +from iso4217 import Currency + +from homeassistant.components import usb + +DISCOVERY_INFO = usb.UsbServiceInfo( + device="/dev/ttyACM0", + pid="0x0003", + vid="0x04B4", + serial_number="1234", + description="RFA-Z105-2 HW2.7.3 EMU-2", + manufacturer="Rainforest Automation, Inc.", +) + + +DEVICE_NAME = usb.human_readable_device_name( + DISCOVERY_INFO.device, + DISCOVERY_INFO.serial_number, + DISCOVERY_INFO.manufacturer, + DISCOVERY_INFO.description, + int(DISCOVERY_INFO.vid, 0), + int(DISCOVERY_INFO.pid, 0), +) + + +DEVICE_INFO = DeviceInfo( + device_mac_id=bytes.fromhex("abcdef0123456789"), + install_code=None, + link_key=None, + fw_version="2.0.0 (7400)", + hw_version="2.7.3", + image_type=None, + manufacturer=DISCOVERY_INFO.manufacturer, + model_id="Z105-2-EMU2-LEDD_JM", + date_code=None, +) + + +METER_LIST = MeterList( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_ids=[ + bytes.fromhex("1234567890abcdef"), + bytes.fromhex("9876543210abcdef"), + ], +) + + +METER_INFO = { + None: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[0], + meter_type=MeterType.ELECTRIC, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), + METER_LIST.meter_mac_ids[0]: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[0], + meter_type=MeterType.ELECTRIC, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), + METER_LIST.meter_mac_ids[1]: MeterInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_LIST.meter_mac_ids[1], + meter_type=MeterType.GAS, + nick_name=None, + account=None, + auth=None, + host=None, + enabled=True, + ), +} + + +NETWORK_INFO = NetworkInfo( + device_mac_id=DEVICE_INFO.device_mac_id, + coord_mac_id=None, + status=None, + description=None, + status_code=None, + ext_pan_id=None, + channel=13, + short_addr=None, + link_strength=100, +) + + +PRICE_CLUSTER = PriceCluster( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + price="0.10", + currency=Currency.usd, + tier=3, + tier_label="Set by user", + rate_label="Set by user", +) + + +SUMMATION = CurrentSummationDelivered( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + summation_delivered="23456.7890", + summation_received="00000.0000", +) + + +DEMAND = InstantaneousDemand( + device_mac_id=DEVICE_INFO.device_mac_id, + meter_mac_id=METER_INFO[None].meter_mac_id, + time_stamp=None, + demand="1.2345", +) diff --git a/tests/components/rainforest_raven/test_config_flow.py b/tests/components/rainforest_raven/test_config_flow.py new file mode 100644 index 00000000000..7ec6c52349c --- /dev/null +++ b/tests/components/rainforest_raven/test_config_flow.py @@ -0,0 +1,238 @@ +"""Test Rainforest RAVEn config flow.""" +import asyncio +from unittest.mock import patch + +from aioraven.device import RAVEnConnectionError +import pytest +import serial.tools.list_ports + +from homeassistant import data_entry_flow +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.config_entries import SOURCE_USB, SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_MAC, CONF_SOURCE +from homeassistant.core import HomeAssistant + +from . import create_mock_device +from .const import DEVICE_NAME, DISCOVERY_INFO, METER_LIST + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.config_flow.RAVEnSerialDevice", + return_value=device, + ): + yield device + + +@pytest.fixture +def mock_device_no_open(mock_device): + """Mock a device which fails to open.""" + mock_device.__aenter__.side_effect = RAVEnConnectionError + mock_device.open.side_effect = RAVEnConnectionError + return mock_device + + +@pytest.fixture +def mock_device_comm_error(mock_device): + """Mock a device which fails to read or parse raw data.""" + mock_device.get_meter_list.side_effect = RAVEnConnectionError + mock_device.get_meter_info.side_effect = RAVEnConnectionError + return mock_device + + +@pytest.fixture +def mock_device_timeout(mock_device): + """Mock a device which times out when queried.""" + mock_device.get_meter_list.side_effect = asyncio.TimeoutError + mock_device.get_meter_info.side_effect = asyncio.TimeoutError + return mock_device + + +@pytest.fixture +def mock_comports(): + """Mock serial port list.""" + port = serial.tools.list_ports_common.ListPortInfo(DISCOVERY_INFO.device) + port.serial_number = DISCOVERY_INFO.serial_number + port.manufacturer = DISCOVERY_INFO.manufacturer + port.device = DISCOVERY_INFO.device + port.description = DISCOVERY_INFO.description + port.pid = int(DISCOVERY_INFO.pid, 0) + port.vid = int(DISCOVERY_INFO.vid, 0) + comports = [port] + with patch("serial.tools.list_ports.comports", return_value=comports): + yield comports + + +async def test_flow_usb(hass: HomeAssistant, mock_comports, mock_device): + """Test usb flow connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "meters" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_MAC: [METER_LIST.meter_mac_ids[0].hex()]} + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_flow_usb_cannot_connect( + hass: HomeAssistant, mock_comports, mock_device_no_open +): + """Test usb flow connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_flow_usb_timeout_connect( + hass: HomeAssistant, mock_comports, mock_device_timeout +): + """Test usb flow connection timeout.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "timeout_connect" + + +async def test_flow_usb_comm_error( + hass: HomeAssistant, mock_comports, mock_device_comm_error +): + """Test usb flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USB}, data=DISCOVERY_INFO + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_flow_user(hass: HomeAssistant, mock_comports, mock_device): + """Test user flow connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "meters" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_MAC: [METER_LIST.meter_mac_ids[0].hex()]} + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.CREATE_ENTRY + + +async def test_flow_user_no_available_devices(hass: HomeAssistant, mock_comports): + """Test user flow with no available devices.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: DISCOVERY_INFO.device}, + ) + entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "no_devices_found" + + +async def test_flow_user_in_progress(hass: HomeAssistant, mock_comports): + """Test user flow with no available devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert not result.get("errors") + assert result.get("flow_id") + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.ABORT + assert result.get("reason") == "already_in_progress" + + +async def test_flow_user_cannot_connect( + hass: HomeAssistant, mock_comports, mock_device_no_open +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "cannot_connect"} + + +async def test_flow_user_timeout_connect( + hass: HomeAssistant, mock_comports, mock_device_timeout +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "timeout_connect"} + + +async def test_flow_user_comm_error( + hass: HomeAssistant, mock_comports, mock_device_comm_error +): + """Test user flow connection failure to communicate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + data={ + CONF_DEVICE: DEVICE_NAME, + }, + ) + assert result + assert result.get("type") == data_entry_flow.FlowResultType.FORM + assert result.get("errors") == {CONF_DEVICE: "cannot_connect"} diff --git a/tests/components/rainforest_raven/test_coordinator.py b/tests/components/rainforest_raven/test_coordinator.py new file mode 100644 index 00000000000..6b29c944aeb --- /dev/null +++ b/tests/components/rainforest_raven/test_coordinator.py @@ -0,0 +1,93 @@ +"""Tests for the Rainforest RAVEn data coordinator.""" +from aioraven.device import RAVEnConnectionError +import pytest + +from homeassistant.components.rainforest_raven.coordinator import RAVEnDataCoordinator +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +async def test_coordinator_device_info(hass: HomeAssistant, mock_device): + """Test reporting device information from the coordinator.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + assert coordinator.device_fw_version is None + assert coordinator.device_hw_version is None + assert coordinator.device_info is None + assert coordinator.device_mac_address is None + assert coordinator.device_manufacturer is None + assert coordinator.device_model is None + assert coordinator.device_name == "RAVEn Device" + + await coordinator.async_config_entry_first_refresh() + + assert coordinator.device_fw_version == "2.0.0 (7400)" + assert coordinator.device_hw_version == "2.7.3" + assert coordinator.device_info + assert coordinator.device_mac_address + assert coordinator.device_manufacturer == "Rainforest Automation, Inc." + assert coordinator.device_model == "Z105-2-EMU2-LEDD_JM" + assert coordinator.device_name == "RAVEn Device" + + +async def test_coordinator_cache_device(hass: HomeAssistant, mock_device): + """Test that the device isn't re-opened for subsequent refreshes.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + assert mock_device.get_network_info.call_count == 1 + assert mock_device.open.call_count == 1 + + await coordinator.async_refresh() + assert mock_device.get_network_info.call_count == 2 + assert mock_device.open.call_count == 1 + + +async def test_coordinator_device_error_setup(hass: HomeAssistant, mock_device): + """Test handling of a device error during initialization.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + mock_device.get_network_info.side_effect = RAVEnConnectionError + with pytest.raises(ConfigEntryNotReady): + await coordinator.async_config_entry_first_refresh() + + +async def test_coordinator_device_error_update(hass: HomeAssistant, mock_device): + """Test handling of a device error during an update.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + assert coordinator.last_update_success is True + + mock_device.get_network_info.side_effect = RAVEnConnectionError + await coordinator.async_refresh() + assert coordinator.last_update_success is False + + +async def test_coordinator_comm_error(hass: HomeAssistant, mock_device): + """Test handling of an error parsing or reading raw device data.""" + entry = create_mock_entry() + coordinator = RAVEnDataCoordinator(hass, entry) + + mock_device.synchronize.side_effect = RAVEnConnectionError + with pytest.raises(ConfigEntryNotReady): + await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/rainforest_raven/test_diagnostics.py b/tests/components/rainforest_raven/test_diagnostics.py new file mode 100644 index 00000000000..639eacadc76 --- /dev/null +++ b/tests/components/rainforest_raven/test_diagnostics.py @@ -0,0 +1,103 @@ +"""Test the Rainforest Eagle diagnostics.""" +from dataclasses import asdict + +import pytest + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.const import CONF_MAC +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry +from .const import DEMAND, NETWORK_INFO, PRICE_CLUSTER, SUMMATION + +from tests.common import patch +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +@pytest.fixture +async def mock_entry_no_meters(hass: HomeAssistant, mock_device): + """Mock a RAVEn config entry with no meters.""" + mock_entry = create_mock_entry(True) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_entry_diagnostics_no_meters( + hass, hass_client, mock_device, mock_entry_no_meters +): + """Test RAVEn diagnostics before the coordinator has updated.""" + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_entry_no_meters + ) + + config_entry_dict = mock_entry_no_meters.as_dict() + config_entry_dict["data"][CONF_MAC] = REDACTED + + assert result == { + "config_entry": config_entry_dict, + "data": { + "Meters": {}, + "NetworkInfo": {**asdict(NETWORK_INFO), "device_mac_id": REDACTED}, + }, + } + + +async def test_entry_diagnostics(hass, hass_client, mock_device, mock_entry): + """Test RAVEn diagnostics.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, mock_entry) + + config_entry_dict = mock_entry.as_dict() + config_entry_dict["data"][CONF_MAC] = REDACTED + + assert result == { + "config_entry": config_entry_dict, + "data": { + "Meters": { + "**REDACTED0**": { + "CurrentSummationDelivered": { + **asdict(SUMMATION), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + }, + "InstantaneousDemand": { + **asdict(DEMAND), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + }, + "PriceCluster": { + **asdict(PRICE_CLUSTER), + "device_mac_id": REDACTED, + "meter_mac_id": REDACTED, + "currency": { + "__type": str(type(PRICE_CLUSTER.currency)), + "repr": repr(PRICE_CLUSTER.currency), + }, + }, + }, + }, + "NetworkInfo": {**asdict(NETWORK_INFO), "device_mac_id": REDACTED}, + }, + } diff --git a/tests/components/rainforest_raven/test_init.py b/tests/components/rainforest_raven/test_init.py new file mode 100644 index 00000000000..b99d94f4b43 --- /dev/null +++ b/tests/components/rainforest_raven/test_init.py @@ -0,0 +1,43 @@ +"""Tests for the Rainforest RAVEn component initialisation.""" +import pytest + +from homeassistant.components.rainforest_raven.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_load_unload_entry(hass: HomeAssistant, mock_entry): + """Test load and unload.""" + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/rainforest_raven/test_sensor.py b/tests/components/rainforest_raven/test_sensor.py new file mode 100644 index 00000000000..e637e22ecf9 --- /dev/null +++ b/tests/components/rainforest_raven/test_sensor.py @@ -0,0 +1,59 @@ +"""Tests for the Rainforest RAVEn sensors.""" +import pytest + +from homeassistant.core import HomeAssistant + +from . import create_mock_device, create_mock_entry + +from tests.common import patch + + +@pytest.fixture +def mock_device(): + """Mock a functioning RAVEn device.""" + mock_device = create_mock_device() + with patch( + "homeassistant.components.rainforest_raven.coordinator.RAVEnSerialDevice", + return_value=mock_device, + ): + yield mock_device + + +@pytest.fixture +async def mock_entry(hass: HomeAssistant, mock_device): + """Mock a functioning RAVEn config entry.""" + mock_entry = create_mock_entry() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + return mock_entry + + +async def test_sensors(hass: HomeAssistant, mock_device, mock_entry): + """Test the sensors.""" + assert len(hass.states.async_all()) == 5 + + demand = hass.states.get("sensor.raven_device_meter_power_demand") + assert demand is not None + assert demand.state == "1.2345" + assert demand.attributes["unit_of_measurement"] == "kW" + + delivered = hass.states.get("sensor.raven_device_total_meter_energy_delivered") + assert delivered is not None + assert delivered.state == "23456.7890" + assert delivered.attributes["unit_of_measurement"] == "kWh" + + received = hass.states.get("sensor.raven_device_total_meter_energy_received") + assert received is not None + assert received.state == "00000.0000" + assert received.attributes["unit_of_measurement"] == "kWh" + + price = hass.states.get("sensor.raven_device_meter_price") + assert price is not None + assert price.state == "0.10" + assert price.attributes["unit_of_measurement"] == "USD/kWh" + + signal = hass.states.get("sensor.raven_device_meter_signal_strength") + assert signal is not None + assert signal.state == "100" + assert signal.attributes["unit_of_measurement"] == "%"