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/__init__.py
ADDED
|
@@ -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)
|
brickremote/protocol.py
ADDED
|
@@ -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()
|