22# Copyright (c) 2021 The Pybricks Authors
33
44import asyncio
5- import base64
6- import json
75import logging
86import os
9- import random
107import struct
118
129import asyncssh
1310import semver
1411from bleak import BleakClient
1512from bleak .backends .device import BLEDevice
13+ from serial .tools import list_ports
14+ from serial import Serial
1615from tqdm .auto import tqdm
1716from 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