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.
- kmtronic_usb_relay/__init__.py +0 -0
- kmtronic_usb_relay/com_utils.py +303 -0
- kmtronic_usb_relay/four_channel_relay.py +229 -0
- kmtronic_usb_relay/four_channel_relay_app.py +500 -0
- kmtronic_usb_relay/four_channel_relay_gui.py +324 -0
- kmtronic_usb_relay-26.0.1.dist-info/METADATA +64 -0
- kmtronic_usb_relay-26.0.1.dist-info/RECORD +8 -0
- kmtronic_usb_relay-26.0.1.dist-info/WHEEL +4 -0
|
@@ -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()
|