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/__init__.py +8 -0
- brickremote/car.py +40 -0
- brickremote/cli.py +131 -0
- brickremote/module.py +208 -0
- brickremote/protocol.py +90 -0
- brickremote/script.py +159 -0
- brickremote/tui.py +661 -0
- brickremote/web/index.html +611 -0
- brickremote/web.py +243 -0
- brickremote-0.3.0.dist-info/METADATA +196 -0
- brickremote-0.3.0.dist-info/RECORD +14 -0
- brickremote-0.3.0.dist-info/WHEEL +4 -0
- brickremote-0.3.0.dist-info/entry_points.txt +2 -0
- brickremote-0.3.0.dist-info/licenses/LICENSE +21 -0
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)))
|