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 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)
@@ -0,0 +1,14 @@
1
+ class SeymourError(Exception):
2
+ pass
3
+
4
+
5
+ class SeymourConnectionError(SeymourError):
6
+ pass
7
+
8
+
9
+ class SeymourProtocolError(SeymourError):
10
+ pass
11
+
12
+
13
+ class SeymourTransportError(Exception):
14
+ pass
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
@@ -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
+ [![CI](https://img.shields.io/github/actions/workflow/status/richard-berg/seymourlib/ci.yml?label=CI&logo=github)](https://github.com/richard-berg/seymourlib/actions/workflows/ci.yml)
50
+ [![Coverage](https://img.shields.io/codecov/c/github/richard-berg/seymourlib?logo=codecov)](https://codecov.io/gh/richard-berg/seymourlib)
51
+ [![PyPI](https://img.shields.io/pypi/v/seymourlib?logo=pypi)](https://pypi.org/project/seymourlib/)
52
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ seymourctl = seymourlib.cli:main
@@ -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