conson-xp 1.19.0__py3-none-any.whl → 1.21.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,393 @@
1
+ """Protocol Log Widget for displaying telegram stream."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from enum import Enum
6
+ from typing import Any, Optional
7
+
8
+ from textual.message import Message
9
+ from textual.reactive import reactive
10
+ from textual.widget import Widget
11
+ from textual.widgets import RichLog
12
+
13
+ from xp.models.protocol.conbus_protocol import TelegramReceivedEvent
14
+ from xp.services.conbus.conbus_receive_service import ConbusReceiveService
15
+ from xp.services.protocol import ConbusEventProtocol
16
+ from xp.utils.state_machine import StateMachine
17
+
18
+
19
+ class ConnectionState(str, Enum):
20
+ """Connection state enumeration.
21
+
22
+ Attributes:
23
+ DISCONNECTING: Disconnecting to server.
24
+ DISCONNECTED: Not connected to server.
25
+ CONNECTING: Connection in progress.
26
+ CONNECTED: Successfully connected.
27
+ FAILED: Connection failed.
28
+ """
29
+
30
+ DISCONNECTING = "DISCONNECTING"
31
+ DISCONNECTED = "DISCONNECTED"
32
+ CONNECTING = "CONNECTING"
33
+ CONNECTED = "CONNECTED"
34
+ FAILED = "FAILED"
35
+
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
+
72
+ class ProtocolLogWidget(Widget):
73
+ """Widget for displaying protocol telegram stream.
74
+
75
+ Connects to Conbus server via ConbusReceiveService and displays
76
+ live RX/TX telegram stream with color-coded direction markers.
77
+
78
+ Attributes:
79
+ container: ServiceContainer for dependency injection.
80
+ connection_state: Current connection state (reactive).
81
+ protocol: Reference to ConbusEventProtocol (prevents duplicate connections).
82
+ service: ConbusReceiveService instance.
83
+ logger: Logger instance for this widget.
84
+ log_widget: RichLog widget for displaying messages.
85
+ """
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
+
99
+ connection_state = reactive(ConnectionState.DISCONNECTED)
100
+
101
+ def __init__(self, container: Any) -> None:
102
+ """Initialize the Protocol Log widget.
103
+
104
+ Args:
105
+ container: ServiceContainer for resolving services.
106
+ """
107
+ super().__init__()
108
+ self.border_title = "Protocol"
109
+ self.container = container
110
+ self.protocol: Optional[ConbusEventProtocol] = None
111
+ self.service: Optional[ConbusReceiveService] = None
112
+ self.logger = logging.getLogger(__name__)
113
+ self.log_widget: Optional[RichLog] = None
114
+ self._state_machine = create_connection_state_machine()
115
+
116
+ def compose(self) -> Any:
117
+ """Compose the widget layout.
118
+
119
+ Yields:
120
+ RichLog widget for message display.
121
+ """
122
+ self.log_widget = RichLog(highlight=False, markup=True)
123
+ yield self.log_widget
124
+
125
+ async def on_mount(self) -> None:
126
+ """Initialize connection when widget mounts.
127
+
128
+ Delays connection by 0.5s to let UI render first.
129
+ Resolves ConbusReceiveService and connects signals.
130
+ """
131
+ # Resolve service from container (singleton)
132
+ self.service = self.container.resolve(ConbusReceiveService)
133
+ self.protocol = self.service.conbus_protocol
134
+
135
+ # Connect psygnal signals
136
+ self.protocol.on_connection_made.connect(self._on_connection_made)
137
+ self.protocol.on_telegram_received.connect(self._on_telegram_received)
138
+ self.protocol.on_telegram_sent.connect(self._on_telegram_sent)
139
+ self.protocol.on_timeout.connect(self._on_timeout)
140
+ self.protocol.on_failed.connect(self._on_failed)
141
+
142
+ # Delay connection to let UI render
143
+ await asyncio.sleep(0.5)
144
+ self._start_connection()
145
+
146
+ async def _start_connection_async(self) -> None:
147
+ """Start TCP connection to Conbus server (async).
148
+
149
+ Guards against duplicate connections and sets up protocol signals.
150
+ Integrates Twisted reactor with Textual's asyncio loop cleanly.
151
+ """
152
+ # Guard against duplicate connections (race condition)
153
+ if self.service is None:
154
+ self.logger.error("Service not initialized")
155
+ return
156
+
157
+ if self.protocol is None:
158
+ self.logger.error("Protocol not initialized")
159
+ return
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
+
168
+ try:
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
+ )
177
+
178
+ # Store protocol reference
179
+ self.logger.info(f"Protocol object: {self.protocol}")
180
+ self.logger.info(f"Reactor object: {self.protocol._reactor}")
181
+ self.logger.info(f"Reactor running: {self.protocol._reactor.running}")
182
+
183
+ # Setup service callbacks
184
+ def progress_callback(telegram: str) -> None:
185
+ """Handle progress updates for telegram reception.
186
+
187
+ Args:
188
+ telegram: Received telegram string.
189
+ """
190
+ pass
191
+
192
+ def finish_callback(response: Any) -> None:
193
+ """Handle completion of telegram reception.
194
+
195
+ Args:
196
+ response: Response object from telegram reception.
197
+ """
198
+ pass
199
+
200
+ # Get the currently running asyncio event loop (Textual's loop)
201
+ event_loop = asyncio.get_running_loop()
202
+ self.logger.info(f"Current running loop: {event_loop}")
203
+ self.logger.info(f"Loop is running: {event_loop.is_running()}")
204
+
205
+ self.service.init(
206
+ progress_callback=progress_callback,
207
+ finish_callback=finish_callback,
208
+ timeout_seconds=None, # Continuous monitoring
209
+ event_loop=event_loop,
210
+ )
211
+
212
+ reactor = self.service.conbus_protocol._reactor
213
+ reactor.connectTCP(
214
+ self.protocol.cli_config.ip,
215
+ self.protocol.cli_config.port,
216
+ self.protocol,
217
+ )
218
+
219
+ # Wait for connection to establish
220
+ await asyncio.sleep(1.0)
221
+ self.logger.info(f"After 1s - transport: {self.protocol.transport}")
222
+
223
+ except Exception as e:
224
+ self.logger.error(f"Connection failed: {e}")
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}"))
229
+
230
+ def _start_connection(self) -> None:
231
+ """Start connection (sync wrapper for async method)."""
232
+ # Use run_worker to run async method from sync context
233
+ self.logger.debug("Start connection")
234
+ self.run_worker(self._start_connection_async(), exclusive=True)
235
+
236
+ def _on_connection_made(self) -> None:
237
+ """Handle connection established signal.
238
+
239
+ Sets state to CONNECTED and displays success message.
240
+ """
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
+ )
251
+
252
+ def _on_telegram_received(self, event: TelegramReceivedEvent) -> None:
253
+ """Handle telegram received signal.
254
+
255
+ Args:
256
+ event: Telegram received event with frame data.
257
+ """
258
+ self.logger.debug("Telegram received")
259
+ if self.log_widget:
260
+ # Display [RX] and frame in bright green
261
+ self.log_widget.write(f"[#00ff00]\\[RX] {event.frame}[/#00ff00]")
262
+
263
+ def _on_telegram_sent(self, telegram: str) -> None:
264
+ """Handle telegram sent signal.
265
+
266
+ Args:
267
+ telegram: Sent telegram string.
268
+ """
269
+ self.logger.debug("Telegram sent")
270
+ if self.log_widget:
271
+ # Display [TX] and frame in bold bright green
272
+ self.log_widget.write(f"[bold #00ff00]\\[TX] {telegram}[/bold #00ff00]")
273
+
274
+ def _on_timeout(self) -> None:
275
+ """Handle timeout signal.
276
+
277
+ Logs timeout but continues monitoring (no action needed).
278
+ """
279
+ self.logger.debug("Timeout")
280
+ self.logger.debug("Timeout occurred (continuous monitoring)")
281
+
282
+ def _on_failed(self, error: str) -> None:
283
+ """Handle connection failed signal.
284
+
285
+ Args:
286
+ error: Error message describing the failure.
287
+ """
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}"))
293
+
294
+ def connect(self) -> None:
295
+ """Connect to Conbus server.
296
+
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
307
+
308
+ self._start_connection()
309
+
310
+ def disconnect(self) -> None:
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
+
331
+ if self.protocol:
332
+ self.protocol.disconnect()
333
+
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.
341
+
342
+ Args:
343
+ name: Telegram name (e.g., "Discover")
344
+ telegram: Telegram string including angle brackets (e.g., "S0000000000F01D00")
345
+ """
346
+ if self.protocol is None:
347
+ self.logger.warning("Cannot send telegram: not connected")
348
+ return
349
+
350
+ try:
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)
355
+
356
+ except Exception as e:
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"))
365
+
366
+ def on_unmount(self) -> None:
367
+ """Clean up when widget unmounts.
368
+
369
+ Disconnects signals and closes transport connection.
370
+ """
371
+ if self.protocol is not None:
372
+ try:
373
+ # Disconnect all signals
374
+ self.protocol.on_connection_made.disconnect(self._on_connection_made)
375
+ self.protocol.on_telegram_received.disconnect(
376
+ self._on_telegram_received
377
+ )
378
+ self.protocol.on_telegram_sent.disconnect(self._on_telegram_sent)
379
+ self.protocol.on_timeout.disconnect(self._on_timeout)
380
+ self.protocol.on_failed.disconnect(self._on_failed)
381
+
382
+ # Close transport if connected
383
+ if self.protocol.transport:
384
+ self.protocol.disconnect()
385
+
386
+ # Reset protocol reference
387
+ self.protocol = None
388
+
389
+ # Set state to disconnected
390
+ self.connection_state = ConnectionState.DISCONNECTED
391
+
392
+ except Exception as e:
393
+ self.logger.error(f"Error during cleanup: {e}")
xp/utils/dependencies.py CHANGED
@@ -7,6 +7,7 @@ from twisted.internet.interfaces import IConnector
7
7
  from twisted.internet.posixbase import PosixReactorBase
8
8
 
9
9
  from xp.models import ConbusClientConfig
10
+ from xp.models.conbus.conbus_logger_config import ConbusLoggerConfig
10
11
  from xp.models.homekit.homekit_config import HomekitConfig
11
12
  from xp.models.homekit.homekit_conson_config import ConsonModuleListConfig
12
13
  from xp.services.actiontable.actiontable_serializer import ActionTableSerializer
@@ -72,6 +73,7 @@ from xp.services.telegram.telegram_discover_service import TelegramDiscoverServi
72
73
  from xp.services.telegram.telegram_link_number_service import LinkNumberService
73
74
  from xp.services.telegram.telegram_output_service import TelegramOutputService
74
75
  from xp.services.telegram.telegram_service import TelegramService
76
+ from xp.utils.logging import LoggerService
75
77
 
76
78
  asyncioreactor.install()
77
79
  from twisted.internet import reactor # noqa: E402
@@ -87,7 +89,8 @@ class ServiceContainer:
87
89
 
88
90
  def __init__(
89
91
  self,
90
- config_path: str = "cli.yml",
92
+ client_config_path: str = "cli.yml",
93
+ logger_config_path: str = "logger.yml",
91
94
  homekit_config_path: str = "homekit.yml",
92
95
  conson_config_path: str = "conson.yml",
93
96
  server_port: int = 10001,
@@ -97,14 +100,16 @@ class ServiceContainer:
97
100
  Initialize the service container.
98
101
 
99
102
  Args:
100
- config_path: Path to the Conbus CLI configuration file
103
+ client_config_path: Path to the Conbus CLI configuration file
104
+ logger_config_path: Path to the Conbus Loggerr configuration file
101
105
  homekit_config_path: Path to the HomeKit configuration file
102
106
  conson_config_path: Path to the Conson configuration file
103
107
  server_port: Port for the server service
104
108
  reverse_proxy_port: Port for the reverse proxy service
105
109
  """
106
110
  self.container = punq.Container()
107
- self._config_path = config_path
111
+ self._client_config_path = client_config_path
112
+ self._logger_config_path = logger_config_path
108
113
  self._homekit_config_path = homekit_config_path
109
114
  self._conson_config_path = conson_config_path
110
115
  self._server_port = server_port
@@ -117,7 +122,13 @@ class ServiceContainer:
117
122
  # ConbusClientConfig
118
123
  self.container.register(
119
124
  ConbusClientConfig,
120
- factory=lambda: ConbusClientConfig.from_yaml(self._config_path),
125
+ factory=lambda: ConbusClientConfig.from_yaml(self._client_config_path),
126
+ scope=punq.Scope.singleton,
127
+ )
128
+
129
+ self.container.register(
130
+ ConbusLoggerConfig,
131
+ factory=lambda: ConbusLoggerConfig.from_yaml(self._logger_config_path),
121
132
  scope=punq.Scope.singleton,
122
133
  )
123
134
 
@@ -332,8 +343,7 @@ class ServiceContainer:
332
343
  self.container.register(
333
344
  ConbusReceiveService,
334
345
  factory=lambda: ConbusReceiveService(
335
- cli_config=self.container.resolve(ConbusClientConfig),
336
- reactor=self.container.resolve(PosixReactorBase),
346
+ conbus_protocol=self.container.resolve(ConbusEventProtocol)
337
347
  ),
338
348
  scope=punq.Scope.singleton,
339
349
  )
@@ -387,6 +397,15 @@ class ServiceContainer:
387
397
  scope=punq.Scope.singleton,
388
398
  )
389
399
 
400
+ # Logging
401
+ self.container.register(
402
+ LoggerService,
403
+ factory=lambda: LoggerService(
404
+ logger_config=self.container.resolve(ConbusLoggerConfig),
405
+ ),
406
+ scope=punq.Scope.singleton,
407
+ )
408
+
390
409
  # Module type services layer
391
410
  self.container.register(ModuleTypeService, scope=punq.Scope.singleton)
392
411
 
xp/utils/logging.py ADDED
@@ -0,0 +1,102 @@
1
+ """Logging service for XP application."""
2
+
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+ from pathlib import Path
6
+
7
+ from xp.models.conbus.conbus_logger_config import ConbusLoggerConfig
8
+
9
+
10
+ class LoggerService:
11
+ """Service for managing logging configuration and setup."""
12
+
13
+ def __init__(self, logger_config: ConbusLoggerConfig):
14
+ """Initialize LoggerService with configuration.
15
+
16
+ Args:
17
+ logger_config: Logger configuration object.
18
+ """
19
+ self.logging_config = logger_config.log
20
+ self.logger = logging.getLogger(__name__)
21
+
22
+ def setup(self) -> None:
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
+
39
+ self.setup_file_logging(
40
+ self.logging_config.log_format, self.logging_config.date_format
41
+ )
42
+
43
+ for module in self.logging_config.levels.keys():
44
+ logging.getLogger(module).setLevel(self.logging_config.levels[module])
45
+
46
+ def setup_console_logging(self, log_format: str, date_format: str) -> None:
47
+ """Setup console logging with specified format.
48
+
49
+ Args:
50
+ log_format: Log message format string.
51
+ date_format: Date format string for log timestamps.
52
+ """
53
+ # Force format on root logger and all handlers
54
+ formatter = logging.Formatter(log_format, datefmt=date_format)
55
+ root_logger = logging.getLogger()
56
+
57
+ # Set log level from CLI argument
58
+ numeric_level = getattr(logging, self.logging_config.default_level.upper())
59
+ root_logger.setLevel(numeric_level)
60
+
61
+ # Update all existing handlers or create new one
62
+ if root_logger.handlers:
63
+ for handler in root_logger.handlers:
64
+ handler.setFormatter(formatter)
65
+ else:
66
+ handler = logging.StreamHandler()
67
+ handler.setFormatter(formatter)
68
+ root_logger.addHandler(handler)
69
+
70
+ def setup_file_logging(self, log_format: str, date_format: str) -> None:
71
+ """Setup file logging with rotation for term application.
72
+
73
+ Args:
74
+ log_format: Log message format string.
75
+ date_format: Date format string for log timestamps.
76
+ """
77
+ log_path = Path(self.logging_config.path)
78
+ log_level = self.logging_config.default_level
79
+
80
+ try:
81
+ # Create log directory if it doesn't exist
82
+ log_path.parent.mkdir(parents=True, exist_ok=True)
83
+
84
+ # Create rotating file handler
85
+ file_handler = RotatingFileHandler(
86
+ log_path,
87
+ maxBytes=self.logging_config.max_bytes,
88
+ backupCount=self.logging_config.backup_count,
89
+ )
90
+
91
+ # Configure formatter to match console format
92
+ formatter = logging.Formatter(log_format, datefmt=date_format)
93
+ file_handler.setFormatter(formatter)
94
+ file_handler.setLevel(log_level)
95
+
96
+ # Attach to root logger
97
+ root_logger = logging.getLogger()
98
+ root_logger.addHandler(file_handler)
99
+
100
+ except (OSError, PermissionError) as e:
101
+ self.logger.warning(f"Failed to setup file logging at {log_path}: {e}")
102
+ self.logger.warning("Continuing without file logging")
@@ -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