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.
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}")