Add Rainforest RAVEn integration (#80061)
* Add Rainforest RAVEn integration * Add Rainforest Automation brand * Add diagnostics to Rainforest RAVEn integration * Drop a test assertion for an undefined behavior * Add DEVICE_NAME test constant * Catch up with reality * Use Platform.SENSOR Co-authored-by: Robert Resch <robert@resch.dev> * Make rainforest_raven translatable * Stop setting device_class on unsupported scenarios * Rename rainforest_raven.data -> rainforest_raven.coordinator * Make _generate_unique_id more reusable * Move device synchronization into third party library * Switch from asyncio_timeout to asyncio.timeout * Ignore non-electric meters Co-authored-by: Robert Resch <robert@resch.dev> * Drop direct dependency on iso4217, bump aioraven * Use RAVEn-specific exceptions * Add timeouts to data updates * Move DeviceInfo generation from Sensor to Coordinator * Store meter macs as strings * Convert to using SelectSelector * Drop test_flow_user_invalid_mac This test isn't necessary now that SelectSelector is used. * Implement PR feedback - Split some long format lines - Simplify meter mac_id extraction in diagnostics - Expose unique_id using an attribute instead of a property - Add a comment about the meters dictionary shallow copy Co-authored-by: Erik Montnemery <erik@montnemery.com> * Simplify mac address redaction Co-authored-by: Joakim Sørensen <ludeeus@ludeeus.dev> * Freeze RAVEnSensorEntityDescription dataclass Co-authored-by: Erik Montnemery <erik@montnemery.com> --------- Co-authored-by: Robert Resch <robert@resch.dev> Co-authored-by: Erik Montnemery <erik@montnemery.com> Co-authored-by: Joakim Sørensen <ludeeus@ludeeus.dev>pull/107252/head
parent
824bb94d1d
commit
f249563608
|
@ -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.*
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"domain": "rainforest_automation",
|
||||
"name": "Rainforest Automation",
|
||||
"integrations": ["rainforest_eagle", "rainforest_raven"]
|
||||
}
|
|
@ -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
|
|
@ -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)
|
|
@ -0,0 +1,3 @@
|
|||
"""Constants for the Rainforest RAVEn integration."""
|
||||
DEFAULT_NAME = "Rainforest RAVEn"
|
||||
DOMAIN = "rainforest_raven"
|
|
@ -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
|
|
@ -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)
|
||||
),
|
||||
}
|
|
@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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, {})
|
||||
)
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -399,6 +399,7 @@ FLOWS = {
|
|||
"radiotherm",
|
||||
"rainbird",
|
||||
"rainforest_eagle",
|
||||
"rainforest_raven",
|
||||
"rainmachine",
|
||||
"rapt_ble",
|
||||
"rdw",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
10
mypy.ini
10
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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()],
|
||||
},
|
||||
)
|
|
@ -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",
|
||||
)
|
|
@ -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"}
|
|
@ -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()
|
|
@ -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},
|
||||
},
|
||||
}
|
|
@ -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)
|
|
@ -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"] == "%"
|
Loading…
Reference in New Issue