Skip to content

Commit

Permalink
Merge pull request #603 from canton7/feature/snapshot
Browse files Browse the repository at this point in the history
Introduce snapshot testing of entities
  • Loading branch information
canton7 authored Apr 26, 2024
2 parents 1e5ddef + 3ddb3ad commit 1b0828a
Show file tree
Hide file tree
Showing 28 changed files with 8,158 additions and 6 deletions.
5 changes: 4 additions & 1 deletion .devcontainer/setup
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ set -e

cd "$(dirname "$0")/.."

python3 -m pip install --requirement requirements.txt
python3 -m pip install -r requirements.txt
# json files on syrupy are broken before 4.0.4, but pytest-homeassistant-custom-component for our current HA version
# relies on 4.0.2
python3 -m pip install --no-deps syrupy==4.0.4

git config --global --fixed-value --replace-all safe.directory "${PWD}" "${PWD}"
3 changes: 3 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ jobs:
- name: Install Python modules
run: |
pip install --no-cache-dir -r requirements.txt
# json files on syrupy are broken before 4.0.4, but pytest-homeassistant-custom-component for our current HA
# version relies on 4.0.2
pip install --no-deps syrupy==4.0.4
- name: Run pre-commit on all files
run: |
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tests/__snapshots__
6 changes: 6 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
"type": "shell",
"command": "pre-commit run --all-files --color=always",
"problemMatcher": []
},
{
"label": "Update snapshots",
"type": "shell",
"command": "pytest --snapshot-update",
"problemMatcher": []
}
]
}
21 changes: 21 additions & 0 deletions custom_components/foxess_modbus/entities/entity_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from abc import ABC
from abc import abstractmethod
from typing import Any
from typing import Sequence

from homeassistant.helpers.entity import Entity
Expand Down Expand Up @@ -49,6 +50,10 @@ def create_entity_if_supported(
) -> Entity | None:
"""Instantiate a new entity. The returned type must match self.entity_type"""

@abstractmethod
def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
"""Serialize to a dict, used for snapshot testing."""

def _supports_inverter_model(
self,
address_specs: Sequence[InverterModelSpec],
Expand Down Expand Up @@ -116,3 +121,19 @@ def _addresses_for_inverter_model(
), f"{self}: more than one address spec defined for ({inverter_model}, {register_type})"
result = addresses
return result

def _addresses_for_serialization(
self, address_specs: Sequence[InverterModelSpec], inverter_model: Inv
) -> dict[str, list[int] | None] | None:
result: dict[str, list[int] | None] | None = None
for spec in address_specs:
address_type_map = spec.address_type_map_for_inverter_model(inverter_model)
for k, v in address_type_map.items():
if result is None:
result = {}

key = k.name.lower()
assert key not in result
result[key] = v

return result
17 changes: 17 additions & 0 deletions custom_components/foxess_modbus/entities/inverter_model_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
class InverterModelSpec(ABC):
"""Base class for specifications which describe which inverter models an entity supports"""

@abstractmethod
def address_type_map_for_inverter_model(self, models: Inv) -> dict[RegisterType, list[int] | None]:
"""
If this spec supports the given inverter model (e.g. "H1", return the dict of register type -> addresses)
which it cares about (or an empty dict if it doesn't rely on any addresses).
"""

@abstractmethod
def addresses_for_inverter_model(self, *, register_type: RegisterType, models: Inv) -> list[int] | None:
"""
Expand All @@ -32,6 +39,11 @@ def __init__(self, addresses: dict[RegisterType, list[int] | None], models: Inv)
self._addresses = addresses
self._models = models

def address_type_map_for_inverter_model(self, models: Inv) -> dict[RegisterType, list[int] | None]:
if models not in self._models:
return {}
return self._addresses

def addresses_for_inverter_model(self, *, register_type: RegisterType, models: Inv) -> list[int] | None:
if models not in self._models:
return None
Expand Down Expand Up @@ -81,5 +93,10 @@ def __init__(self, register_types: list[RegisterType], models: Inv) -> None:
self._register_types = register_types
self._models = models

def address_type_map_for_inverter_model(self, models: Inv) -> dict[RegisterType, list[int] | None]:
if models not in self._models:
return {}
return {x: None for x in self._register_types}

def addresses_for_inverter_model(self, register_type: RegisterType, models: Inv) -> list[int] | None:
return [] if register_type in self._register_types and models in self._models else None
13 changes: 13 additions & 0 deletions custom_components/foxess_modbus/entities/modbus_binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from typing import Callable
from typing import cast

Expand Down Expand Up @@ -44,6 +45,18 @@ def create_entity_if_supported(
address = self._address_for_inverter_model(self.address, inverter_model, register_type)
return ModbusBinarySensor(controller, self, address) if address is not None else None

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
address_map = self._addresses_for_serialization(self.address, inverter_model)
if address_map is None:
return None

return {
"type": "binary-sensor",
"key": self.key,
"name": self.name,
"addresses": address_map,
}


class ModbusBinarySensor(ModbusEntityMixin, BinarySensorEntity):
"""Modbus binary sensor"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from dataclasses import dataclass
from dataclasses import field
from datetime import time
from typing import Any
from typing import cast

from homeassistant.components.binary_sensor import BinarySensorDeviceClass
Expand Down Expand Up @@ -86,6 +87,19 @@ def create_entity_if_supported(
), f"{self}: address is {address} but other_address is None for ({inverter_model}, {register_type})"
return ModbusChargePeriodStartEndSensor(controller, self, address, other_address)

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
address_map = self._addresses_for_serialization(self.address, inverter_model)
if address_map is None:
return None

return {
"type": "charge-period-time",
"key": self.key,
"name": self.name,
"addresses": address_map,
"other_addresses": self._addresses_for_serialization(self.other_address, inverter_model),
}


class ModbusChargePeriodStartEndSensor(ModbusEntityMixin, RestoreEntity, SensorEntity):
"""Sensor used for the start/end of a charge time period"""
Expand Down Expand Up @@ -212,6 +226,20 @@ def create_entity_if_supported(
period_end_address,
)

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
start_address_map = self._addresses_for_serialization(self.period_start_address, inverter_model)
end_address_map = self._addresses_for_serialization(self.period_end_address, inverter_model)
if start_address_map is None or end_address_map is None:
return None

return {
"type": "charge-period-enabled",
"key": self.key,
"name": self.name,
"start_addresses": start_address_map,
"end_addresses": end_address_map,
}


class ModbusEnableForceChargeSensor(ModbusEntityMixin, BinarySensorEntity):
"""Sensor which synthesises an "enable force charge" state, based on the start/end time registers"""
Expand Down
14 changes: 14 additions & 0 deletions custom_components/foxess_modbus/entities/modbus_fault_sensor.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Decodes the fault registers"""

from dataclasses import dataclass
from typing import Any

from homeassistant.components.sensor import SensorEntity
from homeassistant.components.sensor import SensorEntityDescription
Expand Down Expand Up @@ -176,6 +177,19 @@ def create_entity_if_supported(
addresses = self._addresses_for_inverter_model(self.addresses, inverter_model, register_type)
return ModbusFaultSensor(controller, self, addresses) if addresses is not None else None

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
address_map = self._addresses_for_serialization(self.addresses, inverter_model)
if address_map is None:
return None

return {
"type": "fault-sensor",
"key": self.key,
"name": self.name,
"addresses": address_map,
"faults": _FAULTS,
}


class ModbusFaultSensor(ModbusEntityMixin, SensorEntity):
"""Sensor class."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
from dataclasses import dataclass
from typing import Any
from typing import cast

from homeassistant.components.integration.sensor import DEFAULT_ROUND
Expand Down Expand Up @@ -59,6 +60,21 @@ def create_entity_if_supported(
unit_time=self.unit_time,
)

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
address_map = self._addresses_for_serialization(self.models, inverter_model)
if address_map is None:
return None

return {
"type": "integration-sensor",
"key": self.key,
"name": self.name,
"register_types": address_map.keys(),
"method": self.integration_method,
"source": self.source_entity,
"unit_time": self.unit_time,
}


class ModbusIntegrationSensor(ModbusEntityMixin, IntegrationSensor):
"""Sensor class."""
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Decodes the fault registers"""

from dataclasses import dataclass
from typing import Any
from typing import cast

from homeassistant.components.sensor import SensorDeviceClass
Expand Down Expand Up @@ -58,6 +59,19 @@ def create_entity_if_supported(
address = self._address_for_inverter_model(self.address, inverter_model, register_type)
return ModbusInverterStateSensor(controller, self, address) if address is not None else None

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
address_map = self._addresses_for_serialization(self.address, inverter_model)
if address_map is None:
return None

return {
"type": "inverter-state-sensor",
"key": self.key,
"name": self.name,
"addresses": address_map,
"states": self.states,
}


class ModbusInverterStateSensor(ModbusEntityMixin, SensorEntity):
"""Sensor class."""
Expand Down Expand Up @@ -109,6 +123,18 @@ def create_entity_if_supported(
addresses = self._addresses_for_inverter_model(self.addresses, inverter_model, register_type)
return ModbusG2InverterStateSensor(controller, self, addresses) if addresses is not None else None

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
address_map = self._addresses_for_serialization(self.addresses, inverter_model)
if address_map is None:
return None

return {
"type": "inverter-state-sensor",
"key": self.key,
"name": self.name,
"addresses": address_map,
}


class ModbusG2InverterStateSensor(ModbusEntityMixin, SensorEntity):
"""Sensor class."""
Expand Down
13 changes: 13 additions & 0 deletions custom_components/foxess_modbus/entities/modbus_lambda_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ def create_entity_if_supported(
method=self.method,
)

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
address_map = self._addresses_for_serialization(self.models, inverter_model)
if address_map is None:
return None

return {
"type": "lambda",
"key": self.key,
"name": self.name,
"register_types": address_map.keys(),
"sources": self.sources,
}


class ModbusLambdaSensor(ModbusEntityMixin, SensorEntity):
"""Generates a value by applying a lambda to the values of a number of other sensors"""
Expand Down
14 changes: 14 additions & 0 deletions custom_components/foxess_modbus/entities/modbus_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import logging
from dataclasses import dataclass
from dataclasses import field
from typing import Any
from typing import Callable
from typing import cast

Expand Down Expand Up @@ -47,6 +48,19 @@ def create_entity_if_supported(
address = self._address_for_inverter_model(self.address, inverter_model, register_type)
return ModbusNumber(controller, self, address) if address is not None else None

def serialize(self, inverter_model: Inv) -> dict[str, Any] | None:
address_map = self._addresses_for_serialization(self.address, inverter_model)
if address_map is None:
return None

return {
"type": "number",
"key": self.key,
"name": self.name,
"addresses": address_map,
"scale": self.scale,
}


class ModbusNumber(ModbusEntityMixin, NumberEntity):
"""Number class"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
from dataclasses import dataclass
from typing import Any
from typing import Callable
from typing import cast

Expand Down Expand Up @@ -57,6 +58,9 @@ def create_entity_if_supported(
)
return ModbusRemoteControlNumber(controller, self, max_value_address)

def serialize(self, _inverter_model: Inv) -> dict[str, Any] | None:
return None


class ModbusRemoteControlNumber(ModbusEntityMixin, RestoreNumber, NumberEntity):
"""Number class"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
from dataclasses import dataclass
from typing import Any

from homeassistant.components.select import SelectEntity
from homeassistant.components.select import SelectEntityDescription
Expand Down Expand Up @@ -38,6 +39,9 @@ def create_entity_if_supported(
return None
return ModbusRemoteControlSelect(controller, self)

def serialize(self, _inverter_model: Inv) -> dict[str, Any] | None:
return None


class ModbusRemoteControlSelect(ModbusEntityMixin, SelectEntity):
def __init__(
Expand Down
Loading

0 comments on commit 1b0828a

Please sign in to comment.