Skip to content

Commit 937e41d

Browse files
committed
Add a stream_sensor_data() RPC to get sensor data
This RPC is also based on version 0.17 of the microgrid API instead of the current 0.15 version, so users can already use the new interface and make migrating to 0.17 more straight forward. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent d5783b8 commit 937e41d

File tree

2 files changed

+229
-11
lines changed

2 files changed

+229
-11
lines changed

src/frequenz/client/microgrid/_client.py

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
from __future__ import annotations
77

88
import asyncio
9+
import itertools
910
import logging
1011
from collections.abc import Callable, Iterable, Set
1112
from dataclasses import replace
12-
from typing import Any, TypeVar
13+
from functools import partial
14+
from typing import Any, NotRequired, TypedDict, TypeVar, assert_never
1315

1416
from frequenz.api.common import components_pb2, metrics_pb2
15-
from frequenz.api.microgrid import microgrid_pb2, microgrid_pb2_grpc
17+
from frequenz.api.microgrid import microgrid_pb2, microgrid_pb2_grpc, sensor_pb2
1618
from frequenz.channels import Receiver
1719
from frequenz.client.base import channel, client, retry, streaming
1820
from google.protobuf.empty_pb2 import Empty
@@ -35,10 +37,10 @@
3537
from ._connection import Connection
3638
from ._constants import RECEIVER_MAX_SIZE
3739
from ._exception import ApiClientError, ClientNotConnected
38-
from ._id import ComponentId, MicrogridId
40+
from ._id import ComponentId, MicrogridId, SensorId
3941
from ._metadata import Location, Metadata
40-
from ._sensor_proto import sensor_from_proto
41-
from .sensor import Sensor
42+
from ._sensor_proto import sensor_data_samples_from_proto, sensor_from_proto
43+
from .sensor import Sensor, SensorDataSamples, SensorMetric
4244

4345
DEFAULT_GRPC_CALL_TIMEOUT = 60.0
4446
"""The default timeout for gRPC calls made by this client (in seconds)."""
@@ -98,6 +100,12 @@ def __init__(
98100
self._broadcasters: dict[
99101
ComponentId, streaming.GrpcStreamBroadcaster[Any, Any]
100102
] = {}
103+
self._sensor_data_broadcasters: dict[
104+
str,
105+
streaming.GrpcStreamBroadcaster[
106+
microgrid_pb2.ComponentData, SensorDataSamples
107+
],
108+
] = {}
101109
self._retry_strategy = retry_strategy
102110

103111
@property
@@ -119,15 +127,22 @@ async def __aexit__(
119127
exc_tb: Any | None,
120128
) -> bool | None:
121129
"""Close the gRPC channel and stop all broadcasters."""
122-
exceptions = [
130+
exceptions = list(
123131
exc
124132
for exc in await asyncio.gather(
125-
*(broadcaster.stop() for broadcaster in self._broadcasters.values()),
133+
*(
134+
broadcaster.stop()
135+
for broadcaster in itertools.chain(
136+
self._broadcasters.values(),
137+
self._sensor_data_broadcasters.values(),
138+
)
139+
),
126140
return_exceptions=True,
127141
)
128142
if isinstance(exc, BaseException)
129-
]
143+
)
130144
self._broadcasters.clear()
145+
self._sensor_data_broadcasters.clear()
131146

132147
result = None
133148
try:
@@ -568,3 +583,91 @@ async def set_bounds( # noqa: DOC503 (raises ApiClientError indirectly)
568583
),
569584
method_name="AddInclusionBounds",
570585
)
586+
587+
# noqa: DOC502 (Raises ApiClientError indirectly)
588+
def stream_sensor_data(
589+
self,
590+
sensor: SensorId | Sensor,
591+
metrics: Iterable[SensorMetric | int] | None = None,
592+
*,
593+
buffer_size: int = 50,
594+
) -> Receiver[SensorDataSamples]:
595+
"""Stream data samples from a sensor.
596+
597+
Warning:
598+
Sensors may not support all metrics. If a sensor does not support
599+
a given metric, then the returned data stream will not contain that metric.
600+
601+
There is no way to tell if a metric is not being received because the
602+
sensor does not support it or because there is a transient issue when
603+
retrieving the metric from the sensor.
604+
605+
The supported metrics by a sensor can even change with time, for example,
606+
if a sensor is updated with new firmware.
607+
608+
Args:
609+
sensor: The sensor to stream data from.
610+
metrics: If not `None`, only the specified metrics will be retrieved.
611+
Otherwise all available metrics will be retrieved.
612+
buffer_size: The maximum number of messages to buffer in the returned
613+
receiver. After this limit is reached, the oldest messages will be
614+
dropped.
615+
616+
Returns:
617+
A receiver to retrieve data from the sensor.
618+
"""
619+
sensor_id = _get_sensor_id(sensor)
620+
key = str(sensor_id)
621+
622+
class _ExtraArgs(TypedDict):
623+
metrics: NotRequired[frozenset[sensor_pb2.SensorMetric.ValueType]]
624+
625+
extra_args: _ExtraArgs = {}
626+
if metrics is not None:
627+
extra_args["metrics"] = frozenset(
628+
[_get_sensor_metric_value(m) for m in metrics]
629+
)
630+
# We use the frozenset because iterables are not hashable
631+
key += f"{hash(extra_args['metrics'])}"
632+
633+
broadcaster = self._sensor_data_broadcasters.get(key)
634+
if broadcaster is None:
635+
client_id = hex(id(self))[2:]
636+
stream_name = f"microgrid-client-{client_id}-sensor-data-{key}"
637+
broadcaster = streaming.GrpcStreamBroadcaster(
638+
stream_name,
639+
lambda: aiter(
640+
self.stub.StreamComponentData(
641+
microgrid_pb2.ComponentIdParam(id=sensor_id),
642+
timeout=DEFAULT_GRPC_CALL_TIMEOUT,
643+
)
644+
),
645+
partial(sensor_data_samples_from_proto, **extra_args),
646+
retry_strategy=self._retry_strategy,
647+
)
648+
self._sensor_data_broadcasters[key] = broadcaster
649+
return broadcaster.new_receiver(maxsize=buffer_size)
650+
651+
652+
def _get_sensor_id(sensor: SensorId | Sensor) -> int:
653+
"""Get the sensor ID from a sensor or sensor ID."""
654+
match sensor:
655+
case SensorId():
656+
return int(sensor)
657+
case Sensor():
658+
return int(sensor.id)
659+
case unexpected:
660+
assert_never(unexpected)
661+
662+
663+
def _get_sensor_metric_value(
664+
metric: SensorMetric | int,
665+
) -> sensor_pb2.SensorMetric.ValueType:
666+
"""Get the sensor metric ID from a sensor metric or sensor metric ID."""
667+
match metric:
668+
case SensorMetric():
669+
return sensor_pb2.SensorMetric.ValueType(metric.value)
670+
case int():
671+
return sensor_pb2.SensorMetric.ValueType(metric)
672+
case unexpected:
673+
assert_never(unexpected)

tests/test_client.py

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33

44
"""Tests for the microgrid client thin wrapper."""
55

6+
# We are going to split these tests in the future, but for now...
7+
# pylint: disable=too-many-lines
8+
69
import logging
710
from collections.abc import AsyncIterator
11+
from datetime import datetime, timezone
812
from typing import Any
913
from unittest import mock
1014

1115
import grpc.aio
1216
import pytest
1317
from frequenz.api.common import components_pb2, metrics_pb2
1418
from frequenz.api.microgrid import grid_pb2, inverter_pb2, microgrid_pb2, sensor_pb2
15-
from frequenz.client.base import retry
19+
from frequenz.client.base import conversion, retry
1620
from google.protobuf.empty_pb2 import Empty
1721

1822
from frequenz.client.microgrid import (
@@ -33,7 +37,14 @@
3337
MicrogridId,
3438
SensorId,
3539
)
36-
from frequenz.client.microgrid.sensor import Sensor
40+
from frequenz.client.microgrid.sensor import (
41+
Sensor,
42+
SensorDataSamples,
43+
SensorMetric,
44+
SensorMetricSample,
45+
SensorStateCode,
46+
SensorStateSample,
47+
)
3748

3849

3950
class _TestClient(MicrogridApiClient):
@@ -590,15 +601,28 @@ def ev_charger101() -> microgrid_pb2.Component:
590601
)
591602

592603

604+
@pytest.fixture
605+
def sensor201() -> microgrid_pb2.Component:
606+
"""Return a test sensor component."""
607+
return microgrid_pb2.Component(
608+
id=201,
609+
category=components_pb2.ComponentCategory.COMPONENT_CATEGORY_SENSOR,
610+
sensor=sensor_pb2.Metadata(
611+
type=components_pb2.SensorType.SENSOR_TYPE_THERMOMETER
612+
),
613+
)
614+
615+
593616
@pytest.fixture
594617
def component_list(
595618
meter83: microgrid_pb2.Component,
596619
battery38: microgrid_pb2.Component,
597620
inverter99: microgrid_pb2.Component,
598621
ev_charger101: microgrid_pb2.Component,
622+
sensor201: microgrid_pb2.Component,
599623
) -> list[microgrid_pb2.Component]:
600624
"""Return a list of test components."""
601-
return [meter83, battery38, inverter99, ev_charger101]
625+
return [meter83, battery38, inverter99, ev_charger101, sensor201]
602626

603627

604628
@pytest.mark.parametrize("method", ["meter_data", "battery_data", "inverter_data"])
@@ -890,6 +914,97 @@ async def test_set_bounds_grpc_error(client: _TestClient) -> None:
890914
await client.set_bounds(ComponentId(99), 0.0, 100.0)
891915

892916

917+
async def test_stream_sensor_data_success(
918+
sensor201: microgrid_pb2.Component, client: _TestClient
919+
) -> None:
920+
"""Test successful streaming of sensor data."""
921+
now = datetime.now(timezone.utc)
922+
923+
async def stream_data_impl(
924+
*_: Any, **__: Any
925+
) -> AsyncIterator[microgrid_pb2.ComponentData]:
926+
yield microgrid_pb2.ComponentData(
927+
id=int(sensor201.id),
928+
ts=conversion.to_timestamp(now),
929+
sensor=sensor_pb2.Sensor(
930+
state=sensor_pb2.State(
931+
component_state=sensor_pb2.ComponentState.COMPONENT_STATE_OK
932+
),
933+
data=sensor_pb2.Data(
934+
sensor_data=[
935+
sensor_pb2.SensorData(
936+
value=1.0,
937+
sensor_metric=sensor_pb2.SensorMetric.SENSOR_METRIC_TEMPERATURE,
938+
)
939+
],
940+
),
941+
),
942+
)
943+
944+
client.mock_stub.StreamComponentData.side_effect = stream_data_impl
945+
receiver = client.stream_sensor_data(
946+
SensorId(sensor201.id), [SensorMetric.TEMPERATURE]
947+
)
948+
sample = await receiver.receive()
949+
950+
assert isinstance(sample, SensorDataSamples)
951+
assert int(sample.sensor_id) == sensor201.id
952+
assert sample.states == [
953+
SensorStateSample(
954+
sampled_at=now,
955+
states=frozenset({SensorStateCode.ON}),
956+
warnings=frozenset(),
957+
errors=frozenset(),
958+
)
959+
]
960+
assert sample.metrics == [
961+
SensorMetricSample(sampled_at=now, metric=SensorMetric.TEMPERATURE, value=1.0)
962+
]
963+
964+
965+
async def test_stream_sensor_data_grpc_error(
966+
sensor201: microgrid_pb2.Component, caplog: pytest.LogCaptureFixture
967+
) -> None:
968+
"""Test stream_sensor_data() when the gRPC call fails and retries."""
969+
caplog.set_level(logging.WARNING)
970+
971+
num_calls = 0
972+
973+
async def stream_data_error_impl(
974+
*_: Any, **__: Any
975+
) -> AsyncIterator[microgrid_pb2.ComponentData]:
976+
nonlocal num_calls
977+
num_calls += 1
978+
if num_calls <= 2: # Fail first two times
979+
raise grpc.aio.AioRpcError(
980+
mock.MagicMock(name="mock_status"),
981+
mock.MagicMock(name="mock_initial_metadata"),
982+
mock.MagicMock(name="mock_trailing_metadata"),
983+
f"fake grpc details stream_sensor_data num_calls={num_calls}",
984+
"fake grpc debug_error_string",
985+
)
986+
# Succeed on the third call
987+
yield microgrid_pb2.ComponentData(id=int(sensor201.id))
988+
989+
async with _TestClient(
990+
retry_strategy=retry.LinearBackoff(interval=0.0, jitter=0.0, limit=3)
991+
) as client:
992+
client.mock_stub.StreamComponentData.side_effect = stream_data_error_impl
993+
receiver = client.stream_sensor_data(
994+
SensorId(sensor201.id), [SensorMetric.TEMPERATURE]
995+
)
996+
sample = await receiver.receive() # Should succeed after retries
997+
998+
assert isinstance(sample, SensorDataSamples)
999+
assert int(sample.sensor_id) == sensor201.id
1000+
1001+
assert num_calls == 3 # Check that it was called 3 times (1 initial + 2 retries)
1002+
# Check log messages for retries
1003+
assert "connection ended, retrying" in caplog.text
1004+
assert "fake grpc details stream_sensor_data num_calls=1" in caplog.text
1005+
assert "fake grpc details stream_sensor_data num_calls=2" in caplog.text
1006+
1007+
8931008
def _clear_components(component_list: microgrid_pb2.ComponentList) -> None:
8941009
while component_list.components:
8951010
component_list.components.pop()

0 commit comments

Comments
 (0)