kmtronic-usb-relay 25.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.
Potentially problematic release.
This version of kmtronic-usb-relay might be problematic. Click here for more details.
- 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_api.py +165 -0
- kmtronic_usb_relay/four_channel_relay_gui.py +324 -0
- kmtronic_usb_relay-25.0.0.dist-info/METADATA +65 -0
- kmtronic_usb_relay-25.0.0.dist-info/RECORD +8 -0
- kmtronic_usb_relay-25.0.0.dist-info/WHEEL +4 -0
|
File without changes
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Any, Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import serial
|
|
5
|
+
import serial.tools.list_ports
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class SerialComUtils:
|
|
10
|
+
"""
|
|
11
|
+
User-friendly utility class for serial COM port communication.
|
|
12
|
+
|
|
13
|
+
This class provides methods for discovering available serial ports,
|
|
14
|
+
connecting/disconnecting to a port, and sending/receiving data.
|
|
15
|
+
It also supports context management for safe resource handling.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
# --- Initialization and Configuration ---
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
baudrate: int = 9600,
|
|
23
|
+
bytesize: int = serial.EIGHTBITS,
|
|
24
|
+
stopbits: int = serial.STOPBITS_ONE,
|
|
25
|
+
parity: str = serial.PARITY_NONE,
|
|
26
|
+
timeout: Optional[float] = 2.5,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Initialize SerialComUtils with serial parameters.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
baudrate (int): Serial baudrate (default: 9600).
|
|
33
|
+
bytesize (int): Number of data bits (default: serial.EIGHTBITS).
|
|
34
|
+
stopbits (int): Number of stop bits (default: serial.STOPBITS_ONE).
|
|
35
|
+
parity (str): Parity setting (default: serial.PARITY_NONE).
|
|
36
|
+
timeout (float, optional): Read timeout in seconds, or None for blocking mode (default: 2.5).
|
|
37
|
+
"""
|
|
38
|
+
self.connection: Optional[serial.Serial] = None
|
|
39
|
+
self.baudrate = baudrate
|
|
40
|
+
self.bytesize = bytesize
|
|
41
|
+
self.stopbits = stopbits
|
|
42
|
+
self.parity = parity
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
|
|
45
|
+
def get_connection_params(self) -> Dict[str, Any]:
|
|
46
|
+
"""
|
|
47
|
+
Get the current serial connection parameters.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
dict: Dictionary containing baudrate, bytesize, stopbits, parity, and timeout.
|
|
51
|
+
"""
|
|
52
|
+
return {
|
|
53
|
+
"baudrate": self.baudrate,
|
|
54
|
+
"bytesize": self.bytesize,
|
|
55
|
+
"stopbits": self.stopbits,
|
|
56
|
+
"parity": self.parity,
|
|
57
|
+
"timeout": self.timeout,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def __repr__(self) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Return a string representation of the SerialComUtils instance.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
str: Human-readable summary of the current configuration and port.
|
|
66
|
+
"""
|
|
67
|
+
port = self.connection.port if self.connection and self.connection.is_open else None
|
|
68
|
+
return (
|
|
69
|
+
f"<SerialComUtils(port={port}, baudrate={self.baudrate}, "
|
|
70
|
+
f"bytesize={self.bytesize}, stopbits={self.stopbits}, "
|
|
71
|
+
f"parity={self.parity}, timeout={self.timeout})>"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# --- Port Discovery and Info ---
|
|
75
|
+
|
|
76
|
+
@staticmethod
|
|
77
|
+
def get_port_names() -> List[str]:
|
|
78
|
+
"""
|
|
79
|
+
List all available COM port device names.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List[str]: List of device names (e.g., ['COM1', 'COM2']).
|
|
83
|
+
"""
|
|
84
|
+
return [port.device for port in serial.tools.list_ports.comports()]
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def get_port_details() -> List[Dict[str, Any]]:
|
|
88
|
+
"""
|
|
89
|
+
Get details of all available COM ports.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List[dict]: Each dict contains:
|
|
93
|
+
- device (str): COM port number (e.g., 'COM1')
|
|
94
|
+
- description (str): Port description
|
|
95
|
+
- hwid (str): Hardware ID
|
|
96
|
+
"""
|
|
97
|
+
return [
|
|
98
|
+
{
|
|
99
|
+
"device": port.device,
|
|
100
|
+
"description": port.description,
|
|
101
|
+
"hwid": port.hwid
|
|
102
|
+
}
|
|
103
|
+
for port in serial.tools.list_ports.comports()
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def format_port_detail(port_info: Dict[str, Any]) -> str:
|
|
108
|
+
"""
|
|
109
|
+
Format a port info dictionary as a readable string.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
port_info (dict): Dictionary with keys 'device', 'description', 'hwid'.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
str: Formatted string describing the port.
|
|
116
|
+
"""
|
|
117
|
+
return (
|
|
118
|
+
f"Device: {port_info['device']}, "
|
|
119
|
+
f"Description: {port_info['description']}, "
|
|
120
|
+
f"HWID: {port_info['hwid']}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def log_port_details(port_name: Optional[str] = None) -> None:
|
|
125
|
+
"""
|
|
126
|
+
Log details of all COM ports or a specific port if port_name is given.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
port_name (str, optional): Device name of the port (e.g., 'COM3').
|
|
130
|
+
"""
|
|
131
|
+
ports = SerialComUtils.get_port_details()
|
|
132
|
+
if port_name:
|
|
133
|
+
for port in ports:
|
|
134
|
+
if port["device"] == port_name:
|
|
135
|
+
logger.info(SerialComUtils.format_port_detail(port))
|
|
136
|
+
return
|
|
137
|
+
logger.warning(f"Port {port_name} not found.")
|
|
138
|
+
else:
|
|
139
|
+
for port in ports:
|
|
140
|
+
logger.info(SerialComUtils.format_port_detail(port))
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def get_busy_ports() -> List[str]:
|
|
144
|
+
"""
|
|
145
|
+
Attempt to list COM ports that are currently in use (best effort).
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
List[str]: List of port device names that are in use.
|
|
149
|
+
"""
|
|
150
|
+
busy_ports = []
|
|
151
|
+
for port in SerialComUtils.get_port_names():
|
|
152
|
+
try:
|
|
153
|
+
with serial.Serial(port) as s:
|
|
154
|
+
pass
|
|
155
|
+
except (OSError, serial.SerialException):
|
|
156
|
+
busy_ports.append(port)
|
|
157
|
+
return busy_ports
|
|
158
|
+
|
|
159
|
+
# --- Connection Management ---
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def is_connected(self) -> bool:
|
|
163
|
+
"""
|
|
164
|
+
Check if the serial connection is open.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
bool: True if connected, False otherwise.
|
|
168
|
+
"""
|
|
169
|
+
return self.connection is not None and self.connection.is_open
|
|
170
|
+
|
|
171
|
+
def connect(self, port: str) -> bool:
|
|
172
|
+
"""
|
|
173
|
+
Open a connection to a COM port with the configured parameters.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
port (str): Device name of the port (e.g., 'COM3').
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
bool: True if connection was successful, False otherwise.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
self.connection = serial.Serial(
|
|
183
|
+
port=port,
|
|
184
|
+
baudrate=self.baudrate,
|
|
185
|
+
bytesize=self.bytesize,
|
|
186
|
+
stopbits=self.stopbits,
|
|
187
|
+
parity=self.parity,
|
|
188
|
+
timeout=self.timeout
|
|
189
|
+
)
|
|
190
|
+
logger.info(f"Connected to {port}")
|
|
191
|
+
return True
|
|
192
|
+
except serial.SerialException as e:
|
|
193
|
+
logger.error(f"Failed to connect to {port}: {e}")
|
|
194
|
+
self.connection = None
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
def open_connection(self, port: str) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Alias for connect. Opens a connection to the specified port.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
port (str): Device name of the port.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
bool: True if connection was successful, False otherwise.
|
|
206
|
+
"""
|
|
207
|
+
return self.connect(port)
|
|
208
|
+
|
|
209
|
+
def disconnect(self) -> bool:
|
|
210
|
+
"""
|
|
211
|
+
Alias for close_connection. Closes the serial connection.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
bool: True if disconnected or already closed, False if error occurred.
|
|
215
|
+
"""
|
|
216
|
+
return self.close_connection()
|
|
217
|
+
|
|
218
|
+
def close_connection(self) -> bool:
|
|
219
|
+
"""
|
|
220
|
+
Close the connection to the COM port.
|
|
221
|
+
|
|
222
|
+
Returns:
|
|
223
|
+
bool: True if disconnected or already closed, False if error occurred.
|
|
224
|
+
"""
|
|
225
|
+
if self.connection and self.connection.is_open:
|
|
226
|
+
try:
|
|
227
|
+
self.connection.close()
|
|
228
|
+
logger.info("Disconnected.")
|
|
229
|
+
self.connection = None
|
|
230
|
+
return True
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.error(f"Error while disconnecting: {e}")
|
|
233
|
+
return False
|
|
234
|
+
self.connection = None
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
# --- Data Transfer ---
|
|
238
|
+
|
|
239
|
+
def send(self, data: bytes) -> bool:
|
|
240
|
+
"""
|
|
241
|
+
Send data to the serial port.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
data (bytes): Bytes to send.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
bool: True if sent, False if not connected.
|
|
248
|
+
"""
|
|
249
|
+
if self.is_connected:
|
|
250
|
+
self.connection.write(data)
|
|
251
|
+
return True
|
|
252
|
+
else:
|
|
253
|
+
logger.warning("No open connection. Cannot send data.")
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
def receive(self, size: int = 1, as_int_list: bool = False) -> Optional[Union[bytes, List[int]]]:
|
|
257
|
+
"""
|
|
258
|
+
Receive data from the serial port.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
size (int): Number of bytes to read (default: 1).
|
|
262
|
+
as_int_list (bool): If True, return a list of ints instead of bytes.
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
bytes or List[int] or None: Received data, or None if not connected.
|
|
266
|
+
"""
|
|
267
|
+
if self.is_connected:
|
|
268
|
+
data = self.connection.read(size)
|
|
269
|
+
if as_int_list:
|
|
270
|
+
return list(data)
|
|
271
|
+
return data
|
|
272
|
+
else:
|
|
273
|
+
logger.warning("No open connection. Cannot receive data.")
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
# --- Context Management ---
|
|
277
|
+
|
|
278
|
+
def __enter__(self) -> "SerialComUtils":
|
|
279
|
+
"""
|
|
280
|
+
Enter the runtime context related to this object.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
SerialComUtils: The instance itself.
|
|
284
|
+
"""
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
def __exit__(
|
|
288
|
+
self,
|
|
289
|
+
exc_type: Optional[type],
|
|
290
|
+
exc_val: Optional[BaseException],
|
|
291
|
+
exc_tb: Optional[object],
|
|
292
|
+
) -> None:
|
|
293
|
+
"""
|
|
294
|
+
Exit the runtime context and close the serial connection.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
exc_type: Exception type.
|
|
298
|
+
exc_val: Exception value.
|
|
299
|
+
exc_tb: Exception traceback.
|
|
300
|
+
"""
|
|
301
|
+
self.close_connection()
|
|
302
|
+
|
|
303
|
+
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from kmtronic_usb_relay.com_utils import SerialComUtils
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
class RelayController:
|
|
10
|
+
"""Controller for KMTronic 4-Channel USB Relay devices.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
with RelayController("COM4") as relay:
|
|
14
|
+
relay.turn_on(1)
|
|
15
|
+
relay.turn_off(2)
|
|
16
|
+
print(relay.statuses)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
RELAY_COUNT = 4
|
|
20
|
+
STATUS_ON = "ON"
|
|
21
|
+
STATUS_OFF = "OFF"
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
com_port: str,
|
|
26
|
+
switch_delay: float = 1.0,
|
|
27
|
+
serial_utils: Optional[SerialComUtils] = None,
|
|
28
|
+
auto_connect: bool = True,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Initialize the RelayController.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
com_port (str): The COM port to which the relay board is connected (e.g., 'COM4').
|
|
35
|
+
switch_delay (float): Delay in seconds after switching a relay (default: 1.0).
|
|
36
|
+
serial_utils (Optional[SerialComUtils]): Custom SerialComUtils instance for testing/mocking.
|
|
37
|
+
auto_connect (bool): Automatically open connection on init (default: True).
|
|
38
|
+
"""
|
|
39
|
+
self.com_port = com_port
|
|
40
|
+
self.switch_delay = switch_delay
|
|
41
|
+
self.serial_utils = serial_utils or SerialComUtils()
|
|
42
|
+
self._is_connected = False
|
|
43
|
+
if auto_connect:
|
|
44
|
+
self.connect()
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def is_connected(self) -> bool:
|
|
48
|
+
"""
|
|
49
|
+
Check if the relay controller is connected to the COM port.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
bool: True if connected, False otherwise.
|
|
53
|
+
"""
|
|
54
|
+
return self.serial_utils.is_connected if self.serial_utils else False
|
|
55
|
+
|
|
56
|
+
def connect(self) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Open the serial connection to the relay board.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
Exception: If the connection cannot be established.
|
|
62
|
+
"""
|
|
63
|
+
if not self.is_connected:
|
|
64
|
+
try:
|
|
65
|
+
self._is_connected = self.serial_utils.open_connection(self.com_port)
|
|
66
|
+
logger.info(f"Connected to relay on {self.com_port}")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.error(f"Failed to open serial connection on {self.com_port}: {e}")
|
|
69
|
+
raise
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Close the serial connection to the relay board.
|
|
74
|
+
|
|
75
|
+
This method is safe to call multiple times.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
result = self.serial_utils.close_connection()
|
|
79
|
+
self._is_connected = not result
|
|
80
|
+
logger.info("Serial connection closed.")
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.warning(f"Error closing serial connection: {e}")
|
|
83
|
+
|
|
84
|
+
def turn_on(self, relay_number: int) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Turn ON the specified relay.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
relay_number (int): Relay number to turn ON (1-4).
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: If relay_number is out of range.
|
|
93
|
+
Exception: If sending the command fails.
|
|
94
|
+
"""
|
|
95
|
+
self._validate_relay_number(relay_number)
|
|
96
|
+
self._send_relay_command(relay_number, True)
|
|
97
|
+
|
|
98
|
+
def turn_off(self, relay_number: int) -> None:
|
|
99
|
+
"""
|
|
100
|
+
Turn OFF the specified relay.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
relay_number (int): Relay number to turn OFF (1-4).
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: If relay_number is out of range.
|
|
107
|
+
Exception: If sending the command fails.
|
|
108
|
+
"""
|
|
109
|
+
self._validate_relay_number(relay_number)
|
|
110
|
+
self._send_relay_command(relay_number, False)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def statuses(self) -> Dict[str, str]:
|
|
114
|
+
"""
|
|
115
|
+
Get the status of all relays as a dictionary.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Dict[str, str]: Dictionary mapping relay names (e.g., 'R1') to their status ('ON' or 'OFF').
|
|
119
|
+
"""
|
|
120
|
+
return self.get_statuses()
|
|
121
|
+
|
|
122
|
+
def get_statuses(self) -> Dict[str, str]:
|
|
123
|
+
"""
|
|
124
|
+
Query and return the status of all relays.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Dict[str, str]: Dictionary mapping relay names (e.g., 'R1') to their status ('ON' or 'OFF').
|
|
128
|
+
Returns an empty dict if communication fails.
|
|
129
|
+
"""
|
|
130
|
+
status_cmd = bytes([0xFF, 0x09, 0x00])
|
|
131
|
+
try:
|
|
132
|
+
self.serial_utils.send(status_cmd)
|
|
133
|
+
response: Optional[List[int]] = self.serial_utils.receive(
|
|
134
|
+
self.RELAY_COUNT, as_int_list=True
|
|
135
|
+
)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
logger.error(f"Failed to communicate with relay board: {e}")
|
|
138
|
+
return {}
|
|
139
|
+
|
|
140
|
+
if not response or len(response) < self.RELAY_COUNT:
|
|
141
|
+
logger.error("Failed to read relay status or incomplete response.")
|
|
142
|
+
return {}
|
|
143
|
+
|
|
144
|
+
relay_status = {
|
|
145
|
+
f"R{i}": self.STATUS_ON if byte == 1 else self.STATUS_OFF
|
|
146
|
+
for i, byte in enumerate(response, start=1)
|
|
147
|
+
}
|
|
148
|
+
logger.info("Relay statuses: %s", ", ".join(f"{k}: {v}" for k, v in relay_status.items()))
|
|
149
|
+
return relay_status
|
|
150
|
+
|
|
151
|
+
def __enter__(self) -> "RelayController":
|
|
152
|
+
"""
|
|
153
|
+
Enter the runtime context related to this object.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
RelayController: The connected relay controller instance.
|
|
157
|
+
"""
|
|
158
|
+
self.connect()
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def __exit__(
|
|
162
|
+
self,
|
|
163
|
+
exc_type: Optional[type],
|
|
164
|
+
exc_val: Optional[BaseException],
|
|
165
|
+
exc_tb: Optional[object],
|
|
166
|
+
) -> None:
|
|
167
|
+
"""
|
|
168
|
+
Exit the runtime context and close the serial connection.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
exc_type: Exception type.
|
|
172
|
+
exc_val: Exception value.
|
|
173
|
+
exc_tb: Exception traceback.
|
|
174
|
+
"""
|
|
175
|
+
self.close()
|
|
176
|
+
|
|
177
|
+
# --- Internal helpers ---
|
|
178
|
+
|
|
179
|
+
def _send_relay_command(self, relay_number: int, turn_on: bool) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Send a command to turn a relay ON or OFF.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
relay_number (int): Relay number (1-4).
|
|
185
|
+
turn_on (bool): True to turn ON, False to turn OFF.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
Exception: If sending the command fails.
|
|
189
|
+
"""
|
|
190
|
+
cmd = bytes([0xFF, relay_number, 0x01 if turn_on else 0x00])
|
|
191
|
+
logger.debug(
|
|
192
|
+
"Sending command: %s to relay %d (%s)",
|
|
193
|
+
cmd.hex(), relay_number, "ON" if turn_on else "OFF"
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
self.serial_utils.send(cmd)
|
|
197
|
+
time.sleep(self.switch_delay)
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Failed to send command to relay {relay_number}: {e}")
|
|
200
|
+
raise
|
|
201
|
+
|
|
202
|
+
def _validate_relay_number(self, relay_number: int) -> None:
|
|
203
|
+
"""
|
|
204
|
+
Validate that the relay number is within the allowed range.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
relay_number (int): Relay number to validate.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ValueError: If relay_number is not between 1 and RELAY_COUNT.
|
|
211
|
+
"""
|
|
212
|
+
if not (1 <= relay_number <= self.RELAY_COUNT):
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"relay_number must be between 1 and {self.RELAY_COUNT}, got {relay_number}."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
logging.basicConfig(level=logging.INFO)
|
|
219
|
+
print("Hello Kmtronic USB Relay User!")
|
|
220
|
+
|
|
221
|
+
# Example: Using RelayController directly
|
|
222
|
+
print("\n--- Example: RelayController ---")
|
|
223
|
+
try:
|
|
224
|
+
with RelayController("COM4") as relay:
|
|
225
|
+
relay.turn_on(1)
|
|
226
|
+
relay.turn_off(2)
|
|
227
|
+
print(f"Relay statuses: {relay.statuses}")
|
|
228
|
+
except Exception as e:
|
|
229
|
+
print(f"Error: {e}")
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException, Path
|
|
7
|
+
import uvicorn
|
|
8
|
+
|
|
9
|
+
from kmtronic_usb_relay.four_channel_relay import RelayController
|
|
10
|
+
from kmtronic_usb_relay.com_utils import SerialComUtils
|
|
11
|
+
|
|
12
|
+
def create_app(com_port: str, controller: Optional[RelayController] = None) -> FastAPI:
|
|
13
|
+
"""
|
|
14
|
+
Create and configure a FastAPI app for the KMTronic 4-Channel USB Relay.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
com_port: Serial port for the relay board.
|
|
18
|
+
controller: Optional RelayController instance (for testing/mocking).
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
Configured FastAPI app.
|
|
22
|
+
"""
|
|
23
|
+
relay_controller = controller or RelayController(com_port)
|
|
24
|
+
|
|
25
|
+
@asynccontextmanager
|
|
26
|
+
async def lifespan(app: FastAPI):
|
|
27
|
+
yield
|
|
28
|
+
relay_controller.close()
|
|
29
|
+
|
|
30
|
+
app = FastAPI(title="KMTronic USB Relay API", lifespan=lifespan)
|
|
31
|
+
|
|
32
|
+
@app.get("/health", response_model=Dict[str, str])
|
|
33
|
+
def health_check() -> Dict[str, str]:
|
|
34
|
+
"""Health check endpoint."""
|
|
35
|
+
return {"status": "ok"}
|
|
36
|
+
|
|
37
|
+
@app.get("/", response_model=Dict[str, Any])
|
|
38
|
+
def root() -> Dict[str, Any]:
|
|
39
|
+
endpoints = [
|
|
40
|
+
{"path": route.path, "methods": list(route.methods)}
|
|
41
|
+
for route in app.routes
|
|
42
|
+
if hasattr(route, "path") and hasattr(route, "methods")
|
|
43
|
+
]
|
|
44
|
+
return {
|
|
45
|
+
"message": "KMTronic USB Relay API is running.",
|
|
46
|
+
"endpoints": endpoints
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@app.get("/relay/status", response_model=Dict[str, Any])
|
|
50
|
+
def get_status() -> Dict[str, Any]:
|
|
51
|
+
"""Get the status of all relays."""
|
|
52
|
+
return relay_controller.get_statuses()
|
|
53
|
+
|
|
54
|
+
@app.post("/relay/{relay_number}/on", response_model=Dict[str, Any])
|
|
55
|
+
def turn_on(
|
|
56
|
+
relay_number: int = Path(..., ge=1, le=4, description="Relay number (1-4)")
|
|
57
|
+
) -> Dict[str, Any]:
|
|
58
|
+
"""Turn ON the specified relay."""
|
|
59
|
+
try:
|
|
60
|
+
relay_controller.turn_on(relay_number)
|
|
61
|
+
return {"result": "Relay turned ON", "relay": relay_number}
|
|
62
|
+
except ValueError as e:
|
|
63
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
64
|
+
|
|
65
|
+
@app.post("/relay/{relay_number}/off", response_model=Dict[str, Any])
|
|
66
|
+
def turn_off(
|
|
67
|
+
relay_number: int = Path(..., ge=1, le=4, description="Relay number (1-4)")
|
|
68
|
+
) -> Dict[str, Any]:
|
|
69
|
+
"""Turn OFF the specified relay."""
|
|
70
|
+
try:
|
|
71
|
+
relay_controller.turn_off(relay_number)
|
|
72
|
+
return {"result": "Relay turned OFF", "relay": relay_number}
|
|
73
|
+
except ValueError as e:
|
|
74
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
75
|
+
|
|
76
|
+
return app
|
|
77
|
+
|
|
78
|
+
def get_app() -> FastAPI:
|
|
79
|
+
"""
|
|
80
|
+
Get a FastAPI app instance for ASGI servers (e.g., uvicorn).
|
|
81
|
+
Uses the KMTRONIC_COM_PORT environment variable or defaults to 'COM4'.
|
|
82
|
+
"""
|
|
83
|
+
com_port = os.getenv("KMTRONIC_COM_PORT", "COM4")
|
|
84
|
+
return create_app(com_port)
|
|
85
|
+
|
|
86
|
+
class RelayControllerApi:
|
|
87
|
+
"""
|
|
88
|
+
User-friendly wrapper to run the FastAPI server for KMTronic USB Relay.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
api = RelayControllerApi("COM4")
|
|
92
|
+
api.run()
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(self, com_port: str = "COM4", controller: Optional[RelayController] = None):
|
|
96
|
+
"""
|
|
97
|
+
Initialize the API server wrapper.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
com_port: Serial port for the relay board (e.g., "COM4" or "/dev/ttyUSB0").
|
|
101
|
+
controller: Optional RelayController instance for testing/mocking.
|
|
102
|
+
"""
|
|
103
|
+
self.app = create_app(com_port, controller)
|
|
104
|
+
|
|
105
|
+
def run(self, host: str = "127.0.0.1", port: int = 8000, reload: bool = False) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Start the FastAPI server using uvicorn.
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
host: Hostname or IP address to bind the server.
|
|
111
|
+
port: Port number to bind the server.
|
|
112
|
+
reload: Enable auto-reload (for development).
|
|
113
|
+
"""
|
|
114
|
+
uvicorn.run(self.app, host=host, port=port, reload=reload)
|
|
115
|
+
|
|
116
|
+
def select_com_port() -> Optional[str]:
|
|
117
|
+
"""
|
|
118
|
+
Helper to select a COM port interactively if not provided.
|
|
119
|
+
Returns the selected COM port or None if not available.
|
|
120
|
+
"""
|
|
121
|
+
ports = SerialComUtils.get_port_names()
|
|
122
|
+
if not ports:
|
|
123
|
+
print("No serial COM ports found. Please connect your relay and try again.")
|
|
124
|
+
return None
|
|
125
|
+
print("Available COM ports:")
|
|
126
|
+
for idx, port in enumerate(ports, 1):
|
|
127
|
+
print(f" {idx}: {port}")
|
|
128
|
+
while True:
|
|
129
|
+
selection = input(f"Select COM port [1-{len(ports)}]: ").strip()
|
|
130
|
+
if selection.isdigit() and 1 <= int(selection) <= len(ports):
|
|
131
|
+
return ports[int(selection) - 1]
|
|
132
|
+
print("Invalid selection. Please try again.")
|
|
133
|
+
|
|
134
|
+
def main() -> None:
|
|
135
|
+
"""
|
|
136
|
+
Command-line entry point to run the API server.
|
|
137
|
+
|
|
138
|
+
Usage:
|
|
139
|
+
``python -m src.kmtronic_usb_relay.four_channel_relay_api [COM_PORT] [--host HOST] [--port PORT] [--reload]``
|
|
140
|
+
"""
|
|
141
|
+
import argparse
|
|
142
|
+
|
|
143
|
+
parser = argparse.ArgumentParser(description="Run KMTronic USB Relay API server.")
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"com_port",
|
|
146
|
+
nargs="?",
|
|
147
|
+
default=None,
|
|
148
|
+
help="COM port for relay board (e.g., COM4 or /dev/ttyUSB0)."
|
|
149
|
+
)
|
|
150
|
+
parser.add_argument("--host", default="127.0.0.1", help="Host to bind the server (default: 127.0.0.1)")
|
|
151
|
+
parser.add_argument("--port", type=int, default=8000, help="Port to bind the server (default: 8000)")
|
|
152
|
+
parser.add_argument("--reload", action="store_true", help="Enable auto-reload (for development)")
|
|
153
|
+
args = parser.parse_args()
|
|
154
|
+
|
|
155
|
+
com_port = args.com_port or os.getenv("KMTRONIC_COM_PORT")
|
|
156
|
+
if not com_port:
|
|
157
|
+
com_port = select_com_port()
|
|
158
|
+
if not com_port:
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
api = RelayControllerApi(com_port)
|
|
162
|
+
api.run(host=args.host, port=args.port, reload=args.reload)
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
import customtkinter as ctk
|
|
6
|
+
import tkinter.messagebox as messagebox
|
|
7
|
+
|
|
8
|
+
from kmtronic_usb_relay.com_utils import SerialComUtils
|
|
9
|
+
from kmtronic_usb_relay.four_channel_relay import RelayController
|
|
10
|
+
|
|
11
|
+
class RelayControllerGui:
|
|
12
|
+
"""
|
|
13
|
+
User-friendly GUI for KMTronic USB 4-channel relay module.
|
|
14
|
+
Provides an interface to connect, control, and monitor relays.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
com_port: str = "",
|
|
20
|
+
controller: Optional[RelayController] = None,
|
|
21
|
+
relay_names: Optional[List[str]] = None,
|
|
22
|
+
):
|
|
23
|
+
"""
|
|
24
|
+
Initialize the RelayControllerGui.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
com_port (str): Default COM port to select.
|
|
28
|
+
controller (Optional[RelayController]): Optional pre-initialized RelayController.
|
|
29
|
+
relay_names (Optional[List[str]]): Optional list of relay names for labeling buttons.
|
|
30
|
+
"""
|
|
31
|
+
self.controller = controller
|
|
32
|
+
self.com_port = com_port
|
|
33
|
+
self.relay_names = relay_names or [f"Relay {i}" for i in range(1, 5)]
|
|
34
|
+
ctk.set_appearance_mode("dark")
|
|
35
|
+
ctk.set_default_color_theme("dark-blue")
|
|
36
|
+
self.root = ctk.CTk()
|
|
37
|
+
self.root.title("KMTronic USB Relay Controller")
|
|
38
|
+
self.root.protocol("WM_DELETE_WINDOW", self.close)
|
|
39
|
+
self.relay_buttons: List[ctk.CTkButton] = []
|
|
40
|
+
self.combobox = None
|
|
41
|
+
self.conn_btn = None
|
|
42
|
+
self._build_ui()
|
|
43
|
+
self._update_ui()
|
|
44
|
+
|
|
45
|
+
def _build_ui(self):
|
|
46
|
+
"""
|
|
47
|
+
Build the main UI layout, including port selection, relay controls, and action buttons.
|
|
48
|
+
"""
|
|
49
|
+
main = ctk.CTkFrame(self.root, fg_color="#23272e")
|
|
50
|
+
main.pack(padx=1, pady=1, fill="both", expand=True)
|
|
51
|
+
self._build_port_group(main)
|
|
52
|
+
self._build_relay_group(main)
|
|
53
|
+
self._build_action_group(main)
|
|
54
|
+
|
|
55
|
+
def _build_port_group(self, parent):
|
|
56
|
+
"""
|
|
57
|
+
Build the serial port selection group in the UI.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
parent: The parent widget to attach this group to.
|
|
61
|
+
"""
|
|
62
|
+
port_group = ctk.CTkFrame(parent, border_color="#1976D2", border_width=2, fg_color="#23272e")
|
|
63
|
+
port_group.pack(fill="x", pady=(0, 4), padx=1, ipady=2, ipadx=2)
|
|
64
|
+
ctk.CTkLabel(
|
|
65
|
+
port_group, text="Port:", width=50, anchor="w", text_color="#B0B8C1",
|
|
66
|
+
font=ctk.CTkFont(size=13, weight="bold")
|
|
67
|
+
).grid(row=0, column=0, padx=(4, 1), pady=4, sticky="w")
|
|
68
|
+
ports = SerialComUtils.get_port_names()
|
|
69
|
+
self.combobox = ctk.CTkComboBox(
|
|
70
|
+
port_group, values=ports, width=120, fg_color="#23272e", border_color="#1976D2"
|
|
71
|
+
)
|
|
72
|
+
self.combobox.grid(row=0, column=1, padx=1, pady=4, sticky="w")
|
|
73
|
+
self.combobox.set(self.com_port if self.com_port in ports else (ports[0] if ports else ""))
|
|
74
|
+
ctk.CTkButton(
|
|
75
|
+
port_group, text="⟳", width=28, command=self.refresh_ports,
|
|
76
|
+
fg_color="#23272e", border_color="#1976D2"
|
|
77
|
+
).grid(row=0, column=2, padx=1, pady=4)
|
|
78
|
+
self.conn_btn = ctk.CTkButton(
|
|
79
|
+
port_group, text="Connect", fg_color="#444c56", hover_color="#1976D2",
|
|
80
|
+
width=90, command=self._toggle_connection
|
|
81
|
+
)
|
|
82
|
+
self.conn_btn.grid(row=0, column=3, padx=(6, 1), pady=4)
|
|
83
|
+
port_group.grid_columnconfigure(4, weight=1)
|
|
84
|
+
|
|
85
|
+
def _build_relay_group(self, parent):
|
|
86
|
+
"""
|
|
87
|
+
Build the relay control buttons in the UI.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
parent: The parent widget to attach this group to.
|
|
91
|
+
"""
|
|
92
|
+
relay_group = ctk.CTkFrame(parent, border_color="#388E3C", border_width=2, fg_color="#23272e")
|
|
93
|
+
relay_group.pack(fill="x", pady=(0, 4), padx=1, ipady=2, ipadx=2)
|
|
94
|
+
ctk.CTkLabel(
|
|
95
|
+
relay_group, text="Relay Controls", font=ctk.CTkFont(size=15, weight="bold"),
|
|
96
|
+
text_color="#A5D6A7"
|
|
97
|
+
).pack(anchor="w", padx=4, pady=(4, 6))
|
|
98
|
+
row = ctk.CTkFrame(relay_group, fg_color="#23272e")
|
|
99
|
+
row.pack(fill="x", padx=6, pady=(0, 4))
|
|
100
|
+
for i in range(1, 5):
|
|
101
|
+
col = ctk.CTkFrame(row, fg_color="#23272e")
|
|
102
|
+
col.pack(side="left", padx=6, expand=True)
|
|
103
|
+
relay_label = self.relay_names[i - 1] if i - 1 < len(self.relay_names) else f"Relay {i}"
|
|
104
|
+
ctk.CTkLabel(
|
|
105
|
+
col, text=relay_label, width=60, anchor="center", text_color="#B0B8C1",
|
|
106
|
+
font=ctk.CTkFont(size=12, weight="bold")
|
|
107
|
+
).pack(pady=(0, 2))
|
|
108
|
+
btn = ctk.CTkButton(
|
|
109
|
+
col, text="OFF", fg_color="#444c56", hover_color="#388E3C", width=70,
|
|
110
|
+
command=lambda n=i: self._toggle_relay(n)
|
|
111
|
+
)
|
|
112
|
+
btn.pack()
|
|
113
|
+
self.relay_buttons.append(btn)
|
|
114
|
+
|
|
115
|
+
def _build_action_group(self, parent):
|
|
116
|
+
"""
|
|
117
|
+
Build the action buttons group (e.g., Refresh Status) in the UI.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
parent: The parent widget to attach this group to.
|
|
121
|
+
"""
|
|
122
|
+
btn_frame = ctk.CTkFrame(parent, fg_color="#23272e", border_color="#F57C00", border_width=2)
|
|
123
|
+
btn_frame.pack(fill="x", pady=(4, 0), padx=1, ipady=2, ipadx=2)
|
|
124
|
+
ctk.CTkButton(
|
|
125
|
+
btn_frame, text="Refresh Status", command=self._update_status_labels, width=120,
|
|
126
|
+
fg_color="#444c56", hover_color="#F57C00", text_color="#FFA726"
|
|
127
|
+
).pack(side="right", padx=(0, 4))
|
|
128
|
+
|
|
129
|
+
def refresh_ports(self):
|
|
130
|
+
"""
|
|
131
|
+
Refresh the list of available serial ports and update the port selection combobox.
|
|
132
|
+
Enables or disables the connect button based on port availability.
|
|
133
|
+
"""
|
|
134
|
+
ports = SerialComUtils.get_port_names()
|
|
135
|
+
if self.combobox:
|
|
136
|
+
self.combobox.configure(values=ports)
|
|
137
|
+
self.combobox.set(self.com_port if self.com_port in ports else (ports[0] if ports else ""))
|
|
138
|
+
if self.conn_btn:
|
|
139
|
+
self.conn_btn.configure(state="normal" if ports else "disabled")
|
|
140
|
+
|
|
141
|
+
def _toggle_connection(self):
|
|
142
|
+
"""
|
|
143
|
+
Handle connect/disconnect button click.
|
|
144
|
+
Starts a background thread to connect or disconnect from the relay controller.
|
|
145
|
+
"""
|
|
146
|
+
if self.controller:
|
|
147
|
+
self._run_in_thread(self._disconnect_threaded)
|
|
148
|
+
else:
|
|
149
|
+
if self.conn_btn:
|
|
150
|
+
self.conn_btn.configure(state="disabled", text="Connecting...")
|
|
151
|
+
self._run_in_thread(self._connect_threaded)
|
|
152
|
+
|
|
153
|
+
def _connect_threaded(self):
|
|
154
|
+
"""
|
|
155
|
+
Attempt to connect to the relay controller in a background thread.
|
|
156
|
+
On success, updates the UI and relay status. On failure, shows an error dialog.
|
|
157
|
+
"""
|
|
158
|
+
port = self.combobox.get() if self.combobox else ""
|
|
159
|
+
try:
|
|
160
|
+
if self.controller:
|
|
161
|
+
self.controller.close()
|
|
162
|
+
self.controller = None
|
|
163
|
+
controller = RelayController(port, switch_delay=0.1)
|
|
164
|
+
statuses = controller.get_statuses() # <-- FIXED
|
|
165
|
+
if statuses:
|
|
166
|
+
self.root.after(0, self._on_connect_success, controller, port)
|
|
167
|
+
else:
|
|
168
|
+
raise Exception("Failed to read relay status after connecting.")
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logging.error(f"Connection failed: {e}")
|
|
171
|
+
self.root.after(0, lambda: messagebox.showerror("Connection Error", str(e)))
|
|
172
|
+
self.root.after(0, self._update_ui)
|
|
173
|
+
finally:
|
|
174
|
+
self.root.after(0, self._enable_connect_btn)
|
|
175
|
+
|
|
176
|
+
def _disconnect_threaded(self):
|
|
177
|
+
"""
|
|
178
|
+
Disconnect from the relay controller in a background thread.
|
|
179
|
+
Updates the UI and enables the connect button after disconnecting.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
if self.controller:
|
|
183
|
+
self.controller.close()
|
|
184
|
+
self.controller = None
|
|
185
|
+
except Exception as e:
|
|
186
|
+
logging.error(f"Disconnection failed: {e}")
|
|
187
|
+
self.root.after(0, lambda: messagebox.showerror("Disconnection Error", str(e)))
|
|
188
|
+
finally:
|
|
189
|
+
self.root.after(0, self._update_ui)
|
|
190
|
+
self.root.after(0, self._enable_connect_btn)
|
|
191
|
+
|
|
192
|
+
def _on_connect_success(self, controller: RelayController, port: str):
|
|
193
|
+
"""
|
|
194
|
+
Callback for successful connection.
|
|
195
|
+
Sets the controller, updates the UI, and refreshes relay statuses.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
controller (RelayController): The connected relay controller instance.
|
|
199
|
+
port (str): The COM port used for connection.
|
|
200
|
+
"""
|
|
201
|
+
self.controller = controller
|
|
202
|
+
self.com_port = port
|
|
203
|
+
self._update_ui()
|
|
204
|
+
self._update_status_labels()
|
|
205
|
+
|
|
206
|
+
def _enable_connect_btn(self):
|
|
207
|
+
"""
|
|
208
|
+
Enable the connect/disconnect button after a connection or disconnection attempt.
|
|
209
|
+
Updates the button text based on connection state.
|
|
210
|
+
"""
|
|
211
|
+
if self.conn_btn:
|
|
212
|
+
self.conn_btn.configure(state="normal", text="Disconnect" if self.controller else "Connect")
|
|
213
|
+
|
|
214
|
+
def _toggle_relay(self, relay_number: int):
|
|
215
|
+
"""
|
|
216
|
+
Toggle the state of a relay in a background thread.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
relay_number (int): The relay number to toggle (1-4).
|
|
220
|
+
"""
|
|
221
|
+
if self.controller:
|
|
222
|
+
self._run_in_thread(lambda: self._toggle_relay_worker(relay_number))
|
|
223
|
+
|
|
224
|
+
def _toggle_relay_worker(self, relay_number: int):
|
|
225
|
+
"""
|
|
226
|
+
Worker function to toggle relay state.
|
|
227
|
+
Reads current status and switches relay ON/OFF accordingly.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
relay_number (int): The relay number to toggle (1-4).
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
statuses = self.controller.get_statuses()
|
|
234
|
+
relay_key = f"R{relay_number}"
|
|
235
|
+
if statuses.get(relay_key) == "ON":
|
|
236
|
+
self.controller.turn_off(relay_number) # <-- FIXED
|
|
237
|
+
else:
|
|
238
|
+
self.controller.turn_on(relay_number) # <-- FIXED
|
|
239
|
+
except Exception as e:
|
|
240
|
+
self.root.after(0, lambda e=e: messagebox.showerror("Relay Error", str(e)))
|
|
241
|
+
self.root.after(0, self._update_status_labels)
|
|
242
|
+
|
|
243
|
+
def _update_status_labels(self):
|
|
244
|
+
"""
|
|
245
|
+
Update the relay button labels and colors to reflect current relay states.
|
|
246
|
+
If not connected, sets all buttons to unknown state.
|
|
247
|
+
"""
|
|
248
|
+
if self.controller:
|
|
249
|
+
try:
|
|
250
|
+
statuses = self.controller.get_statuses() # <-- FIXED
|
|
251
|
+
for i in range(4):
|
|
252
|
+
status = statuses.get(f"R{i+1}", "Unknown")
|
|
253
|
+
color = "green" if status == "ON" else "red" if status == "OFF" else "gray"
|
|
254
|
+
text = status if status in ("ON", "OFF") else "?"
|
|
255
|
+
self.relay_buttons[i].configure(text=text, fg_color=color)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
messagebox.showerror("Status Error", str(e))
|
|
258
|
+
for btn in self.relay_buttons:
|
|
259
|
+
btn.configure(text="?", fg_color="gray")
|
|
260
|
+
else:
|
|
261
|
+
for btn in self.relay_buttons:
|
|
262
|
+
btn.configure(text="?", fg_color="gray")
|
|
263
|
+
|
|
264
|
+
def _update_ui(self):
|
|
265
|
+
"""
|
|
266
|
+
Update the UI elements (button states, labels) based on connection state.
|
|
267
|
+
Disables relay buttons if not connected.
|
|
268
|
+
"""
|
|
269
|
+
is_connected = self.controller is not None
|
|
270
|
+
if self.conn_btn:
|
|
271
|
+
self.conn_btn.configure(
|
|
272
|
+
text="Disconnect" if is_connected else "Connect",
|
|
273
|
+
fg_color="green" if is_connected else "gray"
|
|
274
|
+
)
|
|
275
|
+
for btn in self.relay_buttons:
|
|
276
|
+
btn.configure(state="normal" if is_connected else "disabled")
|
|
277
|
+
|
|
278
|
+
def _run_in_thread(self, func):
|
|
279
|
+
"""
|
|
280
|
+
Run a function in a background thread to avoid blocking the UI.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
func (Callable): The function to run in a thread.
|
|
284
|
+
"""
|
|
285
|
+
threading.Thread(target=func, daemon=True).start()
|
|
286
|
+
|
|
287
|
+
def run(self):
|
|
288
|
+
"""
|
|
289
|
+
Start the GUI main loop. Call this method to launch the application.
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
self.root.mainloop()
|
|
293
|
+
except Exception as e:
|
|
294
|
+
print(f"Unexpected error: {e}")
|
|
295
|
+
|
|
296
|
+
def close(self):
|
|
297
|
+
"""
|
|
298
|
+
Close the GUI and release resources.
|
|
299
|
+
Closes the relay controller connection and destroys the main window.
|
|
300
|
+
"""
|
|
301
|
+
if self.controller:
|
|
302
|
+
self.controller.close()
|
|
303
|
+
self.controller = None
|
|
304
|
+
if self.root.winfo_exists():
|
|
305
|
+
self.root.destroy()
|
|
306
|
+
|
|
307
|
+
def __del__(self):
|
|
308
|
+
"""
|
|
309
|
+
Destructor to ensure resources are released when the object is deleted.
|
|
310
|
+
"""
|
|
311
|
+
try:
|
|
312
|
+
self.close()
|
|
313
|
+
except Exception:
|
|
314
|
+
pass
|
|
315
|
+
|
|
316
|
+
def main():
|
|
317
|
+
"""
|
|
318
|
+
Entry point for running the GUI as a standalone application.
|
|
319
|
+
"""
|
|
320
|
+
print("\n--- KMTronic USB Relay Controller GUI ---")
|
|
321
|
+
RelayControllerGui().run()
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
main()
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: kmtronic-usb-relay
|
|
3
|
+
Version: 25.0.0
|
|
4
|
+
Summary: Python library for KMTronic USB Relay
|
|
5
|
+
Keywords: python,KMTronic,USB,relay,app
|
|
6
|
+
Author: chaitu-ycr
|
|
7
|
+
Author-email: chaitu-ycr <chaitu.ycr@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2025 chaitu-ycr
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
Classifier: Programming Language :: Python :: 3
|
|
30
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
31
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
32
|
+
Requires-Dist: pyserial
|
|
33
|
+
Requires-Dist: customtkinter
|
|
34
|
+
Requires-Dist: fastapi[all]
|
|
35
|
+
Requires-Python: >=3.10, <=3.14
|
|
36
|
+
Project-URL: documentation, https://chaitu-ycr.github.io/automotive-test-kit/packages/kmtronic_usb_relay
|
|
37
|
+
Project-URL: homepage, https://github.com/chaitu-ycr/automotive-test-kit
|
|
38
|
+
Project-URL: repository, https://github.com/chaitu-ycr/automotive-test-kit
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# kmtronic_usb_relay
|
|
42
|
+
|
|
43
|
+
This project is a Python package for controlling KMTronics USB relay boards. It provides a simple interfaces to interact with the relays, allowing you to turn them on and off programmatically.
|
|
44
|
+
|
|
45
|
+
It also includes a GUI and FastApi implementation for easy control of the relays.
|
|
46
|
+
|
|
47
|
+
## usage
|
|
48
|
+
|
|
49
|
+
### kmtronic_usb_relay_gui
|
|
50
|
+
|
|
51
|
+
```cmd
|
|
52
|
+
python -m src.kmtronic_usb_relay.four_channel_relay_gui
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+

|
|
56
|
+
|
|
57
|
+
### kmtronic_usb_relay_api
|
|
58
|
+
|
|
59
|
+
```cmd
|
|
60
|
+
python -m src.kmtronic_usb_relay.four_channel_relay_api
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+

|
|
64
|
+
|
|
65
|
+
## [source manual](https://chaitu-ycr.github.io/automotive-test-kit/packages/kmtronic_usb_relay/#source-manual)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
kmtronic_usb_relay/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
kmtronic_usb_relay/com_utils.py,sha256=gJSJC3rd60QpEKhro-xbRwmIZ-usYsmdlbch3EFa6UI,9621
|
|
3
|
+
kmtronic_usb_relay/four_channel_relay.py,sha256=2W8WSfm5V6bxvtInuTlcNhVkLGh0jl4AR0JHNvurcRk,7542
|
|
4
|
+
kmtronic_usb_relay/four_channel_relay_api.py,sha256=SRAUCsCRODSZVrVhNmloFzMRp04jHvYFKLoD2If9Kao,5944
|
|
5
|
+
kmtronic_usb_relay/four_channel_relay_gui.py,sha256=1ub1Ah9iFMwrnnpAmBiQJL7ptPkjCKaSsGsiXOTNyrM,13059
|
|
6
|
+
kmtronic_usb_relay-25.0.0.dist-info/WHEEL,sha256=5h_Q-_6zWQhhADpsAD_Xpw7gFbCRK5WjOOEq0nB806Q,79
|
|
7
|
+
kmtronic_usb_relay-25.0.0.dist-info/METADATA,sha256=RCFIJAOKsXq2PSH9O6SxUTAgdRIl5VMG81-RQzHsHO4,2798
|
|
8
|
+
kmtronic_usb_relay-25.0.0.dist-info/RECORD,,
|