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.
- examples/frames.py +23 -0
- examples/mari_cloud.py +72 -0
- examples/mari_cloud_minimal.py +38 -0
- examples/mari_edge.py +73 -0
- examples/mari_edge_minimal.py +37 -0
- examples/mari_edge_stats.py +156 -0
- examples/uart.py +35 -0
- marilib/__init__.py +10 -0
- marilib/communication_adapter.py +212 -0
- marilib/latency.py +78 -0
- marilib/logger.py +211 -0
- marilib/mari_protocol.py +76 -0
- marilib/marilib.py +35 -0
- marilib/marilib_cloud.py +193 -0
- marilib/marilib_edge.py +248 -0
- marilib/model.py +393 -0
- marilib/protocol.py +109 -0
- marilib/serial_hdlc.py +228 -0
- marilib/serial_uart.py +84 -0
- marilib/tui.py +13 -0
- marilib/tui_cloud.py +158 -0
- marilib/tui_edge.py +185 -0
- marilib_pkg-0.6.0.dist-info/METADATA +57 -0
- marilib_pkg-0.6.0.dist-info/RECORD +30 -0
- marilib_pkg-0.6.0.dist-info/WHEEL +4 -0
- marilib_pkg-0.6.0.dist-info/licenses/AUTHORS +2 -0
- marilib_pkg-0.6.0.dist-info/licenses/LICENSE +28 -0
- tests/__init__.py +0 -0
- tests/test_hdlc.py +76 -0
- tests/test_protocol.py +35 -0
marilib/marilib_edge.py
ADDED
@@ -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)
|