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/web.py ADDED
@@ -0,0 +1,243 @@
1
+ """Web remote control: `brickremote web` serves a touch-friendly page to any browser on the network.
2
+
3
+ The browser talks to the server over a WebSocket. While a control is held, the page repeats
4
+ its state several times a second; if those messages stop (closed tab, lost Wi-Fi), the motors
5
+ are stopped after a moment.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import contextlib
12
+ import json
13
+ import logging
14
+ import socket
15
+ import time
16
+ from importlib import resources
17
+
18
+ from aiohttp import WSMsgType, web
19
+
20
+ from .car import Car
21
+ from .module import JMCModule
22
+ from .protocol import PORTS
23
+ from .script import Calibration, ScriptError, parse_path, parse_program, run_path, run_program
24
+ from .tui import EXAMPLE_PATH, EXAMPLE_PROGRAM, load_config, save_config
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ DEADMAN = 0.7 # seconds without a drive message before moving motors are stopped
29
+
30
+
31
+ class Remote:
32
+ """Shared state between all connected browsers."""
33
+
34
+ def __init__(self, module: JMCModule, car: Car):
35
+ self.module, self.car = module, car
36
+ cfg = load_config()
37
+ self.cfg = cfg
38
+ self.module.inverted = set(cfg.get("inverted", []))
39
+ self.settings = {
40
+ "gear": cfg.get("gear", 3),
41
+ "program": cfg.get("program", EXAMPLE_PROGRAM),
42
+ "path": cfg.get("path", EXAMPLE_PATH),
43
+ "calibration": cfg.get("calibration", vars(Calibration())),
44
+ "inverted": sorted(self.module.inverted),
45
+ "links": {},
46
+ "drive_port": car.drive_port,
47
+ "steer_port": car.steer_port,
48
+ }
49
+ self.clients: set[web.WebSocketResponse] = set()
50
+ self.task: asyncio.Task | None = None
51
+ self.log: list[str] = []
52
+ self.last_drive = 0.0
53
+
54
+ @property
55
+ def running(self) -> bool:
56
+ return self.task is not None and not self.task.done()
57
+
58
+ def say(self, msg: str) -> None:
59
+ log.debug("say %s", msg)
60
+ self.log = (self.log + [msg])[-5:]
61
+
62
+ def persist(self) -> None:
63
+ self.cfg.update(gear=self.settings["gear"], inverted=self.settings["inverted"],
64
+ calibration=self.settings["calibration"], program=self.settings["program"],
65
+ path=self.settings["path"])
66
+ save_config(self.cfg)
67
+
68
+ def state(self) -> dict:
69
+ return {"type": "state", "connected": self.module.is_connected, "battery": self.module.battery,
70
+ "speeds": self.module.speeds, "running": self.running, "log": self.log,
71
+ "settings": self.settings}
72
+
73
+ async def broadcast(self) -> None:
74
+ msg = json.dumps(self.state())
75
+ for ws in list(self.clients):
76
+ with contextlib.suppress(Exception):
77
+ await ws.send_str(msg)
78
+
79
+ def start(self, coro) -> None:
80
+ self.cancel()
81
+ self.task = asyncio.create_task(coro)
82
+ self.task.add_done_callback(self._finished)
83
+
84
+ def _finished(self, task: asyncio.Task) -> None:
85
+ if task.cancelled():
86
+ self.say("Stopped.")
87
+ elif task.exception():
88
+ self.say(f"Oops: {task.exception()}")
89
+ else:
90
+ self.say("All done!")
91
+
92
+ def cancel(self) -> None:
93
+ if self.running:
94
+ self.task.cancel()
95
+
96
+ async def handle(self, msg: dict) -> None:
97
+ kind = msg.get("type")
98
+ if kind == "drive":
99
+ if self.running or not self.module.is_connected:
100
+ return
101
+ speeds = {p: max(-255, min(255, int(v))) for p, v in msg.get("speeds", {}).items() if p in PORTS}
102
+ self.last_drive = time.monotonic()
103
+ if any(self.module.speeds[p] != v for p, v in speeds.items()):
104
+ await self.module.drive(**speeds)
105
+ elif kind == "stop":
106
+ self.cancel()
107
+ if self.module.is_connected:
108
+ await self.module.stop()
109
+ self.say("STOP!")
110
+ elif kind == "settings":
111
+ new = msg.get("settings", {})
112
+ for key in ("gear", "program", "path", "calibration"):
113
+ if key in new:
114
+ self.settings[key] = new[key]
115
+ if "inverted" in new:
116
+ self.settings["inverted"] = sorted(p for p in new["inverted"] if p in PORTS)
117
+ self.module.inverted = set(self.settings["inverted"])
118
+ if "links" in new:
119
+ self.settings["links"] = {a: b for a, b in new["links"].items() if a in PORTS and b in PORTS}
120
+ self.module.links = dict(self.settings["links"])
121
+ self.persist()
122
+ elif kind == "run_program":
123
+ try:
124
+ steps = parse_program(self.settings["program"])
125
+ except ScriptError as exc:
126
+ self.say(f"Oops: {exc}")
127
+ else:
128
+ self.say("Running your program!")
129
+ self.start(run_program(self.module, steps, int(self.settings["gear"]), log=self.say))
130
+ elif kind == "run_path":
131
+ try:
132
+ moves = parse_path(self.settings["path"])
133
+ except ScriptError as exc:
134
+ self.say(f"Oops: {exc}")
135
+ else:
136
+ self.say("Off we go!")
137
+ cal = Calibration(**self.settings["calibration"])
138
+ self.start(run_path(self.car, moves, cal, log=self.say))
139
+
140
+ async def deadman(self) -> None:
141
+ """Stop motors when browsers stop sending while something is moving."""
142
+ while True:
143
+ await asyncio.sleep(0.1)
144
+ moving = any(self.module.speeds.values())
145
+ if moving and not self.running and time.monotonic() - self.last_drive > DEADMAN:
146
+ with contextlib.suppress(Exception):
147
+ await self.module.stop()
148
+
149
+ async def keep_connected(self) -> None:
150
+ while True:
151
+ if not self.module.is_connected:
152
+ self.cancel()
153
+ try:
154
+ await self.module.disconnect()
155
+ await self.module.connect()
156
+ self.say("Connected! Let's go!")
157
+ except Exception as exc:
158
+ log.debug("connect failed: %s", exc)
159
+ await asyncio.sleep(1)
160
+ continue
161
+ await asyncio.sleep(0.5)
162
+
163
+ async def ticker(self) -> None:
164
+ while True:
165
+ await self.broadcast()
166
+ await asyncio.sleep(0.2)
167
+
168
+
169
+ async def index(request: web.Request) -> web.Response:
170
+ html = resources.files("brickremote").joinpath("web/index.html").read_text(encoding="utf-8")
171
+ return web.Response(text=html, content_type="text/html")
172
+
173
+
174
+ async def websocket(request: web.Request) -> web.WebSocketResponse:
175
+ remote: Remote = request.app["remote"]
176
+ ws = web.WebSocketResponse(heartbeat=5)
177
+ await ws.prepare(request)
178
+ remote.clients.add(ws)
179
+ await ws.send_str(json.dumps(remote.state()))
180
+ try:
181
+ async for msg in ws:
182
+ if msg.type == WSMsgType.TEXT:
183
+ try:
184
+ await remote.handle(json.loads(msg.data))
185
+ except Exception as exc:
186
+ remote.say(f"Oops: {exc}")
187
+ await remote.broadcast()
188
+ finally:
189
+ remote.clients.discard(ws)
190
+ return ws
191
+
192
+
193
+ def local_addresses() -> list[str]:
194
+ addresses = set()
195
+ with contextlib.suppress(OSError):
196
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
197
+ s.connect(("192.0.2.1", 1)) # no packet is sent; this just picks the outgoing interface
198
+ addresses.add(s.getsockname()[0])
199
+ s.close()
200
+ with contextlib.suppress(OSError):
201
+ addresses.add(socket.gethostname() + ".local")
202
+ return sorted(addresses)
203
+
204
+
205
+ async def serve(module: JMCModule, car: Car, host: str, port: int) -> None:
206
+ remote = Remote(module, car)
207
+ app = web.Application()
208
+ app["remote"] = remote
209
+ app.router.add_get("/", index)
210
+ app.router.add_get("/ws", websocket)
211
+ runner = web.AppRunner(app, access_log=None)
212
+ await runner.setup()
213
+ await web.TCPSite(runner, host, port).start()
214
+ print("Brick Remote is running! Open one of these in a browser:", flush=True)
215
+ print(f" http://localhost:{port}")
216
+ if host in ("0.0.0.0", "::", ""):
217
+ for addr in local_addresses():
218
+ print(f" http://{addr}:{port} (from a tablet or another computer)")
219
+ print("Press Ctrl-C to stop.", flush=True)
220
+ tasks = [asyncio.create_task(t) for t in (remote.keep_connected(), remote.deadman(), remote.ticker())]
221
+ try:
222
+ await asyncio.Event().wait()
223
+ finally:
224
+ for t in tasks:
225
+ t.cancel()
226
+ remote.cancel()
227
+ if module.is_connected:
228
+ with contextlib.suppress(Exception):
229
+ await asyncio.wait_for(module.stop(), 0.5)
230
+ command = module.detach()
231
+ if command:
232
+ import subprocess
233
+ subprocess.Popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
234
+ stderr=subprocess.DEVNULL, start_new_session=True)
235
+ await runner.cleanup()
236
+
237
+
238
+ def run(address: str | None = None, drive_port: str = "a", steer_port: str = "d",
239
+ host: str = "0.0.0.0", port: int = 8080) -> None:
240
+ module = JMCModule(address, timeout=5.0)
241
+ car = Car(module, drive_port=drive_port, steer_port=steer_port)
242
+ with contextlib.suppress(KeyboardInterrupt):
243
+ asyncio.run(serve(module, car, host, port))
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.5
2
+ Name: brickremote
3
+ Version: 0.3.0
4
+ Summary: Drive Lego-compatible Bluetooth motor bricks (JG_JMC / XP Block "Smart creative module") from a computer
5
+ Project-URL: Homepage, https://surak.github.io/brickremote/
6
+ Project-URL: Source, https://github.com/surak/brickremote
7
+ Project-URL: Changelog, https://github.com/surak/brickremote/blob/main/CHANGELOG.md
8
+ Author-email: Alexandre Strube <surak@surak.eti.br>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ble,bluetooth,building blocks,jg_jmc,kids,motor,remote control,smart creative module,xp block
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: AsyncIO
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Education
17
+ Classifier: Topic :: Home Automation
18
+ Classifier: Topic :: System :: Hardware
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: bleak>=0.22
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest; extra == 'test'
23
+ Provides-Extra: web
24
+ Requires-Dist: aiohttp>=3.9; extra == 'web'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # brickremote
28
+
29
+ **Website for kids and parents: https://surak.github.io/brickremote/**
30
+
31
+ Python library for the cheap Lego-compatible Bluetooth LE motor bricks that advertise as
32
+ `JG_JMC-xxxx` and are normally driven by the **XP Block** phone app ("Smart creative module",
33
+ four motor ports A–D). Works anywhere [bleak](https://github.com/hbldh/bleak) does: Linux
34
+ (incl. Raspberry Pi), macOS and Windows.
35
+
36
+ ```
37
+ pip install brickremote # library and terminal app
38
+ pip install "brickremote[web]" # plus the web remote control
39
+ ```
40
+
41
+ Or from a checkout, without installing anything yourself:
42
+
43
+ ```
44
+ ./run.sh # creates .venv on first run, then starts the remote control app
45
+ ./run.sh scan # any other command works the same way
46
+ ```
47
+
48
+ ## Remote control app
49
+
50
+ Run `brickremote` with no arguments for a terminal version of the phone app (everything except the
51
+ phone-only gyroscope and voice modes):
52
+
53
+ | Tab | Keys |
54
+ |-----|------|
55
+ | Car | arrows: forward/back, steer left/right (drive port A, steering port D; change with `--drive`/`--steer`) |
56
+ | Ports | like two game sticks: `W`/`S` port A, `A`/`D` port B, `I`/`K` port C, `J`/`L` port D |
57
+ | Program | `e` edit in `$EDITOR`, Enter run (see below) |
58
+ | Path | `e` edit, Enter run, `[` `]` / `{` `}` calibrate speed and turn rate |
59
+
60
+ Everywhere: `Tab` switch screen, `1`-`3` speed (60/120/255), `space` STOP, `q` quit, and
61
+
62
+ - `h` **hold**: motors keep running after you let go of the key (steering still springs back)
63
+ - `p` **pair**: one port copies another (off → A+B → A+C), for builds with two motors that
64
+ should always move together, like a tank
65
+ - `r` + port letter **reverse**: that port turns the other way round, for a motor that was
66
+ mounted backwards
67
+
68
+ Settings, programs and paths are saved in `~/.config/brickremote.json`.
69
+
70
+ Terminals report key presses but not releases, so a motor runs while its key auto-repeats and
71
+ stops a moment after you let go. Steering always springs back to centre, even with hold on.
72
+
73
+ ## Web remote control
74
+
75
+ ```
76
+ ./run.sh web # or: brickremote web (needs `pip install 'brickremote[web]'`)
77
+ ```
78
+
79
+ This starts a small web server and prints its address. Open it in any browser: on the same
80
+ computer, or on a tablet or laptop on the same Wi-Fi, which then becomes the remote control.
81
+ It has the same four screens as the terminal app, plus things a browser is good at:
82
+
83
+ - buttons you hold down, a steering wheel you can drag, and two joysticks for the ports
84
+ - program blocks you tap to build a program
85
+ - a grid where you draw the route for path mode
86
+
87
+ If the browser stops talking to the server (tab closed, Wi-Fi gone), the motors stop by themselves.
88
+
89
+ ## Command line
90
+
91
+ ```
92
+ brickremote scan # list modules nearby
93
+ brickremote run a=255 --time 2 # port A forward, full speed, 2 s
94
+ brickremote run a=-120 d=255 --time 1.5 # A reverse at medium speed, D forward
95
+ brickremote program dance.txt # run a program script
96
+ brickremote path route.txt --mps 0.6 # drive a path script
97
+ brickremote --invert d run d=255 # global options: --address, --slot, --invert, --drive, --steer
98
+ ```
99
+
100
+ ### Program scripts (the app's block programming)
101
+
102
+ ```
103
+ gear 2 # speed for + and -: 1 = 60, 2 = 120, 3 = 255
104
+ repeat 3
105
+ a + 1.5 # port A forward for 1.5 s
106
+ ac - 1 # ports A and C reverse for 1 s
107
+ wait 0.5
108
+ end
109
+ d 200 # port D at speed 200, keep running
110
+ forever # until Ctrl-C / space
111
+ b + 1
112
+ b - 1
113
+ end
114
+ ```
115
+
116
+ ### Path scripts (the app's path mode)
117
+
118
+ ```
119
+ forward 1.5 # metres
120
+ left 90 # degrees, driving forward with full steering
121
+ back 0.5
122
+ right 45
123
+ ```
124
+
125
+ Distances and angles become times through a calibration (`--mps`, `--dps`, or `[ ] { }` in the
126
+ app): measure how far the car goes in one second at full speed, and how many degrees it turns.
127
+
128
+ ## Library
129
+
130
+ ```python
131
+ import asyncio
132
+ from brickremote import JMCModule
133
+
134
+ async def main():
135
+ async with JMCModule() as m: # connects to the first module found
136
+ await m.drive(a=255, d=-120) # speeds -255..255, negative = reverse
137
+ await asyncio.sleep(2)
138
+ await m.stop()
139
+ print("battery:", m.battery, "%")
140
+
141
+ asyncio.run(main())
142
+ ```
143
+
144
+ `JMCModule(address)` targets a specific module, `discover()` lists all of them. While connected,
145
+ the current state is resent every 0.2 s, which keeps the module from going to sleep.
146
+
147
+ ```python
148
+ JMCModule(inverted="d", links={"a": "c"}) # reverse port D; C follows A
149
+ await m.run(2, a=255) # A forward for 2 s, then stop
150
+ ```
151
+
152
+ `brickremote.script` has the program and path engines (`parse_program`, `run_program`,
153
+ `parse_path`, `run_path`) if you want them from your own code.
154
+
155
+ For the stock car (drive motor on A, steering on D) there is a small wrapper:
156
+
157
+ ```python
158
+ from brickremote import Car, JMCModule
159
+
160
+ async with JMCModule() as m:
161
+ car = Car(m)
162
+ await car.left(speed=200) # forward while steering left
163
+ ```
164
+
165
+ ## Protocol
166
+
167
+ Reverse-engineered from the XP Block Android app. The module exposes service `FFF0`;
168
+ commands go to `FFF2` and **must be written with response** (write-without-response is
169
+ silently ignored):
170
+
171
+ ```
172
+ 5A 6B 02 00 05 dirAB speedAB dirCD speedCD slot SUM
173
+ ```
174
+
175
+ | Field | Meaning |
176
+ |---------|---------|
177
+ | dirAB | A: `1` forward, `2` reverse; B: `+4` forward, `+8` reverse |
178
+ | speedAB | 0–255, shared by A and B (the app's gears are 60 / 120 / 255) |
179
+ | dirCD, speedCD | same for C and D |
180
+ | slot | 1–3, module slot (the app can drive three modules at once) |
181
+ | SUM | sum of all previous bytes, modulo 256 |
182
+
183
+ Six-port modules use length `08` and append `dirEF speedE speedF`.
184
+
185
+ The module reports on `FFF1` with `5A 6B 01 00 01 <battery %> SUM`. It switches itself off
186
+ after about ten minutes without commands.
187
+
188
+ ## Limitations
189
+
190
+ - The two ports of a pair share one speed byte, so A and B (or C and D) cannot run at different
191
+ speeds at the same time; the faster one wins.
192
+ - Only tested with a four-port module. Six-port support follows the app's code but is unverified.
193
+
194
+ ## License
195
+
196
+ MIT
@@ -0,0 +1,14 @@
1
+ brickremote/__init__.py,sha256=fDh9aKEKqP1sThGbBEIrrqHEiidBPtVg9kZjJY1QBHQ,369
2
+ brickremote/car.py,sha256=G1aE8KDvopRTuO_a1snA542Isx58ZQ5OFq2mP5Eox6g,1498
3
+ brickremote/cli.py,sha256=MZulXrOcDo8bf7lDb6ILPaQwunUu6kQJwd53cAq2Ajo,5702
4
+ brickremote/module.py,sha256=5KrV0WBXCv_6geeocrkopbzL5jGgbirEjGPuSyytgq0,8109
5
+ brickremote/protocol.py,sha256=Z3CiSGF-vOBuCV_nSC53sx8ctN94nT6Y1schrqvlB5w,2915
6
+ brickremote/script.py,sha256=ewuZoZRhLwPI1uv66tuRS-tf99V5v9YG_h8TqHyy8Cw,6123
7
+ brickremote/tui.py,sha256=VoGllR2PYjsfyiz4BeaRkAr437XGuR6bs-bD-31hjbE,29707
8
+ brickremote/web.py,sha256=0GojwZsMGRMsUo6MyPWc7-ZdGvDbCh-dcaCdqFsg9Qk,9347
9
+ brickremote/web/index.html,sha256=bjzb5fv7nIi0GI5KDAjxhy3Nx9p484rJcuD7stepwFg,33416
10
+ brickremote-0.3.0.dist-info/METADATA,sha256=_ZPZyuHpRO_lEz8iWfUU0inVwbN4Jj1sqteSfb46uLw,7123
11
+ brickremote-0.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ brickremote-0.3.0.dist-info/entry_points.txt,sha256=P2ufB2nHe5T-jvZ70AHV0JdNwelzgnNjRD8IqOpD0a4,53
13
+ brickremote-0.3.0.dist-info/licenses/LICENSE,sha256=b-oeTQceLUsSNGJQezIG2xGZHZUY_CysQ09bjWPsGyQ,1073
14
+ brickremote-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ brickremote = brickremote.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexandre Strube
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.