brickremote 0.3.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.
brickremote/tui.py ADDED
@@ -0,0 +1,661 @@
1
+ """Terminal remote control, modelled on the official app minus the phone-only modes.
2
+
3
+ Tabs: Car (steering wheel), Ports (A-D sticks), Program, Path.
4
+ Terminals only report key presses, not releases, so a motor keeps running while its key
5
+ auto-repeats and stops shortly after you let go. With hold on, the drive motors keep
6
+ running until you press space; steering always springs back.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import curses
13
+ import functools
14
+ import json
15
+ import math
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ import logging
21
+ import time
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ from .car import Car
26
+ from .module import JMCModule
27
+ from .protocol import GEARS
28
+ from .script import Calibration, ScriptError, parse_path, parse_program, run_path, run_program
29
+
30
+ log = logging.getLogger(__name__)
31
+
32
+ TABS = ("Car", "Ports", "Program", "Path")
33
+ TAB_ICONS = ("◉", "✚", "≡", "⌁")
34
+ CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "brickremote.json"
35
+
36
+ # Release detection: before auto-repeat kicks in (~0.5 s after the first press) we must wait;
37
+ # once a key is repeating, a short gap in the repeats means it was let go.
38
+ FIRST_HOLD = 0.6
39
+ REPEAT_HOLD = 0.15
40
+
41
+ # Ports tab: (forward key, reverse key) per port.
42
+ # Like two game sticks: WASD drives A (up/down) and B (left/right), IJKL drives C and D.
43
+ PORT_KEYS = {"a": ("w", "s"), "b": ("d", "a"), "c": ("i", "k"), "d": ("l", "j")}
44
+ LINKS = (None, ("a", "b"), ("a", "c"))
45
+
46
+ EXAMPLE_PROGRAM = """# Program mode - see `brickremote program --help` for all commands
47
+ gear 2
48
+ repeat 2
49
+ a + 1.5
50
+ wait 0.5
51
+ a - 1.5
52
+ end
53
+ stop
54
+ """
55
+ EXAMPLE_PATH = """# Path mode - metres and degrees
56
+ forward 1
57
+ left 90
58
+ forward 0.5
59
+ right 90
60
+ back 0.5
61
+ """
62
+
63
+ BIG_DIGITS = {
64
+ 1: (" ██ ", "███ ", " ██ ", " ██ ", "████"),
65
+ 2: ("███ ", " █", " ██ ", "█ ", "████"),
66
+ 3: ("███ ", " █", " ██ ", " █", "███ "),
67
+ }
68
+
69
+ # colour pair numbers
70
+ GREEN, ON_GREEN, DIM, YELLOW, RED, BLUE, ON_YELLOW, ON_RED, WHITE, ORANGE = range(1, 11)
71
+
72
+
73
+ def load_config() -> dict:
74
+ for path in (CONFIG, CONFIG.with_name("jmcble.json")): # the package was called jmcble before 0.3
75
+ try:
76
+ return json.loads(path.read_text())
77
+ except (OSError, ValueError):
78
+ continue
79
+ return {}
80
+
81
+
82
+ def save_config(cfg: dict) -> None:
83
+ try:
84
+ CONFIG.parent.mkdir(parents=True, exist_ok=True)
85
+ CONFIG.write_text(json.dumps(cfg, indent=2))
86
+ except OSError:
87
+ pass
88
+
89
+
90
+ def init_colours() -> None:
91
+ curses.start_color()
92
+ try:
93
+ curses.use_default_colors()
94
+ bg = -1
95
+ except curses.error:
96
+ bg = curses.COLOR_BLACK
97
+ orange = 208 if curses.COLORS >= 256 else curses.COLOR_YELLOW
98
+ green = 112 if curses.COLORS >= 256 else curses.COLOR_GREEN
99
+ pairs = {
100
+ GREEN: (green, bg), ON_GREEN: (curses.COLOR_BLACK, green), DIM: (8 if curses.COLORS > 8 else curses.COLOR_WHITE, bg),
101
+ YELLOW: (curses.COLOR_YELLOW, bg), RED: (curses.COLOR_RED, bg), BLUE: (curses.COLOR_BLUE, bg),
102
+ ON_YELLOW: (curses.COLOR_BLACK, curses.COLOR_YELLOW), ON_RED: (curses.COLOR_WHITE, curses.COLOR_RED),
103
+ WHITE: (curses.COLOR_WHITE, bg), ORANGE: (orange, bg),
104
+ }
105
+ for n, (fg, b) in pairs.items():
106
+ curses.init_pair(n, fg, b)
107
+
108
+
109
+ @functools.lru_cache(maxsize=256)
110
+ def wheel(angle: float, w: int = 23, h: int = 11) -> list[list[bool]]:
111
+ """Rasterise a steering wheel turned by `angle` radians (positive = left)."""
112
+ grid = [[False] * w for _ in range(h)]
113
+ spokes = [angle + a for a in (0.0, math.pi, -math.pi / 2)] # left, right, down
114
+ for row in range(h):
115
+ for col in range(w):
116
+ x = (col - (w - 1) / 2) / ((w - 1) / 2)
117
+ y = (row - (h - 1) / 2) / ((h - 1) / 2)
118
+ r = math.hypot(x, y)
119
+ on = 0.84 <= r <= 1.02 or r <= 0.2
120
+ if not on and r < 0.86:
121
+ for s in spokes:
122
+ dx, dy = math.cos(s), -math.sin(s) # screen y grows downwards
123
+ along = x * dx + y * dy
124
+ across = abs(x * dy - y * dx)
125
+ if along > 0 and across < 0.1:
126
+ on = True
127
+ grid[row][col] = on
128
+ return grid
129
+
130
+
131
+ @dataclass
132
+ class Held:
133
+ direction: int
134
+ first: float
135
+ last: float
136
+ repeats: int = 0
137
+
138
+
139
+ class App:
140
+ def __init__(self, scr, module: JMCModule, car: Car):
141
+ self.scr, self.module, self.car = scr, module, car
142
+ cfg = load_config()
143
+ self.cfg = cfg
144
+ self.tab = 0
145
+ self.gear = cfg.get("gear", 3)
146
+ self.lock = False
147
+ self.link = 0
148
+ self.module.inverted = set(cfg.get("inverted", []))
149
+ self.cal = Calibration(**cfg.get("calibration", {}))
150
+ self.program = cfg.get("program", EXAMPLE_PROGRAM)
151
+ self.path = cfg.get("path", EXAMPLE_PATH)
152
+ self.held: dict[str, Held] = {}
153
+ self.task: asyncio.Task | None = None
154
+ self.log: list[str] = ["Hi! Use the arrow keys to drive."]
155
+ self.awaiting_invert = False
156
+ self.quit = False
157
+ self.steer_angle = 0.0 # animated wheel angle
158
+ self.connect_error = ""
159
+ self.hits: list[tuple[int, int, int, int, object]] = [] # clickable regions
160
+ self.mouse_hold: str | None = None # motor button held down with the mouse
161
+
162
+ # --- helpers -------------------------------------------------------------
163
+ def say(self, msg: str) -> None:
164
+ log.debug("say %s", msg)
165
+ self.log = (self.log + [msg])[-3:]
166
+
167
+ def persist(self) -> None:
168
+ self.cfg.update(gear=self.gear, inverted=sorted(self.module.inverted),
169
+ calibration=vars(self.cal), program=self.program, path=self.path)
170
+ save_config(self.cfg)
171
+
172
+ @property
173
+ def speed(self) -> int:
174
+ return GEARS[self.gear - 1]
175
+
176
+ @property
177
+ def running(self) -> bool:
178
+ return self.task is not None and not self.task.done()
179
+
180
+ def start(self, coro) -> None:
181
+ self.cancel()
182
+ self.task = asyncio.create_task(coro)
183
+ self.task.add_done_callback(self._finished)
184
+
185
+ def _finished(self, task: asyncio.Task) -> None:
186
+ if not task.cancelled() and task.exception():
187
+ self.say(f"Oops: {task.exception()}")
188
+ else:
189
+ self.say("All done!" if not task.cancelled() else "Stopped.")
190
+
191
+ def cancel(self) -> None:
192
+ if self.running:
193
+ self.task.cancel()
194
+
195
+ def edit(self, text: str) -> str:
196
+ """Edit text in $EDITOR, suspending curses meanwhile."""
197
+ with tempfile.NamedTemporaryFile("w+", suffix=".txt", delete=False) as f:
198
+ f.write(text)
199
+ name = f.name
200
+ curses.def_prog_mode()
201
+ curses.endwin()
202
+ try:
203
+ subprocess.call([os.environ.get("EDITOR", "nano"), name])
204
+ return Path(name).read_text()
205
+ finally:
206
+ os.unlink(name)
207
+ curses.reset_prog_mode()
208
+ self.scr.refresh()
209
+
210
+ def press(self, name: str, direction: int, now: float) -> None:
211
+ h = self.held.get(name)
212
+ if h and h.direction == direction:
213
+ h.last, h.repeats = now, h.repeats + 1
214
+ else:
215
+ self.held[name] = Held(direction, now, now)
216
+
217
+ def active(self, name: str) -> int:
218
+ h = self.held.get(name)
219
+ return h.direction if h else 0
220
+
221
+ # --- input ---------------------------------------------------------------
222
+ def select_tab(self, i: int) -> None:
223
+ self.tab = i
224
+ self.held.clear()
225
+
226
+ def mouse(self) -> None:
227
+ try:
228
+ _, mx, my, _, state = curses.getmouse()
229
+ except curses.error:
230
+ return
231
+ if state & curses.BUTTON1_RELEASED and self.mouse_hold:
232
+ self.held.pop(self.mouse_hold, None)
233
+ self.mouse_hold = None
234
+ if not state & (curses.BUTTON1_PRESSED | curses.BUTTON1_CLICKED):
235
+ return
236
+ for y, x, height, width, action in reversed(self.hits):
237
+ if y <= my < y + height and x <= mx < x + width:
238
+ if isinstance(action, tuple):
239
+ _, name, direction = action
240
+ self.press(name, direction, time.monotonic())
241
+ # a press waits for its release; a bare click acts like a short key press
242
+ self.mouse_hold = name if state & curses.BUTTON1_PRESSED else None
243
+ else:
244
+ action()
245
+ return
246
+
247
+ def key(self, k: int) -> None:
248
+ log.debug("key %r on %s", curses.keyname(k) if k >= 0 else k, TABS[self.tab])
249
+ ch = chr(k).lower() if 0 <= k < 256 else ""
250
+ raw = chr(k) if 0 <= k < 256 else ""
251
+ now = time.monotonic()
252
+ if self.awaiting_invert:
253
+ self.awaiting_invert = False
254
+ if ch in PORT_KEYS:
255
+ self.module.inverted ^= {ch}
256
+ self.say(f"Port {ch.upper()} now turns {'the other way round' if ch in self.module.inverted else 'the normal way'}.")
257
+ self.persist()
258
+ return
259
+ if k == 9: # Tab
260
+ self.tab = (self.tab + 1) % len(TABS)
261
+ self.held.clear()
262
+ elif k == curses.KEY_BTAB:
263
+ self.tab = (self.tab - 1) % len(TABS)
264
+ self.held.clear()
265
+ elif ch == "q":
266
+ self.quit = True
267
+ elif ch == " ":
268
+ self.held.clear()
269
+ self.cancel()
270
+ asyncio.create_task(self.module.stop())
271
+ self.say("STOP!")
272
+ elif ch and ch in "123":
273
+ self.gear = int(ch)
274
+ self.persist()
275
+ elif ch == "h":
276
+ self.lock = not self.lock
277
+ self.say("Hold on: motors keep going when you let go." if self.lock else "Hold off.")
278
+ elif ch == "p":
279
+ self.link = (self.link + 1) % len(LINKS)
280
+ pair = LINKS[self.link]
281
+ self.module.links = {pair[0]: pair[1]} if pair else {}
282
+ self.say("Pair off." if not pair else f"Paired: {pair[1].upper()} now does whatever {pair[0].upper()} does.")
283
+ elif ch == "r":
284
+ self.awaiting_invert = True
285
+ self.say("Reverse which port? Press a, b, c or d.")
286
+ elif TABS[self.tab] == "Car":
287
+ axis = {curses.KEY_UP: ("drive", 1), curses.KEY_DOWN: ("drive", -1),
288
+ curses.KEY_LEFT: ("steer", 1), curses.KEY_RIGHT: ("steer", -1)}.get(k)
289
+ if axis:
290
+ self.press(*axis, now)
291
+ elif TABS[self.tab] == "Ports":
292
+ for port, (fwd, rev) in PORT_KEYS.items():
293
+ if ch in (fwd, rev):
294
+ self.press(port, 1 if ch == fwd else -1, now)
295
+ elif TABS[self.tab] == "Program":
296
+ if ch == "e":
297
+ self.program = self.edit(self.program)
298
+ self.persist()
299
+ elif k in (10, 13, curses.KEY_ENTER):
300
+ try:
301
+ steps = parse_program(self.program)
302
+ except ScriptError as exc:
303
+ self.say(f"Oops: {exc}")
304
+ else:
305
+ self.say("Running your program!")
306
+ self.start(run_program(self.module, steps, self.gear, log=self.say))
307
+ elif TABS[self.tab] == "Path":
308
+ step = {"[": ("metres_per_second", -0.05), "]": ("metres_per_second", 0.05),
309
+ "{": ("degrees_per_second", -5), "}": ("degrees_per_second", 5)}.get(raw)
310
+ if ch == "e":
311
+ self.path = self.edit(self.path)
312
+ self.persist()
313
+ elif step:
314
+ setattr(self.cal, step[0], max(0.05, round(getattr(self.cal, step[0]) + step[1], 2)))
315
+ self.persist()
316
+ elif k in (10, 13, curses.KEY_ENTER):
317
+ try:
318
+ moves = parse_path(self.path)
319
+ except ScriptError as exc:
320
+ self.say(f"Oops: {exc}")
321
+ else:
322
+ self.say("Off we go!")
323
+ self.start(run_path(self.car, moves, self.cal, log=self.say))
324
+
325
+ def expire_keys(self, now: float) -> None:
326
+ for name, h in list(self.held.items()):
327
+ if name == self.mouse_hold:
328
+ continue
329
+ latched = self.lock and name != "steer" # steering always springs back
330
+ limit = REPEAT_HOLD if h.repeats else FIRST_HOLD
331
+ if not latched and now - h.last > limit:
332
+ del self.held[name]
333
+
334
+ async def apply_held(self) -> None:
335
+ """Turn held keys into motor speeds (manual tabs only)."""
336
+ if self.running or TABS[self.tab] not in ("Car", "Ports"):
337
+ return
338
+ self.expire_keys(time.monotonic())
339
+ if TABS[self.tab] == "Car":
340
+ want = {self.car.drive_port: self.active("drive") * self.speed * self.car._drive_sign,
341
+ self.car.steer_port: self.active("steer") * 255 * self.car._steer_sign}
342
+ else:
343
+ followers = set(self.module.links.values())
344
+ want = {p: self.active(p) * self.speed for p in PORT_KEYS if p not in followers}
345
+ current = self.module.speeds
346
+ if any(current[p] != s for p, s in want.items()):
347
+ await self.module.drive(**want)
348
+
349
+ # --- drawing -------------------------------------------------------------
350
+ def put(self, y: int, x: int, text: str, attr: int = 0) -> None:
351
+ h, w = self.scr.getmaxyx()
352
+ if 0 <= y < h and 0 <= x < w - 1:
353
+ try:
354
+ self.scr.addnstr(y, x, text, w - x - 1, attr)
355
+ except curses.error:
356
+ pass
357
+
358
+ def hit(self, y: int, x: int, height: int, width: int, action) -> None:
359
+ """Register a clickable region. `action` is a callable, or ("hold", name, direction)."""
360
+ self.hits.append((y, x, height, width, action))
361
+
362
+ def button(self, y: int, x: int, label: str, on: bool, width: int = 11, colour: int = GREEN,
363
+ action=None) -> None:
364
+ """A rounded button; filled when pressed."""
365
+ if action:
366
+ self.hit(y, x, 3, width, action)
367
+ inner = label.center(width - 2)
368
+ if on:
369
+ fill = curses.color_pair(ON_GREEN if colour == GREEN else colour) | curses.A_BOLD
370
+ self.put(y, x, "▗" + "▄" * (width - 2) + "▖", curses.color_pair(colour))
371
+ self.put(y + 1, x, " " + inner + " ", fill)
372
+ self.put(y + 2, x, "▝" + "▀" * (width - 2) + "▘", curses.color_pair(colour))
373
+ else:
374
+ c = curses.color_pair(colour)
375
+ self.put(y, x, "╭" + "─" * (width - 2) + "╮", c)
376
+ self.put(y + 1, x, "│", c)
377
+ self.put(y + 1, x + 1, inner, curses.color_pair(WHITE) | curses.A_BOLD)
378
+ self.put(y + 1, x + width - 1, "│", c)
379
+ self.put(y + 2, x, "╰" + "─" * (width - 2) + "╯", c)
380
+
381
+ def pill(self, y: int, x: int, text: str, attr: int, action=None) -> int:
382
+ self.put(y, x, f" {text} ", attr)
383
+ if action:
384
+ self.hit(y, x, 1, len(text) + 2, action)
385
+ return x + len(text) + 3
386
+
387
+ def draw_header(self, w: int) -> None:
388
+ g = curses.color_pair(GREEN) | curses.A_BOLD
389
+ x = 2
390
+ for colour in (RED, YELLOW, GREEN, BLUE):
391
+ self.put(0, x, "●", curses.color_pair(colour) | curses.A_BOLD)
392
+ x += 2
393
+ self.put(0, x + 1, "BRICK REMOTE", g)
394
+ x = 26
395
+ for i, (name, icon) in enumerate(zip(TABS, TAB_ICONS)):
396
+ attr = curses.color_pair(ON_GREEN) | curses.A_BOLD if i == self.tab else curses.color_pair(DIM)
397
+ x = self.pill(0, x, f"{icon} {name}", attr, action=lambda i=i: self.select_tab(i))
398
+ # battery + connection, right aligned
399
+ bat = self.module.battery
400
+ level = 0 if bat is None else round(bat / 20)
401
+ colour = GREEN if (bat or 0) > 50 else YELLOW if (bat or 0) > 20 else RED
402
+ text = f"{'●' if self.module.is_connected else '○'} ▕{'█' * level}{'░' * (5 - level)}▏{bat if bat is not None else '?'}%"
403
+ self.put(0, max(x + 1, w - len(text) - 2), text, curses.color_pair(colour) | curses.A_BOLD)
404
+
405
+ def draw_gears(self, y: int, x: int) -> None:
406
+ self.put(y, x + 1, "SPEED", curses.color_pair(ORANGE) | curses.A_BOLD)
407
+ for i, g in enumerate((1, 2, 3)):
408
+ on = g == self.gear
409
+ attr = curses.color_pair(ON_GREEN) | curses.A_BOLD if on else curses.color_pair(DIM)
410
+ for r, line in enumerate(BIG_DIGITS[g]):
411
+ self.put(y + 1 + i * 6 + r, x, f" {line} ", attr)
412
+ self.hit(y + 1 + i * 6, x, 5, 6, lambda g=g: self.key(ord(str(g))))
413
+ self.put(y + 19, x, "keys 1 2 3", curses.color_pair(DIM))
414
+ self.put(y + 20, x, "or click!", curses.color_pair(DIM))
415
+
416
+ def draw_meter(self, y: int, x: int, label: str, speed: int, width: int = 16) -> None:
417
+ n = round(abs(speed) / 255 * width)
418
+ colour = GREEN if speed >= 0 else ORANGE
419
+ self.put(y, x, f"{label} ", curses.color_pair(WHITE) | curses.A_BOLD)
420
+ self.put(y, x + len(label) + 1, "▕" + "█" * n, curses.color_pair(colour) | curses.A_BOLD)
421
+ self.put(y, x + len(label) + 2 + n, "░" * (width - n) + "▏", curses.color_pair(DIM))
422
+
423
+ def draw_car(self, y: int, w: int, h: int) -> None:
424
+ drive, steer = self.active("drive"), self.active("steer")
425
+ # left: forward / back
426
+ self.button(y + 2, 3, "▲", drive > 0, 13, action=("hold", "drive", 1))
427
+ self.put(y + 5, 5, "FORWARD", curses.color_pair(GREEN) | curses.A_BOLD)
428
+ self.button(y + 8, 3, "▼", drive < 0, 13, ORANGE, action=("hold", "drive", -1))
429
+ self.put(y + 11, 6, "BACK", curses.color_pair(ORANGE) | curses.A_BOLD)
430
+ self.draw_meter(y + 14, 3, "MOTOR", self.module.speeds[self.car.drive_port], 8)
431
+ # right: left / right, with the gears just inside them
432
+ rx = w - 30
433
+ self.button(y + 5, rx, "◀", steer > 0, 13, action=("hold", "steer", 1))
434
+ self.button(y + 5, rx + 14, "▶", steer < 0, 13, action=("hold", "steer", -1))
435
+ self.put(y + 8, rx + 3, "LEFT", curses.color_pair(GREEN) | curses.A_BOLD)
436
+ self.put(y + 8, rx + 18, "RIGHT", curses.color_pair(GREEN) | curses.A_BOLD)
437
+ self.put(y + 10, rx, "hold the arrow keys", curses.color_pair(DIM))
438
+ self.put(y + 11, rx, "or press the buttons", curses.color_pair(DIM))
439
+ gx = rx - 10
440
+ self.draw_gears(y - 1, gx)
441
+ # centre: the steering wheel fills the space left over, eased towards the target angle
442
+ left, right = 19, gx - 2
443
+ wh = max(9, h - 9 - y) # rows available above the label
444
+ ww = min(right - left, round(wh * 2.2)) # terminal cells are about twice as tall as wide
445
+ wh = min(wh, round(ww / 2.2))
446
+ wh -= 1 - wh % 2 # odd sizes keep the hub centred
447
+ ww -= 1 - ww % 2
448
+ self.steer_angle += (steer * 0.6 - self.steer_angle) * 0.35
449
+ grid = wheel(round(self.steer_angle, 2), ww, wh)
450
+ wx = left + (right - left - ww) // 2
451
+ colour = curses.color_pair(GREEN) | curses.A_BOLD
452
+ for r, row in enumerate(grid):
453
+ self.put(y + r, wx, "".join("█" if c else " " for c in row), colour)
454
+ label = {1: "◀ LEFT", -1: "RIGHT ▶", 0: "STRAIGHT"}[steer]
455
+ self.put(y + wh + 1, wx + (ww - len(label)) // 2, label, curses.color_pair(WHITE) | curses.A_BOLD)
456
+
457
+ def draw_ports(self, y: int, w: int) -> None:
458
+ half = (w - 24) // 2
459
+ speeds = self.module.speeds
460
+ followers = set(self.module.links.values())
461
+ for i, (vert, horiz) in enumerate((("a", "b"), ("c", "d"))):
462
+ x = 3 + i * (half + 24)
463
+ (vf, vr), (hf, hr) = PORT_KEYS[vert], PORT_KEYS[horiz]
464
+ V, H = vert.upper(), horiz.upper()
465
+ self.button(y, x + 9, f"{V} ▲ {vf}", self.active(vert) > 0, 11, action=("hold", vert, 1))
466
+ self.button(y + 4, x, f"{H} ◀ {hr}", self.active(horiz) < 0, 11, ORANGE, action=("hold", horiz, -1))
467
+ self.button(y + 4, x + 18, f"{H} ▶ {hf}", self.active(horiz) > 0, 11, action=("hold", horiz, 1))
468
+ self.button(y + 8, x + 9, f"{V} ▼ {vr}", self.active(vert) < 0, 11, ORANGE, action=("hold", vert, -1))
469
+ self.put(y + 5, x + 13, "◎", curses.color_pair(GREEN) | curses.A_BOLD)
470
+ for j, p in enumerate((vert, horiz)):
471
+ tag = " (paired)" if p in followers else (" (reversed)" if p in self.module.inverted else "")
472
+ self.draw_meter(y + 12 + j, x, p.upper(), speeds[p], 12)
473
+ self.put(y + 12 + j, x + 17, tag, curses.color_pair(DIM))
474
+ self.draw_gears(y - 2, w // 2 - 4)
475
+
476
+ def draw_script(self, y: int, w: int, h: int, title: str, text: str, extra: list[str]) -> None:
477
+ box_w = min(w - 6, 60)
478
+ lines = text.splitlines()
479
+ rows = min(len(lines), h - y - 9)
480
+ c = curses.color_pair(GREEN)
481
+ self.put(y, 3, "╭─ " + title + " " + "─" * (box_w - len(title) - 5) + "╮", c)
482
+ for i in range(rows):
483
+ line = lines[i]
484
+ attr = curses.color_pair(DIM) if line.strip().startswith("#") else curses.color_pair(WHITE) | curses.A_BOLD
485
+ self.put(y + 1 + i, 3, "│", c)
486
+ self.put(y + 1 + i, 5, f"{i + 1:2} ", curses.color_pair(DIM))
487
+ self.put(y + 1 + i, 8, line[:box_w - 7], attr)
488
+ self.put(y + 1 + i, 3 + box_w - 1, "│", c)
489
+ self.put(y + 1 + rows, 3, "╰" + "─" * (box_w - 2) + "╯", c)
490
+ bx = box_w + 6
491
+ if bx + 16 < w:
492
+ self.button(y, bx, "▶ RUN ⏎", self.running, 14, action=lambda: self.key(10))
493
+ self.button(y + 3, bx, "✎ EDIT e", False, 14, ORANGE, action=lambda: self.key(ord("e")))
494
+ self.button(y + 6, bx, "■ STOP ␣", False, 14, RED, action=lambda: self.key(32))
495
+ for i, line in enumerate(extra):
496
+ self.put(y + 10 + i, bx, line, curses.color_pair(WHITE))
497
+
498
+ def draw_footer(self, h: int, w: int) -> None:
499
+ for i, line in enumerate(self.log):
500
+ self.put(h - 6 + i, 3, ("» " if i == len(self.log) - 1 else " ") + line,
501
+ curses.color_pair(YELLOW) | curses.A_BOLD if i == len(self.log) - 1 else curses.color_pair(DIM))
502
+ y = h - 3
503
+ x = 2
504
+ link = LINKS[self.link]
505
+ status = [("HOLD " + ("ON" if self.lock else "off"), ON_YELLOW if self.lock else DIM),
506
+ ("PAIR " + ("+".join(link).upper() if link else "off"), ON_YELLOW if link else DIM),
507
+ ("REVERSED " + (",".join(sorted(self.module.inverted)).upper() or "-"),
508
+ ON_YELLOW if self.module.inverted else DIM)]
509
+ for (text, colour), k in zip(status, "hpr"):
510
+ x = self.pill(y, x, text, curses.color_pair(colour) | curses.A_BOLD, action=lambda k=k: self.key(ord(k)))
511
+ bar = curses.color_pair(ON_GREEN) | curses.A_BOLD
512
+ self.put(h - 1, 0, " " * (w - 1), bar)
513
+ keys = [("tab", "screen"), ("1-3", "speed"), ("h", "hold"), ("p", "pair"), ("r", "reverse"),
514
+ ("space", "STOP"), ("q", "quit")]
515
+ x = 1
516
+ codes = {"tab": 9, "1-3": None, "space": 32}
517
+ for key, what in keys:
518
+ code = codes.get(key, ord(key[0]))
519
+ if code is not None:
520
+ self.hit(h - 1, x, 1, len(key) + len(what) + 5, lambda c=code: self.key(c))
521
+ self.put(h - 1, x, f" {key} ", curses.color_pair(ON_YELLOW) | curses.A_BOLD)
522
+ x += len(key) + 2
523
+ self.put(h - 1, x, f" {what} ", bar)
524
+ x += len(what) + 3
525
+
526
+ def draw_searching(self, h: int, w: int) -> None:
527
+ """Splash screen while no module is connected."""
528
+ t = time.monotonic()
529
+ brick = [" ▄▄▄ ▄▄▄ ▄▄▄ ▄▄▄ ",
530
+ " █████ █████ █████ █████ ",
531
+ "▄▄█████▄▄█████▄▄█████▄▄█████▄▄",
532
+ "██████████████████████████████",
533
+ "██████████████████████████████",
534
+ "██████████████████████████████"]
535
+ studs = (RED, YELLOW, GREEN, BLUE)
536
+ y0, x0 = max(1, h // 2 - 8), (w - len(brick[0])) // 2
537
+ hop = int(t * 3) % 4 # one stud bounces at a time
538
+ for r, line in enumerate(brick):
539
+ for i in range(4):
540
+ seg = line[i * 7:i * 7 + 7] if r < 2 else None
541
+ if seg is not None:
542
+ self.put(y0 + r - (1 if i == hop else 0), x0 + i * 7, seg,
543
+ curses.color_pair(studs[i]) | curses.A_BOLD)
544
+ if r >= 2:
545
+ self.put(y0 + r, x0, line, curses.color_pair(GREEN) | curses.A_BOLD)
546
+ dots = "." * (int(t * 2) % 4)
547
+ lines = [("Looking for your brick" + dots, YELLOW),
548
+ ("", WHITE),
549
+ ("Turn it on with the power button.", WHITE),
550
+ ("The light blinks while it waits for us.", DIM)]
551
+ if self.connect_error:
552
+ lines.append((self.connect_error, DIM))
553
+ for i, (text, colour) in enumerate(lines):
554
+ self.put(y0 + 8 + i, (w - len(text)) // 2, text, curses.color_pair(colour) | curses.A_BOLD)
555
+ bar = curses.color_pair(ON_GREEN) | curses.A_BOLD
556
+ self.put(h - 1, 0, " " * (w - 1), bar)
557
+ self.put(h - 1, 1, " q ", curses.color_pair(ON_YELLOW) | curses.A_BOLD)
558
+ self.hit(h - 1, 1, 1, 8, lambda: self.key(ord("q")))
559
+ self.put(h - 1, 4, " quit", bar)
560
+
561
+ async def keep_connected(self) -> None:
562
+ """Connect, and reconnect whenever the module goes away (e.g. it switched itself off)."""
563
+ while not self.quit:
564
+ if not self.module.is_connected:
565
+ self.held.clear()
566
+ self.cancel()
567
+ try:
568
+ await self.module.disconnect() # clean up a dropped connection
569
+ await self.module.connect()
570
+ self.connect_error = ""
571
+ self.say("Connected! Let's go!")
572
+ except Exception as exc:
573
+ self.connect_error = "" if isinstance(exc, ConnectionError) else f"({exc})"
574
+ await asyncio.sleep(1)
575
+ continue
576
+ await asyncio.sleep(0.5)
577
+
578
+ def draw(self) -> None:
579
+ self.scr.erase()
580
+ self.hits = []
581
+ h, w = self.scr.getmaxyx()
582
+ if h < 26 or w < 80:
583
+ self.put(0, 0, f"Make the window bigger please! ({w}x{h}, need 80x26)",
584
+ curses.color_pair(YELLOW) | curses.A_BOLD)
585
+ self.scr.refresh()
586
+ return
587
+ if not self.module.is_connected:
588
+ self.draw_searching(h, w)
589
+ self.scr.refresh()
590
+ return
591
+ self.draw_header(w)
592
+ tab = TABS[self.tab]
593
+ if tab == "Car":
594
+ self.draw_car(2, w, h)
595
+ elif tab == "Ports":
596
+ self.draw_ports(4, w)
597
+ elif tab == "Program":
598
+ self.draw_script(2, w, h, "MY PROGRAM", self.program, [
599
+ "gear 2 a + 1.5", "repeat 3 … end", "wait 1 stop"])
600
+ else:
601
+ self.draw_script(2, w, h, "MY PATH", self.path, [
602
+ f"speed {self.cal.metres_per_second:.2f} m/s [ ]",
603
+ f"turn {self.cal.degrees_per_second:.0f} °/s {{ }}",
604
+ "forward 1 left 90"])
605
+ self.draw_footer(h, w)
606
+ self.scr.refresh()
607
+
608
+ async def run(self) -> None:
609
+ self.scr.nodelay(True)
610
+ self.scr.keypad(True)
611
+ curses.mousemask(curses.ALL_MOUSE_EVENTS)
612
+ curses.mouseinterval(0) # report press and release separately, without click delay
613
+ curses.curs_set(0)
614
+ init_colours()
615
+ connector = asyncio.create_task(self.keep_connected())
616
+ try:
617
+ while not self.quit:
618
+ while (k := self.scr.getch()) != -1:
619
+ if k == curses.KEY_MOUSE:
620
+ self.mouse()
621
+ elif self.module.is_connected or k in (ord("q"), ord("Q")):
622
+ self.key(k)
623
+ if self.module.is_connected:
624
+ try:
625
+ await self.apply_held()
626
+ except Exception as exc:
627
+ self.say(f"Oops, send failed: {exc}")
628
+ self.draw()
629
+ await asyncio.sleep(0.03)
630
+ finally:
631
+ self.cancel()
632
+ connector.cancel()
633
+ await self.shutdown()
634
+
635
+ async def shutdown(self) -> None:
636
+ """Stop the motors and exit at once; BlueZ finishes the disconnect in the background."""
637
+ if not self.module.is_connected:
638
+ return
639
+ try:
640
+ await asyncio.wait_for(self.module.stop(), 0.5)
641
+ except Exception:
642
+ pass
643
+ command = self.module.detach()
644
+ if command:
645
+ subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
646
+ stderr=subprocess.DEVNULL, start_new_session=True)
647
+ return
648
+ try:
649
+ await asyncio.wait_for(self.module.disconnect(), 5)
650
+ except Exception:
651
+ pass
652
+
653
+
654
+ async def _main(scr, address, drive_port, steer_port) -> None:
655
+ module = JMCModule(address, timeout=5.0)
656
+ await App(scr, module, Car(module, drive_port=drive_port, steer_port=steer_port)).run()
657
+
658
+
659
+ def run(address: str | None = None, drive_port: str = "a", steer_port: str = "d") -> None:
660
+ os.environ.setdefault("ESCDELAY", "25")
661
+ curses.wrapper(lambda scr: asyncio.run(_main(scr, address, drive_port, steer_port)))