Skip to content

Commit 460a07d

Browse files
committed
connections: Add generic MicroPython REPL runner.
This repurposes older USB related classes to create a download and run tool similar to the one we have for Pybricks hubs.
1 parent 05a798c commit 460a07d

File tree

3 files changed

+199
-8
lines changed

3 files changed

+199
-8
lines changed

demo/repldemo.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
LOG_FILE_NAME = "test.txt"
2+
3+
print("Hello")
4+
5+
print("_file_begin_", LOG_FILE_NAME)
6+
7+
for i in range(10):
8+
print(i)
9+
10+
print("_file_end_")
11+
12+
print("World!")

pybricksdev/cli/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ def add_parser(self, subparsers: argparse._SubParsersAction):
168168

169169
async def run(self, args: argparse.Namespace):
170170
from ..ble import find_device
171-
from ..connections import PybricksHub, EV3Connection
171+
from ..connections import PybricksHub, EV3Connection, REPLHub
172172

173173
# Pick the right connection
174174
if args.conntype == "ssh":
@@ -189,7 +189,8 @@ async def run(self, args: argparse.Namespace):
189189
device_or_address = await find_device(args.name)
190190

191191
elif args.conntype == "usb":
192-
pass
192+
hub = REPLHub()
193+
device_or_address = None
193194
else:
194195
raise ValueError(f"Unknown connection type: {args.conntype}")
195196

pybricksdev/connections.py

Lines changed: 184 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,16 @@
22
# Copyright (c) 2021 The Pybricks Authors
33

44
import asyncio
5-
import base64
6-
import json
75
import logging
86
import os
9-
import random
107
import struct
118

129
import asyncssh
1310
import semver
1411
from bleak import BleakClient
1512
from bleak.backends.device import BLEDevice
13+
from serial.tools import list_ports
14+
from serial import Serial
1615
from tqdm.auto import tqdm
1716
from tqdm.contrib.logging import logging_redirect_tqdm
1817

@@ -187,13 +186,15 @@ def line_handler(self, line):
187186
"""
188187

189188
# The line tells us to open a log file, so do it.
190-
if b"PB_OF" in line:
189+
if b"PB_OF:" in line or b"_file_begin_ " in line:
191190
if self.log_file is not None:
192191
raise RuntimeError("Log file is already open!")
193192

193+
path_start = len(b"PB_OF:") if b"PB_OF:" in line else len(b"_file_begin_ ")
194+
194195
# Get path relative to running script, so log will go
195196
# in the same folder unless specified otherwise.
196-
full_path = os.path.join(self.script_dir, line[6:].decode())
197+
full_path = os.path.join(self.script_dir, line[path_start:].decode())
197198
dir_path, _ = os.path.split(full_path)
198199
if not os.path.exists(dir_path):
199200
os.makedirs(dir_path)
@@ -203,7 +204,7 @@ def line_handler(self, line):
203204
return
204205

205206
# The line tells us to close a log file, so do it.
206-
if b"PB_EOF" in line:
207+
if b"PB_EOF" in line or b"_file_end_" in line:
207208
if self.log_file is None:
208209
raise RuntimeError("No log file is currently open!")
209210
logger.info("Done saving log.")
@@ -378,3 +379,180 @@ async def run(self, py_path, wait=True, print_output=True):
378379
if wait:
379380
await self.user_program_stopped.wait()
380381
await asyncio.sleep(0.3)
382+
383+
384+
class REPLHub:
385+
"""Run scripts on generic MicroPython boards with a REPL over USB."""
386+
387+
EOL = b"\r\n" # MicroPython EOL
388+
389+
def __init__(self):
390+
self.reset_buffers()
391+
392+
def reset_buffers(self):
393+
"""Resets internal buffers that track (parsed) serial data."""
394+
self.print_output = False
395+
self.output = []
396+
self.buffer = b""
397+
self.log_file = None
398+
try:
399+
self.serial.read(self.serial.in_waiting)
400+
except AttributeError:
401+
pass
402+
403+
async def connect(self, device=None):
404+
"""Connects to a SPIKE Prime or MINDSTORMS Inventor Hub."""
405+
406+
# Go through all comports.
407+
port = None
408+
devices = list_ports.comports()
409+
for dev in devices:
410+
if dev.product == "LEGO Technic Large Hub in FS Mode" or dev.vid == 0x0694:
411+
port = dev.device
412+
break
413+
414+
# Raise error if there is no hub.
415+
if port is None:
416+
raise OSError("Could not find hub.")
417+
418+
# Open the serial connection.
419+
print("Connecting to {0}".format(port))
420+
self.serial = Serial(port)
421+
self.serial.read(self.serial.in_waiting)
422+
print("Connected!")
423+
424+
async def disconnect(self):
425+
"""Disconnects from the hub."""
426+
self.serial.close()
427+
428+
def parse_input(self):
429+
"""Reads waiting serial data and parse as needed."""
430+
data = self.serial.read(self.serial.in_waiting)
431+
self.buffer += data
432+
433+
def is_idle(self, key=b">>> "):
434+
"""Checks if REPL is ready for a new command."""
435+
self.parse_input()
436+
return self.buffer[-len(key) :] == key
437+
438+
async def reset_hub(self):
439+
"""Soft resets the hub to clear MicroPython variables."""
440+
441+
# Cancel anything that is running
442+
for i in range(5):
443+
self.serial.write(b"\x03")
444+
await asyncio.sleep(0.1)
445+
446+
# Soft reboot
447+
self.serial.write(b"\x04")
448+
await asyncio.sleep(0.5)
449+
450+
# Prevent runtime from coming up
451+
while not self.is_idle():
452+
self.serial.write(b"\x03")
453+
await asyncio.sleep(0.1)
454+
455+
# Clear all buffers
456+
self.reset_buffers()
457+
458+
async def exec_line(self, line, wait=True):
459+
"""Executes one line on the REPL."""
460+
461+
# Initialize
462+
self.reset_buffers()
463+
encoded = line.encode()
464+
start_len = len(self.buffer)
465+
466+
# Write the command and prepare expected echo.
467+
echo = encoded + b"\r\n"
468+
self.serial.write(echo)
469+
470+
# We are done if we don't want to wait for the result.
471+
if not wait:
472+
return
473+
474+
# Wait until the echo has been read.
475+
while len(self.buffer) < start_len + len(echo):
476+
await asyncio.sleep(0.05)
477+
self.parse_input()
478+
# Raise error if we did not get the echo back.
479+
if echo not in self.buffer[start_len:]:
480+
print(start_len, self.buffer, self.buffer[start_len - 1 :], echo)
481+
raise ValueError("Failed to execute line: {0}.".format(line))
482+
483+
# Wait for MicroPython to execute the command.
484+
while not self.is_idle():
485+
await asyncio.sleep(0.1)
486+
487+
line_handler = PybricksHub.line_handler
488+
489+
async def exec_paste_mode(self, code, wait=True, print_output=True):
490+
"""Executes commands via paste mode."""
491+
492+
# Initialize buffers
493+
self.reset_buffers()
494+
self.print_output = print_output
495+
496+
# Convert script string to binary.
497+
encoded = code.encode()
498+
499+
# Enter paste mode.
500+
self.serial.write(b"\x05")
501+
while not self.is_idle(key=b"=== "):
502+
await asyncio.sleep(0.1)
503+
504+
# Paste the script, chunk by chunk to avoid overrun
505+
start_len = len(self.buffer)
506+
echo = encoded + b"\r\n"
507+
508+
for c in chunk(echo, 200):
509+
self.serial.write(c)
510+
# Wait until the pasted code is echoed back.
511+
while len(self.buffer) < start_len + len(c):
512+
await asyncio.sleep(0.05)
513+
self.parse_input()
514+
515+
# If it isn't, then stop.
516+
if c not in self.buffer[start_len:]:
517+
print(start_len, self.buffer, self.buffer[start_len - 1 :], echo)
518+
raise ValueError("Failed to paste: {0}.".format(code))
519+
520+
start_len += len(c)
521+
522+
# Parse hub output until the script is done.
523+
line_index = len(self.buffer)
524+
self.output = []
525+
526+
# Exit paste mode and start executing.
527+
self.serial.write(b"\x04")
528+
529+
# If we don't want to wait, we are done.
530+
if not wait:
531+
return
532+
533+
# Look for output while the program runs
534+
while not self.is_idle():
535+
536+
# Keep parsing hub data.
537+
self.parse_input()
538+
539+
# Look for completed lines that we haven't parsed yet.
540+
next_line_index = self.buffer.find(self.EOL, line_index)
541+
542+
if next_line_index >= 0:
543+
# If a new line is found, parse it.
544+
self.line_handler(self.buffer[line_index:next_line_index])
545+
line_index = next_line_index + len(self.EOL)
546+
await asyncio.sleep(0.1)
547+
548+
# Parse remaining hub data.
549+
while (next_line_index := self.buffer.find(self.EOL, line_index)) >= 0:
550+
self.line_handler(self.buffer[line_index:next_line_index])
551+
line_index = next_line_index + len(self.EOL)
552+
553+
async def run(self, py_path, wait=True, print_output=True):
554+
"""Executes a script via paste mode."""
555+
script = open(py_path).read()
556+
self.script_dir, _ = os.path.split(py_path)
557+
await self.reset_hub()
558+
await self.exec_paste_mode(script, wait, print_output)

0 commit comments

Comments
 (0)