seymourlib 0.1.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.
- seymourlib/__init__.py +0 -0
- seymourlib/cli.py +249 -0
- seymourlib/client.py +270 -0
- seymourlib/exceptions.py +14 -0
- seymourlib/protocol.py +388 -0
- seymourlib/py.typed +0 -0
- seymourlib/transport.py +87 -0
- seymourlib-0.1.0.dist-info/METADATA +215 -0
- seymourlib-0.1.0.dist-info/RECORD +13 -0
- seymourlib-0.1.0.dist-info/WHEEL +5 -0
- seymourlib-0.1.0.dist-info/entry_points.txt +2 -0
- seymourlib-0.1.0.dist-info/licenses/LICENSE +21 -0
- seymourlib-0.1.0.dist-info/top_level.txt +1 -0
seymourlib/__init__.py
ADDED
|
File without changes
|
seymourlib/cli.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Simple CLI utility to exercise the client."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import attrs
|
|
7
|
+
import typer
|
|
8
|
+
from async_typer import AsyncTyper
|
|
9
|
+
from rich import print as rprint
|
|
10
|
+
|
|
11
|
+
from seymourlib.protocol import DiagnosticOption, MotorID, MovementCode, Ratio
|
|
12
|
+
|
|
13
|
+
from .client import SeymourClient
|
|
14
|
+
from .transport import SeymourTransport
|
|
15
|
+
|
|
16
|
+
app = AsyncTyper(help="Seymour controller CLI")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@attrs.frozen
|
|
20
|
+
class GlobalOptions:
|
|
21
|
+
verbose: bool
|
|
22
|
+
host: str
|
|
23
|
+
port: int
|
|
24
|
+
serial_port: str | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_GLOBAL_OPTIONS: GlobalOptions | None = None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback() # type: ignore
|
|
31
|
+
def handle_global_options(
|
|
32
|
+
verbose: Annotated[
|
|
33
|
+
bool,
|
|
34
|
+
typer.Option(
|
|
35
|
+
"--verbose",
|
|
36
|
+
"-v",
|
|
37
|
+
help="Enable verbose output.",
|
|
38
|
+
),
|
|
39
|
+
] = False,
|
|
40
|
+
host: Annotated[
|
|
41
|
+
str,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--host",
|
|
44
|
+
"-h",
|
|
45
|
+
help="Hostname or IP address for TCP transport.",
|
|
46
|
+
envvar="SEYMOUR_HOST",
|
|
47
|
+
),
|
|
48
|
+
] = "localhost",
|
|
49
|
+
port: Annotated[
|
|
50
|
+
int,
|
|
51
|
+
typer.Option(
|
|
52
|
+
"--port",
|
|
53
|
+
"-p",
|
|
54
|
+
help="Port number for TCP transport.",
|
|
55
|
+
envvar="SEYMOUR_PORT",
|
|
56
|
+
),
|
|
57
|
+
] = 4999,
|
|
58
|
+
serial_port: Annotated[
|
|
59
|
+
str | None,
|
|
60
|
+
typer.Option(
|
|
61
|
+
"--serial-port",
|
|
62
|
+
"-s",
|
|
63
|
+
help="Serial port device for Serial transport (overrides host/port).",
|
|
64
|
+
envvar="SEYMOUR_SERIAL_PORT",
|
|
65
|
+
),
|
|
66
|
+
] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
global _GLOBAL_OPTIONS
|
|
69
|
+
_GLOBAL_OPTIONS = GlobalOptions(verbose=verbose, host=host, port=port, serial_port=serial_port)
|
|
70
|
+
if verbose:
|
|
71
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
72
|
+
else:
|
|
73
|
+
logging.basicConfig(level=logging.WARN)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_client() -> SeymourClient:
|
|
77
|
+
assert _GLOBAL_OPTIONS is not None
|
|
78
|
+
if _GLOBAL_OPTIONS.serial_port:
|
|
79
|
+
from .transport import SerialTransport
|
|
80
|
+
|
|
81
|
+
transport: SeymourTransport = SerialTransport(_GLOBAL_OPTIONS.serial_port)
|
|
82
|
+
else:
|
|
83
|
+
from .transport import TCPTransport
|
|
84
|
+
|
|
85
|
+
transport = TCPTransport(_GLOBAL_OPTIONS.host, _GLOBAL_OPTIONS.port)
|
|
86
|
+
|
|
87
|
+
return SeymourClient(transport)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.async_command() # type: ignore
|
|
91
|
+
async def status() -> None:
|
|
92
|
+
"""Show the current status code."""
|
|
93
|
+
async with _get_client() as client:
|
|
94
|
+
status = await client.get_status()
|
|
95
|
+
rprint(status)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.async_command() # type: ignore
|
|
99
|
+
async def calibrate(motor: Annotated[MotorID, typer.Argument()] = MotorID.ALL) -> None:
|
|
100
|
+
"""Calibrate the given motor(s) by moving all the way in & out."""
|
|
101
|
+
async with _get_client() as client:
|
|
102
|
+
await client.calibrate(motor)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
positions_app = AsyncTyper(help="Commands for the motors' absolute positions.")
|
|
106
|
+
app.add_typer(positions_app, name="positions")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@positions_app.async_command("get") # type: ignore
|
|
110
|
+
async def positions_get() -> None:
|
|
111
|
+
"""Show the current motor positions."""
|
|
112
|
+
async with _get_client() as client:
|
|
113
|
+
positions = await client.get_positions()
|
|
114
|
+
for position in positions:
|
|
115
|
+
rprint(position)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@positions_app.async_command("halt") # type: ignore
|
|
119
|
+
async def positions_halt(motor: Annotated[MotorID, typer.Argument()] = MotorID.ALL) -> None:
|
|
120
|
+
"""Stop the specified motor(s) at their current position."""
|
|
121
|
+
async with _get_client() as client:
|
|
122
|
+
await client.halt(motor)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@positions_app.async_command("home") # type: ignore
|
|
126
|
+
async def positions_home(motor: Annotated[MotorID, typer.Argument()] = MotorID.ALL) -> None:
|
|
127
|
+
"""Move the specified motor(s) to their home position."""
|
|
128
|
+
async with _get_client() as client:
|
|
129
|
+
await client.home(motor)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_increment(move: bool, jog: bool, until_limit: bool) -> MovementCode:
|
|
133
|
+
match (move, jog, until_limit):
|
|
134
|
+
case (True, False, False):
|
|
135
|
+
return MovementCode.MOVE
|
|
136
|
+
case (False, True, False):
|
|
137
|
+
return MovementCode.JOG
|
|
138
|
+
case (False, False, True):
|
|
139
|
+
return MovementCode.UNTIL_LIMIT
|
|
140
|
+
case _:
|
|
141
|
+
raise typer.BadParameter(
|
|
142
|
+
"Exactly one of --move, --jog, or --until-limit must be specified."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@positions_app.async_command("in") # type: ignore
|
|
147
|
+
async def positions_in(
|
|
148
|
+
motor: Annotated[MotorID, typer.Argument()] = MotorID.ALL,
|
|
149
|
+
move: bool = True,
|
|
150
|
+
jog: bool = False,
|
|
151
|
+
until_limit: bool = False,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Move the specified motor(s) inward by the given increment."""
|
|
154
|
+
increment = _parse_increment(move, jog, until_limit)
|
|
155
|
+
async with _get_client() as client:
|
|
156
|
+
await client.move_in(motor, increment)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@positions_app.async_command("out") # type: ignore
|
|
160
|
+
async def positions_out(
|
|
161
|
+
motor: Annotated[MotorID, typer.Argument()] = MotorID.ALL,
|
|
162
|
+
move: bool = True,
|
|
163
|
+
jog: bool = False,
|
|
164
|
+
until_limit: bool = False,
|
|
165
|
+
) -> None:
|
|
166
|
+
"""Move the specified motor(s) outward by the given increment."""
|
|
167
|
+
increment = _parse_increment(move, jog, until_limit)
|
|
168
|
+
async with _get_client() as client:
|
|
169
|
+
await client.move_out(motor, increment)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
preset_app = AsyncTyper(help="Commands for motor presets.")
|
|
173
|
+
app.add_typer(preset_app, name="preset")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_ratio_id(ratio_id: str) -> Ratio:
|
|
177
|
+
try:
|
|
178
|
+
return Ratio(ratio_id)
|
|
179
|
+
except ValueError as exc:
|
|
180
|
+
raise typer.BadParameter(f"Invalid ratio ID: {ratio_id}") from exc
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@preset_app.async_command("apply") # type: ignore
|
|
184
|
+
async def preset_apply(
|
|
185
|
+
ratio_id: Annotated[str, typer.Argument(help="3-digit ratio ID, e.g. '235' for 2.35:1")],
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Move motors to the designated ratio preset."""
|
|
188
|
+
ratio = _parse_ratio_id(ratio_id)
|
|
189
|
+
async with _get_client() as client:
|
|
190
|
+
await client.move_to_ratio(ratio)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@preset_app.async_command("list") # type: ignore
|
|
194
|
+
async def preset_list() -> None:
|
|
195
|
+
"""List all stored ratio presets."""
|
|
196
|
+
async with _get_client() as client:
|
|
197
|
+
settings = await client.get_ratio_settings()
|
|
198
|
+
for preset in settings:
|
|
199
|
+
rprint(preset)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@preset_app.async_command("reset") # type: ignore
|
|
203
|
+
async def preset_reset(
|
|
204
|
+
ratio_id: Annotated[
|
|
205
|
+
str | None,
|
|
206
|
+
typer.Argument(help="3-digit ratio ID, e.g. '235' for 2.35:1. Default: ALL PRESETS"),
|
|
207
|
+
] = None,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Restore the given ratio preset(s) to their factory default."""
|
|
210
|
+
ratio = _parse_ratio_id(ratio_id) if ratio_id else None
|
|
211
|
+
ratio_text = ratio if ratio else "ALL PRESETS"
|
|
212
|
+
typer.confirm(f"Are you sure you want to reset {ratio_text} to factory default?", abort=True)
|
|
213
|
+
async with _get_client() as client:
|
|
214
|
+
await client.reset_factory_default(ratio)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@preset_app.async_command("store") # type: ignore
|
|
218
|
+
async def preset_store(
|
|
219
|
+
ratio_id: Annotated[str, typer.Argument(help="3-digit ratio ID, e.g. '235' for 2.35:1")],
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Store the current motor positions as the designated ratio preset."""
|
|
222
|
+
ratio = _parse_ratio_id(ratio_id)
|
|
223
|
+
async with _get_client() as client:
|
|
224
|
+
await client.update_ratio(ratio)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
system_app = AsyncTyper(help="Commands for reading system internals.")
|
|
228
|
+
app.add_typer(system_app, name="system")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@system_app.async_command("info") # type: ignore
|
|
232
|
+
async def system_info() -> None:
|
|
233
|
+
"""Show static information about the Seymour screen system."""
|
|
234
|
+
async with _get_client() as client:
|
|
235
|
+
info = await client.get_system_info()
|
|
236
|
+
for key, value in attrs.asdict(info).items():
|
|
237
|
+
rprint(f"[bold yellow]{key}[/bold yellow]: {value}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@system_app.async_command() # type: ignore
|
|
241
|
+
async def diagnostics(option: DiagnosticOption) -> None:
|
|
242
|
+
"""Show diagnostic information from the Seymour screen system."""
|
|
243
|
+
async with _get_client() as client:
|
|
244
|
+
diag = await client.get_diagnostics(option)
|
|
245
|
+
rprint(diag)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def main() -> None:
|
|
249
|
+
app()
|
seymourlib/client.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Main async client for interacting with the Seymour controller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from tenacity import (
|
|
10
|
+
AsyncRetrying,
|
|
11
|
+
after_log,
|
|
12
|
+
before_sleep_log,
|
|
13
|
+
retry_if_exception_type,
|
|
14
|
+
stop_after_attempt,
|
|
15
|
+
wait_exponential,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from . import protocol
|
|
19
|
+
from .exceptions import SeymourConnectionError, SeymourProtocolError, SeymourTransportError
|
|
20
|
+
from .transport import SeymourTransport
|
|
21
|
+
|
|
22
|
+
_LOGGER = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
DEFAULT_MAX_RETRIES = 3
|
|
25
|
+
DEFAULT_REQUEST_TIMEOUT = 10.0
|
|
26
|
+
HEALTH_CHECK_INTERVAL = 90.0
|
|
27
|
+
HEALTH_CHECK_TIMEOUT = 3.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SeymourClient:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
transport: SeymourTransport,
|
|
34
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
35
|
+
request_timeout: float = DEFAULT_REQUEST_TIMEOUT,
|
|
36
|
+
):
|
|
37
|
+
self.transport = transport
|
|
38
|
+
self._lock = asyncio.Lock()
|
|
39
|
+
self._connected = False
|
|
40
|
+
self.max_retries = max_retries
|
|
41
|
+
self.request_timeout = request_timeout
|
|
42
|
+
self._last_successful_operation = 0.0
|
|
43
|
+
self._health_check_task: asyncio.Task[None] | None = None
|
|
44
|
+
|
|
45
|
+
async def _connect_with_retry(self) -> None:
|
|
46
|
+
"""Connect with tenacity-managed retry logic."""
|
|
47
|
+
retryer = AsyncRetrying(
|
|
48
|
+
stop=stop_after_attempt(self.max_retries),
|
|
49
|
+
wait=wait_exponential(multiplier=1, min=1, max=30),
|
|
50
|
+
retry=retry_if_exception_type((SeymourTransportError, ConnectionError, OSError)),
|
|
51
|
+
before_sleep=before_sleep_log(_LOGGER, logging.WARNING),
|
|
52
|
+
after=after_log(_LOGGER, logging.INFO),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
async for attempt in retryer:
|
|
56
|
+
with attempt:
|
|
57
|
+
await self.transport.connect()
|
|
58
|
+
self._connected = True
|
|
59
|
+
self._last_successful_operation = time.time()
|
|
60
|
+
|
|
61
|
+
async def connect(self) -> None:
|
|
62
|
+
async with self._lock:
|
|
63
|
+
if self._connected:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
await self._connect_with_retry()
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
self._connected = False
|
|
70
|
+
raise SeymourConnectionError("Failed to connect after all retries") from exc
|
|
71
|
+
|
|
72
|
+
async def close(self) -> None:
|
|
73
|
+
async with self._lock:
|
|
74
|
+
# Stop health checking
|
|
75
|
+
if self._health_check_task and not self._health_check_task.done():
|
|
76
|
+
self._health_check_task.cancel()
|
|
77
|
+
try:
|
|
78
|
+
await self._health_check_task
|
|
79
|
+
except asyncio.CancelledError:
|
|
80
|
+
pass
|
|
81
|
+
self._health_check_task = None
|
|
82
|
+
|
|
83
|
+
# Close transport
|
|
84
|
+
if self._connected:
|
|
85
|
+
await self.transport.close()
|
|
86
|
+
self._connected = False
|
|
87
|
+
_LOGGER.info("Closed Seymour transport")
|
|
88
|
+
|
|
89
|
+
async def start_health_monitoring(self, interval: float = HEALTH_CHECK_INTERVAL) -> None:
|
|
90
|
+
"""Start background health monitoring for long-running applications like Home Assistant."""
|
|
91
|
+
if self._health_check_task and not self._health_check_task.done():
|
|
92
|
+
return # Already running
|
|
93
|
+
|
|
94
|
+
self._health_check_task = asyncio.create_task(self._health_check_loop(interval))
|
|
95
|
+
_LOGGER.info("Started health monitoring (interval=%.1fs)", interval)
|
|
96
|
+
|
|
97
|
+
async def _health_check_loop(self, interval: float) -> None:
|
|
98
|
+
"""Background task that periodically checks connection health."""
|
|
99
|
+
while True:
|
|
100
|
+
try:
|
|
101
|
+
await asyncio.sleep(interval)
|
|
102
|
+
|
|
103
|
+
# Skip health check if we've had recent successful operations
|
|
104
|
+
if time.time() - self._last_successful_operation < interval / 2:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Perform lightweight health check
|
|
108
|
+
if self._connected:
|
|
109
|
+
try:
|
|
110
|
+
await self._health_check()
|
|
111
|
+
_LOGGER.debug("Health check passed")
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
_LOGGER.warning("Health check failed, marking disconnected: %s", exc)
|
|
114
|
+
self._connected = False
|
|
115
|
+
|
|
116
|
+
except asyncio.CancelledError:
|
|
117
|
+
_LOGGER.debug("Health monitoring cancelled")
|
|
118
|
+
break
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
_LOGGER.error("Unexpected error in health monitoring: %s", exc)
|
|
121
|
+
await asyncio.sleep(interval) # Continue monitoring despite errors
|
|
122
|
+
|
|
123
|
+
async def _health_check(self) -> None:
|
|
124
|
+
"""Perform a lightweight health check by requesting status."""
|
|
125
|
+
# Use a shorter timeout for health checks
|
|
126
|
+
original_timeout = self.request_timeout
|
|
127
|
+
self.request_timeout = min(HEALTH_CHECK_TIMEOUT, original_timeout)
|
|
128
|
+
try:
|
|
129
|
+
await self.get_status()
|
|
130
|
+
finally:
|
|
131
|
+
self.request_timeout = original_timeout
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def is_connected(self) -> bool:
|
|
135
|
+
"""Check if the client believes it's connected."""
|
|
136
|
+
return self._connected
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def connection_stats(self) -> dict[str, float]:
|
|
140
|
+
"""Get connection statistics for monitoring."""
|
|
141
|
+
return {
|
|
142
|
+
"last_successful_operation": self._last_successful_operation,
|
|
143
|
+
"time_since_last_success": (
|
|
144
|
+
time.time() - self._last_successful_operation
|
|
145
|
+
if self._last_successful_operation > 0
|
|
146
|
+
else -1
|
|
147
|
+
),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async def __aenter__(self) -> SeymourClient:
|
|
151
|
+
"""Async context manager entry."""
|
|
152
|
+
await self.connect()
|
|
153
|
+
return self
|
|
154
|
+
|
|
155
|
+
async def __aexit__(
|
|
156
|
+
self,
|
|
157
|
+
exc_type: type[BaseException] | None,
|
|
158
|
+
exc_val: BaseException | None,
|
|
159
|
+
exc_tb: object | None,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Async context manager exit."""
|
|
162
|
+
await self.close()
|
|
163
|
+
|
|
164
|
+
async def _execute_operation(self, frame: bytes, receive: bool = True) -> bytes | None:
|
|
165
|
+
"""Execute a single transport operation with retry logic."""
|
|
166
|
+
retryer = AsyncRetrying(
|
|
167
|
+
stop=stop_after_attempt(self.max_retries),
|
|
168
|
+
wait=wait_exponential(multiplier=0.5, min=0.5, max=10),
|
|
169
|
+
retry=retry_if_exception_type(
|
|
170
|
+
(SeymourTransportError, TimeoutError, ConnectionError, OSError)
|
|
171
|
+
),
|
|
172
|
+
before_sleep=before_sleep_log(_LOGGER, logging.WARNING),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
async for attempt in retryer:
|
|
176
|
+
with attempt:
|
|
177
|
+
# Ensure connection before operation
|
|
178
|
+
if not self._connected:
|
|
179
|
+
await self.connect()
|
|
180
|
+
|
|
181
|
+
# Execute with timeout
|
|
182
|
+
async with asyncio.timeout(self.request_timeout):
|
|
183
|
+
await self.transport.send(frame)
|
|
184
|
+
if receive:
|
|
185
|
+
response = await self.transport.receive()
|
|
186
|
+
self._last_successful_operation = time.time()
|
|
187
|
+
return response
|
|
188
|
+
else:
|
|
189
|
+
self._last_successful_operation = time.time()
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
# This should never be reached due to retry logic, but satisfy type checker
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
async def _send_and_maybe_receive(self, frame: bytes, receive: bool = True) -> bytes | None:
|
|
196
|
+
"""Send frame and optionally receive response with automatic reconnection."""
|
|
197
|
+
try:
|
|
198
|
+
logging.debug("Sending frame: %s", frame)
|
|
199
|
+
return await self._execute_operation(frame, receive)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
# Mark disconnected for any transport error
|
|
202
|
+
self._connected = False
|
|
203
|
+
if isinstance(exc, SeymourTransportError | TimeoutError | ConnectionError | OSError):
|
|
204
|
+
raise SeymourConnectionError("Transport operation failed after retries") from exc
|
|
205
|
+
else:
|
|
206
|
+
raise SeymourProtocolError("Protocol or unexpected error") from exc
|
|
207
|
+
|
|
208
|
+
async def _send(self, frame: bytes) -> None:
|
|
209
|
+
"""Low-level: send frame without awaiting reply."""
|
|
210
|
+
await self._send_and_maybe_receive(frame, receive=False)
|
|
211
|
+
|
|
212
|
+
async def _send_and_receive(self, frame: bytes) -> bytes:
|
|
213
|
+
"""Low-level: send frame and await reply frame."""
|
|
214
|
+
raw = await self._send_and_maybe_receive(frame, receive=True)
|
|
215
|
+
logging.debug("Received frame: %s", raw)
|
|
216
|
+
assert raw is not None
|
|
217
|
+
return raw
|
|
218
|
+
|
|
219
|
+
# Public API
|
|
220
|
+
async def get_status(self) -> protocol.MaskStatus:
|
|
221
|
+
raw = await self._send_and_receive(protocol.encode_status())
|
|
222
|
+
return protocol.decode_status(raw)
|
|
223
|
+
|
|
224
|
+
async def get_positions(self) -> list[protocol.MaskPosition]:
|
|
225
|
+
raw = await self._send_and_receive(protocol.encode_positions())
|
|
226
|
+
return protocol.decode_positions(raw)
|
|
227
|
+
|
|
228
|
+
async def get_system_info(self) -> protocol.SystemInfo:
|
|
229
|
+
raw = await self._send_and_receive(protocol.encode_read_sysinfo())
|
|
230
|
+
return protocol.decode_system_info(raw)
|
|
231
|
+
|
|
232
|
+
async def get_ratio_settings(self) -> list[protocol.RatioSetting]:
|
|
233
|
+
raw = await self._send_and_receive(protocol.encode_read_settings())
|
|
234
|
+
return protocol.decode_settings(raw)
|
|
235
|
+
|
|
236
|
+
async def get_diagnostics(self, option: protocol.DiagnosticOption) -> str:
|
|
237
|
+
raw = await self._send_and_receive(protocol.encode_diagnostics(option))
|
|
238
|
+
return raw.decode("ascii")
|
|
239
|
+
|
|
240
|
+
async def move_out(self, motor: protocol.MotorID, movement: protocol.MovementCode) -> None:
|
|
241
|
+
frame = protocol.encode_move_out(motor, movement)
|
|
242
|
+
await self._send(frame)
|
|
243
|
+
|
|
244
|
+
async def move_in(self, motor: protocol.MotorID, movement: protocol.MovementCode) -> None:
|
|
245
|
+
frame = protocol.encode_move_in(motor, movement)
|
|
246
|
+
await self._send(frame)
|
|
247
|
+
|
|
248
|
+
async def move_to_ratio(self, ratio: protocol.Ratio) -> None:
|
|
249
|
+
frame = protocol.encode_move_ratio(ratio)
|
|
250
|
+
await self._send(frame)
|
|
251
|
+
|
|
252
|
+
async def home(self, motor: protocol.MotorID) -> None:
|
|
253
|
+
frame = protocol.encode_home(motor)
|
|
254
|
+
await self._send(frame)
|
|
255
|
+
|
|
256
|
+
async def halt(self, motor: protocol.MotorID) -> None:
|
|
257
|
+
frame = protocol.encode_halt(motor)
|
|
258
|
+
await self._send(frame)
|
|
259
|
+
|
|
260
|
+
async def calibrate(self, motor: protocol.MotorID) -> None:
|
|
261
|
+
frame = protocol.encode_calibrate(motor)
|
|
262
|
+
await self._send(frame)
|
|
263
|
+
|
|
264
|
+
async def update_ratio(self, ratio: protocol.Ratio) -> None:
|
|
265
|
+
frame = protocol.encode_update_ratio(ratio)
|
|
266
|
+
await self._send(frame)
|
|
267
|
+
|
|
268
|
+
async def reset_factory_default(self, ratio: protocol.Ratio | None) -> None:
|
|
269
|
+
frame = protocol.encode_clear_settings(ratio)
|
|
270
|
+
await self._send(frame)
|
seymourlib/exceptions.py
ADDED
seymourlib/protocol.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Implementation for the Seymour RS232 protocol version 01.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import enum
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
import attrs
|
|
11
|
+
|
|
12
|
+
FRAME_START = b"["
|
|
13
|
+
FRAME_END = b"]"
|
|
14
|
+
PROTOCOL_VERSION = b"01"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SingleByte(enum.StrEnum):
|
|
18
|
+
"""
|
|
19
|
+
Base class for enums that represent a 1-byte code.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def regex(cls, n: int | None = None) -> bytes:
|
|
24
|
+
"""
|
|
25
|
+
Regular expression that parses exactly one code value into a capture group named after the class.
|
|
26
|
+
"""
|
|
27
|
+
all_commands = "".join(code.value for code in cls)
|
|
28
|
+
capture_group_suffix = str(n) if n is not None else ""
|
|
29
|
+
return rf"(?P<{cls.__name__}{capture_group_suffix}>[{all_commands}])".encode("ascii")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CommandCode(SingleByte):
|
|
33
|
+
MOVE_OUT = "O"
|
|
34
|
+
MOVE_IN = "I"
|
|
35
|
+
MOVE_TO_RATIO = "M"
|
|
36
|
+
HOME = "A"
|
|
37
|
+
HALT = "H"
|
|
38
|
+
CALIBRATE = "C"
|
|
39
|
+
STATUS = "S"
|
|
40
|
+
POSITIONS = "P"
|
|
41
|
+
UPDATE_RATIO = "U"
|
|
42
|
+
READ_SYSTEM_INFO = "Y"
|
|
43
|
+
READ_SETTINGS = "R"
|
|
44
|
+
CLEAR_SETTINGS = "X"
|
|
45
|
+
DIAGNOSTICS = "@"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MotorID(SingleByte):
|
|
49
|
+
TOP = "T"
|
|
50
|
+
BOTTOM = "B"
|
|
51
|
+
LEFT = "L"
|
|
52
|
+
RIGHT = "R"
|
|
53
|
+
VERTICAL = "V"
|
|
54
|
+
HORIZONTAL = "H"
|
|
55
|
+
ALL = "A"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class MovementCode(enum.StrEnum):
|
|
59
|
+
UNTIL_LIMIT = ""
|
|
60
|
+
JOG = "J" # smallest increment (<0.1%)
|
|
61
|
+
MOVE = "M" # 1% of the motor's range (ignored if not calibrated)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class StatusCode(SingleByte):
|
|
65
|
+
STOPPED_AT_RATIO = "P"
|
|
66
|
+
MOVING_TO_RATIO = "M"
|
|
67
|
+
HALTED = "H"
|
|
68
|
+
HOMING = "A"
|
|
69
|
+
CALIBRATING = "C"
|
|
70
|
+
MOVING_OUTWARD = "O"
|
|
71
|
+
MOVING_INWARD = "I"
|
|
72
|
+
ERROR = "E"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@attrs.frozen
|
|
76
|
+
class Ratio:
|
|
77
|
+
id: str = attrs.field()
|
|
78
|
+
|
|
79
|
+
@id.validator
|
|
80
|
+
def check(self, _attribute: attrs.Attribute[str], value: str) -> None:
|
|
81
|
+
if len(value) != 3:
|
|
82
|
+
raise ValueError("ratio_id must be three digits long")
|
|
83
|
+
if not value.isdigit():
|
|
84
|
+
raise ValueError("ratio_id must be numeric")
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def regex(cls) -> bytes:
|
|
88
|
+
"""
|
|
89
|
+
Regular expression that parses a ratio ID into a capture group named 'Ratio'.
|
|
90
|
+
"""
|
|
91
|
+
return rb"(?P<Ratio>[0-9]{3})"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@attrs.frozen
|
|
95
|
+
class MaskStatus:
|
|
96
|
+
code: StatusCode
|
|
97
|
+
ratio: Ratio | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@attrs.frozen
|
|
101
|
+
class MaskPosition:
|
|
102
|
+
motor_id: MotorID
|
|
103
|
+
position_pct: float
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@attrs.frozen
|
|
107
|
+
class Serial:
|
|
108
|
+
model_code: str
|
|
109
|
+
month: int
|
|
110
|
+
year: int
|
|
111
|
+
production_number: str
|
|
112
|
+
|
|
113
|
+
def to_serial_number(self) -> str:
|
|
114
|
+
return f"{self.model_code}-{self.month:02d}{self.year:02d}-{self.production_number}"
|
|
115
|
+
|
|
116
|
+
PARSER_RE = re.compile(
|
|
117
|
+
rb"(?P<model_code>.{2})-(?P<month>[0-9]{2})(?P<year>[0-9]{2})-(?P<production_number>.{5})"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def from_serial_number(serial_number: bytes) -> Serial:
|
|
122
|
+
"""
|
|
123
|
+
Parse a serial number bytestring into a Serial object.
|
|
124
|
+
|
|
125
|
+
The expected format is "XX-MMYY-PPPPP", where XX is the model code and PPPPP is the
|
|
126
|
+
production number. The hyphens are required.
|
|
127
|
+
"""
|
|
128
|
+
match = Serial.PARSER_RE.fullmatch(serial_number)
|
|
129
|
+
if not match:
|
|
130
|
+
raise ValueError(f"Malformed serial number: {serial_number!r}")
|
|
131
|
+
|
|
132
|
+
model_code = match.group("model_code").decode("ascii")
|
|
133
|
+
month = int(match.group("month"))
|
|
134
|
+
year = int(match.group("year"))
|
|
135
|
+
production_number = match.group("production_number").decode("ascii")
|
|
136
|
+
return Serial(
|
|
137
|
+
model_code=model_code,
|
|
138
|
+
month=month,
|
|
139
|
+
year=year,
|
|
140
|
+
production_number=production_number,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@attrs.frozen
|
|
145
|
+
class SystemInfo:
|
|
146
|
+
screen_model: str
|
|
147
|
+
width_inches: float
|
|
148
|
+
height_inches: float
|
|
149
|
+
serial_number: Serial
|
|
150
|
+
mask_ids: list[MotorID]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@attrs.frozen
|
|
154
|
+
class RatioSetting:
|
|
155
|
+
ratio: Ratio
|
|
156
|
+
label: str
|
|
157
|
+
width_inches: float
|
|
158
|
+
height_inches: float
|
|
159
|
+
motor_positions_pct: list[float]
|
|
160
|
+
motor_adjustments_pct: list[float]
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def from_response_entry(entry: bytes, num_motors: int) -> RatioSetting:
|
|
164
|
+
"""
|
|
165
|
+
Parse a ratio setting info string into a RatioSetting object.
|
|
166
|
+
|
|
167
|
+
The expected format is:
|
|
168
|
+
- 3 bytes: ratio ID
|
|
169
|
+
- 8 bytes: label (ASCII, space-padded)
|
|
170
|
+
- 6 bytes: width in inches (ASCII float)
|
|
171
|
+
- 6 bytes: height in inches (ASCII float)
|
|
172
|
+
- For each motor:
|
|
173
|
+
- 4 bytes: position percentage (ASCII float)
|
|
174
|
+
- For each motor:
|
|
175
|
+
- 4 bytes: position adjustment percentage (ASCII float)
|
|
176
|
+
"""
|
|
177
|
+
base_pattern = Ratio.regex() + rb"(?P<label>.{8})(?P<width>[0-9.]{6})(?P<height>[0-9.]{6})"
|
|
178
|
+
|
|
179
|
+
pattern = base_pattern + _ASCII_PCT_RE * num_motors * 2
|
|
180
|
+
|
|
181
|
+
match = re.fullmatch(pattern, entry)
|
|
182
|
+
if not match:
|
|
183
|
+
raise ValueError(f"Malformed ratio setting entry for pattern {pattern!r}: {entry!r}")
|
|
184
|
+
|
|
185
|
+
ratio_id = match.group("Ratio").decode("ascii")
|
|
186
|
+
label = match.group("label").decode("ascii").strip()
|
|
187
|
+
width = float(match.group("width"))
|
|
188
|
+
height = float(match.group("height"))
|
|
189
|
+
|
|
190
|
+
# parse the motor positions and adjustments
|
|
191
|
+
motor_positions_pct: list[float] = []
|
|
192
|
+
motor_adjustments_pct: list[float] = []
|
|
193
|
+
position_group_start = 5 # after ratio, label, width, height
|
|
194
|
+
for i in range(num_motors):
|
|
195
|
+
pos_str = match.group(position_group_start + i)
|
|
196
|
+
adj_str = match.group(position_group_start + num_motors + i)
|
|
197
|
+
motor_positions_pct.append(float(pos_str))
|
|
198
|
+
motor_adjustments_pct.append(float(adj_str))
|
|
199
|
+
|
|
200
|
+
return RatioSetting(
|
|
201
|
+
ratio=Ratio(ratio_id),
|
|
202
|
+
label=label,
|
|
203
|
+
width_inches=width,
|
|
204
|
+
height_inches=height,
|
|
205
|
+
motor_positions_pct=motor_positions_pct,
|
|
206
|
+
motor_adjustments_pct=motor_adjustments_pct,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@staticmethod
|
|
210
|
+
def expected_entry_length(num_motors: int) -> int:
|
|
211
|
+
"""
|
|
212
|
+
Calculate the expected length of a ratio setting entry for the given number of motors.
|
|
213
|
+
"""
|
|
214
|
+
# 3 (ratio ID) + 8 (label) + 6 (width) + 6 (height) + N * 4 (positions) + N * 4 (adjustments)
|
|
215
|
+
return 3 + 8 + 6 + 6 + num_motors * 4 + num_motors * 4
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class DiagnosticCommand(SingleByte):
|
|
219
|
+
DEBUG_LOG = "D"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class DiagnosticOption(enum.StrEnum):
|
|
223
|
+
LIST_FS = "00"
|
|
224
|
+
LIST_SETTINGS_JSON = "10"
|
|
225
|
+
LIST_SYSTEM_JSON = "20"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _frame(payload: str) -> bytes:
|
|
229
|
+
return FRAME_START + PROTOCOL_VERSION + payload.encode("ascii") + FRAME_END
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def encode_move_out(motor_id: MotorID, movement: MovementCode) -> bytes:
|
|
233
|
+
return _frame(f"{CommandCode.MOVE_OUT}{motor_id}{movement}")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def encode_move_in(motor_id: MotorID, movement: MovementCode) -> bytes:
|
|
237
|
+
return _frame(f"{CommandCode.MOVE_IN}{motor_id}{movement}")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def encode_move_ratio(ratio: Ratio) -> bytes:
|
|
241
|
+
return _frame(f"{CommandCode.MOVE_TO_RATIO}{ratio.id}")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def encode_home(motor_id: MotorID) -> bytes:
|
|
245
|
+
return _frame(f"{CommandCode.HOME}{motor_id}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def encode_halt(motor_id: MotorID) -> bytes:
|
|
249
|
+
return _frame(f"{CommandCode.HALT}{motor_id}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def encode_calibrate(motor_id: MotorID) -> bytes:
|
|
253
|
+
return _frame(f"{CommandCode.CALIBRATE}{motor_id}")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def encode_status() -> bytes:
|
|
257
|
+
return _frame(CommandCode.STATUS)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def encode_positions() -> bytes:
|
|
261
|
+
return _frame(CommandCode.POSITIONS)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def encode_update_ratio(ratio: Ratio) -> bytes:
|
|
265
|
+
return _frame(f"{CommandCode.UPDATE_RATIO}{ratio.id}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def encode_clear_settings(ratio: Ratio | None) -> bytes:
|
|
269
|
+
id = ratio.id if ratio is not None else ""
|
|
270
|
+
return _frame(f"{CommandCode.CLEAR_SETTINGS}{id}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def encode_read_sysinfo() -> bytes:
|
|
274
|
+
return _frame(CommandCode.READ_SYSTEM_INFO)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def encode_read_settings() -> bytes:
|
|
278
|
+
return _frame(CommandCode.READ_SETTINGS)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def encode_diagnostics(option: DiagnosticOption) -> bytes:
|
|
282
|
+
return _frame(f"{CommandCode.DIAGNOSTICS}{DiagnosticCommand.DEBUG_LOG}{option}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _frame_re(regex_body: bytes) -> re.Pattern[bytes]:
|
|
286
|
+
regex = b"^\\" + FRAME_START + PROTOCOL_VERSION + regex_body + b"\\" + FRAME_END + b"$"
|
|
287
|
+
return re.compile(regex)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
_STATUS_RE = _frame_re(StatusCode.regex() + Ratio.regex() + rb"?")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def decode_status(raw: bytes) -> MaskStatus:
|
|
294
|
+
match = _STATUS_RE.match(raw)
|
|
295
|
+
if not match:
|
|
296
|
+
raise ValueError(f"Malformed status response: {raw!r}")
|
|
297
|
+
status_code = match.group("StatusCode").decode("ascii")
|
|
298
|
+
maybe_ratio_str = match.group("Ratio")
|
|
299
|
+
ratio = Ratio(maybe_ratio_str.decode("ascii")) if maybe_ratio_str is not None else None
|
|
300
|
+
return MaskStatus(code=StatusCode(status_code), ratio=ratio)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
_POSITIONS_RE = _frame_re(rb"(?P<num_motors>[1-4])(?P<entries>.+)")
|
|
304
|
+
_ASCII_PCT_RE = rb"([0-9.\-]{4})"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def decode_positions(raw: bytes) -> list[MaskPosition]:
|
|
308
|
+
match = _POSITIONS_RE.match(raw)
|
|
309
|
+
if not match:
|
|
310
|
+
raise ValueError(f"Malformed positions response: {raw!r}")
|
|
311
|
+
num_motors = int(match.group("num_motors"))
|
|
312
|
+
entries_str = match.group("entries")
|
|
313
|
+
|
|
314
|
+
pattern = b"".join(MotorID.regex(i) + _ASCII_PCT_RE for i in range(num_motors))
|
|
315
|
+
match = re.fullmatch(pattern, entries_str)
|
|
316
|
+
if not match:
|
|
317
|
+
raise ValueError(f"Malformed motor position entries: {entries_str!r}")
|
|
318
|
+
if len(match.groups()) != num_motors * 2:
|
|
319
|
+
raise ValueError(f"Expected {num_motors} motor entries, got: {entries_str!r}")
|
|
320
|
+
positions: list[MaskPosition] = []
|
|
321
|
+
for i in range(num_motors):
|
|
322
|
+
motor_id_str = match.group(1 + i * 2).decode("ascii")
|
|
323
|
+
position_str = match.group(2 + i * 2).decode("ascii")
|
|
324
|
+
positions.append(
|
|
325
|
+
MaskPosition(
|
|
326
|
+
motor_id=MotorID(motor_id_str),
|
|
327
|
+
position_pct=float(position_str),
|
|
328
|
+
)
|
|
329
|
+
)
|
|
330
|
+
return positions
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_SYSTEM_INFO_RE = _frame_re(
|
|
334
|
+
rb"(?P<model>.{20})(?P<width>[0-9.]{6})(?P<height>[0-9.]{6})(?P<serial>.{13})(?P<mask_ids>[LRTB]{1,5})"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def decode_system_info(raw: bytes) -> SystemInfo:
|
|
339
|
+
match = _SYSTEM_INFO_RE.match(raw)
|
|
340
|
+
if not match:
|
|
341
|
+
raise ValueError(f"Malformed system info response: {raw!r}")
|
|
342
|
+
model = match.group("model").decode("ascii").strip()
|
|
343
|
+
|
|
344
|
+
width = float(match.group("width"))
|
|
345
|
+
height = float(match.group("height"))
|
|
346
|
+
|
|
347
|
+
serial_str = match.group("serial")
|
|
348
|
+
serial = Serial.from_serial_number(serial_str)
|
|
349
|
+
|
|
350
|
+
mask_ids_str = match.group("mask_ids").decode("ascii")
|
|
351
|
+
mask_ids = [MotorID(c) for c in mask_ids_str]
|
|
352
|
+
|
|
353
|
+
return SystemInfo(
|
|
354
|
+
screen_model=model,
|
|
355
|
+
width_inches=width,
|
|
356
|
+
height_inches=height,
|
|
357
|
+
serial_number=serial,
|
|
358
|
+
mask_ids=mask_ids,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
_SETTINGS_RE = _frame_re(rb"(?P<num_motors>[0-9])(?P<num_ratios>[0-9]{2})(?P<entries>.+)")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def decode_settings(raw: bytes) -> list[RatioSetting]:
|
|
366
|
+
match = _SETTINGS_RE.match(raw)
|
|
367
|
+
if not match:
|
|
368
|
+
raise ValueError(f"Malformed settings response: {raw!r}")
|
|
369
|
+
num_motors = int(match.group("num_motors"))
|
|
370
|
+
num_ratios = int(match.group("num_ratios"))
|
|
371
|
+
entries_str = match.group("entries")
|
|
372
|
+
|
|
373
|
+
expected_length_per_entry = RatioSetting.expected_entry_length(num_motors)
|
|
374
|
+
if len(entries_str) != expected_length_per_entry * num_ratios:
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f"Expected {num_ratios} ratio entries of length {expected_length_per_entry}, "
|
|
377
|
+
f"got total length {len(entries_str)}: {entries_str!r}"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
settings: list[RatioSetting] = []
|
|
381
|
+
for i in range(num_ratios):
|
|
382
|
+
entry_bytes = entries_str[
|
|
383
|
+
i * expected_length_per_entry : (i + 1) * expected_length_per_entry
|
|
384
|
+
]
|
|
385
|
+
setting = RatioSetting.from_response_entry(entry_bytes, num_motors)
|
|
386
|
+
settings.append(setting)
|
|
387
|
+
|
|
388
|
+
return settings
|
seymourlib/py.typed
ADDED
|
File without changes
|
seymourlib/transport.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Transport abstraction: TCPTransport (for IP2SL) + SerialTransport (local serial)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from abc import abstractmethod
|
|
7
|
+
|
|
8
|
+
from .exceptions import SeymourTransportError
|
|
9
|
+
|
|
10
|
+
SEYMOUR_BAUD_RATE = 115200
|
|
11
|
+
ITACH_SERIAL_PORT = 4999
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SeymourTransport:
|
|
15
|
+
"""Abstract transport used by the SeymourClient."""
|
|
16
|
+
|
|
17
|
+
def __init__(self) -> None:
|
|
18
|
+
self.reader: asyncio.StreamReader | None = None
|
|
19
|
+
self.writer: asyncio.StreamWriter | None = None
|
|
20
|
+
self._lock = asyncio.Lock()
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def connect(self) -> None:
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
|
|
26
|
+
async def close(self) -> None:
|
|
27
|
+
try:
|
|
28
|
+
if self.writer:
|
|
29
|
+
self.writer.close()
|
|
30
|
+
await self.writer.wait_closed()
|
|
31
|
+
finally:
|
|
32
|
+
self.writer = None
|
|
33
|
+
self.reader = None
|
|
34
|
+
|
|
35
|
+
async def send(self, data: bytes) -> None:
|
|
36
|
+
"""Send one data frame to transport, blocking until the complete frame is sent."""
|
|
37
|
+
assert data.startswith(b"["), "Frame must start with '['"
|
|
38
|
+
assert data.endswith(b"]"), "Frame must end with ']'"
|
|
39
|
+
|
|
40
|
+
if not self.writer:
|
|
41
|
+
raise SeymourTransportError("Not connected")
|
|
42
|
+
|
|
43
|
+
async with self._lock:
|
|
44
|
+
self.writer.write(data)
|
|
45
|
+
await self.writer.drain()
|
|
46
|
+
|
|
47
|
+
async def receive(self) -> bytes:
|
|
48
|
+
"""
|
|
49
|
+
Get one data frame from transport, blocking until frame end (']') is received.
|
|
50
|
+
|
|
51
|
+
Note: no timeout here; caller should use structured concurrency if desired.
|
|
52
|
+
"""
|
|
53
|
+
if not self.reader:
|
|
54
|
+
raise SeymourTransportError("Not connected")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
data = await asyncio.wait_for(self.reader.readuntil(b"]"), timeout=None)
|
|
58
|
+
return data
|
|
59
|
+
except asyncio.IncompleteReadError as exc:
|
|
60
|
+
raise SeymourTransportError("Connection closed") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TCPTransport(SeymourTransport):
|
|
64
|
+
def __init__(self, host: str, port: int = ITACH_SERIAL_PORT) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self.host = host
|
|
67
|
+
self.port = port
|
|
68
|
+
|
|
69
|
+
async def connect(self) -> None:
|
|
70
|
+
self.reader, self.writer = await asyncio.open_connection(self.host, self.port)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SerialTransport(SeymourTransport):
|
|
74
|
+
def __init__(self, port: str, baudrate: int = SEYMOUR_BAUD_RATE) -> None:
|
|
75
|
+
super().__init__()
|
|
76
|
+
self.port = port
|
|
77
|
+
self.baudrate = baudrate
|
|
78
|
+
|
|
79
|
+
async def connect(self) -> None:
|
|
80
|
+
try:
|
|
81
|
+
import serial_asyncio
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
raise SeymourTransportError("serial_asyncio not installed") from exc
|
|
84
|
+
|
|
85
|
+
self.reader, self.writer = await serial_asyncio.open_serial_connection(
|
|
86
|
+
url=self.port, baudrate=self.baudrate
|
|
87
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: seymourlib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for Seymour masking screens, connected via direct serial or Global Caché iTach IP2SL
|
|
5
|
+
Author-email: Richard Berg <ars@richardberg.net>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: omepage, https://github.com/richard-berg/seymourlib
|
|
8
|
+
Project-URL: source, https://github.com/richard-berg/seymourlib.git
|
|
9
|
+
Project-URL: issues, https://github.com/richard-berg/seymourlib/issues
|
|
10
|
+
Keywords: seymour,masking,screen,serial,itach,ip2sl,home-assistant
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Home Automation
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: anyio>=3.4
|
|
21
|
+
Requires-Dist: attrs>=25.4.0
|
|
22
|
+
Requires-Dist: tenacity>=8.0
|
|
23
|
+
Requires-Dist: async-typer>=0.1.10
|
|
24
|
+
Provides-Extra: serial
|
|
25
|
+
Requires-Dist: pyserial_asyncio>=0.6; extra == "serial"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-timeout>=2; extra == "dev"
|
|
30
|
+
Requires-Dist: coverage>=7; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
32
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
33
|
+
Requires-Dist: types-setuptools; extra == "dev"
|
|
34
|
+
Requires-Dist: pyserial_asyncio>=0.6; extra == "dev"
|
|
35
|
+
Provides-Extra: lint
|
|
36
|
+
Requires-Dist: ruff>=0.6; extra == "lint"
|
|
37
|
+
Provides-Extra: typecheck
|
|
38
|
+
Requires-Dist: mypy>=1.10; extra == "typecheck"
|
|
39
|
+
Provides-Extra: test
|
|
40
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
41
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
|
42
|
+
Requires-Dist: pytest-timeout>=2; extra == "test"
|
|
43
|
+
Requires-Dist: coverage>=7; extra == "test"
|
|
44
|
+
Requires-Dist: pyserial_asyncio>=0.6; extra == "test"
|
|
45
|
+
Dynamic: license-file
|
|
46
|
+
|
|
47
|
+
# seymourlib
|
|
48
|
+
|
|
49
|
+
[](https://github.com/richard-berg/seymourlib/actions/workflows/ci.yml)
|
|
50
|
+
[](https://codecov.io/gh/richard-berg/seymourlib)
|
|
51
|
+
[](https://pypi.org/project/seymourlib/)
|
|
52
|
+
[](LICENSE)
|
|
53
|
+
|
|
54
|
+
Python client for Seymour masking screens, connected via direct serial or Global Caché iTach IP2SL. Built for rock-solid control loops, [Home Assistant](https://www.home-assistant.io/) integrations, and quick bench testing. 😎
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Highlights
|
|
59
|
+
|
|
60
|
+
- Async-first client and transport abstractions (TCP/IP2SL vs native serial) with consistent APIs.
|
|
61
|
+
- Protocol helpers that mirror the official Seymour RS232 spec, including parsing/validation utilities.
|
|
62
|
+
- `seymourctl` CLI for one-off commands, scripting, and smoke tests in CI.
|
|
63
|
+
- Batteries-included retry logic (`tenacity`), timeout handling, and graceful cancellation via `anyio`.
|
|
64
|
+
|
|
65
|
+
## Architecture at a Glance
|
|
66
|
+
|
|
67
|
+
```mermaid
|
|
68
|
+
flowchart TB
|
|
69
|
+
App[Your automation / HA stack] -->|async API| Client[SeymourClient]
|
|
70
|
+
Client --> Protocol[Protocol helpers]
|
|
71
|
+
Client --> Transport
|
|
72
|
+
Transport -->|TCP 4999| IP2SL[Global Caché IP2SL]
|
|
73
|
+
Transport -->|RS232| Screen[Seymour Masking Screen]
|
|
74
|
+
IP2SL -->|RS232| Screen
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Installation
|
|
78
|
+
|
|
79
|
+
### Requirements
|
|
80
|
+
|
|
81
|
+
- Python 3.11+
|
|
82
|
+
- `uv` >= 0.2 (recommended) or classic `pip`
|
|
83
|
+
|
|
84
|
+
### Using uv (dev + extras)
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv sync --all-extras
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Using pip (runtime only)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python -m venv .venv
|
|
94
|
+
source .venv/bin/activate # or .venv\Scripts\activate on Windows
|
|
95
|
+
pip install "seymourlib[serial]"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> 💡 The optional `serial` extra pulls in `pyserial-asyncio` so the same client works over USB/RS232 without the IP bridge.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## First-Time Setup Checklist
|
|
103
|
+
|
|
104
|
+
1. **Clone & bootstrap**
|
|
105
|
+
- `git clone https://github.com/richard-berg/seymourlib && cd seymourlib`
|
|
106
|
+
- `uv sync --all-extras`
|
|
107
|
+
2. **Sanity-test the library**
|
|
108
|
+
- `uv run pytest` for local regression.
|
|
109
|
+
- `uv run seymourctl -v status` to hit your target once hardware is reachable.
|
|
110
|
+
3. **Wire up hardware**
|
|
111
|
+
- Confirm straight-through RS232 between IP2SL and the Seymour control port.
|
|
112
|
+
- For direct USB serial, a FTDI-based adapter keeps timing stable.
|
|
113
|
+
4. **Pick your transport**
|
|
114
|
+
- `TCPTransport(host, port=4999)` for IP2SL.
|
|
115
|
+
- `SerialTransport(port="COM5")` for direct connection (requires `[serial]`).
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
119
|
+
## Usage Examples
|
|
120
|
+
|
|
121
|
+
### Python API
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
import asyncio
|
|
125
|
+
from seymourlib.client import SeymourClient
|
|
126
|
+
from seymourlib.transport import TCPTransport
|
|
127
|
+
|
|
128
|
+
async def main() -> None:
|
|
129
|
+
async with SeymourClient(TCPTransport("192.168.1.70")) as client:
|
|
130
|
+
status = await client.get_status()
|
|
131
|
+
print(status)
|
|
132
|
+
|
|
133
|
+
asyncio.run(main())
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### CLI
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
uv run seymourctl --host 192.168.1.70 status
|
|
140
|
+
uv run seymourctl --host 192.168.1.70 preset apply 235
|
|
141
|
+
uv run seymourctl --help
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
## IP2SL Setup & Diagnostics
|
|
147
|
+
|
|
148
|
+
### 1. Test direct serial first
|
|
149
|
+
|
|
150
|
+
- Connect a laptop directly to the Seymour RS232 port with a known-good USB adapter.
|
|
151
|
+
- Use `RealTerm`, `CoolTerm`, or any tty tool (115200 8N1 by default - verify via Seymour docs) to send a `PS?` (position status) command.
|
|
152
|
+
- Once commands succeed locally, you know the projector + cable + command syntax are correct before adding the network hop.
|
|
153
|
+
|
|
154
|
+
### 2. Discover the IP2SL via iHelp
|
|
155
|
+
|
|
156
|
+
- Download [Global Caché iHelp](https://www.globalcache.com/files/software/iHelp.zip) and run it on the same subnet.
|
|
157
|
+
- Press **Scan** to locate the IP2SL; note the MAC, firmware, and current IP.
|
|
158
|
+
- If nothing shows up, temporarily disable host firewalls or plug directly into a laptop + small DHCP scope.
|
|
159
|
+
|
|
160
|
+
### 3. Pin the network configuration
|
|
161
|
+
|
|
162
|
+
- Point a browser at `http://<your-ip>/` (from step 2) if you want a point-and-click GUI for DHCP/static IP, firmware, and reboot controls.
|
|
163
|
+
- Otherwise, install [Global Caché iTest](https://www.globalcache.com/files/software/iTest.zip).
|
|
164
|
+
- Use iTest or telnet into port **4998** (the configuration/control port).
|
|
165
|
+
- Send `set dhcp 0`, `set ip <static>`, `set mask <subnet>`, `set gateway <gw>`, then `save` to burn settings.
|
|
166
|
+
- Reboot (power cycle or `reboot` command) and confirm the device responds on the static IP.
|
|
167
|
+
- Document the MAC/IP in your DHCP reservations to avoid future collisions.
|
|
168
|
+
|
|
169
|
+
### 4. Lock the serial parameters
|
|
170
|
+
|
|
171
|
+
- Open the IP2SL web UI and switch to the **Serial** tab, or telnet/iTest into port **4998** for command-line configuration.
|
|
172
|
+
- Ensure the port is set to **115200 baud, 8 data bits, no parity, 1 stop bit** to match Seymour’s RS232 spec.
|
|
173
|
+
- Saving via the UI writes immediately; if you used the control port, run `set baud 115200`, `set data 8`, `set parity 0`, `set stop 1`, then `save` and reboot so the bridge always boots with the correct framing.
|
|
174
|
+
|
|
175
|
+
### 5. Exercise the serial bridge with iTest
|
|
176
|
+
|
|
177
|
+
- Open iTest (or `telnet`)
|
|
178
|
+
- Target port **4999** on the static IP, choose **RAW** mode, and click **Connect**.
|
|
179
|
+
- Send a few simple commands (e.g. `[01S]`, `[01P]`). The response should echo immediately.
|
|
180
|
+
- If latency creeps in, verify telnet keep-alives/device busy LEDs. Keep the window open while you walk through automation flows to watch the live traffic.
|
|
181
|
+
|
|
182
|
+
### 6. Promote to seymourlib
|
|
183
|
+
|
|
184
|
+
- Update your `.env` / config with the same host/port verified above.
|
|
185
|
+
- Start with `uv run seymourctl -v status` (verbose logging shows full request/response frames).
|
|
186
|
+
- Flip to automation ([Home Assistant](https://www.home-assistant.io/), Homebridge, etc.) once CLI calls are boringly reliable.
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Troubleshooting & Field Notes
|
|
191
|
+
|
|
192
|
+
- ⚡ **No response over TCP but serial works** - Ensure port 4999 is open on local firewalls and that the IP2SL LED blinks on connect; if not, recheck the static IP settings from step 3.
|
|
193
|
+
- 🔌 **Commands hang after a day** - The IP2SL can queue stale sockets; send `set rs232disconnect 1` over port 4998 so idle TCP clients auto-close.
|
|
194
|
+
- 🔁 **Race conditions from HA automations** - Wrap your automations with seymourlib's async context manager so only one command pipeline exists per transport.
|
|
195
|
+
- 🧪 **Need deeper diagnostics** - Add `--verbose` to `seymourctl` to dump protocol frames, or [enable debug logging](https://docs.python.org/3/library/logging.html) to capture per-request timestamps.
|
|
196
|
+
- 🛠️ **Firmware drift** - Global Caché publishes release notes; mismatched firmware sometimes defaults back to DHCP on reboot. Always export a config snapshot in iHelp after upgrades.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
## Related Docs & Inspiration
|
|
201
|
+
|
|
202
|
+
- [Seymour Screen Excellence RS232 Protocol PDF](https://www.seymourscreenexcellence.com/) (request via dealer portal).
|
|
203
|
+
- [Global Caché Knowledge Base](https://globalcache.zendesk.com/).
|
|
204
|
+
- [Home Assistant Template Switch Cookbook](https://www.home-assistant.io/integrations/template/) for inspiration on wiring seymourlib into HA.
|
|
205
|
+
- [CoolTerm Serial Terminal](https://freeware.the-meiers.org/) - handy for the direct-serial smoke test.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
## Contributing
|
|
210
|
+
|
|
211
|
+
- Fork, create a feature branch, and open a PR (tests + type checks 🚦 required).
|
|
212
|
+
- `pre-commit run` before pushing.
|
|
213
|
+
- Issues and hardware findings are welcome - real-world serial logs are gold for regression tests.
|
|
214
|
+
|
|
215
|
+
Need something else? Open a discussion or ping via Issues and we’ll figure it out together. 🙌
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
seymourlib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
seymourlib/cli.py,sha256=2sNzNukIZ8GA_kZ2IZKgeGF6nxPFVGp1C_YHLxyzEw4,7714
|
|
3
|
+
seymourlib/client.py,sha256=WyNxGKotXgwvj-K1m5101_rHfg40pTrC4S-LMFrhAR4,10520
|
|
4
|
+
seymourlib/exceptions.py,sha256=JQYPKsZbZwQBnb-rqbsyz7EXbIE7KGPMYaaiOM0LaR0,199
|
|
5
|
+
seymourlib/protocol.py,sha256=UclH9n6-9-XkywZCDh8efmj8jnEudEELlq1iGJIf4GU,11483
|
|
6
|
+
seymourlib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
seymourlib/transport.py,sha256=fKOQygWm0m5bNTdMJL_hWX6W6yT9aAQ-SY82sl2t9dI,2751
|
|
8
|
+
seymourlib-0.1.0.dist-info/licenses/LICENSE,sha256=cgMk0mtLXy8D5VnPG7la5aC7X6qYqMWAEFIRMlE56V8,1089
|
|
9
|
+
seymourlib-0.1.0.dist-info/METADATA,sha256=J6O5lCXpazMdlyVQbTWVpUXv0dpwdHZbRKX2XDTR8aM,9564
|
|
10
|
+
seymourlib-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
11
|
+
seymourlib-0.1.0.dist-info/entry_points.txt,sha256=CSBM6mD3Ooa3QJ7tZBWmnyN19IRHyJTwZ8jsUcrhHjE,51
|
|
12
|
+
seymourlib-0.1.0.dist-info/top_level.txt,sha256=ScOirVLXTyJ1kjsC_7coaFFJvlHsDETK76Wpysw3CIg,11
|
|
13
|
+
seymourlib-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Richard Berg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
seymourlib
|