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/usb_relay.py
CHANGED
|
@@ -1,197 +1,582 @@
|
|
|
1
1
|
"""
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
HID USB Relay Controller Module
|
|
3
|
+
|
|
4
|
+
Provides an object-oriented interface for controlling HID USB relay devices
|
|
5
|
+
via command-line executable. Supports multiple relay devices with proper
|
|
6
|
+
error handling, validation, and reusability.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
>>> relay = USBRelayDevice() # Default device
|
|
10
|
+
>>> relay.turn_on(1)
|
|
11
|
+
>>> relay.get_state()
|
|
12
|
+
{'R1': 'ON', 'R2': 'OFF'}
|
|
13
|
+
|
|
14
|
+
>>> specific = USBRelayDevice(device_id='HURTM')
|
|
15
|
+
>>> specific.turn_on_all()
|
|
16
|
+
|
|
17
|
+
>>> # Context manager pattern
|
|
18
|
+
>>> with USBRelayDevice('HURTM') as relay:
|
|
19
|
+
... relay.turn_on_all()
|
|
4
20
|
"""
|
|
5
21
|
|
|
22
|
+
import logging
|
|
6
23
|
import os
|
|
7
24
|
import platform
|
|
25
|
+
import re
|
|
8
26
|
import subprocess
|
|
9
|
-
from
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from enum import Enum
|
|
29
|
+
from functools import lru_cache
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Dict, List, Optional, Union
|
|
10
32
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
33
|
+
__all__ = [
|
|
34
|
+
'USBRelayDevice',
|
|
35
|
+
'enumerate_devices',
|
|
36
|
+
'RelayState',
|
|
37
|
+
'RelayError',
|
|
38
|
+
'RelayCommandError',
|
|
39
|
+
'RelayValidationError',
|
|
40
|
+
]
|
|
14
41
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"""
|
|
18
|
-
return platform.system().lower(), platform.architecture()[0].lower()
|
|
42
|
+
# Configure module logger
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
19
44
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
45
|
+
# Constants
|
|
46
|
+
DEFAULT_TIMEOUT = 5.0
|
|
47
|
+
MAX_RELAY_COUNT = 8
|
|
23
48
|
|
|
24
|
-
Returns:
|
|
25
|
-
str: Path to the binary folder.
|
|
26
|
-
"""
|
|
27
|
-
return os.path.join(os.path.dirname(os.path.abspath(__file__)), 'hid_usb_relay_bin')
|
|
28
49
|
|
|
29
|
-
|
|
30
|
-
"""
|
|
31
|
-
|
|
50
|
+
class RelayState(Enum):
|
|
51
|
+
"""Relay state enumeration."""
|
|
52
|
+
ON = "on"
|
|
53
|
+
OFF = "off"
|
|
32
54
|
|
|
33
|
-
Args:
|
|
34
|
-
file_name (str): Name of the binary/library file.
|
|
35
55
|
|
|
36
|
-
|
|
37
|
-
|
|
56
|
+
class RelayCommand(Enum):
|
|
57
|
+
"""Available relay commands."""
|
|
58
|
+
STATE = "state"
|
|
59
|
+
ENUM = "enum"
|
|
38
60
|
|
|
39
|
-
Raises:
|
|
40
|
-
OSError: If the system platform is unsupported.
|
|
41
|
-
"""
|
|
42
|
-
bin_dir = get_bin_path()
|
|
43
|
-
system, arch = get_platform_and_architecture()
|
|
44
|
-
if system not in ['windows', 'linux']:
|
|
45
|
-
raise OSError(f'Unsupported system platform: {system}')
|
|
46
|
-
return os.path.join(bin_dir, system, arch, file_name)
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
"""
|
|
50
|
-
|
|
62
|
+
class RelayError(Exception):
|
|
63
|
+
"""Base exception for relay operations."""
|
|
64
|
+
pass
|
|
51
65
|
|
|
52
|
-
Returns:
|
|
53
|
-
str: Full path to the relay executable.
|
|
54
|
-
"""
|
|
55
|
-
exe_name = "hidusb-relay-cmd.exe" if platform.system().lower() == 'windows' else "hidusb-relay-cmd"
|
|
56
|
-
return get_relay_path(exe_name)
|
|
57
66
|
|
|
58
|
-
|
|
67
|
+
class RelayCommandError(RelayError):
|
|
68
|
+
"""Raised when relay command execution fails."""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class RelayValidationError(RelayError):
|
|
73
|
+
"""Raised when input validation fails."""
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class PlatformInfo:
|
|
79
|
+
"""Platform and architecture information."""
|
|
80
|
+
system: str
|
|
81
|
+
architecture: str
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_windows(self) -> bool:
|
|
85
|
+
return self.system == 'windows'
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_linux(self) -> bool:
|
|
89
|
+
return self.system == 'linux'
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def arch_bits(self) -> str:
|
|
93
|
+
"""Get architecture as bit string (32bit/64bit)."""
|
|
94
|
+
return '64bit' if '64' in self.architecture else '32bit'
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@lru_cache(maxsize=1)
|
|
98
|
+
def get_platform_info() -> PlatformInfo:
|
|
59
99
|
"""
|
|
60
|
-
Get
|
|
100
|
+
Get cached platform information.
|
|
61
101
|
|
|
62
102
|
Returns:
|
|
63
|
-
|
|
103
|
+
PlatformInfo: Platform and architecture details.
|
|
64
104
|
"""
|
|
65
|
-
|
|
66
|
-
|
|
105
|
+
return PlatformInfo(
|
|
106
|
+
system=platform.system().lower(),
|
|
107
|
+
architecture=platform.architecture()[0].lower()
|
|
108
|
+
)
|
|
109
|
+
|
|
67
110
|
|
|
68
|
-
def
|
|
111
|
+
def _get_module_bin_directory() -> Path:
|
|
112
|
+
"""Get the default binaries folder in module directory."""
|
|
113
|
+
return Path(__file__).parent / 'hid_usb_relay_bin'
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_bin_directory(base_path: Optional[Union[str, Path]] = None) -> Path:
|
|
69
117
|
"""
|
|
70
|
-
|
|
118
|
+
Get the absolute path to the binaries folder.
|
|
71
119
|
|
|
72
120
|
Args:
|
|
73
|
-
|
|
121
|
+
base_path: Optional custom base path. If None, uses module directory.
|
|
74
122
|
|
|
75
123
|
Returns:
|
|
76
|
-
|
|
124
|
+
Path: Path to the binary folder.
|
|
77
125
|
"""
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
print(f"Error executing command: {process.stderr}")
|
|
82
|
-
return None
|
|
126
|
+
if base_path is None:
|
|
127
|
+
return _get_module_bin_directory()
|
|
128
|
+
return Path(base_path) / 'hid_usb_relay_bin'
|
|
83
129
|
|
|
84
|
-
def get_default_relay_device_state() -> Optional[List[str]]:
|
|
85
|
-
"""
|
|
86
|
-
Get the status of the default relay device.
|
|
87
130
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"""
|
|
91
|
-
|
|
92
|
-
return output.split(':')[-1].strip().split(' ') if output else None
|
|
131
|
+
@lru_cache(maxsize=1)
|
|
132
|
+
def _get_default_executable_path() -> Path:
|
|
133
|
+
"""Get cached default executable path."""
|
|
134
|
+
plat = get_platform_info()
|
|
93
135
|
|
|
94
|
-
|
|
136
|
+
if not (plat.is_windows or plat.is_linux):
|
|
137
|
+
raise RelayError(f'Unsupported platform: {plat.system}')
|
|
138
|
+
|
|
139
|
+
bin_dir = _get_module_bin_directory()
|
|
140
|
+
exe_name = "hidusb-relay-cmd.exe" if plat.is_windows else "hidusb-relay-cmd"
|
|
141
|
+
exe_path = bin_dir / plat.system / plat.arch_bits / exe_name
|
|
142
|
+
|
|
143
|
+
if not exe_path.exists():
|
|
144
|
+
raise RelayError(f'Executable not found: {exe_path}')
|
|
145
|
+
|
|
146
|
+
return exe_path
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_executable_path(base_path: Optional[Union[str, Path]] = None) -> Path:
|
|
95
150
|
"""
|
|
96
|
-
|
|
151
|
+
Get the path to the relay command-line executable.
|
|
97
152
|
|
|
98
153
|
Args:
|
|
99
|
-
|
|
154
|
+
base_path: Optional custom base path for binaries. If None, uses cached default.
|
|
100
155
|
|
|
101
156
|
Returns:
|
|
102
|
-
|
|
157
|
+
Path: Full path to the relay executable.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
RelayError: If platform is unsupported or executable not found.
|
|
103
161
|
"""
|
|
104
|
-
|
|
162
|
+
if base_path is None:
|
|
163
|
+
return _get_default_executable_path()
|
|
105
164
|
|
|
106
|
-
|
|
165
|
+
plat = get_platform_info()
|
|
166
|
+
|
|
167
|
+
if not (plat.is_windows or plat.is_linux):
|
|
168
|
+
raise RelayError(f'Unsupported platform: {plat.system}')
|
|
169
|
+
|
|
170
|
+
bin_dir = get_bin_directory(base_path)
|
|
171
|
+
exe_name = "hidusb-relay-cmd.exe" if plat.is_windows else "hidusb-relay-cmd"
|
|
172
|
+
exe_path = bin_dir / plat.system / plat.arch_bits / exe_name
|
|
173
|
+
|
|
174
|
+
if not exe_path.exists():
|
|
175
|
+
raise RelayError(f'Executable not found: {exe_path}')
|
|
176
|
+
|
|
177
|
+
return exe_path
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _execute_command(command: List[str], timeout: float = DEFAULT_TIMEOUT) -> str:
|
|
107
181
|
"""
|
|
108
|
-
|
|
182
|
+
Execute a relay command and return output.
|
|
109
183
|
|
|
110
184
|
Args:
|
|
111
|
-
|
|
185
|
+
command: Command and arguments as list.
|
|
186
|
+
timeout: Command timeout in seconds.
|
|
112
187
|
|
|
113
188
|
Returns:
|
|
114
|
-
|
|
115
|
-
"""
|
|
116
|
-
output = run_command([get_relay_executable(), f"id={relay_id}", "STATUS"])
|
|
117
|
-
return output.split(':')[-1].strip().split(' ') if output else None
|
|
189
|
+
str: Command output (empty string if no output).
|
|
118
190
|
|
|
119
|
-
|
|
191
|
+
Raises:
|
|
192
|
+
RelayCommandError: If command execution fails.
|
|
193
|
+
"""
|
|
194
|
+
# Convert Path objects to strings for subprocess
|
|
195
|
+
cmd_str = [str(c) for c in command]
|
|
196
|
+
logger.debug(f"Executing: {' '.join(cmd_str)}")
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
result = subprocess.run(
|
|
200
|
+
cmd_str,
|
|
201
|
+
stdout=subprocess.PIPE,
|
|
202
|
+
stderr=subprocess.PIPE,
|
|
203
|
+
text=True,
|
|
204
|
+
check=True,
|
|
205
|
+
timeout=timeout
|
|
206
|
+
)
|
|
207
|
+
output = result.stdout.strip() if result.stdout else ""
|
|
208
|
+
if output:
|
|
209
|
+
logger.debug(f"Output: {output}")
|
|
210
|
+
return output
|
|
211
|
+
|
|
212
|
+
except subprocess.CalledProcessError as e:
|
|
213
|
+
error_msg = f"Command failed (exit {e.returncode}): {e.stderr.strip() if e.stderr else 'Unknown error'}"
|
|
214
|
+
logger.error(error_msg)
|
|
215
|
+
raise RelayCommandError(error_msg) from e
|
|
216
|
+
except subprocess.TimeoutExpired as e:
|
|
217
|
+
error_msg = f"Command timed out after {timeout}s"
|
|
218
|
+
logger.error(error_msg)
|
|
219
|
+
raise RelayCommandError(error_msg) from e
|
|
220
|
+
except FileNotFoundError as e:
|
|
221
|
+
error_msg = f"Executable not found: {command[0]}"
|
|
222
|
+
logger.error(error_msg)
|
|
223
|
+
raise RelayCommandError(error_msg) from e
|
|
224
|
+
except OSError as e:
|
|
225
|
+
error_msg = f"OS error executing command: {e}"
|
|
226
|
+
logger.error(error_msg)
|
|
227
|
+
raise RelayCommandError(error_msg) from e
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_relay_states(output: str) -> Dict[str, str]:
|
|
120
231
|
"""
|
|
121
|
-
|
|
232
|
+
Parse relay state output into a dictionary.
|
|
122
233
|
|
|
123
234
|
Args:
|
|
124
|
-
|
|
125
|
-
relay_state (str): "ON" or "OFF".
|
|
235
|
+
output: Raw command output (e.g., "Board ID=[BITFT] State: R1=OFF R2=OFF").
|
|
126
236
|
|
|
127
237
|
Returns:
|
|
128
|
-
|
|
129
|
-
"""
|
|
130
|
-
return run_command([get_relay_executable(), f"id={relay_id}", relay_state, "ALL"]) is not None
|
|
238
|
+
Dict mapping relay names to states (e.g., {'R1': 'OFF', 'R2': 'OFF'}).
|
|
131
239
|
|
|
132
|
-
|
|
240
|
+
Raises:
|
|
241
|
+
RelayError: If output format is invalid.
|
|
133
242
|
"""
|
|
134
|
-
|
|
243
|
+
if not output or 'State:' not in output:
|
|
244
|
+
raise RelayError(f"Invalid state format: {output}")
|
|
135
245
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
"""
|
|
139
|
-
return run_command([get_relay_executable(), "ENUM"])
|
|
246
|
+
# Extract state portion after "State:"
|
|
247
|
+
state_str = output.split('State:', 1)[-1].strip()
|
|
140
248
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
249
|
+
# Parse relay states using regex for robustness
|
|
250
|
+
# Matches patterns like R1=OFF, R2=ON
|
|
251
|
+
pattern = re.compile(r'(R\d+)=(ON|OFF)', re.IGNORECASE)
|
|
252
|
+
matches = pattern.findall(state_str)
|
|
144
253
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
relay_number (str): Relay number as string (e.g., "1").
|
|
254
|
+
if not matches:
|
|
255
|
+
raise RelayError(f"No relay states found in: {output}")
|
|
148
256
|
|
|
149
|
-
|
|
150
|
-
Optional[str]: State ("ON"/"OFF") or None on error.
|
|
151
|
-
"""
|
|
152
|
-
states = get_relay_device_state(relay_id)
|
|
153
|
-
if states:
|
|
154
|
-
return states[int(relay_number) - 1].split('=')[-1]
|
|
155
|
-
return None
|
|
257
|
+
return {relay: state.upper() for relay, state in matches}
|
|
156
258
|
|
|
157
|
-
|
|
259
|
+
|
|
260
|
+
def _validate_relay_number(relay_num: Union[int, str], max_relays: int = MAX_RELAY_COUNT) -> int:
|
|
158
261
|
"""
|
|
159
|
-
|
|
262
|
+
Validate and convert relay number.
|
|
160
263
|
|
|
161
264
|
Args:
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
relay_state (str): "ON" or "OFF".
|
|
265
|
+
relay_num: Relay number as int or string.
|
|
266
|
+
max_relays: Maximum valid relay number.
|
|
165
267
|
|
|
166
268
|
Returns:
|
|
167
|
-
|
|
168
|
-
"""
|
|
169
|
-
return run_command([get_relay_executable(), f"id={relay_id}", relay_state, relay_number]) is not None
|
|
269
|
+
int: Validated relay number.
|
|
170
270
|
|
|
171
|
-
|
|
271
|
+
Raises:
|
|
272
|
+
RelayValidationError: If relay number is invalid.
|
|
172
273
|
"""
|
|
173
|
-
|
|
274
|
+
try:
|
|
275
|
+
num = int(relay_num)
|
|
276
|
+
except (ValueError, TypeError) as e:
|
|
277
|
+
raise RelayValidationError(
|
|
278
|
+
f"Invalid relay number format: {relay_num!r}"
|
|
279
|
+
) from e
|
|
174
280
|
|
|
175
|
-
|
|
176
|
-
|
|
281
|
+
if not 1 <= num <= max_relays:
|
|
282
|
+
raise RelayValidationError(
|
|
283
|
+
f"Relay number must be between 1 and {max_relays}, got {num}"
|
|
284
|
+
)
|
|
177
285
|
|
|
178
|
-
|
|
179
|
-
|
|
286
|
+
return num
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class USBRelayDevice:
|
|
180
290
|
"""
|
|
181
|
-
|
|
182
|
-
if states:
|
|
183
|
-
return states[int(relay_number) - 1].split('=')[-1]
|
|
184
|
-
return None
|
|
291
|
+
Interface for controlling a USB relay device.
|
|
185
292
|
|
|
186
|
-
|
|
293
|
+
Supports context manager protocol for resource management patterns.
|
|
294
|
+
|
|
295
|
+
Attributes:
|
|
296
|
+
device_id: Optional device ID. If None, uses default device.
|
|
297
|
+
|
|
298
|
+
Example:
|
|
299
|
+
>>> relay = USBRelayDevice(device_id='HURTM')
|
|
300
|
+
>>> relay.turn_on(1)
|
|
301
|
+
>>> relay.get_state()
|
|
302
|
+
{'R1': 'ON', 'R2': 'OFF'}
|
|
303
|
+
|
|
304
|
+
>>> with USBRelayDevice('HURTM') as relay:
|
|
305
|
+
... relay.turn_on_all()
|
|
187
306
|
"""
|
|
188
|
-
Set the state of a specific relay on the default device.
|
|
189
307
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
308
|
+
def __init__(
|
|
309
|
+
self,
|
|
310
|
+
device_id: Optional[str] = None,
|
|
311
|
+
executable_path: Optional[Union[str, Path]] = None,
|
|
312
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
313
|
+
):
|
|
314
|
+
"""
|
|
315
|
+
Initialize relay device controller.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
device_id: Device ID. If None, controls the default device.
|
|
319
|
+
executable_path: Custom path to relay executable. If None, auto-detects.
|
|
320
|
+
timeout: Command timeout in seconds.
|
|
321
|
+
"""
|
|
322
|
+
self.device_id = device_id
|
|
323
|
+
self._timeout = timeout
|
|
324
|
+
self._exe_path = Path(executable_path) if executable_path else get_executable_path()
|
|
325
|
+
logger.info(f"Initialized USB relay: {device_id or 'default'}")
|
|
326
|
+
|
|
327
|
+
def __enter__(self) -> 'USBRelayDevice':
|
|
328
|
+
"""Context manager entry."""
|
|
329
|
+
return self
|
|
330
|
+
|
|
331
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
332
|
+
"""Context manager exit."""
|
|
333
|
+
# Could add cleanup logic here if needed
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
def __repr__(self) -> str:
|
|
337
|
+
return f"USBRelayDevice(device_id={self.device_id!r})"
|
|
338
|
+
|
|
339
|
+
def _build_command(
|
|
340
|
+
self,
|
|
341
|
+
action: str,
|
|
342
|
+
target: Optional[str] = None
|
|
343
|
+
) -> List[Union[Path, str]]:
|
|
344
|
+
"""
|
|
345
|
+
Build command list for execution.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
action: Action to perform (on/off/state/enum).
|
|
349
|
+
target: Target relay number or "all".
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
List of command arguments.
|
|
353
|
+
"""
|
|
354
|
+
cmd: List[Union[Path, str]] = [self._exe_path]
|
|
355
|
+
|
|
356
|
+
if self.device_id:
|
|
357
|
+
cmd.append(f"id={self.device_id}")
|
|
358
|
+
|
|
359
|
+
cmd.append(action)
|
|
360
|
+
|
|
361
|
+
if target is not None:
|
|
362
|
+
cmd.append(target)
|
|
363
|
+
|
|
364
|
+
return cmd
|
|
365
|
+
|
|
366
|
+
def _execute(self, action: str, target: Optional[str] = None) -> str:
|
|
367
|
+
"""Execute command with device-specific timeout."""
|
|
368
|
+
cmd = self._build_command(action, target)
|
|
369
|
+
return _execute_command(cmd, timeout=self._timeout)
|
|
370
|
+
|
|
371
|
+
def get_state(self) -> Dict[str, str]:
|
|
372
|
+
"""
|
|
373
|
+
Get current state of all relays on this device.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Dict mapping relay names to states (e.g., {'R1': 'ON', 'R2': 'OFF'}).
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
RelayCommandError: If command fails.
|
|
380
|
+
"""
|
|
381
|
+
output = self._execute(RelayCommand.STATE.value)
|
|
382
|
+
return _parse_relay_states(output)
|
|
383
|
+
|
|
384
|
+
def get_relay_state(self, relay_num: Union[int, str]) -> str:
|
|
385
|
+
"""
|
|
386
|
+
Get state of a specific relay.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
relay_num: Relay number (1-based).
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
str: "ON" or "OFF".
|
|
393
|
+
|
|
394
|
+
Raises:
|
|
395
|
+
RelayValidationError: If relay number is invalid.
|
|
396
|
+
RelayCommandError: If command fails.
|
|
397
|
+
RelayError: If relay not found.
|
|
398
|
+
"""
|
|
399
|
+
num = _validate_relay_number(relay_num)
|
|
400
|
+
states = self.get_state()
|
|
401
|
+
relay_key = f"R{num}"
|
|
402
|
+
|
|
403
|
+
if relay_key not in states:
|
|
404
|
+
raise RelayError(
|
|
405
|
+
f"Relay {num} not found. Available: {', '.join(states.keys())}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
return states[relay_key]
|
|
409
|
+
|
|
410
|
+
def set_state(
|
|
411
|
+
self,
|
|
412
|
+
state: Union[RelayState, str],
|
|
413
|
+
relay_num: Optional[Union[int, str]] = None
|
|
414
|
+
) -> None:
|
|
415
|
+
"""
|
|
416
|
+
Set relay state.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
state: RelayState.ON/OFF or "on"/"off"/"ON"/"OFF" string.
|
|
420
|
+
relay_num: Optional relay number. If None, sets all relays.
|
|
421
|
+
|
|
422
|
+
Raises:
|
|
423
|
+
RelayValidationError: If inputs are invalid.
|
|
424
|
+
RelayCommandError: If command fails.
|
|
425
|
+
"""
|
|
426
|
+
# Normalize state
|
|
427
|
+
if isinstance(state, RelayState):
|
|
428
|
+
state_str = state.value
|
|
429
|
+
elif isinstance(state, str):
|
|
430
|
+
state_str = state.lower()
|
|
431
|
+
if state_str not in ('on', 'off'):
|
|
432
|
+
raise RelayValidationError(
|
|
433
|
+
f"Invalid state: {state!r}. Must be 'on' or 'off'"
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
raise RelayValidationError(
|
|
437
|
+
f"State must be RelayState or str, got {type(state).__name__}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Determine target
|
|
441
|
+
if relay_num is None:
|
|
442
|
+
target = "all"
|
|
443
|
+
else:
|
|
444
|
+
num = _validate_relay_number(relay_num)
|
|
445
|
+
target = str(num)
|
|
446
|
+
|
|
447
|
+
self._execute(state_str, target)
|
|
448
|
+
logger.info(f"Set {self.device_id or 'default'} relay {target} to {state_str.upper()}")
|
|
449
|
+
|
|
450
|
+
def turn_on(self, relay_num: Union[int, str]) -> None:
|
|
451
|
+
"""Turn on a specific relay."""
|
|
452
|
+
self.set_state(RelayState.ON, relay_num)
|
|
453
|
+
|
|
454
|
+
def turn_off(self, relay_num: Union[int, str]) -> None:
|
|
455
|
+
"""Turn off a specific relay."""
|
|
456
|
+
self.set_state(RelayState.OFF, relay_num)
|
|
457
|
+
|
|
458
|
+
def turn_on_all(self) -> None:
|
|
459
|
+
"""Turn on all relays."""
|
|
460
|
+
self.set_state(RelayState.ON)
|
|
461
|
+
|
|
462
|
+
def turn_off_all(self) -> None:
|
|
463
|
+
"""Turn off all relays."""
|
|
464
|
+
self.set_state(RelayState.OFF)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def enumerate_devices() -> List[Dict[str, str]]:
|
|
468
|
+
"""
|
|
469
|
+
Enumerate all connected relay devices.
|
|
193
470
|
|
|
194
471
|
Returns:
|
|
195
|
-
|
|
472
|
+
List of dicts containing device info and states.
|
|
473
|
+
Example: [
|
|
474
|
+
{'device_id': 'BITFT', 'R1': 'OFF', 'R2': 'OFF'},
|
|
475
|
+
{'device_id': 'HURTM', 'R1': 'ON', 'R2': 'ON'}
|
|
476
|
+
]
|
|
477
|
+
|
|
478
|
+
Raises:
|
|
479
|
+
RelayCommandError: If enumeration fails.
|
|
196
480
|
"""
|
|
197
|
-
|
|
481
|
+
exe_path = _get_default_executable_path()
|
|
482
|
+
output = _execute_command([exe_path, RelayCommand.ENUM.value])
|
|
483
|
+
|
|
484
|
+
# Regex to extract device ID more robustly
|
|
485
|
+
device_pattern = re.compile(r'Board ID=\[([^\]]+)\]', re.IGNORECASE)
|
|
486
|
+
|
|
487
|
+
devices = []
|
|
488
|
+
for line in output.split('\n'):
|
|
489
|
+
if not line.strip():
|
|
490
|
+
continue
|
|
491
|
+
|
|
492
|
+
match = device_pattern.search(line)
|
|
493
|
+
if match:
|
|
494
|
+
device_id = match.group(1)
|
|
495
|
+
try:
|
|
496
|
+
states = _parse_relay_states(line)
|
|
497
|
+
devices.append({'device_id': device_id, **states})
|
|
498
|
+
except RelayError as e:
|
|
499
|
+
logger.warning(f"Failed to parse device {device_id}: {e}")
|
|
500
|
+
|
|
501
|
+
logger.info(f"Found {len(devices)} relay device(s)")
|
|
502
|
+
return devices
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# Backward compatibility functions (delegating to class-based API)
|
|
506
|
+
def get_default_relay_device_state() -> Optional[List[str]]:
|
|
507
|
+
"""DEPRECATED: Use USBRelayDevice().get_state() instead."""
|
|
508
|
+
try:
|
|
509
|
+
states = USBRelayDevice().get_state()
|
|
510
|
+
return [f"{k}={v}" for k, v in states.items()]
|
|
511
|
+
except RelayError:
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def set_default_relay_device_state(relay_state: str) -> bool:
|
|
516
|
+
"""DEPRECATED: Use USBRelayDevice().set_state() instead."""
|
|
517
|
+
try:
|
|
518
|
+
USBRelayDevice().set_state(relay_state)
|
|
519
|
+
return True
|
|
520
|
+
except RelayError:
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def get_relay_device_state(relay_id: str) -> Optional[List[str]]:
|
|
525
|
+
"""DEPRECATED: Use USBRelayDevice(device_id).get_state() instead."""
|
|
526
|
+
try:
|
|
527
|
+
states = USBRelayDevice(device_id=relay_id).get_state()
|
|
528
|
+
return [f"{k}={v}" for k, v in states.items()]
|
|
529
|
+
except RelayError:
|
|
530
|
+
return None
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def set_relay_device_state(relay_id: str, relay_state: str) -> bool:
|
|
534
|
+
"""DEPRECATED: Use USBRelayDevice(device_id).set_state() instead."""
|
|
535
|
+
try:
|
|
536
|
+
USBRelayDevice(device_id=relay_id).set_state(relay_state)
|
|
537
|
+
return True
|
|
538
|
+
except RelayError:
|
|
539
|
+
return False
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def get_all_relay_device_state() -> Optional[str]:
|
|
543
|
+
"""DEPRECATED: Use enumerate_devices() instead."""
|
|
544
|
+
try:
|
|
545
|
+
exe_path = get_executable_path()
|
|
546
|
+
return _execute_command([exe_path, RelayCommand.ENUM.value])
|
|
547
|
+
except RelayError:
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def get_relay_device_relay_state(relay_id: str, relay_number: str) -> Optional[str]:
|
|
552
|
+
"""DEPRECATED: Use USBRelayDevice(device_id).get_relay_state() instead."""
|
|
553
|
+
try:
|
|
554
|
+
return USBRelayDevice(device_id=relay_id).get_relay_state(relay_number)
|
|
555
|
+
except RelayError:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def set_relay_device_relay_state(relay_id: str, relay_number: str, relay_state: str) -> bool:
|
|
560
|
+
"""DEPRECATED: Use USBRelayDevice(device_id).set_state() instead."""
|
|
561
|
+
try:
|
|
562
|
+
USBRelayDevice(device_id=relay_id).set_state(relay_state, relay_number)
|
|
563
|
+
return True
|
|
564
|
+
except RelayError:
|
|
565
|
+
return False
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def get_default_relay_device_relay_state(relay_number: str) -> Optional[str]:
|
|
569
|
+
"""DEPRECATED: Use USBRelayDevice().get_relay_state() instead."""
|
|
570
|
+
try:
|
|
571
|
+
return USBRelayDevice().get_relay_state(relay_number)
|
|
572
|
+
except RelayError:
|
|
573
|
+
return None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def set_default_relay_device_relay_state(relay_number: str, relay_state: str) -> bool:
|
|
577
|
+
"""DEPRECATED: Use USBRelayDevice().set_state() instead."""
|
|
578
|
+
try:
|
|
579
|
+
USBRelayDevice().set_state(relay_state, relay_number)
|
|
580
|
+
return True
|
|
581
|
+
except RelayError:
|
|
582
|
+
return False
|