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
Scott K Logan 2024-01-05 07:00:54 -06:00 committed by GitHub
parent 824bb94d1d
commit f249563608
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1426 additions and 5 deletions

View File

@ -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.*

View File

@ -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

View File

@ -0,0 +1,5 @@
{
"domain": "rainforest_automation",
"name": "Rainforest Automation",
"integrations": ["rainforest_eagle", "rainforest_raven"]
}

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,3 @@
"""Constants for the Rainforest RAVEn integration."""
DEFAULT_NAME = "Rainforest RAVEn"
DOMAIN = "rainforest_raven"

View File

@ -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

View File

@ -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)
),
}

View File

@ -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"]
}
]
}

View File

@ -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, {})
)

View File

@ -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"
}
}
}
}

View File

@ -399,6 +399,7 @@ FLOWS = {
"radiotherm",
"rainbird",
"rainforest_eagle",
"rainforest_raven",
"rainmachine",
"rapt_ble",
"rdw",

View File

@ -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",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()],
},
)

View File

@ -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",
)

View File

@ -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"}

View File

@ -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()

View File

@ -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},
},
}

View File

@ -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)

View File

@ -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"] == "%"