mpytool 1.2.0__py3-none-any.whl → 2.1.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.
mpytool/__init__.py CHANGED
@@ -2,5 +2,7 @@
2
2
 
3
3
  from mpytool.conn import ConnError, Timeout
4
4
  from mpytool.conn_serial import ConnSerial
5
+ from mpytool.conn_socket import ConnSocket
5
6
  from mpytool.mpy_comm import MpyError, CmdError
6
7
  from mpytool.mpy import Mpy
8
+ from mpytool.logger import SimpleColorLogger
mpytool/conn.py CHANGED
@@ -1,5 +1,8 @@
1
1
  """MicroPython tool: abstract connector"""
2
2
 
3
+ import time as _time
4
+ import select as _select
5
+
3
6
 
4
7
  class ConnError(Exception):
5
8
  """General connection error"""
@@ -12,27 +15,115 @@ class Timeout(ConnError):
12
15
  class Conn():
13
16
  def __init__(self, log=None):
14
17
  self._log = log
18
+ self._buffer = bytearray(b'')
15
19
 
16
20
  @property
17
21
  def fd(self):
18
- """Return file descriptor
19
- """
22
+ """Return file descriptor for select()"""
20
23
  return None
21
24
 
22
- def read(self):
23
- """Read available data from device
24
- """
25
+ def _has_data(self, timeout=0):
26
+ """Check if data is available to read using select()"""
27
+ fd = self.fd
28
+ if fd is None:
29
+ return False
30
+ readable, _, _ = _select.select([fd], [], [], timeout)
31
+ return bool(readable)
32
+
33
+ def _read_available(self):
34
+ """Read available data from device (must be implemented by subclass)"""
35
+ raise NotImplementedError
25
36
 
26
- def write(self, data, chunk_size=128, delay=0.01):
27
- """Write to device
37
+ def _write_raw(self, data):
38
+ """Write data to device, return bytes written (must be implemented by subclass)"""
39
+ raise NotImplementedError
40
+
41
+ def _read_to_buffer(self, wait_timeout=0):
42
+ """Read available data into buffer
43
+
44
+ Arguments:
45
+ wait_timeout: how long to wait for data (0 = non-blocking)
28
46
  """
47
+ if self._has_data(wait_timeout):
48
+ data = self._read_available()
49
+ if data:
50
+ self._buffer += data
51
+ return True
52
+ return False
53
+
54
+ def flush(self):
55
+ """Flush and return buffer contents"""
56
+ buffer = bytes(self._buffer)
57
+ del self._buffer[:]
58
+ return buffer
59
+
60
+ def read(self):
61
+ """Read available data from device"""
62
+ if self._has_data():
63
+ return self._read_available()
64
+ return None
65
+
66
+ def read_bytes(self, count, timeout=1):
67
+ """Read exactly count bytes from device"""
68
+ start_time = _time.time()
69
+ while len(self._buffer) < count:
70
+ if self._read_to_buffer(wait_timeout=0.001):
71
+ start_time = _time.time()
72
+ if timeout is not None and start_time + timeout < _time.time():
73
+ if self._buffer:
74
+ raise Timeout(
75
+ f"During timeout received: {bytes(self._buffer)}")
76
+ raise Timeout("No data received")
77
+ data = bytes(self._buffer[:count])
78
+ del self._buffer[:count]
79
+ if self._log:
80
+ self._log.debug("rd: %s", data)
81
+ return data
82
+
83
+ def write(self, data):
84
+ """Write data to device"""
85
+ if self._log:
86
+ self._log.debug("wr: %s", bytes(data))
87
+ while data:
88
+ count = self._write_raw(data)
89
+ data = data[count:]
29
90
 
30
91
  def read_until(self, end, timeout=1):
31
- """Read until
32
- """
33
- return ''
92
+ """Read until end marker is found"""
93
+ if self._log:
94
+ self._log.debug("wait for %s", end)
95
+ start_time = _time.time()
96
+ while True:
97
+ # Use select() with 1ms timeout instead of sleep - wakes immediately on data
98
+ if self._read_to_buffer(wait_timeout=0.001):
99
+ start_time = _time.time() # reset timeout on data received
100
+ if end in self._buffer:
101
+ break
102
+ if timeout is not None and start_time + timeout < _time.time():
103
+ if self._buffer:
104
+ raise Timeout(
105
+ f"During timeout received: {bytes(self._buffer)}")
106
+ raise Timeout("No data received")
107
+ index = self._buffer.index(end)
108
+ data = self._buffer[:index]
109
+ del self._buffer[:index + len(end)]
110
+ if self._log:
111
+ self._log.debug("rd: %s", bytes(data + end))
112
+ return data
34
113
 
35
114
  def read_line(self, timeout=None):
36
- """Read signle line"""
115
+ """Read single line"""
37
116
  line = self.read_until(b'\n', timeout)
38
117
  return line.strip(b'\r')
118
+
119
+ def hard_reset(self):
120
+ """Hardware reset (only available on serial connections)"""
121
+ raise NotImplementedError("Hardware reset not available on this connection")
122
+
123
+ def reset_to_bootloader(self):
124
+ """Reset into bootloader mode (only available on serial connections)"""
125
+ raise NotImplementedError("Reset to bootloader not available on this connection")
126
+
127
+ def reconnect(self, timeout=None):
128
+ """Reconnect after device reset (only available on serial connections)"""
129
+ raise NotImplementedError("Reconnect not available on this connection")
mpytool/conn_serial.py CHANGED
@@ -6,62 +6,106 @@ import mpytool.conn as _conn
6
6
 
7
7
 
8
8
  class ConnSerial(_conn.Conn):
9
+ RECONNECT_TIMEOUT = 5 # seconds
10
+
9
11
  def __init__(self, log=None, **serial_config):
10
12
  super().__init__(log)
11
- self._serial = _serial.Serial(**serial_config)
12
- self._buffer = bytearray(b'')
13
+ self._serial_config = serial_config
14
+ try:
15
+ self._serial = _serial.Serial(**serial_config)
16
+ except _serial.serialutil.SerialException as err:
17
+ self._serial = None
18
+ raise _conn.ConnError(
19
+ f"Error opening serial port {serial_config['port']}") from err
13
20
 
14
21
  def __del__(self):
15
22
  if self._serial:
16
23
  self._serial.close()
17
24
 
18
- def _read_to_buffer(self):
19
- in_waiting = self._serial.in_waiting
20
- if in_waiting > 0:
21
- self._buffer += self._serial.read(in_waiting)
22
- return True
23
- return False
24
-
25
25
  @property
26
26
  def fd(self):
27
- return self._serial.fd
27
+ return self._serial.fd if self._serial else None
28
28
 
29
- def read(self):
30
- self._read_to_buffer()
31
- if self._buffer:
32
- data = self._buffer[:]
33
- del self._buffer[:]
34
- return data
29
+ def _read_available(self):
30
+ """Read available data from serial port"""
31
+ in_waiting = self._serial.in_waiting
32
+ if in_waiting > 0:
33
+ return self._serial.read(in_waiting)
35
34
  return None
36
35
 
37
- def write(self, data, chunk_size=128, delay=0.01):
38
- if self._log:
39
- self._log.debug(f"wr: {data}")
40
- while data:
41
- chunk = data[:chunk_size]
42
- count = self._serial.write(chunk)
43
- data = data[count:]
44
- _time.sleep(delay)
36
+ def _write_raw(self, data):
37
+ """Write data to serial port"""
38
+ return self._serial.write(data)
39
+
40
+ def _is_usb_cdc(self):
41
+ """Check if this is a USB-CDC port (native USB on ESP32-S/C)"""
42
+ port = self._serial.port or ''
43
+ return 'ACM' in port or 'usbmodem' in port
44
+
45
+ def hard_reset(self):
46
+ """Hardware reset using DTR/RTS signals"""
47
+ self._serial.setDTR(False) # GPIO0 high (normal boot)
48
+ self._serial.setRTS(True) # Assert reset
49
+ _time.sleep(0.1)
50
+ self._serial.setRTS(False) # Release reset
51
+
52
+ def reconnect(self, timeout=None):
53
+ """Close and reopen serial port (for USB-CDC reconnect after reset)"""
54
+ if timeout is None:
55
+ timeout = self.RECONNECT_TIMEOUT
56
+ port = self._serial_config.get('port', 'unknown')
57
+ if self._serial:
58
+ try:
59
+ self._serial.close()
60
+ except (OSError, _serial.serialutil.SerialException):
61
+ pass # Port may already be gone
62
+ self._serial = None
63
+ start = _time.time()
64
+ while _time.time() - start < timeout:
65
+ try:
66
+ self._serial = _serial.Serial(**self._serial_config)
67
+ del self._buffer[:] # Clear any stale data
68
+ _time.sleep(0.5) # Wait for device to stabilize
69
+ # Verify connection is stable
70
+ _ = self._serial.in_waiting
71
+ return True
72
+ except (_serial.serialutil.SerialException, OSError):
73
+ if self._serial:
74
+ try:
75
+ self._serial.close()
76
+ except (OSError, _serial.serialutil.SerialException):
77
+ pass
78
+ self._serial = None
79
+ _time.sleep(0.1)
80
+ raise _conn.ConnError(f"Could not reconnect to {port} within {timeout}s")
81
+
82
+ def reset_to_bootloader(self):
83
+ """Reset into bootloader mode (auto-detects USB-CDC vs USB-UART)"""
84
+ if self._is_usb_cdc():
85
+ self._reset_to_bootloader_usb_jtag()
86
+ else:
87
+ self._reset_to_bootloader_classic()
88
+
89
+ def _reset_to_bootloader_usb_jtag(self):
90
+ """Bootloader reset for USB-JTAG-Serial (esptool sequence)"""
91
+ self._serial.setRTS(False)
92
+ self._serial.setDTR(False)
93
+ _time.sleep(0.1)
94
+ self._serial.setDTR(True)
95
+ self._serial.setRTS(True)
96
+ _time.sleep(0.1)
97
+ self._serial.setDTR(False)
98
+ self._serial.setRTS(True)
99
+ _time.sleep(0.1)
100
+ self._serial.setDTR(False)
101
+ self._serial.setRTS(False)
45
102
 
46
- def read_until(self, end, timeout=1):
47
- if self._log:
48
- self._log.debug(f'wait for {end}')
49
- start_time = _time.time()
50
- while True:
51
- if self._read_to_buffer():
52
- start_time = _time.time()
53
- if end in self._buffer:
54
- break
55
- if timeout is not None and start_time + timeout < _time.time():
56
- if self._buffer:
57
- raise _conn.Timeout(
58
- f"During timeout received: {bytes(self._buffer)}")
59
- raise _conn.Timeout("No data received")
60
- _time.sleep(.01)
61
- index = self._buffer.index(end)
62
- data = self._buffer[:index]
63
- del self._buffer[:index + len(end)]
64
- # data, self._buffer = self._buffer.split(end, 1)
65
- if self._log:
66
- self._log.debug(f"rd: {data + end}")
67
- return data
103
+ def _reset_to_bootloader_classic(self):
104
+ """Bootloader reset for USB-UART (classic DTR/RTS circuit)"""
105
+ self._serial.setDTR(False) # GPIO0 high
106
+ self._serial.setRTS(True) # Assert reset
107
+ _time.sleep(0.1)
108
+ self._serial.setDTR(True) # GPIO0 low (bootloader)
109
+ self._serial.setRTS(False) # Release reset
110
+ _time.sleep(0.05)
111
+ self._serial.setDTR(False) # GPIO0 high
mpytool/conn_socket.py ADDED
@@ -0,0 +1,53 @@
1
+ """MicroPython tool: socket connector"""
2
+
3
+ import socket as _socket
4
+ import mpytool.conn as _conn
5
+
6
+
7
+ class ConnSocket(_conn.Conn):
8
+ def __init__(self, address, log=None):
9
+ super().__init__(log)
10
+ self._socket = None
11
+ sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
12
+ sock.settimeout(5)
13
+ if ':' in address:
14
+ host, port = address.split(':')
15
+ port = int(port)
16
+ else:
17
+ host = address
18
+ port = 23
19
+ if log:
20
+ log.info(f"Connecting to: {host}:{port}")
21
+ try:
22
+ sock.connect((host, port))
23
+ except _socket.timeout as err:
24
+ raise _conn.ConnError(f"Timeout connecting to {host}:{port}") from err
25
+ except _socket.error as err:
26
+ raise _conn.ConnError(f"Cannot connect to {host}:{port}: {err}") from err
27
+ sock.settimeout(None)
28
+ sock.setblocking(False)
29
+ self._socket = sock
30
+ if log:
31
+ log.info("connected")
32
+
33
+ def __del__(self):
34
+ if self._socket:
35
+ self._socket.close()
36
+
37
+ @property
38
+ def fd(self):
39
+ return self._socket.fileno() if self._socket else None
40
+
41
+ def _read_available(self):
42
+ """Read available data from socket"""
43
+ try:
44
+ data = self._socket.recv(4096)
45
+ if data:
46
+ return data
47
+ except BlockingIOError:
48
+ pass
49
+ return None
50
+
51
+ def _write_raw(self, data):
52
+ """Write data to socket"""
53
+ return self._socket.send(data)
mpytool/logger.py ADDED
@@ -0,0 +1,87 @@
1
+ """Simple color logger for mpytool"""
2
+
3
+ import os as _os
4
+ import sys as _sys
5
+
6
+
7
+ class SimpleColorLogger():
8
+ # ANSI color codes
9
+ _RESET = '\033[0m'
10
+ _BOLD_RED = '\033[1;31m'
11
+ _BOLD_YELLOW = '\033[1;33m'
12
+ _BOLD_MAGENTA = '\033[1;35m'
13
+ _BOLD_BLUE = '\033[1;34m'
14
+ _BOLD_GREEN = '\033[1;32m'
15
+ _BOLD_CYAN = '\033[1;36m'
16
+ _CLEAR_LINE = '\033[K'
17
+
18
+ # Color names for verbose()
19
+ COLORS = {
20
+ 'red': _BOLD_RED,
21
+ 'yellow': _BOLD_YELLOW,
22
+ 'magenta': _BOLD_MAGENTA,
23
+ 'blue': _BOLD_BLUE,
24
+ 'green': _BOLD_GREEN,
25
+ 'cyan': _BOLD_CYAN,
26
+ }
27
+
28
+ def __init__(self, loglevel=1, verbose_level=0):
29
+ self._loglevel = loglevel
30
+ self._verbose_level = verbose_level
31
+ self._is_tty = (
32
+ _sys.stderr.isatty()
33
+ and _os.environ.get('NO_COLOR') is None
34
+ and _os.environ.get('TERM') != 'dumb'
35
+ and _os.environ.get('CI') is None
36
+ )
37
+
38
+ def log(self, msg):
39
+ print(msg, file=_sys.stderr)
40
+
41
+ def error(self, msg, *args):
42
+ if args:
43
+ msg = msg % args
44
+ if self._loglevel >= 1:
45
+ if self._is_tty:
46
+ self.log(f"{self._BOLD_RED}{msg}{self._RESET}")
47
+ else:
48
+ self.log(f"E: {msg}")
49
+
50
+ def warning(self, msg, *args):
51
+ if args:
52
+ msg = msg % args
53
+ if self._loglevel >= 2:
54
+ if self._is_tty:
55
+ self.log(f"{self._BOLD_YELLOW}{msg}{self._RESET}")
56
+ else:
57
+ self.log(f"W: {msg}")
58
+
59
+ def info(self, msg, *args):
60
+ if args:
61
+ msg = msg % args
62
+ if self._loglevel >= 3:
63
+ if self._is_tty:
64
+ self.log(f"{self._BOLD_MAGENTA}{msg}{self._RESET}")
65
+ else:
66
+ self.log(f"I: {msg}")
67
+
68
+ def debug(self, msg, *args):
69
+ if args:
70
+ msg = msg % args
71
+ if self._loglevel >= 4:
72
+ if self._is_tty:
73
+ self.log(f"{self._BOLD_BLUE}{msg}{self._RESET}")
74
+ else:
75
+ self.log(f"D: {msg}")
76
+
77
+ def verbose(self, msg, level=1, color='green', end='\n', overwrite=False):
78
+ """Print verbose message if verbose_level >= level"""
79
+ if self._verbose_level < level:
80
+ return
81
+ # Skip progress updates (overwrite without newline) in non-TTY mode
82
+ if overwrite and not self._is_tty and end != '\n':
83
+ return
84
+ color_code = self.COLORS.get(color, self._BOLD_GREEN) if self._is_tty else ''
85
+ reset_code = self._RESET if self._is_tty else ''
86
+ clear = f'\r{self._CLEAR_LINE}' if self._is_tty and overwrite else ''
87
+ print(f"{clear}{color_code}{msg}{reset_code}", end=end, file=_sys.stderr, flush=True)