conson-xp 1.20.0__py3-none-any.whl → 1.22.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,55 @@
1
+ """Help Menu Widget for displaying keyboard shortcuts and protocol keys."""
2
+
3
+ from typing import Any
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Vertical
7
+ from textual.widgets import DataTable
8
+
9
+ from xp.models.term import ProtocolKeysConfig
10
+
11
+
12
+ class HelpMenuWidget(Vertical):
13
+ """Help menu widget displaying keyboard shortcuts and protocol keys.
14
+
15
+ Displays a table of available keyboard shortcuts mapped to their
16
+ corresponding protocol commands.
17
+
18
+ Attributes:
19
+ protocol_keys: Configuration of protocol keys and their telegrams.
20
+ help_table: DataTable widget for displaying key mappings.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ protocol_keys: ProtocolKeysConfig,
26
+ *args: Any,
27
+ **kwargs: Any,
28
+ ) -> None:
29
+ """Initialize the Help Menu widget.
30
+
31
+ Args:
32
+ protocol_keys: Configuration containing protocol key mappings.
33
+ args: Additional positional arguments for Vertical.
34
+ kwargs: Additional keyword arguments for Vertical.
35
+ """
36
+ super().__init__(*args, **kwargs)
37
+ self.protocol_keys = protocol_keys
38
+ self.help_table: DataTable = DataTable(id="help-table", show_header=False)
39
+ self.help_table.can_focus = False
40
+ self.border_title = "Help menu"
41
+ self.can_focus = False
42
+
43
+ def compose(self) -> ComposeResult:
44
+ """Compose the help menu layout.
45
+
46
+ Yields:
47
+ DataTable widget with key mappings.
48
+ """
49
+ yield self.help_table
50
+
51
+ def on_mount(self) -> None:
52
+ """Populate help table when widget mounts."""
53
+ self.help_table.add_columns("Key", "Command")
54
+ for key, config in self.protocol_keys.protocol.items():
55
+ self.help_table.add_row(key, config.name)
@@ -5,6 +5,7 @@ import logging
5
5
  from enum import Enum
6
6
  from typing import Any, Optional
7
7
 
8
+ from textual.message import Message
8
9
  from textual.reactive import reactive
9
10
  from textual.widget import Widget
10
11
  from textual.widgets import RichLog
@@ -12,24 +13,62 @@ from textual.widgets import RichLog
12
13
  from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
13
14
  from xp.services.conbus.conbus_receive_service import ConbusReceiveService
14
15
  from xp.services.protocol import ConbusEventProtocol
16
+ from xp.utils.state_machine import StateMachine
15
17
 
16
18
 
17
19
  class ConnectionState(str, Enum):
18
20
  """Connection state enumeration.
19
21
 
20
22
  Attributes:
23
+ DISCONNECTING: Disconnecting to server.
21
24
  DISCONNECTED: Not connected to server.
22
25
  CONNECTING: Connection in progress.
23
26
  CONNECTED: Successfully connected.
24
27
  FAILED: Connection failed.
25
28
  """
26
29
 
30
+ DISCONNECTING = "DISCONNECTING"
27
31
  DISCONNECTED = "DISCONNECTED"
28
32
  CONNECTING = "CONNECTING"
29
33
  CONNECTED = "CONNECTED"
30
34
  FAILED = "FAILED"
31
35
 
32
36
 
37
+ def create_connection_state_machine() -> StateMachine:
38
+ """Create and configure state machine for connection management.
39
+
40
+ Returns:
41
+ Configured StateMachine with connection state transitions.
42
+ """
43
+ sm = StateMachine(ConnectionState.DISCONNECTED)
44
+
45
+ # Define valid transitions
46
+ sm.define_transition(
47
+ "connect", {ConnectionState.DISCONNECTED, ConnectionState.FAILED}
48
+ )
49
+ sm.define_transition(
50
+ "disconnect", {ConnectionState.CONNECTED, ConnectionState.CONNECTING}
51
+ )
52
+ sm.define_transition(
53
+ "connecting", {ConnectionState.DISCONNECTED, ConnectionState.FAILED}
54
+ )
55
+ sm.define_transition("connected", {ConnectionState.CONNECTING})
56
+ sm.define_transition(
57
+ "disconnecting", {ConnectionState.CONNECTED, ConnectionState.CONNECTING}
58
+ )
59
+ sm.define_transition("disconnected", {ConnectionState.DISCONNECTING})
60
+ sm.define_transition(
61
+ "failed",
62
+ {
63
+ ConnectionState.CONNECTING,
64
+ ConnectionState.CONNECTED,
65
+ ConnectionState.DISCONNECTING,
66
+ },
67
+ )
68
+
69
+ return sm
70
+
71
+
33
72
  class ProtocolLogWidget(Widget):
34
73
  """Widget for displaying protocol telegram stream.
35
74
 
@@ -45,6 +84,18 @@ class ProtocolLogWidget(Widget):
45
84
  log_widget: RichLog widget for displaying messages.
46
85
  """
47
86
 
87
+ class StatusMessageChanged(Message):
88
+ """Message posted when status message changes."""
89
+
90
+ def __init__(self, message: str) -> None:
91
+ """Initialize the message.
92
+
93
+ Args:
94
+ message: The status message to display.
95
+ """
96
+ super().__init__()
97
+ self.message = message
98
+
48
99
  connection_state = reactive(ConnectionState.DISCONNECTED)
49
100
 
50
101
  def __init__(self, container: Any) -> None:
@@ -54,11 +105,13 @@ class ProtocolLogWidget(Widget):
54
105
  container: ServiceContainer for resolving services.
55
106
  """
56
107
  super().__init__()
108
+ self.border_title = "Protocol"
57
109
  self.container = container
58
110
  self.protocol: Optional[ConbusEventProtocol] = None
59
111
  self.service: Optional[ConbusReceiveService] = None
60
112
  self.logger = logging.getLogger(__name__)
61
113
  self.log_widget: Optional[RichLog] = None
114
+ self._state_machine = create_connection_state_machine()
62
115
 
63
116
  def compose(self) -> Any:
64
117
  """Compose the widget layout.
@@ -66,7 +119,7 @@ class ProtocolLogWidget(Widget):
66
119
  Yields:
67
120
  RichLog widget for message display.
68
121
  """
69
- self.log_widget = RichLog(highlight=True, markup=True)
122
+ self.log_widget = RichLog(highlight=False, markup=True)
70
123
  yield self.log_widget
71
124
 
72
125
  async def on_mount(self) -> None:
@@ -88,7 +141,7 @@ class ProtocolLogWidget(Widget):
88
141
 
89
142
  # Delay connection to let UI render
90
143
  await asyncio.sleep(0.5)
91
- await self._start_connection_async()
144
+ self._start_connection()
92
145
 
93
146
  async def _start_connection_async(self) -> None:
94
147
  """Start TCP connection to Conbus server (async).
@@ -105,11 +158,22 @@ class ProtocolLogWidget(Widget):
105
158
  self.logger.error("Protocol not initialized")
106
159
  return
107
160
 
161
+ # Guard: Don't connect if already connected or connecting
162
+ if not self._state_machine.can_transition("connecting"):
163
+ self.logger.warning(
164
+ f"Already {self._state_machine.get_state().value}, ignoring connect request"
165
+ )
166
+ return
167
+
108
168
  try:
109
- # Set state to connecting
110
- self.connection_state = ConnectionState.CONNECTING
111
- if self.log_widget:
112
- self.log_widget.write("[yellow]Connecting to Conbus server...[/yellow]")
169
+ # Transition to CONNECTING
170
+ if self._state_machine.transition("connecting", ConnectionState.CONNECTING):
171
+ self.connection_state = ConnectionState.CONNECTING
172
+ self.post_message(
173
+ self.StatusMessageChanged(
174
+ f"Connecting to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}..."
175
+ )
176
+ )
113
177
 
114
178
  # Store protocol reference
115
179
  self.logger.info(f"Protocol object: {self.protocol}")
@@ -146,26 +210,11 @@ class ProtocolLogWidget(Widget):
146
210
  )
147
211
 
148
212
  reactor = self.service.conbus_protocol._reactor
149
- # Schedule the connection on the running asyncio loop
150
- # This ensures connectTCP is called in the context of the running loop
151
-
152
- def do_connect() -> None:
153
- """Execute TCP connection in event loop context."""
154
- self.logger.info("Executing connectTCP in event loop callback")
155
- if self.protocol is not None:
156
- reactor.connectTCP(
157
- self.protocol.cli_config.ip,
158
- self.protocol.cli_config.port,
159
- self.protocol,
160
- )
161
-
162
- event_loop.call_soon(do_connect)
163
- self.logger.info("Scheduled connectTCP on running loop")
164
-
165
- if self.log_widget:
166
- self.log_widget.write(
167
- f"[dim]→ {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}[/dim]"
168
- )
213
+ reactor.connectTCP(
214
+ self.protocol.cli_config.ip,
215
+ self.protocol.cli_config.port,
216
+ self.protocol,
217
+ )
169
218
 
170
219
  # Wait for connection to establish
171
220
  await asyncio.sleep(1.0)
@@ -173,15 +222,15 @@ class ProtocolLogWidget(Widget):
173
222
 
174
223
  except Exception as e:
175
224
  self.logger.error(f"Connection failed: {e}")
176
- self.connection_state = ConnectionState.FAILED
177
- if self.log_widget:
178
- self.log_widget.write(f"[red]Connection error: {e}[/red]")
179
- # Exit app after brief delay
180
- self.set_timer(2.0, self.app.exit)
225
+ # Transition to FAILED
226
+ if self._state_machine.transition("failed", ConnectionState.FAILED):
227
+ self.connection_state = ConnectionState.FAILED
228
+ self.post_message(self.StatusMessageChanged(f"Connection error: {e}"))
181
229
 
182
230
  def _start_connection(self) -> None:
183
231
  """Start connection (sync wrapper for async method)."""
184
232
  # Use run_worker to run async method from sync context
233
+ self.logger.debug("Start connection")
185
234
  self.run_worker(self._start_connection_async(), exclusive=True)
186
235
 
187
236
  def _on_connection_made(self) -> None:
@@ -189,10 +238,16 @@ class ProtocolLogWidget(Widget):
189
238
 
190
239
  Sets state to CONNECTED and displays success message.
191
240
  """
192
- self.connection_state = ConnectionState.CONNECTED
193
- if self.log_widget:
194
- self.log_widget.write("[green]Connected to Conbus server[/green]")
195
- self.log_widget.write("[dim]---[/dim]")
241
+ self.logger.debug("Connection made")
242
+ # Transition to CONNECTED
243
+ if self._state_machine.transition("connected", ConnectionState.CONNECTED):
244
+ self.connection_state = ConnectionState.CONNECTED
245
+ if self.protocol:
246
+ self.post_message(
247
+ self.StatusMessageChanged(
248
+ f"Connected to {self.protocol.cli_config.ip}:{self.protocol.cli_config.port}"
249
+ )
250
+ )
196
251
 
197
252
  def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
198
253
  """Handle telegram received signal.
@@ -200,9 +255,10 @@ class ProtocolLogWidget(Widget):
200
255
  Args:
201
256
  event: Telegram received event with frame data.
202
257
  """
258
+ self.logger.debug("Telegram received")
203
259
  if self.log_widget:
204
- # Display [RX] in green, frame in gray
205
- self.log_widget.write(f"[green]\\[RX][/green] [dim]{event.frame}[/dim]")
260
+ # Display [RX] and frame in bright green
261
+ self.log_widget.write(f"[#00ff00]\\[RX] {event.frame}[/#00ff00]")
206
262
 
207
263
  def _on_telegram_sent(self, telegram: str) -> None:
208
264
  """Handle telegram sent signal.
@@ -210,15 +266,17 @@ class ProtocolLogWidget(Widget):
210
266
  Args:
211
267
  telegram: Sent telegram string.
212
268
  """
269
+ self.logger.debug("Telegram sent")
213
270
  if self.log_widget:
214
- # Display [TX] in green, frame in gray
215
- self.log_widget.write(f"[green]\\[TX][/green] [dim]{telegram}[/dim]")
271
+ # Display [TX] and frame in bold bright green
272
+ self.log_widget.write(f"[bold #00ff00]\\[TX] {telegram}[/bold #00ff00]")
216
273
 
217
274
  def _on_timeout(self) -> None:
218
275
  """Handle timeout signal.
219
276
 
220
277
  Logs timeout but continues monitoring (no action needed).
221
278
  """
279
+ self.logger.debug("Timeout")
222
280
  self.logger.debug("Timeout occurred (continuous monitoring)")
223
281
 
224
282
  def _on_failed(self, error: str) -> None:
@@ -227,60 +285,83 @@ class ProtocolLogWidget(Widget):
227
285
  Args:
228
286
  error: Error message describing the failure.
229
287
  """
230
- self.connection_state = ConnectionState.FAILED
231
- self.logger.error(f"Connection failed: {error}")
288
+ # Transition to FAILED
289
+ if self._state_machine.transition("failed", ConnectionState.FAILED):
290
+ self.connection_state = ConnectionState.FAILED
291
+ self.logger.error(f"Connection failed: {error}")
292
+ self.post_message(self.StatusMessageChanged(f"Failed: {error}"))
232
293
 
233
- if self.log_widget:
234
- self.log_widget.write(f"[red]Connection failed: {error}[/red]")
294
+ def connect(self) -> None:
295
+ """Connect to Conbus server.
235
296
 
236
- # Exit app after brief delay to show error
237
- self.set_timer(2.0, self.app.exit)
297
+ Only initiates connection if currently DISCONNECTED or FAILED.
298
+ """
299
+ self.logger.debug("Connect")
300
+
301
+ # Guard: Check if connection is allowed
302
+ if not self._state_machine.can_transition("connect"):
303
+ self.logger.warning(
304
+ f"Cannot connect: current state is {self._state_machine.get_state().value}"
305
+ )
306
+ return
238
307
 
239
- def connect(self) -> None:
240
- """Connect to Conbus server."""
241
308
  self._start_connection()
242
309
 
243
310
  def disconnect(self) -> None:
244
- """Disconnect from Conbus server."""
311
+ """Disconnect from Conbus server.
312
+
313
+ Only disconnects if currently CONNECTED or CONNECTING.
314
+ """
315
+ self.logger.debug("Disconnect")
316
+
317
+ # Guard: Check if disconnection is allowed
318
+ if not self._state_machine.can_transition("disconnect"):
319
+ self.logger.warning(
320
+ f"Cannot disconnect: current state is {self._state_machine.get_state().value}"
321
+ )
322
+ return
323
+
324
+ # Transition to DISCONNECTING
325
+ if self._state_machine.transition(
326
+ "disconnecting", ConnectionState.DISCONNECTING
327
+ ):
328
+ self.connection_state = ConnectionState.DISCONNECTING
329
+ self.post_message(self.StatusMessageChanged("Disconnecting..."))
330
+
245
331
  if self.protocol:
246
332
  self.protocol.disconnect()
247
333
 
248
- def send_discover(self) -> None:
249
- """Send discover telegram.
334
+ # Transition to DISCONNECTED
335
+ if self._state_machine.transition("disconnected", ConnectionState.DISCONNECTED):
336
+ self.connection_state = ConnectionState.DISCONNECTED
337
+ self.post_message(self.StatusMessageChanged("Disconnected"))
338
+
339
+ def send_telegram(self, name: str, telegram: str) -> None:
340
+ """Send a raw telegram string.
250
341
 
251
- Sends predefined discover telegram <S0000000000F01D00FA> to the bus.
252
- Called when user presses 'd' key.
342
+ Args:
343
+ name: Telegram name (e.g., "Discover")
344
+ telegram: Telegram string including angle brackets (e.g., "S0000000000F01D00")
253
345
  """
254
346
  if self.protocol is None:
255
- self.logger.warning("Cannot send discover: not connected")
256
- if self.log_widget:
257
- self.log_widget.write(
258
- "[yellow]Not connected, cannot send discover[/yellow]"
259
- )
347
+ self.logger.warning("Cannot send telegram: not connected")
260
348
  return
261
349
 
262
350
  try:
263
- # Send discover telegram
264
- # Note: The telegram includes framing <>, but protocol may add it
265
- # Check if protocol expects with or without brackets
266
- from xp.models.telegram.system_function import SystemFunction
267
- from xp.models.telegram.telegram_type import TelegramType
268
-
269
- # Send discover: S 0000000000 F01 D00
270
- self.protocol.send_telegram(
271
- telegram_type=TelegramType.SYSTEM,
272
- serial_number="0000000000",
273
- system_function=SystemFunction.DISCOVERY,
274
- data_value="00",
275
- )
276
-
277
- if self.log_widget:
278
- self.log_widget.write("[yellow]Discover telegram sent[/yellow]")
351
+ # Remove angle brackets if present
352
+ self.post_message(self.StatusMessageChanged(f"Sending {name}..."))
353
+ # Send raw telegram
354
+ self.protocol.send_raw_telegram(telegram)
279
355
 
280
356
  except Exception as e:
281
- self.logger.error(f"Failed to send discover: {e}")
282
- if self.log_widget:
283
- self.log_widget.write(f"[red]Failed to send discover: {e}[/red]")
357
+ self.logger.error(f"Failed to send telegram: {e}")
358
+ self.post_message(self.StatusMessageChanged(f"Failed: {e}"))
359
+
360
+ def clear_log(self) -> None:
361
+ """Clear the protocol log widget."""
362
+ if self.log_widget:
363
+ self.log_widget.clear()
364
+ self.post_message(self.StatusMessageChanged("Log cleared"))
284
365
 
285
366
  def on_unmount(self) -> None:
286
367
  """Clean up when widget unmounts.
@@ -0,0 +1,53 @@
1
+ """Status Footer Widget for displaying app footer with connection status."""
2
+
3
+ from typing import Any
4
+
5
+ from textual.app import ComposeResult
6
+ from textual.containers import Horizontal
7
+ from textual.widgets import Footer, Static
8
+
9
+
10
+ class StatusFooterWidget(Horizontal):
11
+ """Footer widget with connection status indicator.
12
+
13
+ Combines the Textual Footer with a status indicator dot that shows
14
+ the current connection state.
15
+
16
+ Attributes:
17
+ status_widget: Static widget displaying colored status dot.
18
+ """
19
+
20
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
21
+ """Initialize the Status Footer widget.
22
+
23
+ Args:
24
+ args: Additional positional arguments for Horizontal.
25
+ kwargs: Additional keyword arguments for Horizontal.
26
+ """
27
+ super().__init__(*args, **kwargs)
28
+ self.status_widget: Static = Static("○", id="status-line")
29
+
30
+ def compose(self) -> ComposeResult:
31
+ """Compose the footer layout.
32
+
33
+ Yields:
34
+ Footer and status indicator widgets.
35
+ """
36
+ yield Footer()
37
+ yield self.status_widget
38
+
39
+ def update_status(self, state: Any) -> None:
40
+ """Update status indicator with connection state.
41
+
42
+ Args:
43
+ state: Current connection state (ConnectionState enum).
44
+ """
45
+ # Map states to colored dots
46
+ dot = {
47
+ "CONNECTED": "[green]●[/green]",
48
+ "CONNECTING": "[yellow]●[/yellow]",
49
+ "DISCONNECTING": "[yellow]●[/yellow]",
50
+ "FAILED": "[red]●[/red]",
51
+ "DISCONNECTED": "○",
52
+ }.get(state.value, "○")
53
+ self.status_widget.update(dot)
xp/utils/logging.py CHANGED
@@ -20,11 +20,22 @@ class LoggerService:
20
20
  self.logger = logging.getLogger(__name__)
21
21
 
22
22
  def setup(self) -> None:
23
- """Setup console and file logging with configured levels."""
24
- # Setup file logging for term app
25
- self.setup_console_logging(
26
- self.logging_config.log_format, self.logging_config.date_format
27
- )
23
+ """Setup file logging only with configured levels."""
24
+ # Setup file logging for term app (console logging disabled)
25
+ root_logger = logging.getLogger()
26
+
27
+ # Remove any existing console handlers
28
+ root_logger.handlers = [
29
+ h
30
+ for h in root_logger.handlers
31
+ if not isinstance(h, logging.StreamHandler)
32
+ or isinstance(h, RotatingFileHandler)
33
+ ]
34
+
35
+ # Set root logger level
36
+ numeric_level = getattr(logging, self.logging_config.default_level.upper())
37
+ root_logger.setLevel(numeric_level)
38
+
28
39
  self.setup_file_logging(
29
40
  self.logging_config.log_format, self.logging_config.date_format
30
41
  )
@@ -0,0 +1,81 @@
1
+ """Lightweight state machine utilities.
2
+
3
+ Provides simple, zero-dependency state machine implementation for
4
+ managing state transitions with validation.
5
+ """
6
+
7
+ from enum import Enum
8
+ from typing import Set
9
+
10
+
11
+ class StateMachine:
12
+ """Lightweight state machine for managing state transitions.
13
+
14
+ Enforces valid state transitions and prevents invalid operations.
15
+ Zero dependencies, suitable for any state-based logic.
16
+
17
+ Example:
18
+ >>> from enum import Enum
19
+ >>> class State(str, Enum):
20
+ ... IDLE = "IDLE"
21
+ ... RUNNING = "RUNNING"
22
+ ...
23
+ >>> sm = StateMachine(State.IDLE)
24
+ >>> sm.define_transition("start", {State.IDLE}, State.RUNNING)
25
+ >>> sm.can_transition("start") # True
26
+ >>> sm.transition("start", State.RUNNING) # True
27
+ >>> sm.get_state() # State.RUNNING
28
+ """
29
+
30
+ def __init__(self, initial: Enum):
31
+ """Initialize state machine.
32
+
33
+ Args:
34
+ initial: Initial state (any Enum value).
35
+ """
36
+ self.state = initial
37
+ self._valid_transitions: dict[str, Set[Enum]] = {}
38
+
39
+ def define_transition(self, action: str, valid_sources: Set[Enum]) -> None:
40
+ """Define valid source states for an action.
41
+
42
+ Args:
43
+ action: Action name (e.g., "connect", "disconnect").
44
+ valid_sources: Set of states from which action is valid.
45
+ """
46
+ self._valid_transitions[action] = valid_sources
47
+
48
+ def can_transition(self, action: str) -> bool:
49
+ """Check if action is valid from current state.
50
+
51
+ Args:
52
+ action: Action to check (e.g., "connect", "disconnect").
53
+
54
+ Returns:
55
+ True if action is valid from current state.
56
+ """
57
+ valid_sources = self._valid_transitions.get(action, set())
58
+ return self.state in valid_sources
59
+
60
+ def transition(self, action: str, new_state: Enum) -> bool:
61
+ """Attempt state transition.
62
+
63
+ Args:
64
+ action: Action triggering the transition.
65
+ new_state: Target state.
66
+
67
+ Returns:
68
+ True if transition succeeded, False if invalid.
69
+ """
70
+ if self.can_transition(action):
71
+ self.state = new_state
72
+ return True
73
+ return False
74
+
75
+ def get_state(self) -> Enum:
76
+ """Get current state.
77
+
78
+ Returns:
79
+ Current state as Enum value.
80
+ """
81
+ return self.state
xp/tui/app.py DELETED
@@ -1,72 +0,0 @@
1
- """Protocol Monitor TUI Application."""
2
-
3
- from pathlib import Path
4
- from typing import Any, Optional
5
-
6
- from textual.app import App, ComposeResult
7
- from textual.widgets import Footer, Header
8
-
9
- from xp.tui.widgets.protocol_log import ProtocolLogWidget
10
-
11
-
12
- class ProtocolMonitorApp(App[None]):
13
- """Textual app for real-time protocol monitoring.
14
-
15
- Displays live RX/TX telegram stream from Conbus server in an interactive
16
- terminal interface with keyboard shortcuts for control.
17
-
18
- Attributes:
19
- container: ServiceContainer for dependency injection.
20
- CSS_PATH: Path to CSS stylesheet file.
21
- BINDINGS: Keyboard bindings for app actions.
22
- TITLE: Application title displayed in header.
23
- """
24
-
25
- CSS_PATH = Path(__file__).parent / "protocol.tcss"
26
- TITLE = "Protocol Monitor"
27
-
28
- BINDINGS = [
29
- ("q", "quit", "Quit"),
30
- ("c", "connect", "Connect"),
31
- ("d", "disconnect", "Disconnect"),
32
- ("1", "discover", "Discover"),
33
- ]
34
-
35
- def __init__(self, container: Any) -> None:
36
- """Initialize the Protocol Monitor app.
37
-
38
- Args:
39
- container: ServiceContainer for resolving services.
40
- """
41
- super().__init__()
42
- self.container = container
43
- self.protocol_widget: Optional[ProtocolLogWidget] = None
44
-
45
- def compose(self) -> ComposeResult:
46
- """Compose the app layout with widgets.
47
-
48
- Yields:
49
- Header, ProtocolLogWidget, and Footer widgets.
50
- """
51
- yield Header()
52
- self.protocol_widget = ProtocolLogWidget(container=self.container)
53
- yield self.protocol_widget
54
- yield Footer()
55
-
56
- def action_discover(self) -> None:
57
- """Send discover telegram on 'D' key press.
58
-
59
- Sends predefined discover telegram <S0000000000F01D00FA> to the bus.
60
- """
61
- if self.protocol_widget:
62
- self.protocol_widget.send_discover()
63
-
64
- def action_connect(self) -> None:
65
- """Connect protocol on 'c' key press."""
66
- if self.protocol_widget:
67
- self.protocol_widget.connect()
68
-
69
- def action_disconnect(self) -> None:
70
- """Disconnect protocol on 'd' key press."""
71
- if self.protocol_widget:
72
- self.protocol_widget.disconnect()