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 +468 -0
- hid_usb_relay/usb_relay.py +510 -125
- {hid_usb_relay-25.0.0.dist-info → hid_usb_relay-26.0.0.dist-info}/METADATA +8 -8
- {hid_usb_relay-25.0.0.dist-info → hid_usb_relay-26.0.0.dist-info}/RECORD +5 -5
- {hid_usb_relay-25.0.0.dist-info → hid_usb_relay-26.0.0.dist-info}/WHEEL +2 -2
- hid_usb_relay/rest_api.py +0 -116
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)
|