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.
- {conson_xp-1.19.0.dist-info → conson_xp-1.21.0.dist-info}/METADATA +6 -1
- {conson_xp-1.19.0.dist-info → conson_xp-1.21.0.dist-info}/RECORD +27 -13
- {conson_xp-1.19.0.dist-info → conson_xp-1.21.0.dist-info}/WHEEL +1 -1
- xp/__init__.py +1 -1
- xp/cli/commands/__init__.py +4 -0
- xp/cli/commands/conbus/conbus_receive_commands.py +2 -1
- xp/cli/commands/term/__init__.py +5 -0
- xp/cli/commands/term/term.py +12 -0
- xp/cli/commands/term/term_commands.py +31 -0
- xp/cli/main.py +7 -35
- xp/models/conbus/conbus_client_config.py +1 -0
- xp/models/conbus/conbus_logger_config.py +107 -0
- xp/models/term/__init__.py +11 -0
- xp/models/term/protocol_keys_config.py +45 -0
- xp/services/conbus/conbus_receive_service.py +58 -30
- xp/services/protocol/conbus_event_protocol.py +36 -3
- xp/term/__init__.py +1 -0
- xp/term/app.py +158 -0
- xp/term/protocol.tcss +135 -0
- xp/term/protocol.yml +139 -0
- xp/term/widgets/__init__.py +1 -0
- xp/term/widgets/protocol_log.py +393 -0
- xp/utils/dependencies.py +25 -6
- xp/utils/logging.py +102 -0
- xp/utils/state_machine.py +81 -0
- {conson_xp-1.19.0.dist-info → conson_xp-1.21.0.dist-info}/entry_points.txt +0 -0
- {conson_xp-1.19.0.dist-info → conson_xp-1.21.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|