ertftm070 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.
- ertftm070/__init__.py +56 -0
- ertftm070/__main__.py +302 -0
- ertftm070/_fastio.c +556 -0
- ertftm070/_i2c.py +135 -0
- ertftm070/backends.py +471 -0
- ertftm070/colors.py +132 -0
- ertftm070/display.py +801 -0
- ertftm070/errors.py +16 -0
- ertftm070/init.py +92 -0
- ertftm070/pins.py +76 -0
- ertftm070/touch.py +560 -0
- ertftm070-0.1.0.dist-info/METADATA +278 -0
- ertftm070-0.1.0.dist-info/RECORD +17 -0
- ertftm070-0.1.0.dist-info/WHEEL +5 -0
- ertftm070-0.1.0.dist-info/entry_points.txt +2 -0
- ertftm070-0.1.0.dist-info/licenses/LICENSE +21 -0
- ertftm070-0.1.0.dist-info/top_level.txt +1 -0
ertftm070/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""ertftm070 — Python driver for the EastRising ER-TFTM070-4V2.1.
|
|
2
|
+
|
|
3
|
+
A 7" 800x480 TFT (SSD1963 controller) driven over a bit-banged 16-bit
|
|
4
|
+
8080 bus from a Raspberry Pi Zero/1/2/3/4. The board is strapped for
|
|
5
|
+
16-bit 8080, so one WR strobe writes one 16-bit pixel.
|
|
6
|
+
|
|
7
|
+
Quickstart::
|
|
8
|
+
|
|
9
|
+
pip install ertftm070[Pillow]
|
|
10
|
+
|
|
11
|
+
from ertftm070 import Display
|
|
12
|
+
|
|
13
|
+
with Display() as lcd: # verified wiring + init, backlight on
|
|
14
|
+
lcd.fill(0xF800) # red screen in ~0.6 s
|
|
15
|
+
lcd.image(pil_image, x=0, y=0)
|
|
16
|
+
|
|
17
|
+
``BACKEND`` reports which pixel path is active: ``"fast"`` (the compiled
|
|
18
|
+
C extension) or ``"slow"`` (the pure-Python fallback).
|
|
19
|
+
|
|
20
|
+
Full wiring table: docs/WIRING.md. Debugging lore: docs/LESSONS.md.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
25
|
+
|
|
26
|
+
from . import backends
|
|
27
|
+
from .colors import rgb565
|
|
28
|
+
from .display import Display
|
|
29
|
+
from .errors import Ertftm070Error, NotOnRaspberryPi
|
|
30
|
+
from .init import INIT_ALT, INIT_BD, INIT_UTFT
|
|
31
|
+
from .pins import DEFAULT_PINS, Pins
|
|
32
|
+
from .touch import Touch, TouchCalibration, TouchPoint
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
__version__ = version("ertftm070")
|
|
36
|
+
except PackageNotFoundError: # not installed (e.g. running from a source tree)
|
|
37
|
+
__version__ = "0.1.0"
|
|
38
|
+
|
|
39
|
+
BACKEND = backends.BACKEND
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Display",
|
|
43
|
+
"Pins",
|
|
44
|
+
"DEFAULT_PINS",
|
|
45
|
+
"rgb565",
|
|
46
|
+
"INIT_UTFT",
|
|
47
|
+
"INIT_ALT",
|
|
48
|
+
"INIT_BD",
|
|
49
|
+
"Ertftm070Error",
|
|
50
|
+
"NotOnRaspberryPi",
|
|
51
|
+
"Touch",
|
|
52
|
+
"TouchPoint",
|
|
53
|
+
"TouchCalibration",
|
|
54
|
+
"BACKEND",
|
|
55
|
+
"__version__",
|
|
56
|
+
]
|
ertftm070/__main__.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""ertftm070 command-line tool.
|
|
2
|
+
|
|
3
|
+
ertftm070 selftest bus self-test (device ID + round-trip)
|
|
4
|
+
ertftm070 bars 8 full-height color bars
|
|
5
|
+
ertftm070 fill F800 fill the whole screen with one RGB565 color
|
|
6
|
+
ertftm070 image photo.png show an image (needs Pillow)
|
|
7
|
+
ertftm070 gramcheck write known pixels, read them back from GRAM
|
|
8
|
+
ertftm070 refresh measure the pixel clock and frame rate
|
|
9
|
+
ertftm070 touch-test stream touches until Ctrl+C (needs the I2C wires)
|
|
10
|
+
|
|
11
|
+
The drawing commands (bars/fill/image) hold the picture on screen until
|
|
12
|
+
Ctrl+C or SIGTERM (both turn the backlight off); pass --once to exit
|
|
13
|
+
immediately instead. The diagnostics (selftest/gramcheck) run once and
|
|
14
|
+
exit 0 on success, 1 on failure. Global options (--init, --rotation,
|
|
15
|
+
--once) are accepted before or after the subcommand. Works identically
|
|
16
|
+
as ``python -m ertftm070``.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import logging
|
|
22
|
+
import signal
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from . import init
|
|
27
|
+
from ._i2c import I2CError
|
|
28
|
+
from .display import Display, crystal_guess
|
|
29
|
+
from .errors import Ertftm070Error
|
|
30
|
+
|
|
31
|
+
BARS = [
|
|
32
|
+
("black", 0x0000),
|
|
33
|
+
("red", 0xF800),
|
|
34
|
+
("green", 0x07E0),
|
|
35
|
+
("blue", 0x001F),
|
|
36
|
+
("white", 0xFFFF),
|
|
37
|
+
("yellow", 0xFFE0),
|
|
38
|
+
("cyan", 0x07FF),
|
|
39
|
+
("magenta", 0xF81F),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
_TABLES = {"utft": init.INIT_UTFT, "alt": init.INIT_ALT, "bd": init.INIT_BD}
|
|
43
|
+
|
|
44
|
+
# Commands that leave a picture on the panel: hold until interrupted.
|
|
45
|
+
# The diagnostics are one-shot; holding them open would defeat their
|
|
46
|
+
# exit codes.
|
|
47
|
+
_HOLD_COMMANDS = frozenset({"bars", "fill", "image"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _add_global_options(
|
|
51
|
+
parser: argparse.ArgumentParser, *, suppress_defaults: bool = False
|
|
52
|
+
) -> None:
|
|
53
|
+
"""The options valid in front of the subcommand AND behind it.
|
|
54
|
+
|
|
55
|
+
Registered on the top-level parser (with real defaults) and on every
|
|
56
|
+
subparser (via a shared parent). On the subparser level the defaults
|
|
57
|
+
are SUPPRESSED: argparse versions before 3.12 re-parse the remaining
|
|
58
|
+
args into a fresh namespace and copy it over the outer one, which
|
|
59
|
+
would clobber a ``--rotation 90`` given *before* the subcommand with
|
|
60
|
+
the subparser's default of 0. With no default to apply, the
|
|
61
|
+
subparser only writes what the user actually typed there — so
|
|
62
|
+
``ertftm070 bars --rotation 90`` and ``ertftm070 --rotation 90 bars``
|
|
63
|
+
behave identically on every supported Python.
|
|
64
|
+
"""
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--init",
|
|
67
|
+
choices=sorted(_TABLES),
|
|
68
|
+
default=argparse.SUPPRESS if suppress_defaults else "utft",
|
|
69
|
+
help="init table (default: utft — the one verified on hardware)",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--rotation",
|
|
73
|
+
type=int,
|
|
74
|
+
choices=[0, 90, 180, 270],
|
|
75
|
+
default=argparse.SUPPRESS if suppress_defaults else 0,
|
|
76
|
+
help="logical rotation (default: 0)",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--once",
|
|
80
|
+
action="store_true",
|
|
81
|
+
default=argparse.SUPPRESS if suppress_defaults else False,
|
|
82
|
+
help="exit right after drawing instead of keeping the display on "
|
|
83
|
+
"until Ctrl+C (turns the backlight off)",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _rgb565_hex(value: str) -> int:
|
|
88
|
+
"""argparse type: a hex string that must be a 16-bit RGB565 color."""
|
|
89
|
+
try:
|
|
90
|
+
color = int(value, 16)
|
|
91
|
+
except ValueError:
|
|
92
|
+
raise argparse.ArgumentTypeError(f"not a hex number: {value!r}") from None
|
|
93
|
+
if not 0 <= color <= 0xFFFF:
|
|
94
|
+
raise argparse.ArgumentTypeError(
|
|
95
|
+
f"{value!r} is outside 0..FFFF — RGB565 colors are 16 bits"
|
|
96
|
+
)
|
|
97
|
+
return color
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
101
|
+
parser = argparse.ArgumentParser(
|
|
102
|
+
prog="ertftm070",
|
|
103
|
+
description="Drive an ER-TFTM070-4V2.1 (SSD1963) display on a Raspberry Pi.",
|
|
104
|
+
)
|
|
105
|
+
_add_global_options(parser) # before the subcommand
|
|
106
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
107
|
+
|
|
108
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
109
|
+
_add_global_options(common, suppress_defaults=True) # ... and after it
|
|
110
|
+
|
|
111
|
+
subparsers.add_parser(
|
|
112
|
+
"selftest", parents=[common],
|
|
113
|
+
help="bus self-test (device ID + register round-trip)",
|
|
114
|
+
)
|
|
115
|
+
subparsers.add_parser(
|
|
116
|
+
"bars", parents=[common], help="draw 8 full-height color bars"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
fill = subparsers.add_parser(
|
|
120
|
+
"fill", parents=[common], help="fill the whole screen with one color"
|
|
121
|
+
)
|
|
122
|
+
fill.add_argument(
|
|
123
|
+
"color", type=_rgb565_hex, help="RGB565 hex, e.g. F800 for red (0..FFFF)"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
image = subparsers.add_parser(
|
|
127
|
+
"image", parents=[common], help="show an image file (needs Pillow)"
|
|
128
|
+
)
|
|
129
|
+
image.add_argument("path", help="path to a PNG/JPEG/… file")
|
|
130
|
+
image.add_argument("--x", type=int, default=0, help="top-left x (default 0)")
|
|
131
|
+
image.add_argument("--y", type=int, default=0, help="top-left y (default 0)")
|
|
132
|
+
|
|
133
|
+
subparsers.add_parser(
|
|
134
|
+
"gramcheck", parents=[common],
|
|
135
|
+
help="write known pixels, read them back from GRAM",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
subparsers.add_parser(
|
|
139
|
+
"refresh", parents=[common],
|
|
140
|
+
help="measure the pixel clock and frame rate from the TE pin",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
subparsers.add_parser(
|
|
144
|
+
"touch-test", parents=[common],
|
|
145
|
+
help="stream touch points until Ctrl+C (needs SCL/SDA/WAKE wired)",
|
|
146
|
+
)
|
|
147
|
+
return parser
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _make_display(args: argparse.Namespace) -> Display:
|
|
151
|
+
return Display(init_table=_TABLES[args.init], rotation=args.rotation)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def run(args: argparse.Namespace, display: Display | None = None) -> int:
|
|
155
|
+
"""Execute parsed args. ``display`` is injectable for tests."""
|
|
156
|
+
own = display is None
|
|
157
|
+
if own:
|
|
158
|
+
display = _make_display(args)
|
|
159
|
+
display.open()
|
|
160
|
+
try:
|
|
161
|
+
if args.command == "selftest":
|
|
162
|
+
return 0 if display.selftest() else 1
|
|
163
|
+
|
|
164
|
+
if args.command == "bars":
|
|
165
|
+
t0 = time.time()
|
|
166
|
+
w = display.width // len(BARS)
|
|
167
|
+
for i, (_name, color) in enumerate(BARS):
|
|
168
|
+
display.fill_rect(i * w, 0, w, display.height, color)
|
|
169
|
+
print("8 color bars drawn in %.1f s" % (time.time() - t0))
|
|
170
|
+
print("Expected left->right: {}".format(", ".join(n for n, _ in BARS)))
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
if args.command == "fill":
|
|
174
|
+
t0 = time.time()
|
|
175
|
+
display.fill(args.color) # already validated by _rgb565_hex
|
|
176
|
+
print("filled in %.1f s" % (time.time() - t0))
|
|
177
|
+
return 0
|
|
178
|
+
|
|
179
|
+
if args.command == "image":
|
|
180
|
+
try:
|
|
181
|
+
from PIL import Image # lazy — needs the pillow extra
|
|
182
|
+
except ImportError:
|
|
183
|
+
print(
|
|
184
|
+
"the image command needs Pillow: "
|
|
185
|
+
"pip install 'ertftm070[Pillow]'",
|
|
186
|
+
file=sys.stderr,
|
|
187
|
+
)
|
|
188
|
+
return 2
|
|
189
|
+
try:
|
|
190
|
+
img = Image.open(args.path)
|
|
191
|
+
except OSError as exc:
|
|
192
|
+
print(f"cannot open {args.path!r}: {exc}", file=sys.stderr)
|
|
193
|
+
return 2
|
|
194
|
+
t0 = time.time()
|
|
195
|
+
display.image(img, args.x, args.y, fit=True)
|
|
196
|
+
print(
|
|
197
|
+
f"{img.size[0]}x{img.size[1]} image blitted in "
|
|
198
|
+
f"{time.time() - t0:.1f} s"
|
|
199
|
+
)
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
if args.command == "gramcheck":
|
|
203
|
+
return 0 if display.gramcheck() else 1
|
|
204
|
+
|
|
205
|
+
if args.command == "refresh":
|
|
206
|
+
try:
|
|
207
|
+
pclk_khz, hz = display.refresh_rate()
|
|
208
|
+
except (TimeoutError, RuntimeError) as exc:
|
|
209
|
+
# TimeoutError: no TE pulses (wiring / panel asleep);
|
|
210
|
+
# RuntimeError: Pins.te is None, or the init table lacks
|
|
211
|
+
# 0xB4/0xB6. Both mean "cannot measure" — report and fail.
|
|
212
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
213
|
+
return 1
|
|
214
|
+
print(f"pixel clock (from TE): {pclk_khz} kHz")
|
|
215
|
+
print(f"frame rate (TE): {hz:.1f} Hz")
|
|
216
|
+
print(
|
|
217
|
+
"crystal: "
|
|
218
|
+
f"{crystal_guess(pclk_khz, display.init_table)}"
|
|
219
|
+
)
|
|
220
|
+
return 0
|
|
221
|
+
|
|
222
|
+
if args.command == "touch-test":
|
|
223
|
+
from .touch import Touch
|
|
224
|
+
|
|
225
|
+
touch = Touch(display.bus)
|
|
226
|
+
try:
|
|
227
|
+
touch.open()
|
|
228
|
+
except I2CError as exc:
|
|
229
|
+
print(f"touch unavailable: {exc}", file=sys.stderr)
|
|
230
|
+
return 1
|
|
231
|
+
print("touch test — touch the panel (Ctrl+C to exit)")
|
|
232
|
+
seen = None
|
|
233
|
+
try:
|
|
234
|
+
while True:
|
|
235
|
+
points = touch.read()
|
|
236
|
+
key = tuple((p.id, p.x, p.y) for p in points)
|
|
237
|
+
if key != seen:
|
|
238
|
+
seen = key
|
|
239
|
+
for p in points:
|
|
240
|
+
m = touch.calibration.map(p)
|
|
241
|
+
# mapped points are panel-native; --rotation
|
|
242
|
+
# users want the logical frame instead
|
|
243
|
+
ux, uy = display.unmap_point(m.x, m.y)
|
|
244
|
+
print(
|
|
245
|
+
f" finger {p.id}: raw=({p.x},{p.y}) "
|
|
246
|
+
f"-> panel=({m.x},{m.y}) "
|
|
247
|
+
f"screen=({ux},{uy}) event={p.event}"
|
|
248
|
+
)
|
|
249
|
+
time.sleep(0.02)
|
|
250
|
+
finally:
|
|
251
|
+
touch.close()
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
print("no command given — see ertftm070 --help", file=sys.stderr)
|
|
255
|
+
return 2
|
|
256
|
+
finally:
|
|
257
|
+
if own:
|
|
258
|
+
display.close()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _sigterm(_signum, _frame) -> None:
|
|
262
|
+
"""SIGTERM (kill, systemd stop) unwinds like Ctrl-C: the finally
|
|
263
|
+
blocks close the display and the process exits."""
|
|
264
|
+
raise KeyboardInterrupt
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main(argv: list[str] | None = None) -> None:
|
|
268
|
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
|
269
|
+
args = build_parser().parse_args(argv)
|
|
270
|
+
signal.signal(signal.SIGTERM, _sigterm) # together with Ctrl-C: exit cleanly
|
|
271
|
+
code = 0
|
|
272
|
+
display = None
|
|
273
|
+
try:
|
|
274
|
+
display = _make_display(args)
|
|
275
|
+
display.open()
|
|
276
|
+
code = run(args, display)
|
|
277
|
+
except Ertftm070Error as exc:
|
|
278
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
279
|
+
code = 1
|
|
280
|
+
except ValueError as exc:
|
|
281
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
282
|
+
code = 2
|
|
283
|
+
except KeyboardInterrupt:
|
|
284
|
+
print() # user aborted mid-command; nothing to report
|
|
285
|
+
else:
|
|
286
|
+
if not args.once and args.command in _HOLD_COMMANDS:
|
|
287
|
+
# keep the picture on screen until the user is done looking;
|
|
288
|
+
# Ctrl-C (or SIGTERM) turns the backlight off and exits
|
|
289
|
+
print("display on — press Ctrl+C to exit")
|
|
290
|
+
try:
|
|
291
|
+
while True:
|
|
292
|
+
time.sleep(1)
|
|
293
|
+
except KeyboardInterrupt:
|
|
294
|
+
print()
|
|
295
|
+
finally:
|
|
296
|
+
if display is not None:
|
|
297
|
+
display.close()
|
|
298
|
+
sys.exit(code)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|