kmtronic-usb-relay 26.0.1__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,500 @@
1
+ """KMTronic USB Relay - Web Interface with REST API"""
2
+ import os, socket, logging
3
+ from typing import Optional, Callable, Dict, List
4
+ from functools import wraps
5
+ from dataclasses import dataclass
6
+ from fastapi import HTTPException
7
+ from nicegui import ui, app
8
+ from kmtronic_usb_relay.four_channel_relay import RelayController
9
+ from kmtronic_usb_relay.com_utils import SerialComUtils
10
+
11
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
12
+ logger = logging.getLogger(__name__)
13
+
14
+ @dataclass(frozen=True)
15
+ class UIConfig:
16
+ PRIMARY: str = "#0ea5a4"
17
+ SECONDARY: str = "#64748b"
18
+ ACCENT: str = "#22c55e"
19
+ CARD_WIDTH: str = "900px"
20
+ POLL_INTERVAL: int = 0
21
+ RELAY_SWITCH_DELAY: float = 0.01
22
+ UI_REFRESH_DELAY: float = 0.01
23
+ DARK_BG: str = "background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%)"
24
+ DARK_PANEL: str = "background: rgba(30, 41, 59, 0.7)"
25
+ DARK_BORDER: str = "1px solid rgba(255, 255, 255, 0.15)"
26
+ DARK_TEXT: str = "text-white"
27
+ LIGHT_BG: str = "background: linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)"
28
+ LIGHT_PANEL: str = "background: rgba(255, 255, 255, 0.9)"
29
+ LIGHT_BORDER: str = "1px solid rgba(0, 0, 0, 0.12)"
30
+ LIGHT_TEXT: str = "text-grey-9"
31
+ BLUR: str = "backdrop-filter: blur(10px)"
32
+ RELAY_ON_BG: str = "rgba(34, 197, 94, 0.15)"
33
+ RELAY_OFF_BG: str = "rgba(239, 68, 68, 0.15)"
34
+ RELAY_ON_BORDER: str = "rgba(34, 197, 94, 0.4)"
35
+ RELAY_OFF_BORDER: str = "rgba(239, 68, 68, 0.4)"
36
+
37
+ CFG = UIConfig()
38
+
39
+ class RelayService:
40
+ def __init__(self) -> None:
41
+ self._controller: Optional[RelayController] = None
42
+ self.current_port: Optional[str] = None
43
+
44
+ @property
45
+ def is_connected(self) -> bool:
46
+ return bool(self._controller and self._controller.is_connected)
47
+
48
+ def connect(self, com_port: str) -> None:
49
+ if not com_port:
50
+ raise ValueError("COM port is required")
51
+ if self._controller:
52
+ try:
53
+ self._controller.close()
54
+ except Exception as e:
55
+ logger.warning(f"Error closing connection: {e}")
56
+ self._controller = RelayController(com_port, switch_delay=CFG.RELAY_SWITCH_DELAY)
57
+ if not self._controller.is_connected:
58
+ raise RuntimeError(f"Failed to connect to {com_port}")
59
+ self.current_port = com_port
60
+
61
+ def disconnect(self) -> None:
62
+ if self._controller:
63
+ try:
64
+ self._controller.close()
65
+ finally:
66
+ self._controller = None
67
+ self.current_port = None
68
+
69
+ def ensure(self) -> RelayController:
70
+ if not self._controller or not self._controller.is_connected:
71
+ raise HTTPException(status_code=503, detail="Relay controller not connected")
72
+ return self._controller
73
+
74
+ def statuses_int_keys(self) -> Dict[int, str]:
75
+ raw = self.ensure().get_statuses()
76
+ result: Dict[int, str] = {}
77
+ for key, value in raw.items():
78
+ try:
79
+ relay_num = int(str(key).strip().lstrip('Rr'))
80
+ result[relay_num] = value
81
+ except (ValueError, AttributeError):
82
+ continue
83
+ return result
84
+
85
+ def set(self, relay_number: int, on: bool) -> None:
86
+ controller = self.ensure()
87
+ (controller.turn_on if on else controller.turn_off)(relay_number)
88
+
89
+ def bulk(self, on: bool) -> None:
90
+ for relay_num in range(1, 5):
91
+ self.set(relay_num, on)
92
+
93
+ @staticmethod
94
+ def available_ports() -> List[str]:
95
+ return SerialComUtils.get_port_names() or []
96
+
97
+ service = RelayService()
98
+
99
+ def handle_rest_error(func: Callable) -> Callable:
100
+ @wraps(func)
101
+ def wrapper(*args, **kwargs):
102
+ try:
103
+ return func(*args, **kwargs)
104
+ except HTTPException:
105
+ raise
106
+ except Exception as e:
107
+ logger.error(f"REST error in {func.__name__}: {e}")
108
+ raise HTTPException(status_code=500, detail="Internal Server Error")
109
+ return wrapper
110
+
111
+ @app.get("/health")
112
+ @handle_rest_error
113
+ def health_check() -> dict:
114
+ return {"status": "ok", "connected": service.is_connected, "port": service.current_port}
115
+
116
+ @app.get("/relay/ports")
117
+ @handle_rest_error
118
+ def list_ports() -> dict:
119
+ ports = RelayService.available_ports()
120
+ return {"status": "success", "count": len(ports), "ports": ports}
121
+
122
+ @app.get("/relay/status")
123
+ @handle_rest_error
124
+ def get_status() -> dict:
125
+ return {"status": "success", "relays": service.ensure().get_statuses()}
126
+
127
+ @app.post("/relay/{relay_number}/on")
128
+ @handle_rest_error
129
+ def turn_on(relay_number: int) -> dict:
130
+ if not 1 <= relay_number <= 4:
131
+ raise HTTPException(status_code=400, detail="Relay number must be between 1 and 4")
132
+ service.set(relay_number, True)
133
+ return {"status": "success", "relay": relay_number, "state": "ON"}
134
+
135
+ @app.post("/relay/{relay_number}/off")
136
+ @handle_rest_error
137
+ def turn_off(relay_number: int) -> dict:
138
+ if not 1 <= relay_number <= 4:
139
+ raise HTTPException(status_code=400, detail="Relay number must be between 1 and 4")
140
+ service.set(relay_number, False)
141
+ return {"status": "success", "relay": relay_number, "state": "OFF"}
142
+
143
+ class ThemeManager:
144
+ def __init__(self) -> None:
145
+ self.dark_mode_obj = None
146
+ self.body_style = None
147
+ self.header_toggle_row: Optional[ui.row] = None
148
+ self.connection_card: Optional[ui.card] = None
149
+ self.control_card: Optional[ui.card] = None
150
+ self.status_card: Optional[ui.card] = None
151
+
152
+ def init(self, start_dark: bool = False) -> None:
153
+ ui.colors(primary=CFG.PRIMARY, secondary=CFG.SECONDARY, accent=CFG.ACCENT)
154
+ self.dark_mode_obj = ui.dark_mode(start_dark)
155
+ self.body_style = ui.query('body').style(CFG.DARK_BG if start_dark else CFG.LIGHT_BG)
156
+
157
+ def register_cards(self, connection: ui.card, control: ui.card, status: ui.card, toggle_row: ui.row) -> None:
158
+ self.connection_card = connection
159
+ self.control_card = control
160
+ self.status_card = status
161
+ self.header_toggle_row = toggle_row
162
+
163
+ @property
164
+ def is_dark(self) -> bool:
165
+ return bool(self.dark_mode_obj and self.dark_mode_obj.value)
166
+
167
+ def text_class(self) -> str:
168
+ return CFG.DARK_TEXT if self.is_dark else CFG.LIGHT_TEXT
169
+
170
+ def apply_text_class(self, *elements) -> None:
171
+ text_cls = self.text_class()
172
+ for element in elements:
173
+ if element:
174
+ try:
175
+ element.classes(remove="text-white text-grey-9 text-grey-6 text-grey-4 text-grey-3")
176
+ element.classes(text_cls)
177
+ except Exception:
178
+ pass
179
+
180
+ def _panel_style(self, is_dark: bool, extra: str = "") -> str:
181
+ bg = CFG.DARK_PANEL if is_dark else CFG.LIGHT_PANEL
182
+ border = CFG.DARK_BORDER if is_dark else CFG.LIGHT_BORDER
183
+ return f"{bg}; {CFG.BLUR}; border: {border}; padding: 16px; {extra}".strip()
184
+
185
+ def _status_style(self, is_dark: bool) -> str:
186
+ return self._panel_style(is_dark, f"border-left: 3px solid {CFG.PRIMARY}; padding: 8px 12px;")
187
+
188
+ def apply(self, ui_controller=None) -> None:
189
+ if not self.dark_mode_obj:
190
+ return
191
+ if self.body_style:
192
+ self.body_style.style(CFG.DARK_BG if self.is_dark else CFG.LIGHT_BG)
193
+ panel_style = self._panel_style(self.is_dark)
194
+ if self.connection_card:
195
+ self.connection_card.style(panel_style)
196
+ if self.control_card:
197
+ self.control_card.style(panel_style)
198
+ if self.status_card:
199
+ self.status_card.style(self._status_style(self.is_dark))
200
+ if self.header_toggle_row:
201
+ self.header_toggle_row.clear()
202
+ with self.header_toggle_row:
203
+ ui.button(icon="light_mode" if self.is_dark else "dark_mode",
204
+ on_click=self.toggle).props("flat dense round").classes("text-white")
205
+ if ui_controller and hasattr(ui_controller, 'apply_theme_to_all_elements'):
206
+ ui_controller.apply_theme_to_all_elements()
207
+
208
+ def toggle(self, ui_controller=None) -> None:
209
+ try:
210
+ if not self.dark_mode_obj:
211
+ ui.notify("Dark mode not initialized", type="warning")
212
+ return
213
+ self.dark_mode_obj.value = not self.dark_mode_obj.value
214
+ self.apply(ui_controller)
215
+ except Exception as e:
216
+ logger.error(f"Theme toggle error: {e}")
217
+ ui.notify("Theme toggle failed", type="warning")
218
+
219
+ class KMTronicUIController:
220
+ def __init__(self, theme: ThemeManager) -> None:
221
+ self.theme = theme
222
+ self.com_port_input: Optional[ui.input] = None
223
+ self.port_select: Optional[ui.select] = None
224
+ self.status_output: Optional[ui.label] = None
225
+ self.connection_status: Optional[ui.chip] = None
226
+ self.header_status: Optional[ui.chip] = None
227
+ self.relay_grid: Optional[ui.column] = None
228
+ self.timer: Optional[ui.timer] = None
229
+ self.header_toggle_row: Optional[ui.row] = None
230
+ self.connection_card: Optional[ui.card] = None
231
+ self.control_card: Optional[ui.card] = None
232
+ self.status_card: Optional[ui.card] = None
233
+ self.controls: List[ui.element] = []
234
+ self.text_elements: List[ui.label] = []
235
+ self.input_elements: List = []
236
+
237
+ def apply_theme_to_all_elements(self) -> None:
238
+ self.theme.apply_text_class(*self.text_elements)
239
+ for element in self.input_elements:
240
+ if element:
241
+ self.theme.apply_text_class(element)
242
+
243
+ def build(self) -> None:
244
+ self._build_header()
245
+ with ui.column().classes("w-full items-center").style("padding: 12px;"):
246
+ with ui.column().style(f"width: {CFG.CARD_WIDTH}; max-width: 98vw;").classes("q-gutter-sm"):
247
+ self._build_status_strip()
248
+ self._build_connection_panel()
249
+ self._build_control_panel()
250
+ if self.connection_card and self.control_card and self.status_card and self.header_toggle_row:
251
+ self.theme.register_cards(self.connection_card, self.control_card, self.status_card, self.header_toggle_row)
252
+ self.theme.apply(self)
253
+
254
+ def _update_status_chips(self, connected: bool, port: str = "") -> None:
255
+ if self.connection_status:
256
+ text, color, icon = (f"Connected: {port}", "positive", "check_circle") if connected else ("Disconnected", "grey", "power_off")
257
+ self.connection_status.set_text(text)
258
+ self.connection_status.props(f'color={color} icon={icon}')
259
+ if self.header_status:
260
+ text, color, icon = (port or "Connected", "positive", "lan") if connected else ("Disconnected", "grey", "power_off")
261
+ self.header_status.set_text(text)
262
+ self.header_status.props(f'color={color} icon={icon}')
263
+
264
+ def _toggle_controls(self, enabled: bool) -> None:
265
+ for control in self.controls:
266
+ try:
267
+ control.enable() if enabled else control.disable()
268
+ except Exception:
269
+ pass
270
+
271
+ async def connect_relay(self) -> None:
272
+ com_port = (self.com_port_input.value or "").strip() if self.com_port_input else ""
273
+ if not com_port:
274
+ ui.notify("Please enter a COM port", type="warning")
275
+ return
276
+ try:
277
+ service.connect(com_port)
278
+ self._update_status_chips(True, com_port)
279
+ self._toggle_controls(True)
280
+ if self.status_output:
281
+ self.status_output.set_text(f"Connected to {com_port}")
282
+ await self.refresh_status()
283
+ if CFG.POLL_INTERVAL > 0 and not self.timer:
284
+ self.timer = ui.timer(CFG.POLL_INTERVAL, self.refresh_status)
285
+ ui.notify(f"✓ Connected to {com_port}", type="positive")
286
+ except Exception as e:
287
+ logger.error(f"Connection failed: {e}")
288
+ self._update_status_chips(False)
289
+ self._toggle_controls(False)
290
+ if self.status_output:
291
+ self.status_output.set_text(f"Error: {str(e)}")
292
+ ui.notify("Connection failed", type="negative")
293
+
294
+ async def disconnect_relay(self) -> None:
295
+ try:
296
+ service.disconnect()
297
+ self._update_status_chips(False)
298
+ self._toggle_controls(False)
299
+ if self.status_output:
300
+ self.status_output.set_text("Disconnected")
301
+ if self.timer:
302
+ self.timer.cancel()
303
+ self.timer = None
304
+ ui.notify("Disconnected", type="info")
305
+ except Exception as e:
306
+ logger.error(f"Disconnect error: {e}")
307
+ ui.notify("Disconnect failed", type="warning")
308
+
309
+ async def control_relay(self, relay_num: int, state: str) -> None:
310
+ try:
311
+ service.set(relay_num, state.upper() == "ON")
312
+ ui.notify(f"✓ R{relay_num} {state.upper()}", type="positive")
313
+ ui.timer(CFG.UI_REFRESH_DELAY, self.refresh_status, once=True)
314
+ except HTTPException as he:
315
+ ui.notify(he.detail, type="negative")
316
+ except Exception as e:
317
+ logger.error(f"Control relay error: {e}")
318
+ if self.status_output:
319
+ self.status_output.set_text(f"Error: {str(e)}")
320
+ ui.notify("Operation failed", type="negative")
321
+
322
+ async def bulk_set(self, state: str) -> None:
323
+ try:
324
+ service.bulk(state.upper() == "ON")
325
+ ui.notify(f"All {state.upper()}", type="positive")
326
+ ui.timer(CFG.UI_REFRESH_DELAY, self.refresh_status, once=True)
327
+ except Exception as e:
328
+ logger.error(f"Bulk operation error: {e}")
329
+ ui.notify("Bulk operation failed", type="negative")
330
+
331
+ async def refresh_status(self) -> None:
332
+ try:
333
+ statuses = service.statuses_int_keys()
334
+ if self.status_output:
335
+ self.status_output.set_text(" | ".join([f"R{n}: {s}" for n, s in sorted(statuses.items())]))
336
+ if self.relay_grid:
337
+ self.relay_grid.clear()
338
+ with self.relay_grid:
339
+ self._build_relay_buttons(statuses)
340
+ except HTTPException as he:
341
+ ui.notify(he.detail, type="warning")
342
+ except Exception as e:
343
+ logger.error(f"Status refresh error: {e}")
344
+ if self.status_output:
345
+ self.status_output.set_text(f"Error: {str(e)}")
346
+ ui.notify("Failed to get status", type="warning")
347
+
348
+ async def scan_ports(self) -> None:
349
+ try:
350
+ ports = RelayService.available_ports()
351
+ if self.port_select:
352
+ self.port_select.options = ports
353
+ self.port_select.value = ports[0] if ports else None
354
+ self.port_select.update()
355
+ ui.notify(f"Found {len(ports)} port{'s' if len(ports) != 1 else ''}", type="positive")
356
+ except Exception as e:
357
+ logger.error(f"Port scan error: {e}")
358
+ ui.notify("Scan failed", type="negative")
359
+
360
+ def _build_header(self) -> None:
361
+ style = f"background: linear-gradient(90deg, {CFG.PRIMARY}, {CFG.ACCENT}); padding: 8px 16px;"
362
+ with ui.header(elevated=True).style(style):
363
+ with ui.row().classes("w-full items-center").style("max-width: 1400px; margin: 0 auto;"):
364
+ ui.icon("settings_input_component", size="sm").classes("text-white")
365
+ ui.label("KMTronic Relay").classes("text-white text-subtitle1 text-weight-bold")
366
+ ui.space()
367
+ ui.button(icon="api", on_click=self.open_api_dialog).props("flat dense round").classes("text-white").tooltip("API Endpoints")
368
+ self.header_toggle_row = ui.row().style("margin-left: 8px; margin-right: 12px;")
369
+ with self.header_toggle_row:
370
+ ui.button(icon="light_mode" if self.theme.is_dark else "dark_mode",
371
+ on_click=lambda: self.theme.toggle(self)).props("flat dense round").classes("text-white").tooltip("Toggle Theme")
372
+ self.header_status = ui.chip("Disconnected", icon="power_off").props("dense square color=grey")
373
+
374
+ def _build_status_strip(self) -> None:
375
+ strip_style = self.theme._status_style(self.theme.is_dark)
376
+ self.status_card = ui.card().classes("w-full").style(strip_style)
377
+ with self.status_card:
378
+ with ui.row().classes("w-full items-center"):
379
+ ui.icon("radio_button_checked", size="xs").classes("text-positive")
380
+ self.status_output = ui.label("Ready").classes(f"text-caption {self.theme.text_class()}").style("margin-left: 8px;")
381
+ self.text_elements.append(self.status_output)
382
+
383
+ def _build_connection_panel(self) -> None:
384
+ panel_style = self.theme._panel_style(self.theme.is_dark, extra="padding: 12px 16px;")
385
+ self.connection_card = ui.card().classes("w-full").style(panel_style)
386
+ with self.connection_card:
387
+ with ui.row().classes("w-full items-center").style("gap: 12px;"):
388
+ with ui.column().style("flex: 1; min-width: 0;"):
389
+ self.com_port_input = ui.input(placeholder="COM4").props("outlined dense").classes(f"w-full {self.theme.text_class()}").style("margin: 0;")
390
+ self.input_elements.append(self.com_port_input)
391
+ with ui.column().style("flex: 1; min-width: 0;"):
392
+ self.port_select = ui.select([], label="Quick Select").props("outlined dense").classes(f"w-full {self.theme.text_class()}").style("margin: 0;")
393
+ self.input_elements.append(self.port_select)
394
+ scan_btn = ui.button(icon="search", on_click=self.scan_ports).props("flat dense")
395
+ connect_btn = ui.button("CONNECT", icon="link", color="positive", on_click=self._on_connect_click).props("dense unelevated")
396
+ disconnect_btn = ui.button("DISCONNECT", icon="link_off", color="negative", on_click=self.disconnect_relay).props("dense outline")
397
+ self.connection_status = ui.chip("OFF", icon="power_off").props("dense square color=grey").style("margin: 0;")
398
+ self.controls.extend([disconnect_btn, scan_btn])
399
+
400
+ def _on_connect_click(self) -> None:
401
+ async def do_connect():
402
+ if self.port_select and self.port_select.value and self.com_port_input:
403
+ self.com_port_input.set_value(self.port_select.value)
404
+ await self.connect_relay()
405
+ return do_connect()
406
+
407
+ def _build_control_panel(self) -> None:
408
+ panel_style = self.theme._panel_style(self.theme.is_dark, extra="padding: 16px 20px;")
409
+ self.control_card = ui.card().classes("w-full").style(panel_style)
410
+ with self.control_card:
411
+ with ui.row().classes("w-full items-center").style("margin-bottom: 16px;"):
412
+ ui.icon("settings_input_component", size="sm").classes("text-primary")
413
+ title = ui.label("Relay Control").classes(f"text-h6 text-weight-medium {self.theme.text_class()}").style("margin-left: 8px;")
414
+ self.text_elements.append(title)
415
+ ui.space()
416
+ with ui.row().classes("q-gutter-sm"):
417
+ all_on = ui.button("All ON", icon="flash_on", color="positive", on_click=lambda: self.bulk_set("ON")).props("unelevated")
418
+ all_off = ui.button("All OFF", icon="flash_off", color="negative", on_click=lambda: self.bulk_set("OFF")).props("outline")
419
+ self.controls.extend([all_on, all_off])
420
+ self.relay_grid = ui.column().classes("w-full")
421
+
422
+ def _build_relay_buttons(self, statuses: Dict[int, str]) -> None:
423
+ with ui.grid(columns=4).classes("w-full").style("gap: 12px;"):
424
+ for relay_num in range(1, 5):
425
+ self._build_single_relay_card(relay_num, statuses)
426
+
427
+ def _build_single_relay_card(self, relay_num: int, statuses: Dict[int, str]) -> None:
428
+ is_on = statuses.get(relay_num, "OFF").upper() == "ON"
429
+ bg = CFG.RELAY_ON_BG if is_on else CFG.RELAY_OFF_BG
430
+ border = CFG.RELAY_ON_BORDER if is_on else CFG.RELAY_OFF_BORDER
431
+ color = "positive" if is_on else "negative"
432
+ text = "ON" if is_on else "OFF"
433
+ icon = "power" if is_on else "power_off"
434
+ card_style = f"padding: 16px; background: {bg}; border: 2px solid {border}; border-radius: 12px; transition: all 0.3s ease;"
435
+ with ui.card().classes("w-full").style(card_style):
436
+ with ui.column().classes("w-full items-center").style("gap: 8px;"):
437
+ relay_label = ui.label(f"Relay {relay_num}").classes(f"text-subtitle2 text-weight-bold text-center {self.theme.text_class()}")
438
+ self.text_elements.append(relay_label)
439
+ ui.chip(text, icon=icon).props(f"color={color} size=sm")
440
+ btn = ui.button("Toggle", icon="power_settings_new", color="primary",
441
+ on_click=lambda num=relay_num, current=is_on: self.control_relay(num, "OFF" if current else "ON")
442
+ ).props("unelevated size=md").classes("w-full").style("font-size: 0.9rem;")
443
+ self.controls.append(btn)
444
+
445
+ def open_api_dialog(self) -> None:
446
+ try:
447
+ panel_style = self.theme._panel_style(self.theme.is_dark, extra="padding: 16px;")
448
+ text_class = self.theme.text_class()
449
+ endpoints = [{"method": "GET", "path": "/health", "desc": "Service health"},
450
+ {"method": "GET", "path": "/relay/ports", "desc": "Available COM ports"},
451
+ {"method": "GET", "path": "/relay/status", "desc": "Relay status"}]
452
+ for n in range(1, 5):
453
+ endpoints.append({"method": "POST", "path": f"/relay/{n}/on", "desc": f"Turn R{n} ON"})
454
+ endpoints.append({"method": "POST", "path": f"/relay/{n}/off", "desc": f"Turn R{n} OFF"})
455
+ with ui.dialog() as dialog:
456
+ with ui.card().style(panel_style).classes("w-full"):
457
+ ui.label("API Endpoints").classes(f"text-h6 text-weight-medium {text_class}")
458
+ with ui.column().classes("w-full").style("gap: 6px;"):
459
+ for ep in endpoints:
460
+ with ui.row().classes("items-center w-full").style("gap: 8px;"):
461
+ method_color = "primary" if ep["method"] == "GET" else "orange"
462
+ ui.chip(ep["method"]).props(f"color={method_color} size=sm")
463
+ ui.label(ep["path"]).classes(f"text-body2 text-weight-medium {text_class}")
464
+ ui.space()
465
+ ui.label(ep["desc"]).classes(f"text-caption {text_class}").style("opacity: 0.7;")
466
+ with ui.row().classes("w-full justify-end").style("margin-top: 12px;"):
467
+ ui.button("Close", icon="close", on_click=dialog.close).props("flat")
468
+ dialog.open()
469
+ except Exception as e:
470
+ logger.error(f"Failed to open API endpoints dialog: {e}")
471
+ ui.notify("Failed to show API endpoints", type="warning")
472
+
473
+
474
+ @ui.page("/")
475
+ def index_page() -> None:
476
+ theme = ThemeManager()
477
+ theme.init(start_dark=False)
478
+ KMTronicUIController(theme).build()
479
+
480
+ def main() -> None:
481
+ import argparse
482
+ parser = argparse.ArgumentParser(description="KMTronic USB Relay - Web Interface")
483
+ parser.add_argument("com_port", nargs="?", default=None, help="COM port to auto-connect")
484
+ parser.add_argument("--host", default='0.0.0.0', help="Server host")
485
+ parser.add_argument("--port", type=int, default=9401, help="Server port")
486
+ parser.add_argument("--native", action="store_true", help="Launch in native window")
487
+ args = parser.parse_args()
488
+ com_port = args.com_port or os.getenv("KMTRONIC_COM_PORT")
489
+ if com_port:
490
+ try:
491
+ service.connect(com_port)
492
+ logger.info(f"Auto-connected to {com_port}")
493
+ except Exception as e:
494
+ logger.warning(f"Failed to auto-connect to {com_port}: {e}")
495
+ logger.info(f"Starting server on {args.host}:{args.port}")
496
+ ui.run(host=args.host, port=args.port, native=args.native)
497
+
498
+
499
+ if __name__ in {"__main__", "__mp_main__"}:
500
+ main()