A fork of https://github.com/crosspoint-reader/crosspoint-reader
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Allow screenshot retrieval from device (#820)

## Summary

* Add a small loop in main to be able to receive external commands,
currently being sent via the debugging_monitor
* Implemented command: cmd:SCREENSHOT sends the currently displayed
screen to the monitor, which will then store it to screenshot.bmp

## Additional Context

I was getting annoyed with taking tilted/unsharp photos of the device
screen, so I added the ability to press Enter during the monitor
execution and type SCREENSHOT to send a command. Could be extended in
the future

[screenshot.bmp](https://github.com/user-attachments/files/25213230/screenshot.bmp)

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? No

authored by

jpirnay and committed by
GitHub
7a385d78 0991782f

+244 -66
+229 -66
scripts/debugging_monitor.py
··· 2 2 """ 3 3 ESP32 Serial Monitor with Memory Graph 4 4 5 - This script provides a real-time serial monitor for ESP32 devices with 6 - integrated memory usage graphing capabilities. It reads serial output, 7 - parses memory information, and displays it in both console and graphical form. 5 + This script provides a comprehensive real-time serial monitor for ESP32 devices with 6 + integrated memory usage graphing capabilities. It reads serial output, parses memory 7 + information, and displays it in both console and graphical form. 8 + 9 + Features: 10 + - Real-time serial output monitoring with color-coded log levels 11 + - Interactive memory usage graphing with matplotlib 12 + - Command input interface for sending commands to the ESP32 device 13 + - Screenshot capture and processing (1-bit black/white format) 14 + - Graceful shutdown handling with Ctrl-C signal processing 15 + - Configurable filtering and suppression of log messages 16 + - Thread-safe operation with coordinated shutdown events 17 + 18 + Usage: 19 + python debugging_monitor.py [port] [options] 20 + 21 + The script will open a matplotlib window showing memory usage over time and provide 22 + an interactive command prompt for sending commands to the device. Press Ctrl-C or 23 + close the graph window to exit gracefully. 8 24 """ 9 25 10 - import sys 26 + from __future__ import annotations 27 + 11 28 import argparse 29 + import glob 30 + import platform 12 31 import re 32 + import signal 33 + import sys 13 34 import threading 35 + from collections import deque 14 36 from datetime import datetime 15 - from collections import deque 16 37 17 38 # Try to import potentially missing packages 18 39 PACKAGE_MAPPING: dict[str, str] = { 19 40 "serial": "pyserial", 20 41 "colorama": "colorama", 21 42 "matplotlib": "matplotlib", 43 + "PIL": "Pillow", 22 44 } 23 45 24 46 try: 47 + import matplotlib.pyplot as plt 25 48 import serial 26 - from colorama import init, Fore, Style 27 - import matplotlib.pyplot as plt 49 + from colorama import Fore, Style, init 28 50 from matplotlib import animation 51 + 52 + try: 53 + from PIL import Image 54 + except ImportError: 55 + Image = None 29 56 except ImportError as e: 30 57 ERROR_MSG = str(e).lower() 31 58 missing_packages = [pkg for mod, pkg in PACKAGE_MAPPING.items() if mod in ERROR_MSG] ··· 52 79 free_mem_data: deque[float] = deque(maxlen=MAX_POINTS) 53 80 total_mem_data: deque[float] = deque(maxlen=MAX_POINTS) 54 81 data_lock: threading.Lock = threading.Lock() # Prevent reading while writing 82 + 83 + # Global shutdown flag 84 + shutdown_event = threading.Event() 55 85 56 86 # Initialize colors 57 87 init(autoreset=True) ··· 121 151 } 122 152 123 153 154 + def signal_handler(signum, frame): 155 + """Handle SIGINT (Ctrl-C) by setting the shutdown event.""" 156 + # frame parameter is required by signal handler signature but not used 157 + del frame # Explicitly mark as unused to satisfy linters 158 + print(f"\n{Fore.YELLOW}Received signal {signum}. Shutting down...{Style.RESET_ALL}") 159 + shutdown_event.set() 160 + plt.close("all") 161 + 162 + 124 163 # pylint: disable=R0912 125 164 def get_color_for_line(line: str) -> str: 126 165 """ ··· 150 189 return None, None 151 190 152 191 153 - def serial_worker(port: str, baud: int, kwargs: dict[str, str]) -> None: 192 + def serial_worker(ser, kwargs: dict[str, str]) -> None: 154 193 """ 155 - Runs in a background thread. Handles reading serial, printing to console, 156 - and updating the data lists. 194 + Runs in a background thread. Handles reading serial data, printing to console, 195 + updating memory usage data for graphing, and processing screenshot data. 196 + Monitors the global shutdown event for graceful termination. 157 197 """ 158 - print(f"{Fore.CYAN}--- Opening {port} at {baud} baud ---{Style.RESET_ALL}") 198 + print(f"{Fore.CYAN}--- Opening serial port ---{Style.RESET_ALL}") 159 199 filter_keyword = kwargs.get("filter", "").lower() 160 200 suppress = kwargs.get("suppress", "").lower() 161 201 if filter_keyword and suppress and filter_keyword == suppress: ··· 173 213 f"{Fore.YELLOW}Suppressing lines containing: '{suppress}'{Style.RESET_ALL}" 174 214 ) 175 215 176 - try: 177 - ser = serial.Serial(port, baud, timeout=0.1) 178 - ser.dtr = False 179 - ser.rts = False 180 - except serial.SerialException as e: 181 - print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}") 182 - return 216 + expecting_screenshot = False 217 + screenshot_size = 0 218 + screenshot_data = b"" 183 219 184 220 try: 185 - while True: 186 - try: 187 - raw_data = ser.readline().decode("utf-8", errors="replace") 188 - 189 - if not raw_data: 221 + while not shutdown_event.is_set(): 222 + if expecting_screenshot: 223 + data = ser.read(screenshot_size - len(screenshot_data)) 224 + if not data: 190 225 continue 226 + screenshot_data += data 227 + if len(screenshot_data) == screenshot_size: 228 + if Image: 229 + img = Image.frombytes("1", (800, 480), screenshot_data) 230 + # We need to rotate the image because the raw data is in landscape mode 231 + img = img.transpose(Image.ROTATE_270) 232 + img.save("screenshot.bmp") 233 + print( 234 + f"{Fore.GREEN}Screenshot saved to screenshot.bmp{Style.RESET_ALL}" 235 + ) 236 + else: 237 + with open("screenshot.raw", "wb") as f: 238 + f.write(screenshot_data) 239 + print( 240 + f"{Fore.GREEN}Screenshot saved to screenshot.raw (PIL not available){Style.RESET_ALL}" 241 + ) 242 + expecting_screenshot = False 243 + screenshot_data = b"" 244 + else: 245 + try: 246 + raw_data = ser.readline().decode("utf-8", errors="replace") 191 247 192 - clean_line = raw_data.strip() 193 - if not clean_line: 194 - continue 248 + if not raw_data: 249 + continue 195 250 196 - # Add PC timestamp 197 - pc_time = datetime.now().strftime("%H:%M:%S") 198 - formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line) 251 + clean_line = raw_data.strip() 252 + if not clean_line: 253 + continue 199 254 200 - # Check for Memory Line 201 - if "[MEM]" in formatted_line: 202 - free_val, total_val = parse_memory_line(formatted_line) 203 - if free_val is not None and total_val is not None: 204 - with data_lock: 205 - time_data.append(pc_time) 206 - free_mem_data.append(free_val / 1024) # Convert to KB 207 - total_mem_data.append(total_val / 1024) # Convert to KB 208 - # Apply filters 209 - if filter_keyword and filter_keyword not in formatted_line.lower(): 210 - continue 211 - if suppress and suppress in formatted_line.lower(): 212 - continue 213 - # Print to console 214 - line_color = get_color_for_line(formatted_line) 215 - print(f"{line_color}{formatted_line}") 255 + if clean_line.startswith("SCREENSHOT_START:"): 256 + screenshot_size = int(clean_line.split(":")[1]) 257 + expecting_screenshot = True 258 + continue 259 + elif clean_line == "SCREENSHOT_END": 260 + continue # ignore 261 + 262 + # Add PC timestamp 263 + pc_time = datetime.now().strftime("%H:%M:%S") 264 + formatted_line = re.sub(r"^\[\d+\]", f"[{pc_time}]", clean_line) 265 + 266 + # Check for Memory Line 267 + if "[MEM]" in formatted_line: 268 + free_val, total_val = parse_memory_line(formatted_line) 269 + if free_val is not None and total_val is not None: 270 + with data_lock: 271 + time_data.append(pc_time) 272 + free_mem_data.append(free_val / 1024) # Convert to KB 273 + total_mem_data.append(total_val / 1024) # Convert to KB 274 + # Apply filters 275 + if filter_keyword and filter_keyword not in formatted_line.lower(): 276 + continue 277 + if suppress and suppress in formatted_line.lower(): 278 + continue 279 + # Print to console 280 + line_color = get_color_for_line(formatted_line) 281 + print(f"{line_color}{formatted_line}") 216 282 217 - except (OSError, UnicodeDecodeError): 218 - print(f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}") 219 - break 283 + except (OSError, UnicodeDecodeError): 284 + print( 285 + f"{Fore.RED}Device disconnected or data error.{Style.RESET_ALL}" 286 + ) 287 + break 220 288 except KeyboardInterrupt: 221 289 # If thread is killed violently (e.g. main exit), silence errors 222 290 pass 223 291 finally: 224 - if "ser" in locals() and ser.is_open: 225 - ser.close() 292 + pass # ser closed in main 293 + 294 + 295 + def input_worker(ser) -> None: 296 + """ 297 + Runs in a background thread. Handles user input to send commands to the ESP32 device. 298 + Monitors the global shutdown event for graceful termination on Ctrl-C. 299 + """ 300 + while not shutdown_event.is_set(): 301 + try: 302 + cmd = input("Command: ") 303 + ser.write(f"CMD:{cmd}\n".encode()) 304 + except (EOFError, KeyboardInterrupt): 305 + break 226 306 227 307 228 308 def update_graph(frame) -> list: # pylint: disable=unused-argument 229 309 """ 230 - Called by Matplotlib animation to redraw the chart. 310 + Called by Matplotlib animation to redraw the memory usage chart. 311 + Monitors the global shutdown event and closes the plot when shutdown is requested. 231 312 """ 313 + if shutdown_event.is_set(): 314 + plt.close("all") 315 + return [] 316 + 232 317 with data_lock: 233 318 if not time_data: 234 319 return [] ··· 260 345 plt.tight_layout() 261 346 262 347 return [] 348 + 349 + 350 + def get_auto_detected_port() -> list[str]: 351 + """ 352 + Attempts to auto-detect the serial port for the ESP32 device. 353 + Returns a list of all detected ports. 354 + If no suitable port is found, the list will be empty. 355 + Darwin/Linux logic by jonasdiemer 356 + """ 357 + port_list = [] 358 + system = platform.system() 359 + # Code for darwin (macOS), linux, and windows 360 + if system in ("Darwin", "Linux"): 361 + pattern = "/dev/tty.usbmodem*" if system == "Darwin" else "/dev/ttyACM*" 362 + port_list = sorted(glob.glob(pattern)) 363 + elif system == "Windows": 364 + from serial.tools import list_ports 365 + 366 + # Be careful with this pattern list - it should be specific 367 + # enough to avoid picking up unrelated devices, but broad enough 368 + # to catch all common USB-serial adapters used with ESP32 369 + # Caveat: localized versions of Windows may have different descriptions, 370 + # so we also check for specific VID:PID (but that may not cover all clones) 371 + pattern_list = ["CP210x", "CH340", "USB Serial"] 372 + found_ports = list_ports.comports() 373 + port_list = [ 374 + port.device 375 + for port in found_ports 376 + if any(pat in port.description for pat in pattern_list) 377 + or port.hwid.startswith( 378 + "USB VID:PID=303A:1001" 379 + ) # Add specific VID:PID for XTEINK X4 380 + ] 381 + 382 + return port_list 263 383 264 384 265 385 def main() -> None: 266 386 """ 267 387 Main entry point for the ESP32 monitor application. 268 - Sets up argument parsing, starts serial monitoring thread, and initializes the memory graph. 388 + 389 + Sets up argument parsing, initializes serial communication, starts background threads 390 + for serial monitoring and command input, and launches the memory usage graph. 391 + Implements graceful shutdown handling with signal processing for clean termination. 392 + 393 + Features: 394 + - Serial port monitoring with color-coded output 395 + - Real-time memory usage graphing 396 + - Interactive command interface 397 + - Screenshot capture capability 398 + - Graceful shutdown on Ctrl-C or window close 269 399 """ 270 - parser = argparse.ArgumentParser(description="ESP32 Monitor with Graph") 271 - if sys.platform.startswith("win"): 272 - default_port = "COM8" 273 - elif sys.platform.startswith("darwin"): 274 - default_port = "/dev/cu.usbmodem101" 275 - else: 276 - default_port = "/dev/ttyACM0" 400 + parser = argparse.ArgumentParser( 401 + description="ESP32 Serial Monitor with Memory Graph - Real-time monitoring, graphing, and command interface" 402 + ) 277 403 default_baudrate = 115200 278 404 parser.add_argument( 279 405 "port", 280 406 nargs="?", 281 - default=default_port, 282 - help=f"Serial port (default: {default_port})", 407 + default=None, 408 + help="Serial port (leave empty for autodetection)", 283 409 ) 284 410 parser.add_argument( 285 411 "--baud", ··· 300 426 help="Suppress lines containing this keyword (case-insensitive)", 301 427 ) 302 428 args = parser.parse_args() 429 + port = args.port 430 + if port is None: 431 + port_list = get_auto_detected_port() 432 + if len(port_list) == 1: 433 + port = port_list[0] 434 + print(f"{Fore.CYAN}Auto-detected serial port: {port}{Style.RESET_ALL}") 435 + elif len(port_list) > 1: 436 + print(f"{Fore.YELLOW}Multiple serial ports found:{Style.RESET_ALL}") 437 + for p in port_list: 438 + print(f" - {p}") 439 + print( 440 + f"{Fore.YELLOW}Please specify the desired port as a command-line argument.{Style.RESET_ALL}" 441 + ) 442 + if port is None: 443 + print(f"{Fore.RED}Error: No suitable serial port found.{Style.RESET_ALL}") 444 + sys.exit(1) 445 + 446 + try: 447 + ser = serial.Serial(port, args.baud, timeout=0.1) 448 + ser.dtr = False 449 + ser.rts = False 450 + except serial.SerialException as e: 451 + print(f"{Fore.RED}Error opening port: {e}{Style.RESET_ALL}") 452 + return 453 + 454 + # Set up signal handler for graceful shutdown 455 + signal.signal(signal.SIGINT, signal_handler) 303 456 304 457 # 1. Start the Serial Reader in a separate thread 305 458 # Daemon=True means this thread dies when the main program closes 306 459 myargs = vars(args) # Convert Namespace to dict for easier passing 307 - t = threading.Thread( 308 - target=serial_worker, args=(args.port, args.baud, myargs), daemon=True 309 - ) 460 + t = threading.Thread(target=serial_worker, args=(ser, myargs), daemon=True) 310 461 t.start() 311 462 463 + # Start input thread 464 + input_thread = threading.Thread(target=input_worker, args=(ser,), daemon=True) 465 + input_thread.start() 466 + 312 467 # 2. Set up the Graph (Main Thread) 313 468 try: 314 469 import matplotlib.style as mplstyle # pylint: disable=import-outside-toplevel 315 - default_styles = ("light_background", "ggplot", "seaborn", "dark_background", ) 470 + 471 + default_styles = ( 472 + "light_background", 473 + "ggplot", 474 + "seaborn", 475 + "dark_background", 476 + ) 316 477 styles = list(mplstyle.available) 317 478 for default_style in default_styles: 318 479 if default_style in styles: ··· 333 494 334 495 try: 335 496 print( 336 - f"{Fore.YELLOW}Starting Graph Window... (Close window to exit){Style.RESET_ALL}" 497 + f"{Fore.YELLOW}Starting Graph Window... (Close window or press Ctrl-C to exit){Style.RESET_ALL}" 337 498 ) 338 499 plt.show() 339 500 except KeyboardInterrupt: 340 501 print(f"\n{Fore.YELLOW}Exiting...{Style.RESET_ALL}") 502 + finally: 503 + shutdown_event.set() # Ensure all threads know to stop 341 504 plt.close("all") # Force close any lingering plot windows 342 505 343 506
+15
src/main.cpp
··· 369 369 lastMemPrint = millis(); 370 370 } 371 371 372 + // Handle incoming serial commands 373 + if (Serial.available() > 0) { 374 + String line = Serial.readStringUntil('\n'); 375 + if (line.startsWith("CMD:")) { 376 + String cmd = line.substring(4); 377 + cmd.trim(); 378 + if (cmd == "SCREENSHOT") { 379 + Serial.printf("SCREENSHOT_START:%d\n", HalDisplay::BUFFER_SIZE); 380 + uint8_t* buf = display.getFrameBuffer(); 381 + Serial.write(buf, HalDisplay::BUFFER_SIZE); 382 + Serial.printf("SCREENSHOT_END\n"); 383 + } 384 + } 385 + } 386 + 372 387 // Check for any user activity (button press or release) or active background work 373 388 static unsigned long lastActivityTime = millis(); 374 389 if (gpio.wasAnyPressed() || gpio.wasAnyReleased() || (currentActivity && currentActivity->preventAutoSleep())) {