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/protocol.py
ADDED
@@ -0,0 +1,109 @@
|
|
1
|
+
# TODO: import this from like PyDotBot or similar
|
2
|
+
|
3
|
+
import dataclasses
|
4
|
+
import typing
|
5
|
+
from abc import ABC
|
6
|
+
from dataclasses import dataclass
|
7
|
+
from enum import IntEnum
|
8
|
+
|
9
|
+
|
10
|
+
class ProtocolPayloadParserException(Exception):
|
11
|
+
"""Exception raised on invalid or unsupported payload."""
|
12
|
+
|
13
|
+
|
14
|
+
class PacketType(IntEnum):
|
15
|
+
"""Types of MAC layer packet."""
|
16
|
+
|
17
|
+
BEACON = 1
|
18
|
+
JOIN_REQUEST = 2
|
19
|
+
JOIN_RESPONSE = 4
|
20
|
+
KEEP_ALIVE = 8
|
21
|
+
DATA = 16
|
22
|
+
|
23
|
+
|
24
|
+
@dataclass
|
25
|
+
class PacketFieldMetadata:
|
26
|
+
"""Data class that describes a packet field metadata."""
|
27
|
+
|
28
|
+
name: str = ""
|
29
|
+
disp: str = ""
|
30
|
+
length: int = 1
|
31
|
+
signed: bool = False
|
32
|
+
type_: typing.Any = int
|
33
|
+
|
34
|
+
def __post_init__(self):
|
35
|
+
if not self.disp:
|
36
|
+
self.disp = self.name
|
37
|
+
|
38
|
+
|
39
|
+
@dataclass
|
40
|
+
class Packet(ABC):
|
41
|
+
"""Base class for packet classes."""
|
42
|
+
|
43
|
+
@property
|
44
|
+
def size(self) -> int:
|
45
|
+
return sum(field.length for field in self.metadata)
|
46
|
+
|
47
|
+
def from_bytes(self, bytes_):
|
48
|
+
fields = dataclasses.fields(self)
|
49
|
+
# base class makes metadata attribute mandatory so there's at least one
|
50
|
+
# field defined in subclasses
|
51
|
+
# first elements in fields has to be metadata
|
52
|
+
if not fields or fields[0].name != "metadata":
|
53
|
+
raise ValueError("metadata must be defined first")
|
54
|
+
metadata = fields[0].default_factory()
|
55
|
+
for idx, field in enumerate(fields[1:]):
|
56
|
+
if metadata[idx].type_ is list:
|
57
|
+
element_class = typing.get_args(field.type)[0]
|
58
|
+
field_attribute = getattr(self, field.name)
|
59
|
+
# subclass element is a list and previous attribute is called
|
60
|
+
# "count" and should have already been retrieved from the byte
|
61
|
+
# stream
|
62
|
+
for _ in range(self.count):
|
63
|
+
element = element_class()
|
64
|
+
if len(bytes_) < element.size:
|
65
|
+
raise ValueError("Not enough bytes to parse")
|
66
|
+
field_attribute.append(element.from_bytes(bytes_))
|
67
|
+
bytes_ = bytes_[element.size :]
|
68
|
+
elif metadata[idx].type_ in [bytes, bytearray]:
|
69
|
+
# subclass element is bytes and previous attribute is called
|
70
|
+
# "count" and should have already been retrieved from the byte
|
71
|
+
# stream
|
72
|
+
length = metadata[idx].length
|
73
|
+
if hasattr(self, "count"):
|
74
|
+
length = self.count
|
75
|
+
setattr(self, field.name, bytes_[0:length])
|
76
|
+
bytes_ = bytes_[length:]
|
77
|
+
else:
|
78
|
+
length = metadata[idx].length
|
79
|
+
if len(bytes_) < length:
|
80
|
+
raise ValueError("Not enough bytes to parse")
|
81
|
+
setattr(
|
82
|
+
self,
|
83
|
+
field.name,
|
84
|
+
int.from_bytes(
|
85
|
+
bytes=bytes_[0:length],
|
86
|
+
signed=metadata[idx].signed,
|
87
|
+
byteorder="little",
|
88
|
+
),
|
89
|
+
)
|
90
|
+
bytes_ = bytes_[length:]
|
91
|
+
return self
|
92
|
+
|
93
|
+
def to_bytes(self, byteorder="little") -> bytes:
|
94
|
+
buffer = bytearray()
|
95
|
+
metadata = dataclasses.fields(self)[0].default_factory()
|
96
|
+
for idx, field in enumerate(dataclasses.fields(self)[1:]):
|
97
|
+
value = getattr(self, field.name)
|
98
|
+
if isinstance(value, list):
|
99
|
+
for element in value:
|
100
|
+
buffer += element.to_bytes()
|
101
|
+
elif isinstance(value, (bytes, bytearray)):
|
102
|
+
buffer += value
|
103
|
+
else:
|
104
|
+
buffer += int(value).to_bytes(
|
105
|
+
length=metadata[idx].length,
|
106
|
+
byteorder=byteorder,
|
107
|
+
signed=metadata[idx].signed,
|
108
|
+
)
|
109
|
+
return buffer
|
marilib/serial_hdlc.py
ADDED
@@ -0,0 +1,228 @@
|
|
1
|
+
"""Module implementing HDLC protocol primitives."""
|
2
|
+
|
3
|
+
import logging
|
4
|
+
from enum import Enum
|
5
|
+
|
6
|
+
HDLC_FLAG = b"\x7e"
|
7
|
+
HDLC_FLAG_ESCAPED = b"\x5e"
|
8
|
+
HDLC_ESCAPE = b"\x7d"
|
9
|
+
HDLC_ESCAPE_ESCAPED = b"\x5d"
|
10
|
+
HDLC_FCS_INIT = 0xFFFF
|
11
|
+
HDLC_FCS_OK = 0xF0B8
|
12
|
+
|
13
|
+
# fmt: off
|
14
|
+
FCS16TAB = (
|
15
|
+
0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF,
|
16
|
+
0x8C48, 0x9DC1, 0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7,
|
17
|
+
0x1081, 0x0108, 0x3393, 0x221A, 0x56A5, 0x472C, 0x75B7, 0x643E,
|
18
|
+
0x9CC9, 0x8D40, 0xBFDB, 0xAE52, 0xDAED, 0xCB64, 0xF9FF, 0xE876,
|
19
|
+
0x2102, 0x308B, 0x0210, 0x1399, 0x6726, 0x76AF, 0x4434, 0x55BD,
|
20
|
+
0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, 0xFAE7, 0xC87C, 0xD9F5,
|
21
|
+
0x3183, 0x200A, 0x1291, 0x0318, 0x77A7, 0x662E, 0x54B5, 0x453C,
|
22
|
+
0xBDCB, 0xAC42, 0x9ED9, 0x8F50, 0xFBEF, 0xEA66, 0xD8FD, 0xC974,
|
23
|
+
0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9, 0x2732, 0x36BB,
|
24
|
+
0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3,
|
25
|
+
0x5285, 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A,
|
26
|
+
0xDECD, 0xCF44, 0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72,
|
27
|
+
0x6306, 0x728F, 0x4014, 0x519D, 0x2522, 0x34AB, 0x0630, 0x17B9,
|
28
|
+
0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, 0xA96A, 0xB8E3, 0x8A78, 0x9BF1,
|
29
|
+
0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, 0x242A, 0x16B1, 0x0738,
|
30
|
+
0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, 0x9AF9, 0x8B70,
|
31
|
+
0x8408, 0x9581, 0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, 0xF0B7,
|
32
|
+
0x0840, 0x19C9, 0x2B52, 0x3ADB, 0x4E64, 0x5FED, 0x6D76, 0x7CFF,
|
33
|
+
0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324, 0xF1BF, 0xE036,
|
34
|
+
0x18C1, 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E,
|
35
|
+
0xA50A, 0xB483, 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5,
|
36
|
+
0x2942, 0x38CB, 0x0A50, 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD,
|
37
|
+
0xB58B, 0xA402, 0x9699, 0x8710, 0xF3AF, 0xE226, 0xD0BD, 0xC134,
|
38
|
+
0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, 0x6E6E, 0x5CF5, 0x4D7C,
|
39
|
+
0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, 0xA33A, 0xB2B3,
|
40
|
+
0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, 0x3EFB,
|
41
|
+
0xD68D, 0xC704, 0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232,
|
42
|
+
0x5AC5, 0x4B4C, 0x79D7, 0x685E, 0x1CE1, 0x0D68, 0x3FF3, 0x2E7A,
|
43
|
+
0xE70E, 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3, 0x8238, 0x93B1,
|
44
|
+
0x6B46, 0x7ACF, 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9,
|
45
|
+
0xF78F, 0xE606, 0xD49D, 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330,
|
46
|
+
0x7BC7, 0x6A4E, 0x58D5, 0x495C, 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78,
|
47
|
+
)
|
48
|
+
# fmt: on
|
49
|
+
|
50
|
+
|
51
|
+
class HDLCDecodeException(Exception):
|
52
|
+
"""Exception raised when decoding wrong HDLC frames."""
|
53
|
+
|
54
|
+
|
55
|
+
def _fcs_update(fcs, byte):
|
56
|
+
return (fcs >> 8) ^ FCS16TAB[((fcs ^ ord(byte)) & 0xFF)]
|
57
|
+
|
58
|
+
|
59
|
+
def _to_byte(value):
|
60
|
+
return int(value).to_bytes(1, "little")
|
61
|
+
|
62
|
+
|
63
|
+
def _escape_byte(byte) -> bytes:
|
64
|
+
result = bytearray()
|
65
|
+
if byte == HDLC_ESCAPE:
|
66
|
+
result += HDLC_ESCAPE
|
67
|
+
result += HDLC_ESCAPE_ESCAPED
|
68
|
+
elif byte == HDLC_FLAG:
|
69
|
+
result += HDLC_ESCAPE
|
70
|
+
result += HDLC_FLAG_ESCAPED
|
71
|
+
else:
|
72
|
+
result += byte
|
73
|
+
return result
|
74
|
+
|
75
|
+
|
76
|
+
def hdlc_encode(payload: bytes) -> bytes:
|
77
|
+
"""Encodes a payload in an HDLC frame.
|
78
|
+
>>> hdlc_encode(b"test")
|
79
|
+
bytearray(b'~test\\x88\\x07~')
|
80
|
+
>>> hdlc_encode(b"")
|
81
|
+
bytearray(b'~\\x00\\x00~')
|
82
|
+
>>> hdlc_encode(b"\\x00\\x00\\xf6\\xf6\\xf6\\xf6")
|
83
|
+
bytearray(b'~\\x00\\x00\\xf6\\xf6\\xf6\\xf6\\xb2+~')
|
84
|
+
>>> hdlc_encode(b"\\x00\\x01\\n\\n\\n")
|
85
|
+
bytearray(b'~\\x00\\x01\\n\\n\\n\\x9c\\xf2~')
|
86
|
+
>>> hdlc_encode(b"~test~")
|
87
|
+
bytearray(b'~}^test}^\\x9d\\xa6~')
|
88
|
+
>>> hdlc_encode(b"~test}")
|
89
|
+
bytearray(b'~}^test}]\\x06\\x94~')
|
90
|
+
>>> hdlc_encode(b"\\xe7\\x94:\\xa6")
|
91
|
+
bytearray(b'~\\xe7\\x94:\\xa6\\x83}^~')
|
92
|
+
>>> hdlc_encode(b"'$W\\x82")
|
93
|
+
bytearray(b"~\\'$W\\x82\\x13}]~")
|
94
|
+
"""
|
95
|
+
# initialize output buffer
|
96
|
+
hdlc_frame = bytearray()
|
97
|
+
|
98
|
+
# initialize frame check sequence
|
99
|
+
fcs = HDLC_FCS_INIT
|
100
|
+
|
101
|
+
# add start flag
|
102
|
+
hdlc_frame += HDLC_FLAG
|
103
|
+
|
104
|
+
# write payload in frame
|
105
|
+
for byte in payload:
|
106
|
+
fcs = _fcs_update(fcs, _to_byte(byte))
|
107
|
+
hdlc_frame += _escape_byte(_to_byte(byte))
|
108
|
+
fcs = 0xFFFF - fcs
|
109
|
+
|
110
|
+
# add FCS
|
111
|
+
hdlc_frame += _escape_byte(_to_byte(fcs & 0xFF))
|
112
|
+
hdlc_frame += _escape_byte(_to_byte((fcs & 0xFF00) >> 8))
|
113
|
+
|
114
|
+
# add end flag
|
115
|
+
hdlc_frame += HDLC_FLAG
|
116
|
+
|
117
|
+
return hdlc_frame
|
118
|
+
|
119
|
+
|
120
|
+
def hdlc_decode(frame: bytes) -> bytes:
|
121
|
+
"""Decodes an HDLC frame and return the payload it contains.
|
122
|
+
|
123
|
+
>>> hdlc_decode(b"~test\\x88\\x07~")
|
124
|
+
bytearray(b'test')
|
125
|
+
>>> hdlc_decode(b"~\\x00\\x00\\xf6\\xf6\\xf6\\xf6\\xb2+~")
|
126
|
+
bytearray(b'\\x00\\x00\\xf6\\xf6\\xf6\\xf6')
|
127
|
+
>>> hdlc_decode(b"~\\x00\\x01\\n\\n\\n\\x9c\\xf2~")
|
128
|
+
bytearray(b'\\x00\\x01\\n\\n\\n')
|
129
|
+
>>> hdlc_decode(b"~}^test}^\\x9d\\xa6~")
|
130
|
+
bytearray(b'~test~')
|
131
|
+
>>> hdlc_decode(b"~}^test}]\\x06\\x94~")
|
132
|
+
bytearray(b'~test}')
|
133
|
+
>>> hdlc_decode(b"~\\xe7\\x94:\\xa6\\x83}^~")
|
134
|
+
bytearray(b'\\xe7\\x94:\\xa6')
|
135
|
+
>>> hdlc_decode(b"~\\'$W\\x82\\x13}]~")
|
136
|
+
bytearray(b"\\'$W\\x82")
|
137
|
+
>>> hdlc_decode(b"~\\x00\\x00~")
|
138
|
+
bytearray(b'')
|
139
|
+
>>> hdlc_decode(b"~test\\x42\\x42~")
|
140
|
+
Traceback (most recent call last):
|
141
|
+
marilib.serial_hdlc.HDLCDecodeException: Invalid FCS
|
142
|
+
>>> hdlc_decode(b"~\\x00~")
|
143
|
+
Traceback (most recent call last):
|
144
|
+
marilib.serial_hdlc.HDLCDecodeException: Invalid payload
|
145
|
+
"""
|
146
|
+
output = bytearray()
|
147
|
+
fcs = HDLC_FCS_INIT
|
148
|
+
escape_byte = False
|
149
|
+
for byte in frame[1:-1]:
|
150
|
+
byte = _to_byte(byte)
|
151
|
+
if byte == HDLC_ESCAPE:
|
152
|
+
escape_byte = True
|
153
|
+
elif escape_byte is True:
|
154
|
+
if byte == HDLC_ESCAPE_ESCAPED:
|
155
|
+
output += HDLC_ESCAPE
|
156
|
+
fcs = _fcs_update(fcs, HDLC_ESCAPE)
|
157
|
+
elif byte == HDLC_FLAG_ESCAPED:
|
158
|
+
output += HDLC_FLAG
|
159
|
+
fcs = _fcs_update(fcs, HDLC_FLAG)
|
160
|
+
escape_byte = False
|
161
|
+
else:
|
162
|
+
output += byte
|
163
|
+
fcs = _fcs_update(fcs, byte)
|
164
|
+
if len(output) < 2:
|
165
|
+
raise HDLCDecodeException("Invalid payload")
|
166
|
+
if fcs != HDLC_FCS_OK:
|
167
|
+
raise HDLCDecodeException("Invalid FCS")
|
168
|
+
return output[:-2]
|
169
|
+
|
170
|
+
|
171
|
+
class HDLCState(Enum):
|
172
|
+
"""State of the HDLC handler."""
|
173
|
+
|
174
|
+
IDLE = 0
|
175
|
+
RECEIVING = 1
|
176
|
+
READY = 2
|
177
|
+
|
178
|
+
|
179
|
+
class HDLCHandler:
|
180
|
+
"""Handles the reception of an HDLC frame byte by byte."""
|
181
|
+
|
182
|
+
def __init__(self):
|
183
|
+
self.state = HDLCState.IDLE
|
184
|
+
self.fcs = HDLC_FCS_INIT
|
185
|
+
self.output = bytearray()
|
186
|
+
self.escape_byte = False
|
187
|
+
self._logger = logging.getLogger(__name__)
|
188
|
+
|
189
|
+
@property
|
190
|
+
def payload(self):
|
191
|
+
"""Returns the payload contained in a frame."""
|
192
|
+
if self.state != HDLCState.READY:
|
193
|
+
raise HDLCDecodeException("Incomplete HDLC frame")
|
194
|
+
|
195
|
+
self.state = HDLCState.IDLE
|
196
|
+
if len(self.output) < 2:
|
197
|
+
self._logger.error("Invalid payload")
|
198
|
+
return bytearray()
|
199
|
+
if self.fcs != HDLC_FCS_OK:
|
200
|
+
self._logger.error("Invalid FCS")
|
201
|
+
return bytearray()
|
202
|
+
self.fcs = HDLC_FCS_INIT
|
203
|
+
return self.output[:-2]
|
204
|
+
|
205
|
+
def handle_byte(self, byte):
|
206
|
+
"""Handle new byte received."""
|
207
|
+
if self.state in [HDLCState.IDLE, HDLCState.READY] and byte == HDLC_FLAG:
|
208
|
+
self.output = bytearray()
|
209
|
+
self.fcs = HDLC_FCS_INIT
|
210
|
+
self.state = HDLCState.RECEIVING
|
211
|
+
elif self.output and self.state == HDLCState.RECEIVING and byte == HDLC_FLAG:
|
212
|
+
# End of frame
|
213
|
+
self.state = HDLCState.READY
|
214
|
+
elif self.state == HDLCState.RECEIVING and byte != HDLC_FLAG:
|
215
|
+
# Middle of the frame
|
216
|
+
if byte == HDLC_ESCAPE:
|
217
|
+
self.escape_byte = True
|
218
|
+
elif self.escape_byte is True:
|
219
|
+
if byte == HDLC_ESCAPE_ESCAPED:
|
220
|
+
self.output += HDLC_ESCAPE
|
221
|
+
self.fcs = _fcs_update(self.fcs, HDLC_ESCAPE)
|
222
|
+
elif byte == HDLC_FLAG_ESCAPED:
|
223
|
+
self.output += HDLC_FLAG
|
224
|
+
self.fcs = _fcs_update(self.fcs, HDLC_FLAG)
|
225
|
+
self.escape_byte = False
|
226
|
+
else:
|
227
|
+
self.output += byte
|
228
|
+
self.fcs = _fcs_update(self.fcs, byte)
|
marilib/serial_uart.py
ADDED
@@ -0,0 +1,84 @@
|
|
1
|
+
# SPDX-FileCopyrightText: 2022-present Inria
|
2
|
+
# SPDX-FileCopyrightText: 2022-present Alexandre Abadie <alexandre.abadie@inria.fr>
|
3
|
+
# SPDX-FileCopyrightText: 2025-present Geovane Fedrecheski <geovane.fedrecheski@inria.fr>
|
4
|
+
#
|
5
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
6
|
+
|
7
|
+
"""Serial interface."""
|
8
|
+
|
9
|
+
import logging
|
10
|
+
import sys
|
11
|
+
import threading
|
12
|
+
import time
|
13
|
+
from typing import Callable
|
14
|
+
|
15
|
+
import serial
|
16
|
+
from serial.tools import list_ports
|
17
|
+
|
18
|
+
SERIAL_PAYLOAD_CHUNK_SIZE = 64
|
19
|
+
SERIAL_PAYLOAD_CHUNK_DELAY = 0.002 # 2 ms
|
20
|
+
SERIAL_DEFAULT_PORT = "/dev/ttyACM0"
|
21
|
+
SERIAL_DEFAULT_BAUDRATE = 1_000_000
|
22
|
+
|
23
|
+
|
24
|
+
def get_default_port():
|
25
|
+
"""Return default serial port."""
|
26
|
+
ports = [port for port in list_ports.comports()]
|
27
|
+
if sys.platform != "win32":
|
28
|
+
ports = sorted([port for port in ports if "J-Link" == port.product])
|
29
|
+
if not ports:
|
30
|
+
return SERIAL_DEFAULT_PORT
|
31
|
+
# return first JLink port available
|
32
|
+
return ports[0].device
|
33
|
+
|
34
|
+
|
35
|
+
class SerialInterfaceException(Exception):
|
36
|
+
"""Exception raised when serial port is disconnected."""
|
37
|
+
|
38
|
+
|
39
|
+
class SerialInterface(threading.Thread):
|
40
|
+
"""Bidirectional serial interface."""
|
41
|
+
|
42
|
+
def __init__(self, port: str, baudrate: int, callback: Callable):
|
43
|
+
self.lock = threading.Lock()
|
44
|
+
self.callback = callback
|
45
|
+
self.serial = serial.Serial(port, baudrate)
|
46
|
+
super().__init__(daemon=True)
|
47
|
+
self._logger = logging.getLogger(__name__)
|
48
|
+
self.start()
|
49
|
+
self._logger.info("Serial port thread started")
|
50
|
+
|
51
|
+
def run(self):
|
52
|
+
"""Listen continuously at each byte received on serial."""
|
53
|
+
self.serial.flush()
|
54
|
+
try:
|
55
|
+
while 1:
|
56
|
+
try:
|
57
|
+
byte = self.serial.read(1)
|
58
|
+
except (TypeError, serial.serialutil.SerialException):
|
59
|
+
byte = None
|
60
|
+
if byte is None:
|
61
|
+
self._logger.info("Serial port disconnected")
|
62
|
+
break
|
63
|
+
self.callback(byte)
|
64
|
+
except serial.serialutil.PortNotOpenError as exc:
|
65
|
+
self._logger.error(f"{exc}")
|
66
|
+
raise SerialInterfaceException(f"{exc}") from exc
|
67
|
+
except serial.serialutil.SerialException as exc:
|
68
|
+
self._logger.error(f"{exc}")
|
69
|
+
raise SerialInterfaceException(f"{exc}") from exc
|
70
|
+
|
71
|
+
def stop(self):
|
72
|
+
self.serial.close()
|
73
|
+
self.join()
|
74
|
+
|
75
|
+
def write(self, bytes_):
|
76
|
+
"""Write bytes on serial."""
|
77
|
+
# Send 64 bytes at a time
|
78
|
+
pos = 0
|
79
|
+
while (pos % SERIAL_PAYLOAD_CHUNK_SIZE) == 0 and pos < len(bytes_):
|
80
|
+
self.serial.write(bytes_[pos : pos + SERIAL_PAYLOAD_CHUNK_SIZE])
|
81
|
+
self.serial.flush()
|
82
|
+
pos += SERIAL_PAYLOAD_CHUNK_SIZE
|
83
|
+
time.sleep(SERIAL_PAYLOAD_CHUNK_DELAY)
|
84
|
+
# self.serial.flush()
|
marilib/tui.py
ADDED
marilib/tui_cloud.py
ADDED
@@ -0,0 +1,158 @@
|
|
1
|
+
from datetime import datetime, timedelta
|
2
|
+
|
3
|
+
from rich.columns import Columns
|
4
|
+
from rich.console import Console, Group
|
5
|
+
from rich.layout import Layout
|
6
|
+
from rich.live import Live
|
7
|
+
from rich.panel import Panel
|
8
|
+
from rich.table import Table
|
9
|
+
from rich.text import Text
|
10
|
+
|
11
|
+
from marilib import MarilibCloud
|
12
|
+
from marilib.model import MariGateway
|
13
|
+
from marilib.tui import MarilibTUI
|
14
|
+
|
15
|
+
|
16
|
+
class MarilibTUICloud(MarilibTUI):
|
17
|
+
"""A Text-based User Interface for MarilibCloud."""
|
18
|
+
|
19
|
+
def __init__(
|
20
|
+
self,
|
21
|
+
max_tables=4,
|
22
|
+
re_render_max_freq=0.2,
|
23
|
+
):
|
24
|
+
self.console = Console()
|
25
|
+
self.live = Live(console=self.console, auto_refresh=False, transient=True)
|
26
|
+
self.live.start()
|
27
|
+
self.max_tables = max_tables
|
28
|
+
self.re_render_max_freq = re_render_max_freq
|
29
|
+
self.last_render_time = datetime.now()
|
30
|
+
|
31
|
+
def get_max_rows(self) -> int:
|
32
|
+
"""Calculate maximum rows based on terminal height."""
|
33
|
+
terminal_height = self.console.height
|
34
|
+
available_height = terminal_height - 10 - 2 - 2 - 1 - 2
|
35
|
+
return max(2, available_height)
|
36
|
+
|
37
|
+
def render(self, mari: MarilibCloud):
|
38
|
+
"""Render the TUI layout."""
|
39
|
+
with mari.lock:
|
40
|
+
if datetime.now() - self.last_render_time < timedelta(seconds=self.re_render_max_freq):
|
41
|
+
return
|
42
|
+
self.last_render_time = datetime.now()
|
43
|
+
layout = Layout()
|
44
|
+
layout.split(
|
45
|
+
Layout(self.create_header_panel(mari), size=6),
|
46
|
+
Layout(self.create_gateways_panel(mari)),
|
47
|
+
)
|
48
|
+
self.live.update(layout, refresh=True)
|
49
|
+
|
50
|
+
def create_header_panel(self, mari: MarilibCloud) -> Panel:
|
51
|
+
"""Create the header panel with MQTT connection and network info."""
|
52
|
+
status = Text()
|
53
|
+
status.append("MarilibCloud is ", style="bold")
|
54
|
+
status.append("connected", style="bold green")
|
55
|
+
status.append(
|
56
|
+
f" to MQTT broker {mari.mqtt_interface.host}:{mari.mqtt_interface.port} "
|
57
|
+
f"at topic /mari/{mari.network_id_str}/to_cloud "
|
58
|
+
f"since {mari.started_ts.strftime('%Y-%m-%d %H:%M:%S')}"
|
59
|
+
)
|
60
|
+
status.append(" | ")
|
61
|
+
secs = int((datetime.now() - mari.last_received_mqtt_data_ts).total_seconds())
|
62
|
+
status.append(
|
63
|
+
f"last received: {secs}s ago",
|
64
|
+
style="bold green" if secs <= 1 else "bold red",
|
65
|
+
)
|
66
|
+
|
67
|
+
status.append("\n\nNetwork ID: ", style="bold cyan")
|
68
|
+
status.append(f"0x{mari.network_id:04X}")
|
69
|
+
status.append(" | ")
|
70
|
+
status.append("Gateways: ", style="bold cyan")
|
71
|
+
status.append(f"{len(mari.gateways)}")
|
72
|
+
status.append(" | ")
|
73
|
+
status.append("Nodes: ", style="bold cyan")
|
74
|
+
status.append(f"{len(mari.nodes)}")
|
75
|
+
|
76
|
+
return Panel(status, title="[bold]MarilibCloud Status", border_style="blue")
|
77
|
+
|
78
|
+
def create_gateway_table(self, gateway: MariGateway) -> Table:
|
79
|
+
"""Create a table for a single gateway with 3 rows and 2 columns."""
|
80
|
+
table = Table(
|
81
|
+
show_header=False,
|
82
|
+
border_style="blue",
|
83
|
+
padding=(0, 1),
|
84
|
+
)
|
85
|
+
table.add_column("Field", style="bold", width=18, justify="right")
|
86
|
+
table.add_column("Value")
|
87
|
+
|
88
|
+
# Row 1: Gateway info
|
89
|
+
node_count = f"{len(gateway.nodes)} / {gateway.info.schedule_uplink_cells}"
|
90
|
+
schedule_info = f"#{gateway.info.schedule_id} {gateway.info.schedule_name}"
|
91
|
+
table.add_row(
|
92
|
+
f"[bold cyan]0x{gateway.info.address:016X}[/bold cyan]",
|
93
|
+
f"Nodes: {node_count} | Schedule: {schedule_info}",
|
94
|
+
)
|
95
|
+
|
96
|
+
# Row 2: Schedule usage
|
97
|
+
schedule_repr = gateway.info.repr_schedule_cells_with_colors()
|
98
|
+
table.add_row("[bold cyan]Live schedule[/bold cyan]", schedule_repr)
|
99
|
+
|
100
|
+
# Row 3: Node list
|
101
|
+
if gateway.nodes:
|
102
|
+
node_addresses = [f"0x{node.address:016X}" for node in gateway.nodes]
|
103
|
+
node_display = " ".join(node_addresses)
|
104
|
+
else:
|
105
|
+
node_display = "—"
|
106
|
+
|
107
|
+
table.add_row("[bold cyan]Nodes[/bold cyan]", node_display)
|
108
|
+
|
109
|
+
return table
|
110
|
+
|
111
|
+
def create_gateways_panel(self, mari: MarilibCloud) -> Panel:
|
112
|
+
"""Create the panel that contains individual gateway tables."""
|
113
|
+
gateways = list(mari.gateways.values())
|
114
|
+
|
115
|
+
if not gateways:
|
116
|
+
empty_table = Table(title="No Gateways Connected")
|
117
|
+
return Panel(
|
118
|
+
empty_table,
|
119
|
+
title="[bold]Connected Gateways",
|
120
|
+
border_style="blue",
|
121
|
+
)
|
122
|
+
|
123
|
+
# Create individual tables for each gateway
|
124
|
+
gateway_tables = []
|
125
|
+
max_displayable_gateways = self.max_tables
|
126
|
+
gateways_to_display = gateways[:max_displayable_gateways]
|
127
|
+
remaining_gateways = max(0, len(gateways) - max_displayable_gateways)
|
128
|
+
|
129
|
+
for gateway in gateways_to_display:
|
130
|
+
gateway_tables.append(self.create_gateway_table(gateway))
|
131
|
+
|
132
|
+
# Arrange tables in columns
|
133
|
+
if len(gateway_tables) > 1:
|
134
|
+
content = Columns(gateway_tables, equal=True, expand=True)
|
135
|
+
else:
|
136
|
+
content = gateway_tables[0]
|
137
|
+
|
138
|
+
if remaining_gateways > 0:
|
139
|
+
panel_content = Group(
|
140
|
+
content,
|
141
|
+
Text(
|
142
|
+
f"\n...and {remaining_gateways} more gateway(s)",
|
143
|
+
style="bold yellow",
|
144
|
+
),
|
145
|
+
)
|
146
|
+
else:
|
147
|
+
panel_content = content
|
148
|
+
|
149
|
+
return Panel(
|
150
|
+
panel_content,
|
151
|
+
title="[bold]Connected Gateways",
|
152
|
+
border_style="blue",
|
153
|
+
)
|
154
|
+
|
155
|
+
def close(self):
|
156
|
+
"""Clean up the live display."""
|
157
|
+
self.live.stop()
|
158
|
+
print("")
|