├── .gitignore ├── README.md ├── lib └── bleADV.py ├── run.sh ├── setup.sh ├── tools └── bleSniffer.py └── util └── pyboard.py /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NOIAPP micropython apps & tools for ESP32 2 | 3 | This project contains micropython implementations to interact with BLE with contact tracing devices. 4 | 5 | ## Getting Started 6 | 7 | These instructions will get you a copy of the project up and running on your eps32 board for development and testing purposes. 8 | 9 | ### Prerequisites 10 | 11 | To play with this software you neeed: 12 | 13 | * An Expressif [ESP32](https://www.espressif.com/en/products/socs/esp32/overview "Expressif ESP32") board; 14 | 15 | * to fullfill [requirements](https://docs.micropython.org/en/latest/esp32/tutorial/intro.html "Expressif ESP32") for micropython for esp32: 16 | 17 | * [Micropython](https://micropython.org/download/esp32/ "Microypython") on the board, see [here](https://docs.micropython.org/en/latest/esp32/tutorial/intro.html#deploying-the-firmware) 18 | * [esptool](https://github.com/espressif/esptool/ "esptool") 19 | * A local version of Python3 with pyserial library 20 | * A bash shell (Linux/Mac/Windows with Git Bash) 21 | 22 | 23 | ### Installing 24 | 25 | `./setup.sh ` 26 | 27 | where `` is the name of your serial device. 28 | 29 | ### Executing 30 | 31 | You can now run the tools by name with: 32 | 33 | `./run.sh [-f] args...` 34 | 35 | where 36 | - `` is the name of the script in `tools` without `.py` 37 | - `-f` follow the output indefinitely (press ^C to interrupt) 38 | - you can pass multiple args as strings (but do not put single quotes in it!) 39 | 40 | args are then available inside the script in the array `args` as strings, and args[0] is the script name. 41 | 42 | ## Tools 43 | 44 | ### BLE sniffer 45 | 46 | This simple tool scan for [BLE advertising](https://www.argenox.com/library/bluetooth-low-energy/ble-advertising-primer/) and output some information to help beacon analysis. 47 | 48 | usage: `./run.sh bleSniffer -f 2000` 49 | 50 | the `2000` is the number of milliseconds it must be listening. 51 | 52 | ## Contributing 53 | 54 | Please read (_italian_) [help us](https://www.protetti.app/helpus) for details on our code of conduct, and the process for submitting pull requests to us. 55 | 56 | ## Versioning 57 | 58 | We use [SemVer](http://semver.org/) for versioning. 59 | -------------------------------------------------------------------------------- /lib/bleADV.py: -------------------------------------------------------------------------------- 1 | from struct import unpack 2 | 3 | ADV_TYPES = { 4 | 0x00: "[IND] connectable and scannable undirected advertising", 5 | 0x01: "[DIRECT_IND] connectable directed advertising", 6 | 0x02: "[SCAN_IND] scannable undirected advertising", 7 | 0x03: "[NONCONN_IND] non-connectable undirected advertising", 8 | 0x04: "[SCAN_RSP] scan response" 9 | } 10 | 11 | # see https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/ 12 | ADV_DATA_TYPES = { 13 | 0x01: "Flags", 14 | 0x02: "Incomplete List of 16-bit Serv Class UUIDs", 15 | 0x03: "Complete List of 16-bit Serv Class UUIDs", 16 | 0x04: "Incomplete List of 32-bit Serv Class UUIDs", 17 | 0x05: "Complete List of 32-bit Serv Class UUIDs", 18 | 0x06: "Incomplete List of 128-bit Serv Class UUIDs", 19 | 0x07: "Complete List of 128-bit Serv Class UUIDs", 20 | 0x08: "Shortened Local Name", 21 | 0x09: "Complete Local Name", 22 | 0x0A: "Tx Power Level", 23 | 0x0D: "Class of Device", 24 | 0x0E: "Simple Pairing Hash C-192", 25 | 0x0F: "Simple Pairing Randomizer R-192", 26 | 0x10: "Dev ID/Sec Mngr TK Val", 27 | 0x11: "Security Manager Out of Band Flags", 28 | 0x12: "Slave Connection Interval Range", 29 | 0x14: "List of 16-bit Serv Solicitation UUIDs", 30 | 0x15: "List of 128-bit Serv Solicitation UUIDs", 31 | 0x16: "Serv Data 16-bit UUID", 32 | 0x17: "Public Target Address", 33 | 0x18: "Random Target Address", 34 | 0x19: "Appearance", 35 | 0x1A: "Advertising Interval", 36 | 0x1B: "LE Bluetooth Device Address", 37 | 0x1C: "LE Role", 38 | 0x1D: "Simple Pairing Hash C-256", 39 | 0x1E: "Simple Pairing Randomizer R-256", 40 | 0x1F: "List of 32-bit Serv Solicitation UUIDs", 41 | 0x20: "Serv Data - 32-bit UUID", 42 | 0x21: "Serv Data - 128-bit UUID", 43 | 0x22: "LE Secure Connections Confirmation Value", 44 | 0x23: "LE Secure Connections Random Value", 45 | 0x24: "URI", 46 | 0x25: "Indoor Positioning", 47 | 0x26: "Transport Discovery Data", 48 | 0x27: "LE Supported Features", 49 | 0x28: "Channel Map Update Indication", 50 | 0x29: "PB-ADV", 51 | 0x2A: "Mesh Message", 52 | 0x2B: "Mesh Beacon", 53 | 0x2C: "BIGInfo", 54 | 0x3D: "3D Information Data", 55 | 0xFF: "Manufacturer Specific Data" 56 | } 57 | 58 | COMPANY_IDENTIFIER = { 59 | 0x079A: "GuangDong Oppo Mob", 60 | 0x072F: "OnePlus Electronics Co.", 61 | 0x004C: "Apple", 62 | 0x08A9: "Fraunhofer IIS", 63 | 0x08B7: "ABB", 64 | 0x083F: "D-Link", 65 | 0x0837: "vivo Mobile Comm", 66 | 0x0826: "Hyundai Motor Company", 67 | 0x07FC: "Renault SA", 68 | 0x07A2: "Roku", 69 | 0x038F: "Xiaomi", 70 | 0x0393: "LAVAZZA SpA", 71 | 0x0397: "LEGO System A/S", 72 | 0x0381: "Sharp", 73 | 0x0307: "FUJI INDUSTRIAL CO.", 74 | 0x02FF: "Silicon Laboratories", 75 | 0x02EA: "Fujitsu Limited", 76 | 0x02D0: "3M", 77 | 0x02D5: "OMRON", 78 | 0x02C5: "Lenovo", 79 | 0x02B6: "Schneider Electric", 80 | 0x027D: "HUAWEI Technologies Co.", 81 | 0x022E: "Siemens AG", 82 | 0x022B: "Tesla Motors", 83 | 0x01A9: "Canon", 84 | 0x009F: "Suunto Oy", 85 | 0x009E: "Bose", 86 | 0x0090: "Funai Electric Co.", 87 | 0x0087: "Garmin", 88 | 0x0078: "Nike", 89 | 0x0075: "Samsung Electronics Co.", 90 | 0x005D: "Realtek Semiconductor", 91 | 0x005C: "Belkin International", 92 | 0x0059: "Nordic Semiconductor ASA", 93 | 0x0057: "Harman International Industries", 94 | 0x0056: "Sony Ericsson", 95 | 0x004B: "Continental Automotive Systems", 96 | 0x0040: "Seiko Epson", 97 | 0x003F: "Bluetooth SIG", 98 | 0x003E: "Systems and Chips", 99 | 0x003D: "IPextreme", 100 | 0x003C: "BlackBerry Limited", 101 | 0x003B: "Gennum", 102 | 0x003A: "Panasonic", 103 | 0x0036: "Renesas Electronics", 104 | 0x0031: "Synopsys", 105 | 0x0030: "ST Microelectronics", 106 | 0x002F: "MewTel Technology", 107 | 0x002E: "Norwood Systems", 108 | 0x002D: "GCT Semiconductor", 109 | 0x002C: "Macronix International Co.", 110 | 0x002B: "Tenovis", 111 | 0x002A: "Symbol Technologies", 112 | 0x0029: "Hitachi", 113 | 0x0028: "R F Micro Devices", 114 | 0x0027: "Open Interface", 115 | 0x0026: "C Technologies", 116 | 0x0025: "NXP Semiconductors", 117 | 0x0024: "Alcatel", 118 | 0x0023: "WavePlus Technology Co.", 119 | 0x0022: "NEC", 120 | 0x0021: "Mansella", 121 | 0x0020: "BandSpeed", 122 | 0x001F: "AVM Berlin", 123 | 0x001E: "Inventel", 124 | 0x001D: "Qualcomm", 125 | 0x001C: "Conexant Systems", 126 | 0x001B: "Signia Technologies", 127 | 0x001A: "TTPCom Limited", 128 | 0x0019: "Rohde & Schwarz & Co. KG", 129 | 0x0018: "Transilica", 130 | 0x0017: "Newlogic", 131 | 0x0016: "KC Technology", 132 | 0x0015: "RTX Telecom A/S", 133 | 0x0014: "Mitsubishi Electric", 134 | 0x0013: "Atmel", 135 | 0x0012: "Zeevo", 136 | 0x0011: "Widcomm", 137 | 0x0010: "Mitel Semiconductor", 138 | 0x000F: "Broadcom", 139 | 0x000E: "Parthus Technologies", 140 | 0x000D: "Texas Instruments", 141 | 0x000C: "Digianswer A/S", 142 | 0x000B: "Silicon Wave", 143 | 0x000A: "Qualcomm", 144 | 0x0009: "Infineon Technologies AG", 145 | 0x0008: "Motorola", 146 | 0x0007: "Lucent", 147 | 0x0006: "Microsoft", 148 | 0x0005: "3Com", 149 | 0x0004: "Toshiba", 150 | 0x0003: "IBM", 151 | 0x0002: "Intel", 152 | 0x0001: "Nokia Mobile Phones", 153 | 0x0000: "Ericsson Technology Licensing" 154 | } 155 | 156 | 157 | def uuidStr(data): 158 | uuid = "_INVALID_" 159 | if len(data) >= 16: 160 | codes = unpack(" 2 and typeid == 0x16 and data[0] == 0x6F and data[1] == 0xFD: 259 | return ADV16UuidAGExpData(typeid, data) 260 | else: 261 | return ADV16UuidData(typeid, data) 262 | 263 | def buildManufacter(typeid, data): 264 | return ADVManufacter(typeid, data) 265 | 266 | 267 | ADV_DISSECTOR_MAP = { 268 | 0x07: buildUuid128, 269 | 0x03: buildUuid16, 270 | 0x16: buildUuid16Data, 271 | 0xFF: buildManufacter 272 | } 273 | 274 | 275 | def buildAdvDataTypes(data): 276 | i = 0 277 | ladvs = [] 278 | while i < len(data): 279 | advsize = data[i] # adv_size 280 | advtype = data[i + 1] 281 | advpdu = data[i + 2:i + advsize + 1] 282 | adv = None 283 | try: 284 | adv = ADV_DISSECTOR_MAP[advtype](advtype, advpdu) 285 | except: 286 | adv = ADVData(advtype, advpdu) 287 | ladvs.append(adv) 288 | i += advsize + 1 289 | return ladvs 290 | 291 | 292 | class ADV: 293 | def __init__(self, advtype, rssi, addr, addrtype, advs): 294 | self.type = advtype 295 | self.rssi = rssi 296 | self.addr = dumpHex(addr) 297 | self.addrtype = addrtype 298 | self.advs = advs 299 | 300 | def __repr__(self): 301 | s = "%s\r\n" % (ADV_TYPES[self.type],) 302 | s += "ADDR: %s, ADDTYPE: %s, rssi %s dBm" % (self.addr, repr(self.addrtype), repr(self.rssi),) 303 | return s 304 | 305 | def __str__(self): 306 | return self.__repr__() 307 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | TOOL="${1:?tool to execute}" 3 | TOOL="${TOOL%%.py}" 4 | cd "$(dirname $0)" 5 | if ! test -f .env 6 | then echo Please run ./setup.sh ; exit 1 7 | fi 8 | if ! test -f "tools/$TOOL.py" 9 | then echo "No such command" ; exit 1 10 | fi 11 | source .env 12 | # get -f 13 | shift 14 | PARAMS="--device $MPY_DEVICE" 15 | if [[ "$1" == "-f" ]] 16 | then PARAMS="$PARAMS --follow" ; shift 17 | fi 18 | # collect args 19 | ARGS="'$TOOL.py'" 20 | for i in "$@" 21 | do ARGS="$ARGS, '$i'" 22 | done 23 | python3 util/pyboard.py $PARAMS -c "args=[$ARGS]" "tools/$TOOL.py" 24 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd "$(dirname $0)" 3 | MPY_DEVICE=${1:?serial device} 4 | echo "MPY_DEVICE=$MPY_DEVICE" >.env 5 | for i in lib/*.py 6 | do python3 util/pyboard.py --device "$MPY_DEVICE" \ 7 | -f cp "$i" ":${i##lib/}" 8 | done 9 | -------------------------------------------------------------------------------- /tools/bleSniffer.py: -------------------------------------------------------------------------------- 1 | from ubluetooth import BLE, UUID, FLAG_NOTIFY, FLAG_READ, FLAG_WRITE 2 | from micropython import const 3 | from bleADV import ADV_TYPES, ADV, ADVData, buildAdvDataTypes 4 | 5 | IRQ_SCAN_RESULT = const(1 << 4) 6 | IRQ_SCAN_COMPLETE = const(1 << 5) 7 | 8 | def bt_irq(event, data): 9 | if event == IRQ_SCAN_RESULT: 10 | # A single scan result. 11 | addr_type, addr, adv_type, rssi, adv_data = data 12 | if adv_type in ADV_TYPES: 13 | 14 | advs = buildAdvDataTypes(adv_data) 15 | 16 | adv = ADV(adv_type, rssi, addr, addr_type, advs) 17 | 18 | print("\r\n%s" %(repr(adv),)) 19 | for it in advs: 20 | print("\r\n%s" %(str(it))) 21 | 22 | elif event == IRQ_SCAN_COMPLETE: 23 | # Scan duration finished or manually stopped. 24 | print('scan complete') 25 | 26 | def sniffer(time): 27 | # Scan continuosly 28 | bt = BLE() 29 | bt.active(True) 30 | bt.irq(handler=bt_irq) 31 | print("Scanning for %dms..." % time, end="") 32 | bt.gap_scan(time, 10, 10) 33 | print("DONE!") 34 | 35 | if __name__ == '__main__': 36 | print(args) 37 | if len(args) > 1: 38 | time = int(args[1]) 39 | else: 40 | time = 0 41 | print(time) 42 | sniffer(time) 43 | -------------------------------------------------------------------------------- /util/pyboard.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # This file is part of the MicroPython project, http://micropython.org/ 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Copyright (c) 2014-2019 Damien P. George 8 | # Copyright (c) 2017 Paul Sokolovsky 9 | # 10 | # Permission is hereby granted, free of charge, to any person obtaining a copy 11 | # of this software and associated documentation files (the "Software"), to deal 12 | # in the Software without restriction, including without limitation the rights 13 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | # copies of the Software, and to permit persons to whom the Software is 15 | # furnished to do so, subject to the following conditions: 16 | # 17 | # The above copyright notice and this permission notice shall be included in 18 | # all copies or substantial portions of the Software. 19 | # 20 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 26 | # THE SOFTWARE. 27 | 28 | """ 29 | pyboard interface 30 | 31 | This module provides the Pyboard class, used to communicate with and 32 | control a MicroPython device over a communication channel. Both real 33 | boards and emulated devices (e.g. running in QEMU) are supported. 34 | Various communication channels are supported, including a serial 35 | connection, telnet-style network connection, external process 36 | connection. 37 | 38 | Example usage: 39 | 40 | import pyboard 41 | pyb = pyboard.Pyboard('/dev/ttyACM0') 42 | 43 | Or: 44 | 45 | pyb = pyboard.Pyboard('192.168.1.1') 46 | 47 | Then: 48 | 49 | pyb.enter_raw_repl() 50 | pyb.exec('import pyb') 51 | pyb.exec('pyb.LED(1).on()') 52 | pyb.exit_raw_repl() 53 | 54 | Note: if using Python2 then pyb.exec must be written as pyb.exec_. 55 | To run a script from the local machine on the board and print out the results: 56 | 57 | import pyboard 58 | pyboard.execfile('test.py', device='/dev/ttyACM0') 59 | 60 | This script can also be run directly. To execute a local script, use: 61 | 62 | ./pyboard.py test.py 63 | 64 | Or: 65 | 66 | python pyboard.py test.py 67 | 68 | """ 69 | 70 | import sys 71 | import time 72 | import os 73 | 74 | try: 75 | stdout = sys.stdout.buffer 76 | except AttributeError: 77 | # Python2 doesn't have buffer attr 78 | stdout = sys.stdout 79 | 80 | 81 | def stdout_write_bytes(b): 82 | b = b.replace(b"\x04", b"") 83 | stdout.write(b) 84 | stdout.flush() 85 | 86 | 87 | class PyboardError(Exception): 88 | pass 89 | 90 | 91 | class TelnetToSerial: 92 | def __init__(self, ip, user, password, read_timeout=None): 93 | self.tn = None 94 | import telnetlib 95 | 96 | self.tn = telnetlib.Telnet(ip, timeout=15) 97 | self.read_timeout = read_timeout 98 | if b"Login as:" in self.tn.read_until(b"Login as:", timeout=read_timeout): 99 | self.tn.write(bytes(user, "ascii") + b"\r\n") 100 | 101 | if b"Password:" in self.tn.read_until(b"Password:", timeout=read_timeout): 102 | # needed because of internal implementation details of the telnet server 103 | time.sleep(0.2) 104 | self.tn.write(bytes(password, "ascii") + b"\r\n") 105 | 106 | if b"for more information." in self.tn.read_until( 107 | b'Type "help()" for more information.', timeout=read_timeout 108 | ): 109 | # login successful 110 | from collections import deque 111 | 112 | self.fifo = deque() 113 | return 114 | 115 | raise PyboardError("Failed to establish a telnet connection with the board") 116 | 117 | def __del__(self): 118 | self.close() 119 | 120 | def close(self): 121 | if self.tn: 122 | self.tn.close() 123 | 124 | def read(self, size=1): 125 | while len(self.fifo) < size: 126 | timeout_count = 0 127 | data = self.tn.read_eager() 128 | if len(data): 129 | self.fifo.extend(data) 130 | timeout_count = 0 131 | else: 132 | time.sleep(0.25) 133 | if self.read_timeout is not None and timeout_count > 4 * self.read_timeout: 134 | break 135 | timeout_count += 1 136 | 137 | data = b"" 138 | while len(data) < size and len(self.fifo) > 0: 139 | data += bytes([self.fifo.popleft()]) 140 | return data 141 | 142 | def write(self, data): 143 | self.tn.write(data) 144 | return len(data) 145 | 146 | def inWaiting(self): 147 | n_waiting = len(self.fifo) 148 | if not n_waiting: 149 | data = self.tn.read_eager() 150 | self.fifo.extend(data) 151 | return len(data) 152 | else: 153 | return n_waiting 154 | 155 | 156 | class ProcessToSerial: 157 | "Execute a process and emulate serial connection using its stdin/stdout." 158 | 159 | def __init__(self, cmd): 160 | import subprocess 161 | 162 | self.subp = subprocess.Popen( 163 | cmd, 164 | bufsize=0, 165 | shell=True, 166 | preexec_fn=os.setsid, 167 | stdin=subprocess.PIPE, 168 | stdout=subprocess.PIPE, 169 | ) 170 | 171 | # Initially was implemented with selectors, but that adds Python3 172 | # dependency. However, there can be race conditions communicating 173 | # with a particular child process (like QEMU), and selectors may 174 | # still work better in that case, so left inplace for now. 175 | # 176 | # import selectors 177 | # self.sel = selectors.DefaultSelector() 178 | # self.sel.register(self.subp.stdout, selectors.EVENT_READ) 179 | 180 | import select 181 | 182 | self.poll = select.poll() 183 | self.poll.register(self.subp.stdout.fileno()) 184 | 185 | def close(self): 186 | import signal 187 | 188 | os.killpg(os.getpgid(self.subp.pid), signal.SIGTERM) 189 | 190 | def read(self, size=1): 191 | data = b"" 192 | while len(data) < size: 193 | data += self.subp.stdout.read(size - len(data)) 194 | return data 195 | 196 | def write(self, data): 197 | self.subp.stdin.write(data) 198 | return len(data) 199 | 200 | def inWaiting(self): 201 | # res = self.sel.select(0) 202 | res = self.poll.poll(0) 203 | if res: 204 | return 1 205 | return 0 206 | 207 | 208 | class ProcessPtyToTerminal: 209 | """Execute a process which creates a PTY and prints slave PTY as 210 | first line of its output, and emulate serial connection using 211 | this PTY.""" 212 | 213 | def __init__(self, cmd): 214 | import subprocess 215 | import re 216 | import serial 217 | 218 | self.subp = subprocess.Popen( 219 | cmd.split(), 220 | bufsize=0, 221 | shell=False, 222 | preexec_fn=os.setsid, 223 | stdin=subprocess.PIPE, 224 | stdout=subprocess.PIPE, 225 | stderr=subprocess.PIPE, 226 | ) 227 | pty_line = self.subp.stderr.readline().decode("utf-8") 228 | m = re.search(r"/dev/pts/[0-9]+", pty_line) 229 | if not m: 230 | print("Error: unable to find PTY device in startup line:", pty_line) 231 | self.close() 232 | sys.exit(1) 233 | pty = m.group() 234 | # rtscts, dsrdtr params are to workaround pyserial bug: 235 | # http://stackoverflow.com/questions/34831131/pyserial-does-not-play-well-with-virtual-port 236 | self.ser = serial.Serial(pty, interCharTimeout=1, rtscts=True, dsrdtr=True) 237 | 238 | def close(self): 239 | import signal 240 | 241 | os.killpg(os.getpgid(self.subp.pid), signal.SIGTERM) 242 | 243 | def read(self, size=1): 244 | return self.ser.read(size) 245 | 246 | def write(self, data): 247 | return self.ser.write(data) 248 | 249 | def inWaiting(self): 250 | return self.ser.inWaiting() 251 | 252 | 253 | class Pyboard: 254 | def __init__(self, device, baudrate=115200, user="micro", password="python", wait=0): 255 | if device.startswith("exec:"): 256 | self.serial = ProcessToSerial(device[len("exec:") :]) 257 | elif device.startswith("execpty:"): 258 | self.serial = ProcessPtyToTerminal(device[len("qemupty:") :]) 259 | elif device and device[0].isdigit() and device[-1].isdigit() and device.count(".") == 3: 260 | # device looks like an IP address 261 | self.serial = TelnetToSerial(device, user, password, read_timeout=10) 262 | else: 263 | import serial 264 | 265 | delayed = False 266 | for attempt in range(wait + 1): 267 | try: 268 | self.serial = serial.Serial(device, baudrate=baudrate, interCharTimeout=1) 269 | break 270 | except (OSError, IOError): # Py2 and Py3 have different errors 271 | if wait == 0: 272 | continue 273 | if attempt == 0: 274 | sys.stdout.write("Waiting {} seconds for pyboard ".format(wait)) 275 | delayed = True 276 | time.sleep(1) 277 | sys.stdout.write(".") 278 | sys.stdout.flush() 279 | else: 280 | if delayed: 281 | print("") 282 | raise PyboardError("failed to access " + device) 283 | if delayed: 284 | print("") 285 | 286 | def close(self): 287 | self.serial.close() 288 | 289 | def read_until(self, min_num_bytes, ending, timeout=10, data_consumer=None): 290 | # if data_consumer is used then data is not accumulated and the ending must be 1 byte long 291 | assert data_consumer is None or len(ending) == 1 292 | 293 | data = self.serial.read(min_num_bytes) 294 | if data_consumer: 295 | data_consumer(data) 296 | timeout_count = 0 297 | while True: 298 | if data.endswith(ending): 299 | break 300 | elif self.serial.inWaiting() > 0: 301 | new_data = self.serial.read(1) 302 | if data_consumer: 303 | data_consumer(new_data) 304 | data = new_data 305 | else: 306 | data = data + new_data 307 | timeout_count = 0 308 | else: 309 | timeout_count += 1 310 | if timeout is not None and timeout_count >= 100 * timeout: 311 | break 312 | time.sleep(0.01) 313 | return data 314 | 315 | def enter_raw_repl(self): 316 | self.serial.write(b"\r\x03\x03") # ctrl-C twice: interrupt any running program 317 | 318 | # flush input (without relying on serial.flushInput()) 319 | n = self.serial.inWaiting() 320 | while n > 0: 321 | self.serial.read(n) 322 | n = self.serial.inWaiting() 323 | 324 | self.serial.write(b"\r\x01") # ctrl-A: enter raw REPL 325 | data = self.read_until(1, b"raw REPL; CTRL-B to exit\r\n>") 326 | if not data.endswith(b"raw REPL; CTRL-B to exit\r\n>"): 327 | print(data) 328 | raise PyboardError("could not enter raw repl") 329 | 330 | self.serial.write(b"\x04") # ctrl-D: soft reset 331 | data = self.read_until(1, b"soft reboot\r\n") 332 | if not data.endswith(b"soft reboot\r\n"): 333 | print(data) 334 | raise PyboardError("could not enter raw repl") 335 | # By splitting this into 2 reads, it allows boot.py to print stuff, 336 | # which will show up after the soft reboot and before the raw REPL. 337 | data = self.read_until(1, b"raw REPL; CTRL-B to exit\r\n") 338 | if not data.endswith(b"raw REPL; CTRL-B to exit\r\n"): 339 | print(data) 340 | raise PyboardError("could not enter raw repl") 341 | 342 | def exit_raw_repl(self): 343 | self.serial.write(b"\r\x02") # ctrl-B: enter friendly REPL 344 | 345 | def follow(self, timeout, data_consumer=None): 346 | # wait for normal output 347 | data = self.read_until(1, b"\x04", timeout=timeout, data_consumer=data_consumer) 348 | if not data.endswith(b"\x04"): 349 | raise PyboardError("timeout waiting for first EOF reception") 350 | data = data[:-1] 351 | 352 | # wait for error output 353 | data_err = self.read_until(1, b"\x04", timeout=timeout) 354 | if not data_err.endswith(b"\x04"): 355 | raise PyboardError("timeout waiting for second EOF reception") 356 | data_err = data_err[:-1] 357 | 358 | # return normal and error output 359 | return data, data_err 360 | 361 | def exec_raw_no_follow(self, command): 362 | if isinstance(command, bytes): 363 | command_bytes = command 364 | else: 365 | command_bytes = bytes(command, encoding="utf8") 366 | 367 | # check we have a prompt 368 | data = self.read_until(1, b">") 369 | if not data.endswith(b">"): 370 | raise PyboardError("could not enter raw repl") 371 | 372 | # write command 373 | for i in range(0, len(command_bytes), 256): 374 | self.serial.write(command_bytes[i : min(i + 256, len(command_bytes))]) 375 | time.sleep(0.01) 376 | self.serial.write(b"\x04") 377 | 378 | # check if we could exec command 379 | data = self.serial.read(2) 380 | if data != b"OK": 381 | raise PyboardError("could not exec command (response: %r)" % data) 382 | 383 | def exec_raw(self, command, timeout=10, data_consumer=None): 384 | self.exec_raw_no_follow(command) 385 | return self.follow(timeout, data_consumer) 386 | 387 | def eval(self, expression): 388 | ret = self.exec_("print({})".format(expression)) 389 | ret = ret.strip() 390 | return ret 391 | 392 | def exec_(self, command, data_consumer=None): 393 | ret, ret_err = self.exec_raw(command, data_consumer=data_consumer) 394 | if ret_err: 395 | raise PyboardError("exception", ret, ret_err) 396 | return ret 397 | 398 | def execfile(self, filename): 399 | with open(filename, "rb") as f: 400 | pyfile = f.read() 401 | return self.exec_(pyfile) 402 | 403 | def get_time(self): 404 | t = str(self.eval("pyb.RTC().datetime()"), encoding="utf8")[1:-1].split(", ") 405 | return int(t[4]) * 3600 + int(t[5]) * 60 + int(t[6]) 406 | 407 | def fs_ls(self, src): 408 | cmd = ( 409 | "import uos\nfor f in uos.ilistdir(%s):\n" 410 | " print('{:12} {}{}'.format(f[3]if len(f)>3 else 0,f[0],'/'if f[1]&0x4000 else ''))" 411 | % (("'%s'" % src) if src else "") 412 | ) 413 | self.exec_(cmd, data_consumer=stdout_write_bytes) 414 | 415 | def fs_cat(self, src, chunk_size=256): 416 | cmd = ( 417 | "with open('%s') as f:\n while 1:\n" 418 | " b=f.read(%u)\n if not b:break\n print(b,end='')" % (src, chunk_size) 419 | ) 420 | self.exec_(cmd, data_consumer=stdout_write_bytes) 421 | 422 | def fs_get(self, src, dest, chunk_size=256): 423 | self.exec_("f=open('%s','rb')\nr=f.read" % src) 424 | with open(dest, "wb") as f: 425 | while True: 426 | data = bytearray() 427 | self.exec_("print(r(%u))" % chunk_size, data_consumer=lambda d: data.extend(d)) 428 | assert data.endswith(b"\r\n\x04") 429 | data = eval(str(data[:-3], "ascii")) 430 | if not data: 431 | break 432 | f.write(data) 433 | self.exec_("f.close()") 434 | 435 | def fs_put(self, src, dest, chunk_size=256): 436 | self.exec_("f=open('%s','wb')\nw=f.write" % dest) 437 | with open(src, "rb") as f: 438 | while True: 439 | data = f.read(chunk_size) 440 | if not data: 441 | break 442 | if sys.version_info < (3,): 443 | self.exec_("w(b" + repr(data) + ")") 444 | else: 445 | self.exec_("w(" + repr(data) + ")") 446 | self.exec_("f.close()") 447 | 448 | def fs_mkdir(self, dir): 449 | self.exec_("import uos\nuos.mkdir('%s')" % dir) 450 | 451 | def fs_rmdir(self, dir): 452 | self.exec_("import uos\nuos.rmdir('%s')" % dir) 453 | 454 | def fs_rm(self, src): 455 | self.exec_("import uos\nuos.remove('%s')" % src) 456 | 457 | 458 | # in Python2 exec is a keyword so one must use "exec_" 459 | # but for Python3 we want to provide the nicer version "exec" 460 | setattr(Pyboard, "exec", Pyboard.exec_) 461 | 462 | 463 | def execfile(filename, device="/dev/ttyACM0", baudrate=115200, user="micro", password="python"): 464 | pyb = Pyboard(device, baudrate, user, password) 465 | pyb.enter_raw_repl() 466 | output = pyb.execfile(filename) 467 | stdout_write_bytes(output) 468 | pyb.exit_raw_repl() 469 | pyb.close() 470 | 471 | 472 | def filesystem_command(pyb, args): 473 | def fname_remote(src): 474 | if src.startswith(":"): 475 | src = src[1:] 476 | return src 477 | 478 | def fname_cp_dest(src, dest): 479 | src = src.rsplit("/", 1)[-1] 480 | if dest is None or dest == "": 481 | dest = src 482 | elif dest == ".": 483 | dest = "./" + src 484 | elif dest.endswith("/"): 485 | dest += src 486 | return dest 487 | 488 | cmd = args[0] 489 | args = args[1:] 490 | try: 491 | if cmd == "cp": 492 | srcs = args[:-1] 493 | dest = args[-1] 494 | if srcs[0].startswith("./") or dest.startswith(":"): 495 | op = pyb.fs_put 496 | fmt = "cp %s :%s" 497 | dest = fname_remote(dest) 498 | else: 499 | op = pyb.fs_get 500 | fmt = "cp :%s %s" 501 | for src in srcs: 502 | src = fname_remote(src) 503 | dest2 = fname_cp_dest(src, dest) 504 | print(fmt % (src, dest2)) 505 | op(src, dest2) 506 | else: 507 | op = { 508 | "ls": pyb.fs_ls, 509 | "cat": pyb.fs_cat, 510 | "mkdir": pyb.fs_mkdir, 511 | "rmdir": pyb.fs_rmdir, 512 | "rm": pyb.fs_rm, 513 | }[cmd] 514 | if cmd == "ls" and not args: 515 | args = [""] 516 | for src in args: 517 | src = fname_remote(src) 518 | print("%s :%s" % (cmd, src)) 519 | op(src) 520 | except PyboardError as er: 521 | print(str(er.args[2], "ascii")) 522 | pyb.exit_raw_repl() 523 | pyb.close() 524 | sys.exit(1) 525 | 526 | 527 | _injected_import_hook_code = """\ 528 | import uos, uio 529 | class _FS: 530 | class File(uio.IOBase): 531 | def __init__(self): 532 | self.off = 0 533 | def ioctl(self, request, arg): 534 | return 0 535 | def readinto(self, buf): 536 | buf[:] = memoryview(_injected_buf)[self.off:self.off + len(buf)] 537 | self.off += len(buf) 538 | return len(buf) 539 | mount = umount = chdir = lambda *args: None 540 | def stat(self, path): 541 | if path == '_injected.mpy': 542 | return tuple(0 for _ in range(10)) 543 | else: 544 | raise OSError(-2) # ENOENT 545 | def open(self, path, mode): 546 | return self.File() 547 | uos.mount(_FS(), '/_') 548 | uos.chdir('/_') 549 | from _injected import * 550 | uos.umount('/_') 551 | del _injected_buf, _FS 552 | """ 553 | 554 | 555 | def main(): 556 | import argparse 557 | 558 | cmd_parser = argparse.ArgumentParser(description="Run scripts on the pyboard.") 559 | cmd_parser.add_argument( 560 | "-d", 561 | "--device", 562 | default=os.environ.get("PYBOARD_DEVICE", "/dev/ttyACM0"), 563 | help="the serial device or the IP address of the pyboard", 564 | ) 565 | cmd_parser.add_argument( 566 | "-b", 567 | "--baudrate", 568 | default=os.environ.get("PYBOARD_BAUDRATE", "115200"), 569 | help="the baud rate of the serial device", 570 | ) 571 | cmd_parser.add_argument("-u", "--user", default="micro", help="the telnet login username") 572 | cmd_parser.add_argument("-p", "--password", default="python", help="the telnet login password") 573 | cmd_parser.add_argument("-c", "--command", help="program passed in as string") 574 | cmd_parser.add_argument( 575 | "-w", 576 | "--wait", 577 | default=0, 578 | type=int, 579 | help="seconds to wait for USB connected board to become available", 580 | ) 581 | group = cmd_parser.add_mutually_exclusive_group() 582 | group.add_argument( 583 | "--follow", 584 | action="store_true", 585 | help="follow the output after running the scripts [default if no scripts given]", 586 | ) 587 | group.add_argument( 588 | "--no-follow", 589 | action="store_true", 590 | help="Do not follow the output after running the scripts.", 591 | ) 592 | cmd_parser.add_argument( 593 | "-f", "--filesystem", action="store_true", help="perform a filesystem action" 594 | ) 595 | cmd_parser.add_argument("files", nargs="*", help="input files") 596 | args = cmd_parser.parse_args() 597 | 598 | # open the connection to the pyboard 599 | try: 600 | pyb = Pyboard(args.device, args.baudrate, args.user, args.password, args.wait) 601 | except PyboardError as er: 602 | print(er) 603 | sys.exit(1) 604 | 605 | # run any command or file(s) 606 | if args.command is not None or args.filesystem or len(args.files): 607 | # we must enter raw-REPL mode to execute commands 608 | # this will do a soft-reset of the board 609 | try: 610 | pyb.enter_raw_repl() 611 | except PyboardError as er: 612 | print(er) 613 | pyb.close() 614 | sys.exit(1) 615 | 616 | def execbuffer(buf): 617 | try: 618 | if args.no_follow: 619 | pyb.exec_raw_no_follow(buf) 620 | ret_err = None 621 | else: 622 | ret, ret_err = pyb.exec_raw( 623 | buf, timeout=None, data_consumer=stdout_write_bytes 624 | ) 625 | except PyboardError as er: 626 | print(er) 627 | pyb.close() 628 | sys.exit(1) 629 | except KeyboardInterrupt: 630 | sys.exit(1) 631 | if ret_err: 632 | pyb.exit_raw_repl() 633 | pyb.close() 634 | stdout_write_bytes(ret_err) 635 | sys.exit(1) 636 | 637 | # do filesystem commands, if given 638 | if args.filesystem: 639 | filesystem_command(pyb, args.files) 640 | del args.files[:] 641 | 642 | # run the command, if given 643 | if args.command is not None: 644 | execbuffer(args.command.encode("utf-8")) 645 | 646 | # run any files 647 | for filename in args.files: 648 | with open(filename, "rb") as f: 649 | pyfile = f.read() 650 | if filename.endswith(".mpy") and pyfile[0] == ord("M"): 651 | pyb.exec_("_injected_buf=" + repr(pyfile)) 652 | pyfile = _injected_import_hook_code 653 | execbuffer(pyfile) 654 | 655 | # exiting raw-REPL just drops to friendly-REPL mode 656 | pyb.exit_raw_repl() 657 | 658 | # if asked explicitly, or no files given, then follow the output 659 | if args.follow or (args.command is None and not args.filesystem and len(args.files) == 0): 660 | try: 661 | ret, ret_err = pyb.follow(timeout=None, data_consumer=stdout_write_bytes) 662 | except PyboardError as er: 663 | print(er) 664 | sys.exit(1) 665 | except KeyboardInterrupt: 666 | sys.exit(1) 667 | if ret_err: 668 | pyb.close() 669 | stdout_write_bytes(ret_err) 670 | sys.exit(1) 671 | 672 | # close the connection to the pyboard 673 | pyb.close() 674 | 675 | 676 | if __name__ == "__main__": 677 | main() 678 | --------------------------------------------------------------------------------