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.
@@ -0,0 +1,8 @@
1
+ """Control JG_JMC-xxxx Bluetooth LE motor modules (Lego-compatible, "XP Block" app)."""
2
+
3
+ from .car import Car
4
+ from .module import JMCModule, discover, find
5
+ from .protocol import GEARS, Status, motor_frame, parse_status, stop_frame
6
+
7
+ __all__ = ["GEARS", "Car", "JMCModule", "Status", "discover", "find", "motor_frame", "parse_status", "stop_frame"]
8
+ __version__ = "0.3.0"
brickremote/car.py ADDED
@@ -0,0 +1,40 @@
1
+ """Convenience wrapper for a car with one drive motor and one steering motor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .module import JMCModule
6
+
7
+
8
+ class Car:
9
+ """A car built on a JMCModule.
10
+
11
+ Defaults match the stock car: drive motor on port A (positive = forward),
12
+ steering on port D (positive = left, self-centring when stopped).
13
+ """
14
+
15
+ def __init__(self, module: JMCModule, *, drive_port: str = "a", steer_port: str = "d",
16
+ invert_drive: bool = False, invert_steer: bool = False):
17
+ self.module = module
18
+ self.drive_port, self.steer_port = drive_port, steer_port
19
+ self._drive_sign = -1 if invert_drive else 1
20
+ self._steer_sign = -1 if invert_steer else 1
21
+
22
+ async def drive(self, speed: int, steer: int = 0) -> None:
23
+ """speed: -255..255 (positive = forward); steer: -255..255 (positive = left)."""
24
+ await self.module.drive(**{self.drive_port: self._drive_sign * speed,
25
+ self.steer_port: self._steer_sign * steer})
26
+
27
+ async def forward(self, speed: int = 255) -> None:
28
+ await self.drive(speed)
29
+
30
+ async def backward(self, speed: int = 255) -> None:
31
+ await self.drive(-speed)
32
+
33
+ async def left(self, speed: int = 255, steer: int = 255) -> None:
34
+ await self.drive(speed, steer)
35
+
36
+ async def right(self, speed: int = 255, steer: int = 255) -> None:
37
+ await self.drive(speed, -steer)
38
+
39
+ async def stop(self) -> None:
40
+ await self.module.stop()
brickremote/cli.py ADDED
@@ -0,0 +1,131 @@
1
+ """Control JG_JMC motor modules: `brickremote` (remote control), `scan`, `run`, `program`, `path`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import logging
8
+ import sys
9
+
10
+ from . import protocol, script
11
+ from .car import Car
12
+ from .module import JMCModule, discover
13
+
14
+
15
+ async def _scan(args) -> None:
16
+ devices = await discover(args.timeout, settle=None if args.full else 2.0,
17
+ on_found=lambda dev: print(f"{dev.address} {(dev.name or '').strip()}", flush=True))
18
+ if not devices:
19
+ print("no modules found - is it switched on, and not connected to anything else?")
20
+
21
+
22
+ async def _wait_battery(module: JMCModule) -> None:
23
+ for _ in range(20): # the first status report takes a moment
24
+ if module.battery is not None:
25
+ print(f"battery: {module.battery}%")
26
+ return
27
+ await asyncio.sleep(0.1)
28
+
29
+
30
+ def _connect(args) -> JMCModule:
31
+ return JMCModule(args.address, slot=args.slot, six_ports=args.six_ports, inverted=args.invert)
32
+
33
+
34
+ async def _run(args) -> None:
35
+ speeds = {}
36
+ for item in args.speeds:
37
+ port, _, value = item.partition("=")
38
+ if port.lower() not in protocol.PORTS or not value:
39
+ raise SystemExit(f"bad argument {item!r}; expected e.g. a=255 or d=-120")
40
+ speeds[port.lower()] = int(value)
41
+ async with _connect(args) as module:
42
+ await _wait_battery(module)
43
+ await module.run(args.time, **speeds)
44
+
45
+
46
+ async def _program(args) -> None:
47
+ try:
48
+ steps = script.parse_program(args.file.read())
49
+ except script.ScriptError as exc:
50
+ raise SystemExit(f"error: {exc}")
51
+ async with _connect(args) as module:
52
+ await _wait_battery(module)
53
+ await script.run_program(module, steps, gear=args.gear)
54
+
55
+
56
+ async def _path(args) -> None:
57
+ try:
58
+ moves = script.parse_path(args.file.read())
59
+ except script.ScriptError as exc:
60
+ raise SystemExit(f"error: {exc}")
61
+ cal = script.Calibration(args.mps, args.dps)
62
+ async with _connect(args) as module:
63
+ await _wait_battery(module)
64
+ await script.run_path(Car(module, drive_port=args.drive, steer_port=args.steer), moves, cal)
65
+
66
+
67
+ def main() -> None:
68
+ parser = argparse.ArgumentParser(prog="brickremote", description=__doc__)
69
+ parser.add_argument("--address", help="module address (default: first one found)")
70
+ parser.add_argument("--slot", type=int, default=1, help="module slot 1-3 (default 1)")
71
+ parser.add_argument("--six-ports", action="store_true", help="module has ports E and F")
72
+ parser.add_argument("--invert", default="", metavar="PORTS", help="ports to reverse, e.g. --invert ad")
73
+ parser.add_argument("--drive", default="a", help="car drive port (default a)")
74
+ parser.add_argument("--steer", default="d", help="car steering port (default d)")
75
+ parser.add_argument("--log", metavar="FILE", help="write a debug log (keys and commands sent) to FILE")
76
+ sub = parser.add_subparsers(dest="command")
77
+
78
+ scan = sub.add_parser("scan", help="list nearby modules")
79
+ scan.add_argument("--timeout", type=float, default=10.0, help="give up after this many seconds (default 10)")
80
+ scan.add_argument("--full", action="store_true", help="always scan the whole timeout, to find every module")
81
+ scan.set_defaults(func=_scan)
82
+
83
+ run = sub.add_parser("run", help="run ports for a while, e.g. `run a=255 d=-255 --time 2`")
84
+ run.add_argument("speeds", nargs="+", metavar="PORT=SPEED", help="speed -255..255 per port (a-f)")
85
+ run.add_argument("--time", type=float, default=2.0, help="seconds to run (default 2)")
86
+ run.set_defaults(func=_run)
87
+
88
+ prog = sub.add_parser("program", help="run a program script ('-' for stdin)",
89
+ description=script.__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
90
+ prog.add_argument("file", type=argparse.FileType())
91
+ prog.add_argument("--gear", type=int, default=3, choices=(1, 2, 3), help="initial gear (default 3)")
92
+ prog.set_defaults(func=_program)
93
+
94
+ path = sub.add_parser("path", help="drive a path script ('-' for stdin)",
95
+ description=script.__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
96
+ path.add_argument("file", type=argparse.FileType())
97
+ path.add_argument("--mps", type=float, default=0.5, help="metres per second at full speed (default 0.5)")
98
+ path.add_argument("--dps", type=float, default=90.0, help="degrees per second when turning (default 90)")
99
+ path.set_defaults(func=_path)
100
+
101
+ web = sub.add_parser("web", help="start the web remote control (open it in any browser on the network)")
102
+ web.add_argument("--port", type=int, default=8080, help="port to listen on (default 8080)")
103
+ web.add_argument("--host", default="0.0.0.0", help="address to listen on (default: all)")
104
+ web.set_defaults(func=None)
105
+
106
+ args = parser.parse_args()
107
+ if args.log:
108
+ logging.basicConfig(filename=args.log, level=logging.DEBUG,
109
+ format="%(asctime)s %(name)s %(message)s")
110
+ logging.getLogger("bleak").setLevel(logging.INFO)
111
+ if args.command is None:
112
+ from . import tui
113
+ tui.run(args.address, args.drive, args.steer)
114
+ return
115
+ if args.command == "web":
116
+ try:
117
+ from . import web as webapp
118
+ except ImportError:
119
+ sys.exit("The web remote needs aiohttp: pip install 'brickremote[web]'")
120
+ webapp.run(args.address, args.drive, args.steer, args.host, args.port)
121
+ return
122
+ try:
123
+ asyncio.run(args.func(args))
124
+ except ConnectionError as exc:
125
+ sys.exit(f"error: {exc}")
126
+ except KeyboardInterrupt:
127
+ pass
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
brickremote/module.py ADDED
@@ -0,0 +1,208 @@
1
+ """Async client for a JG_JMC-xxxx motor module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import shutil
9
+ from collections.abc import Callable, Iterable
10
+
11
+ from bleak import BleakClient, BleakScanner
12
+ from bleak.backends.device import BLEDevice
13
+
14
+ from . import protocol
15
+ from .protocol import Status
16
+
17
+ log = logging.getLogger(__name__)
18
+
19
+
20
+ async def discover(timeout: float = 10.0, settle: float | None = 2.0,
21
+ on_found: Callable[[BLEDevice], None] | None = None) -> list[BLEDevice]:
22
+ """Scan for modules.
23
+
24
+ Stops `settle` seconds after the last new module appeared (None = always scan the full
25
+ `timeout`), or after `timeout` seconds. `on_found` is called as each module is seen.
26
+ """
27
+ found: dict[str, BLEDevice] = {}
28
+ last_new = asyncio.Event()
29
+
30
+ def seen(dev: BLEDevice, adv) -> None:
31
+ if dev.address not in found and (adv.local_name or dev.name or "").startswith(protocol.NAME_PREFIX):
32
+ found[dev.address] = dev
33
+ last_new.set()
34
+ if on_found:
35
+ on_found(dev)
36
+
37
+ async with BleakScanner(seen):
38
+ deadline = asyncio.get_running_loop().time() + timeout
39
+ while (remaining := deadline - asyncio.get_running_loop().time()) > 0:
40
+ wait = remaining if settle is None or not found else min(settle, remaining)
41
+ last_new.clear()
42
+ try:
43
+ await asyncio.wait_for(last_new.wait(), wait)
44
+ except asyncio.TimeoutError:
45
+ if found and settle is not None:
46
+ break
47
+ return list(found.values())
48
+
49
+
50
+ async def find(address: str | None = None, timeout: float = 10.0) -> BLEDevice | None:
51
+ """Find one module, by address if given, otherwise the first one advertising."""
52
+ if address:
53
+ return await BleakScanner.find_device_by_address(address, timeout=timeout)
54
+ return await BleakScanner.find_device_by_filter(
55
+ lambda dev, adv: (adv.local_name or dev.name or "").startswith(protocol.NAME_PREFIX),
56
+ timeout=timeout)
57
+
58
+
59
+ class JMCModule:
60
+ """A connected motor module.
61
+
62
+ The current motor state is resent every `resend_interval` seconds while
63
+ connected, which keeps the module awake and recovers from dropped writes.
64
+
65
+ async with JMCModule() as m:
66
+ await m.drive(a=255)
67
+ await asyncio.sleep(2)
68
+ await m.stop()
69
+
70
+ `inverted` flips the direction of the given ports (like the app's wrench
71
+ screen); `links` makes ports follow others, e.g. {"a": "c"} drives C
72
+ whenever A is driven (the app's A+C button).
73
+ """
74
+
75
+ def __init__(self, device: BLEDevice | str | None = None, *, slot: int = 1,
76
+ six_ports: bool = False, resend_interval: float | None = 0.2,
77
+ timeout: float = 20.0, inverted: Iterable[str] = (),
78
+ links: dict[str, str] | None = None):
79
+ self._device = device
80
+ self.slot = slot
81
+ self.six_ports = six_ports
82
+ self.resend_interval = resend_interval
83
+ self.timeout = timeout
84
+ self.inverted = set(inverted)
85
+ self.links = dict(links or {})
86
+ self.status: Status | None = None
87
+ self.on_status: Callable[[Status], None] | None = None
88
+ self._client: BleakClient | None = None
89
+ self._speeds = dict.fromkeys(protocol.PORTS, 0)
90
+ self._resender: asyncio.Task | None = None
91
+ self._lock = asyncio.Lock()
92
+ self._last_frame = b""
93
+
94
+ @property
95
+ def is_connected(self) -> bool:
96
+ return self._client is not None and self._client.is_connected
97
+
98
+ @property
99
+ def address(self) -> str | None:
100
+ """Address of the connected module."""
101
+ return self._client.address if self._client else None
102
+
103
+ def detach(self) -> list[str] | None:
104
+ """Forget the connection without waiting for it to close, and return a command that closes it.
105
+
106
+ For apps that want to exit at once: run the returned command in the background. Only
107
+ available on Linux (BlueZ); returns None elsewhere, then use `disconnect()` instead.
108
+ """
109
+ path = getattr(getattr(self._client, "_backend", None), "_device_path", None)
110
+ if not path or not shutil.which("busctl"):
111
+ return None
112
+ if self._resender:
113
+ self._resender.cancel()
114
+ self._resender = None
115
+ self._client = None
116
+ return ["busctl", "call", "org.bluez", path, "org.bluez.Device1", "Disconnect"]
117
+
118
+ @property
119
+ def battery(self) -> int | None:
120
+ return self.status.battery if self.status else None
121
+
122
+ async def connect(self) -> None:
123
+ device = self._device
124
+ if device is None or isinstance(device, str):
125
+ device = await find(device, timeout=self.timeout)
126
+ if device is None:
127
+ raise ConnectionError("no JG_JMC module found; is it switched on and not connected elsewhere?")
128
+ self._client = BleakClient(device, timeout=self.timeout)
129
+ await self._client.connect()
130
+ await self._client.start_notify(protocol.NOTIFY_UUID, self._handle_notify)
131
+ if self.resend_interval:
132
+ self._resender = asyncio.create_task(self._resend_loop())
133
+ log.info("connected to %s", device)
134
+
135
+ async def disconnect(self) -> None:
136
+ if self._resender:
137
+ self._resender.cancel()
138
+ with contextlib.suppress(asyncio.CancelledError):
139
+ await self._resender
140
+ self._resender = None
141
+ if self._client:
142
+ if self._client.is_connected:
143
+ with contextlib.suppress(Exception):
144
+ await self.stop()
145
+ await self._client.disconnect()
146
+ self._client = None
147
+
148
+ async def __aenter__(self) -> JMCModule:
149
+ await self.connect()
150
+ return self
151
+
152
+ async def __aexit__(self, *exc) -> None:
153
+ await self.disconnect()
154
+
155
+ async def drive(self, **speeds: int) -> None:
156
+ """Set port speeds, e.g. drive(a=255, d=-120). Ports not given keep their speed."""
157
+ for port, speed in speeds.items():
158
+ if port not in self._speeds:
159
+ raise ValueError(f"unknown port {port!r}; use one of {', '.join(protocol.PORTS)}")
160
+ self._speeds[port] = speed
161
+ if port in self.links:
162
+ self._speeds[self.links[port]] = speed
163
+ await self._send()
164
+
165
+ async def run(self, seconds: float, **speeds: int) -> None:
166
+ """Drive the given ports for `seconds`, then stop them."""
167
+ await self.drive(**speeds)
168
+ try:
169
+ await asyncio.sleep(seconds)
170
+ finally:
171
+ await self.drive(**dict.fromkeys(speeds, 0))
172
+
173
+ @property
174
+ def speeds(self) -> dict[str, int]:
175
+ """Current speed per port, as last set."""
176
+ return dict(self._speeds)
177
+
178
+ async def stop(self) -> None:
179
+ """Stop every port."""
180
+ self._speeds = dict.fromkeys(protocol.PORTS, 0)
181
+ await self._send()
182
+
183
+ async def _send(self) -> None:
184
+ if not self.is_connected:
185
+ raise ConnectionError("not connected")
186
+ speeds = {p: -s if p in self.inverted else s for p, s in self._speeds.items()}
187
+ frame = protocol.motor_frame(**speeds, module=self.slot, six_ports=self.six_ports)
188
+ if frame != self._last_frame:
189
+ log.debug("send %s", frame.hex(" "))
190
+ self._last_frame = frame
191
+ async with self._lock:
192
+ # The module ignores write-without-response.
193
+ await self._client.write_gatt_char(protocol.WRITE_UUID, frame, response=True)
194
+
195
+ async def _resend_loop(self) -> None:
196
+ while True:
197
+ await asyncio.sleep(self.resend_interval)
198
+ try:
199
+ await self._send()
200
+ except Exception as exc: # keep resending; disconnects surface on the next user call
201
+ log.debug("resend failed: %s", exc)
202
+
203
+ def _handle_notify(self, _sender, data: bytearray) -> None:
204
+ status = protocol.parse_status(bytes(data))
205
+ if status:
206
+ self.status = status
207
+ if self.on_status:
208
+ self.on_status(status)
@@ -0,0 +1,90 @@
1
+ """Wire format of JG_JMC-xxxx motor modules.
2
+
3
+ Commands are written (with response) to characteristic FFF2:
4
+
5
+ 5A 6B 02 00 LEN dirAB speedAB dirCD speedCD module [dirEF speedE speedF] SUM
6
+
7
+ LEN payload length: 5 for 4-port modules, 8 for 6-port modules
8
+ dirAB A: 1 = forward, 2 = reverse; B: +4 = forward, +8 = reverse
9
+ dirCD same for C and D
10
+ speed 0..255, shared by both ports of a pair
11
+ module 1..3, the module slot (the official app can drive three at once)
12
+ SUM sum of all previous bytes, modulo 256
13
+
14
+ The module reports on FFF1:
15
+
16
+ 5A 6B 01 00 01 battery% SUM
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass
22
+
23
+ SERVICE_UUID = "0000fff0-0000-1000-8000-00805f9b34fb"
24
+ WRITE_UUID = "0000fff2-0000-1000-8000-00805f9b34fb"
25
+ NOTIFY_UUID = "0000fff1-0000-1000-8000-00805f9b34fb"
26
+ NAME_PREFIX = "JG_JMC-"
27
+
28
+ HEADER = 0x5A
29
+ FRAME_TYPE = 0x6B
30
+ CMD_MOTORS = 0x02
31
+ CMD_STATUS = 0x01
32
+
33
+ MAX_SPEED = 255
34
+ GEARS = (60, 120, 255) # the official app's gears 1, 2 and 3
35
+ PORTS = ("a", "b", "c", "d", "e", "f")
36
+
37
+
38
+ def checksum(data: bytes | list[int]) -> int:
39
+ return sum(data) & 0xFF
40
+
41
+
42
+ def _clamp(speed: int) -> int:
43
+ return max(-MAX_SPEED, min(MAX_SPEED, int(speed)))
44
+
45
+
46
+ def _pair(first: int, second: int) -> tuple[int, int]:
47
+ """Direction bits and shared speed for two ports."""
48
+ first, second = _clamp(first), _clamp(second)
49
+ dirs = (1 if first > 0 else 2 if first < 0 else 0) | (4 if second > 0 else 8 if second < 0 else 0)
50
+ return dirs, max(abs(first), abs(second))
51
+
52
+
53
+ def motor_frame(a: int = 0, b: int = 0, c: int = 0, d: int = 0, e: int = 0, f: int = 0,
54
+ *, module: int = 1, six_ports: bool = False) -> bytes:
55
+ """Build a motor command. Speeds are -255..255, negative means reverse.
56
+
57
+ Both ports of a pair (A/B, C/D, E/F) share one speed byte, so when both
58
+ run, the faster of the two is used.
59
+ """
60
+ if not 1 <= module <= 3:
61
+ raise ValueError("module must be 1, 2 or 3")
62
+ if not six_ports and (e or f):
63
+ raise ValueError("ports E and F need six_ports=True")
64
+ body = [HEADER, FRAME_TYPE, CMD_MOTORS, 0x00, 8 if six_ports else 5]
65
+ body += _pair(a, b)
66
+ body += _pair(c, d)
67
+ body.append(module)
68
+ if six_ports:
69
+ dirs, _ = _pair(e, f)
70
+ body += [dirs, abs(_clamp(e)), abs(_clamp(f))]
71
+ body.append(checksum(body))
72
+ return bytes(body)
73
+
74
+
75
+ def stop_frame(*, module: int = 1, six_ports: bool = False) -> bytes:
76
+ return motor_frame(module=module, six_ports=six_ports)
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Status:
81
+ battery: int # percent
82
+
83
+
84
+ def parse_status(data: bytes) -> Status | None:
85
+ """Parse a FFF1 notification; returns None for anything unrecognised."""
86
+ if len(data) < 7 or data[0] != HEADER or data[1] != FRAME_TYPE or data[2] != CMD_STATUS:
87
+ return None
88
+ if checksum(data[:-1]) != data[-1]:
89
+ return None
90
+ return Status(battery=data[5])
brickremote/script.py ADDED
@@ -0,0 +1,159 @@
1
+ """Text versions of the official app's "Program" (blocks) and "Path" modes.
2
+
3
+ Program scripts, one command per line (`#` starts a comment):
4
+
5
+ gear 2 set speed for `+` / `-` (1 = 60, 2 = 120, 3 = 255)
6
+ a + 2 port A forward for 2 s at the current gear
7
+ ac - 1.5 ports A and C reverse for 1.5 s
8
+ d 200 1 port D at speed 200 for 1 s (-255..255)
9
+ b + port B forward, keep running
10
+ wait 1 pause 1 s (motors keep their state)
11
+ stop stop every port
12
+ repeat 3 repeat the block up to `end` three times
13
+ forever repeat the block up to `end` until stopped
14
+ end
15
+
16
+ Path scripts drive a car (drive port + steering port), distances in metres,
17
+ turns in degrees; `Calibration` turns them into times:
18
+
19
+ forward 1.5
20
+ left 90
21
+ back 0.5
22
+ right 45
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ from dataclasses import dataclass
29
+
30
+ from .car import Car
31
+ from .module import JMCModule
32
+ from .protocol import GEARS, PORTS
33
+
34
+
35
+ class ScriptError(ValueError):
36
+ pass
37
+
38
+
39
+ @dataclass
40
+ class Step:
41
+ op: str # "drive", "wait", "stop", "gear", "repeat"
42
+ ports: str = ""
43
+ speed: str | int = 0 # "+", "-" or an int
44
+ seconds: float | None = None
45
+ count: int | None = None # repeat count, None = forever
46
+ body: list[Step] | None = None
47
+
48
+
49
+ def parse_program(text: str) -> list[Step]:
50
+ stack: list[list[Step]] = [[]]
51
+ for lineno, raw in enumerate(text.splitlines(), 1):
52
+ words = raw.split("#", 1)[0].split()
53
+ if not words:
54
+ continue
55
+ cmd, args = words[0].lower(), words[1:]
56
+ try:
57
+ if cmd == "end":
58
+ if len(stack) == 1:
59
+ raise ScriptError("`end` without `repeat`/`forever`")
60
+ stack.pop()
61
+ elif cmd in ("repeat", "forever"):
62
+ step = Step("repeat", count=int(args[0]) if cmd == "repeat" else None, body=[])
63
+ stack[-1].append(step)
64
+ stack.append(step.body)
65
+ elif cmd == "wait":
66
+ stack[-1].append(Step("wait", seconds=float(args[0])))
67
+ elif cmd == "stop":
68
+ stack[-1].append(Step("stop"))
69
+ elif cmd == "gear":
70
+ gear = int(args[0])
71
+ if not 1 <= gear <= len(GEARS):
72
+ raise ScriptError("gear must be 1, 2 or 3")
73
+ stack[-1].append(Step("gear", count=gear))
74
+ elif cmd and all(c in PORTS for c in cmd):
75
+ speed = args[0] if args[0] in "+-" else int(args[0])
76
+ seconds = float(args[1]) if len(args) > 1 else None
77
+ stack[-1].append(Step("drive", ports=cmd, speed=speed, seconds=seconds))
78
+ else:
79
+ raise ScriptError(f"unknown command {cmd!r}")
80
+ except (IndexError, ValueError) as exc:
81
+ raise ScriptError(f"line {lineno}: {raw.strip()!r}: {exc or 'missing argument'}") from None
82
+ if len(stack) != 1:
83
+ raise ScriptError("missing `end`")
84
+ return stack[0]
85
+
86
+
87
+ async def run_program(module: JMCModule, steps: list[Step], gear: int = 3, log=print) -> None:
88
+ """Run a parsed program. Cancel the task to abort; motors are stopped either way."""
89
+ state = {"gear": gear}
90
+
91
+ async def run(block: list[Step]) -> None:
92
+ for step in block:
93
+ if step.op == "gear":
94
+ state["gear"] = step.count
95
+ elif step.op == "wait":
96
+ await asyncio.sleep(step.seconds)
97
+ elif step.op == "stop":
98
+ await module.stop()
99
+ elif step.op == "repeat":
100
+ n = 0
101
+ while step.count is None or n < step.count:
102
+ await run(step.body)
103
+ n += 1
104
+ elif step.op == "drive":
105
+ speed = step.speed
106
+ if speed in ("+", "-"):
107
+ speed = GEARS[state["gear"] - 1] * (1 if speed == "+" else -1)
108
+ speeds = dict.fromkeys(step.ports, speed)
109
+ log(f"{step.ports.upper()} {speed:+d}" + (f" for {step.seconds}s" if step.seconds else ""))
110
+ if step.seconds is None:
111
+ await module.drive(**speeds)
112
+ else:
113
+ await module.run(step.seconds, **speeds)
114
+
115
+ try:
116
+ await run(steps)
117
+ finally:
118
+ await module.stop()
119
+
120
+
121
+ @dataclass
122
+ class Calibration:
123
+ metres_per_second: float = 0.5 # at full speed, straight
124
+ degrees_per_second: float = 90.0 # at full speed, full steering lock
125
+
126
+
127
+ def parse_path(text: str) -> list[tuple[str, float]]:
128
+ moves = []
129
+ for lineno, raw in enumerate(text.splitlines(), 1):
130
+ words = raw.split("#", 1)[0].split()
131
+ if not words:
132
+ continue
133
+ cmd = {"fwd": "forward", "backward": "back", "bwd": "back"}.get(words[0].lower(), words[0].lower())
134
+ if cmd not in ("forward", "back", "left", "right") or len(words) != 2:
135
+ raise ScriptError(f"line {lineno}: expected `forward|back|left|right <amount>`, got {raw.strip()!r}")
136
+ try:
137
+ moves.append((cmd, float(words[1])))
138
+ except ValueError:
139
+ raise ScriptError(f"line {lineno}: {words[1]!r} is not a number") from None
140
+ return moves
141
+
142
+
143
+ async def run_path(car: Car, moves: list[tuple[str, float]], cal: Calibration, log=print) -> None:
144
+ """Drive a path at full speed. Turns are made driving forward with full steering."""
145
+ try:
146
+ for cmd, amount in moves:
147
+ if cmd in ("forward", "back"):
148
+ seconds = amount / cal.metres_per_second
149
+ log(f"{cmd} {amount} m ({seconds:.1f}s)")
150
+ await car.drive(255 if cmd == "forward" else -255)
151
+ else:
152
+ seconds = amount / cal.degrees_per_second
153
+ log(f"{cmd} {amount}° ({seconds:.1f}s)")
154
+ await car.drive(255, 255 if cmd == "left" else -255)
155
+ await asyncio.sleep(seconds)
156
+ await car.stop()
157
+ await asyncio.sleep(0.3) # let the steering re-centre
158
+ finally:
159
+ await car.stop()