Replace getattr lookups in Sofar with typed accessors (#183295)

This commit is contained in:
darkrain-nl
2026-09-29 18:21:19 +02:00
committed by GitHub
parent c57d92dd31
commit c579c8dd80
7 changed files with 303 additions and 33 deletions
+2 -5
View File
@@ -12,7 +12,6 @@ from homeassistant.components.modbus import async_get_unit
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
SensorExtraStoredData,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
@@ -104,7 +103,7 @@ def _async_seed_high_water_marks(
registry = er.async_get(hass)
last_states = restore_state.async_get(hass).last_states
for description in SENSOR_DESCRIPTIONS:
if description.state_class is not SensorStateClass.TOTAL_INCREASING:
if (total_fn := description.total_fn) is None:
continue
entity_id = registry.async_get_entity_id(
SENSOR_DOMAIN, DOMAIN, f"{serial}_{description.key}"
@@ -116,9 +115,7 @@ def _async_seed_high_water_marks(
extra = SensorExtraStoredData.from_dict(stored.extra_data.as_dict())
if extra is None or not isinstance(extra.native_value, (int, float)):
continue
getattr(device, description.component).seed_high_water(
description.key, float(extra.native_value)
)
total_fn(device).seed(float(extra.native_value))
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -1,9 +1,11 @@
"""Support for Sofar binary sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from enum import IntFlag
from typing import override
from sofar_modbus.modern.device import SofarInverter
from sofar_modbus.modern.enums import PowerControlFlags
from sofar_modbus.modern.faults import FaultCategory
@@ -60,7 +62,7 @@ class SofarFlagBinarySensorDescription(
):
"""Describe a Sofar binary sensor backed by one flags-register bit."""
attribute: str
flags_fn: Callable[[SofarInverter], IntFlag | None]
flag: IntFlag
@@ -71,7 +73,7 @@ FLAG_SENSOR_DESCRIPTIONS: tuple[SofarFlagBinarySensorDescription, ...] = (
translation_key="active_power_limit_enabled",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
attribute="power_control",
flags_fn=lambda device: device.active_power_control.power_control,
flag=PowerControlFlags.ACTIVE_POWER,
),
)
@@ -105,10 +107,9 @@ class SofarFaultBinarySensor(SofarEntity, BinarySensorEntity):
@property
@override
def is_on(self) -> bool:
component = getattr(self.coordinator.device, self.entity_description.component)
return any(
fault.category is self.entity_description.category
for fault in component.active_faults
for fault in self.coordinator.device.state.active_faults
)
@@ -119,7 +120,6 @@ class SofarFlagBinarySensor(SofarEntity, BinarySensorEntity):
@property
@override
def is_on(self) -> bool:
component = getattr(self.coordinator.device, self.entity_description.component)
flags = getattr(component, self.entity_description.attribute)
return self.entity_description.flag in flags
def is_on(self) -> bool | None:
flags = self.entity_description.flags_fn(self.coordinator.device)
return None if flags is None else self.entity_description.flag in flags
@@ -179,8 +179,7 @@ class SofarRuntimeData:
component_name = BATTERY_COMPONENTS[number]
if component_name not in self.served_components:
return False
component = getattr(self.readings.device, component_name)
return bool(getattr(component, f"battery_voltage_{number}", None))
return bool(self.readings.device.battery_string(number).voltage)
def coordinator_for(self, component: str) -> SofarDataUpdateCoordinator:
"""Which coordinator owns a given component's data."""
+4 -2
View File
@@ -28,6 +28,7 @@ class SofarSelectEntityDescription(SelectEntityDescription, SofarEntityDescripti
"""Describe a Sofar select entity."""
options_enum: type[IntEnum]
value_fn: Callable[[SofarInverter], IntEnum | None]
write_fn: Callable[[SofarInverter, int], Awaitable[None]]
@@ -38,6 +39,7 @@ SELECT_DESCRIPTIONS: tuple[SofarSelectEntityDescription, ...] = (
translation_key="charger_use_mode",
options=_enum_options(ChargerUseMode),
options_enum=ChargerUseMode,
value_fn=lambda device: device.charger.charger_use_mode,
write_fn=lambda device, value: device.charger.write("charger_use_mode", value),
),
SofarSelectEntityDescription(
@@ -46,6 +48,7 @@ SELECT_DESCRIPTIONS: tuple[SofarSelectEntityDescription, ...] = (
translation_key="eps_control",
options=_enum_options(EpsControlMode),
options_enum=EpsControlMode,
value_fn=lambda device: device.eps.eps_control,
write_fn=lambda device, value: device.eps.async_write_control(
EpsControlMode(value)
),
@@ -77,8 +80,7 @@ class SofarSelect(SofarEntity, SelectEntity):
@override
def current_option(self) -> str | None:
"""Return the currently selected option."""
component = getattr(self.coordinator.device, self.entity_description.component)
value: IntEnum | None = getattr(component, self.entity_description.key)
value = self.entity_description.value_fn(self.coordinator.device)
return value.name.lower() if value is not None else None
@override
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -21,6 +21,7 @@ PARALLEL_UPDATES = 1
class SofarSwitchEntityDescription(SwitchEntityDescription, SofarEntityDescription):
"""Describe a Sofar switch entity."""
value_fn: Callable[[SofarInverter], RemoteSwitchOnOff | None]
write_fn: Callable[[SofarInverter, bool], Awaitable[None]]
@@ -29,6 +30,7 @@ SWITCH_DESCRIPTIONS: tuple[SofarSwitchEntityDescription, ...] = (
key="remote_switch_on_off",
component="remote",
name=None,
value_fn=lambda device: device.remote.remote_switch_on_off,
write_fn=lambda device, value: device.remote.write(
"remote_switch_on_off",
RemoteSwitchOnOff.ON if value else RemoteSwitchOnOff.OFF,
@@ -61,8 +63,7 @@ class SofarSwitch(SofarEntity, SwitchEntity):
@override
def is_on(self) -> bool | None:
"""Return whether the remote switch is on."""
component = getattr(self.coordinator.device, self.entity_description.component)
value = getattr(component, self.entity_description.key)
value = self.entity_description.value_fn(self.coordinator.device)
return None if value is None else bool(value)
async def _async_write(self, value: bool) -> None:
+55
View File
@@ -265,6 +265,7 @@ async def test_total_sensor_restore_data_parsing(
key="load_consumption_total",
component="meter_energy",
translation_key="load_consumption_total",
value_fn=lambda device: device.meter_energy.load_consumption_total,
)
device.meter_energy.load_consumption_total = None
@@ -312,6 +313,10 @@ async def test_total_sensor_seeds_high_water_from_restored_value(
component="meter_energy",
translation_key="load_consumption_total",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: (
device.meter_energy.load_consumption_total_corrected.value
),
total_fn=lambda device: device.meter_energy.load_consumption_total_corrected,
)
sensor = SofarTotalSensor(runtime_data, description)
sensor.hass = hass
@@ -330,6 +335,7 @@ async def test_sensor_dead_link_unavailable(init_integration: MockConfigEntry) -
key="grid_frequency",
component="grid",
translation_key="grid_frequency",
value_fn=lambda device: device.grid.grid_frequency,
)
sensor = SofarSensor(runtime_data, description)
assert sensor.native_value == 50.0
@@ -347,6 +353,10 @@ async def test_total_sensor_dead_link_unavailable(
component="meter_energy",
translation_key="load_consumption_total",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: (
device.meter_energy.load_consumption_total_corrected.value
),
total_fn=lambda device: device.meter_energy.load_consumption_total_corrected,
)
sensor = SofarTotalSensor(runtime_data, description)
assert sensor.available
@@ -367,6 +377,7 @@ async def test_sensor_availability_on_component_failure(
key="grid_frequency",
component="grid",
translation_key="grid_frequency",
value_fn=lambda device: device.grid.grid_frequency,
)
sensor = SofarSensor(runtime_data, description)
assert sensor.available
@@ -395,6 +406,10 @@ async def test_total_sensor_total_increasing_uses_corrected_value(
component="meter_energy",
translation_key="load_consumption_total",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: (
device.meter_energy.load_consumption_total_corrected.value
),
total_fn=lambda device: device.meter_energy.load_consumption_total_corrected,
)
device = runtime_data.readings.device
sensor = SofarTotalSensor(runtime_data, description)
@@ -404,3 +419,43 @@ async def test_total_sensor_total_increasing_uses_corrected_value(
assert sensor.native_value == 42.0
mock_corrected.assert_called_once_with("load_consumption_total")
assert sensor.available
@pytest.mark.parametrize(
"description",
[
pytest.param(description, id=description.key)
for description in SENSOR_DESCRIPTIONS
if description.state_class is not SensorStateClass.TOTAL_INCREASING
],
)
async def test_value_fn_reads_the_field_its_key_names(
init_integration: MockConfigEntry, description: SofarSensorDescription
) -> None:
"""Test each value_fn reads the field its key and component name."""
device = init_integration.runtime_data.readings.device
component = getattr(device, description.component)
sentinel = object()
with patch.dict(component._values, {description.key: sentinel}):
assert description.value_fn(device) is sentinel
@pytest.mark.parametrize(
"description",
[
pytest.param(description, id=description.key)
for description in SENSOR_DESCRIPTIONS
if description.state_class is SensorStateClass.TOTAL_INCREASING
],
)
async def test_total_reads_and_seeds_its_own_corrected_total(
init_integration: MockConfigEntry, description: SofarSensorDescription
) -> None:
"""Test each total reads and seeds the corrected total its key names."""
device = init_integration.runtime_data.readings.device
component = getattr(device, description.component)
with patch.object(component, "corrected", return_value=42.0) as mock_corrected:
assert description.value_fn(device) == 42.0
mock_corrected.assert_called_once_with(description.key)
assert description.total_fn is not None
assert description.total_fn(device).name == description.key