From 73d008b4d3fe4c366e8ecd686c7f450a770618e5 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 22 Jul 2026 10:39:22 +0000 Subject: [PATCH 1/3] Require frequenz-api-common v0.8.4 or newer The operational mode field and the ElectricalComponentOperationalMode enum were added to the ElectricalComponent protobuf message in frequenz-api-common v0.8.3, but the enum value ELECTRICAL_COMPONENT_OPERATIONAL_MODE_TELEMETRY_ONLY was misspelled (TELEMTRY) in that release and only corrected in v0.8.4. The client now reads that field and references the enum by name, so require v0.8.4 as the minimum version. Signed-off-by: Leandro Lucarella --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9ecf572..b1cc8ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ ] requires-python = ">= 3.11, < 4" dependencies = [ - "frequenz-api-common >= 0.8.2, < 1.0.0", + "frequenz-api-common >= 0.8.4, < 1.0.0", "frequenz-api-microgrid >= 0.18.0, < 1", "frequenz-channels >= 1.6.1, < 2.0.0", "frequenz-client-base >= 0.10.0, < 0.12.0", From 8b36e6d332bfa71ec25cf82fbab1f9c92bcdf103 Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 22 Jul 2026 10:39:34 +0000 Subject: [PATCH 2/3] Expose operational mode through individual properties Using the operational mode protobuf enum as a component attribute is not very convenient. Users would need to check for multiple combinations to find out whether telemetry or control is available, plus handle the unspecified value, which should be rare but is possible. Instead, keep the operational mode representation entirely in the protobuf conversion layer and expose it through two Component accessors that are much safer to use: - `provides_telemetry()`: whether the component provides telemetry data - `accepts_control()`: whether the component accepts control commands Both raise `ValueError` when the operational mode is unspecified or unrecognized, so callers get an explicit failure instead of a silently wrong boolean. The mode is stored internally as two private `bool | None` fields that default to `None` (unspecified), so existing component construction sites remain unaffected. This is a port of commit [5c729ba][] from `frequenz-client-common`, so we upgrade to it the migration should be smooth. [5c729ba]: https://github.com/frequenz-floss/frequenz-client-common-python/commit/5c729bacc8707f013f44287d0b37dfe0fc22dc8d Signed-off-by: Leandro Lucarella --- .../client/microgrid/component/_component.py | 40 +++++++++++++ .../microgrid/component/_component_proto.py | 59 +++++++++++++++++++ tests/component/component_proto/conftest.py | 31 ++++++++++ tests/component/component_proto/test_base.py | 43 ++++++++++++++ tests/component/test_component.py | 30 ++++++++++ 5 files changed, 203 insertions(+) diff --git a/src/frequenz/client/microgrid/component/_component.py b/src/frequenz/client/microgrid/component/_component.py index 40954b2..0faad65 100644 --- a/src/frequenz/client/microgrid/component/_component.py +++ b/src/frequenz/client/microgrid/component/_component.py @@ -52,6 +52,12 @@ class Component: # pylint: disable=too-many-instance-attributes operational_lifetime: Lifetime = dataclasses.field(default_factory=Lifetime) """The operational lifetime of this component.""" + _provides_telemetry: bool | None = None + """Whether this component provides telemetry data, or `None` if unspecified.""" + + _accepts_control: bool | None = None + """Whether this component accepts control commands, or `None` if unspecified.""" + rated_bounds: Mapping[Metric | int, Bounds] = dataclasses.field( default_factory=dict, # dict is not hashable, so we don't use this field to calculate the hash. This @@ -85,6 +91,40 @@ def __new__(cls, *_: Any, **__: Any) -> Self: raise TypeError(f"Cannot instantiate {cls.__name__} directly") return super().__new__(cls) + def provides_telemetry(self) -> bool: + """Check whether this component provides telemetry data. + + Returns: + Whether this component provides telemetry data. + + Raises: + ValueError: If the operational mode is unspecified, so whether telemetry + is provided is unknown. + """ + if self._provides_telemetry is None: + raise ValueError( + f"operational mode of {self} is unspecified; " + "telemetry availability is unknown" + ) + return self._provides_telemetry + + def accepts_control(self) -> bool: + """Check whether this component accepts control commands. + + Returns: + Whether this component accepts control commands. + + Raises: + ValueError: If the operational mode is unspecified, so whether control + commands are accepted is unknown. + """ + if self._accepts_control is None: + raise ValueError( + f"operational mode of {self} is unspecified; " + "control availability is unknown" + ) + return self._accepts_control + def is_operational_at(self, timestamp: datetime) -> bool: """Check whether this component is operational at a specific timestamp. diff --git a/src/frequenz/client/microgrid/component/_component_proto.py b/src/frequenz/client/microgrid/component/_component_proto.py index b6acebd..6d8d7f7 100644 --- a/src/frequenz/client/microgrid/component/_component_proto.py +++ b/src/frequenz/client/microgrid/component/_component_proto.py @@ -71,6 +71,40 @@ # pylint: disable=too-many-arguments +_BOOLS_BY_OPERATIONAL_MODE: dict[int, tuple[bool | None, bool | None]] = { + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_INACTIVE: ( + False, + False, + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_TELEMETRY_ONLY: ( + True, + False, + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_ONLY: ( + False, + True, + ), + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_AND_TELEMETRY: ( + True, + True, + ), +} + + +def _operational_mode_to_bools(value: int) -> tuple[bool | None, bool | None]: + """Map a protobuf operational mode to telemetry/control booleans. + + Args: + value: A protobuf operational-mode enum value (an + `ELECTRICAL_COMPONENT_OPERATIONAL_MODE_*` constant). + + Returns: + A `(provides_telemetry, accepts_control)` tuple, with both elements `None` + when the operational mode is unspecified or unrecognized. + """ + return _BOOLS_BY_OPERATIONAL_MODE.get(value, (None, None)) + + def component_from_proto( message: electrical_components_pb2.ElectricalComponent, ) -> ComponentTypes: @@ -117,6 +151,8 @@ class ComponentBaseData(NamedTuple): lifetime: Lifetime rated_bounds: dict[Metric | int, Bounds] category_specific_info: dict[str, Any] + provides_telemetry: bool | None + accepts_control: bool | None category_mismatched: bool = False @@ -197,6 +233,7 @@ def component_base_from_proto_with_issues( lifetime, rated_bounds, category_specific_info, + *_operational_mode_to_bools(message.operational_mode), category_mismatched, ) @@ -231,6 +268,8 @@ def component_from_proto_with_issues( model_name=base_data.model_name, category=base_data.category, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, category_specific_metadata=base_data.category_specific_info, rated_bounds=base_data.rated_bounds, ) @@ -245,6 +284,8 @@ def component_from_proto_with_issues( model_name=base_data.model_name, category=base_data.category, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, ) case ( @@ -267,6 +308,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, ) case ComponentCategory.BATTERY: @@ -291,6 +334,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, ) case int(): @@ -302,6 +347,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, type=battery_type, ) @@ -338,6 +385,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, ) case int(): @@ -351,6 +400,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, type=ev_charger_type, ) @@ -368,6 +419,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, rated_fuse_current=rated_fuse_current, ) @@ -405,6 +458,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, ) case int(): @@ -418,6 +473,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, type=inverter_type, ) @@ -433,6 +490,8 @@ def component_from_proto_with_issues( manufacturer=base_data.manufacturer, model_name=base_data.model_name, operational_lifetime=base_data.lifetime, + _provides_telemetry=base_data.provides_telemetry, + _accepts_control=base_data.accepts_control, rated_bounds=base_data.rated_bounds, primary_voltage=message.category_specific_info.power_transformer.primary, secondary_voltage=message.category_specific_info.power_transformer.secondary, diff --git a/tests/component/component_proto/conftest.py b/tests/component/component_proto/conftest.py index 2e9d4a7..a313b0a 100644 --- a/tests/component/component_proto/conftest.py +++ b/tests/component/component_proto/conftest.py @@ -59,6 +59,8 @@ def default_component_base_data( lifetime=DEFAULT_LIFETIME, rated_bounds={Metric.AC_ENERGY_ACTIVE: Bounds(lower=0, upper=100)}, category_specific_info={}, + provides_telemetry=True, + accepts_control=True, category_mismatched=False, ) @@ -74,6 +76,32 @@ def assert_base_data(base_data: ComponentBaseData, other: Component) -> None: assert base_data.lifetime == other.operational_lifetime assert base_data.rated_bounds == other.rated_bounds assert base_data.category_specific_info == other.category_specific_metadata + # pylint: disable=protected-access + assert base_data.provides_telemetry == other._provides_telemetry + assert base_data.accepts_control == other._accepts_control + # pylint: enable=protected-access + + +_OPERATIONAL_MODE_BY_BOOLS: dict[ + tuple[bool | None, bool | None], + electrical_components_pb2.ElectricalComponentOperationalMode.ValueType, +] = { + (None, None): ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_UNSPECIFIED + ), + (False, False): ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_INACTIVE + ), + (True, False): ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_TELEMETRY_ONLY + ), + (False, True): ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_ONLY + ), + (True, True): ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_AND_TELEMETRY + ), +} def base_data_as_proto( @@ -91,6 +119,9 @@ def base_data_as_proto( if isinstance(base_data.category, int) else int(base_data.category.value) # type: ignore[arg-type] ), + operational_mode=_OPERATIONAL_MODE_BY_BOOLS[ + (base_data.provides_telemetry, base_data.accepts_control) + ], ) if base_data.lifetime: lifetime_dict: dict[str, Timestamp] = {} diff --git a/tests/component/component_proto/test_base.py b/tests/component/component_proto/test_base.py index 80257d1..f7b44c6 100644 --- a/tests/component/component_proto/test_base.py +++ b/tests/component/component_proto/test_base.py @@ -3,6 +3,7 @@ """Tests for protobuf conversion of the base/common part of Component objects.""" +import pytest from frequenz.api.common.v1alpha8.microgrid.electrical_components import ( electrical_components_pb2, ) @@ -12,12 +13,54 @@ from frequenz.client.microgrid.component import ComponentCategory from frequenz.client.microgrid.component._component_proto import ( ComponentBaseData, + _operational_mode_to_bools, component_base_from_proto_with_issues, ) from .conftest import base_data_as_proto +@pytest.mark.parametrize( + "proto_value, expected", + [ + ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_UNSPECIFIED, + (None, None), + ), + ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_INACTIVE, + (False, False), + ), + ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_TELEMETRY_ONLY, + (True, False), + ), + ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_ONLY, + (False, True), + ), + ( + electrical_components_pb2.ELECTRICAL_COMPONENT_OPERATIONAL_MODE_CONTROL_AND_TELEMETRY, + (True, True), + ), + (999, (None, None)), + ], + ids=[ + "unspecified", + "inactive", + "telemetry-only", + "control-only", + "control-and-telemetry", + "unknown-int", + ], +) +def test_operational_mode_to_bools( + proto_value: int, expected: tuple[bool | None, bool | None] +) -> None: + """Test proto operational-mode maps to (provides_telemetry, accepts_control).""" + assert _operational_mode_to_bools(proto_value) == expected + + def test_complete(default_component_base_data: ComponentBaseData) -> None: """Test parsing of a complete base component proto.""" major_issues: list[str] = [] diff --git a/tests/component/test_component.py b/tests/component/test_component.py index 9fcd5ea..8ef16b2 100644 --- a/tests/component/test_component.py +++ b/tests/component/test_component.py @@ -74,6 +74,36 @@ def test_creation_full() -> None: assert component.category_specific_metadata == metadata +def test_accessors_return_values_when_set() -> None: + """Test that telemetry/control accessors return the stored booleans.""" + component = _TestComponent( + id=ComponentId(1), + microgrid_id=MicrogridId(2), + category=ComponentCategory.UNSPECIFIED, + _provides_telemetry=True, + _accepts_control=False, + ) + + assert component.provides_telemetry() is True + assert component.accepts_control() is False + + +def test_accessors_raise_when_unspecified() -> None: + """Test that accessors raise ValueError when the value is unknown.""" + component = _TestComponent( + id=ComponentId(1), + microgrid_id=MicrogridId(2), + category=ComponentCategory.UNSPECIFIED, + _provides_telemetry=None, + _accepts_control=None, + ) + + with pytest.raises(ValueError): + component.provides_telemetry() + with pytest.raises(ValueError): + component.accepts_control() + + @pytest.mark.parametrize( "name,expected_str", [ From 3ca46c08e937043fb8d3003f854f08187643305a Mon Sep 17 00:00:00 2001 From: Leandro Lucarella Date: Wed, 22 Jul 2026 12:50:15 +0200 Subject: [PATCH 3/3] Update release notes and prepare them for v0.18.4 Signed-off-by: Leandro Lucarella --- RELEASE_NOTES.md | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 80e5f0c..31e5b49 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,17 +1,8 @@ # Frequenz Microgrid API Client Release Notes -## Summary - - - -## Upgrading - - - ## New Features - - -## Bug Fixes +* `Component` classes now expose two new functions to check the operational mode: - + - `provides_telemetry()`: whether the component provides telemetry data + - `accepts_control()`: whether the component accepts control commands