marilib-pkg 0.6.0__py3-none-any.whl → 0.7.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
marilib/mari_protocol.py CHANGED
@@ -1,5 +1,6 @@
1
1
  import dataclasses
2
2
  from dataclasses import dataclass
3
+ from enum import IntEnum
3
4
 
4
5
  from marilib.protocol import Packet, PacketFieldMetadata, PacketType
5
6
 
@@ -8,6 +9,195 @@ MARI_BROADCAST_ADDRESS = 0xFFFFFFFFFFFFFFFF
8
9
  MARI_NET_ID_DEFAULT = 0x0001
9
10
 
10
11
 
12
+ class DefaultPayloadType(IntEnum):
13
+ APPLICATION_DATA = 1
14
+ METRICS_REQUEST = 128
15
+ METRICS_RESPONSE = 129
16
+ METRICS_LOAD = 130
17
+ METRICS_PROBE = 140
18
+
19
+ def as_bytes(self) -> bytes:
20
+ return bytes([self.value])
21
+
22
+
23
+ @dataclass
24
+ class DefaultPayload(Packet):
25
+ metadata: list[PacketFieldMetadata] = dataclasses.field(
26
+ default_factory=lambda: [
27
+ PacketFieldMetadata(name="type", length=1),
28
+ ]
29
+ )
30
+ type_: DefaultPayloadType = DefaultPayloadType.APPLICATION_DATA
31
+
32
+
33
+ @dataclass
34
+ class MetricsProbePayload(Packet):
35
+ metadata: list[PacketFieldMetadata] = dataclasses.field(
36
+ default_factory=lambda: [
37
+ PacketFieldMetadata(name="type", length=1),
38
+ PacketFieldMetadata(name="cloud_tx_ts_us", length=8),
39
+ PacketFieldMetadata(name="cloud_rx_ts_us", length=8),
40
+ PacketFieldMetadata(name="cloud_tx_count", length=4),
41
+ PacketFieldMetadata(name="cloud_rx_count", length=4),
42
+ PacketFieldMetadata(name="edge_tx_ts_us", length=8),
43
+ PacketFieldMetadata(name="edge_rx_ts_us", length=8),
44
+ PacketFieldMetadata(name="edge_tx_count", length=4),
45
+ PacketFieldMetadata(name="edge_rx_count", length=4),
46
+ PacketFieldMetadata(name="gw_tx_count", length=4),
47
+ PacketFieldMetadata(name="gw_rx_count", length=4),
48
+ PacketFieldMetadata(name="gw_rx_asn", length=8),
49
+ PacketFieldMetadata(name="gw_tx_enqueued_asn", length=8),
50
+ PacketFieldMetadata(name="gw_tx_dequeued_asn", length=8),
51
+ PacketFieldMetadata(name="node_rx_count", length=4),
52
+ PacketFieldMetadata(name="node_tx_count", length=4),
53
+ PacketFieldMetadata(name="node_rx_asn", length=8),
54
+ PacketFieldMetadata(name="node_tx_enqueued_asn", length=8),
55
+ PacketFieldMetadata(name="node_tx_dequeued_asn", length=8),
56
+ PacketFieldMetadata(name="rssi_at_node", length=1),
57
+ PacketFieldMetadata(name="rssi_at_gw", length=1),
58
+ ]
59
+ )
60
+ type_: DefaultPayloadType = DefaultPayloadType.METRICS_PROBE
61
+ cloud_tx_ts_us: int = 0
62
+ cloud_rx_ts_us: int = 0
63
+ cloud_tx_count: int = 0
64
+ cloud_rx_count: int = 0
65
+ edge_tx_ts_us: int = 0
66
+ edge_rx_ts_us: int = 0
67
+ edge_tx_count: int = 0
68
+ edge_rx_count: int = 0
69
+ gw_tx_count: int = 0
70
+ gw_rx_count: int = 0
71
+ gw_rx_asn: int = 0
72
+ gw_tx_enqueued_asn: int = 0
73
+ gw_tx_dequeued_asn: int = 0
74
+ node_rx_count: int = 0
75
+ node_tx_count: int = 0
76
+ node_rx_asn: int = 0
77
+ node_tx_enqueued_asn: int = 0
78
+ node_tx_dequeued_asn: int = 0
79
+ rssi_at_node: int = 0
80
+ rssi_at_gw: int = 0
81
+
82
+ @property
83
+ def packet_length(self) -> int:
84
+ return sum(field.length for field in self.metadata)
85
+
86
+ @property
87
+ def asn(self) -> int:
88
+ """ASN at reception back from the network"""
89
+ return self.gw_rx_asn
90
+
91
+ def latency_roundtrip_node_edge_ms(self) -> float:
92
+ return (self.edge_rx_ts_us - self.edge_tx_ts_us) / 1000.0
93
+
94
+ def latency_roundtrip_node_cloud_ms(self) -> float:
95
+ return (self.cloud_rx_ts_us - self.cloud_tx_ts_us) / 1000.0
96
+
97
+ def pdr_uplink_radio(self, probe_stats_start_epoch=None) -> float:
98
+ if probe_stats_start_epoch is None:
99
+ # if no epoch is provided, use the current values
100
+ gw_rx_count = self.gw_rx_count
101
+ node_tx_count = self.node_tx_count
102
+ else:
103
+ # if epoch is provided, subtract the epoch values from the current values
104
+ if probe_stats_start_epoch.asn == 0:
105
+ return 0
106
+ gw_rx_count = self.gw_rx_count - probe_stats_start_epoch.gw_rx_count
107
+ node_tx_count = self.node_tx_count - probe_stats_start_epoch.node_tx_count
108
+ if node_tx_count <= 0:
109
+ return 0
110
+ return gw_rx_count / node_tx_count
111
+
112
+ def pdr_downlink_radio(self, probe_stats_start_epoch=None) -> float:
113
+ if probe_stats_start_epoch is None:
114
+ # if no epoch is provided, use the current values
115
+ gw_tx_count = self.gw_tx_count
116
+ node_rx_count = self.node_rx_count
117
+ else:
118
+ # if epoch is provided, subtract the epoch values from the current values
119
+ if probe_stats_start_epoch.asn == 0:
120
+ return 0
121
+ gw_tx_count = self.gw_tx_count - probe_stats_start_epoch.gw_tx_count
122
+ node_rx_count = self.node_rx_count - probe_stats_start_epoch.node_rx_count
123
+ if gw_tx_count <= 0:
124
+ return 0
125
+ return node_rx_count / gw_tx_count
126
+
127
+ def pdr_uplink_uart(self, probe_stats_start_epoch=None) -> float:
128
+ if probe_stats_start_epoch is None:
129
+ # if no epoch is provided, just wait a bit
130
+ return -1
131
+ else:
132
+ # if epoch is provided, subtract the epoch values from the current values
133
+ if probe_stats_start_epoch.asn == 0:
134
+ return 0
135
+ # if a packet was received at the gatweway, it should also be received at the edge (otherwise, it's a loss)
136
+ gw_rx_count = self.gw_rx_count - probe_stats_start_epoch.gw_rx_count
137
+ edge_rx_count = self.edge_rx_count - probe_stats_start_epoch.edge_rx_count
138
+ if gw_rx_count <= 0:
139
+ return 0
140
+ return edge_rx_count / gw_rx_count
141
+
142
+ def pdr_downlink_uart(self, probe_stats_start_epoch=None) -> float:
143
+ if probe_stats_start_epoch is None:
144
+ # if no epoch is provided, just wait a bit
145
+ return -1
146
+ else:
147
+ # if epoch is provided, subtract the epoch values from the current values
148
+ if probe_stats_start_epoch.asn == 0:
149
+ return 0
150
+ # if a packet was sent at the edge, it should also be sent at the gateway (otherwise, it's a loss)
151
+ gw_tx_count = self.gw_tx_count - probe_stats_start_epoch.gw_tx_count
152
+ edge_tx_count = self.edge_tx_count - probe_stats_start_epoch.edge_tx_count
153
+ if edge_tx_count <= 0:
154
+ return 0
155
+ return gw_tx_count / edge_tx_count
156
+
157
+ def rssi_at_node_dbm(self) -> int:
158
+ if self.rssi_at_node > 127:
159
+ return self.rssi_at_node - 255
160
+ return self.rssi_at_node
161
+
162
+ def rssi_at_gw_dbm(self) -> int:
163
+ if self.rssi_at_gw > 127:
164
+ return self.rssi_at_gw - 255
165
+ return self.rssi_at_gw
166
+
167
+ def __repr__(self):
168
+ rep = dataclasses.asdict(self)
169
+ rep.pop("metadata", None)
170
+ return f"{rep}"
171
+
172
+
173
+ @dataclass
174
+ class MetricsRequestPayload(Packet):
175
+ metadata: list[PacketFieldMetadata] = dataclasses.field(
176
+ default_factory=lambda: [
177
+ PacketFieldMetadata(name="type", length=1),
178
+ PacketFieldMetadata(name="timestamp_us", length=8),
179
+ ]
180
+ )
181
+ type_: DefaultPayloadType = DefaultPayloadType.METRICS_REQUEST
182
+ timestamp_us: int = 0
183
+
184
+
185
+ @dataclass
186
+ class MetricsResponsePayload(Packet):
187
+ metadata: list[PacketFieldMetadata] = dataclasses.field(
188
+ default_factory=lambda: [
189
+ PacketFieldMetadata(name="type", length=1),
190
+ PacketFieldMetadata(name="timestamp_us", length=8),
191
+ PacketFieldMetadata(name="rx_count", length=4),
192
+ PacketFieldMetadata(name="tx_count", length=4),
193
+ ]
194
+ )
195
+ type_: DefaultPayloadType = DefaultPayloadType.METRICS_RESPONSE
196
+ timestamp_us: int = 0
197
+ rx_count: int = 0
198
+ tx_count: int = 0
199
+
200
+
11
201
  @dataclass
12
202
  class HeaderStats(Packet):
13
203
  """Dataclass that holds MAC header stats."""
@@ -71,6 +261,20 @@ class Frame:
71
261
  stats_bytes = self.stats.to_bytes(byteorder)
72
262
  return header_bytes + stats_bytes + self.payload
73
263
 
264
+ @property
265
+ def is_test_packet(self) -> bool:
266
+ """Returns True if either the payload is a metrics response, request, or load test packet."""
267
+ return (
268
+ self.payload.startswith(DefaultPayloadType.METRICS_RESPONSE.as_bytes())
269
+ or self.payload.startswith(DefaultPayloadType.METRICS_REQUEST.as_bytes())
270
+ or self.payload.startswith(DefaultPayloadType.METRICS_LOAD.as_bytes())
271
+ or self.payload.startswith(DefaultPayloadType.METRICS_PROBE.as_bytes())
272
+ )
273
+
274
+ @property
275
+ def is_load_test_packet(self) -> bool:
276
+ return self.payload.startswith(DefaultPayloadType.METRICS_LOAD.as_bytes())
277
+
74
278
  def __repr__(self):
75
279
  header_no_metadata = dataclasses.replace(self.header, metadata=[])
76
280
  return f"Frame(header={header_no_metadata}, payload={self.payload})"
marilib/marilib_cloud.py CHANGED
@@ -3,7 +3,7 @@ from dataclasses import dataclass, field
3
3
  from datetime import datetime
4
4
  from typing import Any, Callable
5
5
 
6
- from marilib.latency import LatencyTester
6
+ from marilib.metrics import MetricsTester
7
7
  from marilib.mari_protocol import Frame, Header
8
8
  from marilib.model import (
9
9
  EdgeEvent,
@@ -34,7 +34,7 @@ class MarilibCloud(MarilibBase):
34
34
  logger: Any | None = None
35
35
  gateways: dict[int, MariGateway] = field(default_factory=dict)
36
36
  lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
37
- latency_tester: LatencyTester | None = None
37
+ metrics_tester: MetricsTester | None = None
38
38
 
39
39
  started_ts: datetime = field(default_factory=datetime.now)
40
40
  last_received_mqtt_data_ts: datetime = field(default_factory=datetime.now)
@@ -52,6 +52,9 @@ class MarilibCloud(MarilibBase):
52
52
  self.mqtt_interface.init()
53
53
  if self.logger:
54
54
  self.logger.log_setup_parameters(self.setup_params)
55
+ self.metrics_tester = MetricsTester(
56
+ self
57
+ ) # just instantiate, do not start it at the cloud, for now
55
58
 
56
59
  # ============================ MarilibBase methods =========================
57
60
 
@@ -168,12 +171,21 @@ class MarilibCloud(MarilibBase):
168
171
  gateway_address = frame.header.destination
169
172
  node_address = frame.header.source
170
173
  gateway = self.gateways.get(gateway_address)
174
+ if not gateway:
175
+ return False, EdgeEvent.UNKNOWN, None
171
176
  node = gateway.get_node(node_address)
172
177
  if not gateway or not node:
173
178
  return False, EdgeEvent.UNKNOWN, None
174
179
 
175
180
  gateway.update_node_liveness(node_address)
176
- gateway.register_received_frame(frame, is_test_packet=False)
181
+ gateway.register_received_frame(frame)
182
+
183
+ # handle metrics probe packets
184
+ if frame.is_test_packet:
185
+ payload = self.metrics_tester.handle_response_cloud(frame, gateway, node)
186
+ if payload:
187
+ frame.payload = payload.to_bytes()
188
+
177
189
  return True, EdgeEvent.NODE_DATA, frame
178
190
 
179
191
  except Exception as e:
marilib/marilib_edge.py CHANGED
@@ -4,8 +4,14 @@ from datetime import datetime
4
4
  from typing import Any, Callable
5
5
  from rich import print
6
6
 
7
- from marilib.latency import LATENCY_PACKET_MAGIC, LatencyTester
8
- from marilib.mari_protocol import MARI_BROADCAST_ADDRESS, Frame, Header
7
+ from marilib.metrics import MetricsTester
8
+ from marilib.mari_protocol import (
9
+ MARI_BROADCAST_ADDRESS,
10
+ Frame,
11
+ Header,
12
+ DefaultPayload,
13
+ DefaultPayloadType,
14
+ )
9
15
  from marilib.model import (
10
16
  EdgeEvent,
11
17
  GatewayInfo,
@@ -19,8 +25,6 @@ from marilib.communication_adapter import MQTTAdapter, MQTTAdapterDummy, SerialA
19
25
  from marilib.marilib import MarilibBase
20
26
  from marilib.tui_edge import MarilibTUIEdge
21
27
 
22
- LOAD_PACKET_PAYLOAD = b"L"
23
-
24
28
 
25
29
  @dataclass
26
30
  class MarilibEdge(MarilibBase):
@@ -39,10 +43,11 @@ class MarilibEdge(MarilibBase):
39
43
  logger: Any | None = None
40
44
  gateway: MariGateway = field(default_factory=MariGateway)
41
45
  lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
42
- latency_tester: LatencyTester | None = None
46
+ metrics_tester: MetricsTester | None = None
43
47
 
44
48
  started_ts: datetime = field(default_factory=datetime.now)
45
49
  last_received_serial_data_ts: datetime = field(default_factory=datetime.now)
50
+ last_received_mqtt_data_ts: datetime = field(default_factory=datetime.now)
46
51
  main_file: str | None = None
47
52
 
48
53
  def __post_init__(self):
@@ -53,9 +58,9 @@ class MarilibEdge(MarilibBase):
53
58
  if self.mqtt_interface is None:
54
59
  self.mqtt_interface = MQTTAdapterDummy()
55
60
  self.serial_interface.init(self.on_serial_data_received)
56
- # NOTE: MQTT interface will only be initialized when the network_id is known
57
61
  if self.logger:
58
62
  self.logger.log_setup_parameters(self.setup_params)
63
+ self.metrics_tester = MetricsTester(self) # it may or not be started later
59
64
 
60
65
  # ============================ MarilibBase methods =========================
61
66
 
@@ -82,19 +87,18 @@ class MarilibEdge(MarilibBase):
82
87
  assert self.serial_interface is not None
83
88
 
84
89
  mari_frame = Frame(Header(destination=dst), payload=payload)
85
- is_test = self._is_test_packet(payload)
86
90
 
87
91
  with self.lock:
88
- # Only register statistics for normal data packets, not for test packets.
89
- self.gateway.register_sent_frame(mari_frame, is_test)
92
+ self.gateway.register_sent_frame(mari_frame)
90
93
  if dst == MARI_BROADCAST_ADDRESS:
91
94
  for n in self.gateway.nodes:
92
- n.register_sent_frame(mari_frame, is_test)
95
+ n.register_sent_frame(mari_frame)
93
96
  elif n := self.gateway.get_node(dst):
94
- n.register_sent_frame(mari_frame, is_test)
97
+ n.register_sent_frame(mari_frame)
95
98
 
96
- # FIXME: instead of prefixing with a magic 0x01 byte, we should use EdgeEvent.NODE_DATA
97
- self.serial_interface.send_data(b"\x01" + mari_frame.to_bytes())
99
+ self.serial_interface.send_data(
100
+ EdgeEvent.to_bytes(EdgeEvent.NODE_DATA) + mari_frame.to_bytes()
101
+ )
98
102
 
99
103
  def render_tui(self):
100
104
  if self.tui:
@@ -110,6 +114,10 @@ class MarilibEdge(MarilibBase):
110
114
  def uses_mqtt(self) -> bool:
111
115
  return not isinstance(self.mqtt_interface, MQTTAdapterDummy)
112
116
 
117
+ @property
118
+ def mqtt_connected(self) -> bool:
119
+ return self.uses_mqtt and self.mqtt_interface.is_ready()
120
+
113
121
  @property
114
122
  def serial_connected(self) -> bool:
115
123
  return self.serial_interface is not None
@@ -131,6 +139,9 @@ class MarilibEdge(MarilibBase):
131
139
  """Just forwards the data to the serial interface."""
132
140
  if len(data) < 1:
133
141
  return
142
+
143
+ self.last_received_mqtt_data_ts = datetime.now()
144
+
134
145
  try:
135
146
  event_type = EdgeEvent(data[0])
136
147
  frame = Frame().from_bytes(data[1:])
@@ -138,23 +149,17 @@ class MarilibEdge(MarilibBase):
138
149
  print(f"[red]Error parsing frame: {exc}[/]")
139
150
  return
140
151
  if event_type != EdgeEvent.NODE_DATA:
141
- # ignore non-data events
142
152
  return
143
153
  if frame.header.destination != MARI_BROADCAST_ADDRESS and not self.gateway.get_node(
144
154
  frame.header.destination
145
155
  ):
146
- # ignore frames for unknown nodes
147
156
  return
148
157
  self.send_frame(frame.header.destination, frame.payload)
149
158
 
150
159
  def handle_serial_data(self, data: bytes) -> tuple[bool, EdgeEvent, Any]:
151
160
  """
152
- Handles the serial data received from the radio gateway:
153
- - parses the event
154
- - updates node or gateway information
155
- - returns the event type and data
161
+ Handles the serial data received from the radio gateway.
156
162
  """
157
-
158
163
  if len(data) < 1:
159
164
  return False, EdgeEvent.UNKNOWN, None
160
165
 
@@ -196,53 +201,58 @@ class MarilibEdge(MarilibBase):
196
201
  frame = Frame().from_bytes(data[1:])
197
202
  with self.lock:
198
203
  self.gateway.update_node_liveness(frame.header.source)
199
- self.gateway.register_received_frame(frame, is_test_packet=False)
204
+ self.gateway.register_received_frame(frame)
205
+
206
+ # handle metrics probe packets
207
+ if frame.is_test_packet:
208
+ payload = self.metrics_tester.handle_response_edge(frame)
209
+ if payload:
210
+ frame.payload = payload.to_bytes()
211
+
200
212
  return True, event_type, frame
201
213
  except (ValueError, ProtocolPayloadParserException):
202
214
  return False, EdgeEvent.UNKNOWN, None
203
- return True, event_type, None
215
+
216
+ return False, event_type, None
204
217
 
205
218
  def on_serial_data_received(self, data: bytes):
206
219
  res, event_type, event_data = self.handle_serial_data(data)
207
- if res:
208
- if self.logger and event_type in [EdgeEvent.NODE_JOINED, EdgeEvent.NODE_LEFT]:
209
- self.logger.log_event(
210
- self.gateway.info.address, event_data.address, event_type.name
211
- )
212
- if event_type == EdgeEvent.GATEWAY_INFO:
213
- # when the first GATEWAY_INFO is received, this will cause the MQTT interface to be initialized
214
- self.mqtt_interface.update(event_data.network_id_str, self.on_mqtt_data_received)
215
- if self.logger:
216
- self.setup_params["schedule_name"] = self.gateway.info.schedule_name
217
- self.logger.log_setup_parameters(self.setup_params)
220
+ if not res:
221
+ return
222
+
223
+ if self.logger and event_type in [EdgeEvent.NODE_JOINED, EdgeEvent.NODE_LEFT]:
224
+ self.logger.log_event(self.gateway.info.address, event_data.address, event_type.name)
225
+ if event_type == EdgeEvent.GATEWAY_INFO:
226
+ self.mqtt_interface.update(event_data.network_id_str, self.on_mqtt_data_received)
227
+ if self.logger:
228
+ self.setup_params["schedule_name"] = self.gateway.info.schedule_name
229
+ self.logger.log_setup_parameters(self.setup_params)
230
+
231
+ if event_type == EdgeEvent.NODE_DATA and not event_data.is_test_packet:
232
+ # only notify the application if it's not a test packet
218
233
  self.cb_application(event_type, event_data)
219
- self.send_data_to_cloud(event_type, event_data)
234
+
235
+ self.send_data_to_cloud(event_type, event_data)
220
236
 
221
237
  def send_data_to_cloud(
222
238
  self, event_type: EdgeEvent, event_data: NodeInfoEdge | GatewayInfo | Frame
223
239
  ):
224
240
  if event_type in [EdgeEvent.NODE_JOINED, EdgeEvent.NODE_LEFT, EdgeEvent.NODE_KEEP_ALIVE]:
225
- # the cloud needs to know which gateway the node belongs to
226
241
  event_data = event_data.to_cloud(self.gateway.info.address)
227
242
  data = EdgeEvent.to_bytes(event_type) + event_data.to_bytes()
228
243
  self.mqtt_interface.send_data_to_cloud(data)
229
244
 
230
245
  # ============================ Utility methods =============================
231
246
 
232
- def latency_test_enable(self):
233
- if self.latency_tester is None:
234
- self.latency_tester = LatencyTester(self)
235
- self.latency_tester.start()
247
+ def metrics_test_enable(self):
248
+ self.metrics_tester.start()
236
249
 
237
- def latency_test_disable(self):
238
- if self.latency_tester is not None:
239
- self.latency_tester.stop()
240
- self.latency_tester = None
250
+ def metrics_test_disable(self):
251
+ self.metrics_tester.stop()
241
252
 
242
253
  # ============================ Private methods =============================
243
254
 
244
255
  def _is_test_packet(self, payload: bytes) -> bool:
245
- """Determines if a packet is for testing purposes (load or latency)."""
246
- is_latency = payload.startswith(LATENCY_PACKET_MAGIC)
247
- is_load = payload == LOAD_PACKET_PAYLOAD
248
- return is_latency or is_load
256
+ """Determines if a packet sent FROM the edge is for testing purposes."""
257
+ payload = DefaultPayload().from_bytes(payload)
258
+ return payload.type_ in [DefaultPayloadType.LOAD_TEST, DefaultPayloadType.LATENCY_TEST]
marilib/metrics.py ADDED
@@ -0,0 +1,141 @@
1
+ import threading
2
+ import time
3
+ from typing import TYPE_CHECKING
4
+
5
+ from rich import print
6
+ from marilib.mari_protocol import Frame, DefaultPayloadType
7
+ from marilib.mari_protocol import MetricsProbePayload
8
+ from marilib.model import MariGateway, MariNode
9
+
10
+ if TYPE_CHECKING:
11
+ from marilib.marilib_edge import MarilibEdge
12
+
13
+
14
+ class MetricsTester:
15
+ """A thread-based class to periodically test metrics to all nodes."""
16
+
17
+ def __init__(self, marilib: "MarilibEdge", interval: float = 3):
18
+ self.marilib = marilib
19
+ self.interval = interval
20
+ self._stop_event = threading.Event()
21
+ self._thread = threading.Thread(target=self._run, daemon=True)
22
+
23
+ def start(self):
24
+ """Starts the metrics testing thread."""
25
+ print("[yellow]Metrics tester started.[/]")
26
+ self._thread.start()
27
+
28
+ def stop(self):
29
+ """Stops the metrics testing thread."""
30
+ self._stop_event.set()
31
+ if self._thread.is_alive():
32
+ self._thread.join()
33
+ print("[yellow]Metrics tester stopped.[/]")
34
+
35
+ def _run(self):
36
+ """The main loop for the testing thread."""
37
+ # Initial delay to allow nodes to join
38
+ self._stop_event.wait(self.interval)
39
+
40
+ while not self._stop_event.is_set():
41
+ nodes = list(self.marilib.nodes)
42
+ if not nodes:
43
+ self._stop_event.wait(self.interval)
44
+ continue
45
+
46
+ for node in nodes:
47
+ if self._stop_event.is_set():
48
+ break
49
+ self.send_metrics_request(node, "edge")
50
+ # Spread the requests evenly over the interval
51
+ sleep_duration = self.interval / len(nodes)
52
+ self._stop_event.wait(sleep_duration)
53
+
54
+ def timestamp_us(self) -> int:
55
+ """Returns the current time in microseconds."""
56
+ return int(time.time() * 1000 * 1000)
57
+
58
+ def send_metrics_request(self, node: MariNode, marilib_type: str):
59
+ """Sends a metrics request packet to a specific address."""
60
+ payload = MetricsProbePayload()
61
+ if marilib_type == "edge":
62
+ payload.edge_tx_ts_us = self.timestamp_us()
63
+ payload.edge_tx_count = node.probe_increment_tx_count()
64
+ elif marilib_type == "cloud":
65
+ payload.cloud_tx_ts_us = self.timestamp_us()
66
+ payload.cloud_tx_count = node.probe_increment_tx_count()
67
+ # print(f">>> sending metrics probe to {node.address:016x}: {payload}")
68
+ payload = payload.to_bytes()
69
+ # print(f" size is {len(payload)} bytes: {payload.hex()}\n")
70
+ self.marilib.send_frame(node.address, payload)
71
+
72
+ def handle_response_edge(self, frame: Frame):
73
+ """
74
+ Processes a metrics response frame.
75
+ This should be called when a LATENCY_DATA event is received.
76
+ """
77
+ node = self.marilib.gateway.get_node(frame.header.source)
78
+ if not node:
79
+ print(f"[red]Node not found: {frame.header.source:016x}[/]")
80
+ return
81
+
82
+ try:
83
+ payload = MetricsProbePayload().from_bytes(frame.payload)
84
+ if payload.type_ != DefaultPayloadType.METRICS_PROBE:
85
+ print(f"[red]Expected METRICS_PROBE, got {payload.type_}[/]")
86
+ return
87
+
88
+ except Exception as e:
89
+ print(f"[red]Error parsing metrics response: {e}[/]")
90
+ return
91
+
92
+ payload.edge_rx_ts_us = self.timestamp_us()
93
+ payload.edge_rx_count = node.probe_increment_rx_count()
94
+
95
+ node.save_probe_stats(payload)
96
+
97
+ # print(f"<<< received metrics probe from {frame.header.source:016x}: {payload}")
98
+ # print(f" size is {len(frame.payload)} bytes: {frame.payload.hex()}\n")
99
+
100
+ # print(f" latency_roundtrip_node_edge_ms: {payload.latency_roundtrip_node_edge_ms()}")
101
+ # print(f" pdr_uplink_radio: {payload.pdr_uplink_radio(node.probe_stats_start_epoch)}")
102
+ # print(f" pdr_downlink_radio: {payload.pdr_downlink_radio(node.probe_stats_start_epoch)}")
103
+ # print(f" pdr_uplink_uart: {payload.pdr_uplink_uart(node.probe_stats_start_epoch)}")
104
+ # print(f" pdr_downlink_uart: {payload.pdr_downlink_uart(node.probe_stats_start_epoch)}")
105
+ # print(f" rssi_at_node_dbm: {payload.rssi_at_node_dbm()}")
106
+ # print(f" rssi_at_gw_dbm: {payload.rssi_at_gw_dbm()}")
107
+
108
+ return payload
109
+
110
+ def handle_response_cloud(self, frame: Frame, gateway: MariGateway, node: MariNode):
111
+ """
112
+ Processes a metrics response frame.
113
+ This should be called when a LATENCY_DATA event is received.
114
+ """
115
+ try:
116
+ payload = MetricsProbePayload().from_bytes(frame.payload)
117
+ if payload.type_ != DefaultPayloadType.METRICS_PROBE:
118
+ print(f"[red]Expected METRICS_PROBE, got {payload.type_}[/]")
119
+ return
120
+
121
+ except Exception as e:
122
+ print(f"[red]Error parsing metrics response: {e}[/]")
123
+ return
124
+
125
+ payload.cloud_rx_ts_us = self.timestamp_us()
126
+ payload.cloud_rx_count = node.probe_increment_rx_count()
127
+
128
+ node.save_probe_stats(payload)
129
+
130
+ # print(f"<<< received metrics probe from {frame.header.source:016x}: {payload}")
131
+ # print(f" size is {len(frame.payload)} bytes: {frame.payload.hex()}\n")
132
+
133
+ # print(f" latency_roundtrip_node_edge_ms: {payload.latency_roundtrip_node_edge_ms()}")
134
+ # print(f" pdr_uplink_radio: {payload.pdr_uplink_radio(node.probe_stats_start_epoch)}")
135
+ # print(f" pdr_downlink_radio: {payload.pdr_downlink_radio(node.probe_stats_start_epoch)}")
136
+ # print(f" pdr_uplink_uart: {payload.pdr_uplink_uart(node.probe_stats_start_epoch)}")
137
+ # print(f" pdr_downlink_uart: {payload.pdr_downlink_uart(node.probe_stats_start_epoch)}")
138
+ # print(f" rssi_at_node_dbm: {payload.rssi_at_node_dbm()}")
139
+ # print(f" rssi_at_gw_dbm: {payload.rssi_at_gw_dbm()}")
140
+
141
+ return payload