Skip to content

Commit 147c337

Browse files
committed
cli.lwp.repl: new command line tool for LWP3
This adds an interactive REPL tool for sending and receiving messages using the LWP3 protocol.
1 parent fc7e5a7 commit 147c337

File tree

6 files changed

+213
-5
lines changed

6 files changed

+213
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## Added
99
- `pybricksdev.ble.lwp3.bytecodes` module.
1010
- `pybricksdev.ble.lwp3.messages` module.
11+
- `pybricksdev lwp3 repl` command line tool.
1112

1213
## [1.0.0-alpha.8] - 2021-05-18
1314
## Added

poetry.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pybricksdev/cli/__init__.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,43 @@ def run(self, args: argparse.Namespace):
258258
return self.subparsers.choices[args.action].tool.run(args)
259259

260260

261+
class LWP3Repl(Tool):
262+
def add_parser(self, subparsers: argparse._SubParsersAction):
263+
parser = subparsers.add_parser(
264+
"repl",
265+
help="interactive REPL for sending and receiving LWP3 messages",
266+
)
267+
parser.tool = self
268+
269+
def run(self, args: argparse.Namespace):
270+
from .lwp3.repl import setup_repl_logging, repl
271+
272+
setup_repl_logging()
273+
return repl()
274+
275+
276+
class LWP3(Tool):
277+
def add_parser(self, subparsers: argparse._SubParsersAction):
278+
self.parser = subparsers.add_parser(
279+
"lwp3", help="interact with devices using LWP3"
280+
)
281+
self.parser.tool = self
282+
self.subparsers = self.parser.add_subparsers(
283+
metavar="<lwp3-tool>", dest="lwp3_tool", help="the tool to run"
284+
)
285+
286+
for tool in (LWP3Repl(),):
287+
tool.add_parser(self.subparsers)
288+
289+
def run(self, args: argparse.Namespace):
290+
if args.lwp3_tool not in self.subparsers.choices:
291+
self.parser.error(
292+
f'Missing name of tool: {"|".join(self.subparsers.choices.keys())}'
293+
)
294+
295+
return self.subparsers.choices[args.lwp3_tool].tool.run(args)
296+
297+
261298
class Udev(Tool):
262299
def add_parser(self, subparsers: argparse._SubParsersAction):
263300
parser = subparsers.add_parser("udev", help="print udev rules to stdout")
@@ -293,7 +330,7 @@ def main():
293330
help="the tool to use",
294331
)
295332

296-
for tool in Compile(), Run(), Flash(), DFU(), Udev():
333+
for tool in Compile(), Run(), Flash(), DFU(), LWP3(), Udev():
297334
tool.add_parser(subparsers)
298335

299336
argcomplete.autocomplete(parser)

pybricksdev/cli/lwp3/__init__.py

Whitespace-only changes.

pybricksdev/cli/lwp3/repl.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2021 The Pybricks Authors
3+
4+
"""
5+
The :mod:`pybricks.cli.lwp3.repl` module provides a command line interface
6+
for connecting to a device and sending and receiving LWP3 messages.
7+
"""
8+
9+
import asyncio
10+
import inspect
11+
import logging
12+
import os
13+
import struct
14+
from pathlib import Path
15+
16+
from appdirs import user_cache_dir
17+
from bleak import BleakClient, BleakScanner
18+
from bleak.backends.device import BLEDevice
19+
from bleak.backends.scanner import AdvertisementData
20+
from prompt_toolkit import PromptSession
21+
from prompt_toolkit.history import FileHistory
22+
from prompt_toolkit.patch_stdout import StdoutProxy, patch_stdout
23+
24+
from ...ble.lwp3 import (
25+
LEGO_CID,
26+
LWP3_HUB_CHARACTERISTIC_UUID,
27+
LWP3_HUB_SERVICE_UUID,
28+
bytecodes,
29+
messages,
30+
)
31+
from ...ble.lwp3.bytecodes import Capabilities, HubKind, LastNetwork, Status
32+
from ...ble.lwp3.messages import AbstractMessage, parse_message
33+
34+
logger = logging.getLogger(__name__)
35+
history_file = Path(user_cache_dir("pybricksdev"), "lwp3-explorer-history.txt")
36+
37+
# Get names that are valid for evaluating on the REPL.
38+
# This hides built-in functions to avoid arbitrary code execution.
39+
_eval_pool = {"__builtins__": {}}
40+
41+
# TODO: these dicts can be used for tab completion on the REPL
42+
43+
# The first groups is any type from bytecodes that inherits from int (includes
44+
# enums/flags) or bytes.
45+
_eval_pool.update(
46+
{
47+
k: v
48+
for k, v in bytecodes.__dict__.items()
49+
if inspect.isclass(v)
50+
and v.__module__ == bytecodes.__name__
51+
and (issubclass(v, int) or issubclass(v, bytes))
52+
}
53+
)
54+
55+
# The second group are all of the non-abstract message types from the messages module.
56+
_eval_pool.update(
57+
{
58+
k: v
59+
for k, v in messages.__dict__.items()
60+
if inspect.isclass(v)
61+
and issubclass(v, AbstractMessage)
62+
and not inspect.isabstract(v)
63+
}
64+
)
65+
66+
67+
async def repl() -> None:
68+
"""
69+
Provides an interactive REPL for sending and receiving LWP3 messages.
70+
"""
71+
os.makedirs(history_file.parent, exist_ok=True)
72+
session = PromptSession(history=FileHistory(history_file))
73+
74+
queue = asyncio.Queue()
75+
76+
def callback(dev: BLEDevice, adv: AdvertisementData) -> None:
77+
if LWP3_HUB_SERVICE_UUID.lower() not in adv.service_uuids:
78+
return
79+
80+
mfg_data = adv.manufacturer_data[LEGO_CID]
81+
button, kind, cap, last_net, status, opt = struct.unpack("<6B", mfg_data)
82+
button = bool(button)
83+
kind = HubKind(kind)
84+
cap = Capabilities(cap)
85+
last_net = LastNetwork(last_net)
86+
status = Status(status)
87+
logger.debug(
88+
"button: %s, kind: %s, cap: %s, last net: %s, status: %s, option: %s",
89+
button,
90+
kind,
91+
cap,
92+
last_net,
93+
status,
94+
opt,
95+
)
96+
97+
if not (cap & Capabilities.REMOTE):
98+
return
99+
100+
queue.put_nowait(dev)
101+
102+
async with BleakScanner(detection_callback=callback):
103+
logger.info("scanning...")
104+
device = await queue.get()
105+
logger.info("found device")
106+
107+
def handle_disconnect(client: BleakClient):
108+
logger.info("disconnected")
109+
110+
async with BleakClient(device, disconnected_callback=handle_disconnect) as client:
111+
logger.info("connected")
112+
113+
def handle_notify(handle, value):
114+
try:
115+
msg = parse_message(value)
116+
except Exception as ex:
117+
logger.warning("failed to parse message: %s", value, exc_info=ex)
118+
else:
119+
logger.info("received: %s", msg)
120+
121+
await client.start_notify(LWP3_HUB_CHARACTERISTIC_UUID, handle_notify)
122+
123+
# welcome is delayed to allow initial log messages to settle.
124+
async def welcome():
125+
await asyncio.sleep(1)
126+
print("Type message and press ENTER to send. Press CTRL+D to exit.")
127+
128+
asyncio.ensure_future(welcome())
129+
130+
while True:
131+
with patch_stdout():
132+
try:
133+
result = await session.prompt_async(">>> ")
134+
except KeyboardInterrupt:
135+
# CTRL+C ignores the line
136+
continue
137+
except EOFError:
138+
# CTRL+D exits the program
139+
break
140+
try:
141+
msg = eval(result, _eval_pool)
142+
if not isinstance(msg, AbstractMessage):
143+
raise ValueError("not a message object")
144+
except Exception:
145+
logger.exception("bad input:")
146+
else:
147+
logger.info("sending: %s", msg)
148+
await client.write_gatt_char(LWP3_HUB_CHARACTERISTIC_UUID, msg)
149+
150+
logger.info("disconnecting...")
151+
152+
153+
def setup_repl_logging() -> None:
154+
"""
155+
Overrides logging as needed for :func:`repl`.
156+
"""
157+
logging.basicConfig(
158+
stream=StdoutProxy(),
159+
format="[%(asctime)s.%(msecs)03d] %(levelname)s: %(message)s",
160+
datefmt="%H:%M:%S",
161+
force=True,
162+
)
163+
logger.setLevel(logging.INFO)
164+
165+
166+
if __name__ == "__main__":
167+
setup_repl_logging()
168+
asyncio.run(repl())

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ tqdm = "^4.46.1"
3737
validators = "^0.18.2"
3838
pyusb = "^1.0.2"
3939
semver = "^2.13.0"
40+
appdirs = "^1.4.4"
41+
prompt-toolkit = "^3.0.18"
4042

4143
[tool.poetry.dev-dependencies]
4244
black = {version = "^21.5b1", allow-prereleases = true}

0 commit comments

Comments
 (0)