pyvterm 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.
- pyvterm/__init__.py +66 -0
- pyvterm/frame.py +160 -0
- pyvterm/geometry.py +81 -0
- pyvterm/preview.py +149 -0
- pyvterm/protocol.py +236 -0
- pyvterm/py.typed +0 -0
- pyvterm/terminal.py +167 -0
- pyvterm/transport.py +158 -0
- pyvterm-0.1.0.dist-info/METADATA +301 -0
- pyvterm-0.1.0.dist-info/RECORD +12 -0
- pyvterm-0.1.0.dist-info/WHEEL +4 -0
- pyvterm-0.1.0.dist-info/licenses/LICENSE +201 -0
pyvterm/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""pyvterm — drive a pitrex/Vectrex over a serial port from Python.
|
|
2
|
+
|
|
3
|
+
pyvterm speaks the **USB-DVG / *vecterm* serial protocol** used by the
|
|
4
|
+
`gtoal/pitrex <https://github.com/gtoal/pitrex>`_ project: the same wire
|
|
5
|
+
format a custom MAME build uses to push vector frames to a Vectrex. With it
|
|
6
|
+
you can act as the "custom MAME" and draw vectors on real hardware from
|
|
7
|
+
Python.
|
|
8
|
+
|
|
9
|
+
Quick start
|
|
10
|
+
-----------
|
|
11
|
+
>>> from pyvterm import VectorTerminal
|
|
12
|
+
>>> with VectorTerminal(port="/dev/ttyACM0") as vt: # doctest: +SKIP
|
|
13
|
+
... with vt.frame():
|
|
14
|
+
... vt.set_intensity(15)
|
|
15
|
+
... vt.polyline([(0, 0), (100, 100), (-100, 100)], closed=True)
|
|
16
|
+
|
|
17
|
+
For tests and dry runs, pass a :class:`MemoryTransport` instead of a port.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from . import geometry, protocol
|
|
23
|
+
from .frame import FrameBuilder
|
|
24
|
+
from .geometry import clip_line, vector_length
|
|
25
|
+
from .protocol import (
|
|
26
|
+
DEFAULT_BOUNDS,
|
|
27
|
+
DVG_RENDER_QUALITY,
|
|
28
|
+
DVG_RES_MAX,
|
|
29
|
+
DVG_RES_MIN,
|
|
30
|
+
Bounds,
|
|
31
|
+
Flag,
|
|
32
|
+
)
|
|
33
|
+
from .terminal import VectorTerminal
|
|
34
|
+
from .transport import (
|
|
35
|
+
DEFAULT_BAUDRATE,
|
|
36
|
+
DEFAULT_PORT,
|
|
37
|
+
MemoryTransport,
|
|
38
|
+
SerialTransport,
|
|
39
|
+
Transport,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__version__ = "0.1.0"
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"__version__",
|
|
46
|
+
# high level
|
|
47
|
+
"VectorTerminal",
|
|
48
|
+
"FrameBuilder",
|
|
49
|
+
# transports
|
|
50
|
+
"Transport",
|
|
51
|
+
"SerialTransport",
|
|
52
|
+
"MemoryTransport",
|
|
53
|
+
"DEFAULT_PORT",
|
|
54
|
+
"DEFAULT_BAUDRATE",
|
|
55
|
+
# protocol + geometry
|
|
56
|
+
"protocol",
|
|
57
|
+
"geometry",
|
|
58
|
+
"Flag",
|
|
59
|
+
"Bounds",
|
|
60
|
+
"DEFAULT_BOUNDS",
|
|
61
|
+
"DVG_RES_MIN",
|
|
62
|
+
"DVG_RES_MAX",
|
|
63
|
+
"DVG_RENDER_QUALITY",
|
|
64
|
+
"clip_line",
|
|
65
|
+
"vector_length",
|
|
66
|
+
]
|
pyvterm/frame.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Stateful frame assembly.
|
|
2
|
+
|
|
3
|
+
:class:`FrameBuilder` accumulates colour and vector commands and serialises a
|
|
4
|
+
complete frame to bytes. It is **transport-agnostic and pure** — it never
|
|
5
|
+
touches a serial port — so the exact wire output can be asserted in tests.
|
|
6
|
+
|
|
7
|
+
The emitted frame layout matches ``zvgFrame.c``'s ``serial_send``::
|
|
8
|
+
|
|
9
|
+
[FRAME | total_vector_length] <- header, written first
|
|
10
|
+
[RGB ...] [XY ...] [XY ...] ... <- body (colours and vectors)
|
|
11
|
+
[QUALITY | value]
|
|
12
|
+
[COMPLETE]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from . import protocol
|
|
18
|
+
from .geometry import clip_line, vector_length
|
|
19
|
+
from .protocol import DEFAULT_BOUNDS, DVG_RENDER_QUALITY, DVG_RES_MAX, DVG_RES_MIN, Bounds
|
|
20
|
+
|
|
21
|
+
__all__ = ["FrameBuilder"]
|
|
22
|
+
|
|
23
|
+
Window = tuple[int, int, int, int]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _clamp(value: int, low: int, high: int) -> int:
|
|
27
|
+
return max(low, min(high, value))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FrameBuilder:
|
|
31
|
+
"""Accumulate a single frame of vectors and serialise it to bytes.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
bounds:
|
|
36
|
+
Host coordinate space mapped onto the device grid.
|
|
37
|
+
clip_window:
|
|
38
|
+
``(x_min, y_min, x_max, y_max)`` clip rectangle in host coordinates.
|
|
39
|
+
Defaults to the full ``bounds`` (the C code requires the caller to set
|
|
40
|
+
this; we default it so drawing works out of the box).
|
|
41
|
+
quality:
|
|
42
|
+
Value sent in the per-frame ``QUALITY`` command.
|
|
43
|
+
monochrome:
|
|
44
|
+
Set the monochrome bit in the ``COMPLETE`` marker.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
bounds: Bounds = DEFAULT_BOUNDS,
|
|
50
|
+
clip_window: Window | None = None,
|
|
51
|
+
quality: int = DVG_RENDER_QUALITY,
|
|
52
|
+
monochrome: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.bounds = bounds
|
|
55
|
+
self.quality = quality
|
|
56
|
+
self.monochrome = monochrome
|
|
57
|
+
if clip_window is None:
|
|
58
|
+
clip_window = (bounds.x_min, bounds.y_min, bounds.x_max, bounds.y_max)
|
|
59
|
+
self.clip_window: Window = clip_window
|
|
60
|
+
self.reset()
|
|
61
|
+
|
|
62
|
+
def reset(self) -> None:
|
|
63
|
+
"""Discard all accumulated commands and start a fresh frame."""
|
|
64
|
+
self._body = bytearray()
|
|
65
|
+
self._vector_length = 0
|
|
66
|
+
self._last: tuple[int, int] | None = None # last device-space endpoint
|
|
67
|
+
self._last_black = True # colour starts at (0, 0, 0)
|
|
68
|
+
self._count = 0
|
|
69
|
+
|
|
70
|
+
# -- properties --------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def vector_count(self) -> int:
|
|
74
|
+
"""Number of vectors accepted into the current frame."""
|
|
75
|
+
return self._count
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def total_length(self) -> int:
|
|
79
|
+
"""Accumulated beam-travel length (sent in the FRAME header)."""
|
|
80
|
+
return self._vector_length
|
|
81
|
+
|
|
82
|
+
def __len__(self) -> int:
|
|
83
|
+
return self._count
|
|
84
|
+
|
|
85
|
+
# -- drawing -----------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def set_clip_window(self, x_min: int, y_min: int, x_max: int, y_max: int) -> None:
|
|
88
|
+
"""Set the clip rectangle in host coordinates (``zvgFrameSetClipWin``)."""
|
|
89
|
+
self.clip_window = (x_min, y_min, x_max, y_max)
|
|
90
|
+
|
|
91
|
+
def set_rgb(self, r: int, g: int, b: int) -> None:
|
|
92
|
+
"""Set the colour of subsequent vectors from ~4-bit channels.
|
|
93
|
+
|
|
94
|
+
Mirrors ``zvgFrameSetRGB15``: each channel is scaled ``<< 4`` and
|
|
95
|
+
clamped to 255. A colour of ``(0, 0, 0)`` blanks subsequent draws.
|
|
96
|
+
"""
|
|
97
|
+
r8 = protocol.scale_color(r)
|
|
98
|
+
g8 = protocol.scale_color(g)
|
|
99
|
+
b8 = protocol.scale_color(b)
|
|
100
|
+
self._last_black = r8 == 0 and g8 == 0 and b8 == 0
|
|
101
|
+
self._body += protocol.rgb(r8, g8, b8)
|
|
102
|
+
|
|
103
|
+
def vector(self, x0: float, y0: float, x1: float, y1: float) -> bool:
|
|
104
|
+
"""Add a vector from ``(x0, y0)`` to ``(x1, y1)`` in host coordinates.
|
|
105
|
+
|
|
106
|
+
The line is clipped to the clip window; returns ``False`` (emitting
|
|
107
|
+
nothing) when it lies entirely off-screen. A beam-off reposition is
|
|
108
|
+
inserted automatically when the start does not continue the previous
|
|
109
|
+
vector, so connected polylines cost only one extra move overall.
|
|
110
|
+
"""
|
|
111
|
+
clipped = clip_line(x0, y0, x1, y1, self.clip_window)
|
|
112
|
+
if clipped is None:
|
|
113
|
+
return False
|
|
114
|
+
cx0, cy0, cx1, cy1 = clipped
|
|
115
|
+
|
|
116
|
+
start = (
|
|
117
|
+
_clamp(self.bounds.conv_x(cx0), DVG_RES_MIN, DVG_RES_MAX),
|
|
118
|
+
_clamp(self.bounds.conv_y(cy0), DVG_RES_MIN, DVG_RES_MAX),
|
|
119
|
+
)
|
|
120
|
+
end = (
|
|
121
|
+
_clamp(self.bounds.conv_x(cx1), DVG_RES_MIN, DVG_RES_MAX),
|
|
122
|
+
_clamp(self.bounds.conv_y(cy1), DVG_RES_MIN, DVG_RES_MAX),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if self._last is not None:
|
|
126
|
+
self._vector_length += vector_length(self._last[0], self._last[1], *start)
|
|
127
|
+
self._vector_length += vector_length(start[0], start[1], end[0], end[1])
|
|
128
|
+
|
|
129
|
+
if self._last != start:
|
|
130
|
+
# Reposition the beam (always off) to the start of this vector.
|
|
131
|
+
self._body += protocol.xy(start[0], start[1], blank=True)
|
|
132
|
+
self._body += protocol.xy(end[0], end[1], blank=self._last_black)
|
|
133
|
+
|
|
134
|
+
self._last = end
|
|
135
|
+
self._count += 1
|
|
136
|
+
return True
|
|
137
|
+
|
|
138
|
+
def polyline(self, points: list[tuple[float, float]], closed: bool = False) -> int:
|
|
139
|
+
"""Draw a connected sequence of points; returns the vectors emitted."""
|
|
140
|
+
emitted = 0
|
|
141
|
+
for (x0, y0), (x1, y1) in zip(points, points[1:]):
|
|
142
|
+
if self.vector(x0, y0, x1, y1):
|
|
143
|
+
emitted += 1
|
|
144
|
+
if closed and len(points) > 2:
|
|
145
|
+
x0, y0 = points[-1]
|
|
146
|
+
x1, y1 = points[0]
|
|
147
|
+
if self.vector(x0, y0, x1, y1):
|
|
148
|
+
emitted += 1
|
|
149
|
+
return emitted
|
|
150
|
+
|
|
151
|
+
# -- serialisation -----------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def to_bytes(self) -> bytes:
|
|
154
|
+
"""Serialise the accumulated frame (header + body + quality + complete)."""
|
|
155
|
+
out = bytearray()
|
|
156
|
+
out += protocol.frame_header(self._vector_length)
|
|
157
|
+
out += self._body
|
|
158
|
+
out += protocol.quality(self.quality)
|
|
159
|
+
out += protocol.complete(self.monochrome)
|
|
160
|
+
return bytes(out)
|
pyvterm/geometry.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Geometry helpers used by the frame builder.
|
|
2
|
+
|
|
3
|
+
Pure functions only — no hardware or I/O. ``clip_line`` is a direct port of
|
|
4
|
+
the Cohen-Sutherland clipper in ``zvgFrame.c`` (some games emit coordinates
|
|
5
|
+
outside the view window, so vectors must be clipped before transmission).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
|
|
12
|
+
__all__ = ["INSIDE", "LEFT", "RIGHT", "BOTTOM", "TOP", "clip_line", "vector_length"]
|
|
13
|
+
|
|
14
|
+
# Region codes (matching zvgFrame.c).
|
|
15
|
+
INSIDE = 0
|
|
16
|
+
LEFT = 1
|
|
17
|
+
RIGHT = 2
|
|
18
|
+
BOTTOM = 4
|
|
19
|
+
TOP = 8
|
|
20
|
+
|
|
21
|
+
Window = tuple[float, float, float, float] # (x_min, y_min, x_max, y_max)
|
|
22
|
+
Point = tuple[float, float]
|
|
23
|
+
Segment = tuple[float, float, float, float]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _region_code(x: float, y: float, window: Window) -> int:
|
|
27
|
+
x_min, y_min, x_max, y_max = window
|
|
28
|
+
code = INSIDE
|
|
29
|
+
if x < x_min:
|
|
30
|
+
code |= LEFT
|
|
31
|
+
elif x > x_max:
|
|
32
|
+
code |= RIGHT
|
|
33
|
+
if y < y_min:
|
|
34
|
+
code |= BOTTOM
|
|
35
|
+
elif y > y_max:
|
|
36
|
+
code |= TOP
|
|
37
|
+
return code
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def clip_line(x1: float, y1: float, x2: float, y2: float, window: Window) -> Segment | None:
|
|
41
|
+
"""Cohen-Sutherland line clip.
|
|
42
|
+
|
|
43
|
+
Returns the clipped ``(x1, y1, x2, y2)`` segment, or ``None`` if the line
|
|
44
|
+
lies entirely outside ``window`` (``(x_min, y_min, x_max, y_max)``).
|
|
45
|
+
"""
|
|
46
|
+
x_min, y_min, x_max, y_max = window
|
|
47
|
+
code1 = _region_code(x1, y1, window)
|
|
48
|
+
code2 = _region_code(x2, y2, window)
|
|
49
|
+
|
|
50
|
+
while True:
|
|
51
|
+
if code1 == 0 and code2 == 0:
|
|
52
|
+
return (x1, y1, x2, y2)
|
|
53
|
+
if code1 & code2:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
code_out = code1 or code2
|
|
57
|
+
x = y = 0.0
|
|
58
|
+
if code_out & TOP:
|
|
59
|
+
x = x1 + (x2 - x1) * (y_max - y1) / (y2 - y1)
|
|
60
|
+
y = y_max
|
|
61
|
+
elif code_out & BOTTOM:
|
|
62
|
+
x = x1 + (x2 - x1) * (y_min - y1) / (y2 - y1)
|
|
63
|
+
y = y_min
|
|
64
|
+
elif code_out & RIGHT:
|
|
65
|
+
y = y1 + (y2 - y1) * (x_max - x1) / (x2 - x1)
|
|
66
|
+
x = x_max
|
|
67
|
+
elif code_out & LEFT:
|
|
68
|
+
y = y1 + (y2 - y1) * (x_min - x1) / (x2 - x1)
|
|
69
|
+
x = x_min
|
|
70
|
+
|
|
71
|
+
if code_out == code1:
|
|
72
|
+
x1, y1 = x, y
|
|
73
|
+
code1 = _region_code(x1, y1, window)
|
|
74
|
+
else:
|
|
75
|
+
x2, y2 = x, y
|
|
76
|
+
code2 = _region_code(x2, y2, window)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def vector_length(x0: float, y0: float, x1: float, y1: float) -> int:
|
|
80
|
+
"""Integer Euclidean length of a segment (``vector_length`` in C)."""
|
|
81
|
+
return int(math.hypot(x1 - x0, y1 - y0))
|
pyvterm/preview.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Render pyvterm frames as a glowing vector display — previews without hardware.
|
|
2
|
+
|
|
3
|
+
Optional module: needs the ``preview`` extra (``pip install "pyvterm[preview]"``,
|
|
4
|
+
i.e. numpy + Pillow). It is **not** imported by ``pyvterm`` itself, so the core
|
|
5
|
+
package stays dependency-light.
|
|
6
|
+
|
|
7
|
+
Frames are decoded from the *actual wire bytes* back into beam segments, so a
|
|
8
|
+
preview shows exactly what the device would draw. Capture frames with a
|
|
9
|
+
:class:`PreviewTransport`, then save an animated PNG::
|
|
10
|
+
|
|
11
|
+
from pyvterm import VectorTerminal
|
|
12
|
+
from pyvterm.preview import PreviewTransport
|
|
13
|
+
|
|
14
|
+
preview = PreviewTransport(width=440, height=330)
|
|
15
|
+
vt = VectorTerminal(transport=preview)
|
|
16
|
+
for ...:
|
|
17
|
+
with vt.frame():
|
|
18
|
+
vt.polyline(points)
|
|
19
|
+
preview.save_apng("out.png", fps=25)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
|
|
26
|
+
from . import protocol
|
|
27
|
+
from .protocol import DVG_RES_MAX, Flag
|
|
28
|
+
from .transport import Transport
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import numpy as np
|
|
32
|
+
from PIL import Image, ImageDraw, ImageFilter
|
|
33
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
34
|
+
raise ImportError(
|
|
35
|
+
"pyvterm.preview needs the 'preview' extra: pip install 'pyvterm[preview]'"
|
|
36
|
+
) from exc
|
|
37
|
+
|
|
38
|
+
__all__ = ["Segment", "PHOSPHOR", "decode_segments", "rasterize", "save_apng", "PreviewTransport"]
|
|
39
|
+
|
|
40
|
+
#: A lit beam segment in device space: ``(x0, y0, x1, y1, intensity)``.
|
|
41
|
+
Segment = tuple[int, int, int, int, int]
|
|
42
|
+
Color = tuple[float, float, float]
|
|
43
|
+
|
|
44
|
+
#: Default phosphor tint (green-cyan glow with white-hot cores).
|
|
45
|
+
PHOSPHOR: Color = (0.18, 1.0, 0.55)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def decode_segments(frame: bytes) -> list[Segment]:
|
|
49
|
+
"""Decode a pyvterm frame into the lit segments the beam would draw.
|
|
50
|
+
|
|
51
|
+
Coordinates are device space (``0..DVG_RES_MAX``). Blanked moves reposition
|
|
52
|
+
the pen without producing a segment; a segment is emitted for each lit draw.
|
|
53
|
+
"""
|
|
54
|
+
segments: list[Segment] = []
|
|
55
|
+
pen_x = pen_y = 0
|
|
56
|
+
intensity = 0
|
|
57
|
+
for offset in range(0, len(frame) - 3, 4):
|
|
58
|
+
info = protocol.decode_word(int.from_bytes(frame[offset : offset + 4], "big"))
|
|
59
|
+
flag = info["flag"]
|
|
60
|
+
if flag is Flag.RGB:
|
|
61
|
+
intensity = max(info["r"], info["g"], info["b"])
|
|
62
|
+
elif flag is Flag.XY:
|
|
63
|
+
x, y = info["x"], info["y"]
|
|
64
|
+
if not info["blank"] and intensity > 0:
|
|
65
|
+
segments.append((pen_x, pen_y, x, y, intensity))
|
|
66
|
+
pen_x, pen_y = x, y
|
|
67
|
+
return segments
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def rasterize(
|
|
71
|
+
segments: Sequence[Segment], width: int, height: int, color: Color = PHOSPHOR
|
|
72
|
+
) -> Image.Image:
|
|
73
|
+
"""Rasterize device-space ``segments`` into a glowing RGB frame."""
|
|
74
|
+
core = Image.new("L", (width, height), 0)
|
|
75
|
+
draw = ImageDraw.Draw(core)
|
|
76
|
+
sx = (width - 1) / DVG_RES_MAX
|
|
77
|
+
sy = (height - 1) / DVG_RES_MAX
|
|
78
|
+
for x0, y0, x1, y1, intensity in segments:
|
|
79
|
+
value = 90 + int(165 * intensity / 255)
|
|
80
|
+
# Device Y grows upward, image Y downward -> flip.
|
|
81
|
+
draw.line((x0 * sx, (height - 1) - y0 * sy, x1 * sx, (height - 1) - y1 * sy), fill=value)
|
|
82
|
+
|
|
83
|
+
base = np.asarray(core, dtype=np.float32) / 255.0
|
|
84
|
+
glow1 = np.asarray(core.filter(ImageFilter.GaussianBlur(2.0)), dtype=np.float32) / 255.0
|
|
85
|
+
glow2 = np.asarray(core.filter(ImageFilter.GaussianBlur(5.0)), dtype=np.float32) / 255.0
|
|
86
|
+
intensity_map = np.clip(base + 0.8 * glow1 + 0.5 * glow2 - 0.05, 0.0, 1.5)
|
|
87
|
+
hot = np.clip((base - 0.45) * 2.2, 0.0, 1.0) # white-hot line cores
|
|
88
|
+
|
|
89
|
+
r, g, b = color
|
|
90
|
+
rgb = np.zeros((height, width, 3), dtype=np.float32)
|
|
91
|
+
rgb[..., 0] = r * intensity_map + 0.9 * hot
|
|
92
|
+
rgb[..., 1] = g * intensity_map + 0.5 * hot
|
|
93
|
+
rgb[..., 2] = b * intensity_map + 0.9 * hot
|
|
94
|
+
out = (np.clip(rgb, 0.0, 1.0) * 255).astype(np.uint8)
|
|
95
|
+
return Image.fromarray(out, mode="RGB")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def save_apng(images: Sequence[Image.Image], path: str, fps: float = 25.0) -> int:
|
|
99
|
+
"""Write ``images`` as an animated PNG; returns the number of frames."""
|
|
100
|
+
if not images:
|
|
101
|
+
raise ValueError("no frames to save")
|
|
102
|
+
duration = int(1000 / fps) if fps > 0 else 50
|
|
103
|
+
images[0].save(
|
|
104
|
+
path,
|
|
105
|
+
save_all=True,
|
|
106
|
+
append_images=list(images[1:]),
|
|
107
|
+
duration=duration,
|
|
108
|
+
loop=0,
|
|
109
|
+
format="PNG",
|
|
110
|
+
)
|
|
111
|
+
return len(images)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PreviewTransport(Transport):
|
|
115
|
+
"""A :class:`~pyvterm.transport.Transport` that captures frames as images.
|
|
116
|
+
|
|
117
|
+
Drop it into a :class:`~pyvterm.VectorTerminal` in place of a serial port;
|
|
118
|
+
each ``send_frame`` is captured, and :meth:`save_apng` writes the animation.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(self, width: int = 480, height: int = 360, color: Color = PHOSPHOR) -> None:
|
|
122
|
+
self.width = width
|
|
123
|
+
self.height = height
|
|
124
|
+
self.color = color
|
|
125
|
+
self.frames: list[bytes] = []
|
|
126
|
+
|
|
127
|
+
def write(self, data: bytes) -> int:
|
|
128
|
+
self.frames.append(bytes(data))
|
|
129
|
+
return len(data)
|
|
130
|
+
|
|
131
|
+
def _frame_blobs(self) -> list[bytes]:
|
|
132
|
+
# Keep only real frames (skip the trailing EXIT-only write from close()).
|
|
133
|
+
return [
|
|
134
|
+
blob
|
|
135
|
+
for blob in self.frames
|
|
136
|
+
if len(blob) >= 4
|
|
137
|
+
and (int.from_bytes(blob[:4], "big") >> protocol.FLAG_SHIFT) == Flag.FRAME
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
def images(self) -> list[Image.Image]:
|
|
141
|
+
"""Rasterize every captured frame to a list of images."""
|
|
142
|
+
return [
|
|
143
|
+
rasterize(decode_segments(blob), self.width, self.height, self.color)
|
|
144
|
+
for blob in self._frame_blobs()
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
def save_apng(self, path: str, fps: float = 25.0) -> int:
|
|
148
|
+
"""Render captured frames and write them to ``path`` as an animated PNG."""
|
|
149
|
+
return save_apng(self.images(), path, fps=fps)
|
pyvterm/protocol.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Wire-level encoding for the USB-DVG / pitrex *vecterm* serial protocol.
|
|
2
|
+
|
|
3
|
+
This module is **pure**: it has no I/O dependencies and can be imported and
|
|
4
|
+
unit-tested without a serial port or any hardware. It mirrors the command
|
|
5
|
+
encoding in `gtoal/pitrex`'s ``VMMenu/Win32/dvg/zvgFrame.c`` (the USB-DVG
|
|
6
|
+
drivers written by Mario Montminy, 2020) and cross-checks against the
|
|
7
|
+
canonical AdvanceMAME ``advance/osd/dvg.c`` implementation.
|
|
8
|
+
|
|
9
|
+
Every command is a single 32-bit word transmitted **big-endian** (most
|
|
10
|
+
significant byte first). The most significant three bits ``[31:29]`` select
|
|
11
|
+
the command::
|
|
12
|
+
|
|
13
|
+
bit 31 29 28 0
|
|
14
|
+
+---------+------------------------------------------------+
|
|
15
|
+
| flag | payload |
|
|
16
|
+
+---------+------------------------------------------------+
|
|
17
|
+
|
|
18
|
+
See ``docs/PROTOCOL.md`` for the full specification.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import struct
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from enum import IntEnum
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"Flag",
|
|
30
|
+
"Bounds",
|
|
31
|
+
"DEFAULT_BOUNDS",
|
|
32
|
+
"FLAG_SHIFT",
|
|
33
|
+
"BLANK_SHIFT",
|
|
34
|
+
"COORD_BITS",
|
|
35
|
+
"COORD_MASK",
|
|
36
|
+
"PAYLOAD_MASK",
|
|
37
|
+
"DVG_RES_MIN",
|
|
38
|
+
"DVG_RES_MAX",
|
|
39
|
+
"DVG_RENDER_QUALITY",
|
|
40
|
+
"COMPLETE_MONOCHROME",
|
|
41
|
+
"pack_word",
|
|
42
|
+
"scale_color",
|
|
43
|
+
"encode_rgb_word",
|
|
44
|
+
"encode_xy_word",
|
|
45
|
+
"encode_frame_word",
|
|
46
|
+
"encode_quality_word",
|
|
47
|
+
"encode_complete_word",
|
|
48
|
+
"encode_exit_word",
|
|
49
|
+
"decode_word",
|
|
50
|
+
"rgb",
|
|
51
|
+
"rgb_scaled",
|
|
52
|
+
"xy",
|
|
53
|
+
"frame_header",
|
|
54
|
+
"quality",
|
|
55
|
+
"complete",
|
|
56
|
+
"exit_command",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Flag(IntEnum):
|
|
61
|
+
"""Command selector occupying the top three bits of every word."""
|
|
62
|
+
|
|
63
|
+
COMPLETE = 0x0 #: End-of-frame marker (payload 0, or the monochrome bit).
|
|
64
|
+
RGB = 0x1 #: Set the colour/intensity of subsequent vectors.
|
|
65
|
+
XY = 0x2 #: Move (beam off) or draw (beam on) to a coordinate.
|
|
66
|
+
QUALITY = 0x3 #: Render-quality hint (pitrex/zvgFrame variant).
|
|
67
|
+
FRAME = 0x4 #: Frame header carrying total beam-travel length.
|
|
68
|
+
CMD = 0x5 #: Device command channel (AdvanceMAME; e.g. GET_DVG_INFO).
|
|
69
|
+
EXIT = 0x7 #: Tell the device the session is over.
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- Bit layout -----------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
FLAG_SHIFT = 29 #: The flag occupies bits [31:29].
|
|
75
|
+
BLANK_SHIFT = 28 #: The XY blank (beam-off) bit.
|
|
76
|
+
COORD_BITS = 14 #: Each XY coordinate is 14 bits wide.
|
|
77
|
+
COORD_MASK = (1 << COORD_BITS) - 1 #: 0x3FFF.
|
|
78
|
+
PAYLOAD_MASK = (1 << FLAG_SHIFT) - 1 #: 0x1FFFFFFF — bits available below the flag.
|
|
79
|
+
|
|
80
|
+
# --- Device resolution ----------------------------------------------------
|
|
81
|
+
|
|
82
|
+
DVG_RES_MIN = 0 #: Minimum DVG coordinate.
|
|
83
|
+
DVG_RES_MAX = 4095 #: Maximum DVG coordinate (12-bit DAC range).
|
|
84
|
+
DVG_RENDER_QUALITY = 5 #: Default quality value sent once per frame.
|
|
85
|
+
|
|
86
|
+
#: OR'd into a ``COMPLETE`` word to flag a black & white game (AdvanceMAME).
|
|
87
|
+
COMPLETE_MONOCHROME = 1 << 28
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Bounds:
|
|
92
|
+
"""The host coordinate space, mapped onto the device's ``0..4095`` grid.
|
|
93
|
+
|
|
94
|
+
The defaults match ``zvgFrame.h`` (a 1024x768 space centred on the
|
|
95
|
+
origin), which is the standard MAME vector resolution.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
x_min: int = -512
|
|
99
|
+
x_max: int = 511
|
|
100
|
+
y_min: int = -384
|
|
101
|
+
y_max: int = 383
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def width(self) -> int:
|
|
105
|
+
return self.x_max - self.x_min
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def height(self) -> int:
|
|
109
|
+
return self.y_max - self.y_min
|
|
110
|
+
|
|
111
|
+
def conv_x(self, x: float) -> int:
|
|
112
|
+
"""Map a host X coordinate onto ``0..DVG_RES_MAX`` (``CONVX`` in C)."""
|
|
113
|
+
return int(((x - self.x_min) * DVG_RES_MAX) // self.width)
|
|
114
|
+
|
|
115
|
+
def conv_y(self, y: float) -> int:
|
|
116
|
+
"""Map a host Y coordinate onto ``0..DVG_RES_MAX`` (``CONVY`` in C)."""
|
|
117
|
+
return int(((y - self.y_min) * DVG_RES_MAX) // self.height)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
DEFAULT_BOUNDS = Bounds()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- Word encoders --------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def pack_word(word: int) -> bytes:
|
|
127
|
+
"""Serialise a 32-bit command ``word`` as 4 big-endian bytes."""
|
|
128
|
+
return struct.pack(">I", word & 0xFFFFFFFF)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def scale_color(value: int) -> int:
|
|
132
|
+
"""Scale a ~4-bit colour channel to 8 bits, clamped to 255.
|
|
133
|
+
|
|
134
|
+
Matches ``zvgFrameSetRGB15``: the input is shifted left by 4 (so ``15``
|
|
135
|
+
maps to ``240``) and clamped, so values ``>= 16`` saturate at ``255``.
|
|
136
|
+
"""
|
|
137
|
+
scaled = value << 4
|
|
138
|
+
return 255 if scaled > 255 else scaled
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def encode_rgb_word(r: int, g: int, b: int) -> int:
|
|
142
|
+
"""Encode a raw 8-bit-per-channel ``RGB`` word."""
|
|
143
|
+
return (Flag.RGB << FLAG_SHIFT) | ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def encode_xy_word(x: int, y: int, blank: bool) -> int:
|
|
147
|
+
"""Encode an ``XY`` word.
|
|
148
|
+
|
|
149
|
+
``blank`` selects beam-off (a move) when ``True`` and beam-on (a draw)
|
|
150
|
+
when ``False``.
|
|
151
|
+
"""
|
|
152
|
+
return (
|
|
153
|
+
(Flag.XY << FLAG_SHIFT)
|
|
154
|
+
| ((1 if blank else 0) << BLANK_SHIFT)
|
|
155
|
+
| ((x & COORD_MASK) << COORD_BITS)
|
|
156
|
+
| (y & COORD_MASK)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def encode_frame_word(vector_length: int) -> int:
|
|
161
|
+
"""Encode the ``FRAME`` header carrying total beam-travel length."""
|
|
162
|
+
return (Flag.FRAME << FLAG_SHIFT) | (vector_length & PAYLOAD_MASK)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def encode_quality_word(value: int = DVG_RENDER_QUALITY) -> int:
|
|
166
|
+
"""Encode the ``QUALITY`` render hint."""
|
|
167
|
+
return (Flag.QUALITY << FLAG_SHIFT) | (value & PAYLOAD_MASK)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def encode_complete_word(monochrome: bool = False) -> int:
|
|
171
|
+
"""Encode the ``COMPLETE`` end-of-frame marker."""
|
|
172
|
+
return (Flag.COMPLETE << FLAG_SHIFT) | (COMPLETE_MONOCHROME if monochrome else 0)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def encode_exit_word() -> int:
|
|
176
|
+
"""Encode the ``EXIT`` (session over) command."""
|
|
177
|
+
return Flag.EXIT << FLAG_SHIFT
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def decode_word(word: int) -> dict[str, Any]:
|
|
181
|
+
"""Decode a 32-bit word into a human-readable ``dict`` (for tests/debug)."""
|
|
182
|
+
flag = Flag((word >> FLAG_SHIFT) & 0x7)
|
|
183
|
+
info: dict[str, Any] = {"flag": flag}
|
|
184
|
+
if flag is Flag.RGB:
|
|
185
|
+
info.update(r=(word >> 16) & 0xFF, g=(word >> 8) & 0xFF, b=word & 0xFF)
|
|
186
|
+
elif flag is Flag.XY:
|
|
187
|
+
info.update(
|
|
188
|
+
blank=bool((word >> BLANK_SHIFT) & 0x1),
|
|
189
|
+
x=(word >> COORD_BITS) & COORD_MASK,
|
|
190
|
+
y=word & COORD_MASK,
|
|
191
|
+
)
|
|
192
|
+
elif flag is Flag.FRAME:
|
|
193
|
+
info["vector_length"] = word & PAYLOAD_MASK
|
|
194
|
+
elif flag is Flag.QUALITY:
|
|
195
|
+
info["value"] = word & PAYLOAD_MASK
|
|
196
|
+
elif flag is Flag.COMPLETE:
|
|
197
|
+
info["monochrome"] = bool(word & COMPLETE_MONOCHROME)
|
|
198
|
+
return info
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- Byte-producing convenience wrappers ----------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def rgb(r: int, g: int, b: int) -> bytes:
|
|
205
|
+
"""4 bytes setting a raw 8-bit-per-channel colour."""
|
|
206
|
+
return pack_word(encode_rgb_word(r, g, b))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def rgb_scaled(r: int, g: int, b: int) -> bytes:
|
|
210
|
+
"""4 bytes setting a colour from ~4-bit channels (``zvgFrameSetRGB15``)."""
|
|
211
|
+
return pack_word(encode_rgb_word(scale_color(r), scale_color(g), scale_color(b)))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def xy(x: int, y: int, blank: bool) -> bytes:
|
|
215
|
+
"""4 bytes moving (``blank=True``) or drawing (``blank=False``) to ``x,y``."""
|
|
216
|
+
return pack_word(encode_xy_word(x, y, blank))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def frame_header(vector_length: int) -> bytes:
|
|
220
|
+
"""4 bytes for the frame header."""
|
|
221
|
+
return pack_word(encode_frame_word(vector_length))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def quality(value: int = DVG_RENDER_QUALITY) -> bytes:
|
|
225
|
+
"""4 bytes for the quality hint."""
|
|
226
|
+
return pack_word(encode_quality_word(value))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def complete(monochrome: bool = False) -> bytes:
|
|
230
|
+
"""4 bytes for the end-of-frame marker."""
|
|
231
|
+
return pack_word(encode_complete_word(monochrome))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def exit_command() -> bytes:
|
|
235
|
+
"""4 bytes telling the device the session is over."""
|
|
236
|
+
return pack_word(encode_exit_word())
|
pyvterm/py.typed
ADDED
|
File without changes
|