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.
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
@@ -0,0 +1,13 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from marilib.marilib import MarilibBase
4
+
5
+
6
+ class MarilibTUI(ABC):
7
+ @abstractmethod
8
+ def render(self, mari: MarilibBase):
9
+ pass
10
+
11
+ @abstractmethod
12
+ def close(self):
13
+ pass
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("")