hid-usb-relay 25.0.0__py3-none-any.whl → 26.0.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.
hid_usb_relay/app.py ADDED
@@ -0,0 +1,468 @@
1
+ """HID USB Relay Controller - Professional Web Interface"""
2
+ import socket, logging, re
3
+ from typing import Optional, List
4
+ from functools import wraps
5
+ from dataclasses import dataclass
6
+ from argparse import ArgumentParser
7
+ from fastapi import HTTPException
8
+ from nicegui import ui, app
9
+ from hid_usb_relay.usb_relay import (
10
+ set_relay_device_state, set_relay_device_relay_state,
11
+ get_relay_device_state, get_relay_device_relay_state,
12
+ set_default_relay_device_state, set_default_relay_device_relay_state,
13
+ get_default_relay_device_state, get_default_relay_device_relay_state,
14
+ enumerate_devices
15
+ )
16
+
17
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # UI Configuration - colors, gradients, and styling constants
21
+ @dataclass(frozen=True)
22
+ class CFG:
23
+ # Brand colors
24
+ PRIMARY: str = "#0ea5a4"
25
+ SECONDARY: str = "#64748b"
26
+ ACCENT: str = "#22c55e"
27
+ CARD_WIDTH: str = "1400px"
28
+
29
+ # Dark theme styles
30
+ DARK_BG: str = "background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%)"
31
+ DARK_PANEL: str = "background: rgba(30, 41, 59, 0.7)"
32
+ DARK_BORDER: str = "1px solid rgba(255, 255, 255, 0.15)"
33
+
34
+ # Light theme styles
35
+ LIGHT_BG: str = "background: linear-gradient(135deg, #e2e8f0 0%, #f8fafc 100%)"
36
+ LIGHT_PANEL: str = "background: rgba(255, 255, 255, 0.9)"
37
+ LIGHT_BORDER: str = "1px solid rgba(0, 0, 0, 0.12)"
38
+
39
+ BLUR: str = "backdrop-filter: blur(10px)"
40
+
41
+ cfg = CFG()
42
+
43
+ # Hardware abstraction layer for relay operations
44
+ class RelayService:
45
+ """Service layer that encapsulates relay hardware operations"""
46
+
47
+ @staticmethod
48
+ def set_and_get_relay_state(relay_id: Optional[str], relay_number: str, state: str) -> str:
49
+ """Set relay state and return the updated state"""
50
+ state_upper = state.upper()
51
+ is_all = relay_number.lower() == "all"
52
+
53
+ # Set the relay state (specific device or default)
54
+ if relay_id:
55
+ ok = set_relay_device_state(relay_id, state_upper) if is_all else set_relay_device_relay_state(relay_id, relay_number, state_upper)
56
+ else:
57
+ ok = set_default_relay_device_state(state_upper) if is_all else set_default_relay_device_relay_state(relay_number, state_upper)
58
+
59
+ if not ok:
60
+ raise HTTPException(status_code=400, detail="Failed to set relay state")
61
+
62
+ # Get and return the updated state
63
+ if relay_id:
64
+ return get_relay_device_state(relay_id) if is_all else get_relay_device_relay_state(relay_id, relay_number)
65
+ return get_default_relay_device_state() if is_all else get_default_relay_device_relay_state(relay_number)
66
+
67
+ @staticmethod
68
+ def get_devices() -> List[dict]:
69
+ """Enumerate all connected relay devices"""
70
+ return enumerate_devices() or []
71
+
72
+ service = RelayService()
73
+
74
+ # REST API error handling decorator
75
+ def handle_rest_error(func):
76
+ """Decorator to handle REST API errors consistently"""
77
+ @wraps(func)
78
+ def wrapper(*args, **kwargs):
79
+ try:
80
+ return func(*args, **kwargs)
81
+ except HTTPException:
82
+ raise
83
+ except Exception as e:
84
+ logger.error(f"REST error: {e}")
85
+ raise HTTPException(status_code=500, detail="Internal Server Error")
86
+ return wrapper
87
+
88
+ # REST API Endpoints
89
+ @app.get("/health")
90
+ @handle_rest_error
91
+ def health_check() -> dict:
92
+ """Health check endpoint"""
93
+ return {"status": "ok", "device_count": len(service.get_devices())}
94
+
95
+ @app.get("/relay/{relay_id}/{relay_number}/{relay_state}")
96
+ @handle_rest_error
97
+ def relay_control_by_id(relay_id: str, relay_number: str, relay_state: str) -> dict:
98
+ """Control specific relay on specific device"""
99
+ return {"status": "success", "relay_state": service.set_and_get_relay_state(relay_id, relay_number, relay_state)}
100
+
101
+ @app.get("/relay/{relay_number}/{relay_state}")
102
+ @handle_rest_error
103
+ def default_relay_control(relay_number: str, relay_state: str) -> dict:
104
+ """Control relay on default device"""
105
+ return {"status": "success", "relay_state": service.set_and_get_relay_state(None, relay_number, relay_state)}
106
+
107
+ @app.get("/relay/devices")
108
+ @handle_rest_error
109
+ def list_relay_devices() -> dict:
110
+ """List all connected relay devices"""
111
+ devices = service.get_devices()
112
+ return {"status": "success", "count": len(devices), "devices": devices}
113
+
114
+ # Theme management for dark/light mode switching
115
+ class ThemeManager:
116
+ """Manages dark/light theme switching and applies styling to UI elements"""
117
+
118
+ def __init__(self):
119
+ self.dark_mode_obj = None
120
+ self.body_style = None
121
+ self.header_toggle_row = None
122
+ self.status_card = None
123
+ self.control_card = None
124
+ self.device_card = None
125
+ self.device_separator = None
126
+
127
+ def init(self, start_dark: bool = False):
128
+ """Initialize theme system with brand colors"""
129
+ ui.colors(primary=cfg.PRIMARY, secondary=cfg.SECONDARY, accent=cfg.ACCENT)
130
+ self.dark_mode_obj = ui.dark_mode(start_dark)
131
+ self.body_style = ui.query('body').style(cfg.DARK_BG if start_dark else cfg.LIGHT_BG)
132
+
133
+ def register_cards(self, status, control, device, toggle_row, separator=None):
134
+ """Register UI cards for theme application"""
135
+ self.status_card = status
136
+ self.control_card = control
137
+ self.device_card = device
138
+ self.header_toggle_row = toggle_row
139
+ self.device_separator = separator
140
+
141
+ @property
142
+ def is_dark(self) -> bool:
143
+ """Check if dark mode is currently enabled"""
144
+ return bool(self.dark_mode_obj and self.dark_mode_obj.value)
145
+
146
+ def text_class(self) -> str:
147
+ """Get appropriate text color class for current theme"""
148
+ return "text-white" if self.is_dark else "text-grey-9"
149
+
150
+ def _panel_style(self, extra: str = "") -> str:
151
+ """Generate panel style based on current theme"""
152
+ border = cfg.DARK_BORDER if self.is_dark else cfg.LIGHT_BORDER
153
+ bg = cfg.DARK_PANEL if self.is_dark else cfg.LIGHT_PANEL
154
+ return f"{bg}; {cfg.BLUR}; border: {border}; padding: 16px; {extra}".strip()
155
+
156
+ def apply(self, ui_controller=None):
157
+ """Apply current theme to all registered UI elements"""
158
+ if not self.dark_mode_obj:
159
+ return
160
+
161
+ # Update background gradient
162
+ self.body_style.style(cfg.DARK_BG if self.is_dark else cfg.LIGHT_BG)
163
+ panel_style = self._panel_style()
164
+
165
+ # Apply theme to cards
166
+ if self.control_card:
167
+ self.control_card.style(panel_style)
168
+ if self.device_card:
169
+ self.device_card.style(panel_style)
170
+ if self.status_card:
171
+ self.status_card.style(self._panel_style(f"border-left: 3px solid {cfg.PRIMARY}; padding: 8px 12px;"))
172
+
173
+ # Update theme toggle button
174
+ if self.header_toggle_row:
175
+ self.header_toggle_row.clear()
176
+ with self.header_toggle_row:
177
+ ui.button(icon="light_mode" if self.is_dark else "dark_mode", on_click=self.toggle).props("flat dense round").classes("text-white")
178
+
179
+ # Update separator styling
180
+ if self.device_separator:
181
+ self.device_separator.style(f"background: {'rgba(255,255,255,0.15)' if self.is_dark else 'rgba(0,0,0,0.12)'}; margin: 8px 0;")
182
+
183
+ # Update text colors on all tracked elements
184
+ if ui_controller:
185
+ for el in ui_controller.text_elements + ui_controller.input_elements:
186
+ if el:
187
+ el.classes(remove="text-white text-grey-9 text-primary")
188
+ el.classes(self.text_class())
189
+
190
+ def toggle(self, ui_controller=None):
191
+ """Toggle between dark and light mode"""
192
+ try:
193
+ if not self.dark_mode_obj:
194
+ ui.notify("Dark mode not initialized", type="warning")
195
+ return
196
+ self.dark_mode_obj.value = not self.dark_mode_obj.value
197
+ self.apply(ui_controller)
198
+ except Exception as e:
199
+ logger.error(f"Theme toggle error: {e}")
200
+
201
+ # Main UI controller for relay operations
202
+ class RelayController:
203
+ """Manages the web UI for relay control and device management"""
204
+
205
+ def __init__(self, theme: ThemeManager):
206
+ self.theme = theme
207
+
208
+ # Track UI elements for theme updates
209
+ self.text_elements = []
210
+ self.input_elements = []
211
+
212
+ # UI component references
213
+ self.relay_id_input = None
214
+ self.relay_num_input = None
215
+ self.status_label = None
216
+ self.status_output = None
217
+ self.devices_count = None
218
+ self.devices_select = None
219
+ self.devices_container = None
220
+ self.header_toggle_row = None
221
+ self.status_card = None
222
+ self.control_card = None
223
+ self.device_card = None
224
+
225
+ async def control_relay(self, state: str):
226
+ """Control relay state (ON/OFF) for specified device and relay number"""
227
+ try:
228
+ relay_id = self.relay_id_input.value.strip() or None
229
+ relay_num = self.relay_num_input.value.strip()
230
+ result = service.set_and_get_relay_state(relay_id, relay_num, state)
231
+ device_label = relay_id or "Default"
232
+
233
+ # Update status display
234
+ self.status_label.set_text(f"{device_label} • Relay {relay_num.upper()} • {state.upper()}")
235
+ self.status_output.set_text(str(result))
236
+ ui.notify(f"✓ Relay {state.upper()}", type="positive")
237
+ except Exception as e:
238
+ logger.error(e)
239
+ self.status_label.set_text("Error")
240
+ self.status_output.set_text(str(e))
241
+ ui.notify("Operation failed", type="negative")
242
+
243
+ async def refresh_status(self):
244
+ """Refresh and display current relay status"""
245
+ try:
246
+ relay_id = self.relay_id_input.value.strip() or None
247
+ relay_num = self.relay_num_input.value.strip()
248
+ is_all = relay_num.lower() == "all"
249
+
250
+ # Get current status
251
+ if relay_id:
252
+ result = get_relay_device_state(relay_id) if is_all else get_relay_device_relay_state(relay_id, relay_num)
253
+ else:
254
+ result = get_default_relay_device_state() if is_all else get_default_relay_device_relay_state(relay_num)
255
+
256
+ # Update display
257
+ self.status_label.set_text(f"{relay_id or 'Default'} • Current Status")
258
+ self.status_output.set_text(str(result))
259
+ ui.notify("Status refreshed", type="info")
260
+ except Exception as e:
261
+ logger.error(e)
262
+ self.status_label.set_text("Error")
263
+ self.status_output.set_text(str(e))
264
+ async def scan_devices(self):
265
+ """Scan for connected devices and update the device manager UI"""
266
+ try:
267
+ # Get devices and update count
268
+ devices = service.get_devices()
269
+ count = len(devices)
270
+ self.devices_count.set_text(f"{count} device{'s' if count != 1 else ''}")
271
+
272
+ # Update device selector dropdown
273
+ device_ids = [d.get("device_id") for d in devices if d.get("device_id")]
274
+ self.devices_select.options = device_ids
275
+ self.devices_select.value = device_ids[0] if device_ids else None
276
+ self.devices_select.update()
277
+
278
+ # Rebuild device cards
279
+ self.devices_container.clear()
280
+ with self.devices_container:
281
+ if not devices:
282
+ # Show "no devices" message
283
+ with ui.card().classes("w-full").style(f"{self.theme._panel_style('padding: 24px; text-align: center;')}"):
284
+ ui.icon("error_outline", size="lg").classes("text-grey-5")
285
+ no_dev_lbl = ui.label("No devices detected").classes(f"text-subtitle2 {self.theme.text_class()}").style("opacity: 0.7;")
286
+ self.text_elements.append(no_dev_lbl)
287
+ else:
288
+ # Create card for each device
289
+ for device in devices:
290
+ device_id = device.get("device_id", "Unknown")
291
+ relay_states = {k: v for k, v in device.items() if re.match(r"R\d+$", k, re.IGNORECASE)}
292
+ with ui.card().classes("w-full").style(f"{self.theme._panel_style(f'padding: 10px; border-left: 3px solid {cfg.PRIMARY};')}"):
293
+ # Device header with ID and bulk controls
294
+ with ui.row().classes("w-full items-center").style("margin-bottom: 6px;"):
295
+ dev_icon = ui.icon("developer_board", size="xs").classes(f"{self.theme.text_class() if self.theme.is_dark else 'text-primary'}")
296
+ lbl = ui.label(device_id).classes(f"text-body2 text-weight-medium {self.theme.text_class()}").style("margin-left: 6px;")
297
+ self.text_elements.extend([dev_icon, lbl])
298
+ ui.space()
299
+
300
+ # Bulk ON/OFF controls for all relays
301
+ async def bulk_control(state: str, dev=device_id):
302
+ try:
303
+ set_relay_device_state(dev, state)
304
+ ui.notify(f"{dev}: All {state}", type="positive")
305
+ except Exception as e:
306
+ logger.error(e)
307
+ ui.notify("Operation failed", type="negative")
308
+ return
309
+
310
+ # Rescan devices after bulk operation (context-safe)
311
+ try:
312
+ await self.scan_devices()
313
+ except RuntimeError as e:
314
+ if "parent element" not in str(e) and "deleted" not in str(e):
315
+ logger.error(f"Scan error: {e}")
316
+ except Exception as e:
317
+ logger.error(f"Scan error: {e}")
318
+
319
+ ui.button("ON", icon="flash_on", color="positive", on_click=lambda d=device_id: bulk_control("ON", d)).props("dense outline size=xs")
320
+ ui.button("OFF", icon="flash_off", color="negative", on_click=lambda d=device_id: bulk_control("OFF", d)).props("dense outline size=xs")
321
+ if relay_states:
322
+ with ui.grid(columns="repeat(auto-fit, minmax(65px, 1fr))").classes("w-full").style("gap: 4px;"):
323
+ for relay_name, state in sorted(relay_states.items(), key=lambda x: int(re.search(r"\d+", x[0]).group())):
324
+ relay_num = re.search(r"\d+", relay_name).group()
325
+ is_on = state.upper() == "ON"
326
+
327
+ async def toggle(dev_id=device_id, num=relay_num, current=is_on):
328
+ try:
329
+ new_state = "OFF" if current else "ON"
330
+ set_relay_device_relay_state(dev_id, num, new_state)
331
+ ui.notify(f"R{num}: {new_state}", type="positive")
332
+ except Exception as e:
333
+ logger.error(e)
334
+ ui.notify("Failed", type="negative")
335
+ return
336
+
337
+ # Rescan devices after toggle (context-safe)
338
+ try:
339
+ await self.scan_devices()
340
+ except RuntimeError as e:
341
+ if "parent element" not in str(e) and "deleted" not in str(e):
342
+ logger.error(f"Scan error: {e}")
343
+ except Exception as e:
344
+ logger.error(f"Scan error: {e}")
345
+
346
+ color = "positive" if is_on else "grey-6"
347
+ icon = "check_circle" if is_on else "circle"
348
+ ui.button(f"R{relay_num}", icon=icon, color=color, on_click=toggle).props("push size=sm").style("width: 100%; font-weight: 500; font-size: 0.7rem;")
349
+ try: ui.notify(f"Found {count} device{'s' if count != 1 else ''}", type="positive")
350
+ except RuntimeError: pass
351
+ except Exception as e: logger.error(f"Scan failed: {e}"); raise
352
+ def build(self):
353
+ """Build the complete UI with header, status bar, control panel, and device manager"""
354
+ # Application header with branding and controls
355
+ with ui.header(elevated=True).style(f"background: linear-gradient(90deg, {cfg.PRIMARY}, {cfg.ACCENT}); padding: 8px 16px;"):
356
+ with ui.row().classes("w-full items-center").style("max-width: 1400px; margin: 0 auto;"): ui.icon("electric_bolt", size="sm").classes("text-white"); ui.label("HID USB Relay Controller").classes("text-white text-subtitle1 text-weight-bold"); ui.space(); ui.button(icon="api", on_click=lambda: self._api_dialog()).props("flat dense round").classes("text-white"); self.header_toggle_row = ui.row().style("margin-left: 8px;")
357
+
358
+ # Main content area
359
+ with ui.column().classes("w-full items-center").style("padding: 8px;"):
360
+ with ui.column().style(f"width: {cfg.CARD_WIDTH}; max-width: 98vw;").classes("q-gutter-xs"):
361
+ # Status bar showing current operation
362
+ self.status_card = ui.card().classes("w-full").style(self.theme._panel_style(f"border-left: 3px solid {cfg.PRIMARY}; padding: 6px 12px;"))
363
+ with self.status_card:
364
+ with ui.row().classes("w-full items-center"):
365
+ status_icon = ui.icon("radio_button_checked", size="xs").classes("text-positive")
366
+ self.status_label = ui.label("System Ready").classes(f"text-caption text-weight-medium {self.theme.text_class()}").style("margin-left: 8px;")
367
+ ui.space()
368
+ self.status_output = ui.label("No operations performed").classes(f"text-caption {self.theme.text_class()}").style("opacity: 0.7; font-size: 0.7rem;")
369
+ self.text_elements.extend([status_icon, self.status_label, self.status_output])
370
+
371
+ # Side-by-side layout: Control panel (left) + Device manager (right)
372
+ with ui.row().classes("w-full").style("gap: 12px; align-items: flex-start;"):
373
+ # Manual Control Panel (left, fixed width)
374
+ with ui.column().style("flex: 0 0 350px; min-width: 0;"):
375
+ self.control_card = ui.card().classes("w-full").style(self.theme._panel_style().replace("padding: 16px", "padding: 12px"))
376
+ with self.control_card:
377
+ with ui.row().classes("w-full items-center").style("margin-bottom: 8px;"):
378
+ ctrl_icon = ui.icon("tune", size="sm").classes(f"{self.theme.text_class() if self.theme.is_dark else 'text-primary'}")
379
+ t = ui.label("Control Relay").classes(f"text-subtitle1 text-weight-bold {self.theme.text_class()}").style("margin-left: 8px;")
380
+ self.text_elements.extend([ctrl_icon, t])
381
+
382
+ with ui.column().classes("w-full").style("gap: 8px;"):
383
+ l1 = ui.label("Device ID").classes(f"text-caption text-weight-bold {self.theme.text_class()}").style("opacity: 0.7; margin-bottom: 2px;")
384
+ self.text_elements.append(l1)
385
+ self.relay_id_input = ui.input(placeholder="Default device").props("outlined dense").classes("w-full")
386
+ self.input_elements.append(self.relay_id_input)
387
+
388
+ l2 = ui.label("Relay Number").classes(f"text-caption text-weight-bold {self.theme.text_class()}").style("opacity: 0.7; margin-bottom: 2px; margin-top: 8px;")
389
+ self.text_elements.append(l2)
390
+ self.relay_num_input = ui.input(value="all", placeholder="1-8 or 'all'").props("outlined dense").classes("w-full")
391
+ self.input_elements.append(self.relay_num_input)
392
+
393
+ with ui.row().classes("w-full justify-center q-gutter-sm").style("margin-top: 12px;"):
394
+ ui.button("ON", icon="power_settings_new", color="positive", on_click=lambda: self.control_relay("on")).props("unelevated").style("flex: 1; font-weight: 600;")
395
+ ui.button("OFF", icon="power_off", color="negative", on_click=lambda: self.control_relay("off")).props("unelevated").style("flex: 1; font-weight: 600;")
396
+
397
+ ui.button("Refresh Status", icon="refresh", on_click=self.refresh_status).props("flat dense size=sm").classes(f"w-full {self.theme.text_class()}").style("opacity: 0.7; margin-top: 4px;")
398
+
399
+ # Device Manager Panel (right, flexible width)
400
+ with ui.column().style("flex: 1; min-width: 0;"):
401
+ self.device_card = ui.card().classes("w-full").style(self.theme._panel_style().replace("padding: 16px", "padding: 12px"))
402
+ with self.device_card:
403
+ with ui.row().classes("w-full items-center").style("gap: 8px; margin-bottom: 8px;"):
404
+ dev_mgr_icon = ui.icon("devices", size="sm").classes(f"{self.theme.text_class() if self.theme.is_dark else 'text-primary'}")
405
+ t = ui.label("Device Manager").classes(f"text-subtitle1 text-weight-bold {self.theme.text_class()}")
406
+ self.text_elements.extend([dev_mgr_icon, t])
407
+ ui.space()
408
+ self.devices_count = ui.chip("0 devices", icon="info_outline").props(f"dense square color={'grey-7' if self.theme.is_dark else 'primary'} size=sm")
409
+ ui.button("Scan", icon="search", color="primary", on_click=self.scan_devices).props("unelevated dense")
410
+
411
+ with ui.row().classes("w-full q-gutter-xs").style("margin-bottom: 8px;"):
412
+ self.devices_select = ui.select([], label="Device", with_input=True).props("outlined dense").classes("col")
413
+ self.input_elements.append(self.devices_select)
414
+ ui.button(icon="input", on_click=lambda: self.relay_id_input.set_value(self.devices_select.value or "")).props("outline dense").classes(self.theme.text_class())
415
+ ui.button(icon="clear", on_click=lambda: self.relay_id_input.set_value("")).props("flat dense").classes(self.theme.text_class())
416
+
417
+ self.device_separator = ui.separator().style(f"background: {'rgba(255,255,255,0.15)' if self.theme.is_dark else 'rgba(0,0,0,0.12)'}; margin: 8px 0;")
418
+ self.devices_container = ui.column().classes("w-full").style("gap: 8px;")
419
+
420
+ # Register cards with theme manager and apply initial theme
421
+ if self.status_card and self.control_card and self.device_card and self.header_toggle_row:
422
+ self.theme.register_cards(self.status_card, self.control_card, self.device_card, self.header_toggle_row, self.device_separator)
423
+ self.theme.apply(self)
424
+
425
+ def _api_dialog(self):
426
+ """Open dialog showing available REST API endpoints"""
427
+ try:
428
+ endpoints = [
429
+ {"method": "GET", "path": "/health", "desc": "Service health"},
430
+ {"method": "GET", "path": "/relay/devices", "desc": "List devices"},
431
+ {"method": "GET", "path": "/relay/{number}/{state}", "desc": "Default device control"},
432
+ {"method": "GET", "path": "/relay/{id}/{number}/{state}", "desc": "Specific device control"}
433
+ ]
434
+
435
+ with ui.dialog() as dialog, ui.card().style(self.theme._panel_style("padding: 16px;")).classes("w-full"):
436
+ ui.label("API Endpoints").classes(f"text-h6 text-weight-medium {self.theme.text_class()}")
437
+
438
+ with ui.column().classes("w-full").style("gap: 6px;"):
439
+ for ep in endpoints:
440
+ with ui.row().classes("items-center w-full").style("gap: 8px;"):
441
+ ui.chip(ep["method"]).props("color=primary size=sm")
442
+ ui.label(ep["path"]).classes(f"text-body2 text-weight-medium {self.theme.text_class()}")
443
+ ui.space()
444
+ ui.label(ep["desc"]).classes(f"text-caption {self.theme.text_class()}").style("opacity: 0.7;")
445
+
446
+ with ui.row().classes("w-full justify-end").style("margin-top: 12px;"):
447
+ ui.button("Close", icon="close", on_click=dialog.close).props("flat")
448
+
449
+ dialog.open()
450
+ except Exception as e:
451
+ logger.error(f"Failed to open API dialog: {e}")
452
+
453
+ # Application entry point
454
+ @ui.page("/")
455
+ def index_page():
456
+ """Main page - initialize theme and build UI"""
457
+ theme = ThemeManager()
458
+ theme.init(start_dark=False)
459
+ controller = RelayController(theme)
460
+ controller.build()
461
+
462
+ if __name__ in {"__main__", "__mp_main__"}:
463
+ parser = ArgumentParser(description="HID USB Relay REST API Server")
464
+ parser.add_argument("--host", type=str, default='0.0.0.0', help="Host address")
465
+ parser.add_argument("--port", type=int, default=9400, help="Port number")
466
+ parser.add_argument("--native", action="store_true", help="Run as native desktop app")
467
+ args = parser.parse_args()
468
+ ui.run(host=args.host, port=args.port, native=args.native)