marilib-pkg 0.6.0__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.
@@ -0,0 +1,248 @@
1
+ import threading
2
+ from dataclasses import dataclass, field
3
+ from datetime import datetime
4
+ from typing import Any, Callable
5
+ from rich import print
6
+
7
+ from marilib.latency import LATENCY_PACKET_MAGIC, LatencyTester
8
+ from marilib.mari_protocol import MARI_BROADCAST_ADDRESS, Frame, Header
9
+ from marilib.model import (
10
+ EdgeEvent,
11
+ GatewayInfo,
12
+ MariGateway,
13
+ MariNode,
14
+ NodeInfoEdge,
15
+ SCHEDULES,
16
+ )
17
+ from marilib.protocol import ProtocolPayloadParserException
18
+ from marilib.communication_adapter import MQTTAdapter, MQTTAdapterDummy, SerialAdapter
19
+ from marilib.marilib import MarilibBase
20
+ from marilib.tui_edge import MarilibTUIEdge
21
+
22
+ LOAD_PACKET_PAYLOAD = b"L"
23
+
24
+
25
+ @dataclass
26
+ class MarilibEdge(MarilibBase):
27
+ """
28
+ The MarilibEdge class runs in either a computer or a raspberry pi.
29
+ It is used to communicate with:
30
+ - a Mari radio gateway (nRF5340) via serial
31
+ - a Mari cloud instance via MQTT (optional)
32
+ """
33
+
34
+ cb_application: Callable[[EdgeEvent, MariNode | Frame], None]
35
+ serial_interface: SerialAdapter
36
+ mqtt_interface: MQTTAdapter | None = None
37
+ tui: MarilibTUIEdge | None = None
38
+
39
+ logger: Any | None = None
40
+ gateway: MariGateway = field(default_factory=MariGateway)
41
+ lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
42
+ latency_tester: LatencyTester | None = None
43
+
44
+ started_ts: datetime = field(default_factory=datetime.now)
45
+ last_received_serial_data_ts: datetime = field(default_factory=datetime.now)
46
+ main_file: str | None = None
47
+
48
+ def __post_init__(self):
49
+ self.setup_params = {
50
+ "main_file": self.main_file or "unknown",
51
+ "serial_port": self.serial_interface.port,
52
+ }
53
+ if self.mqtt_interface is None:
54
+ self.mqtt_interface = MQTTAdapterDummy()
55
+ self.serial_interface.init(self.on_serial_data_received)
56
+ # NOTE: MQTT interface will only be initialized when the network_id is known
57
+ if self.logger:
58
+ self.logger.log_setup_parameters(self.setup_params)
59
+
60
+ # ============================ MarilibBase methods =========================
61
+
62
+ def update(self):
63
+ with self.lock:
64
+ self.gateway.update()
65
+ if self.logger and self.logger.active:
66
+ self.logger.log_periodic_metrics(self.gateway, self.gateway.nodes)
67
+
68
+ @property
69
+ def nodes(self) -> list[MariNode]:
70
+ return self.gateway.nodes
71
+
72
+ def add_node(self, address: int, gateway_address: int = None) -> MariNode | None:
73
+ with self.lock:
74
+ return self.gateway.add_node(address)
75
+
76
+ def remove_node(self, address: int) -> MariNode | None:
77
+ with self.lock:
78
+ return self.gateway.remove_node(address)
79
+
80
+ def send_frame(self, dst: int, payload: bytes):
81
+ """Sends a frame to the gateway via serial."""
82
+ assert self.serial_interface is not None
83
+
84
+ mari_frame = Frame(Header(destination=dst), payload=payload)
85
+ is_test = self._is_test_packet(payload)
86
+
87
+ 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)
90
+ if dst == MARI_BROADCAST_ADDRESS:
91
+ for n in self.gateway.nodes:
92
+ n.register_sent_frame(mari_frame, is_test)
93
+ elif n := self.gateway.get_node(dst):
94
+ n.register_sent_frame(mari_frame, is_test)
95
+
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())
98
+
99
+ def render_tui(self):
100
+ if self.tui:
101
+ self.tui.render(self)
102
+
103
+ def close_tui(self):
104
+ if self.tui:
105
+ self.tui.close()
106
+
107
+ # ============================ MarilibEdge methods =========================
108
+
109
+ @property
110
+ def uses_mqtt(self) -> bool:
111
+ return not isinstance(self.mqtt_interface, MQTTAdapterDummy)
112
+
113
+ @property
114
+ def serial_connected(self) -> bool:
115
+ return self.serial_interface is not None
116
+
117
+ def get_max_downlink_rate(self) -> float:
118
+ """Calculate the max downlink packets/sec for a given schedule_id."""
119
+ schedule_params = SCHEDULES.get(self.gateway.info.schedule_id)
120
+ if not schedule_params:
121
+ return 0.0
122
+ d_down = schedule_params["d_down"]
123
+ sf_duration_ms = schedule_params["sf_duration"]
124
+ if sf_duration_ms == 0:
125
+ return 0.0
126
+ return d_down / (sf_duration_ms / 1000.0)
127
+
128
+ # ============================ Callbacks ===================================
129
+
130
+ def on_mqtt_data_received(self, data: bytes):
131
+ """Just forwards the data to the serial interface."""
132
+ if len(data) < 1:
133
+ return
134
+ try:
135
+ event_type = EdgeEvent(data[0])
136
+ frame = Frame().from_bytes(data[1:])
137
+ except (ValueError, ProtocolPayloadParserException) as exc:
138
+ print(f"[red]Error parsing frame: {exc}[/]")
139
+ return
140
+ if event_type != EdgeEvent.NODE_DATA:
141
+ # ignore non-data events
142
+ return
143
+ if frame.header.destination != MARI_BROADCAST_ADDRESS and not self.gateway.get_node(
144
+ frame.header.destination
145
+ ):
146
+ # ignore frames for unknown nodes
147
+ return
148
+ self.send_frame(frame.header.destination, frame.payload)
149
+
150
+ def handle_serial_data(self, data: bytes) -> tuple[bool, EdgeEvent, Any]:
151
+ """
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
156
+ """
157
+
158
+ if len(data) < 1:
159
+ return False, EdgeEvent.UNKNOWN, None
160
+
161
+ self.last_received_serial_data_ts = datetime.now()
162
+
163
+ try:
164
+ event_type = EdgeEvent(data[0])
165
+ except ValueError:
166
+ return False, EdgeEvent.UNKNOWN, None
167
+
168
+ if event_type == EdgeEvent.NODE_JOINED:
169
+ node_info = NodeInfoEdge().from_bytes(data[1:])
170
+ self.add_node(node_info.address)
171
+ return True, event_type, node_info
172
+
173
+ elif event_type == EdgeEvent.NODE_LEFT:
174
+ node_info = NodeInfoEdge().from_bytes(data[1:])
175
+ if self.remove_node(node_info.address):
176
+ return True, event_type, node_info
177
+ else:
178
+ return False, event_type, node_info
179
+
180
+ elif event_type == EdgeEvent.NODE_KEEP_ALIVE:
181
+ node_info = NodeInfoEdge().from_bytes(data[1:])
182
+ with self.lock:
183
+ self.gateway.update_node_liveness(node_info.address)
184
+ return True, event_type, node_info
185
+
186
+ elif event_type == EdgeEvent.GATEWAY_INFO:
187
+ try:
188
+ with self.lock:
189
+ self.gateway.set_info(GatewayInfo().from_bytes(data[1:]))
190
+ return True, event_type, self.gateway.info
191
+ except (ValueError, ProtocolPayloadParserException):
192
+ return False, EdgeEvent.UNKNOWN, None
193
+
194
+ elif event_type == EdgeEvent.NODE_DATA:
195
+ try:
196
+ frame = Frame().from_bytes(data[1:])
197
+ with self.lock:
198
+ self.gateway.update_node_liveness(frame.header.source)
199
+ self.gateway.register_received_frame(frame, is_test_packet=False)
200
+ return True, event_type, frame
201
+ except (ValueError, ProtocolPayloadParserException):
202
+ return False, EdgeEvent.UNKNOWN, None
203
+ return True, event_type, None
204
+
205
+ def on_serial_data_received(self, data: bytes):
206
+ 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)
218
+ self.cb_application(event_type, event_data)
219
+ self.send_data_to_cloud(event_type, event_data)
220
+
221
+ def send_data_to_cloud(
222
+ self, event_type: EdgeEvent, event_data: NodeInfoEdge | GatewayInfo | Frame
223
+ ):
224
+ 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
+ event_data = event_data.to_cloud(self.gateway.info.address)
227
+ data = EdgeEvent.to_bytes(event_type) + event_data.to_bytes()
228
+ self.mqtt_interface.send_data_to_cloud(data)
229
+
230
+ # ============================ Utility methods =============================
231
+
232
+ def latency_test_enable(self):
233
+ if self.latency_tester is None:
234
+ self.latency_tester = LatencyTester(self)
235
+ self.latency_tester.start()
236
+
237
+ def latency_test_disable(self):
238
+ if self.latency_tester is not None:
239
+ self.latency_tester.stop()
240
+ self.latency_tester = None
241
+
242
+ # ============================ Private methods =============================
243
+
244
+ 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
marilib/model.py ADDED
@@ -0,0 +1,393 @@
1
+ import statistics
2
+ from collections import deque
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timedelta
5
+ from enum import IntEnum
6
+ import rich
7
+
8
+ from marilib.mari_protocol import Frame
9
+ from marilib.protocol import Packet, PacketFieldMetadata
10
+
11
+ # schedules taken from: https://github.com/DotBots/mari-evaluation/blob/main/simulations/radio-schedule.ipynb
12
+ SCHEDULES = {
13
+ # schedule_id: {name, max_nodes, d_down, sf_duration_ms}
14
+ 1: {
15
+ "name": "huge",
16
+ "slots": "BBB" + ("UUSDUUUUSDUUU" * 11) + "U" * 0,
17
+ "max_nodes": 102,
18
+ "d_down": 22,
19
+ "sf_duration": 256.88,
20
+ },
21
+ 3: {
22
+ "name": "big",
23
+ "slots": "BBB" + ("UUSDUUUUSDUU" * 8) + "U" * 0,
24
+ "max_nodes": 66,
25
+ "d_down": 16,
26
+ "sf_duration": 174.12,
27
+ },
28
+ 4: {
29
+ "name": "medium",
30
+ "slots": "BBB" + ("UUSDUUUUSDUU" * 5) + "U" * 0,
31
+ "max_nodes": 44,
32
+ "d_down": 10,
33
+ "sf_duration": 115.51,
34
+ },
35
+ 6: {
36
+ "name": "tiny",
37
+ "slots": "BBB" + ("UUSDUUUUSDUU" * 1) + "U" * 0,
38
+ "max_nodes": 10,
39
+ "d_down": 2,
40
+ "sf_duration": 29.31,
41
+ },
42
+ }
43
+
44
+ EMPTY_SCHEDULE_DATA = {
45
+ "name": "unknown",
46
+ "slots": "",
47
+ "max_nodes": 0,
48
+ "d_down": 0,
49
+ "sf_duration": 0,
50
+ }
51
+
52
+ MARI_TIMEOUT_NODE_IS_ALIVE = 3 # seconds
53
+ MARI_TIMEOUT_GATEWAY_IS_ALIVE = 3 # seconds
54
+
55
+
56
+ @dataclass
57
+ class TestState:
58
+ rate: int = 0
59
+ load: int = 0
60
+
61
+
62
+ class EdgeEvent(IntEnum):
63
+ NODE_JOINED = 1
64
+ NODE_LEFT = 2
65
+ NODE_DATA = 3
66
+ NODE_KEEP_ALIVE = 4
67
+ GATEWAY_INFO = 5
68
+ UNKNOWN = 255
69
+
70
+ @classmethod
71
+ def to_bytes(cls, event: "EdgeEvent") -> bytes:
72
+ return event.value.to_bytes(1, "little")
73
+
74
+
75
+ @dataclass
76
+ class NodeInfoCloud(Packet):
77
+ metadata: list[PacketFieldMetadata] = field(
78
+ default_factory=lambda: [
79
+ PacketFieldMetadata(name="address", length=8),
80
+ PacketFieldMetadata(name="gateway_address", length=8),
81
+ ],
82
+ repr=False,
83
+ )
84
+ address: int = 0
85
+ gateway_address: int = 0
86
+
87
+
88
+ @dataclass
89
+ class NodeInfoEdge(Packet):
90
+ metadata: list[PacketFieldMetadata] = field(
91
+ default_factory=lambda: [
92
+ PacketFieldMetadata(name="address", length=8),
93
+ ],
94
+ repr=False,
95
+ )
96
+ address: int = 0
97
+
98
+ def to_cloud(self, gateway_address: int) -> NodeInfoCloud:
99
+ return NodeInfoCloud(address=self.address, gateway_address=gateway_address)
100
+
101
+
102
+ @dataclass
103
+ class NodeStatsReply(Packet):
104
+ """Dataclass representing the statistics packet sent back by a node."""
105
+
106
+ metadata: list[PacketFieldMetadata] = field(
107
+ default_factory=lambda: [
108
+ PacketFieldMetadata(name="rx_app_packets", length=4),
109
+ PacketFieldMetadata(name="tx_app_packets", length=4),
110
+ ]
111
+ )
112
+ rx_app_packets: int = 0
113
+ tx_app_packets: int = 0
114
+
115
+
116
+ @dataclass
117
+ class FrameLogEntry:
118
+ frame: Frame
119
+ ts: datetime = field(default_factory=lambda: datetime.now())
120
+ is_test_packet: bool = False
121
+
122
+
123
+ @dataclass
124
+ class LatencyStats:
125
+ latencies: deque = field(default_factory=lambda: deque(maxlen=50))
126
+
127
+ def add_latency(self, rtt_seconds: float):
128
+ self.latencies.append(rtt_seconds * 1000)
129
+
130
+ @property
131
+ def last_ms(self) -> float:
132
+ return self.latencies[-1] if self.latencies else 0.0
133
+
134
+ @property
135
+ def avg_ms(self) -> float:
136
+ return statistics.mean(self.latencies) if self.latencies else 0.0
137
+
138
+ @property
139
+ def min_ms(self) -> float:
140
+ return min(self.latencies) if self.latencies else 0.0
141
+
142
+ @property
143
+ def max_ms(self) -> float:
144
+ return max(self.latencies) if self.latencies else 0.0
145
+
146
+
147
+ @dataclass
148
+ class FrameStats:
149
+ window_seconds: int = 240 # set window duration
150
+ sent: deque[FrameLogEntry] = field(default_factory=deque)
151
+ received: deque[FrameLogEntry] = field(default_factory=deque)
152
+ cumulative_sent: int = 0
153
+ cumulative_received: int = 0
154
+ cumulative_sent_non_test: int = 0
155
+ cumulative_received_non_test: int = 0
156
+
157
+ def add_sent(self, frame: Frame, is_test_packet: bool):
158
+ """Adds a sent frame, prunes old entries, and updates counters."""
159
+ self.cumulative_sent += 1
160
+
161
+ if not is_test_packet:
162
+ self.cumulative_sent_non_test += 1
163
+
164
+ entry = FrameLogEntry(frame=frame, is_test_packet=is_test_packet)
165
+ self.sent.append(entry)
166
+ while self.sent and (entry.ts - self.sent[0].ts).total_seconds() > self.window_seconds:
167
+ self.sent.popleft()
168
+
169
+ def add_received(self, frame: Frame, is_test_packet: bool):
170
+ """Adds a received frame and prunes old entries."""
171
+ self.cumulative_received += 1
172
+
173
+ if not is_test_packet:
174
+ self.cumulative_received_non_test += 1
175
+ entry = FrameLogEntry(frame=frame, is_test_packet=is_test_packet)
176
+ self.received.append(entry)
177
+ while (
178
+ self.received
179
+ and (entry.ts - self.received[0].ts).total_seconds() > self.window_seconds
180
+ ):
181
+ self.received.popleft()
182
+
183
+ def sent_count(self, window_secs: int = 0, include_test_packets: bool = True) -> int:
184
+ if window_secs == 0:
185
+ return self.cumulative_sent if include_test_packets else self.cumulative_sent_non_test
186
+
187
+ now = datetime.now()
188
+ # Windowed count is always for non-test packets.
189
+ entries = [e for e in self.sent if now - e.ts < timedelta(seconds=window_secs)]
190
+ return len(entries)
191
+
192
+ def received_count(self, window_secs: int = 0, include_test_packets: bool = True) -> int:
193
+ if window_secs == 0:
194
+ return (
195
+ self.cumulative_received
196
+ if include_test_packets
197
+ else self.cumulative_received_non_test
198
+ )
199
+
200
+ now = datetime.now()
201
+ entries = [e for e in self.received if now - e.ts < timedelta(seconds=window_secs)]
202
+ return len(entries)
203
+
204
+ def success_rate(self, window_secs: int = 0) -> float:
205
+ s = self.sent_count(window_secs, include_test_packets=False)
206
+ if s == 0:
207
+ return 1.0
208
+ r = self.received_count(window_secs, include_test_packets=False)
209
+ return min(r / s, 1.0)
210
+
211
+ def received_rssi_dbm(self, window_secs: int = 0) -> float:
212
+ if not self.received:
213
+ return 0
214
+
215
+ if window_secs == 0:
216
+ return int(self.received[-1].frame.stats.rssi_dbm) if self.received else 0
217
+ n = datetime.now()
218
+ d = [
219
+ e.frame.stats.rssi_dbm
220
+ for e in self.received
221
+ if (n - e.ts < timedelta(seconds=window_secs))
222
+ ]
223
+ return int(sum(d) / len(d) if d else 0)
224
+
225
+
226
+ @dataclass
227
+ class MariNode:
228
+ address: int
229
+ gateway_address: int
230
+ last_seen: datetime = field(default_factory=lambda: datetime.now())
231
+ stats: FrameStats = field(default_factory=FrameStats)
232
+ latency_stats: LatencyStats = field(default_factory=LatencyStats)
233
+ last_reported_rx_count: int = 0
234
+ last_reported_tx_count: int = 0
235
+ pdr_downlink: float = 0.0
236
+ pdr_uplink: float = 0.0
237
+
238
+ @property
239
+ def is_alive(self) -> bool:
240
+ return datetime.now() - self.last_seen < timedelta(seconds=MARI_TIMEOUT_NODE_IS_ALIVE)
241
+
242
+ def register_received_frame(self, frame: Frame, is_test_packet: bool):
243
+ self.stats.add_received(frame, is_test_packet)
244
+
245
+ def register_sent_frame(self, frame: Frame, is_test_packet: bool):
246
+ self.stats.add_sent(frame, is_test_packet)
247
+
248
+ def as_node_info_cloud(self) -> NodeInfoCloud:
249
+ return NodeInfoCloud(address=self.address, gateway_address=self.gateway_address)
250
+
251
+
252
+ @dataclass
253
+ class GatewayInfo(Packet):
254
+ metadata: list[PacketFieldMetadata] = field(
255
+ default_factory=lambda: [
256
+ PacketFieldMetadata(name="address", length=8),
257
+ PacketFieldMetadata(name="network_id", length=2),
258
+ PacketFieldMetadata(name="schedule_id", length=1),
259
+ PacketFieldMetadata(name="schedule_stats", length=4 * 8), # 4 uint64_t values
260
+ ]
261
+ )
262
+ address: int = 0
263
+ network_id: int = 0
264
+ schedule_id: int = 0
265
+ schedule_stats: bytes = b""
266
+
267
+ # NOTE: maybe move to a separate class, dedicated to schedule stuff
268
+ def repr_schedule_stats(self):
269
+ if not self.schedule_stats:
270
+ return ""
271
+ schedule_data = SCHEDULES.get(self.schedule_id)
272
+ if not schedule_data:
273
+ return ""
274
+ all_bits = format(self.schedule_stats, f"0{4*8*8}b")
275
+ all_bits = [all_bits[i : i + 8] for i in range(0, len(all_bits), 8)]
276
+ all_bits.reverse()
277
+ # print(">>>", reversed(all_bits[0].split("")))
278
+ all_bits = [list(reversed(bits)) for bits in all_bits]
279
+ # now just flatten the list
280
+ all_bits = [item for sublist in all_bits for item in sublist]
281
+ # FIXME: why do we need to skip the first byte?
282
+ all_bits = all_bits[8:]
283
+ # cut it down to the number of slots
284
+ all_bits = all_bits[: len(schedule_data["slots"])]
285
+ return "".join(all_bits)
286
+
287
+ def repr_cell_nice(self, cell: str, is_used: int):
288
+ is_used = bool(int(is_used))
289
+ if cell == "B":
290
+ return rich.text.Text("B", style=f'bold white on {"red" if is_used else "indian_red"}')
291
+ elif cell == "S":
292
+ return rich.text.Text(
293
+ "S", style=f'bold white on {"purple" if is_used else "medium_purple2"}'
294
+ )
295
+ elif cell == "D":
296
+ return rich.text.Text(
297
+ "D", style=f'bold white on {"green" if is_used else "sea_green3"}'
298
+ )
299
+ elif cell == "U":
300
+ return rich.text.Text(
301
+ " ", style=f'bold white on {"yellow" if is_used else "light_yellow3"}'
302
+ )
303
+
304
+ def repr_schedule_cells_with_colors(self):
305
+ schedule_data = SCHEDULES.get(self.schedule_id)
306
+ if not schedule_data:
307
+ return ""
308
+ sched_stats = [
309
+ self.repr_cell_nice(cell, is_used)
310
+ for cell, is_used in zip(schedule_data["slots"], self.repr_schedule_stats())
311
+ ]
312
+ return rich.text.Text.assemble(*sched_stats)
313
+
314
+ @property
315
+ def schedule_name(self) -> str:
316
+ schedule_data = SCHEDULES.get(self.schedule_id)
317
+ return schedule_data["name"] if schedule_data else "unknown"
318
+
319
+ @property
320
+ def network_id_str(self) -> str:
321
+ return f"{self.network_id:04X}"
322
+
323
+ @property
324
+ def schedule_uplink_cells(self) -> int:
325
+ return SCHEDULES.get(self.schedule_id, EMPTY_SCHEDULE_DATA)["max_nodes"]
326
+
327
+ @property
328
+ def schedule_downlink_cells(self) -> int:
329
+ return SCHEDULES.get(self.schedule_id, EMPTY_SCHEDULE_DATA)["slots"].count("D")
330
+
331
+
332
+ @dataclass
333
+ class MariGateway:
334
+ info: GatewayInfo = field(default_factory=GatewayInfo)
335
+ node_registry: dict[int, MariNode] = field(default_factory=dict)
336
+ stats: FrameStats = field(default_factory=FrameStats)
337
+ latency_stats: LatencyStats = field(default_factory=LatencyStats)
338
+ last_seen: datetime = field(default_factory=lambda: datetime.now())
339
+
340
+ def __post_init__(self):
341
+ self.last_seen = datetime.now()
342
+
343
+ @property
344
+ def nodes(self) -> list[MariNode]:
345
+ return list(self.node_registry.values())
346
+
347
+ @property
348
+ def nodes_addresses(self) -> list[int]:
349
+ return list(self.node_registry.keys())
350
+
351
+ @property
352
+ def is_alive(self) -> bool:
353
+ return datetime.now() - self.last_seen < timedelta(seconds=MARI_TIMEOUT_GATEWAY_IS_ALIVE)
354
+
355
+ def update(self):
356
+ """Recurrent bookkeeping. Don't forget to call this periodically on your main loop."""
357
+ self.node_registry = {
358
+ addr: node for addr, node in self.node_registry.items() if node.is_alive
359
+ }
360
+
361
+ def set_info(self, info: GatewayInfo):
362
+ self.info = info
363
+ self.last_seen = datetime.now()
364
+
365
+ def get_node(self, addr: int) -> MariNode | None:
366
+ return self.node_registry.get(addr)
367
+
368
+ def add_node(self, addr: int) -> MariNode:
369
+ if node := self.get_node(addr):
370
+ node.last_seen = datetime.now()
371
+ return node
372
+ node = MariNode(addr, self.info.address)
373
+ self.node_registry[addr] = node
374
+ return node
375
+
376
+ def remove_node(self, addr: int) -> MariNode | None:
377
+ return self.node_registry.pop(addr, None)
378
+
379
+ def update_node_liveness(self, addr: int) -> MariNode:
380
+ node = self.get_node(addr)
381
+ if node:
382
+ node.last_seen = datetime.now()
383
+ else:
384
+ node = self.add_node(addr)
385
+ return node
386
+
387
+ def register_received_frame(self, frame: Frame, is_test_packet: bool):
388
+ if n := self.get_node(frame.header.source):
389
+ n.register_received_frame(frame, is_test_packet)
390
+ self.stats.add_received(frame, is_test_packet)
391
+
392
+ def register_sent_frame(self, frame: Frame, is_test_packet: bool):
393
+ self.stats.add_sent(frame, is_test_packet)