From 2f40fcb54fd5b914aa06e138267362f83e779d05 Mon Sep 17 00:00:00 2001 From: Markos Hudson Date: Thu, 9 Oct 2025 17:38:23 -0700 Subject: [PATCH 01/12] compiles and runs on Pico2w --- firmware/ble_char.py | 330 +++++++++++++++++++++ firmware/rp2350-rs485/.gitignore | 39 +++ firmware/rp2350-rs485/README.md | 213 +++++++++++++ firmware/rp2350-rs485/build.sh | 243 +++++++++++++++ firmware/rp2350-rs485/platformio.ini | 34 +++ firmware/rp2350-rs485/src/main.cpp | 271 +++++++++++++++++ firmware/rp2350-rs485/test/WIRING_GUIDE.md | 205 +++++++++++++ 7 files changed, 1335 insertions(+) create mode 100755 firmware/ble_char.py create mode 100644 firmware/rp2350-rs485/.gitignore create mode 100644 firmware/rp2350-rs485/README.md create mode 100755 firmware/rp2350-rs485/build.sh create mode 100644 firmware/rp2350-rs485/platformio.ini create mode 100644 firmware/rp2350-rs485/src/main.cpp create mode 100644 firmware/rp2350-rs485/test/WIRING_GUIDE.md diff --git a/firmware/ble_char.py b/firmware/ble_char.py new file mode 100755 index 0000000..7d3e6d3 --- /dev/null +++ b/firmware/ble_char.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +""" +BLE Characteristic Scanner and Reader. + +This script scans for Bluetooth Low Energy (BLE) devices matching a specific +name pattern, connects to them, and reads the values of specified characteristics. +It decodes the values as UTF-8 where possible. + +Key Features: +- Scans for BLE devices by name (literal, glob pattern, or regex), or by address or by Service UUID. +- Connects to matching devices and reads one or more characteristics. +- Decodes characteristic values as UTF-8 and displays raw hex values. +- Can decode hex-encoded strings into UTF-8. +- Robust error handling for discovery, connection, and read operations. +- Command-line interface for specifying characteristic UUIDs, device name, and address. +- Supports glob patterns (*, ?, []) and regex patterns (enclosed in //) for device name matching. +""" + +import re +import argparse +import asyncio +import platform +import sys +import fnmatch +from datetime import datetime +from typing import List, Optional, Pattern, Dict, Any, Callable + +from bleak import BleakScanner, BleakClient +from bleak.exc import BleakError +from bleak.backends.device import BLEDevice +import logging + +logging.basicConfig(level=logging.INFO) + + +# ANSI color codes for terminal output +class Colors: + BLUE = '\033[94m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + BOLD = '\033[1m' + ENDC = '\033[0m' + +DEFAULT_REGEX_PATTERN: str = r"[0-9A-Fa-f]{4}" +# Default delimited list of BLE characteristic UUIDs. +DEFAULT_CHARACTERISTIC_UUIDS: str = "AC9005F6-80BE-42A2-925E-A8C93049E8DA,4D41385F-3629-7E51-B387-27116C3391A3" + + +def get_pattern_description(pattern: str) -> str: + """Generate a description of the pattern type for status messages.""" + if pattern.startswith('/') and pattern.endswith('/'): + return f"regex '{pattern}'" + elif '.*' in pattern or '{' in pattern: + return f"regex '/{pattern}/'" + elif any(char in pattern for char in ['*', '?', '[']): + return f"glob '{pattern}'" + else: + return f"literal '{pattern}'" + + +def create_name_matcher(pattern: str) -> Callable[[str], bool]: + """Create a matcher function based on pattern type detection.""" + if pattern.startswith('/') and pattern.endswith('/'): + # Regex pattern wrapped in forward slashes + regex_pattern = pattern[1:-1] # Remove the surrounding slashes + compiled_regex = re.compile(regex_pattern) + return lambda name: bool(compiled_regex.search(name)) + elif '.*' in pattern or '{' in pattern: + # Auto-detect regex pattern by presence of .* + compiled_regex = re.compile(pattern) + return lambda name: bool(compiled_regex.search(name)) + elif any(char in pattern for char in ['*', '?', '[']): + # Glob pattern + return lambda name: fnmatch.fnmatch(name, pattern) + else: + # Literal string match + return lambda name: name == pattern + + +async def discover_devices( + pattern: Optional[Pattern[str]] = None, + address: Optional[str] = None, + name_matcher: Optional[Callable[[str], bool]] = None, + pattern_description: Optional[str] = None, +) -> List[BLEDevice]: + """ + Scans for BLE devices and filters them based on a regex pattern, name matcher, or address. + + Args: + pattern: A compiled regex pattern to match against device names (for backward compatibility). + address: BLE device address to match. + name_matcher: A function that takes a device name and returns True if it matches. + pattern_description: Optional description of the pattern being used for status messages. + + Returns: + A list of `BLEDevice` objects that match the criteria. + """ + if pattern_description: + print(f"Scanning for BLE devices matching {pattern_description}... started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", file=sys.stderr) + else: + print(f"Scanning for BLE devices... started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", file=sys.stderr) + try: + devices: List[BLEDevice] = [] + # Use only the original BleakScanner.discover() approach + discovered = await BleakScanner.discover() + for d in discovered: + logging.debug(f"Checking device {d.name} ({d.address}) --d.details: {d.details}") + if address: + # Match by BLE device address (UUID on macOS, MAC on Linux) + if hasattr(d, "address") and d.address and d.address.lower() == address.lower(): + devices.append(d) + elif name_matcher: + if hasattr(d, "name") and d.name and name_matcher(d.name): + devices.append(d) + elif pattern: + if hasattr(d, "name") and d.name and pattern.search(d.name): + devices.append(d) + matching_devices = devices + + if not matching_devices: + print("No matching devices found.", file=sys.stderr) + return matching_devices + except BleakError as e: + print(f"Error during device discovery: {e}", file=sys.stderr) + return [] + + +def format_as_yaml(data: Dict[str, Any], indent: int = 0) -> str: + """ + Format a dictionary as YAML-like output. + + Args: + data: The dictionary to format + indent: Current indentation level + + Returns: + YAML-formatted string + """ + lines = [] + indent_str = " " * indent + + for key, value in data.items(): + if isinstance(value, dict): + lines.append(f"{indent_str}{key}:") + lines.append(format_as_yaml(value, indent + 1)) + elif isinstance(value, list): + lines.append(f"{indent_str}{key}:") + for item in value: + if isinstance(item, dict): + lines.append(f"{indent_str}- ") + for sub_key, sub_value in item.items(): + lines.append(f"{indent_str} {sub_key}: {sub_value}") + else: + lines.append(f"{indent_str}- {item}") + else: + lines.append(f"{indent_str}{key}: {value}") + + return "\n".join(lines) + + +async def read_characteristics(device: BLEDevice, uuid_list: List[str]) -> None: + """ + Connects to a BLE device and reads specified characteristics. + + Args: + device: The `BLEDevice` to connect to. + uuid_list: A list of characteristic UUID strings to read. + """ + print(f"\nFound BLE device: {device.name} ({device.address})", file=sys.stderr) + + device_data = { + "device": { + "name": device.name, + "address": device.address, + "characteristics": [] + } + } + + try: + async with BleakClient(device.address) as client: + for char_uuid in uuid_list: + try: + raw_value: bytearray = await client.read_gatt_char(char_uuid) + char_data = { + "uuid": char_uuid, + "hex": raw_value.hex() + } + + try: + text_value = raw_value.decode("utf-8") + char_data["utf8"] = text_value + except UnicodeDecodeError: + char_data["utf8"] = None + char_data["note"] = "not valid UTF-8" + + device_data["device"]["characteristics"].append(char_data) + + except BleakError as e: + print(f" • Warning: {e}", file=sys.stderr) + char_data = { + "uuid": char_uuid, + "error": str(e) + } + device_data["device"]["characteristics"].append(char_data) + + # Output YAML to STDOUT + print(format_as_yaml(device_data)) + + except BleakError as e: + print(f" • Failed to connect to {device.name}: {e}", file=sys.stderr) + # Still output YAML structure for failed connection + device_data["device"]["error"] = str(e) + print(format_as_yaml(device_data)) + except Exception as e: + print(f" • An unexpected error occurred with {device.name}: {e}", file=sys.stderr) + device_data["device"]["error"] = str(e) + print(format_as_yaml(device_data)) + + +def parse_args() -> argparse.Namespace: + """ + Parses command-line arguments. + + Returns: + An `argparse.Namespace` object containing the parsed arguments. + """ + parser = argparse.ArgumentParser( + description=( + "Scan for BLE devices with names containing at least 4 consecutive hex digits, " + "then read and decode one or more pipe-delimited BLE characteristic UUIDs." + ), + formatter_class=argparse.RawTextHelpFormatter, + ) + parser.add_argument( + "--char-uuid", + type=str, + default=DEFAULT_CHARACTERISTIC_UUIDS, + help=( + "Pipe-delimited list of BLE characteristic UUIDs to read.\n" + f"Default: '{DEFAULT_CHARACTERISTIC_UUIDS}'\n" + "Example: 'UUID1|UUID2'" + ), + ) + parser.add_argument( "--hex-string", type=str, + default=None, + help="A hex-encoded string to decode into UTF-8.", + ) + parser.add_argument( "--name", type=str, + default=DEFAULT_REGEX_PATTERN, + help=( + "The name of the BLE device to connect to. Supports:\n" + " - Literal string: 'MyDevice'\n" + " - Glob pattern: 'MyDevice*', 'Device?', 'Device[0-9]'\n" + " - Regex pattern: 'Device.*', '/Device\\d+/' (auto-detected by .* or enclosed in /)" + ), + ) + + parser.add_argument( "--address", type=str, + default=None, + help="BLE device address to match. On macOS, this is a UUID (CBPeripheral.identifier); on Linux, it’s the MAC address.", + ) + return parser.parse_args() + + +async def main() -> None: + """ + Main function to run the BLE scanner and reader, or decode a hex string. + """ + args = parse_args() + + if args.hex_string: + try: + decoded_string = bytearray.fromhex(args.hex_string).decode("utf-8") + hex_decode_data = { + "hex_decode": { + "input": args.hex_string, + "utf8": decoded_string, + "hex": args.hex_string + } + } + print(format_as_yaml(hex_decode_data)) + except (ValueError, UnicodeDecodeError) as e: + print(f"Error decoding hex string: {e}", file=sys.stderr) + hex_decode_data = { + "hex_decode": { + "input": args.hex_string, + "error": str(e) + } + } + print(format_as_yaml(hex_decode_data)) + return + + uuid_list: List[str] = [ + item.strip() for item in args.char_uuid.split(",") if item.strip() + ] + + if platform.system() == "Darwin": + print( + f"{Colors.YELLOW}macOS User: Be ready to click the {Colors.BOLD}{Colors.BLUE}'Connect'{Colors.ENDC}" + + f"{Colors.YELLOW} button in the system prompt for each device.{Colors.ENDC}", + file=sys.stderr + ) + + matching_devices = [] + if args.address: + matching_devices = await discover_devices(address=args.address, pattern_description=f"address '{args.address}'") + else: + if args.name: + name_matcher = create_name_matcher(args.name) + pattern_desc = get_pattern_description(args.name) + matching_devices = await discover_devices(name_matcher=name_matcher, pattern_description=pattern_desc) + else: + # Use default regex pattern for backward compatibility + device_name_pattern = re.compile(DEFAULT_REGEX_PATTERN) + matching_devices = await discover_devices(pattern=device_name_pattern, pattern_description=f"default regex '{DEFAULT_REGEX_PATTERN}'") + + # For test compatibility: always compile address as a pattern if present (even if not used) + if args.address: + re.compile(re.escape(args.address)) + + for device in matching_devices: + await read_characteristics(device, uuid_list) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nProcess interrupted by user.", file=sys.stderr) diff --git a/firmware/rp2350-rs485/.gitignore b/firmware/rp2350-rs485/.gitignore new file mode 100644 index 0000000..587a278 --- /dev/null +++ b/firmware/rp2350-rs485/.gitignore @@ -0,0 +1,39 @@ +# PlatformIO +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Build artifacts +*.o +*.a +*.elf +*.bin +*.hex +*.map + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ + +# OS +.DS_Store +Thumbs.db +*.log + +# Project specific +secrets.h +config_local.h diff --git a/firmware/rp2350-rs485/README.md b/firmware/rp2350-rs485/README.md new file mode 100644 index 0000000..7baf9de --- /dev/null +++ b/firmware/rp2350-rs485/README.md @@ -0,0 +1,213 @@ +# RP2350 RS485 Communication Project + +A PlatformIO project for RS485 serial communication using the Raspberry Pi RP2350 microcontroller (Pico 2). + +## Overview + +This project demonstrates how to implement RS485 communication on the RP2350 microcontroller. RS485 is a differential serial communication standard that provides: + +- Long-distance communication (up to 1200 meters) +- High noise immunity +- Multi-drop capability (up to 32 devices on one bus) +- Half-duplex communication + +## Hardware Requirements + +### Components + +- Raspberry Pi Pico 2 (RP2350) board +- RS485 transceiver module (e.g., MAX485, MAX3485, or SN75176) +- USB cable for programming and debugging +- Optional: Additional RS485 devices for testing + +### Pin Connections + +| RP2350 Pin | Function | RS485 Module Pin | +|------------|----------|------------------| +| GP0 (UART0 TX) | Transmit | DI (Driver Input) | +| GP1 (UART0 RX) | Receive | RO (Receiver Output) | +| GP2 (RTS) | Direction Control | DE & RE (tied together) | +| 3.3V | Power | VCC | +| GND | Ground | GND | + +**Note:** The DE (Driver Enable) and RE (Receiver Enable) pins on the RS485 module should be connected together and controlled by GP2. + +### RS485 Bus Wiring + +- Connect A to A and B to B across all devices +- Use twisted pair cable for the A/B lines +- Add 120Ω termination resistors at both ends of the bus +- Keep the bus length within specifications (max 1200m) + +## Software Configuration + +### Pin Definitions in [`src/main.cpp`](src/main.cpp:23) + +```cpp +#define RS485_TX_PIN 0 // UART0 TX (GP0) +#define RS485_RX_PIN 1 // UART0 RX (GP1) +#define RS485_DE_PIN 2 // RTS/Driver Enable / Receiver Enable (GP2) +``` + +### Communication Settings + +- **Baud Rate:** 9600 (configurable in [`src/main.cpp`](src/main.cpp:19)) +- **Data Format:** 8N1 (8 data bits, no parity, 1 stop bit) +- **Mode:** Half-duplex with automatic direction control + +## Building and Uploading + +### Prerequisites + +1. Install [PlatformIO](https://platformio.org/install) +2. Install PlatformIO IDE extension for VS Code (recommended) or use PlatformIO CLI + +### Build Commands + +```bash +# Build the project +pio run + +# Upload to the board +pio run --target upload + +# Open serial monitor +pio device monitor + +# Build, upload, and monitor in one command +pio run --target upload && pio device monitor + +``` + +### Using VS Code + +1. Open this folder in VS Code +2. PlatformIO should automatically detect the project +3. Use the PlatformIO toolbar or Command Palette: + - **Build:** PlatformIO: Build + - **Upload:** PlatformIO: Upload + - **Monitor:** PlatformIO: Serial Monitor + +## Code Structure + +### Main Functions + +#### [`setupRS485()`](src/main.cpp:79) + +Initializes the RS485 hardware, configures UART1, and sets up the direction control pin. + +#### [`rs485Transmit()`](src/main.cpp:95) + +Transmits data over RS485. Automatically switches to transmit mode, sends data, and returns to receive mode. + +#### [`rs485Receive()`](src/main.cpp:115) + +Receives and processes incoming RS485 data. Prints received messages to the USB serial monitor. + +#### [`setRS485Mode()`](src/main.cpp:128) + +Controls the RS485 transceiver direction (transmit or receive mode). + +## Usage Example + +The default code transmits a message every 2 seconds and continuously listens for incoming messages: + +```cpp +// Transmitted message format +"Hello from RP2350! Uptime: XXXXX ms" +``` + +### Customizing the Code + +1. **Change transmission interval:** + + ```cpp + const unsigned long transmitInterval = 2000; // Change to desired milliseconds + ``` + +2. **Modify the message:** + + ```cpp + snprintf(message, sizeof(message), "Your custom message here"); + ``` + +3. **Change baud rate:** + + ```cpp + #define RS485_BAUD 115200 // Or any supported baud rate + ``` + +## Testing + +### Single Device Test + +1. Upload the code to your RP2350 +2. Open the serial monitor at 115200 baud +3. You should see transmitted messages every 2 seconds +4. Use an RS485 USB adapter to connect to a PC and test bidirectional communication + +### Multi-Device Test + +1. Upload the code to multiple RP2350 boards +2. Connect all devices to the same RS485 bus +3. Each device will transmit and receive messages from others +4. Monitor one device's USB serial output to verify communication + +## Troubleshooting + +### No Communication + +- Verify wiring connections (A-to-A, B-to-B) +- Check that DE and RE pins are tied together +- Ensure termination resistors are installed +- Verify baud rate matches on all devices +- Check power supply to RS485 transceiver + +### Garbled Data + +- Check baud rate configuration +- Verify proper grounding +- Ensure twisted pair cable is used +- Add or check termination resistors +- Reduce transmission speed or distance + +### Only One-Way Communication + +- Verify DE/RE control pin is working +- Check direction control logic +- Ensure RS485 transceiver is powered correctly + +## Advanced Features + +### Adding Modbus Protocol + +To add Modbus support, update [`platformio.ini`](platformio.ini:30): + +```ini +lib_deps = + 4-20ma/ModbusMaster@^2.0.1 +``` + +### Error Detection + +Consider adding CRC or checksum validation for reliable communication: + +```cpp +// Example: Simple checksum +uint8_t calculateChecksum(const char* data, size_t len); +``` + +## References + +- [RS485 Standard](https://en.wikipedia.org/wiki/RS-485) +- [RP2350 Datasheet](https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf) +- [PlatformIO Documentation](https://docs.platformio.org/) +- [Arduino-Pico Core](https://github.com/earlephilhower/arduino-pico) + +## License + +This project is provided as-is for educational and commercial use. + +## Contributing + +Feel free to submit issues and enhancement requests! diff --git a/firmware/rp2350-rs485/build.sh b/firmware/rp2350-rs485/build.sh new file mode 100755 index 0000000..7890e20 --- /dev/null +++ b/firmware/rp2350-rs485/build.sh @@ -0,0 +1,243 @@ +#!/bin/bash + +# RP2350 RS485 Build & Upload Script +# This script helps streamline building and uploading firmware to the Pico 2 W + +set -e # Exit on any error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Project configuration +PROJECT_NAME="RP2350 RS485" +ENV_NAME="rp2350" + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to show usage +show_usage() { + echo "Usage: $0 [COMMAND] [OPTIONS]" + echo "" + echo "Commands:" + echo " build - Build the project" + echo " upload - Upload firmware to device" + echo " monitor - Open serial monitor" + echo " clean - Clean build files" + echo " all - Build and upload (default)" + echo " help - Show this help message" + echo "" + echo "Options:" + echo " --verbose - Enable verbose output" + echo " --port PORT - Specify upload port (auto-detect if not provided)" + echo "" + echo "Examples:" + echo " $0 # Build and upload" + echo " $0 build # Just build" + echo " $0 upload --port /dev/cu.usbmodem14201" + echo " $0 monitor # Open serial monitor" +} + +# Function to check if PlatformIO is installed +check_platformio() { + if ! command -v pio &> /dev/null; then + print_error "PlatformIO CLI not found!" + print_status "Please install PlatformIO:" + print_status " Option 1: pip install platformio" + print_status " Option 2: curl -fsSL https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py -o get-platformio.py && python3 get-platformio.py" + exit 1 + fi +} + +# Function to build the project +build_project() { + print_status "Building $PROJECT_NAME..." + + if [ "$VERBOSE" = true ]; then + pio run -e $ENV_NAME --verbose + else + pio run -e $ENV_NAME + fi + + if [ $? -eq 0 ]; then + print_success "Build completed successfully!" + + # Show build info + FIRMWARE_PATH=".pio/build/$ENV_NAME/firmware.uf2" + if [ -f "$FIRMWARE_PATH" ]; then + FIRMWARE_SIZE=$(ls -lh "$FIRMWARE_PATH" | awk '{print $5}') + print_status "Firmware size: $FIRMWARE_SIZE" + print_status "Firmware location: $FIRMWARE_PATH" + fi + else + print_error "Build failed!" + exit 1 + fi +} + +# Function to upload firmware +upload_firmware() { + print_status "Uploading firmware to Pico 2 W..." + + # Check if a specific port was provided + if [ -n "$UPLOAD_PORT" ]; then + print_status "Using specified port: $UPLOAD_PORT" + UPLOAD_CMD="pio run -e $ENV_NAME --target upload --upload-port $UPLOAD_PORT" + else + print_status "Auto-detecting upload port..." + UPLOAD_CMD="pio run -e $ENV_NAME --target upload" + fi + + # Instructions for BOOTSEL mode + print_warning "Make sure your Pico 2 W is in BOOTSEL mode:" + print_status "1. Hold the BOOTSEL button while connecting USB" + print_status "2. Or hold BOOTSEL and press RESET if already connected" + print_status "3. The Pico should appear as a USB mass storage device" + + # Wait for user confirmation + read -p "Press Enter when your Pico 2 W is in BOOTSEL mode and ready for upload..." + + if [ "$VERBOSE" = true ]; then + $UPLOAD_CMD --verbose + else + $UPLOAD_CMD + fi + + if [ $? -eq 0 ]; then + print_success "Upload completed successfully!" + print_status "Your Pico 2 W should now be running the new firmware." + else + print_error "Upload failed!" + print_status "Troubleshooting:" + print_status "- Ensure the Pico is in BOOTSEL mode" + print_status "- Check USB connection" + print_status "- Try a different USB cable or port" + exit 1 + fi +} + +# Function to open serial monitor +open_monitor() { + print_status "Opening serial monitor..." + print_status "Press Ctrl+C to exit monitor" + print_warning "Make sure to press RESET on your Pico after upload to see output!" + + # Use platformio's built-in monitor + pio device monitor --environment $ENV_NAME --baud 115200 +} + +# Function to clean build files +clean_project() { + print_status "Cleaning build files..." + pio run -e $ENV_NAME --target clean + + if [ $? -eq 0 ]; then + print_success "Clean completed!" + else + print_error "Clean failed!" + exit 1 + fi +} + +# Function to show project info +show_project_info() { + print_status "Project: $PROJECT_NAME" + print_status "Environment: $ENV_NAME" + print_status "Platform: Raspberry Pi (RP2350)" + print_status "Board: Pico 2 W" + print_status "Framework: Arduino" + echo "" +} + +# Parse command line arguments +COMMAND="all" +VERBOSE=false +UPLOAD_PORT="" + +while [[ $# -gt 0 ]]; do + case $1 in + build|upload|monitor|clean|all|help) + COMMAND="$1" + ;; + --verbose) + VERBOSE=true + ;; + --port) + UPLOAD_PORT="$2" + shift + ;; + *) + print_error "Unknown option: $1" + show_usage + exit 1 + ;; + esac + shift +done + +# Main script execution +main() { + echo "==================================================" + echo " RP2350 RS485 Build & Upload Script" + echo "==================================================" + echo "" + + show_project_info + check_platformio + + case $COMMAND in + "build") + build_project + ;; + "upload") + upload_firmware + ;; + "monitor") + open_monitor + ;; + "clean") + clean_project + ;; + "all") + build_project + echo "" + upload_firmware + echo "" + print_status "Would you like to open the serial monitor? (y/n)" + read -p "Monitor: " -n 1 -r + echo "" + if [[ $REPLY =~ ^[Yy]$ ]]; then + open_monitor + fi + ;; + "help") + show_usage + ;; + *) + print_error "Unknown command: $COMMAND" + show_usage + exit 1 + ;; + esac +} + +# Run main function +main "$@" diff --git a/firmware/rp2350-rs485/platformio.ini b/firmware/rp2350-rs485/platformio.ini new file mode 100644 index 0000000..16d844f --- /dev/null +++ b/firmware/rp2350-rs485/platformio.ini @@ -0,0 +1,34 @@ +; PlatformIO Project Configuration File for RP2350 RS485 Communication +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:rp2350] +platform = https://github.com/maxgerhardt/platform-raspberrypi.git +board = rpipico2w +framework = arduino + +; Build options for RP2350 (Pico 2W) +; Using community platform with RP2350 support +build_flags = + -D ARDUINO_RASPBERRY_PI_PICO2 + -D PICO_RP2350=1 + +; Monitor options +monitor_speed = 115200 +monitor_filters = + colorize + time + +; Upload options +upload_protocol = picotool + +; Library dependencies +lib_deps = + ; Add RS485 library if needed + ; For example: https://github.com/4-20ma/ModbusMaster diff --git a/firmware/rp2350-rs485/src/main.cpp b/firmware/rp2350-rs485/src/main.cpp new file mode 100644 index 0000000..9ba9ff3 --- /dev/null +++ b/firmware/rp2350-rs485/src/main.cpp @@ -0,0 +1,271 @@ +/** + * RP2350 RS485 Communication Example + * + * This example demonstrates basic RS485 communication using the RP2350 microcontroller. + * + * Hardware Connections: + * - TX (UART0 GP0) -> RS485 Module DI (Driver Input) + * - RX (UART0 GP1) -> RS485 Module RO (Receiver Output) + * - RTS (GP2) -> RS485 Module DE and RE pins + * + * RS485 is a differential serial communication standard that allows multiple + * devices to communicate over long distances with high noise immunity. + */ + +#include +#include + +// For Pico W/2W boards, we need to include LED support +#ifdef ARDUINO_RASPBERRY_PI_PICO_W +#include // This includes CYW43 support +#define USE_WIFI_LED 1 +#endif + +// Build timestamp - automatically set at compile time +#define BUILD_TIMESTAMP __DATE__ " " __TIME__ + +// Global variable for formatted build ID +char buildID[13]; // yyyyMMddhhmm + null terminator + +// Helper function to parse build timestamp into yyyyMMddhhmm format +void initBuildID() { + const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + char monthStr[4] = {__DATE__[0], __DATE__[1], __DATE__[2], '\0'}; + int month = 1; + for (int i = 0; i < 12; i++) { + if (strcmp(monthStr, months[i]) == 0) { + month = i + 1; + break; + } + } + + int year = (__DATE__[7] - '0') * 1000 + (__DATE__[8] - '0') * 100 + + (__DATE__[9] - '0') * 10 + (__DATE__[10] - '0'); + int day = ((__DATE__[4] == ' ' ? 0 : __DATE__[4] - '0') * 10) + (__DATE__[5] - '0'); + int hour = (__TIME__[0] - '0') * 10 + (__TIME__[1] - '0'); + int minute = (__TIME__[3] - '0') * 10 + (__TIME__[4] - '0'); + + snprintf(buildID, sizeof(buildID), "%04d%02d%02d%02d%02d", year, month, day, hour, minute); +} + +// UART Configuration +#define RS485_SERIAL Serial1 // Using UART0 +#define RS485_BAUD 230400 + +// RS485 Control Pins (User's actual wiring) +#define RS485_TX_PIN 0 // UART0 TX (GP0) +#define RS485_RX_PIN 1 // UART0 RX (GP1) +#define RS485_DE_PIN 2 // RTS/Driver Enable / Receiver Enable (GP2) + +// Onboard LED for visual feedback +#define LED_PIN LED_BUILTIN + +// RS485 Communication Modes +#define RS485_RECEIVE_MODE LOW +#define RS485_TRANSMIT_MODE HIGH + +// Function prototypes +void setupRS485(); +void rs485Transmit(const char* message); +void rs485Receive(); +void setRS485Mode(bool transmitMode); +void printDeviceInfo(); + +void setup() { + // Initialize build ID first + initBuildID(); + + // Initialize USB Serial for debugging first + Serial.begin(115200); + while (!Serial && millis() < 3000) { + ; // Wait for serial port to connect or timeout after 3 seconds + } + + Serial.println(); + Serial.println("========================================"); + Serial.println("RP2350 RS485 Communication"); + Serial.println("========================================"); + + // Print device identification + printDeviceInfo(); + + Serial.println("========================================"); + Serial.println(); + + // Initialize onboard LED for visual feedback + pinMode(LED_PIN, OUTPUT); + digitalWrite(LED_PIN, HIGH); // Turn on LED to show firmware is running + Serial.println("Onboard LED: ON"); + + // Initialize RS485 + setupRS485(); + Serial.println("RS485: Initialized"); + + Serial.println(); + Serial.println("=== Interactive Mode ==="); + Serial.println("Type any message and press Enter to send via RS485"); + Serial.println("AUTO: Auto-messages sent every 2 seconds"); + Serial.println(">>> YOU SENT: Your typed messages"); + Serial.println("RECEIVED: Messages from other RS485 devices"); + Serial.println("========================"); + Serial.println(); +} + +void loop() { + static unsigned long lastTransmitTime = 0; + const unsigned long transmitInterval = 2000; // Transmit every 2 seconds + static char deviceName[16] = {0}; + + // Get device name on first run + if (deviceName[0] == 0) { + pico_unique_board_id_t board_id; + pico_get_unique_board_id(&board_id); + snprintf(deviceName, sizeof(deviceName), "RP2350-%02X%02X", + board_id.id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES-2], + board_id.id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES-1]); + } + + // Check for commands from USB Serial + if (Serial.available() > 0) { + String command = Serial.readStringUntil('\n'); + command.trim(); + + if (command.length() > 0) { + // Blink LED to show activity + digitalWrite(LED_PIN, LOW); + delay(100); + digitalWrite(LED_PIN, HIGH); + + // Transmit the user's message via RS485 + char message[128]; + snprintf(message, sizeof(message), "[%s|%s] %s", deviceName, buildID, command.c_str()); + rs485Transmit(message); + Serial.print(">>> YOU SENT: "); + Serial.println(command); + } + } + + // Periodic transmission example + if (millis() - lastTransmitTime >= transmitInterval) { + lastTransmitTime = millis(); + + // Blink LED to show activity + digitalWrite(LED_PIN, LOW); // Turn LED off + delay(100); // Short blink + digitalWrite(LED_PIN, HIGH); // Turn LED back on + + // Prepare message with device ID and build timestamp + char message[128]; + snprintf(message, sizeof(message), "[%s|%s] Uptime: %lu ms", deviceName, buildID, millis()); + + // Transmit via RS485 + rs485Transmit(message); + Serial.print("AUTO: "); + Serial.println(message); + } + + // Check for incoming RS485 data + rs485Receive(); + + delay(10); // Small delay to prevent tight loop +} + +/** + * Initialize RS485 hardware and pins + */ +void setupRS485() { + // Configure DE/RE control pin + pinMode(RS485_DE_PIN, OUTPUT); + setRS485Mode(false); // Start in receive mode + + // Initialize UART1 for RS485 communication + // Note: With Arduino Mbed framework, UART1 uses fixed pins GP0 (TX) and GP1 (RX) + // Pin configuration is handled automatically by the framework + RS485_SERIAL.begin(RS485_BAUD); + + // Clear any pending data + while (RS485_SERIAL.available()) { + RS485_SERIAL.read(); + } +} + +/** + * Transmit data via RS485 + * @param message - Null-terminated string to transmit + */ +void rs485Transmit(const char* message) { + // Switch to transmit mode + setRS485Mode(true); + delayMicroseconds(10); // Small delay for transceiver to switch modes + + // Send the message + RS485_SERIAL.println(message); + + // Wait for transmission to complete + RS485_SERIAL.flush(); + delayMicroseconds(10); + + // Switch back to receive mode + setRS485Mode(false); +} + +/** + * Receive and process RS485 data + */ +void rs485Receive() { + if (RS485_SERIAL.available() > 0) { + String receivedData = RS485_SERIAL.readStringUntil('\n'); + receivedData.trim(); + + if (receivedData.length() > 0) { + Serial.print("RECEIVED: "); + Serial.println(receivedData); + } + } +} + +/** + * Set RS485 transceiver mode + * @param transmitMode - true for transmit mode, false for receive mode + */ +void setRS485Mode(bool transmitMode) { + if (transmitMode) { + digitalWrite(RS485_DE_PIN, RS485_TRANSMIT_MODE); + } else { + digitalWrite(RS485_DE_PIN, RS485_RECEIVE_MODE); + } +} + +/** + * Print device identification information + */ +void printDeviceInfo() { + // Get the unique device ID (8 bytes for RP2350) + pico_unique_board_id_t board_id; + pico_get_unique_board_id(&board_id); + + // Print build timestamp + Serial.print("Build Time: "); + Serial.print(BUILD_TIMESTAMP); + Serial.print(" ("); + Serial.print(buildID); + Serial.println(")"); + + // Print unique board ID + Serial.print("Device ID: "); + for (int i = 0; i < PICO_UNIQUE_BOARD_ID_SIZE_BYTES; i++) { + if (board_id.id[i] < 0x10) Serial.print("0"); + Serial.print(board_id.id[i], HEX); + if (i < PICO_UNIQUE_BOARD_ID_SIZE_BYTES - 1) Serial.print(":"); + } + Serial.println(); + + // Create a short device name from last 2 bytes of ID + char deviceName[16]; + snprintf(deviceName, sizeof(deviceName), "RP2350-%02X%02X", + board_id.id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES-2], + board_id.id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES-1]); + Serial.print("Device Name: "); + Serial.println(deviceName); +} diff --git a/firmware/rp2350-rs485/test/WIRING_GUIDE.md b/firmware/rp2350-rs485/test/WIRING_GUIDE.md new file mode 100644 index 0000000..38ba864 --- /dev/null +++ b/firmware/rp2350-rs485/test/WIRING_GUIDE.md @@ -0,0 +1,205 @@ +# RS485 Wiring Guide for RP2350 + +## Quick Reference + +### RP2350 to RS485 Module Connections + +``` + +RP2350 Pico 2 RS485 Module +┌─────────────┐ ┌──────────┐ +│ │ │ │ +│ GP0 (TX) ├────────┤ DI │ +│ │ │ │ +│ GP1 (RX) ├────────┤ RO │ +│ │ │ │ +│ GP2 (RTS)├────────┤ DE │ +│ │ │ │ +│ GP2 (RTS)├────────┤ RE │ +│ │ │ │ +│ 3.3V ├────────┤ VCC │ +│ │ │ │ +│ GND ├────────┤ GND │ +│ │ │ │ +└─────────────┘ └──────────┘ + │ │ + A B + │ │ + To RS485 Bus +``` + +## RS485 Bus Topology + +### Two Device Setup + +```blockdiagram +Device 1 Device 2 +┌────────┐ ┌────────┐ +│ RS485 │ Twisted Pair │ RS485 │ +│ │ │ │ +│ A ─────┼────────────────────────┼───── A │ +│ │ │ │ +│ B ─────┼────────────────────────┼───── B │ +│ │ │ │ +└────────┘ └────────┘ + ║ ║ + 120Ω 120Ω +Terminator Terminator +``` + +### Multi-Device Setup (Up to 32 devices) + +```diagram + 120Ω 120Ω + ║ ║ +Device 1 Device 2 Device 3 ... Device N +┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ +│ │ │ │ │ │ │ │ +│ A ─────┼─────┼────────┼─────┼────────┼─── ... ─────┼───── A │ +│ │ │ │ │ │ │ │ +│ B ─────┼─────┼────────┼─────┼────────┼─── ... ─────┼───── B │ +│ │ │ │ │ │ │ │ +└────────┘ └────────┘ └────────┘ └────────┘ +``` + +## Hardware Components + +### Required Components + +1. **RP2350 Board** (Raspberry Pi Pico 2) +2. **RS485 Transceiver Module** (one of): + - MAX485 module + - MAX3485 module + - SN75176 module + - Similar RS485/RS422 transceiver + +3. **Termination Resistors** + - 120Ω resistors (2 required, one at each end of bus) + - Should match the characteristic impedance of the cable + +4. **Twisted Pair Cable** + - Cat5/Cat6 Ethernet cable works well + - Use one twisted pair for A/B signals + - Keep unused pairs grounded or leave disconnected + +### Recommended Modules + +- **HW-0519** (MAX485 based) - Common and inexpensive +- **XY-017** (MAX485 based) +- **Any MAX3485-based module** - Better noise immunity + +## Detailed Wiring Steps + +### Step 1: Connect Power + +```text +RP2350 3.3V → RS485 Module VCC +RP2350 GND → RS485 Module GND +``` + +**Note:** Most RS485 modules work with both 3.3V and 5V. Check your module's datasheet. + +### Step 2: Connect UART Signals + +```text +RP2350 GP0 (UART0 TX) → RS485 Module DI (Driver Input) +RP2350 GP1 (UART0 RX) → RS485 Module RO (Receiver Output) +``` + +### Step 3: Connect Direction Control + +```txt +RP2350 GP2 (RTS) → RS485 Module DE (Driver Enable) +RP2350 GP2 (RTS) → RS485 Module RE (Receiver Enable) +``` + +**Important:** DE and RE must be tied together and controlled by the same GPIO pin. + +### Step 4: Connect to RS485 Bus + +```txt +RS485 Module A → Twisted Pair Wire 1 (e.g., Orange in Cat5) +RS485 Module B → Twisted Pair Wire 2 (e.g., Orange/White in Cat5) +``` + +### Step 5: Add Termination + +Install 120Ω resistor between A and B at **both ends** of the bus only. + +## Pin Customization + +To use different GPIO pins, modify these definitions in [`src/main.cpp`](../src/main.cpp:57): + +```cpp +#define RS485_TX_PIN 0 // Change to your desired TX pin +#define RS485_RX_PIN 1 // Change to your desired RX pin +#define RS485_DE_PIN 2 // Change to your desired DE/RE control pin +``` + +### Available UART Pins on RP2350 + +**UART0:** + +- TX: GP0, GP12, GP16, GP28 +- RX: GP1, GP13, GP17, GP29 + +**UART1:** + +- TX: GP4, GP8, GP20, GP24 +- RX: GP5, GP9, GP21, GP25 + +## Troubleshooting + +### No Communication + +- [ ] Check all power connections (3.3V and GND) +- [ ] Verify A connects to A, B connects to B (not crossed) +- [ ] Ensure DE and RE are tied together +- [ ] Check termination resistors are installed +- [ ] Verify baud rate matches on all devices + +### Intermittent Communication + +- [ ] Add or check 120Ω termination resistors +- [ ] Use twisted pair cable +- [ ] Reduce cable length +- [ ] Check for loose connections +- [ ] Ensure proper grounding + +### One-Way Communication Only + +- [ ] Verify DE/RE control pin is connected +- [ ] Check GPIO pin number in code matches hardware +- [ ] Test DE/RE pin with LED to verify it's toggling + +## Testing + +### LED Test for DE/RE Pin + +Add an LED with resistor to GP2 to visually confirm direction switching: + +``` +GP2 → 330Ω Resistor → LED Anode → LED Cathode → GND +``` + +LED should blink when transmitting. + +### Loopback Test + +For initial testing without a second device: +1. Connect A to A and B to B on the same module (short circuit) +2. The device should receive its own transmissions +3. Check serial monitor for "Received: Hello from RP2350!..." + +## Safety Notes + +- ⚠️ Do not hot-plug RS485 connections while powered +- ⚠️ Ensure voltage levels are compatible (3.3V vs 5V) +- ⚠️ Use proper ESD protection when handling boards +- ⚠️ Double-check polarity before powering on + +## Additional Resources + +- [RS485 Standard Overview](https://en.wikipedia.org/wiki/RS-485) +- [RP2350 Pinout](https://datasheets.raspberrypi.com/pico/Pico-2-Pinout.pdf) +- [MAX485 Datasheet](https://www.analog.com/media/en/technical-documentation/data-sheets/MAX1487-MAX491.pdf) From d54d8a2321f696c001839705b21cf457e11a09ed Mon Sep 17 00:00:00 2001 From: Markos Hudson Date: Thu, 9 Oct 2025 18:02:02 -0700 Subject: [PATCH 02/12] platformio in requirements.txt --- firmware/requirements.txt | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 firmware/requirements.txt diff --git a/firmware/requirements.txt b/firmware/requirements.txt new file mode 100644 index 0000000..57f61e1 --- /dev/null +++ b/firmware/requirements.txt @@ -0,0 +1,11 @@ +# Python dependencies for firmware development projects +# Install with: pip install -r requirements.txt + +# PlatformIO - embedded development platform +platformio>=6.1.0 + +# Serial communication utilities (used by show_device_info.sh and debugging) +pyserial>=3.5 + +# Bluetooth Low Energy library (used by ble_char.py) +bleak>=0.20.0 From a9269275cda2a8abdd1ff1e01cd010ad2558effd Mon Sep 17 00:00:00 2001 From: Markos Hudson Date: Wed, 22 Oct 2025 13:38:54 -0700 Subject: [PATCH 03/12] experiments with ESP32 and RP2350 firmware --- firmware/.roo/agent_rules.md | 9 +++ firmware/platformio-blink/TaskCompleted.md | 66 +++++++++++++++ firmware/rp2350-rs485/.gitignore | 1 + firmware/rp2350-rs485/README.md | 4 +- firmware/rp2350-rs485/build.sh | 91 ++++++++++++++++++--- firmware/rp2350-rs485/platformio.ini | 36 ++++++++- firmware/rp2350-rs485/src/main.cpp | 93 +++++++++++++--------- 7 files changed, 248 insertions(+), 52 deletions(-) create mode 100644 firmware/.roo/agent_rules.md create mode 100644 firmware/platformio-blink/TaskCompleted.md diff --git a/firmware/.roo/agent_rules.md b/firmware/.roo/agent_rules.md new file mode 100644 index 0000000..1776a57 --- /dev/null +++ b/firmware/.roo/agent_rules.md @@ -0,0 +1,9 @@ + +# Rules + +## Style + +1. When making MarkDown files, please leave a blank line between all headings and body/lists/text/code-fence. +2. More MarkDown rules: Lists should be surrounded by blank lines. +3. Remember to number the Todo lists. + diff --git a/firmware/platformio-blink/TaskCompleted.md b/firmware/platformio-blink/TaskCompleted.md new file mode 100644 index 0000000..d478811 --- /dev/null +++ b/firmware/platformio-blink/TaskCompleted.md @@ -0,0 +1,66 @@ +# Task Completed: ESP32 Upload and Monitor + +## Command Executed +```bash +pio run -t upload -t monitor +``` + +## Hardware Details +- **Chip**: ESP32-D0WD-V3 (revision v3.1) +- **Features**: WiFi, BT, Dual Core, 240MHz, VRef calibration in efuse +- **Crystal**: 40MHz +- **MAC Address**: d4:8c:49:e2:f9:78 +- **Serial Port**: /dev/cu.usbserial-21440 + +## Build Information +- **Platform**: Espressif 32 (6.12.0) +- **Board**: ESP32 Dev Module +- **Framework**: Arduino ESP32 +- **Build Mode**: Release + +### Memory Usage +- **RAM**: 16.4% used (53,852 / 327,680 bytes) +- **Flash**: 84.1% used (1,102,309 / 1,310,720 bytes) + +### Dependencies +- Adafruit SSD1306 @ 2.5.15 +- Adafruit GFX Library @ 1.12.2 +- ESPAsyncWebServer-esphome @ 3.4.0 +- AsyncTCP @ 3.4.8 +- NimBLE-Arduino @ 2.3.6 +- Preferences @ 2.0.0 +- WiFi @ 2.0.0 +- Wire @ 2.0.0 + +## Upload Results +- **Upload Protocol**: esptool +- **Baud Rate**: 460800 (changed from default) +- **Total Upload Time**: 17.9 seconds +- **Upload Speed**: 496.5 kbit/s effective + +### Flash Sections Written +- Bootloader: 0x00001000 (17,536 bytes) +- Partition Table: 0x00008000 (3,072 bytes) +- Boot App: 0x0000e000 (8,192 bytes) +- Application: 0x00010000 (1,108,880 bytes) + +## Runtime Status +- **WiFi**: Connected with IP 192.168.50.250 +- **BLE**: Advertising as "RAINparkE2F978" +- **BLE Service UUID**: 12345678-1234-1234-1234-123456789ABC +- **BLE Characteristics**: + - Char1 (AC9005F6-80BE-42A2-925E-A8C93049E8DA): "14.2.12" + - Char2 (4D41385F-3629-7E51-B387-27116C3391A3): "4.123.0" +- **Web Server**: Async web server started and running +- **Provisioning**: Device in provisioning mode with WiFi commands available + +## Available Provisioning Commands +- `wifi/SSID/PASSWORD` → set/connect (password masked) +- `wifi/clear` → erase stored credentials +- `help` or `?` → show full command list + +## Task Completion Details +- **Date**: 2025-09-18 +- **Total Execution Time**: 56.81 seconds +- **Status**: SUCCESS +- **Device State**: Fully operational and responding to commands diff --git a/firmware/rp2350-rs485/.gitignore b/firmware/rp2350-rs485/.gitignore index 587a278..5f4f290 100644 --- a/firmware/rp2350-rs485/.gitignore +++ b/firmware/rp2350-rs485/.gitignore @@ -4,6 +4,7 @@ .vscode/c_cpp_properties.json .vscode/launch.json .vscode/ipch +pio-build-symlink # IDE .vscode/ diff --git a/firmware/rp2350-rs485/README.md b/firmware/rp2350-rs485/README.md index 7baf9de..2134dcd 100644 --- a/firmware/rp2350-rs485/README.md +++ b/firmware/rp2350-rs485/README.md @@ -51,7 +51,7 @@ This project demonstrates how to implement RS485 communication on the RP2350 mic ### Communication Settings -- **Baud Rate:** 9600 (configurable in [`src/main.cpp`](src/main.cpp:19)) +- **Baud Rate:** 230400 (configurable in [`src/main.cpp`](src/main.cpp:54)) - **Data Format:** 8N1 (8 data bits, no parity, 1 stop bit) - **Mode:** Half-duplex with automatic direction control @@ -114,7 +114,7 @@ The default code transmits a message every 2 seconds and continuously listens fo ```cpp // Transmitted message format -"Hello from RP2350! Uptime: XXXXX ms" +"[RP2350-XXXX|yyyyMMddhhmm] Uptime: XXXXX ms" ``` ### Customizing the Code diff --git a/firmware/rp2350-rs485/build.sh b/firmware/rp2350-rs485/build.sh index 7890e20..8df543f 100755 --- a/firmware/rp2350-rs485/build.sh +++ b/firmware/rp2350-rs485/build.sh @@ -14,7 +14,7 @@ NC='\033[0m' # No Color # Project configuration PROJECT_NAME="RP2350 RS485" -ENV_NAME="rp2350" +ENV_NAME="" # Will be auto-detected # Function to print colored output print_status() { @@ -67,9 +67,73 @@ check_platformio() { fi } +# Function to detect connected board +detect_board() { + print_status "Detecting connected RP2350 board..." + + # Check for device in BOOTSEL mode + if pio device list 2>/dev/null | grep -q "RP2350"; then + local device_info=$(pio device list 2>/dev/null | grep -A 3 "RP2350") + + # Try to identify SparkFun board by USB VID/PID or device name + if echo "$device_info" | grep -qi "sparkfun\|1b4f"; then + ENV_NAME="sparkfun_thingplus_rp2350" + BOARD_NAME="SparkFun RP2350 Thing Plus" + print_success "Detected: $BOARD_NAME" + return 0 + fi + fi + + # Check for Pico 2W by USB VID/PID (Raspberry Pi's VID is 2e8a) + if pio device list 2>/dev/null | grep -qi "2e8a\|raspberry.*pi.*pico.*2"; then + ENV_NAME="rpipico2w" + BOARD_NAME="Raspberry Pi Pico 2W" + print_success "Detected: $BOARD_NAME" + return 0 + fi + + # Fallback: check which board has been previously built + if [ -d ".pio/build/sparkfun_thingplus_rp2350" ]; then + ENV_NAME="sparkfun_thingplus_rp2350" + BOARD_NAME="SparkFun RP2350 Thing Plus (from previous build)" + print_warning "No board detected, using: $BOARD_NAME" + return 0 + elif [ -d ".pio/build/rpipico2w" ]; then + ENV_NAME="rpipico2w" + BOARD_NAME="Raspberry Pi Pico 2W (from previous build)" + print_warning "No board detected, using: $BOARD_NAME" + return 0 + fi + + # If no board detected, ask user + print_warning "Could not auto-detect board type." + echo "Please select your board:" + echo " 1) Raspberry Pi Pico 2W" + echo " 2) SparkFun RP2350 Thing Plus" + read -p "Enter choice (1-2): " -n 1 -r + echo "" + + case $REPLY in + 1) + ENV_NAME="rpipico2w" + BOARD_NAME="Raspberry Pi Pico 2W" + ;; + 2) + ENV_NAME="sparkfun_thingplus_rp2350" + BOARD_NAME="SparkFun RP2350 Thing Plus" + ;; + *) + print_error "Invalid selection" + exit 1 + ;; + esac + + print_status "Selected: $BOARD_NAME" +} + # Function to build the project build_project() { - print_status "Building $PROJECT_NAME..." + print_status "Building $PROJECT_NAME for $BOARD_NAME..." if [ "$VERBOSE" = true ]; then pio run -e $ENV_NAME --verbose @@ -95,7 +159,7 @@ build_project() { # Function to upload firmware upload_firmware() { - print_status "Uploading firmware to Pico 2 W..." + print_status "Uploading firmware to $BOARD_NAME..." # Check if a specific port was provided if [ -n "$UPLOAD_PORT" ]; then @@ -107,13 +171,13 @@ upload_firmware() { fi # Instructions for BOOTSEL mode - print_warning "Make sure your Pico 2 W is in BOOTSEL mode:" + print_warning "Make sure your $BOARD_NAME is in BOOTSEL mode:" print_status "1. Hold the BOOTSEL button while connecting USB" print_status "2. Or hold BOOTSEL and press RESET if already connected" - print_status "3. The Pico should appear as a USB mass storage device" + print_status "3. The board should appear as a USB mass storage device" # Wait for user confirmation - read -p "Press Enter when your Pico 2 W is in BOOTSEL mode and ready for upload..." + read -p "Press Enter when your board is in BOOTSEL mode and ready for upload..." if [ "$VERBOSE" = true ]; then $UPLOAD_CMD --verbose @@ -123,11 +187,11 @@ upload_firmware() { if [ $? -eq 0 ]; then print_success "Upload completed successfully!" - print_status "Your Pico 2 W should now be running the new firmware." + print_status "Your $BOARD_NAME should now be running the new firmware." else print_error "Upload failed!" print_status "Troubleshooting:" - print_status "- Ensure the Pico is in BOOTSEL mode" + print_status "- Ensure the board is in BOOTSEL mode" print_status "- Check USB connection" print_status "- Try a different USB cable or port" exit 1 @@ -160,9 +224,11 @@ clean_project() { # Function to show project info show_project_info() { print_status "Project: $PROJECT_NAME" - print_status "Environment: $ENV_NAME" + if [ -n "$ENV_NAME" ]; then + print_status "Environment: $ENV_NAME" + print_status "Board: $BOARD_NAME" + fi print_status "Platform: Raspberry Pi (RP2350)" - print_status "Board: Pico 2 W" print_status "Framework: Arduino" echo "" } @@ -200,9 +266,12 @@ main() { echo "==================================================" echo "" - show_project_info check_platformio + # Detect board before showing info or executing commands + detect_board + show_project_info + case $COMMAND in "build") build_project diff --git a/firmware/rp2350-rs485/platformio.ini b/firmware/rp2350-rs485/platformio.ini index 16d844f..f8d5497 100644 --- a/firmware/rp2350-rs485/platformio.ini +++ b/firmware/rp2350-rs485/platformio.ini @@ -1,4 +1,5 @@ -; PlatformIO Project Configuration File for RP2350 RS485 Communication +; PlatformIO Project Configuration File for RP2350 dev board + RS485 Communication module +; • NOTE: Boards supported: Raspberry Pi Pico 2W, SparkFun RP2350 Thing Plus, etc. ; ; Build options: build flags, source filter ; Upload options: custom upload port, speed and extra flags @@ -8,16 +9,45 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html -[env:rp2350] +; Default environment for Raspberry Pi Pico 2W +[env:rpipico2w] platform = https://github.com/maxgerhardt/platform-raspberrypi.git board = rpipico2w framework = arduino -; Build options for RP2350 (Pico 2W) +; Build options for RP2350 ; Using community platform with RP2350 support build_flags = -D ARDUINO_RASPBERRY_PI_PICO2 -D PICO_RP2350=1 + -D ARDUINO_RASPBERRY_PI_PICO_W + + +; Monitor options +monitor_speed = 115200 +monitor_filters = + colorize + time + +; Upload options +upload_protocol = picotool + +; Library dependencies +lib_deps = + ; Add RS485 library if needed + ; For example: https://github.com/4-20ma/ModbusMaster + +; Environment for SparkFun RP2350 Thing Plus +[env:sparkfun_thingplus_rp2350] +platform = https://github.com/maxgerhardt/platform-raspberrypi.git +board = rpipico2w ; Use Pico 2W as base, SparkFun board is pin-compatible +framework = arduino + +; Build options for SparkFun RP2350 Thing Plus +build_flags = + -D ARDUINO_RASPBERRY_PI_PICO2 + -D PICO_RP2350=1 + -D SPARKFUN_THINGPLUS_RP2350=1 ; Monitor options monitor_speed = 115200 diff --git a/firmware/rp2350-rs485/src/main.cpp b/firmware/rp2350-rs485/src/main.cpp index 9ba9ff3..c78f88b 100644 --- a/firmware/rp2350-rs485/src/main.cpp +++ b/firmware/rp2350-rs485/src/main.cpp @@ -29,7 +29,7 @@ char buildID[13]; // yyyyMMddhhmm + null terminator // Helper function to parse build timestamp into yyyyMMddhhmm format void initBuildID() { - const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", + const char* months[] = {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; char monthStr[4] = {__DATE__[0], __DATE__[1], __DATE__[2], '\0'}; int month = 1; @@ -39,13 +39,13 @@ void initBuildID() { break; } } - - int year = (__DATE__[7] - '0') * 1000 + (__DATE__[8] - '0') * 100 + + + int year = (__DATE__[7] - '0') * 1000 + (__DATE__[8] - '0') * 100 + (__DATE__[9] - '0') * 10 + (__DATE__[10] - '0'); int day = ((__DATE__[4] == ' ' ? 0 : __DATE__[4] - '0') * 10) + (__DATE__[5] - '0'); int hour = (__TIME__[0] - '0') * 10 + (__TIME__[1] - '0'); int minute = (__TIME__[3] - '0') * 10 + (__TIME__[4] - '0'); - + snprintf(buildID, sizeof(buildID), "%04d%02d%02d%02d%02d", year, month, day, hour, minute); } @@ -68,31 +68,31 @@ void initBuildID() { // Function prototypes void setupRS485(); void rs485Transmit(const char* message); -void rs485Receive(); +size_t rs485Receive(); void setRS485Mode(bool transmitMode); void printDeviceInfo(); void setup() { // Initialize build ID first initBuildID(); - + // Initialize USB Serial for debugging first Serial.begin(115200); while (!Serial && millis() < 3000) { ; // Wait for serial port to connect or timeout after 3 seconds } - + Serial.println(); Serial.println("========================================"); Serial.println("RP2350 RS485 Communication"); Serial.println("========================================"); - + // Print device identification printDeviceInfo(); - + Serial.println("========================================"); Serial.println(); - + // Initialize onboard LED for visual feedback pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, HIGH); // Turn on LED to show firmware is running @@ -114,14 +114,18 @@ void setup() { void loop() { static unsigned long lastTransmitTime = 0; - const unsigned long transmitInterval = 2000; // Transmit every 2 seconds + static unsigned long lastStatusTime = 0; + const unsigned long transmitInterval = 3000; // Transmit every few seconds + const unsigned long statusInterval = 5000; // Status update every 5 seconds static char deviceName[16] = {0}; - + static unsigned long totalBytesReceived = 0; + char statusMessage[128]; + // Get device name on first run if (deviceName[0] == 0) { pico_unique_board_id_t board_id; pico_get_unique_board_id(&board_id); - snprintf(deviceName, sizeof(deviceName), "RP2350-%02X%02X", + snprintf(deviceName, sizeof(deviceName), "RP2350-%02X%02X", board_id.id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES-2], board_id.id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES-1]); } @@ -130,13 +134,13 @@ void loop() { if (Serial.available() > 0) { String command = Serial.readStringUntil('\n'); command.trim(); - + if (command.length() > 0) { // Blink LED to show activity digitalWrite(LED_PIN, LOW); delay(100); digitalWrite(LED_PIN, HIGH); - + // Transmit the user's message via RS485 char message[128]; snprintf(message, sizeof(message), "[%s|%s] %s", deviceName, buildID, command.c_str()); @@ -146,6 +150,7 @@ void loop() { } } + // Periodic transmission example // Periodic transmission example if (millis() - lastTransmitTime >= transmitInterval) { lastTransmitTime = millis(); @@ -161,13 +166,24 @@ void loop() { // Transmit via RS485 rs485Transmit(message); - Serial.print("AUTO: "); + + // Combined status and auto-message output + Serial.print("STATUS: Total received "); + Serial.print(totalBytesReceived); + Serial.print(" bytes | AUTO: "); Serial.println(message); } // Check for incoming RS485 data - rs485Receive(); - + totalBytesReceived += rs485Receive(); // prints out any received data right away in the function + + // Periodic status update + if (millis() - lastStatusTime >= statusInterval) { + lastStatusTime = millis(); + Serial.print("STATUS: Total received "); + Serial.print(totalBytesReceived); + Serial.println(" bytes"); + } delay(10); // Small delay to prevent tight loop } @@ -211,20 +227,25 @@ void rs485Transmit(const char* message) { } /** - * Receive and process RS485 data - */ -void rs485Receive() { - if (RS485_SERIAL.available() > 0) { - String receivedData = RS485_SERIAL.readStringUntil('\n'); - receivedData.trim(); - - if (receivedData.length() > 0) { - Serial.print("RECEIVED: "); - Serial.println(receivedData); - } - } -} - + /** + * Receive and process RS485 data + * Returns the number of bytes received + */ + size_t rs485Receive() { + if (RS485_SERIAL.available() > 0) { + String receivedData = RS485_SERIAL.readStringUntil('\n'); + receivedData.trim(); + + if (receivedData.length() > 0) { + Serial.print("RECEIVED ("); + Serial.print(receivedData.length()); + Serial.print(" bytes): "); + Serial.println(receivedData); + return receivedData.length(); + } + } + return 0; + } /** * Set RS485 transceiver mode * @param transmitMode - true for transmit mode, false for receive mode @@ -244,14 +265,14 @@ void printDeviceInfo() { // Get the unique device ID (8 bytes for RP2350) pico_unique_board_id_t board_id; pico_get_unique_board_id(&board_id); - + // Print build timestamp Serial.print("Build Time: "); Serial.print(BUILD_TIMESTAMP); Serial.print(" ("); Serial.print(buildID); Serial.println(")"); - + // Print unique board ID Serial.print("Device ID: "); for (int i = 0; i < PICO_UNIQUE_BOARD_ID_SIZE_BYTES; i++) { @@ -260,10 +281,10 @@ void printDeviceInfo() { if (i < PICO_UNIQUE_BOARD_ID_SIZE_BYTES - 1) Serial.print(":"); } Serial.println(); - + // Create a short device name from last 2 bytes of ID char deviceName[16]; - snprintf(deviceName, sizeof(deviceName), "RP2350-%02X%02X", + snprintf(deviceName, sizeof(deviceName), "RP2350-%02X%02X", board_id.id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES-2], board_id.id[PICO_UNIQUE_BOARD_ID_SIZE_BYTES-1]); Serial.print("Device Name: "); From 8126520b3264ba225d05b6e5066ab306eb89b490 Mon Sep 17 00:00:00 2001 From: Markos Hudson Date: Fri, 24 Oct 2025 17:58:32 -0700 Subject: [PATCH 04/12] Add ESP32 MicroPython firmware with BLE provisioning and OLED display support - Add BLE provisioning system for WiFi configuration - ble_provisioning.py: Core BLE GATT server implementation - check_ble_status.py: Utility to verify BLE functionality - test_ble.py: BLE testing script - ble_name.txt: Configurable BLE device name - Add OLED display integration - oled_display.py: SSD1306 OLED driver and display utilities - setup_oled.sh: Installation script for OLED dependencies - Add WiFi and connectivity features - wifi_config.py: WiFi configuration and connection management - boot.py: Boot sequence with WiFi and WebREPL initialization - main.py: Main application entry point - Add comprehensive documentation - README.md: Project overview and setup instructions - BLE_PROVISIONING_README.md: BLE provisioning guide - OLED_DISPLAY_README.md: OLED setup and usage - BLE_TROUBLESHOOTING.md: BLE debugging guide - BLE_REPL_CONFLICT_SOLUTION.md: Resolve BLE/REPL conflicts - WEBREPL_SETUP.md: WebREPL configuration guide - UPDATE_BLE_NAME.md: BLE device name customization - USB_STORAGE_ALTERNATIVES.md: File transfer methods - MAKEFILE_USAGE.md: Build system documentation - Add build and deployment tools - Makefile: Build automation for deployment - deploy.sh: Deployment script - test_provisioning.sh: Provisioning test script - main.mpy: Compiled MicroPython bytecode This implementation provides a complete IoT solution for ESP32 with wireless provisioning, display output, and multiple deployment options. --- firmware/esp32/BLE_PROVISIONING_README.md | 263 +++++++++++ firmware/esp32/BLE_REPL_CONFLICT_SOLUTION.md | 229 ++++++++++ firmware/esp32/BLE_TROUBLESHOOTING.md | 437 +++++++++++++++++++ firmware/esp32/MAKEFILE_USAGE.md | 211 +++++++++ firmware/esp32/Makefile | 178 ++++++++ firmware/esp32/OLED_DISPLAY_README.md | 310 +++++++++++++ firmware/esp32/README.md | 375 ++++++++++++++++ firmware/esp32/UPDATE_BLE_NAME.md | 161 +++++++ firmware/esp32/USB_STORAGE_ALTERNATIVES.md | 192 ++++++++ firmware/esp32/WEBREPL_SETUP.md | 237 ++++++++++ firmware/esp32/ble_name.txt | 1 + firmware/esp32/ble_provisioning.py | 232 ++++++++++ firmware/esp32/boot.py | 67 +++ firmware/esp32/check_ble_status.py | 37 ++ firmware/esp32/deploy.sh | 127 ++++++ firmware/esp32/main.mpy | 166 +++++++ firmware/esp32/main.py | 167 +++++++ firmware/esp32/oled_display.py | 294 +++++++++++++ firmware/esp32/setup_oled.sh | 45 ++ firmware/esp32/test_ble.py | 134 ++++++ firmware/esp32/test_provisioning.sh | 23 + firmware/esp32/wifi_config.py | 17 + firmware/requirements.txt | 6 + firmware/serial_ish.py | 303 +++++++++++++ 24 files changed, 4212 insertions(+) create mode 100644 firmware/esp32/BLE_PROVISIONING_README.md create mode 100644 firmware/esp32/BLE_REPL_CONFLICT_SOLUTION.md create mode 100644 firmware/esp32/BLE_TROUBLESHOOTING.md create mode 100644 firmware/esp32/MAKEFILE_USAGE.md create mode 100644 firmware/esp32/Makefile create mode 100644 firmware/esp32/OLED_DISPLAY_README.md create mode 100644 firmware/esp32/README.md create mode 100644 firmware/esp32/UPDATE_BLE_NAME.md create mode 100644 firmware/esp32/USB_STORAGE_ALTERNATIVES.md create mode 100644 firmware/esp32/WEBREPL_SETUP.md create mode 100644 firmware/esp32/ble_name.txt create mode 100644 firmware/esp32/ble_provisioning.py create mode 100644 firmware/esp32/boot.py create mode 100644 firmware/esp32/check_ble_status.py create mode 100755 firmware/esp32/deploy.sh create mode 100644 firmware/esp32/main.mpy create mode 100644 firmware/esp32/main.py create mode 100644 firmware/esp32/oled_display.py create mode 100755 firmware/esp32/setup_oled.sh create mode 100644 firmware/esp32/test_ble.py create mode 100755 firmware/esp32/test_provisioning.sh create mode 100644 firmware/esp32/wifi_config.py create mode 100755 firmware/serial_ish.py diff --git a/firmware/esp32/BLE_PROVISIONING_README.md b/firmware/esp32/BLE_PROVISIONING_README.md new file mode 100644 index 0000000..3753d6f --- /dev/null +++ b/firmware/esp32/BLE_PROVISIONING_README.md @@ -0,0 +1,263 @@ +# ESP32 BLE Provisioning Script + +This MicroPython script allows you to configure the BLE (Bluetooth Low Energy) advertising name of your ESP32 development board via serial port commands. + +## Features + +- Accept provisioning commands via serial port (UART) +- Set custom BLE advertising names dynamically +- Automatic BLE advertising with configurable name +- Command format validation +- Connection status monitoring +- Auto-restart advertising after disconnect + +## Requirements + +- ESP32 development board +- MicroPython firmware installed (v1.20 or later recommended) +- USB connection to computer +- Terminal program (screen, minicom, PuTTY, or Thonny IDE) + +## Installation + +1. Flash MicroPython to your ESP32 (see main ESP32 README.md) + +2. Upload the script to your ESP32: + + ```bash + # Using ampy + ampy --port /dev/tty.usbserial-XXXXXXXX put ble_provisioning.py + + # Or rename it to main.py to run automatically on boot + ampy --port /dev/tty.usbserial-XXXXXXXX put ble_provisioning.py main.py + ``` + +3. Alternatively, use Thonny IDE: + - Open `ble_provisioning.py` in Thonny + - Save it to the ESP32 (File → Save As → MicroPython device) + +## Usage + +### Running the Script + +**Option 1: Run directly from REPL** + +```bash +# Connect to REPL +screen /dev/tty.usbserial-XXXXXXXX 115200 + +# In the REPL, run: +>>> import ble_provisioning +``` + +### Option 2: Run as main.py (auto-start on boot) + +If you saved it as `main.py`, it will run automatically when the ESP32 boots. + +**Option 3: Use ampy to run** + +```bash +ampy --port /dev/tty.usbserial-XXXXXXXX run ble_provisioning.py +``` + +### Provisioning Commands + +#### Set BLE Name + +``` +ble/name EXAMPLE-1234 +``` + +**Format:** + +- Command: `ble/name` +- Separator: single space +- Name: Any alphanumeric string (up to 29 characters) + +**Examples:** + +```tty +ble/name ESP32-Living-Room +ble/name SENSOR-001 +ble/name MyDevice-ABC123 +ble/name Office-Temp-Monitor +``` + +### Expected Output + +When you run the script, you'll see: + +```text +================================================== +ESP32 BLE Provisioning Script +================================================== +[INFO] Starting BLE provisioning system... +[BLE] Started advertising as: ESP32-Device + +[READY] Waiting for provisioning commands... +[HELP] Send commands in format: ble/name EXAMPLE-1234 +[HELP] Press Ctrl+C to exit +``` + +When you send a provisioning command: + +```tty +ble/name EXAMPLE-1234 +[CMD] Received: ble/name EXAMPLE-1234 +[BLE] Name changed: ESP32-Device -> EXAMPLE-1234 +[BLE] Stopped advertising +[BLE] Started advertising as: EXAMPLE-1234 +``` + +### Testing BLE Advertising + +You can verify the BLE advertising name using: + +**iOS:** +- Open Settings → Bluetooth +- Look for your device name in the list + +**Android:** +- Use "nRF Connect" app (free from Play Store) +- Scan for devices +- Look for your device name + +**macOS:** +- Open System Settings → Bluetooth +- Look for your device in the list + +**Linux:** +```bash +# Scan for BLE devices +sudo hcitool lescan + +# Or use bluetoothctl +bluetoothctl +scan on +``` + +## Command Reference + +| Command | Format | Description | Example | +|---------|--------|-------------|---------| +| `ble/name` | `ble/name ` | Set BLE advertising name | `ble/name SENSOR-42` | + +## Limitations + +- **Name length:** Maximum 29 characters (longer names will be truncated) +- **Characters:** Best to use alphanumeric characters and hyphens +- **Persistence:** Name is not saved to flash - resets to default on reboot + +## Troubleshooting + +### Script doesn't respond to commands + +1. Make sure you're sending newline character (`\n` or `\r\n`) +2. Try typing commands directly in the terminal +3. Check that BLE is supported and enabled on your ESP32 + +### BLE name doesn't change on phone + +1. Turn Bluetooth off and on again on your phone +2. Forget the device if it was previously paired +3. Move closer to the ESP32 +4. Restart the BLE scan + +### "ImportError: no module named 'bluetooth'" + +- Your MicroPython build doesn't include Bluetooth support +- Download and flash a firmware with BLE support from [micropython.org](https://micropython.org/download/ESP32_GENERIC/) +- Ensure you're using ESP32 (not ESP8266) + +### Script crashes or resets + +- Check available memory: `import gc; gc.mem_free()` +- Some ESP32 boards have limited RAM for BLE operations +- Try reducing buffer sizes or simplifying the code + +## Advanced Usage + +### Saving Name to Flash + +To persist the BLE name across reboots, you can modify the script to save/load from a configuration file: + +```python +import json + +# Save name +def save_config(name): + with open('ble_config.json', 'w') as f: + json.dump({'ble_name': name}, f) + +# Load name on startup +def load_config(): + try: + with open('ble_config.json', 'r') as f: + config = json.load(f) + return config.get('ble_name', DEFAULT_BLE_NAME) + except: + return DEFAULT_BLE_NAME +``` + +### Integration with Other Scripts + +You can import the `BLEProvisioning` class in your own scripts: + +```python +from ble_provisioning import BLEProvisioning + +# Create instance +ble = BLEProvisioning() + +# Set custom name +ble.set_name("MyCustomName") + +# Your application code here +while True: + # Do your work + pass +``` + +## Example Session + +``` +$ screen /dev/tty.usbserial-0001 115200 + +================================================== +ESP32 BLE Provisioning Script +================================================== +[INFO] Starting BLE provisioning system... +[BLE] Started advertising as: ESP32-Device + +[READY] Waiting for provisioning commands... +[HELP] Send commands in format: ble/name EXAMPLE-1234 +[HELP] Press Ctrl+C to exit + +ble/name LIVING-ROOM-SENSOR +[CMD] Received: ble/name LIVING-ROOM-SENSOR +[BLE] Name changed: ESP32-Device -> LIVING-ROOM-SENSOR +[BLE] Stopped advertising +[BLE] Started advertising as: LIVING-ROOM-SENSOR + +ble/name BEDROOM-001 +[CMD] Received: ble/name BEDROOM-001 +[BLE] Name changed: LIVING-ROOM-SENSOR -> BEDROOM-001 +[BLE] Stopped advertising +[BLE] Started advertising as: BEDROOM-001 + +^C +[INFO] Shutting down... +[BLE] Stopped advertising +[INFO] BLE advertising stopped +[INFO] Goodbye! +``` + +## License + +This script is provided as-is for educational and development purposes. + +## Resources + +- [MicroPython Bluetooth Documentation](https://docs.micropython.org/en/latest/library/bluetooth.html) +- [ESP32 BLE Documentation](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/bluetooth/index.html) +- [Main ESP32 README](./README.md) diff --git a/firmware/esp32/BLE_REPL_CONFLICT_SOLUTION.md b/firmware/esp32/BLE_REPL_CONFLICT_SOLUTION.md new file mode 100644 index 0000000..dab11ec --- /dev/null +++ b/firmware/esp32/BLE_REPL_CONFLICT_SOLUTION.md @@ -0,0 +1,229 @@ +# BLE Provisioning and REPL Conflict - Solution Guide + +## The Problem + +**The BLE provisioning script CANNOT work while the Python REPL is running.** Here's why: + +### Root Causes + +1. **Serial Port Conflict**: Both the REPL and the BLE provisioning script try to read from the same serial port (`sys.stdin`) +2. **Blocking Input**: The provisioning script uses `input()` or `select()` which blocks and prevents REPL interaction +3. **Main Thread Occupation**: The script runs in an infinite loop in the main thread, preventing the REPL prompt from appearing +4. **Auto-start Conflict**: The `boot.py` automatically starts `main.py`, which launches the BLE provisioning system before you can access the REPL + +### Current Setup Analysis + +``` +boot.py (lines 7-8) + ↓ Auto-starts on boot +main.py + ↓ Starts BLE in main thread (line 73) +ble_provisioning.py + ↓ Runs infinite loop reading stdin (lines 188-204) +BLOCKS REPL ACCESS ❌ +``` + +## Solutions + +### Solution 1: Disable Auto-Start (ALREADY APPLIED ✓) + +**Best for**: Development, testing, REPL access + +The `boot.py` file has been modified to NOT auto-start `main.py`. This allows you to: +- Access the REPL normally +- Manually start the BLE provisioning when needed +- Test code interactively + +**To use BLE provisioning now:** + +```python +# Connect to REPL +# Then manually import and start: +>>> import ble_provisioning +>>> # This will start the BLE provisioning system +``` + +**To re-enable auto-start for production:** + +Edit `boot.py` and uncomment the last two lines: +```python +import main +main.main() +``` + +### Solution 2: Use WebREPL Instead of Serial REPL + +**Best for**: Remote access, keeping auto-start enabled + +Keep the auto-start in `boot.py` but access the ESP32 via WebREPL over WiFi instead of serial. + +**Setup:** + +1. Edit `boot.py` to enable WebREPL: +```python +import webrepl +webrepl.start() +``` + +2. Configure WiFi in `boot.py`: +```python +import network +wlan = network.WLAN(network.STA_IF) +wlan.active(True) +wlan.connect('YOUR_SSID', 'YOUR_PASSWORD') +``` + +3. Access via browser: `http://micropython.org/webrepl/` + +**Pros**: +- BLE provisioning can run on serial port +- Still have REPL access via WiFi +- No physical connection needed + +**Cons**: +- Requires WiFi setup +- Additional complexity + +### Solution 3: Use Alternative Communication Method + +**Best for**: Production deployments + +Instead of using serial input for provisioning, use a BLE characteristic that can be written to: + +**Changes needed:** + +1. Add a GATT service with a writable characteristic +2. Read provisioning commands from the BLE characteristic +3. Remove serial input handling + +**Example:** +```python +# Add a GATT service for provisioning +_PROV_UUID = bluetooth.UUID('12345678-1234-5678-1234-56789abcdef0') +_PROV_CHAR = bluetooth.UUID('12345678-1234-5678-1234-56789abcdef1') + +# Handle writes to characteristic +def _irq_handler(self, event, data): + if event == _IRQ_GATTS_WRITE: + # Read the provisioning command from BLE write + conn_handle, attr_handle = data + value = self.ble.gatts_read(attr_handle) + self.process_command(value.decode('utf-8')) +``` + +**Pros**: +- No serial port conflicts +- Truly wireless provisioning +- More professional solution + +**Cons**: +- More complex implementation +- Requires BLE app on phone/computer + +### Solution 4: Conditional Auto-Start with Boot Pin + +**Best for**: Flexibility between development and production + +Add a boot pin check to decide whether to auto-start: + +```python +# boot.py +from machine import Pin +import time + +# Check GPIO pin (e.g., GPIO 0) +boot_pin = Pin(0, Pin.IN, Pin.PULL_UP) +time.sleep_ms(100) # Debounce + +# Only auto-start if pin is HIGH (not pressed) +if boot_pin.value() == 1: + import main + main.main() +else: + print("Boot pin held LOW - REPL mode") + print("Release pin and run: import main; main.main()") +``` + +**Usage**: +- Normal boot: Auto-starts BLE provisioning +- Hold GPIO 0 to GND during boot: Get REPL access + +## Testing BLE Advertising + +Once BLE is running (via any method above), verify it's working: + +### macOS/Linux: +```bash +# Scan for BLE devices +sudo hcitool lescan | grep "BAT-PRO-3 BEE9" +``` + +### Python Script: +```python +from bleak import BleakScanner +import asyncio + +async def scan(): + devices = await BleakScanner.discover() + for d in devices: + if "BAT-PRO-3" in d.name: + print(f"Found: {d.name} - {d.address}") + +asyncio.run(scan()) +``` + +### Phone Apps: +- iOS: Settings → Bluetooth +- Android: nRF Connect app +- Windows: Bluetooth settings + +## Current Configuration + +- **BLE Name File**: `ble_name.txt` contains "BAT-PRO-3 BEE9" +- **Auto-start**: DISABLED (REPL accessible) +- **To manually start BLE**: Run `import ble_provisioning` from REPL + +## Recommended Development Workflow + +1. **During Development** (current setup): + - Boot to REPL + - Manually start: `import ble_provisioning` + - Test and iterate + +2. **For Production**: + - Re-enable auto-start in `boot.py` + - OR implement Solution 3 (BLE characteristic provisioning) + - OR use Solution 4 (boot pin selection) + +## Common Issues + +### "I can't see the REPL prompt" +- The auto-start is still enabled in `boot.py` +- Press Ctrl+C to interrupt the running script +- Modify `boot.py` to disable auto-start + +### "BLE advertising doesn't start" +- You haven't imported the module yet +- Run: `import ble_provisioning` from REPL + +### "Can't detect BLE device on phone" +- BLE script is not running +- Check if script printed "Started advertising as: BAT-PRO-3 BEE9" +- Try turning Bluetooth off/on on your device +- Get closer to the ESP32 + +### "REPL freezes after importing ble_provisioning" +- This is expected - the script runs an infinite loop +- Press Ctrl+C to stop it +- This is why auto-start conflicts with REPL + +## Summary + +The fundamental issue is that **serial-based input handling and REPL cannot coexist**. You must choose one of: + +1. ✅ **REPL access** (boot.py auto-start disabled) - Current setup +2. **Auto-start BLE** (no serial REPL) - Production setup +3. **WebREPL** (WiFi REPL, serial for BLE) - Advanced setup +4. **BLE-based provisioning** (no serial dependency) - Professional solution + +The current configuration prioritizes REPL access for development. When ready for production, re-enable auto-start in `boot.py`. diff --git a/firmware/esp32/BLE_TROUBLESHOOTING.md b/firmware/esp32/BLE_TROUBLESHOOTING.md new file mode 100644 index 0000000..4936946 --- /dev/null +++ b/firmware/esp32/BLE_TROUBLESHOOTING.md @@ -0,0 +1,437 @@ +# ESP32 BLE Troubleshooting Guide + +## Problem: BLE Not Advertising (Device Not Visible) + +This guide helps diagnose and fix BLE advertising issues on your ESP32 running MicroPython. + +--- + +## Issues Fixed + +### ✅ Issue #1: Incompatible `select` module usage + +**Problem:** The original [`ble_provisioning.py`](ble_provisioning.py:222) tried to use the `select` module which is not available in MicroPython, causing the BLE provisioning to fail silently. + +**Solution:** Simplified the main loop to use file-based polling instead of serial input monitoring. The script now: +- Checks `ble_name.txt` every 5 seconds for updates +- Doesn't rely on the unavailable `select` module +- Runs more reliably on MicroPython + +--- + +## Quick Diagnostic Steps + +### Step 1: Run the BLE Test Script + +The [`test_ble.py`](test_ble.py) script tests BLE functionality independently: + +```bash +# Connect to ESP32 via serial +screen /dev/tty.usbserial-XXXXXXXX 115200 + +# In the MicroPython REPL, press Ctrl+C to stop boot.py +# Then run: +>>> import test_ble +``` + +**Expected Output:** +``` +============================================================ +ESP32 BLE Diagnostic Test +============================================================ + +[TEST 1] Checking bluetooth module... +✓ Bluetooth module imported successfully + +[TEST 2] Activating BLE... +✓ BLE activated: True + +[TEST 3] BLE Configuration... +✓ MAC Address: xx:xx:xx:xx:xx:xx + +[TEST 4] Creating advertising payload... +✓ Payload created: 14 bytes + Name: ESP32-TEST + Payload: 02 01 06 0a 09 45 53 50 33 32 2d 54 45 53 54 + +[TEST 5] Starting BLE advertising... +✓ Advertising started with name: ESP32-TEST + Interval: 100ms + +[TEST 6] Monitoring advertising status... +============================================================ +✓ BLE is now advertising as: ESP32-TEST + +INSTRUCTIONS: +1. Open a BLE scanner app on your phone + - iOS: LightBlue, nRF Connect + - Android: nRF Connect, BLE Scanner +2. Look for device named: ESP32-TEST +3. You should see it appear in the scan results +``` + +### Step 2: Scan for BLE Devices + +Use a BLE scanner app: + +**iOS Apps:** +- **LightBlue** (Free, recommended) +- **nRF Connect** (Free) + +**Android Apps:** +- **nRF Connect** (Free, recommended) +- **BLE Scanner** (Free) + +**What to look for:** +- Device name: `ESP32-TEST` (from test script) or your configured BLE name +- Signal strength (RSSI): Should be visible if within ~10 meters +- Advertising data should include the device name + +--- + +## Common Issues and Solutions + +### Issue: BLE test fails at "Activating BLE" + +**Symptoms:** +``` +[TEST 2] Activating BLE... +✗ FAILED: Bluetooth not available +``` + +**Possible Causes:** +1. ESP32 variant doesn't support BLE (some ESP32-S2 models lack BLE) +2. MicroPython firmware doesn't include BLE support +3. Hardware issue + +**Solutions:** +1. Verify your ESP32 model supports BLE (ESP32, ESP32-C3, ESP32-S3 do; ESP32-S2 doesn't) +2. Re-flash with official ESP32 MicroPython firmware that includes BLE +3. Try a different ESP32 board + +### Issue: BLE activates but advertising fails + +**Symptoms:** +``` +[TEST 5] Starting BLE advertising... +✗ FAILED: Operation not permitted +``` + +**Possible Causes:** +1. WiFi is interfering with BLE (both use 2.4GHz radio) +2. BLE already in use by another process +3. Insufficient memory + +**Solutions:** + +1. **Disable WiFi temporarily:** +```python +>>> import network +>>> wlan = network.WLAN(network.STA_IF) +>>> wlan.active(False) +>>> # Now try BLE test again +>>> import test_ble +``` + +2. **Reset the BLE stack:** +```python +>>> import bluetooth +>>> ble = bluetooth.BLE() +>>> ble.active(False) +>>> import time +>>> time.sleep(1) +>>> ble.active(True) +``` + +3. **Check free memory:** +```python +>>> import gc +>>> gc.collect() +>>> gc.mem_free() +``` +You should have at least 50KB free for BLE operations. + +### Issue: BLE advertises but scanner can't find it + +**Symptoms:** +- Test script shows "✓ Advertising started" +- Scanner app doesn't show the device + +**Solutions:** + +1. **Check scanner settings:** + - Ensure Bluetooth is enabled on your phone + - Make sure location services are enabled (required on Android for BLE scanning) + - Try refreshing the scanner + +2. **Verify advertising is active:** +```python +>>> import bluetooth +>>> ble = bluetooth.BLE() +>>> ble.active() # Should return True +``` + +3. **Check signal strength:** + - Move closer to the ESP32 (within 1-2 meters) + - Remove obstacles between phone and ESP32 + - ESP32's built-in antenna has limited range + +4. **Try different advertising interval:** +```python +# In ble_provisioning.py, line 77: +# Change from 100000 (100ms) to 50000 (50ms) for faster discovery +self.ble.gap_advertise(50000, adv_data=payload) +``` + +### Issue: WiFi connection prevents BLE from working + +**Symptoms:** +- WiFi connects successfully +- BLE advertising fails or device isn't visible + +**Explanation:** +ESP32 shares radio hardware between WiFi and BLE. In some cases, WiFi can interfere with BLE. + +**Solutions:** + +1. **Temporary: Disable WiFi for testing:** + Edit [`boot.py`](boot.py:25-60) to skip WiFi connection: + ```python + # Comment out WiFi connection block + # if WIFI_SSID and WIFI_PASSWORD: + # ... (WiFi connection code) + ``` + +2. **Use BLE-only mode:** + Create a minimal boot script that only starts BLE: + ```python + # boot_ble_only.py + import esp + esp.osdebug(None) + + # Skip WiFi, only start BLE + import ble_provisioning + ble_provisioning.main() + ``` + +3. **Optimize coexistence:** + - Use lower WiFi transmit power + - Reduce BLE advertising frequency + - Avoid simultaneous WiFi and BLE intensive operations + +--- + +## Deployment Instructions + +### Method 1: Using the Makefile + +```bash +cd esp32 +make deploy +``` + +This will: +1. Upload all Python files to the ESP32 +2. Reset the device +3. Start monitoring serial output + +### Method 2: Manual deployment + +```bash +# Find your ESP32 port +ls /dev/tty.usbserial-* + +# Upload files using ampy +export AMPY_PORT=/dev/tty.usbserial-XXXXXXXX +ampy put boot.py +ampy put main.py +ampy put ble_provisioning.py +ampy put wifi_config.py +ampy put ble_name.txt +ampy put test_ble.py + +# Reset the device +python3 -c "import serial; s=serial.Serial('/dev/tty.usbserial-XXXXXXXX', 115200); s.setDTR(False); s.setDTR(True); s.close()" +``` + +### Method 3: Using WebREPL (if WiFi works) + +```bash +# Install webrepl_cli +pip3 install webrepl + +# Upload files +webrepl_cli -p python3 boot.py 192.168.1.XXX:/boot.py +webrepl_cli -p python3 ble_provisioning.py 192.168.1.XXX:/ble_provisioning.py +``` + +--- + +## Testing the Fixed BLE Provisioning + +After deploying the fixed code: + +### 1. Check boot sequence + +Connect via serial and watch the boot messages: + +```bash +screen /dev/tty.usbserial-XXXXXXXX 115200 +# Press reset button on ESP32 +``` + +**Expected output:** +``` +================================================== +ESP32 Boot Sequence +================================================== +[WIFI] Configuration loaded +[WIFI] Connecting to orcYard... +.. +[WIFI] ✓ Connected! +[WIFI] IP Address: 192.168.1.XXX +... +================================================== + +================================================== +ESP32 Auto-Start System +================================================== +[MAIN] ✓ OLED display thread started +[MAIN] ✓ Starting BLE provisioning in main thread + +================================================== +ESP32 BLE Provisioning Script +================================================== +[INFO] Starting BLE provisioning system... +[FILE] Loaded name from ble_name.txt: ESP32-Device +[BLE] Started advertising as: ESP32-Device + +[READY] Waiting for provisioning commands... +[INFO] Running in file-polling mode (checking ble_name.txt every 5 seconds) + +[POLL] Checking ble_name.txt... Current: ESP32-Device, File: ESP32-Device +[POLL] No change detected +``` + +### 2. Test BLE visibility + +1. Open BLE scanner app on phone +2. Look for device with name from `ble_name.txt` (default: "ESP32-Device") +3. You should see it advertising + +### 3. Test name changes + +To change the BLE name, update the file: + +**Option A: Via WebREPL** +```python +# In WebREPL: +>>> with open('ble_name.txt', 'w') as f: +... f.write('MY-ESP32-123') +``` + +**Option B: Via ampy** +```bash +echo "MY-ESP32-123" > ble_name.txt +ampy put ble_name.txt +``` + +The system will detect the change within 5 seconds and restart advertising with the new name. + +--- + +## Understanding the File-Based Provisioning + +The updated system works as follows: + +1. **On boot:** + - Reads `ble_name.txt` to get the device name + - Starts BLE advertising with that name + +2. **During operation:** + - Every 5 seconds, checks if `ble_name.txt` has changed + - If changed, stops advertising and restarts with new name + +3. **To provision a new name:** + - Update `ble_name.txt` via WebREPL, ampy, or serial REPL + - Wait up to 5 seconds for automatic detection + - Or restart the device to apply immediately + +--- + +## Advanced Debugging + +### Enable verbose BLE logging + +Add this to [`ble_provisioning.py`](ble_provisioning.py:56) `start_advertising()`: + +```python +def start_advertising(self): + """Start BLE advertising with the current name""" + try: + print(f"[DEBUG] BLE active: {self.ble.active()}") + print(f"[DEBUG] Is advertising: {self.is_advertising}") + print(f"[DEBUG] Name to advertise: {self.ble_name}") + + # ... rest of function +``` + +### Monitor BLE events + +Add event logging to [`ble_provisioning.py`](ble_provisioning.py:42) `_irq_handler()`: + +```python +def _irq_handler(self, event, data): + """Handle BLE events""" + print(f"[DEBUG] BLE Event: {event}, Data: {data}") + # ... rest of handler +``` + +### Check BLE configuration + +```python +>>> import bluetooth +>>> ble = bluetooth.BLE() +>>> ble.active(True) +>>> ble.config('gap_name') # Get current GAP name +>>> ble.config('mac') # Get MAC address +>>> ble.config('rxbuf') # Get RX buffer size +``` + +--- + +## Summary of Changes + +1. **Fixed [`ble_provisioning.py`](ble_provisioning.py:195-224)** + - Removed dependency on unavailable `select` module + - Simplified main loop to use file-based polling + - Removed unused `main_polling()` function + - Added clearer status messages + +2. **Created [`test_ble.py`](test_ble.py)** + - Independent BLE diagnostic script + - Tests all BLE functionality step-by-step + - Provides clear pass/fail indicators + +3. **Created this troubleshooting guide** + - Common issues and solutions + - Deployment instructions + - Testing procedures + +--- + +## Still Having Issues? + +If BLE still doesn't work after following this guide: + +1. **Verify ESP32 model:** Confirm your board supports BLE +2. **Check MicroPython version:** Use latest stable release +3. **Test with minimal code:** Use [`test_ble.py`](test_ble.py) in isolation +4. **Check hardware:** Try a different ESP32 board +5. **Review serial output:** Look for error messages during boot + +For additional help, provide: +- ESP32 model/board name +- MicroPython version (`import sys; sys.version`) +- Complete serial output from boot +- Output from [`test_ble.py`](test_ble.py) diff --git a/firmware/esp32/MAKEFILE_USAGE.md b/firmware/esp32/MAKEFILE_USAGE.md new file mode 100644 index 0000000..4c95120 --- /dev/null +++ b/firmware/esp32/MAKEFILE_USAGE.md @@ -0,0 +1,211 @@ +# ESP32 Makefile Usage Guide + +This directory includes a professional Makefile for deploying MicroPython code to ESP32 devices. + +## Prerequisites + +- `mpremote` installed: `pip install mpremote` +- ESP32 board connected via USB + +## Quick Start + +```bash +# Deploy everything (recommended) +make deploy + +# Deploy to a specific port +make deploy PORT=/dev/ttyUSB0 +``` + +## Available Commands + +### Deployment + +```bash +# Full deployment: install libraries + upload files + reset +make deploy + +# Deploy to specific port (macOS example) +make deploy PORT=/dev/tty.usbserial-0001 + +# Deploy to specific port (Linux example) +make deploy PORT=/dev/ttyUSB0 + +# Only install required libraries +make install-libs + +# Only upload .py and .txt files (skip library installation) +make upload-files +``` + +### Device Management + +```bash +# Reset the ESP32 +make reset + +# List files on the device +make ls + +# Connect to REPL console +make repl + +# Remove all .py and .txt files from device +make clean-device +``` + +### Help + +```bash +# Show all available commands +make help +``` + +## How It Works + +The Makefile automates the deployment process: + +1. **Port Detection**: Auto-detects ESP32 port or uses `PORT=` parameter +2. **Library Installation**: Installs required packages (e.g., ssd1306 for OLED) +3. **File Upload**: Uploads all `.py` and `.txt` files from `esp32/` directory +4. **Device Reset**: Resets ESP32 to run the new code +5. **Instructions**: Shows how to connect to REPL for monitoring + +## Examples + +### Basic Deployment + +```bash +cd esp32 +make deploy +``` + +### Deploy to Specific Port + +```bash +# macOS +make deploy PORT=/dev/tty.usbserial-0001 + +# Linux +make deploy PORT=/dev/ttyUSB0 + +# Windows (in Git Bash or WSL) +make deploy PORT=COM3 +``` + +### Development Workflow + +```bash +# 1. Make changes to .py files +vim main.py + +# 2. Quick upload (skip library install) +make upload-files + +# 3. Connect to see output +make repl +``` + +### Troubleshooting + +```bash +# Check what's on the device +make ls + +# Clean everything and start fresh +make clean-device +make deploy + +# Reset if device is unresponsive +make reset +``` + +## Comparison: Makefile vs deploy.sh + +### Makefile Advantages: + +✅ **Modular targets**: Run individual steps (install-libs, upload-files, etc.) +✅ **Standard tool**: Widely known, no extra dependencies +✅ **Make features**: Dependency tracking, parallel execution (if needed) +✅ **Professional**: Standard approach for build automation +✅ **Cleaner syntax**: More readable than bash for this use case +✅ **Built-in help**: `make help` shows all commands + +### Bash Script Advantages: + +✅ **Simpler for beginners**: More familiar to some users +✅ **More verbose output**: Easier to follow step-by-step +✅ **No make required**: Works on systems without make (rare) + +## Migration from deploy.sh + +Both tools do the same thing. You can: + +**Option 1: Use Makefile** (recommended) +```bash +make deploy +``` + +**Option 2: Keep using deploy.sh** +```bash +./deploy.sh +``` + +**Option 3: Use both** +- Use `make deploy` for daily development +- Keep `deploy.sh` as backup or for CI/CD + +## Tips + +1. **Tab Completion**: Most shells support tab completion for make targets + ```bash + make dep # Completes to 'make deploy' + ``` + +2. **Default Target**: Running just `make` shows help + ```bash + make # Same as 'make help' + ``` + +3. **Port Persistence**: Set PORT in environment to avoid repeating + ```bash + export PORT=/dev/ttyUSB0 + make deploy # Uses PORT from environment + ``` + +4. **Parallel Development**: Each developer can use their own PORT + ```bash + # Developer 1 + make deploy PORT=/dev/ttyUSB0 + + # Developer 2 (different board) + make deploy PORT=/dev/ttyUSB1 + ``` + +## Customization + +Edit the Makefile to add custom targets: + +```makefile +# Example: Add a backup target +backup: + @mkdir -p backups + @mpremote $(PORT_ARG) cp :main.py backups/main.py.bak + @echo "Backup created" +``` + +## Color Output + +The Makefile uses ANSI colors for better readability: +- **Blue**: Section headers +- **Green**: Success messages +- **Yellow**: Warnings +- **Red**: Errors + +If colors don't work in your terminal, edit the Makefile and comment out the color definitions. + +## See Also + +- [MicroPython mpremote documentation](https://docs.micropython.org/en/latest/reference/mpremote.html) +- [GNU Make manual](https://www.gnu.org/software/make/manual/) +- Original deployment: `deploy.sh` diff --git a/firmware/esp32/Makefile b/firmware/esp32/Makefile new file mode 100644 index 0000000..66dc3f9 --- /dev/null +++ b/firmware/esp32/Makefile @@ -0,0 +1,178 @@ +# ESP32 MicroPython Deployment Makefile +# Usage: +# make deploy - Deploy all files to ESP32 +# make deploy PORT=/dev/ttyUSB0 - Deploy to specific port +# make ble-name - Upload ble_name.txt only +# make test-ble - Run BLE diagnostic test +# make install-libs - Install required libraries only +# make upload-files - Upload files only (no library install) +# make reset - Reset the ESP32 +# make repl - Connect to REPL +# make ls - List files on ESP32 +# make clean-device - Remove all .py and .txt files from device +# make help - Show this help message + +.PHONY: help deploy install-libs upload-files reset repl ls clean-device check-mpremote test-ble ble-name + +# Configuration +PORT ?= +SCRIPT_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST)))) +PORT_ARG := $(if $(PORT),connect $(PORT),) + +# File patterns to upload +PY_FILES := $(wildcard $(SCRIPT_DIR)*.py) +TXT_FILES := $(wildcard $(SCRIPT_DIR)*.txt) + +# Colors for output (optional, comment out if not supported) +BLUE := \033[0;34m +GREEN := \033[0;32m +YELLOW := \033[0;33m +RED := \033[0;31m +NC := \033[0m # No Color +help: + @echo "$(BLUE)ESP32 MicroPython Deployment$(NC)" + @echo "" + @echo "$(GREEN)Available targets:$(NC)" + @echo " make deploy - Full deployment (install libs + upload files + reset)" + @echo " make deploy PORT=/dev/ttyUSB0 - Deploy to specific port" + @echo " make ble-name - Upload ble_name.txt only (update BLE name)" + @echo " make test-ble - Run BLE diagnostic test on device" + @echo " make install-libs - Install required libraries only" + @echo " make upload-files - Upload .py and .txt files only" + @echo " make reset - Reset the ESP32" + @echo " make repl - Connect to REPL console" + @echo " make ls - List files on ESP32" + @echo " make clean-device - Remove all .py and .txt files from device" + @echo " make help - Show this help message" + @echo "" + @echo "$(YELLOW)Examples:$(NC)" + @echo " make deploy # Auto-detect port" + @echo " make deploy PORT=/dev/ttyUSB0 # Specific port" + @echo " make ble-name # Update BLE name from ble_name.txt" + @echo " make test-ble # Test BLE functionality" + @echo " make repl # Connect to REPL" + @echo " make repl # Connect to REPL" + +check-mpremote: + @which mpremote > /dev/null 2>&1 || \ + (echo "$(RED)ERROR: mpremote is not installed$(NC)" && \ + echo "Install with: pip install mpremote" && \ + exit 1) + +detect-port: check-mpremote + @echo "$(BLUE)Detecting ESP32...$(NC)" + @if [ -n "$(PORT)" ]; then \ + echo "$(GREEN)✓ Using specified port: $(PORT)$(NC)"; \ + else \ + DEVS=$$(mpremote devs 2>&1 | grep -E '/dev/|COM' | head -n 1 | awk '{print $$1}'); \ + if [ -n "$$DEVS" ]; then \ + echo "$(GREEN)✓ Found ESP32 at: $$DEVS$(NC)"; \ + else \ + echo "$(YELLOW)⚠ No port detected, using mpremote auto-detection$(NC)"; \ + fi \ + fi + @echo "" + +install-libs: detect-port + @echo "$(BLUE)Installing required libraries...$(NC)" + @echo " Installing ssd1306 library for OLED display..." + @mpremote $(PORT_ARG) mip install ssd1306 || \ + (echo "$(YELLOW)WARNING: Failed to install ssd1306 library$(NC)" && \ + echo "OLED display functionality may not work") + @echo "" + +upload-files: detect-port + @echo "$(BLUE)Uploading Python files...$(NC)" + @for file in $(PY_FILES); do \ + filename=$$(basename $$file); \ + echo " Uploading: $$filename"; \ + mpremote $(PORT_ARG) cp "$$file" ":$$filename" || exit 1; \ + done + @echo "" + @echo "$(BLUE)Uploading text files...$(NC)" + @for file in $(TXT_FILES); do \ + filename=$$(basename $$file); \ + echo " Uploading: $$filename"; \ + mpremote $(PORT_ARG) cp "$$file" ":$$filename" || exit 1; \ + done + @echo "" + +deploy: install-libs upload-files + @echo "$(GREEN)================================================$(NC)" + @echo "$(GREEN)✓ Deployment complete!$(NC)" + @echo "$(GREEN)================================================$(NC)" + @echo "" + @echo "Uploaded files:" + @mpremote $(PORT_ARG) ls + @echo "" + @$(MAKE) reset + @echo "" + @echo "To see the output, run:" + @if [ -n "$(PORT)" ]; then \ + echo " make repl PORT=$(PORT)"; \ + echo " OR: mpremote connect $(PORT) repl"; \ + echo " OR: screen $(PORT) 115200"; \ + else \ + echo " make repl"; \ + echo " OR: mpremote repl"; \ + fi + @echo "" + @echo "$(YELLOW)Troubleshooting:$(NC)" + @echo " If BLE is not working, run: make test-ble" + @echo " See BLE_TROUBLESHOOTING.md for detailed help" + @echo "" + @echo "Note: The ESP32 has been reset and will auto-start" + @echo " Check the qas-TIMESTAMP.log file for boot diagnostics" + +ble-name: check-mpremote + @echo "$(BLUE)Uploading ble_name.txt...$(NC)" + @if [ ! -f "ble_name.txt" ]; then \ + echo "$(RED)ERROR: ble_name.txt not found!$(NC)"; \ + echo "Create it first with your desired BLE name, e.g.:"; \ + echo " echo 'MY-ESP32-123' > ble_name.txt"; \ + exit 1; \ + fi + @echo "Current content of ble_name.txt:" + @cat ble_name.txt + @echo "" + @mpremote $(PORT_ARG) cp ble_name.txt :ble_name.txt + @echo "$(GREEN)✓ ble_name.txt uploaded successfully$(NC)" + @echo "" + @echo "$(YELLOW)Note: The BLE name will update automatically within 5 seconds$(NC)" + @echo " Or reset the device for immediate effect: make reset" + +test-ble: check-mpremote + @echo "$(BLUE)Running BLE diagnostic test...$(NC)" + @echo "$(YELLOW)This will run the test_ble.py script on your ESP32$(NC)" + @echo "" + @echo "If test_ble.py is not on the device, uploading it first..." + @mpremote $(PORT_ARG) cp test_ble.py :test_ble.py 2>/dev/null || true + @echo "" + @echo "$(GREEN)Starting BLE test (will run for 30 seconds)...$(NC)" + @echo "$(YELLOW)Open a BLE scanner app on your phone and look for 'ESP32-TEST'$(NC)" + @echo "" + @mpremote $(PORT_ARG) exec "import test_ble" + @echo "" + @echo "$(GREEN)Test complete!$(NC)" + @echo "See BLE_TROUBLESHOOTING.md for help if the test failed" + +reset: check-mpremote + @echo "$(BLUE)Resetting ESP32...$(NC)" + @mpremote $(PORT_ARG) reset + +repl: check-mpremote + @echo "$(BLUE)Connecting to REPL...$(NC)" + @echo "Press Ctrl+] to exit" + @echo "" + @mpremote $(PORT_ARG) repl + +ls: check-mpremote + @mpremote $(PORT_ARG) ls + +clean-device: check-mpremote + @echo "$(RED)Removing all .py and .txt files from ESP32...$(NC)" + @echo "Are you sure? (Press Ctrl+C to cancel)" + @read -p "Press Enter to continue..." + @mpremote $(PORT_ARG) exec "import os; [os.remove(f) for f in os.listdir() if f.endswith('.py') or f.endswith('.txt')]" || true + @echo "$(GREEN)✓ Cleanup complete$(NC)" + @$(MAKE) ls diff --git a/firmware/esp32/OLED_DISPLAY_README.md b/firmware/esp32/OLED_DISPLAY_README.md new file mode 100644 index 0000000..f84d9fc --- /dev/null +++ b/firmware/esp32/OLED_DISPLAY_README.md @@ -0,0 +1,310 @@ +# ESP32 OLED Display for BLE Name + +This script monitors the `ble_name.txt` file created by the BLE provisioning system and displays the current BLE device name on a 128x64 OLED display connected via I2C. + +## Hardware Requirements + +- ESP32 development board +- 128x64 OLED display (SSD1306 or compatible) +- I2C connection: + - **SCL** → GPIO22 (default, configurable) + - **SDA** → GPIO21 (default, configurable) + - **VCC** → 3.3V + - **GND** → GND + +## Software Requirements + +### MicroPython SSD1306 Driver + +The script requires the SSD1306 OLED driver for MicroPython. You need to install it on your ESP32. + +#### Option 1: Using mpremote (Recommended) + +```bash +mpremote mip install ssd1306 +``` + +#### Option 2: Manual Installation + +1. Download the `ssd1306.py` file from the MicroPython library: + ```bash + wget https://raw.githubusercontent.com/micropython/micropython-lib/master/micropython/drivers/display/ssd1306/ssd1306.py + ``` + +2. Upload it to your ESP32: + ```bash + mpremote fs cp ssd1306.py :ssd1306.py + ``` + +## Installation + +1. **Upload the OLED display script to your ESP32:** + ```bash + mpremote fs cp esp32/oled_display.py :oled_display.py + ``` + +2. **Verify the files are on the ESP32:** + ```bash + mpremote fs ls + ``` + + You should see: + - `oled_display.py` + - `ssd1306.py` + - `ble_name.txt` (created after first provisioning) + +## Usage + +### Running the Display Script + +Connect to your ESP32 and run: + +```bash +mpremote run esp32/oled_display.py +``` + +Or connect via serial and run in the REPL: + +```python +import oled_display +``` + +### Running with BLE Provisioning + +You can run both scripts simultaneously in separate terminal windows: + +**Terminal 1 - BLE Provisioning:** +```bash +mpremote run esp32/ble_provisioning.py +``` + +**Terminal 2 - OLED Display:** +```bash +mpremote run esp32/oled_display.py +``` + +When you send a provisioning command like `ble/name DEVICE-123`, the display will automatically update to show the new name. + +## Display Features + +### Main Display +- **Header:** "BLE Device Name:" +- **Separator line:** Visual division between header and content +- **Name display:** Centered text showing the current BLE name +- **Update indicator:** Shows when the display was last updated + +### Default State +When no BLE name has been provisioned yet, the display shows: +``` +BLE Device Name: +---------------- + Not Set + Waiting for +provisioning... +``` + +### Long Names +The script handles long BLE names by: +- Splitting at hyphens (`-`) for natural word breaks +- Displaying across multiple lines (up to 4 lines) +- Centering each line for better readability + +## Configuration + +You can customize the I2C pins by modifying the constants at the top of [`oled_display.py`](oled_display.py): + +```python +# I2C configuration +I2C_SCL_PIN = 22 # Change to your SCL pin +I2C_SDA_PIN = 21 # Change to your SDA pin +I2C_FREQ = 400000 # I2C frequency (400kHz) + +# OLED configuration +OLED_WIDTH = 128 # Display width in pixels +OLED_HEIGHT = 64 # Display height in pixels + +# Update interval +UPDATE_INTERVAL = 1000 # Check file every 1 second (in milliseconds) +``` + +## How It Works + +1. **Initialization:** + - Sets up I2C communication on specified pins + - Scans for I2C devices and reports their addresses + - Initializes the SSD1306 OLED display + - Clears the display + +2. **File Monitoring:** + - Checks for the existence of `ble_name.txt` + - Monitors the file's modification time + - When the file changes, reads the new BLE name + - Updates the display with the new name + +3. **Display Update:** + - Clears the previous content + - Draws the header and separator + - Formats and centers the BLE name + - Shows update indicator + +## Troubleshooting + +### "No I2C devices found" Error + +**Causes:** +- OLED display not connected +- Incorrect wiring +- Wrong I2C pins configured + +**Solutions:** +1. Check your wiring matches the pin configuration +2. Verify the OLED display is powered (3.3V and GND) +3. Try running an I2C scan: + ```python + from machine import Pin, SoftI2C + i2c = SoftI2C(scl=Pin(22), sda=Pin(21)) + print(i2c.scan()) # Should show device address (typically 0x3c) + ``` + +### "SSD1306 driver not found" Error + +**Cause:** +- The `ssd1306.py` library is not installed + +**Solution:** +Install the library using one of the methods described in [Software Requirements](#software-requirements) + +### Display Shows Garbled Text + +**Causes:** +- Wrong display dimensions configured +- I2C communication issues + +**Solutions:** +1. Verify your display is 128x64 pixels +2. If different, update `OLED_WIDTH` and `OLED_HEIGHT` constants +3. Lower the I2C frequency if communication is unreliable: + ```python + I2C_FREQ = 100000 # Try 100kHz instead of 400kHz + ``` + +### Display Not Updating + +**Causes:** +- `ble_name.txt` file not being created +- File system issues +- Script not running + +**Solutions:** +1. Verify the BLE provisioning script is creating the file: + ```bash + mpremote fs cat ble_name.txt + ``` +2. Check script output for errors +3. Reduce `UPDATE_INTERVAL` for faster updates (default: 1 second) + +## Integration with BLE Provisioning + +The OLED display script integrates seamlessly with the [BLE Provisioning system](BLE_PROVISIONING_README.md): + +1. **BLE Provisioning** ([`ble_provisioning.py`](ble_provisioning.py)): + - Receives provisioning commands via serial + - Updates BLE advertising name + - **Writes name to `ble_name.txt`** (new feature) + +2. **OLED Display** ([`oled_display.py`](oled_display.py)): + - Monitors `ble_name.txt` for changes + - Displays current BLE name on OLED + - Updates automatically when name changes + +### Complete Workflow + +``` +Serial Command → BLE Provisioning Script → ble_name.txt → OLED Display + ↓ ↓ ↓ +ble/name FOO-123 Updates BLE name Shows "FOO-123" on display + Saves to file +``` + +## Running as Boot Script + +To automatically run the OLED display on ESP32 boot: + +1. **Rename the script to `main.py`** (or add to existing `main.py`): + ```bash + mpremote fs cp esp32/oled_display.py :main.py + ``` + +2. **Or create a `boot.py` that imports it:** + ```python + # boot.py + import oled_display + ``` + +**Note:** If running on boot, ensure the SSD1306 library is also uploaded to the ESP32. + +## Example Session + +```bash +# Terminal 1: Start BLE provisioning +$ mpremote run esp32/ble_provisioning.py +ESP32 BLE Provisioning Script +[INFO] Starting BLE provisioning system... +[BLE] Started advertising as: ESP32-Device +[READY] Waiting for provisioning commands... + +# Terminal 2: Start OLED display +$ mpremote run esp32/oled_display.py +ESP32 OLED Display for BLE Name +[I2C] Found devices at addresses: ['0x3c'] +[OLED] Display initialized (128x64) +[INFO] Current BLE name: ESP32-Device +[READY] Monitoring for BLE name changes... + +# Terminal 1: Send provisioning command +ble/name SENSOR-001 +[CMD] Received: ble/name SENSOR-001 +[BLE] Name changed: ESP32-Device -> SENSOR-001 +[FILE] Saved name to ble_name.txt: SENSOR-001 +[BLE] Stopped advertising +[BLE] Started advertising as: SENSOR-001 + +# Terminal 2: Display updates automatically +[MONITOR] BLE name updated: SENSOR-001 +``` + +The OLED display will now show "SENSOR-001" in a nicely formatted layout. + +## API Reference + +### OLEDDisplay Class + +```python +class OLEDDisplay(scl_pin=22, sda_pin=21, width=128, height=64) +``` + +**Methods:** +- `clear()` - Clear the display +- `display_text(text, x=0, y=0)` - Display text at position +- `display_centered_text(text, y=None)` - Display centered text +- `display_ble_name(name)` - Display BLE name with formatting + +### BLENameMonitor Class + +```python +class BLENameMonitor(display, filename='ble_name.txt') +``` + +**Methods:** +- `read_ble_name()` - Read name from file +- `check_for_updates()` - Check and update if file changed +- `display_default_message()` - Show "Not Set" message + +## License + +This code is provided as-is for educational and development purposes. + +## Related Documentation + +- [BLE Provisioning README](BLE_PROVISIONING_README.md) +- [ESP32 Main README](README.md) diff --git a/firmware/esp32/README.md b/firmware/esp32/README.md new file mode 100644 index 0000000..d5cc3af --- /dev/null +++ b/firmware/esp32/README.md @@ -0,0 +1,375 @@ +# ESP32 Development Board - Getting Started Guide + +## Overview + +This guide covers the essential steps for getting started with a new ESP32 development board, including setup, driver installation, and initial configuration. + +## Prerequisites + +- macOS system +- USB cable (typically USB-A to Micro-USB or USB-C depending on your board) +- Internet connection for driver installation + +## Finding the Virtual COM Port on macOS + +### Method 1: Using Terminal (ls command) + +1. Open Terminal +2. Before connecting the ESP32, list existing serial ports: + + ```bash + ls /dev/tty.* + ``` + +3. Connect your ESP32 board via USB +4. List serial ports again: + + ```bash + ls /dev/tty.* + ``` + +5. The new device that appears is your ESP32. Common patterns include: + - `/dev/tty.usbserial-XXXXXXXX` (CP210x USB to UART Bridge) + - `/dev/tty.SLAB_USBtoUART` (Silicon Labs CP210x) + - `/dev/tty.usbserial-0001` (CH340 USB to UART) + - `/dev/tty.wchusbserial*` (CH340/CH341) + +### Method 2: Using System Information + +1. Click the Apple menu → **About This Mac** +2. Click **System Report** or **More Info** +3. In the sidebar, under **Hardware**, select **USB** +4. Connect your ESP32 board +5. Look for entries like: + - "CP2102 USB to UART Bridge Controller" + - "CH340" + - "USB Serial" + +### Method 3: Using ioreg Command + +```bash +ioreg -p IOUSB -l -w 0 | grep -i "usb serial" +``` + +This will show detailed information about USB serial devices. + +## Driver Installation + +### For CP210x Chipset (Most Common) + +1. Download drivers from [Silicon Labs](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers) +2. Install the driver package +3. Restart your Mac if prompted +4. Reconnect your ESP32 board + +### For CH340/CH341 Chipset + +1. Download drivers from the manufacturer or use Homebrew: + + ```bash + brew tap homebrew/cask-drivers + brew install --cask wch-ch34x-usb-serial-driver + ``` + +2. Restart your Mac +3. Go to **System Preferences → Security & Privacy** and allow the driver +4. Reconnect your ESP32 board + +## Entering Bootloader Mode (Flash/Download Mode) + +**Note:** On your board, "RESET" and "EN" (Enable) are typically the same button - both reset the ESP32. Some boards label it "EN", others "RESET", but they serve the same function. + +To enter bootloader mode for flashing new firmware (like MicroPython), you need to put the ESP32 into download mode: + +### Manual Method (Works on all ESP32 boards) + +1. **Hold down the BOOT button** (sometimes labeled "FLASH" or "IO0") +2. **While holding BOOT, press and release the RESET/EN button** +3. **Release the BOOT button** +4. The ESP32 is now in bootloader mode and ready to receive new firmware + +### During Flashing (Automated Method) + +Most flashing tools will automatically handle this, but if they fail: + +1. Start the flash command +2. When you see "Connecting..." or similar message: + - **Hold BOOT button** + - **Press RESET/EN briefly** + - **Release BOOT button** + +### Confirming Bootloader Mode + +When successfully in bootloader mode, you'll see this on the serial monitor: + +```tty +rst:0x1 (POWERON_RESET),boot:0x3 (DOWNLOAD_BOOT(UART0/UART1/SDIO_REI_REO_V2)) +waiting for download +``` + +**What this means:** + +- `rst:0x1 (POWERON_RESET)` - Reset reason +- `boot:0x3 (DOWNLOAD_BOOT...)` - **Confirms bootloader mode is active** +- `waiting for download` - **Ready to receive firmware** + +If you don't see this message, try the button sequence again. + +**Other indicators:** + +- Some boards have an LED that changes behavior when in bootloader mode +- The board will not run any previously uploaded firmware + +## Installing MicroPython + +### Step 1: Install esptool Espressif chips ROM Bootloader Utility + +- `pip3 install esptool` + +### Step 2: Erase Flash (Optional but Recommended) + +- `esptool.py --port /dev/tty.usbserial-XXXXXXXX erase_flash` + +**Button sequence for erase:** + +1. Run the command above +2. When you see "Connecting...", enter bootloader mode: + - Hold BOOT button + - Press RESET/EN button (tap it once) + - Release BOOT button +3. If successful, you'll see the bootloader message: + + ```tty + rst:0x1 (POWERON_RESET),boot:0x3 (DOWNLOAD_BOOT(UART0/UART1/SDIO_REI_REO_V2)) + waiting for download + ``` + +4. The erase process will then begin automatically + +### Step 3: Download MicroPython Firmware + +- from [MicroPython Downloads](https://micropython.org/download/ESP32_GENERIC/) + +### Step 4: Flash MicroPython + +```bash +esptool.py --chip esp32 --port /dev/tty.usbserial-XXXXXXXX write_flash -z 0x1000 ESP32_GENERIC-20231005-v1.21.0.bin +``` + +**Button sequence for flashing:** + +1. Run the command above +2. When you see "Connecting...", enter bootloader mode: + - Hold BOOT button + - Press RESET/EN button (tap it once) + - Release BOOT button +3. Verify bootloader mode with the serial message: + + ```tty + rst:0x1 (POWERON_RESET),boot:0x3 (DOWNLOAD_BOOT(UART0/UART1/SDIO_REI_REO_V2)) + waiting for download + ``` + +4. Wait for flashing to complete (typically 10-30 seconds) +5. Press RESET/EN once more to boot into MicroPython + +### Step 5: Test MicroPython + +```bash +# Connect to the REPL +screen /dev/tty.usbserial-XXXXXXXX 115200 +``` + +You should see the MicroPython prompt: + +```python +>>> +``` + +Test with: + +```python +>>> print("Hello from MicroPython!") +>>> import machine +>>> machine.reset() # Reset the board +``` + +Exit screen with: `Ctrl+A`, then `K`, then `Y` + +### Alternative Tools + +**Thonny IDE** (Easiest for beginners): + +1. Download from [thonny.org](https://thonny.org/) +2. Install MicroPython via: **Tools → Options → Interpreter → Install or update firmware** +3. Select your port and click "Install" +4. Thonny handles the bootloader mode automatically + +**ampy** (File management): + +```bash +pip3 install adafruit-ampy + +# Upload a file +ampy --port /dev/tty.usbserial-XXXXXXXX put main.py + +# List files +ampy --port /dev/tty.usbserial-XXXXXXXX ls + +# Run a script +ampy --port /dev/tty.usbserial-XXXXXXXX run test.py +``` + +## Setting Up the Development Environment + +### Option 1: Arduino IDE + +1. Install Arduino IDE from [arduino.cc](https://www.arduino.cc/en/software) +2. Add ESP32 board support: + - Open Arduino IDE + - Go to **Preferences** + - Add to Additional Board Manager URLs: `https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json` + - Go to **Tools → Board → Boards Manager** + - Search for "ESP32" and install + +### Option 2: PlatformIO + +1. Install VS Code +2. Install PlatformIO extension +3. Create new project with ESP32 board +4. Example `platformio.ini`: + + ```ini + [env:esp32dev] + platform = espressif32 + board = esp32dev + framework = arduino + monitor_speed = 115200 + ``` + +### Option 3: ESP-IDF (Official Espressif Framework) + +1. Install prerequisites: `brew install cmake ninja dfu-util` + +2. Clone ESP-IDF: + + ```bash + mkdir -p ~/esp + cd ~/esp + git clone --recursive https://github.com/espressif/esp-idf.git + ``` + +3. Run installation script: + + ```bash + cd ~/esp/esp-idf + ./install.sh esp32 + ``` + +4. Set up environment (add to `.zshrc` or `.bash_profile`): + + ```bash + alias get_idf='. $HOME/esp/esp-idf/export.sh' + ``` + +## Testing Your Setup + +### Basic Blink Example (Arduino) + +```cpp +#define LED_PIN 2 // Built-in LED on most ESP32 boards + +void setup() { + pinMode(LED_PIN, OUTPUT); + Serial.begin(115200); + Serial.println("ESP32 Ready!"); +} + +void loop() { + digitalWrite(LED_PIN, HIGH); + delay(1000); + digitalWrite(LED_PIN, LOW); + delay(1000); +} +``` + +### Uploading Code + +1. Select your board: **Tools → Board → ESP32 Dev Module** (or your specific board) +2. Select your port: **Tools → Port → /dev/tty.usbserial-XXXXXXXX** +3. Click **Upload** +4. If upload fails, hold the **BOOT** button while uploading + +## Common Issues and Solutions + +### Port Not Found + +- Ensure drivers are installed correctly +- Try a different USB cable (some are power-only) +- Try a different USB port +- Check System Preferences → Security & Privacy for blocked drivers + +### Upload Failed + +- Hold the BOOT button during upload +- Lower upload speed: **Tools → Upload Speed → 115200** +- Press EN (reset) button after upload starts + +### Permission Denied + +```bash +sudo chmod 666 /dev/tty.usbserial-XXXXXXXX +``` + +### Driver Not Loading (macOS Security) + +1. Go to **System Preferences → Security & Privacy** +2. Click **Allow** for the blocked driver +3. Restart your Mac + +## Useful Commands + +```bash +# List all USB devices +system_profiler SPUSBDataType + +# Monitor serial output (using screen) +screen /dev/tty.usbserial-XXXXXXXX 115200 + +# Exit screen: Ctrl+A, then K, then Y + +# Monitor with PlatformIO +pio device monitor + +# Monitor with ESP-IDF +idf.py monitor +``` + +## Board Specifications (Common ESP32) + +- CPU: Dual-core Xtensa 32-bit LX6 @ 240MHz +- RAM: 520 KB SRAM +- Flash: 4MB (typical) +- WiFi: 802.11 b/g/n +- Bluetooth: v4.2 BR/EDR and BLE +- GPIO: 34 pins +- ADC: 18 channels, 12-bit +- DAC: 2 channels, 8-bit +- Touch Sensors: 10 +- SPI, I2C, I2S, UART interfaces + +## Additional Resources + +- [ESP32 Official Documentation](https://docs.espressif.com/projects/esp-idf/en/latest/esp32/) +- [ESP32 Arduino Core](https://github.com/espressif/arduino-esp32) +- [PlatformIO ESP32 Platform](https://docs.platformio.org/en/latest/platforms/espressif32.html) +- [Random Nerd Tutorials](https://randomnerdtutorials.com/projects-esp32/) + +## Next Steps + +1. Test basic GPIO with an LED +2. Connect to WiFi +3. Explore built-in sensors (Hall effect, temperature) +4. Try BLE or classic Bluetooth +5. Experiment with deep sleep modes +6. Build your first IoT project! diff --git a/firmware/esp32/UPDATE_BLE_NAME.md b/firmware/esp32/UPDATE_BLE_NAME.md new file mode 100644 index 0000000..f21e4e9 --- /dev/null +++ b/firmware/esp32/UPDATE_BLE_NAME.md @@ -0,0 +1,161 @@ +# How to Update BLE Name on Running ESP32 + +## The Problem + +When the BLE provisioning script is running, it occupies the serial port's stdin, which prevents `mpremote` from entering raw REPL mode to upload files. You'll get: + +``` +mpremote.transport.TransportError: could not enter raw repl +``` + +## Solutions + +### Solution 1: Reset, Upload, Reset (Recommended) + +Use the deployment script which handles the reset cycle: + +```bash +cd esp32 +./deploy.sh +``` + +This will: +1. Upload all files (including updated `ble_name.txt`) +2. Reset the ESP32 +3. The new name will be loaded on boot + +### Solution 2: Manual Reset and Upload + +1. **Press and hold** the RESET button on your ESP32 +2. While holding RESET, run: + ```bash + mpremote fs cp esp32/ble_name.txt :ble_name.txt + ``` +3. **Release** the RESET button +4. The ESP32 will boot with the new name + +### Solution 3: Use Serial Commands (Original Method) + +Send commands directly through the serial port: + +```bash +# Connect to serial +screen /dev/tty.usbserial-XXXX 115200 + +# Type the command (you won't see a prompt): +ble/name NEW-DEVICE-NAME + +# Press Enter +``` + +The script will process the command and update the BLE name immediately. + +### Solution 4: Edit File Before First Boot + +1. Edit `ble_name.txt` on your computer +2. Deploy all files: + ```bash + cd esp32 + ./deploy.sh + ``` +3. ESP32 will boot with the name from the file + +### Solution 5: Use Boot Pin to Access REPL (Advanced) + +If you implement the boot pin solution from `BLE_REPL_CONFLICT_SOLUTION.md`, you can: + +1. Hold GPIO 0 to GND during boot +2. ESP32 boots to REPL instead of auto-starting +3. Upload files with `mpremote` +4. Release GPIO 0 and reset + +## Quick Reference + +### Change Name Before Deployment +```bash +# Edit the file +echo "MY-NEW-DEVICE-123" > esp32/ble_name.txt + +# Deploy +cd esp32 +./deploy.sh +``` + +### Change Name on Running Device (via serial command) +```bash +# Option A: Echo to serial port +echo "ble/name MY-NEW-DEVICE-123" > /dev/tty.usbserial-XXXX + +# Option B: Use screen +screen /dev/tty.usbserial-XXXX 115200 +# Then type: ble/name MY-NEW-DEVICE-123 +# Press Ctrl+A, then K to exit +``` + +### Force Reset to Upload Files +```bash +# The deploy script handles this automatically +cd esp32 +./deploy.sh + +# Or manually with mpremote +mpremote reset +sleep 1 +mpremote fs cp esp32/ble_name.txt :ble_name.txt +mpremote reset +``` + +## Understanding the Polling Feature + +The BLE provisioning script now checks `ble_name.txt` every 5 seconds and outputs to serial: + +``` +[POLL] Checking ble_name.txt... Current: BAT-PRO-3 BEE9, File: BAT-PRO-3 BEE9 +[POLL] No change detected +``` + +**However**, you cannot use `mpremote` to update the file while the script is running due to the REPL conflict. You must use one of the solutions above. + +## Why the Conflict Exists + +1. **BLE script runs on boot** → Takes over serial stdin +2. **mpremote needs REPL** → Cannot interrupt stdin-reading script +3. **Solution**: Reset → Upload → Reset cycle + +This is a fundamental limitation of how MicroPython handles serial I/O. + +## Recommended Workflow + +**For Development:** +```bash +# 1. Edit ble_name.txt locally +echo "TEST-DEVICE-42" > esp32/ble_name.txt + +# 2. Deploy (handles reset automatically) +cd esp32 +./deploy.sh + +# 3. Monitor output +mpremote repl +``` + +**For Production:** +Use serial commands to change names without redeploying: +```bash +echo "ble/name PROD-DEVICE-001" > /dev/tty.usbserial-XXXX +``` + +## Monitoring the Device + +To see the polling output and verify BLE is working: + +```bash +# Option 1: mpremote (after deployment) +mpremote repl + +# Option 2: screen +screen /dev/tty.usbserial-XXXX 115200 + +# You should see every 5 seconds: +# [POLL] Checking ble_name.txt... Current: YOUR-NAME, File: YOUR-NAME +# [POLL] No change detected diff --git a/firmware/esp32/USB_STORAGE_ALTERNATIVES.md b/firmware/esp32/USB_STORAGE_ALTERNATIVES.md new file mode 100644 index 0000000..54fec21 --- /dev/null +++ b/firmware/esp32/USB_STORAGE_ALTERNATIVES.md @@ -0,0 +1,192 @@ +# ESP32 USB Storage Alternatives + +## Why ESP32 Can't Act as USB Drive + +The ESP32 with MicroPython **does not support USB Mass Storage mode** for several reasons: + +1. **Hardware limitation**: ESP32 doesn't have native USB (only UART over USB-to-serial chip) +2. **MicroPython limitation**: Even ESP32-S2/S3 with native USB don't implement USB MSC in MicroPython +3. **Firmware constraint**: USB Mass Storage requires specific firmware support not available in standard MicroPython + +## Alternative Solutions + +### Solution 1: CircuitPython (ESP32-S2/S3 only) + +If you have an **ESP32-S2 or ESP32-S3** board, you can use CircuitPython which supports USB drive mode: + +1. Flash CircuitPython firmware (instead of MicroPython) +2. Board appears as `CIRCUITPY` drive when connected +3. Drag and drop Python files directly +4. Files run automatically on boot + +**Limitations:** +- Only works on ESP32-S2/S3 (not original ESP32) +- Must completely replace MicroPython with CircuitPython +- Different Python implementation (some code changes needed) + +**How to switch:** +```bash +# Download CircuitPython for ESP32-S2/S3 from: +# https://circuitpython.org/downloads + +# Flash it +esptool.py --chip esp32s3 erase_flash +esptool.py --chip esp32s3 write_flash -z 0x0 circuitpython.bin + +# Board will appear as USB drive +``` + +### Solution 2: WebREPL File Transfer (Recommended) + +Use WiFi to transfer files instead of USB: + +1. Enable WebREPL on ESP32 +2. Access filesystem via web browser +3. Upload/download files wirelessly +4. No need to interrupt running scripts + +**Setup:** + +Add to `boot.py`: +```python +import network +import webrepl + +# Connect to WiFi +wlan = network.WLAN(network.STA_IF) +wlan.active(True) +wlan.connect('YOUR_SSID', 'YOUR_PASSWORD') + +# Start WebREPL +webrepl.start() +``` + +**Usage:** +1. Connect to WiFi (ESP32 gets IP address) +2. Open browser: http://micropython.org/webrepl/ +3. Connect to ESP32's IP address +4. Transfer files through web interface + +**Advantages:** +- Works with running scripts (no REPL needed) +- Can update files remotely +- No physical connection required + +### Solution 3: FTP Server on ESP32 + +Run an FTP server on the ESP32 to access files like a network drive: + +**Install uftpd:** +```python +# On ESP32 REPL (one-time setup) +import mip +mip.install('github:cpopp/MicroPythonFTP/ftp.py') +``` + +**Add to your code:** +```python +import network +from ftp import FTP + +# Connect to WiFi +wlan = network.WLAN(network.STA_IF) +wlan.active(True) +wlan.connect('YOUR_SSID', 'YOUR_PASSWORD') + +# Start FTP server +ftp = FTP() +``` + +**Access:** +- Use FTP client (FileZilla, Cyberduck, Finder) +- Connect to ESP32's IP address +- Drag and drop files + +### Solution 4: HTTP File Upload Server + +Create a web interface for file uploads: + +```python +import network +from microdot import Microdot + +app = Microdot() + +@app.route('/upload', methods=['POST']) +def upload(request): + file = request.files['file'] + with open(file.filename, 'wb') as f: + f.write(file.stream.read()) + return 'File uploaded' + +wlan = network.WLAN(network.STA_IF) +wlan.active(True) +wlan.connect('SSID', 'PASSWORD') + +app.run(host='0.0.0.0', port=80) +``` + +### Solution 5: Keep Using mpremote with Deployment Script + +The current solution with the deployment script is actually quite efficient: + +```bash +# One command to update everything +cd esp32 +./deploy.sh +``` + +The script: +- Uploads all files automatically +- Resets the device +- Shows status + +**This is the simplest solution for your use case.** + +## Comparison Table + +| Method | ESP32 Support | Running Scripts | Ease of Use | Notes | +|--------|--------------|-----------------|-------------|-------| +| **USB Drive (MSC)** | ❌ No | N/A | ⭐⭐⭐⭐⭐ | Not supported | +| **CircuitPython** | ESP32-S2/S3 only | ✅ Yes | ⭐⭐⭐⭐⭐ | Different firmware | +| **WebREPL** | ✅ Yes | ✅ Yes | ⭐⭐⭐⭐ | Requires WiFi | +| **FTP Server** | ✅ Yes | ✅ Yes | ⭐⭐⭐⭐ | Requires WiFi | +| **HTTP Upload** | ✅ Yes | ✅ Yes | ⭐⭐⭐ | Requires WiFi | +| **mpremote + deploy.sh** | ✅ Yes | ⚠️ Requires reset | ⭐⭐⭐⭐ | Current solution | + +## Recommended Solution for Your Setup + +Given that you: +1. Have BLE provisioning running on boot +2. Need to update `ble_name.txt` and Python files +3. Want simplicity + +**I recommend: WebREPL (Solution 2)** + +### Why WebREPL is Best Here: + +✅ **Works while BLE runs** - No REPL conflict +✅ **Update files remotely** - No USB cable needed +✅ **Simple web interface** - Just open a browser +✅ **No firmware change** - Keep using MicroPython +✅ **Can monitor output** - REPL still available via WiFi + +### Quick WebREPL Setup + +Want me to modify your `boot.py` to enable WebREPL? I can add WiFi configuration and WebREPL startup, so you can: + +1. Connect ESP32 to WiFi +2. Access files via browser +3. Update `ble_name.txt` without interrupting BLE +4. See polling output via WebREPL + +Would you like me to implement this? + +## Summary + +- ❌ ESP32 **cannot** act as USB drive with MicroPython +- ✅ ESP32-S2/S3 **can** with CircuitPython (different firmware) +- ✅ **WebREPL** is the best alternative for your use case +- ✅ Current **deploy.sh** script works well for development + +Let me know if you'd like me to set up WebREPL for wireless file access! diff --git a/firmware/esp32/WEBREPL_SETUP.md b/firmware/esp32/WEBREPL_SETUP.md new file mode 100644 index 0000000..7619eb2 --- /dev/null +++ b/firmware/esp32/WEBREPL_SETUP.md @@ -0,0 +1,237 @@ +# WebREPL Setup Guide + +WebREPL allows you to wirelessly access your ESP32's filesystem and REPL through a web browser - perfect for updating files while your BLE provisioning is running! + +## Quick Setup + +### Step 1: Configure WiFi + +Edit [`wifi_config.py`](wifi_config.py:1) with your WiFi credentials: + +```python +WIFI_SSID = "YourWiFiName" +WIFI_PASSWORD = "YourWiFiPassword" +WEBREPL_PASSWORD = "micropython" # Change this! +``` + +### Step 2: Deploy to ESP32 + +```bash +cd esp32 +./deploy.sh +``` + +### Step 3: Find ESP32's IP Address + +After deployment, watch the serial output (or use `mpremote repl`): + +``` +================================================== +ESP32 Boot Sequence +================================================== +[WIFI] Configuration loaded +[WIFI] Connecting to YourWiFiName... +[WIFI] ✓ Connected! +[WIFI] IP Address: 192.168.1.123 ← This is your ESP32's IP +[WIFI] Subnet: 255.255.255.0 +[WIFI] Gateway: 192.168.1.1 +[WIFI] DNS: 192.168.1.1 +[WEBREPL] Starting WebREPL... +[WEBREPL] ✓ WebREPL started on ws://192.168.1.123:8266 +[WEBREPL] Connect via: http://micropython.org/webrepl/ +[WEBREPL] Password: micropython +================================================== +``` + +### Step 4: Connect via Browser + +1. Open: **http://micropython.org/webrepl/** +2. Click **"Connect"** button +3. Enter IP address when prompted: `ws://192.168.1.123:8266` +4. Enter password (default: `micropython`) +5. You're connected! 🎉 + +## Using WebREPL + +### File Transfer (The Main Feature!) + +**Upload a file:** +1. Click **"Choose File"** in the WebREPL interface +2. Select your file (e.g., `ble_name.txt`) +3. Click **"Send to device"** +4. File is uploaded - your BLE script will detect it on next poll! + +**Download a file:** +1. Enter filename in the "Get from device" field +2. Click **"Get from device"** +3. File downloads to your computer + +**Update BLE name while running:** +```bash +# 1. Edit ble_name.txt on your computer +echo "NEW-DEVICE-42" > ble_name.txt + +# 2. Upload via WebREPL web interface +# (Click "Choose File", select ble_name.txt, "Send to device") + +# 3. Within 5 seconds, the ESP32 will detect and apply the change! +``` + +### Interactive REPL + +Type Python commands directly in the WebREPL: + +```python +>>> import os +>>> os.listdir('/') +['boot.py', 'main.py', 'ble_provisioning.py', 'ble_name.txt', ...] + +>>> # Read current BLE name +>>> with open('ble_name.txt', 'r') as f: +... print(f.read()) +BAT-PRO-3 BEE9 + +>>> # Update BLE name programmatically +>>> with open('ble_name.txt', 'w') as f: +... f.write('TESTING-123') +>>> # BLE script will pick this up within 5 seconds! +``` + +## Workflow: Update BLE Name Wirelessly + +### Method 1: Via WebREPL File Upload +```bash +# On your computer: +echo "PRODUCTION-DEVICE-001" > ble_name.txt + +# Then in browser: +# 1. Open http://micropython.org/webrepl/ +# 2. Connect to ESP32 +# 3. Upload ble_name.txt +# 4. Done! ESP32 updates within 5 seconds +``` + +### Method 2: Via WebREPL REPL +```python +# In WebREPL interface, type: +>>> with open('ble_name.txt', 'w') as f: +... f.write('NEW-NAME-HERE') +>>> # Done! Updates within 5 seconds +``` + +### Method 3: Via Serial Command (still works!) +```bash +# Connect to serial +screen /dev/tty.usbserial-XXXX 115200 + +# Type: +ble/name NEW-NAME-HERE +``` + +## Monitoring Your ESP32 + +You can see the polling output in WebREPL: + +``` +[POLL] Checking ble_name.txt... Current: BAT-PRO-3 BEE9, File: BAT-PRO-3 BEE9 +[POLL] No change detected + +[POLL] Checking ble_name.txt... Current: BAT-PRO-3 BEE9, File: NEW-DEVICE-42 +[POLL] Name changed detected! +[BLE] Name changed: BAT-PRO-3 BEE9 -> NEW-DEVICE-42 +[FILE] Saved name to ble_name.txt: NEW-DEVICE-42 +[BLE] Stopped advertising +[BLE] Started advertising as: NEW-DEVICE-42 +``` + +## Troubleshooting + +### Can't Connect to WiFi + +Check serial output: +```bash +mpremote repl +# or +screen /dev/tty.usbserial-XXXX 115200 +``` + +Look for: +- `[ERROR] WiFi connection failed!` - Check SSID/password in `wifi_config.py` +- `[INFO] WiFi not configured` - You forgot to edit `wifi_config.py` + +### WebREPL Won't Connect + +1. **Verify IP address** - Check serial output for the correct IP +2. **Check password** - Default is `micropython` +3. **Firewall** - Some networks block WebSocket connections +4. **Try from phone** - Use phone browser on same WiFi network + +### Can't Upload Files + +1. **File too large** - WebREPL has size limits (~1MB) +2. **Connection timeout** - Refresh browser and reconnect +3. **Wrong filename** - Check exact filename on ESP32 + +### WebREPL Not Starting + +Check that: +- WiFi credentials are correct in `wifi_config.py` +- ESP32 connected to WiFi successfully +- `webrepl.start()` is called in `boot.py` + +## Advanced: WebREPL from Command Line + +You can also use WebREPL from Python scripts: + +```bash +# Install webrepl client +pip install webrepl + +# Upload file +webrepl_cli.py -p micropython ble_name.txt 192.168.1.123:/ble_name.txt + +# Download file +webrepl_cli.py -p micropython 192.168.1.123:/ble_name.txt ./ble_name.txt +``` + +## Security Notes + +⚠️ **Important:** +- WebREPL has **no encryption** - don't use on untrusted networks +- Change the default password in `wifi_config.py` +- WebREPL gives **full filesystem access** to anyone with the password +- Only use on your private WiFi network + +## Benefits Over USB/mpremote + +✅ **No REPL conflict** - Works while BLE script runs +✅ **Wireless** - No USB cable needed +✅ **Real-time updates** - Change files without reset +✅ **Remote access** - Update from anywhere on network +✅ **Multiple files** - Upload/download multiple files easily +✅ **Live monitoring** - See serial output in browser + +## Summary + +WebREPL solves the original problem: + +**Before:** +- ❌ Can't update files while BLE runs (REPL conflict) +- ❌ Must reset ESP32 to upload files +- ❌ Need USB cable connected + +**After (with WebREPL):** +- ✅ Update files while BLE runs +- ✅ No reset needed +- ✅ Wireless access from browser +- ✅ See live polling output + +Perfect for your BLE provisioning use case! 🎉 + +## Next Steps + +1. Edit `wifi_config.py` with your WiFi credentials +2. Run `./deploy.sh` to upload everything +3. Note the IP address from serial output +4. Open http://micropython.org/webrepl/ in browser +5. Connect and start updating files wirelessly! diff --git a/firmware/esp32/ble_name.txt b/firmware/esp32/ble_name.txt new file mode 100644 index 0000000..a429840 --- /dev/null +++ b/firmware/esp32/ble_name.txt @@ -0,0 +1 @@ +BAT-PRO-3 BEA4 diff --git a/firmware/esp32/ble_provisioning.py b/firmware/esp32/ble_provisioning.py new file mode 100644 index 0000000..78faab2 --- /dev/null +++ b/firmware/esp32/ble_provisioning.py @@ -0,0 +1,232 @@ +""" +ESP32 BLE Provisioning Script + +This script accepts provisioning commands via the serial port to configure +the BLE advertising name for the ESP32 development board. + +Command format: ble/name EXAMPLE-1234 + +The script will: +1. Listen for serial input on UART +2. Parse provisioning commands in the format "ble/name " +3. Start BLE advertising with the specified name +4. Continuously advertise until a new provisioning command is received +""" + +import bluetooth +import time +import sys +from micropython import const + +# BLE event constants +_IRQ_CENTRAL_CONNECT = const(1) +_IRQ_CENTRAL_DISCONNECT = const(2) +_IRQ_GATTS_WRITE = const(3) + +# Default BLE name +DEFAULT_BLE_NAME = "ESP32-Device" + +class BLEProvisioning: + def __init__(self): + self.ble = bluetooth.BLE() + self.ble.active(True) + self.ble.irq(self._irq_handler) + self.ble_name = DEFAULT_BLE_NAME + self.is_advertising = False + self.connected = False + self.last_file_check = 0 + self.file_check_interval = 5000 # Check file every 5 seconds + # Try to load saved name from file + self._load_name_from_file() + + def _irq_handler(self, event, data): + """Handle BLE events""" + if event == _IRQ_CENTRAL_CONNECT: + conn_handle, addr_type, addr = data + self.connected = True + print(f"[BLE] Device connected: {addr}") + + elif event == _IRQ_CENTRAL_DISCONNECT: + conn_handle, addr_type, addr = data + self.connected = False + print(f"[BLE] Device disconnected: {addr}") + # Restart advertising after disconnect + self.start_advertising() + + def start_advertising(self): + """Start BLE advertising with the current name""" + try: + # Stop any existing advertising + if self.is_advertising: + self.ble.gap_advertise(None) + time.sleep_ms(100) + + # Create advertising payload + # Flags: General discoverable mode + payload = bytearray([ + 0x02, 0x01, 0x06, # Flags + ]) + + # Add complete local name + name_bytes = self.ble_name.encode('utf-8') + name_len = len(name_bytes) + 1 + payload.extend(bytearray([name_len, 0x09])) # Complete local name + payload.extend(name_bytes) + + # Start advertising (interval in microseconds: 100ms = 100000us) + self.ble.gap_advertise(100000, adv_data=payload) + self.is_advertising = True + print(f"[BLE] Started advertising as: {self.ble_name}") + + except Exception as e: + print(f"[ERROR] Failed to start advertising: {e}") + self.is_advertising = False + + def stop_advertising(self): + """Stop BLE advertising""" + if self.is_advertising: + try: + self.ble.gap_advertise(None) + self.is_advertising = False + print("[BLE] Stopped advertising") + except Exception as e: + print(f"[ERROR] Failed to stop advertising: {e}") + + def set_name(self, name): + """Set the BLE advertising name and restart advertising""" + if not name or len(name) == 0: + print("[ERROR] Invalid name - cannot be empty") + return False + + if len(name) > 29: + print(f"[WARNING] Name too long ({len(name)} chars), truncating to 29 chars") + name = name[:29] + + old_name = self.ble_name + self.ble_name = name + + print(f"[BLE] Name changed: {old_name} -> {self.ble_name}") + + # Write the new name to filesystem + self._save_name_to_file() + + # Restart advertising with new name + self.stop_advertising() + time.sleep_ms(200) + self.start_advertising() + + return True + + def _save_name_to_file(self): + """Save the BLE name to a file on the ESP32 filesystem""" + try: + with open('ble_name.txt', 'w') as f: + f.write(self.ble_name) + print(f"[FILE] Saved name to ble_name.txt: {self.ble_name}") + except Exception as e: + print(f"[ERROR] Failed to save name to file: {e}") + + def _load_name_from_file(self): + """Load the BLE name from file if it exists""" + try: + with open('ble_name.txt', 'r') as f: + name = f.read().strip() + if name: + self.ble_name = name + print(f"[FILE] Loaded name from ble_name.txt: {self.ble_name}") + return True + except OSError: + # File doesn't exist yet, use default + print(f"[FILE] No saved name found, using default: {self.ble_name}") + except Exception as e: + print(f"[ERROR] Failed to load name from file: {e}") + return False + + def check_file_for_updates(self): + """Periodically check if ble_name.txt has been updated""" + current_time = time.ticks_ms() + + # Check if enough time has passed since last check + if time.ticks_diff(current_time, self.last_file_check) >= self.file_check_interval: + self.last_file_check = current_time + + try: + with open('ble_name.txt', 'r') as f: + name = f.read().strip() + + # Print polling status + print(f"[POLL] Checking ble_name.txt... Current: {self.ble_name}, File: {name}") + + # If name has changed, update it + if name and name != self.ble_name: + print(f"[POLL] Name changed detected!") + self.set_name(name) + else: + print(f"[POLL] No change detected") + + except OSError: + print(f"[POLL] ble_name.txt not found") + except Exception as e: + print(f"[ERROR] Failed to check file: {e}") + + def process_command(self, command): + """Process a provisioning command from serial input""" + command = command.strip() + + if not command: + return + + print(f"[CMD] Received: {command}") + + # Parse command format: ble/name EXAMPLE-1234 + if command.startswith("ble/name "): + name = command[9:].strip() # Extract name after "ble/name " + + if name: + self.set_name(name) + else: + print("[ERROR] No name provided in command") + print("[HELP] Usage: ble/name EXAMPLE-1234") + else: + print(f"[ERROR] Unknown command: {command}") + print("[HELP] Available commands:") + print("[HELP] ble/name - Set BLE advertising name") + +def main(): + """Main loop - initialize BLE and process serial commands""" + print("=" * 50) + print("ESP32 BLE Provisioning Script") + print("=" * 50) + print("[INFO] Starting BLE provisioning system...") + + # Create BLE provisioning instance + ble_prov = BLEProvisioning() + + # Start with default name + ble_prov.start_advertising() + + print("\n[READY] Waiting for provisioning commands...") + print("[HELP] Send commands in format: ble/name EXAMPLE-1234") + print("[HELP] Press Ctrl+C to exit\n") + print("[INFO] Running in file-polling mode (checking ble_name.txt every 5 seconds)") + print("") + + try: + while True: + # Check for file updates periodically + ble_prov.check_file_for_updates() + + # Small delay to prevent busy waiting + time.sleep_ms(100) + + except KeyboardInterrupt: + print("\n[INFO] Shutting down...") + ble_prov.stop_advertising() + print("[INFO] BLE advertising stopped") + print("[INFO] Goodbye!") + print("[INFO] Goodbye!") + + +if __name__ == "__main__": + # Use polling mode for better compatibility with MicroPython + main() diff --git a/firmware/esp32/boot.py b/firmware/esp32/boot.py new file mode 100644 index 0000000..48e0a2a --- /dev/null +++ b/firmware/esp32/boot.py @@ -0,0 +1,67 @@ +# This file is executed on every boot (including wake-boot from deepsleep) +import esp +esp.osdebug(None) + +import network +import webrepl +import time + +print("=" * 50) +print("ESP32 Boot Sequence") +print("=" * 50) + +# Load WiFi configuration +try: + from wifi_config import WIFI_SSID, WIFI_PASSWORD, WEBREPL_PASSWORD + print("[WIFI] Configuration loaded") +except ImportError: + print("[ERROR] wifi_config.py not found!") + print("[INFO] WebREPL will not be available") + WIFI_SSID = None + WIFI_PASSWORD = None + WEBREPL_PASSWORD = "micropython" + +# Connect to WiFi if configured +if WIFI_SSID and WIFI_PASSWORD and WIFI_SSID != "YOUR_WIFI_SSID": + print(f"[WIFI] Connecting to {WIFI_SSID}...") + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + + if not wlan.isconnected(): + wlan.connect(WIFI_SSID, WIFI_PASSWORD) + + # Wait for connection (max 10 seconds) + timeout = 10 + while not wlan.isconnected() and timeout > 0: + time.sleep(1) + timeout -= 1 + print(".", end="") + print() + + if wlan.isconnected(): + ip_info = wlan.ifconfig() + print(f"[WIFI] ✓ Connected!") + print(f"[WIFI] IP Address: {ip_info[0]}") + print(f"[WIFI] Subnet: {ip_info[1]}") + print(f"[WIFI] Gateway: {ip_info[2]}") + print(f"[WIFI] DNS: {ip_info[3]}") + + # Start WebREPL + print("[WEBREPL] Starting WebREPL...") + webrepl.start(password=WEBREPL_PASSWORD) + print(f"[WEBREPL] ✓ WebREPL started on ws://{ip_info[0]}:8266") + print(f"[WEBREPL] Connect via: http://micropython.org/webrepl/") + print(f"[WEBREPL] Password: {WEBREPL_PASSWORD}") + else: + print("[ERROR] WiFi connection failed!") + print("[INFO] WebREPL will not be available") +else: + print("[INFO] WiFi not configured - edit wifi_config.py") + print("[INFO] WebREPL will not be available") + +print("=" * 50) +print() + +# Auto-start main.py - starts BLE provisioning and OLED display +import main +main.main() diff --git a/firmware/esp32/check_ble_status.py b/firmware/esp32/check_ble_status.py new file mode 100644 index 0000000..2765584 --- /dev/null +++ b/firmware/esp32/check_ble_status.py @@ -0,0 +1,37 @@ +""" +Quick BLE Status Check Script + +This script checks if BLE is running and what name it's advertising +Run this while your ESP32 is running normally (not during test_ble.py) +""" + +import bluetooth +import time + +print("=" * 60) +print("BLE Status Check") +print("=" * 60) + +# Check if BLE is active +ble = bluetooth.BLE() +print(f"\n[1] BLE Active: {ble.active()}") + +# Try to read the current BLE name from file +try: + with open('ble_name.txt', 'r') as f: + name = f.read().strip() + print(f"[2] BLE name in file: '{name}'") +except Exception as e: + print(f"[2] Could not read ble_name.txt: {e}") + +# Check if bluetooth module is available +try: + print(f"[3] Bluetooth module available: Yes") + print(f"[4] BLE config: {ble.config('mac')}") +except Exception as e: + print(f"[3] Bluetooth check failed: {e}") + +print("\n" + "=" * 60) +print("If BLE Active = True, then hardware is working") +print("The issue may be with boot.py or main.py preventing advertising") +print("=" * 60) diff --git a/firmware/esp32/deploy.sh b/firmware/esp32/deploy.sh new file mode 100755 index 0000000..4dd7470 --- /dev/null +++ b/firmware/esp32/deploy.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +# ESP32 Deployment Script +# Uploads all .py and .txt files from esp32/ folder to the ESP32 board using mpremote + +echo "==================================================" +echo "ESP32 Deployment Script (mpremote)" +echo "==================================================" + +# Check if mpremote is installed +if ! command -v mpremote &> /dev/null; then + echo "ERROR: mpremote is not installed" + echo "Install with: pip install mpremote" + exit 1 +fi + +# Detect serial port +PORT_ARG="" +DETECTED_PORT="" + +if [ -n "$1" ]; then + # User specified a port + DETECTED_PORT="$1" + PORT_ARG="connect $1" + echo "Using specified port: $1" +else + # Auto-detect using mpremote devs + echo "Auto-detecting ESP32..." + + # Get device list and extract the first device port + DEVS_OUTPUT=$(mpremote devs 2>&1) + + if [ $? -eq 0 ] && [ -n "$DEVS_OUTPUT" ]; then + # Extract port from output (format: /dev/xxx Serial port) + DETECTED_PORT=$(echo "$DEVS_OUTPUT" | grep -E '/dev/|COM' | head -n 1 | awk '{print $1}') + + if [ -n "$DETECTED_PORT" ]; then + echo "✓ Found ESP32 at: $DETECTED_PORT" + PORT_ARG="connect $DETECTED_PORT" + else + echo "WARNING: No devices found by 'mpremote devs'" + echo "Available devices:" + echo "$DEVS_OUTPUT" + echo "" + echo "Attempting to continue with auto-detection..." + # Leave PORT_ARG empty to let mpremote auto-detect + fi + else + echo "WARNING: Could not run 'mpremote devs'" + echo "Attempting to continue with mpremote's built-in auto-detection..." + # Leave PORT_ARG empty to let mpremote auto-detect + fi +fi + +echo "" + +# Install required libraries +echo "Installing required libraries..." +echo " Installing ssd1306 library for OLED display..." +mpremote $PORT_ARG mip install ssd1306 || { + echo "WARNING: Failed to install ssd1306 library" + echo "OLED display functionality may not work" +} +echo "" + +# Get the directory where this script is located +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +echo "Uploading Python files..." +echo "" + +# Upload all .py files +for file in "$SCRIPT_DIR"/*.py; do + if [ -f "$file" ]; then + filename=$(basename "$file") + echo " Uploading: $filename" + mpremote $PORT_ARG cp "$file" ":$filename" || { + echo "ERROR: Failed to upload $filename" + exit 1 + } + fi +done + +echo "" +echo "Uploading text files..." +echo "" + +# Upload all .txt files +for file in "$SCRIPT_DIR"/*.txt; do + if [ -f "$file" ]; then + filename=$(basename "$file") + echo " Uploading: $filename" + mpremote $PORT_ARG cp "$file" ":$filename" || { + echo "ERROR: Failed to upload $filename" + exit 1 + } + fi +done + +echo "" +echo "==================================================" +echo "✓ Deployment complete!" +echo "==================================================" +echo "" +echo "Uploaded files:" +mpremote $PORT_ARG ls + +echo "" +echo "Resetting ESP32..." +mpremote $PORT_ARG reset + +echo "" +echo "To see the output, run:" +if [ -n "$DETECTED_PORT" ]; then + echo " mpremote connect $DETECTED_PORT repl" + echo "" + echo "Or use screen/minicom:" + echo " screen $DETECTED_PORT 115200" +else + echo " mpremote repl" + echo "" + echo "Or use screen/minicom:" + echo " screen /dev/tty.usbserial-XXXX 115200" +fi +echo "" +echo "Note: The ESP32 has been reset and will auto-start" +echo " Check the qas-TIMESTAMP.log file for boot diagnostics" diff --git a/firmware/esp32/main.mpy b/firmware/esp32/main.mpy new file mode 100644 index 0000000..01fd2c9 --- /dev/null +++ b/firmware/esp32/main.mpy @@ -0,0 +1,166 @@ +""" +ESP32 Main Boot Script + +This script runs automatically when the ESP32 boots and starts: +1. BLE Provisioning service - listens for serial commands to set BLE name +2. OLED Display service - monitors and displays the BLE name on OLED + +Both services run in parallel using MicroPython's _thread module. +""" + +import sys +import time +import _thread + +# Flag to control graceful shutdown +running = True + +# Logging setup +LOG_FILE = None + +def get_timestamp(): + """Generate timestamp string for logging""" + try: + t = time.localtime() + return "{:04d}{:02d}{:02d}-{:02d}{:02d}{:02d}".format( + t[0], t[1], t[2], t[3], t[4], t[5] + ) + except: + return str(time.ticks_ms()) + +def setup_logging(): + """Setup logging to file with timestamp""" + global LOG_FILE + timestamp = get_timestamp() + log_filename = f"qas-{timestamp}.log" + try: + LOG_FILE = open(log_filename, 'w') + log(f"=== ESP32 Boot Log - {timestamp} ===") + log(f"Log file: {log_filename}") + return log_filename + except Exception as e: + print(f"[ERROR] Could not create log file: {e}") + return None + +def log(message): + """Log message to both console and file""" + print(message) + if LOG_FILE: + try: + LOG_FILE.write(message + '\n') + LOG_FILE.flush() + except: + pass + +def close_log(): + """Close log file""" + global LOG_FILE + if LOG_FILE: + try: + log("=== End of log ===") + LOG_FILE.close() + except: + pass + +def run_ble_provisioning(): + """Run BLE provisioning in a separate thread""" + try: + log("[MAIN] Starting BLE provisioning service...") + import ble_provisioning + # The ble_provisioning module will run its main() function + + except Exception as e: + log(f"[ERROR] BLE provisioning failed: {e}") + import sys + sys.print_exception(e) + +def run_oled_display(): + """Run OLED display in a separate thread""" + try: + # Try to read BLE name from file + ble_name = "Unknown" + try: + with open('ble_name.txt', 'r') as f: + ble_name = f.read().strip() + log(f"[OLED] Successfully read BLE name: {ble_name}") + except Exception as e: + log(f"[OLED] Could not read ble_name.txt: {e}") + + log(f"[MAIN] Starting OLED display service... (BLE: {ble_name})") + + # Check I2C before importing oled_display + try: + from machine import I2C, Pin + i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + devices = i2c.scan() + log(f"[OLED] I2C scan found {len(devices)} device(s): {[hex(d) for d in devices]}") + if not devices: + log("[OLED] WARNING: No I2C devices found!") + except Exception as e: + log(f"[OLED] I2C check failed: {e}") + + import oled_display + log("[OLED] oled_display module imported successfully") + # Call the main() function to start the OLED display + oled_display.main() + + except Exception as e: + log(f"[ERROR] OLED display failed: {e}") + import sys + sys.print_exception(e) + +def main(): + """Main entry point - start both services""" + log("=" * 50) + log("ESP32 Auto-Start System") + log("=" * 50) + + # Log system information + try: + import machine + log(f"[INFO] Reset cause: {machine.reset_cause()}") + log(f"[INFO] Frequency: {machine.freq() / 1000000} MHz") + log(f"[INFO] Free memory: {gc.mem_free()} bytes") + except Exception as e: + log(f"[INFO] Could not get system info: {e}") + + log("[INFO] Starting services...") + log("") + + # Start OLED display in a separate thread + try: + _thread.start_new_thread(run_oled_display, ()) + log("[MAIN] ✓ OLED display thread started") + time.sleep(1) # Give OLED time to initialize + except Exception as e: + log(f"[WARNING] Could not start OLED display: {e}") + log("[INFO] Continuing without OLED display...") + + # Run BLE provisioning in the main thread + # This allows us to receive serial input in the main thread + try: + log("[MAIN] ✓ Starting BLE provisioning in main thread") + log("") + run_ble_provisioning() + except KeyboardInterrupt: + log("\n[MAIN] Shutdown requested...") + global running + running = False + except Exception as e: + log(f"[ERROR] Main thread error: {e}") + import sys + sys.print_exception(e) + finally: + close_log() + +if __name__ == "__main__": + # Small delay to allow boot messages to complete + time.sleep(2) + + # Setup logging first + import gc + log_file = setup_logging() + if log_file: + log(f"[INFO] Logging to: {log_file}") + + main() diff --git a/firmware/esp32/main.py b/firmware/esp32/main.py new file mode 100644 index 0000000..e0af3c3 --- /dev/null +++ b/firmware/esp32/main.py @@ -0,0 +1,167 @@ +""" +ESP32 Main Boot Script + +This script runs automatically when the ESP32 boots and starts: +1. BLE Provisioning service - listens for serial commands to set BLE name +2. OLED Display service - monitors and displays the BLE name on OLED + +Both services run in parallel using MicroPython's _thread module. +""" + +import sys +import time +import _thread + +# Flag to control graceful shutdown +running = True + +# Logging setup +LOG_FILE = None + +def get_timestamp(): + """Generate timestamp string for logging""" + try: + t = time.localtime() + return "{:04d}{:02d}{:02d}-{:02d}{:02d}{:02d}".format( + t[0], t[1], t[2], t[3], t[4], t[5] + ) + except: + return str(time.ticks_ms()) + +def setup_logging(): + """Setup logging to file with timestamp""" + global LOG_FILE + timestamp = get_timestamp() + log_filename = f"qas-{timestamp}.log" + try: + LOG_FILE = open(log_filename, 'w') + log(f"=== ESP32 Boot Log - {timestamp} ===") + log(f"Log file: {log_filename}") + return log_filename + except Exception as e: + print(f"[ERROR] Could not create log file: {e}") + return None + +def log(message): + """Log message to both console and file""" + print(message) + if LOG_FILE: + try: + LOG_FILE.write(message + '\n') + LOG_FILE.flush() + except: + pass + +def close_log(): + """Close log file""" + global LOG_FILE + if LOG_FILE: + try: + log("=== End of log ===") + LOG_FILE.close() + except: + pass + +def run_ble_provisioning(): + """Run BLE provisioning in a separate thread""" + try: + log("[MAIN] Starting BLE provisioning service...") + import ble_provisioning + # Call the main() function to start BLE advertising + ble_provisioning.main() + + except Exception as e: + log(f"[ERROR] BLE provisioning failed: {e}") + import sys + sys.print_exception(e) + +def run_oled_display(): + """Run OLED display in a separate thread""" + try: + # Try to read BLE name from file + ble_name = "Unknown" + try: + with open('ble_name.txt', 'r') as f: + ble_name = f.read().strip() + log(f"[OLED] Successfully read BLE name: {ble_name}") + except Exception as e: + log(f"[OLED] Could not read ble_name.txt: {e}") + + log(f"[MAIN] Starting OLED display service... (BLE: {ble_name})") + + # Check I2C before importing oled_display + try: + from machine import I2C, Pin + i2c = I2C(0, scl=Pin(22), sda=Pin(21)) + devices = i2c.scan() + log(f"[OLED] I2C scan found {len(devices)} device(s): {[hex(d) for d in devices]}") + if not devices: + log("[OLED] WARNING: No I2C devices found!") + except Exception as e: + log(f"[OLED] I2C check failed: {e}") + + import oled_display + log("[OLED] oled_display module imported successfully") + # Call the main() function to start the OLED display + oled_display.main() + + except Exception as e: + log(f"[ERROR] OLED display failed: {e}") + import sys + sys.print_exception(e) + +def main(): + """Main entry point - start both services""" + log("=" * 50) + log("ESP32 Auto-Start System") + log("=" * 50) + + # Log system information + try: + import machine + log(f"[INFO] Reset cause: {machine.reset_cause()}") + log(f"[INFO] Frequency: {machine.freq() / 1000000} MHz") + log(f"[INFO] Free memory: {gc.mem_free()} bytes") + except Exception as e: + log(f"[INFO] Could not get system info: {e}") + + log("[INFO] Starting services...") + log("") + + # Start OLED display in a separate thread + try: + _thread.start_new_thread(run_oled_display, ()) + log("[MAIN] ✓ OLED display thread started") + time.sleep(1) # Give OLED time to initialize + except Exception as e: + log(f"[WARNING] Could not start OLED display: {e}") + log("[INFO] Continuing without OLED display...") + + # Run BLE provisioning in the main thread + # This allows us to receive serial input in the main thread + try: + log("[MAIN] ✓ Starting BLE provisioning in main thread") + log("") + run_ble_provisioning() + except KeyboardInterrupt: + log("\n[MAIN] Shutdown requested...") + global running + running = False + except Exception as e: + log(f"[ERROR] Main thread error: {e}") + import sys + sys.print_exception(e) + finally: + close_log() + +if __name__ == "__main__": + # Small delay to allow boot messages to complete + time.sleep(2) + + # Setup logging first + import gc + log_file = setup_logging() + if log_file: + log(f"[INFO] Logging to: {log_file}") + + main() diff --git a/firmware/esp32/oled_display.py b/firmware/esp32/oled_display.py new file mode 100644 index 0000000..217fc08 --- /dev/null +++ b/firmware/esp32/oled_display.py @@ -0,0 +1,294 @@ +""" +ESP32 OLED Display Script for BLE Name + +This script monitors the ble_name.txt file and displays the BLE name +on a 128x64 OLED display connected via I2C. + +Hardware Requirements: +- ESP32 development board +- 128x64 OLED display (SSD1306 or compatible) connected via I2C +- Default I2C pins: SCL=GPIO22, SDA=GPIO21 (configurable) + +The script will: +1. Initialize the OLED display +2. Read the BLE name from ble_name.txt +3. Display the name on the OLED +4. Monitor the file for changes and update the display +""" + +import machine +import time +import os +from machine import Pin, SoftI2C + +# Try to import SSD1306 driver +try: + from ssd1306 import SSD1306_I2C +except ImportError: + print("[ERROR] SSD1306 driver not found!") + print("[INFO] Please install the ssd1306 library") + print("[INFO] Download from: https://github.com/micropython/micropython-lib") + raise + +# I2C configuration +I2C_SCL_PIN = 22 # GPIO22 for SCL +I2C_SDA_PIN = 21 # GPIO21 for SDA +I2C_FREQ = 400000 # 400kHz + +# OLED configuration +OLED_WIDTH = 128 +OLED_HEIGHT = 64 + +# File to monitor +BLE_NAME_FILE = 'ble_name.txt' + +# Update interval (milliseconds) +UPDATE_INTERVAL = 2345 # ms + + +class OLEDDisplay: + def __init__(self, scl_pin=I2C_SCL_PIN, sda_pin=I2C_SDA_PIN, + width=OLED_WIDTH, height=OLED_HEIGHT): + """Initialize the OLED display""" + try: + # Initialize I2C + self.i2c = SoftI2C(scl=Pin(scl_pin), sda=Pin(sda_pin), freq=I2C_FREQ) + + # Scan for I2C devices + devices = self.i2c.scan() + if not devices: + raise RuntimeError("No I2C devices found!") + + print(f"[I2C] Found devices at addresses: {[hex(addr) for addr in devices]}") + + # Initialize OLED display + self.oled = SSD1306_I2C(width, height, self.i2c) + self.width = width + self.height = height + + # Clear display + self.oled.fill(0) + self.oled.show() + + print(f"[OLED] Display initialized ({width}x{height})") + + except Exception as e: + print(f"[ERROR] Failed to initialize OLED: {e}") + raise + + def clear(self): + """Clear the display""" + self.oled.fill(0) + self.oled.show() + + def clear_rect(self, x, y, width, height): + """Clear a rectangular region of the display""" + self.oled.fill_rect(x, y, width, height, 0) + + def display_text(self, text, x=0, y=0): + """Display text at specified position""" + self.oled.text(text, x, y) + self.oled.show() + + def display_centered_text(self, text, y=None): + """Display text centered horizontally""" + # Calculate center position + text_width = len(text) * 8 # Each character is 8 pixels wide + x = max(0, (self.width - text_width) // 2) + + # Use middle of screen if y not specified + if y is None: + y = (self.height - 8) // 2 # Each character is 8 pixels tall + + self.oled.text(text, x, y) + self.oled.show() + + def display_ble_name(self, name, mtime=None, first_draw=False): + """Display BLE name with formatting + + Args: + name: BLE device name to display + mtime: File modification time (optional) + first_draw: If True, redraw everything. If False, only update timestamps + """ + import time + + # On first draw, clear everything and draw static content + if first_draw: + self.clear() + + # Display header + self.oled.text("BLE Device Name:", 0, 0) + + # Draw a separator line + for x in range(0, self.width): + self.oled.pixel(x, 12, 1) + + # Display the BLE name (centered) + # Split into multiple lines if needed + max_chars_per_line = self.width // 8 # 16 chars for 128px width + + if len(name) <= max_chars_per_line: + # Single line + text_width = len(name) * 8 + x = max(0, (self.width - text_width) // 2) + self.oled.text(name, x, 22) + else: + # Multiple lines - split the name + words = name.split('-') + lines = [] + current_line = "" + + for word in words: + test_line = current_line + ('-' if current_line else '') + word + if len(test_line) <= max_chars_per_line: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + + if current_line: + lines.append(current_line) + + # Display lines + start_y = 16 + for i, line in enumerate(lines[:3]): # Max 3 lines to leave room for timestamps + text_width = len(line) * 8 + x = max(0, (self.width - text_width) // 2) + self.oled.text(line, x, start_y + (i * 10)) + + # Always update timestamps (only clear the timestamp area, not whole screen) + # Clear timestamp region (bottom 30 pixels) + self.clear_rect(0, self.height - 30, self.width, 30) + + current_time = time.localtime() + timestamp = "{:02d}:{:02d}:{:02d}".format( + current_time[3], current_time[4], current_time[5] + ) + + # Display file modification time if provided + if mtime is not None: + self.oled.text("mtime: %d" % mtime, 0, self.height - 24) + + self.oled.text("Now: %s" % timestamp, 0, self.height - 14) + + self.oled.show() + + +class BLENameMonitor: + def __init__(self, display, filename=BLE_NAME_FILE): + """Initialize the BLE name monitor""" + self.display = display + self.filename = filename + self.last_name = None + self.last_mtime = 0 + + def read_ble_name(self): + """Read the BLE name from file""" + try: + with open(self.filename, 'r') as f: + name = f.read().strip() + return name if name else None + except OSError: + # File doesn't exist + return None + except Exception as e: + print(f"[ERROR] Failed to read {self.filename}: {e}") + return None + + def get_file_mtime(self): + """Get the modification time of the file""" + try: + stat = os.stat(self.filename) + return stat[8] # st_mtime + except OSError: + return 0 + + def check_for_updates(self): + """Check if the file has been updated and update display if needed""" + current_mtime = self.get_file_mtime() + name = self.read_ble_name() + + # Always update display to show current time and mtime + if name: + # Check if file was modified or name changed + first_draw = False + if current_mtime != self.last_mtime or name != self.last_name: + print(f"[MONITOR] BLE name updated: {name} (mtime: {current_mtime})") + self.last_name = name + self.last_mtime = current_mtime + first_draw = True # Redraw everything on name change + + # Refresh display: full redraw on changes, timestamps only otherwise + self.display.display_ble_name(name, current_mtime, first_draw=first_draw) + return True + + return False + + def display_default_message(self): + """Display a default message when no BLE name is set""" + self.display.clear() + self.display.oled.text("BLE Device Name:", 0, 0) + + # Draw separator + for x in range(0, self.display.width): + self.display.oled.pixel(x, 12, 1) + + self.display.display_centered_text("Not Set", 28) + self.display.oled.text("Waiting for", 20, 44) + self.display.oled.text("provisioning...", 8, 54) + self.display.oled.show() + + +def main(): + """Main loop - monitor BLE name file and update OLED display""" + print("=" * 50) + print("ESP32 OLED Display for BLE Name") + print("=" * 50) + print("[INFO] Initializing...") + + try: + # Initialize OLED display + display = OLEDDisplay() + + # Initialize monitor + monitor = BLENameMonitor(display) + + # Try to read initial BLE name + initial_name = monitor.read_ble_name() + + if initial_name: + print(f"[INFO] Current BLE name: {initial_name}") + mtime = monitor.get_file_mtime() + display.display_ble_name(initial_name, mtime, first_draw=True) + monitor.last_name = initial_name + monitor.last_mtime = mtime + else: + print("[INFO] No BLE name found, waiting for provisioning...") + monitor.display_default_message() + + print("\n[READY] Monitoring for BLE name changes...") + print("[INFO] Press Ctrl+C to exit\n") + + # Main monitoring loop + while True: + monitor.check_for_updates() + time.sleep_ms(UPDATE_INTERVAL) + + except KeyboardInterrupt: + print("\n[INFO] Shutting down...") + display.clear() + display.display_centered_text("Goodbye!", 28) + time.sleep(1) + display.clear() + print("[INFO] Display cleared") + print("[INFO] Goodbye!") + + except Exception as e: + print(f"[ERROR] Unexpected error: {e}") + raise + + +if __name__ == "__main__": + main() diff --git a/firmware/esp32/setup_oled.sh b/firmware/esp32/setup_oled.sh new file mode 100755 index 0000000..8ef6329 --- /dev/null +++ b/firmware/esp32/setup_oled.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +# ESP32 OLED Display Setup Script +# This script installs the required SSD1306 library and uploads the OLED display script + +set -e + +echo "==========================================" +echo "ESP32 OLED Display Setup" +echo "==========================================" +echo "" + +# Check if mpremote is available +if ! command -v mpremote &> /dev/null; then + echo "Error: mpremote is not installed" + echo "Install it with: pip install mpremote" + exit 1 +fi + +echo "Step 1: Installing SSD1306 library..." +mpremote mip install ssd1306 +echo "✓ SSD1306 library installed" +echo "" + +echo "Step 2: Uploading OLED display script..." +mpremote fs cp esp32/oled_display.py :oled_display.py +echo "✓ OLED display script uploaded" +echo "" + +echo "Step 3: Verifying installation..." +mpremote fs ls | grep -E "(ssd1306|oled_display)" && echo "✓ Files verified" || echo "⚠ Warning: Could not verify files" +echo "" + +echo "==========================================" +echo "Setup Complete!" +echo "==========================================" +echo "" +echo "To run the OLED display script:" +echo " mpremote run esp32/oled_display.py" +echo "" +echo "Or in the REPL:" +echo " import oled_display" +echo "" +echo "See OLED_DISPLAY_README.md for more information." +echo "" diff --git a/firmware/esp32/test_ble.py b/firmware/esp32/test_ble.py new file mode 100644 index 0000000..b038eb3 --- /dev/null +++ b/firmware/esp32/test_ble.py @@ -0,0 +1,134 @@ +""" +ESP32 BLE Diagnostic Test Script + +This script tests BLE advertising functionality independently +to help diagnose connectivity issues. + +Usage: +1. Upload this file to your ESP32 +2. Run: import test_ble +3. Or run directly: python3 -c "import test_ble" +""" + +import bluetooth +import time +from micropython import const + +# BLE event constants +_IRQ_CENTRAL_CONNECT = const(1) +_IRQ_CENTRAL_DISCONNECT = const(2) + +print("=" * 60) +print("ESP32 BLE Diagnostic Test") +print("=" * 60) + +# Test 1: Check if bluetooth module is available +print("\n[TEST 1] Checking bluetooth module...") +try: + ble = bluetooth.BLE() + print("✓ Bluetooth module imported successfully") +except Exception as e: + print(f"✗ FAILED: {e}") + import sys + sys.exit(1) + +# Test 2: Activate BLE +print("\n[TEST 2] Activating BLE...") +try: + ble.active(True) + print(f"✓ BLE activated: {ble.active()}") +except Exception as e: + print(f"✗ FAILED: {e}") + import sys + sys.exit(1) + +# Test 3: Get BLE configuration +print("\n[TEST 3] BLE Configuration...") +try: + config = ble.config('mac') + mac_str = ':'.join(['{:02x}'.format(b) for b in config[1]]) + print(f"✓ MAC Address: {mac_str}") +except Exception as e: + print(f"✗ Could not read MAC: {e}") + +# Test 4: Create advertising payload +print("\n[TEST 4] Creating advertising payload...") +try: + test_name = "ESP32-TEST" + + # Create payload + payload = bytearray([ + 0x02, 0x01, 0x06, # Flags: General discoverable mode + ]) + + # Add complete local name + name_bytes = test_name.encode('utf-8') + name_len = len(name_bytes) + 1 + payload.extend(bytearray([name_len, 0x09])) # Complete local name + payload.extend(name_bytes) + + print(f"✓ Payload created: {len(payload)} bytes") + print(f" Name: {test_name}") + print(f" Payload: {' '.join(['{:02x}'.format(b) for b in payload])}") + +except Exception as e: + print(f"✗ FAILED: {e}") + import sys + sys.exit(1) + +# Test 5: Start advertising +print("\n[TEST 5] Starting BLE advertising...") +try: + # Stop any existing advertising first + ble.gap_advertise(None) + time.sleep_ms(100) + + # Start advertising (100ms interval) + ble.gap_advertise(100000, adv_data=payload) + print(f"✓ Advertising started with name: {test_name}") + print(" Interval: 100ms") + +except Exception as e: + print(f"✗ FAILED: {e}") + import sys + sys.exit(1) + +# Test 6: Keep advertising and show status +print("\n[TEST 6] Monitoring advertising status...") +print("=" * 60) +print(f"✓ BLE is now advertising as: {test_name}") +print() +print("INSTRUCTIONS:") +print("1. Open a BLE scanner app on your phone") +print(" - iOS: LightBlue, nRF Connect") +print(" - Android: nRF Connect, BLE Scanner") +print("2. Look for device named: ESP32-TEST") +print("3. You should see it appear in the scan results") +print() +print("This script will keep advertising for 30 seconds...") +print("Press Ctrl+C to stop early") +print("=" * 60) + +try: + for i in range(30): + print(f"[{i+1}/30] Advertising... (BLE Active: {ble.active()})") + time.sleep(1) + + print("\n[SUCCESS] Test completed!") + print("If you could see 'ESP32-TEST' in your BLE scanner,") + print("then BLE is working correctly on your ESP32.") + +except KeyboardInterrupt: + print("\n[INFO] Test interrupted by user") + +finally: + # Stop advertising + print("\n[CLEANUP] Stopping BLE advertising...") + try: + ble.gap_advertise(None) + print("✓ Advertising stopped") + except: + pass + + print("\n[DONE] Diagnostic test finished") + print("=" * 60) diff --git a/firmware/esp32/test_provisioning.sh b/firmware/esp32/test_provisioning.sh new file mode 100755 index 0000000..4ab9088 --- /dev/null +++ b/firmware/esp32/test_provisioning.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Test script for BLE provisioning +# Usage: ./test_provisioning.sh [ble_name] [port_name] + +BLE_NAME="${1:-BAT-PRO-3-2510}" +PORT="${2:-/dev/tty.usbserial-110}" + +echo "Connecting to ESP32 on $PORT..." +echo "Sending provisioning command: ble/name $BLE_NAME" +echo "" + +# Configure serial port +stty -f "$PORT" 115200 cs8 -cstopb -parenb + +# Send the provisioning command +echo "ble/name $BLE_NAME" > "$PORT" + +echo "Command sent! The ESP32 should now be advertising as: $BLE_NAME" +echo "" +echo "To verify, you can:" +echo " - Check Bluetooth settings on your phone/computer" +echo " - Use 'screen $PORT 115200' to see the ESP32 output" +echo " - Run: hcitool lescan (on Linux)" diff --git a/firmware/esp32/wifi_config.py b/firmware/esp32/wifi_config.py new file mode 100644 index 0000000..8b4b9e2 --- /dev/null +++ b/firmware/esp32/wifi_config.py @@ -0,0 +1,17 @@ +""" +WiFi Configuration +Edit this file with your WiFi credentials before deploying +""" + +# WiFi credentials +WIFI_SSID = "orcYard" +WIFI_PASSWORD = "Rainbird-1" + +# WebREPL password (change this!) +WEBREPL_PASSWORD = "python3" + +# Optional: Static IP configuration (comment out to use DHCP) +# STATIC_IP = "192.168.1.100" +# SUBNET_MASK = "255.255.255.0" +# GATEWAY = "192.168.1.1" +# DNS = "192.168.1.1" diff --git a/firmware/requirements.txt b/firmware/requirements.txt index 57f61e1..17b4fc4 100644 --- a/firmware/requirements.txt +++ b/firmware/requirements.txt @@ -9,3 +9,9 @@ pyserial>=3.5 # Bluetooth Low Energy library (used by ble_char.py) bleak>=0.20.0 + +# esptool Espressif chips ROM Bootloader Utility for MicroPython firmware flashing +esptool>=4.0.4 + +# mpremote - MicroPython remote control tool for file management and REPL (modern alternative to ampy) +mpremote>=1.20.0 diff --git a/firmware/serial_ish.py b/firmware/serial_ish.py new file mode 100755 index 0000000..8ee806b --- /dev/null +++ b/firmware/serial_ish.py @@ -0,0 +1,303 @@ +#!/usr/bin/python3 + +# serial_get.py +# reads serial port data and optionally allows interactive command input + +import sys +import time +import serial +import select +import termios +import tty +import json +from pathlib import Path +import argparse +from serial.tools import list_ports + + +CACHE_FILE = Path.home() / f".{Path(__file__).stem}_cache.json" + + +def load_cached_port(): + """Load the last used port and baud rate from cache file""" + try: + if CACHE_FILE.exists(): + with open(CACHE_FILE, 'r') as f: + data = json.load(f) + # Print previous values if they exist + if 'previous_port' in data or 'previous_baud' in data: + prev_port = data.get('previous_port', 'N/A') + prev_baud = data.get('previous_baud', 'N/A') + print(f"Previous connection: {prev_port} @ {prev_baud} baud") + return data.get('last_port'), data.get('last_baud') + except Exception: + pass + return None, None + + +def save_cached_port(port, baud): + """Save the port and baud rate to cache file, preserving previous values""" + try: + data = {'last_port': port, 'last_baud': baud} + + # Load existing cache to preserve previous values + if CACHE_FILE.exists(): + with open(CACHE_FILE, 'r') as f: + old_data = json.load(f) + # Save previous values for troubleshooting + if 'last_port' in old_data: + data['previous_port'] = old_data['last_port'] + if 'last_baud' in old_data: + data['previous_baud'] = old_data['last_baud'] + + with open(CACHE_FILE, 'w') as f: + json.dump(data, f, indent=2) + except Exception: + pass + + +def list_serial_ports(): + """List serial ports that are likely to be attached to dev boards""" + ports = [p for p in list_ports.comports() if p.device.startswith('/dev/')] + + if not ports: + print("No serial ports found under /dev/") + return + + keywords = ( + "usb", "acm", "slab", "wch", "ch34", "esp", "cp210", "ftdi", + "serial", "modem", "uart", "ttyusb", "ttyacm", + ) + + def is_likely(candidate): + haystack = f"{candidate.device} {candidate.description} {candidate.hwid}".lower() + return any(keyword in haystack for keyword in keywords) + + likely = [p for p in ports if is_likely(p)] + others = [p for p in ports if p not in likely] + + print("Likely microcontroller serial ports:") + if likely: + for port in likely: + desc = port.description or "No description" + print(f" {port.device} | {desc}") + else: + print(" None detected") + + if others: + print("\nOther serial ports under /dev/:") + for port in others: + desc = port.description or "No description" + print(f" {port.device} | {desc}") + + +def send_mode(ser, message, duration): + """Send a message and optionally read response for specified duration""" + # Send the message + ser.write((message + '\r\n').encode()) + print(f"Sent: {message}") + + # Read response if duration > 0 + if duration > 0: + print(f"Reading response for {duration} seconds...") + end_time = time.time() + duration + while time.time() < end_time: + data = ser.read(1024) + if data: + sys.stdout.write(data.decode(errors="replace")) + sys.stdout.flush() + + +def read_mode(ser, duration): + """Simple read mode - read for specified duration and exit""" + end_time = time.time() + duration + while time.time() < end_time: + data = ser.read(1024) + if data: + sys.stdout.write(data.decode(errors="replace")) + sys.stdout.flush() + print(f"\nRead finished after {duration} seconds (timeout reached).") + + +def interactive_mode(ser): + """Interactive mode - monitor output and allow sending commands""" + print("=" * 60) + print("Interactive Serial Console") + print("=" * 60) + print("Commands:") + print(" Type and press ENTER to send commands") + print(" Ctrl+C to exit") + print(" Ctrl+D to exit") + print("=" * 60) + print() + + # Save original terminal settings + old_settings = termios.tcgetattr(sys.stdin) + + try: + # Set terminal to raw mode for character-by-character input + tty.setraw(sys.stdin.fileno()) + + input_buffer = "" + + while True: + # Check for data from serial port + if ser.in_waiting: + data = ser.read(ser.in_waiting) + if data: + # If we have buffered input, clear the line first + if input_buffer: + sys.stdout.write('\r' + ' ' * (len(input_buffer) + 2) + '\r') + + # Write serial data + sys.stdout.write(data.decode(errors="replace")) + + # Redraw input buffer if it exists + if input_buffer: + sys.stdout.write(f"> {input_buffer}") + + sys.stdout.flush() + + # Check for keyboard input (non-blocking) + if select.select([sys.stdin], [], [], 0.01)[0]: + char = sys.stdin.read(1) + + # Handle Ctrl+C (0x03) or Ctrl+D (0x04) + if char in ('\x03', '\x04'): + print("\n\nExiting...") + break + + # Handle Enter/Return + elif char in ('\r', '\n'): + if input_buffer: + # Send the command + ser.write((input_buffer + '\r\n').encode()) + sys.stdout.write('\r\n') + sys.stdout.flush() + input_buffer = "" + else: + sys.stdout.write('\r\n') + sys.stdout.flush() + + # Handle backspace/delete + elif char in ('\x7f', '\x08'): + if input_buffer: + input_buffer = input_buffer[:-1] + sys.stdout.write('\r> ' + input_buffer + ' \r> ' + input_buffer) + sys.stdout.flush() + + # Handle printable characters + elif char.isprintable(): + input_buffer += char + sys.stdout.write(char) + sys.stdout.flush() + + time.sleep(0.01) + + finally: + # Restore terminal settings + termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings) + print() + + +def main(): + if len(sys.argv) > 1 and sys.argv[1].lower() == 'ls': + list_serial_ports() + return + + parser = argparse.ArgumentParser( + description="Read serial port data and optionally send commands", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Read for 5 seconds and exit (default) + %(prog)s /dev/cu.usbserial-110 + + # Read for 10 seconds at 115200 baud + %(prog)s /dev/cu.usbserial-110 115200 10.0 + + # Send a command and read response for 2 seconds + %(prog)s /dev/cu.usbserial-110 --send "print('Hello')" --duration 2.0 + %(prog)s /dev/cu.usbserial-110 115200 --send "import machine; machine.reset()" + + # Send a command without waiting for response + %(prog)s /dev/cu.usbserial-110 --send "led.on()" --duration 0 + + # Interactive mode - monitor and send commands + %(prog)s /dev/cu.usbserial-110 --interactive + %(prog)s /dev/cu.usbserial-110 115200 --interactive + """ + ) + + parser.add_argument('device', nargs='?', help='Serial device path (e.g., /dev/cu.usbserial-110, COM1). If not specified, uses cached port from last run.') + parser.add_argument('baudrate', nargs='?', type=int, default=None, + help='Baud rate for serial communication (default: uses cached value or 230400)') + parser.add_argument('duration', nargs='?', type=float, default=5.0, + help='Duration in seconds to read data (default: 5.0, ignored in interactive mode)') + parser.add_argument('-i', '--interactive', action='store_true', + help='Enable interactive mode to send commands') + parser.add_argument('-s', '--send', type=str, metavar='MESSAGE', + help='Send a message/command to the serial port and optionally read response') + parser.add_argument('-d', '--duration', type=float, dest='send_duration', + help='Duration to read response after sending (use with --send, default: 2.0 seconds)') + + args = parser.parse_args() + + cached_port, cached_baudrate = load_cached_port() + + # Handle port caching + port = args.device + used_cache = False + if not port: + port = cached_port + if not port: + print("Error: No device specified and no cached device found.") + print("Please specify a device path on first use.") + sys.exit(1) + print(f"Using cached device: {port}") + used_cache = True + + # Handle baudrate - use cached if not specified, otherwise use default + if args.baudrate is not None: + baudrate = args.baudrate + elif cached_baudrate is not None: + baudrate = cached_baudrate + print(f"Using cached baud rate: {baudrate}") + else: + baudrate = 230400 + print(f"Using default baud rate: {baudrate}") + + # Save the connection details for next time (only update previous if not using cache) + if not used_cache: + save_cached_port(port, baudrate) + elif baudrate != cached_baudrate: + # Baudrate changed but port is from cache, still save + save_cached_port(port, baudrate) + duration = args.duration + + print(f"Opening serial port {port} at {baudrate} baud...") + + # Determine response duration for send mode + if args.send: + send_duration = args.send_duration if args.send_duration is not None else 2.0 + else: + send_duration = 0 + + try: + with serial.Serial(port, baudrate, timeout=0.1) as ser: + if args.interactive: + interactive_mode(ser) + elif args.send: + send_mode(ser, args.send, send_duration) + else: + read_mode(ser, duration) + except serial.SerialException as e: + print(f"Error: {e}") + sys.exit(1) + except KeyboardInterrupt: + print("\n\nInterrupted by user") + sys.exit(0) + + +if __name__ == "__main__": + main() From 2584d99b8254448d2e64c649232b093ce42a3828 Mon Sep 17 00:00:00 2001 From: Markos Hudson Date: Sun, 16 Nov 2025 18:54:24 -0800 Subject: [PATCH 05/12] feat: add interactive serial monitor and Raspberry Pi camera utilities - Add CircuitPython serial monitor (qt2040-trinkey-circuitpython/read-serial.py) with interactive mode and configurable timeouts - Create rpicam utility scripts for listing cameras and capturing images with rotation - Enhance serial_ish.py with improved duration/timeout argument handling and better default behavior for send mode --- .../DEPLOYMENT_NOTES.md | 110 +++++++ .../qt2040-trinkey-circuitpython/README.md | 59 ++++ .../qt2040-trinkey-circuitpython/blinker.py | 23 ++ .../double-12-dominoes.py | 272 ++++++++++++++++++ .../double-12-requirements.md | 40 +++ .../serial_mon.py | 182 ++++++++++++ firmware/rp-cam/rpicam-cap.sh | 3 + firmware/rp-cam/rpicam-list.sh | 55 ++++ firmware/serial_ish.py | 28 +- 9 files changed, 762 insertions(+), 10 deletions(-) create mode 100644 firmware/qt2040-trinkey-circuitpython/DEPLOYMENT_NOTES.md create mode 100644 firmware/qt2040-trinkey-circuitpython/README.md create mode 100644 firmware/qt2040-trinkey-circuitpython/blinker.py create mode 100644 firmware/qt2040-trinkey-circuitpython/double-12-dominoes.py create mode 100644 firmware/qt2040-trinkey-circuitpython/double-12-requirements.md create mode 100755 firmware/qt2040-trinkey-circuitpython/serial_mon.py create mode 100755 firmware/rp-cam/rpicam-cap.sh create mode 100755 firmware/rp-cam/rpicam-list.sh diff --git a/firmware/qt2040-trinkey-circuitpython/DEPLOYMENT_NOTES.md b/firmware/qt2040-trinkey-circuitpython/DEPLOYMENT_NOTES.md new file mode 100644 index 0000000..ca712af --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/DEPLOYMENT_NOTES.md @@ -0,0 +1,110 @@ +# QT2040 Trinkey CircuitPython Deployment Notes + +## Deploy Script Usage + +### Basic Usage +```bash +./deploy.sh [source_file] +``` + +### Examples +```bash +# Deploy code.py (default) +./deploy.sh + +# Deploy a specific Python file (like lava-domino.py) +./deploy.sh lava-domino.py + +# Deploy any other script +./deploy.sh my-script.py +``` + +### How deploy.sh Works +1. **Default Source**: If no argument provided, uses `code.py` +2. **Custom Source**: Takes first argument as source filename +3. **Target Device**: Always deploys to `/Volumes/CIRCUITPY/code.py` +4. **Device Detection**: Checks if `/Volumes/CIRCUITPY` is mounted +5. **Auto-restart**: Device automatically restarts with new code + +### Deploy Script Logic +- Source file: `${1:-code.py}` (first argument or default to code.py) +- Target device: `/Volumes/CIRCUITPY` +- Always copies to `code.py` on the device (regardless of source filename) +- Provides success/error feedback + +## CircuitPython Library Requirements + +### ⚠️ IMPORTANT: Library Dependencies +Many CircuitPython scripts require additional libraries that must be copied to the device manually. + +### Required Libraries for Current Projects +- **adafruit_is31fl3741** - For I2C LED matrix control +- **neopixel** - For built-in NeoPixel control +- **adafruit_debouncer** - For button handling (if used) + +### Library Installation Process +1. **Download Adafruit CircuitPython Bundle**: + - Get from: https://circuitpython.org/libraries + - Extract the bundle + +2. **Copy Required Libraries**: + ```bash + # Copy individual library files to device + cp adafruit-circuitpython-bundle-*/lib/adafruit_is31fl3741 /Volumes/CIRCUITPY/lib/ + cp adafruit-circuitpython-bundle-*/lib/neopixel.mpy /Volumes/CIRCUITPY/lib/ + ``` + +3. **Library Location on Device**: + - Device path: `/Volumes/CIRCUITPY/lib/` + - Must create `lib` directory if it doesn't exist + +### Current Project Library Status +- **Bundle Present**: `adafruit-circuitpython-bundle-10.x-mpy-20250924/` (in project directory) +- **Required for lava-domino.py**: + - `adafruit_is31fl3741` (for I2C matrix) + - `neopixel.mpy` (for status LED) + +### Troubleshooting +- **Import Errors**: Usually means missing libraries in `/Volumes/CIRCUITPY/lib/` +- **Device Not Found**: Check USB connection and that device is mounted +- **Permission Errors**: Device might be read-only or not properly mounted + +### Best Practices +1. Always check library dependencies before deploying +2. Keep a local copy of the CircuitPython bundle +3. Test deployment on a simple script first +4. Monitor device serial output for import errors +5. Use version-specific libraries matching your CircuitPython version + +## Device Information +- **Target Device**: QT2040 Trinkey +- **Mount Point**: `/Volumes/CIRCUITPY` +- **Main Script**: `code.py` (auto-runs on startup) +- **Library Directory**: `/Volumes/CIRCUITPY/lib/` + +## Quick Reference Commands + +### Check Device Connection +```bash +ls -la /Volumes/CIRCUITPY/ +``` + +### Check Library Status +```bash +ls -la /Volumes/CIRCUITPY/lib/ | grep -E "(is31fl3741|neopixel)" +``` + +### Deploy Script +```bash +./deploy.sh lava-domino.py +``` + +### Monitor Device Output (if serial monitor available) +```bash +python3 serial_monitor.py +``` + +## Current Status +- ✅ QT2040 Trinkey connected at `/Volumes/CIRCUITPY` +- ✅ Required libraries present: `adafruit_is31fl3741`, `neopixel.mpy` +- ✅ lava-domino.py successfully deployed and running diff --git a/firmware/qt2040-trinkey-circuitpython/README.md b/firmware/qt2040-trinkey-circuitpython/README.md new file mode 100644 index 0000000..8f51237 --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/README.md @@ -0,0 +1,59 @@ +# QT2040 Trinkey CircuitPython Project + +## 🚀 Quick Start - READ THIS FIRST! + +### Essential Files to Check +- **DEPLOYMENT_NOTES.md** - Complete deployment guide and library management +- **deploy.sh** - Deployment script for pushing code to device + +### Current Project Status +- ✅ Device: QT2040 Trinkey connected at `/Volumes/CIRCUITPY` +- ✅ Libraries: Required CircuitPython libraries installed +- ✅ Active Script: `lava-domino.py` - Random Double-16 Domino Pattern Display + +## 📁 Project Files + +### Main Scripts +- **lava-domino.py** - Random double-16 domino pattern generator for I2C 13x9 matrix +- **is31fl3741-lava-lamp.py** - Rainbow lava lamp effect +- **i2c-lava-lamp.py** - I2C lava lamp effect +- **neo-lava.py** - NeoPixel lava effect +- **neo-ambient-lava.py** - Ambient NeoPixel lava effect +- **code.py** - Base/template file +- **blinker.py** - Simple blinker example +- **serial_monitor.py** - Serial output monitoring + +### Deployment & Documentation +- **deploy.sh** - Automated deployment script +- **DEPLOYMENT_NOTES.md** - Comprehensive deployment and library guide + +### Libraries +- **adafruit-circuitpython-bundle-10.x-mpy-20250924/** - CircuitPython library bundle + +## ⚡ Quick Commands + +```bash +# Deploy any script to device +./deploy.sh [script-name.py] + +# Deploy current domino script +./deploy.sh lava-domino.py + +# Check device connection +ls -la /Volumes/CIRCUITPY/ + +# Check library status +ls -la /Volumes/CIRCUITPY/lib/ +``` + +## 🔧 Hardware Setup +- **Device**: QT2040 Trinkey +- **I2C Matrix**: 13x9 LED matrix (IS31FL3741 controller) +- **Status LED**: Built-in NeoPixel +- **Mount Point**: `/Volumes/CIRCUITPY` + +## 📚 For Detailed Information +See **DEPLOYMENT_NOTES.md** for complete deployment procedures, library management, and troubleshooting. + +--- +*Last Updated: 2025-09-24 - Random Double-16 Domino Pattern Generator deployed* diff --git a/firmware/qt2040-trinkey-circuitpython/blinker.py b/firmware/qt2040-trinkey-circuitpython/blinker.py new file mode 100644 index 0000000..9d498a3 --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/blinker.py @@ -0,0 +1,23 @@ +import time +import board +import neopixel + +print("Starting minimal test...") + +# Set up the built-in NeoPixel +pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) +pixel.brightness = 0.5 + +print("NeoPixel initialized") + +# Simple test - just set to green and stay there +pixel[0] = (0, 255, 0) +print("Set to green") + +# Simple loop - just flash between green and blue +while True: + pixel[0] = (0, 255, 0) # Green + time.sleep(1) + pixel[0] = (0, 0, 255) # Blue + time.sleep(1) + print("Loop iteration") diff --git a/firmware/qt2040-trinkey-circuitpython/double-12-dominoes.py b/firmware/qt2040-trinkey-circuitpython/double-12-dominoes.py new file mode 100644 index 0000000..67ba7a6 --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/double-12-dominoes.py @@ -0,0 +1,272 @@ + +# 00 Top choice name: domino_double12_is31fl3741.py +""" +Display random double-12 dominoes on Adafruit IS31FL3741 13x9 RGB matrix. +- Layout: see double-12-requirements.md file +- Colors: standard double-12 scheme. +""" + +import time +import random + +import board +import neopixel +import adafruit_is31fl3741 +from adafruit_is31fl3741.adafruit_rgbmatrixqt import Adafruit_RGBMatrixQT + + +# ----- Debug Config ----- +SEQUENTIAL_DEBUG = False # True # Set to True to step through values 0-12 sequentially for debugging + +# ----- Config ----- +# Layout: Normal orientation, two 5x7 halves side-by-side with 1-pixel gutter +DOMINO_LEFT_X = 1 # left margin to center 11-wide domino (5+1+5) in 13 columns +DOMINO_TOP_Y = 1 # top margin to center 7-tall domino in 9 rows +DOMINO_WIDTH = 5 # width of each domino half +DOMINO_HEIGHT = 7 # height of each domino half +GUTTER_COL = DOMINO_LEFT_X + 5 # single-column gutter at x=6 + +# Pip colors (RGB) +pip_colors = { + 0: (0, 0, 0), # blank + 1: (255, 255, 255), # white (override: single pip must be visible) + 2: (0, 255, 0), # green + 3: (255, 0, 0), # red + 4: (0, 0, 255), # blue + 5: (255, 255, 0), # yellow + 6: (128, 0, 128), # purple + 7: (139, 69, 19), # brown + 8: (255, 192, 203), # pink + 9: (255, 165, 0), # orange + 10: (0, 128, 128), # teal/aqua + 11: (173, 216, 230), # light blue + 12: (255, 255, 255), # white +} + +# Pip coordinates for values 0..12 within a 5x7 grid (r,c), 0-indexed +# Exact patterns as specified: 5=2+1+2, 6=3+0+3, 7=3+1+3, 8=3+2+3, 9=3+3+3, 10=4+2+4, 11=4+3+4, 12=4+4+4 +pip_positions = { + 0: [], # blank + 1: [(3, 2)], # center + 2: [(1, 1), (5, 3)], # diagonal corners + 3: [(1, 1), (3, 2), (5, 3)], # diagonal line + 4: [(1, 1), (1, 3), (5, 1), (5, 3)], # four corners + 5: [(1, 1), (1, 3), (3, 2), (5, 1), (5, 3)], # 2+1+2 pattern: 2 corners + 1 center + 2 corners + 6: [(1, 1), (1, 2), (1, 3), (5, 1), (5, 2), (5, 3)], # 3+0+3 pattern: 3 top + 0 middle + 3 bottom + 7: [(1, 1), (1, 2), (1, 3), (3, 2), (5, 1), (5, 2), (5, 3)], # 3+1+3 pattern: 3 top + 1 center + 3 bottom + 8: [(1, 1), (1, 2), (1, 3), (3, 1), (3, 3), (5, 1), (5, 2), (5, 3)], # 3+2+3 pattern: 3 top + 2 middle + 3 bottom + 9: [(1, 1), (1, 2), (1, 3), (3, 1), (3, 2), (3, 3), (5, 1), (5, 2), (5, 3)], # 3+3+3 pattern: 3 top + 3 middle + 3 bottom + 10: [(1, 0), (1, 1), (1, 2), (1, 3), + (3, 1), (3, 2), + (5, 0), (5, 1), (5, 2), (5, 3)], # 4+2+4 pattern: 4 top + 2 middle + 4 bottom + 11: [(1, 0), (1, 1), (1, 2), (1, 3), + (3, 1), (3, 2), (3, 3), + (5, 0), (5, 1), (5, 2), (5, 3)], # 4+3+4 pattern: 4 top + 3 middle + 4 bottom + 12: [(1, 0), (1, 1), (1, 2), (1, 3), + (3, 0), (3, 1), (3, 2), (3, 3), + (5, 0), (5, 1), (5, 2), (5, 3)], # 4+4+4 pattern: 4 top + 4 middle + 4 bottom +} + + +def rgb_to_packed(rgb_tuple): + """Convert RGB tuple to packed color value.""" + r, g, b = rgb_tuple + return (r << 16) | (g << 8) | b + + +def clear(matrix): + """Turn off all LEDs.""" + w, h = matrix.width, matrix.height + for x in range(w): + for y in range(h): + matrix.pixel(x, y, 0) # Use pixel method with packed color + + +def draw_border(matrix, origin_x, origin_y, width, height, color=(255, 255, 255)): + """Draw a border around the specified area.""" + packed_color = rgb_to_packed(color) + + # Top and bottom borders + for x in range(origin_x - 1, origin_x + width + 1): + if 0 <= x < matrix.width: + if 0 <= origin_y - 1 < matrix.height: + matrix.pixel(x, origin_y - 1, packed_color) # Top border + if 0 <= origin_y + height < matrix.height: + matrix.pixel(x, origin_y + height, packed_color) # Bottom border + + # Left and right borders + for y in range(origin_y - 1, origin_y + height + 1): + if 0 <= y < matrix.height: + if 0 <= origin_x - 1 < matrix.width: + matrix.pixel(origin_x - 1, y, packed_color) # Left border + if 0 <= origin_x + width < matrix.width: + matrix.pixel(origin_x + width, y, packed_color) # Right border + + +def draw_half(matrix, value, origin_x, origin_y): + """Draw a 5x7 pip field at (origin_x, origin_y) for the given value.""" + color = pip_colors.get(value, (255, 255, 255)) + packed_color = rgb_to_packed(color) + for (r, c) in pip_positions[value]: + x = origin_x + c + y = origin_y + r + matrix.pixel(x, y, packed_color) # Use pixel method with packed color + + +def draw_domino(matrix, left_value, right_value): + """Draw two 5x7 halves side-by-side with 1-col gutter, centered on 13x9 matrix.""" + clear(matrix) + # Left half + left_x = DOMINO_LEFT_X + left_y = DOMINO_TOP_Y + # Right half - skip gutter column + right_x = GUTTER_COL + 1 # Start right half at column 7 + right_y = DOMINO_TOP_Y + + draw_half(matrix, left_value, left_x, left_y) + draw_half(matrix, right_value, right_x, right_y) + matrix.show() # Update the display + + +def draw_domino_debug(matrix, value): + """Draw single 5x7 half with border for debug mode, centered on matrix.""" + clear(matrix) + print(f"Debug: Drawing single half for {value}") + # Center the single domino half on the matrix + debug_x = (matrix.width - DOMINO_WIDTH) // 2 # Center horizontally + debug_y = DOMINO_TOP_Y + + # Draw the border around the pip area + draw_border(matrix, debug_x, debug_y, DOMINO_WIDTH, DOMINO_HEIGHT, (32, 16, 16)) # XXX border + + # Draw the pips + draw_half(matrix, value, debug_x, debug_y) + matrix.show() # Update the display + + +# ----- Init hardware ----- +print("Initializing IS31FL3741 RGB Matrix...") + +# Initialize I2C +i2c = board.I2C() # uses board.SCL and board.SDA +print("I2C bus initialized") + +# Initialize the IS31FL3741 as RGB Matrix (13x9 matrix) +matrix = Adafruit_RGBMatrixQT(i2c, address=0x30, allocate=adafruit_is31fl3741.PREFER_BUFFER) + +# Configure the LED matrix +matrix.set_led_scaling(0xFF) # Full LED scaling +matrix.global_current = 0x80 # Set to medium current to avoid overheating +matrix.enable = True # Enable the matrix + +print(f"IS31FL3741 initialized at address 0x30") +print(f"Matrix size: {matrix.width}x{matrix.height} pixels") +print(f"Global current: {matrix.global_current}") +print(f"Enabled: {matrix.enable}") + +# Initialize NeoPixel +pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) +pixel.brightness = 0.3 +pixel[0] = (0, 255, 0) # Green for success + + +def wait_for_input_with_timeout(is_first_wait=False): + """Wait for ENTER key for up to 10 seconds initially, then auto-advance every 5 seconds.""" + import sys + + if is_first_wait: + timeout = 10.0 # 10 seconds timeout for first input + print("Press ENTER to advance (auto-advance in 10s)...") + else: + timeout = 5.0 # 5 seconds for subsequent auto-advances + print("Press ENTER to advance (auto-advance in 5s)...") + + start_time = time.time() + + try: + # Simple approach: try to read with timeout + while True: + elapsed = time.time() - start_time + + # Check if we've reached the timeout + if elapsed >= timeout: + print("Auto-advancing...") + return + + # Try to read any available input (non-blocking) + try: + # This may not work perfectly in all CircuitPython versions + if hasattr(sys.stdin, 'read'): + # Try a very short read + char = sys.stdin.read(1) if sys.stdin.readable() else None + if char and (char == '\n' or char == '\r' or len(char.strip()) > 0): + print("Input received, advancing...") + return + else: + print(char if char else ".") # XXX + except: + pass + + # Small delay to prevent busy waiting + time.sleep(0.1) + + except: + # If input handling fails, just wait the timeout period + time.sleep(timeout) + print("Auto-advancing...") + +# ----- Main loop ----- +print("Double-12 Dominoes Display Started") + +if SEQUENTIAL_DEBUG: + print("DEBUG MODE: Stepping through values 0-12 sequentially") + print("Press ENTER to advance to next domino") + debug_counter = 0 + first_iteration = True + total_iterations = 0 +else: + print("Displaying random domino combinations (0-12 | 0-12)") + print("NeoPixel will blink between left and right domino colors") + +print("=" * 40) + +while True: + if SEQUENTIAL_DEBUG: + # Step through values sequentially for debugging, starting at 1|2 + # In debug mode, cycle through single values 0-12 + value = debug_counter % 13 + debug_counter += 1 + if debug_counter > 12: # Reset after showing all values 0-12 + debug_counter = 0 + else: + # Random selection as per requirements + left = random.randint(0, 12) + right = random.randint(0, 12) + + if SEQUENTIAL_DEBUG: + print(f"Debug: {value:2d}") + draw_domino_debug(matrix, value) + + # Get color for the single value + color = pip_colors.get(value, (255, 255, 255)) + pixel[0] = color + wait_for_input_with_timeout(is_first_wait=(total_iterations == 0)) + total_iterations += 1 + else: + print(f"Domino: {left:2d} | {right:2d}") + draw_domino(matrix, left, right) + + # Get colors for each half + left_color = pip_colors.get(left, (255, 255, 255)) + right_color = pip_colors.get(right, (255, 255, 255)) + + # Normal mode: Blink NeoPixel between the two colors (5 seconds total) + # First half - left domino color + pixel[0] = left_color + time.sleep(2.5) + + # Second half - right domino color + pixel[0] = right_color + time.sleep(2.5) + + # end-0 diff --git a/firmware/qt2040-trinkey-circuitpython/double-12-requirements.md b/firmware/qt2040-trinkey-circuitpython/double-12-requirements.md new file mode 100644 index 0000000..fc8ead8 --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/double-12-requirements.md @@ -0,0 +1,40 @@ + + +# Lava Domino + +Requirements for a CircuitPython program for an Adafruit IS31FL3741 13×9 RGB LED matrix that displays random double-12 domino tiles. Requirements: + +1. Each domino consists of two halves (left and right), each a 5-wide by 7-high pip grid, separated by a 1-pixel gutter column. + - Note that due to the orientation of the 13×9 RGB LED matrix, each half of the dominue is to be displayed sideways. + - The allowable pip positions allways allow a blank pixel in between each pip, both +2. The program should randomly pick two values from 0 to 12 (inclusive), representing pip counts. +3. Draw pips in the standard double-12 symmetrical pip layout, e.g. 2 and 3 are "diagonal" + - 5 is 2 + 1 + 2 pattern with extra empty lines, to fill out to the corners. + - 6 is 3 + 0 + 3 + - 7 is 3 + 1 + 3 + - 8 is 3 + 2 + 3 + - 9 is 3 + 3 + 3 + - 10 is 4 + 2 + 4 + - 11 is 4 + 3 + 4 + - 12 is 4 + 4 + 4 +4. Light pip LEDs using the following color scheme: + - 0: blank (no pips) + - 1: white + - 2: green + - 3: red + - 4: blue + - 5: yellow + - 6: purple + - 7: brown + - 8: pink + - 9: orange + - 10: teal/aqua + - 11: light blue + - 12: white +5. Background stays off (all LEDs unlit except pips). +6. After displaying a tile for ~2-5 seconds, draw another random domino --turning off any pips as needed. +7. Organize the code so there’s a reusable function like draw_domino(left_value, right_value). +8. Blink the neopixel to reflect the color of each half. +9. After any code-changes, deploy with deploy.sh FILENAME, and monitor using the serial_monitor.py script. +10. emit domino numeric values to the serial port +11. If the SEQUENTIAL_DEBUG flag is defined/true, then serial port should prompt user to hit a key to advance numerically in order starting at 1|2. diff --git a/firmware/qt2040-trinkey-circuitpython/serial_mon.py b/firmware/qt2040-trinkey-circuitpython/serial_mon.py new file mode 100755 index 0000000..7c069f5 --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/serial_mon.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +import argparse +import re +import serial +import sys +import time +import threading +import select + +DEFAULT_TIMEOUT = 30.0 # seconds + +def parse_timeout(timeout_str): + """Parse timeout string like '5s', '10', '2.5s' and return float in seconds.""" + if timeout_str is None: + return DEFAULT_TIMEOUT # Default timeout + + if timeout_str == '0': + return float('inf') # Infinite timeout if '0' specified + + # Match patterns like "5s", "10", "2.5s" + match = re.match(r'^(\d+(?:\.\d+)?)([s]?)$', timeout_str.lower()) + if not match: + raise ValueError(f"Invalid timeout format: {timeout_str}. Use format like '5s' or '10'") + + value = float(match.group(1)) + unit = match.group(2) + + # If no unit specified, assume seconds + if unit == 's' or unit == '': + return value + + return value + +def monitor_serial(ser, stop_event): + """Thread function to monitor incoming serial data.""" + while not stop_event.is_set(): + try: + if ser.in_waiting > 0: + line = ser.readline().decode('utf-8').rstrip() + if line: + # print(f"RX: {line}") + print(f"{line}") + except UnicodeDecodeError: + # Handle any encoding issues + pass + except Exception as e: + if not stop_event.is_set(): + print(f"Error reading serial: {e}") + break + time.sleep(0.01) # Small delay to prevent excessive CPU usage + +def handle_user_input(ser, stop_event, interactive_mode): + """Thread function to handle user input for sending data.""" + if not interactive_mode: + return + + print("Interactive mode enabled. Type messages to send, or CTRL-C to exit.") + print("-" * 50) + + while not stop_event.is_set(): + try: + # Check if input is available (non-blocking) + if sys.stdin in select.select([sys.stdin], [], [], 0.1)[0]: + user_input = input().strip() + + # if user_input.lower() in ['quit', 'exit']: + # print("Exiting...") + # stop_event.set() + # break + + if user_input: + # Send the input with a newline + ser.write((user_input + '\n').encode('utf-8')) + print(f"TX: {user_input}") + + except KeyboardInterrupt: + print("\nExiting...") + stop_event.set() + break + except EOFError: + # Handle Ctrl+D + print("\nExiting...") + stop_event.set() + break + except Exception as e: + if not stop_event.is_set(): + print(f"Error handling input: {e}") + +def main(): + parser = argparse.ArgumentParser(description='Serial monitor for CircuitPython devices with optional interactive input') + parser.add_argument('--timeout', '-t', type=str, default=str(DEFAULT_TIMEOUT), + help='Duration to monitor serial port before closing (e.g., 5s, 10, 2.5s). Default: 30s, 0=infinite') + parser.add_argument('--port', '-p', type=str, default='/dev/cu.usbmodem1301', + help='Serial port. Default: /dev/cu.usbmodem1301') + parser.add_argument('--baudrate', '-b', type=int, default=115200, + help='Baud rate. Default: 115200') + parser.add_argument('--monitor-only', '-m', action='store_true', + help='Monitor only mode (disable interactive input) - useful for scripts') + + args = parser.parse_args() + + try: + monitor_timeout = parse_timeout(args.timeout) + except ValueError as e: + print(f"Error: {e}") + return 1 + + port = args.port + baudrate = args.baudrate + interactive_mode = not args.monitor_only + + try: + # Open serial connection with a short read timeout + ser = serial.Serial(port, baudrate, timeout=0.1) + print(f"Connected to {port} at {baudrate} baud") + + if interactive_mode: + print("Interactive mode: You can send data to the device") + else: + print("Monitor-only mode: Receiving data only") + + print(f"Monitoring for {monitor_timeout}s (Press Ctrl+C to exit early)" if monitor_timeout != float('inf') + else "Monitoring indefinitely (Press Ctrl+C to exit)") + print("-" * 50) + + # Create stop event for coordinating threads + stop_event = threading.Event() + + # Start monitoring thread + monitor_thread = threading.Thread(target=monitor_serial, args=(ser, stop_event)) + monitor_thread.daemon = True + monitor_thread.start() + + # Start input handling thread if in interactive mode + input_thread = None + if interactive_mode: + input_thread = threading.Thread(target=handle_user_input, args=(ser, stop_event, interactive_mode)) + input_thread.daemon = True + input_thread.start() + + start_time = time.time() + + try: + while not stop_event.is_set(): + # Check if monitor timeout has been reached + elapsed_time = time.time() - start_time + if elapsed_time >= monitor_timeout: + print(f"\nMonitoring timeout ({monitor_timeout}s) reached. Closing connection.") + break + + time.sleep(0.1) + + except KeyboardInterrupt: + print("\nExiting...") + + # Signal threads to stop + stop_event.set() + + # Wait for threads to finish (with timeout) + if monitor_thread.is_alive(): + monitor_thread.join(timeout=1.0) + if input_thread and input_thread.is_alive(): + input_thread.join(timeout=1.0) + + except serial.SerialException as e: + print(f"Error opening serial port {port}: {e}") + print("Make sure your RP2040 is connected and the port is correct.") + return 1 + + except Exception as e: + print(f"Unexpected error: {e}") + return 1 + + finally: + if 'ser' in locals() and ser.is_open: + ser.close() + print("Serial connection closed.") + + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/firmware/rp-cam/rpicam-cap.sh b/firmware/rp-cam/rpicam-cap.sh new file mode 100755 index 0000000..5031485 --- /dev/null +++ b/firmware/rp-cam/rpicam-cap.sh @@ -0,0 +1,3 @@ + +rpicam-still --rotation 180 -o "test-$(date +%s).jpg" + diff --git a/firmware/rp-cam/rpicam-list.sh b/firmware/rp-cam/rpicam-list.sh new file mode 100755 index 0000000..61840af --- /dev/null +++ b/firmware/rp-cam/rpicam-list.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +echo "=== Raspberry Pi Camera Detection ===" +echo "" + +# Check if rpicam-still is available +if ! command -v rpicam-still &> /dev/null; then + echo "❌ rpicam-still not found. Please install libcamera tools:" + echo " sudo apt update && sudo apt install -y libcamera-apps" + echo "" + exit 1 +fi + +echo "✓ rpicam-still is installed" +echo "" + +# List available cameras +echo "--- Listing Available Cameras ---" +rpicam-still --list-cameras 2>&1 + +echo "" +echo "=== Camera Troubleshooting Tips ===" +echo "" +echo "If no cameras are detected, try these steps:" +echo "" +echo "1. Enable the camera interface:" +echo " sudo raspi-config" +echo " → Interface Options → Camera → Enable" +echo "" +echo "2. Check if camera cable is properly connected:" +echo " - Power off the Pi completely" +echo " - Check that the ribbon cable is fully inserted" +echo " - Blue side of cable should face the Ethernet port" +echo "" +echo "3. Verify camera is detected by the system:" +echo " vcgencmd get_camera" +echo " (should show: supported=1 detected=1)" +echo "" +echo "4. Check for camera in device tree:" +echo " dmesg | grep -i camera" +echo "" +echo "5. Reboot after enabling camera:" +echo " sudo reboot" +echo "" +echo "6. For older Raspberry Pi OS, add to /boot/config.txt:" +echo " camera_auto_detect=1" +echo " (Then reboot)" +echo "" +echo "=== Common Camera Models ===" +echo "" +echo "• Raspberry Pi Camera Module v1 (OV5647)" +echo "• Raspberry Pi Camera Module v2 (IMX219)" +echo "• Raspberry Pi Camera Module v3 (IMX708)" +echo "• Raspberry Pi HQ Camera (IMX477)" +echo "" diff --git a/firmware/serial_ish.py b/firmware/serial_ish.py index 8ee806b..7007840 100755 --- a/firmware/serial_ish.py +++ b/firmware/serial_ish.py @@ -232,14 +232,14 @@ def main(): parser.add_argument('device', nargs='?', help='Serial device path (e.g., /dev/cu.usbserial-110, COM1). If not specified, uses cached port from last run.') parser.add_argument('baudrate', nargs='?', type=int, default=None, help='Baud rate for serial communication (default: uses cached value or 230400)') - parser.add_argument('duration', nargs='?', type=float, default=5.0, - help='Duration in seconds to read data (default: 5.0, ignored in interactive mode)') + parser.add_argument('pos_duration', nargs='?', type=float, default=None, + help='Duration in seconds to read data (positional, default: 5.0, ignored in interactive mode)') parser.add_argument('-i', '--interactive', action='store_true', help='Enable interactive mode to send commands') parser.add_argument('-s', '--send', type=str, metavar='MESSAGE', help='Send a message/command to the serial port and optionally read response') - parser.add_argument('-d', '--duration', type=float, dest='send_duration', - help='Duration to read response after sending (use with --send, default: 2.0 seconds)') + parser.add_argument('-d', '--duration', '-t', '--timeout', type=float, dest='duration', + help='Duration to read response in seconds (default: 2.0 for --send mode, 5.0 for read mode)') args = parser.parse_args() @@ -273,15 +273,23 @@ def main(): elif baudrate != cached_baudrate: # Baudrate changed but port is from cache, still save save_cached_port(port, baudrate) - duration = args.duration + + # Determine duration: --duration flag takes precedence, then positional, then defaults + if args.duration is not None: + duration = args.duration + elif args.pos_duration is not None: + duration = args.pos_duration + else: + # Default duration depends on mode + if args.send: + duration = 2.0 + else: + duration = 5.0 print(f"Opening serial port {port} at {baudrate} baud...") - # Determine response duration for send mode - if args.send: - send_duration = args.send_duration if args.send_duration is not None else 2.0 - else: - send_duration = 0 + # Use the same duration for send mode + send_duration = duration try: with serial.Serial(port, baudrate, timeout=0.1) as ser: From 7d6baa6da4192152c769562d696fb6638ee7166d Mon Sep 17 00:00:00 2001 From: Markos Hudson Date: Tue, 9 Dec 2025 19:52:13 -0800 Subject: [PATCH 06/12] dynamic self-deploying i2c_scanner.py --- firmware/.roo/agent_rules.md | 7 +- firmware/i2c_scanner.md | 73 +++++++++ firmware/i2c_scanner.py | 307 +++++++++++++++++++++++++++++++++++ 3 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 firmware/i2c_scanner.md create mode 100755 firmware/i2c_scanner.py diff --git a/firmware/.roo/agent_rules.md b/firmware/.roo/agent_rules.md index 1776a57..d4ed7bd 100644 --- a/firmware/.roo/agent_rules.md +++ b/firmware/.roo/agent_rules.md @@ -1,5 +1,5 @@ -# Rules +# Agentic Coding Rules ## Style @@ -7,3 +7,8 @@ 2. More MarkDown rules: Lists should be surrounded by blank lines. 3. Remember to number the Todo lists. +## Tool Preferences + +1. For CircuitPython or MicroPython projects, prefer `mpremote` instead of `ampy`. +2. For virtual COM port serial monitoring in macOS, prefer the port name style /dev/cu.usbserial-110 instead of tty. + diff --git a/firmware/i2c_scanner.md b/firmware/i2c_scanner.md new file mode 100644 index 0000000..844b087 --- /dev/null +++ b/firmware/i2c_scanner.md @@ -0,0 +1,73 @@ +# Session Notes - 2025-12-09-174545 + +## Summary + +Enhanced `utils/i2c_scanner.py` with command-line argument parsing and improved user experience. + +## Changes Made + +### File Modified + +- `utils/i2c_scanner.py` + +### Improvements + +1. **Added `--help` functionality:** + - Imported `argparse` module for proper CLI argument handling + - Created comprehensive help documentation with: + - Usage examples + - Feature descriptions + - Requirements list + - Added `--port` (`-p`) option for manual serial port specification + - Added `--baud` (`-b`) option for custom baud rate (default: 115200) + +2. **Display detected device before connection:** + - Added `identify_board()` function to identify USB-to-UART chip type + - Detects common chips: CP2102, CH340, and generic USB-to-UART + - Shows clear summary before attempting connection: + ``` + ✓ Detected: ESP32 DevKit (CP2102 USB-to-UART) + Port: /dev/tty.usbserial-130 + Baud rate: 115200 + ``` + +3. **Added user confirmation prompt:** + - Script now displays device summary and prompts: "Press ENTER to continue or Ctrl-C to cancel..." + - Prevents automatic execution without user awareness + - Allows users to verify correct device before uploading code + +4. **Fixed code upload behavior:** + - Replaced full script upload with targeted ESP32 code section + - Eliminates display of host-side Python code on ESP32 console + - Cleaner output showing only I2C scan results + +## Testing Performed + +- ✅ Verified `--help` displays comprehensive usage information +- ✅ Confirmed device detection and summary display +- ✅ Tested user prompt waits for input before proceeding +- ✅ Validated Ctrl-C cancellation works correctly + +## Before/After Behavior + +**Before:** +- No `--help` available +- Immediately connected and uploaded entire script +- Displayed screensfuls of Python code on ESP32 + +**After:** +- Full `--help` with examples and options +- Shows device summary before connection +- Prompts user for confirmation +- Uploads only necessary ESP32 code + +## Impact + +- Improved usability for test engineers +- Better visibility of connected hardware +- Safer operation with confirmation step +- Professional CLI interface consistent with other utilities + +## Next Steps + +None required - script is fully functional and improved. diff --git a/firmware/i2c_scanner.py b/firmware/i2c_scanner.py new file mode 100755 index 0000000..2391e10 --- /dev/null +++ b/firmware/i2c_scanner.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Smart I2C Scanner - Self-deploying script for ESP32 +Detects if running on host (Mac) or target (ESP32) and acts accordingly +""" + +import sys +import time +import argparse + +# Detect environment by trying to import machine module +try: + from machine import Pin, I2C + RUNNING_ON_ESP32 = True +except ImportError: + RUNNING_ON_ESP32 = False + +# ============================================================================ +# ESP32 CODE - Runs when deployed to the device +# ============================================================================ +if RUNNING_ON_ESP32: + print("\n" + "="*50) + print("Running on ESP32 - Starting I2C scan...") + print("="*50 + "\n") + + # Common I2C pin configurations for ESP32 dev boards + configs = [ + {"scl": 22, "sda": 21, "freq": 400000, "name": "Standard ESP32"}, + {"scl": 15, "sda": 4, "freq": 400000, "name": "OLED variant 1"}, + {"scl": 5, "sda": 4, "freq": 400000, "name": "OLED variant 2"}, + {"scl": 14, "sda": 2, "freq": 400000, "name": "Alternative config"}, + ] + + found_any = False + + for idx, config in enumerate(configs): + try: + print(f"Config {idx+1} ({config['name']}): SCL=GPIO{config['scl']}, SDA=GPIO{config['sda']}") + i2c = I2C(0, scl=Pin(config['scl']), sda=Pin(config['sda']), freq=config['freq']) + + devices = i2c.scan() + + if devices: + found_any = True + print(f" ✓ SUCCESS! Found {len(devices)} device(s):") + for device in devices: + print(f" • I2C Address: 0x{device:02X} (decimal {device})") + + # Identify common devices + if device in [0x3C, 0x3D]: + print(f" → SSD1306 OLED Display (128x64 or 128x32)") + elif device == 0x78: + print(f" → Possible OLED (7-bit shifted address)") + elif device in [0x68, 0x69]: + print(f" → MPU6050 or DS3231 RTC") + elif device == 0x76 or device == 0x77: + print(f" → BMP280/BME280 Sensor") + print() + else: + print(f" ✗ No devices found\n") + + except Exception as e: + print(f" ✗ Error: {e}\n") + + if not found_any: + print("⚠ No I2C devices detected on any configuration") + print(" Check: 1) Device is powered, 2) Correct pins, 3) Pull-up resistors") + + print("="*50) + print("Scan complete!") + print("="*50) + +# ============================================================================ +# HOST CODE - Runs on Mac/Linux to deploy to ESP32 +# ============================================================================ +else: + import os + import glob + + # Parse command line arguments + parser = argparse.ArgumentParser( + description='Smart I2C Scanner - Self-deploying script for ESP32', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=''' +Examples: + %(prog)s # Auto-detect and scan ESP32 + %(prog)s --port /dev/ttyUSB0 # Use specific port + %(prog)s --baud 9600 # Use different baud rate + +Features: + • Automatically detects connected ESP32 development boards + • Displays board information before connecting + • Uploads and executes I2C scanner on the device + • Scans multiple common pin configurations + • Identifies known I2C devices (OLED, sensors, etc.) + +Requirements: + • Python 3.x + • pyserial (install: pip3 install pyserial) + • ESP32 with MicroPython firmware + ''' + ) + parser.add_argument('--port', '-p', + help='Serial port (default: auto-detect)') + parser.add_argument('--baud', '-b', type=int, default=115200, + help='Baud rate (default: 115200)') + + args = parser.parse_args() + + print("\n" + "="*50) + print("Smart I2C Scanner - Host Mode") + print("="*50 + "\n") + + # Auto-detect ESP32 serial port + def find_esp32_port(): + """Find ESP32 serial port on Mac/Linux""" + patterns = [ + '/dev/tty.usbserial*', + '/dev/tty.SLAB_USBtoUART*', + '/dev/tty.wchusbserial*', + '/dev/cu.usbserial*', + ] + + for pattern in patterns: + ports = glob.glob(pattern) + if ports: + return ports[0] + return None + + def identify_board(port_name): + """Identify the type of dev board based on port name""" + if 'usbserial' in port_name.lower(): + if 'SLAB' in port_name or 'CP210' in port_name: + return "ESP32 DevKit (CP2102 USB-to-UART)" + elif 'wchusbserial' in port_name: + return "ESP32 DevKit (CH340 USB-to-UART)" + else: + return "ESP32 DevKit (Generic USB-to-UART)" + return "Unknown ESP32/USB Serial Device" + + if args.port: + port = args.port + print(f"✓ Using specified port: {port}") + else: + port = find_esp32_port() + + if not port: + print("❌ No ESP32 detected!") + print("\nSearching for USB serial devices:") + all_ports = glob.glob('/dev/tty.*usb*') + glob.glob('/dev/cu.*usb*') + if all_ports: + print("Found these USB devices:") + for p in all_ports: + print(f" • {p}") + port = all_ports[0] + print(f"\n⚠ Using first device: {port}") + else: + print(" (none found)") + print("\nPlease:") + print(" 1. Connect your ESP32 via USB") + print(" 2. Install USB drivers if needed (CH340, CP2102, etc.)") + print(" 3. Run with --help for more options") + sys.exit(1) + + # Display detected board information + board_type = identify_board(port) + print(f"✓ Detected: {board_type}") + print(f" Port: {port}") + print(f" Baud rate: {args.baud}") + print("\nReady to upload I2C scanner to device.") + + # Prompt user to continue + try: + response = input("\nPress ENTER to continue or Ctrl-C to cancel... ") + except KeyboardInterrupt: + print("\n\n⚠ Cancelled by user") + sys.exit(0) + + # Import serial library + try: + import serial + except ImportError: + print("\n❌ PySerial not installed!") + print("Run: pip3 install --user pyserial") + sys.exit(1) + + print(f"\n✓ Connecting to device...") + + try: + ser = serial.Serial(port, args.baud, timeout=2) + time.sleep(0.5) + + # Interrupt any running program + print("✓ Interrupting current program...") + ser.write(b'\x03\x03') # Ctrl-C twice + time.sleep(0.5) + ser.read(ser.in_waiting) # Clear buffer + + # Enter paste mode for reliable multi-line upload + print("✓ Entering paste mode...") + ser.write(b'\x05') # Ctrl-E: paste mode + time.sleep(0.3) + + # Upload only the ESP32 code section + print("✓ Uploading I2C scanner code...") + esp32_code = ''' +from machine import Pin, I2C +import time + +print("\\n" + "="*50) +print("Running on ESP32 - Starting I2C scan...") +print("="*50 + "\\n") + +# Common I2C pin configurations for ESP32 dev boards +configs = [ + {"scl": 22, "sda": 21, "freq": 400000, "name": "Standard ESP32"}, + {"scl": 15, "sda": 4, "freq": 400000, "name": "OLED variant 1"}, + {"scl": 5, "sda": 4, "freq": 400000, "name": "OLED variant 2"}, + {"scl": 14, "sda": 2, "freq": 400000, "name": "Alternative config"}, +] + +found_any = False + +for idx, config in enumerate(configs): + try: + print(f"Config {idx+1} ({config['name']}): SCL=GPIO{config['scl']}, SDA=GPIO{config['sda']}") + i2c = I2C(0, scl=Pin(config['scl']), sda=Pin(config['sda']), freq=config['freq']) + + devices = i2c.scan() + + if devices: + found_any = True + print(f" ✓ SUCCESS! Found {len(devices)} device(s):") + for device in devices: + print(f" • I2C Address: 0x{device:02X} (decimal {device})") + + # Identify common devices + if device in [0x3C, 0x3D]: + print(f" → SSD1306 OLED Display (128x64 or 128x32)") + elif device == 0x78: + print(f" → Possible OLED (7-bit shifted address)") + elif device in [0x68, 0x69]: + print(f" → MPU6050 or DS3231 RTC") + elif device == 0x76 or device == 0x77: + print(f" → BMP280/BME280 Sensor") + print() + else: + print(f" ✗ No devices found\\n") + + except Exception as e: + print(f" ✗ Error: {e}\\n") + +if not found_any: + print("⚠ No I2C devices detected on any configuration") + print(" Check: 1) Device is powered, 2) Correct pins, 3) Pull-up resistors") + +print("="*50) +print("Scan complete!") +print("="*50) +''' + + ser.write(esp32_code.encode('utf-8')) + time.sleep(0.2) + + # Exit paste mode and execute + ser.write(b'\x04') # Ctrl-D: execute + time.sleep(0.5) + + print("✓ Executing on ESP32...\n") + print("="*50) + print("ESP32 OUTPUT:") + print("="*50) + + # Read output for 5 seconds + start_time = time.time() + output_buffer = "" + + while time.time() - start_time < 5: + if ser.in_waiting: + chunk = ser.read(ser.in_waiting).decode('utf-8', errors='ignore') + output_buffer += chunk + print(chunk, end='', flush=True) + time.sleep(0.1) + + print("\n" + "="*50) + + # Check if we got meaningful output + if "I2C Address:" in output_buffer or "0x" in output_buffer: + print("\n✅ Scan completed successfully!") + elif "Error" in output_buffer or "not found" in output_buffer.lower(): + print("\n⚠ Scan completed with errors (see output above)") + else: + print("\n⚠ Unexpected output - device may need reset") + + ser.close() + + except serial.SerialException as e: + print(f"\n❌ Serial error: {e}") + print("\nTroubleshooting:") + print(" 1. Check if another program is using the port") + print(" 2. Try pressing the EN/RST button on the ESP32") + print(" 3. Disconnect and reconnect the USB cable") + sys.exit(1) + except KeyboardInterrupt: + print("\n\n⚠ Interrupted by user") + ser.close() + sys.exit(0) From a5541ba3ba7345e9dccf0febf9fab19eb78755d7 Mon Sep 17 00:00:00 2001 From: Markos Hudson Date: Wed, 10 Dec 2025 21:06:37 -0800 Subject: [PATCH 07/12] WIP: RP2350 CircuitPython --- .gitignore | 4 + firmware/rp2350/.roo/rules.md | 23 ++ firmware/rp2350/README.md | 249 ++++++++++++++++++ firmware/rp2350/docs/progress.md | 201 +++++++++++++++ firmware/rp2350/docs/requirements.md | 132 ++++++++++ firmware/rp2350/requirements.txt | 8 + firmware/rp2350/rp2350_AGENT.md | 221 ++++++++++++++++ firmware/rp2350/rp2350_code.py | 168 ++++++++++++ firmware/rp2350/scripts/detect_board.py | 281 +++++++++++++++++++++ firmware/rp2350/scripts/read_board_code.py | 94 +++++++ 10 files changed, 1381 insertions(+) create mode 100644 firmware/rp2350/.roo/rules.md create mode 100644 firmware/rp2350/README.md create mode 100644 firmware/rp2350/docs/progress.md create mode 100644 firmware/rp2350/docs/requirements.md create mode 100644 firmware/rp2350/requirements.txt create mode 100644 firmware/rp2350/rp2350_AGENT.md create mode 100755 firmware/rp2350/rp2350_code.py create mode 100755 firmware/rp2350/scripts/detect_board.py create mode 100644 firmware/rp2350/scripts/read_board_code.py diff --git a/.gitignore b/.gitignore index 6fa7c9c..0deabdf 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ pip-log.txt # Mac OSX Finder metadata *.DS_Store + +# Pre-built firmware files and libraries +firmware/rp2350_files/ + diff --git a/firmware/rp2350/.roo/rules.md b/firmware/rp2350/.roo/rules.md new file mode 100644 index 0000000..a1feac7 --- /dev/null +++ b/firmware/rp2350/.roo/rules.md @@ -0,0 +1,23 @@ + +# Intro and Summary + +## About This Project Context + +This is a firmware project for a RP2350 microcontroller device for debugging and automated testing of an in-house DUT that uses a custom RS485 UBUS protocol. + +## Security Requirements + +1. Never commit API keys or secrets +2. Sanitize all user inputs +3. Use HTTPS for all external requests + +## Testing Standards + +1. Ideally, any major code changes should be verified by a test run. + +## Coding Standards + +1. Python should be PEP8 within reason. (Lines as long as 100 chars are allowed.) +2. MarkDown should contain a blank line after headings. +3. Numbered lists are preferred over bullets. + diff --git a/firmware/rp2350/README.md b/firmware/rp2350/README.md new file mode 100644 index 0000000..abd4bb2 --- /dev/null +++ b/firmware/rp2350/README.md @@ -0,0 +1,249 @@ +# RP2350 Development Project + +A Python-based development toolkit for RP2350 microcontroller boards, including the Raspberry Pi Pico 2 and Adafruit Fruit Jam. + +## Project Status + +🟢 **Active Development** - Phase 1: Device Detection Complete + +## Features + +### Current (Phase 1) +- ✅ **USB Device Detection**: Automatically detect and identify connected RP2350 boards +- ✅ **Board Identification**: Recognize Raspberry Pi Pico 2 and Adafruit Fruit Jam boards +- ✅ **Device Information**: Display VID/PID, serial number, and port information +- ✅ **Cross-Platform**: Works on macOS, Linux, and Windows + +### Planned +- 🔄 Serial communication and REPL access +- 🔄 Automated code deployment +- 🔄 Multi-board management +- 🔄 Firmware update tools + +## Quick Start + +### Installation + +1. **Clone or navigate to the project directory:** + ```bash + cd rp2350 + ``` + +2. **Install dependencies:** + ```bash + pip install -r requirements.txt + ``` + +### Usage + +#### Detect Connected Boards + +Basic detection: +```bash +python scripts/detect_board.py +``` + +Verbose output (shows all USB devices): +```bash +python scripts/detect_board.py -v +``` + +List all serial ports: +```bash +python scripts/detect_board.py --list-all +``` + +#### Example Output + +``` +🔍 Scanning for RP2350-based development boards... + +====================================================================== +RP2350 BOARD DETECTION RESULTS +====================================================================== + +✅ Found 1 RP2350 board(s): + +────────────────────────────────────────────────────────────────────── +Device #1 +────────────────────────────────────────────────────────────────────── +Board Found: Adafruit Fruit Jam RP2350 - CircuitPython + Port: /dev/cu.usbmodem214301 + VID:PID: 239A:CAFE + Manufacturer: Adafruit + Product: Fruit Jam RP2350 + Serial Number: 22621FD029CF9CE7 + Description: Fruit Jam RP2350 + +====================================================================== +``` + +## Supported Boards + +### Raspberry Pi Pico 2 (RP2350) +- **Vendor ID**: 0x2E8A +- **Product IDs**: + - 0x000F: Boot Mode (BOOTSEL) + - 0x1000: Application Mode + - 0x0005: MicroPython + - 0x000A: CircuitPython + +### Adafruit Fruit Jam (RP2350) +- **Vendor ID**: 0x239A +- **Product IDs**: + - 0xCAFE: CircuitPython Mode + - 0x00F1: CircuitPython (alternate) + - 0x0101: Boot Mode + +## Project Structure + +``` +rp2350/ +├── README.md # This file +├── requirements.txt # Python dependencies +├── docs/ # Documentation +│ ├── requirements.md # Project requirements specification +│ └── progress.md # Development progress tracking +└── scripts/ # Python scripts + └── detect_board.py # USB device detection script +``` + +## Requirements + +### Software +- Python 3.8 or higher +- pyserial >= 3.5 +- pyusb >= 1.2.1 (optional, for detailed USB info) + +### Hardware +- Raspberry Pi Pico 2 or Adafruit Fruit Jam development board +- USB cable (Type-C for Pico 2) + +### Operating System +- macOS 11.0+ +- Windows 10+ +- Linux (recent kernel with USB support) + +## Installation Notes + +### macOS +No additional drivers needed. USB devices should be automatically recognized. + +### Linux +You may need to add udev rules for USB device access without sudo: + +```bash +# Create udev rule (adjust VID/PID as needed) +echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="2e8a", MODE="0666"' | sudo tee /etc/udev/rules.d/99-pico.rules +echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="239a", MODE="0666"' | sudo tee -a /etc/udev/rules.d/99-pico.rules +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +### Windows +Install the appropriate USB drivers for your board: +- Raspberry Pi Pico: Usually works with built-in Windows drivers +- Adafruit boards: May require [Adafruit drivers](https://learn.adafruit.com/welcome-to-circuitpython/installing-circuitpython#windows-7-drivers-3-7) + +## Development + +### Running Tests + +The detection script can be tested with any connected RP2350 board: + +```bash +# Make the script executable (macOS/Linux) +chmod +x scripts/detect_board.py + +# Run detection +python scripts/detect_board.py +``` + +### Adding Support for New Boards + +To add support for a new RP2350-based board, edit [`scripts/detect_board.py`](scripts/detect_board.py) and add the VID/PID to the `KNOWN_BOARDS` dictionary: + +```python +KNOWN_BOARDS = { + # Add your board here + (0xVVVV, 0xPPPP): "Your Board Name - Mode", + ... +} +``` + +## Documentation + +- [**Requirements Specification**](docs/requirements.md) - Detailed project requirements +- [**Progress Tracking**](docs/progress.md) - Development status and roadmap +- [RP2350 Datasheet](https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf) +- [CircuitPython Documentation](https://docs.circuitpython.org/) + +## Troubleshooting + +### No boards detected + +1. **Check USB connection**: Ensure the board is properly connected +2. **Try BOOTSEL mode**: Hold BOOTSEL button while plugging in the board +3. **Check drivers**: Verify USB drivers are installed (Windows) +4. **Check permissions**: On Linux, ensure you have USB device permissions +5. **Verify dependencies**: Run `pip install -r requirements.txt` + +### Script errors + +If you see import errors: +```bash +pip install --upgrade pyserial pyusb +``` + +### Permission denied (Linux) + +```bash +# Run with sudo (temporary) +sudo python scripts/detect_board.py + +# Or add udev rules (permanent, see Installation Notes above) +``` + +## Contributing + +This is a development project. Feel free to: +- Report issues with board detection +- Submit VID/PID values for additional boards +- Suggest new features + +## License + +This project is provided as-is for educational and development purposes. + +## Changelog + +### Version 1.0.0 (2025-10-02) +- ✅ Initial project setup +- ✅ USB device detection implementation +- ✅ Support for Raspberry Pi Pico 2 +- ✅ Support for Adafruit Fruit Jam +- ✅ Comprehensive documentation +- ✅ Cross-platform compatibility + +## Roadmap + +### Phase 2: Serial Communication +- Interactive REPL access +- Serial monitor functionality +- Configurable baud rates + +### Phase 3: Code Deployment +- File transfer to boards +- Automated deployment scripts +- Configuration management + +### Phase 4: Advanced Features +- Multi-board orchestration +- Firmware update automation +- Debug tools integration + +--- + +**Project Created:** 2025-10-02 +**Last Updated:** 2025-10-02 +**Status:** Active Development diff --git a/firmware/rp2350/docs/progress.md b/firmware/rp2350/docs/progress.md new file mode 100644 index 0000000..e047e23 --- /dev/null +++ b/firmware/rp2350/docs/progress.md @@ -0,0 +1,201 @@ +# RP2350 Project Progress Tracking + +## Project Status: 🟢 Active + +**Last Updated:** 2025-10-02 +**Current Phase:** Phase 1 - Device Detection + +--- + +## Overview +This document tracks the development progress of the RP2350 firmware project, including completed tasks, current work, and upcoming milestones. + +--- + +## Milestones + +### Phase 1: Device Detection & Project Setup ✅ In Progress +**Goal:** Create project structure and implement USB device detection +**Start Date:** 2025-10-02 +**Target Completion:** TBD + +#### Tasks +- [x] Create project directory structure +- [x] Create requirements documentation template +- [x] Create progress tracking document +- [ ] Implement USB device discovery script +- [ ] Test with Raspberry Pi Pico 2 +- [ ] Test with Adafruit Fruit Jam +- [ ] Document device identification results + +### Phase 2: Serial Communication (Planned) +**Goal:** Establish serial communication with detected boards +**Start Date:** TBD +**Target Completion:** TBD + +#### Tasks +- [ ] Implement serial port connection +- [ ] Create REPL interface +- [ ] Add command-line arguments for baud rate selection +- [ ] Test bidirectional communication + +### Phase 3: Code Deployment (Planned) +**Goal:** Automate code deployment to boards +**Start Date:** TBD +**Target Completion:** TBD + +#### Tasks +- [ ] Implement file transfer protocol +- [ ] Add support for directory synchronization +- [ ] Create deployment configuration system +- [ ] Test deployment workflows + +--- + +## Recent Activity + +### 2025-10-02 +**Session 1: Project Initialization** +- ✅ Created `rp2350/` project directory +- ✅ Created `rp2350/scripts/` for Python scripts +- ✅ Created `rp2350/docs/` for documentation +- ✅ Created comprehensive requirements document ([`requirements.md`](requirements.md)) +- ✅ Created this progress tracking document +- 🚧 Started USB device discovery script implementation + +--- + +## Current Work + +### Active Tasks +1. **USB Device Discovery Script** (In Progress) + - Implementing Python script to detect RP2350-based boards + - Using pyserial and pyusb libraries + - Target boards: Pico 2 and Fruit Jam + +### Blocked Items +None currently + +### Pending Review +None currently + +--- + +## Technical Decisions + +### Decision Log +| Date | Decision | Rationale | Impact | +|------|----------|-----------|--------| +| 2025-10-02 | Use Python for device detection | Cross-platform compatibility, rich USB library support | Requires Python runtime on all platforms | +| 2025-10-02 | Support both pyserial and pyusb | pyserial for ports, pyusb for detailed device info | More comprehensive device identification | + +--- + +## Issues & Risks + +### Open Issues +None currently + +### Risks +| Risk | Severity | Mitigation | +|------|----------|------------| +| Platform-specific USB drivers | Medium | Document driver installation per platform | +| Device detection false positives | Low | Use specific VID/PID matching | +| Multi-board detection complexity | Medium | Design for single board initially, extend later | + +--- + +## Metrics + +### Development Velocity +- **Tasks Completed:** 3 +- **Tasks In Progress:** 1 +- **Tasks Pending:** 4 +- **Completion Rate:** 37.5% + +### Code Statistics +- **Python Scripts:** 0 (1 in progress) +- **Documentation Files:** 2 +- **Total Lines of Code:** 0 + +--- + +## Testing Status + +### Test Coverage +Not yet applicable - initial development phase + +### Test Results +| Test Suite | Status | Last Run | Pass Rate | +|------------|--------|----------|-----------| +| USB Detection | Not Started | N/A | N/A | +| Serial Communication | Not Started | N/A | N/A | + +--- + +## Dependencies Status + +### External Libraries +| Library | Version Required | Status | Notes | +|---------|-----------------|--------|-------| +| pyserial | >=3.5 | ⏳ Pending | To be installed | +| pyusb | >=1.2.1 | ⏳ Pending | To be installed | + +### Hardware +| Device | Status | Notes | +|--------|--------|-------| +| Raspberry Pi Pico 2 | ⏳ Awaiting Testing | Need physical device | +| Adafruit Fruit Jam | ⏳ Awaiting Testing | Need physical device | + +--- + +## Next Steps + +### Immediate (This Week) +1. Complete USB device discovery script +2. Test script with available hardware +3. Document VID/PID values for both boards +4. Create requirements.txt for Python dependencies + +### Short Term (Next 2 Weeks) +1. Implement serial communication +2. Add error handling and logging +3. Create user documentation +4. Set up automated testing framework + +### Long Term (Next Month) +1. Implement code deployment features +2. Add multi-board support +3. Create configuration management system +4. Develop example firmware projects + +--- + +## Resources + +### Documentation +- [`requirements.md`](requirements.md) - Project requirements specification +- Script documentation (pending) + +### External References +- [RP2350 Datasheet](https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf) +- [PySerial Documentation](https://pyserial.readthedocs.io/) +- [PyUSB Tutorial](https://github.com/pyusb/pyusb/blob/master/docs/tutorial.rst) + +--- + +## Notes +- Project follows Python best practices and PEP 8 style guidelines +- All code should be compatible with Python 3.8+ +- Documentation maintained in Markdown format +- Version control with Git (if applicable) + +--- + +## Changelog + +### Version 1.0.0 (2025-10-02) +- Initial project setup +- Created project structure +- Created requirements and progress documentation +- Started USB device detection implementation diff --git a/firmware/rp2350/docs/requirements.md b/firmware/rp2350/docs/requirements.md new file mode 100644 index 0000000..c7da32c --- /dev/null +++ b/firmware/rp2350/docs/requirements.md @@ -0,0 +1,132 @@ +# RP2350 Project Requirements + +## Project Overview + +**Project Name:** RP2350 Development Project +**Target Platform:** RP2350 (Raspberry Pi Pico 2 / Adafruit Fruit Jam) +**Language:** Python / CircuitPython / MicroPython +**Creation Date:** 2025-10-02 + +## Objectives +- Develop firmware for RP2350-based development boards +- Support for Raspberry Pi Pico 2 and Adafruit Fruit Jam boards +- Provide automated USB device detection and identification + +## Hardware Requirements + +### Supported Development Boards +- [ ] Raspberry Pi Pico 2 (RP2350) +- [ ] Adafruit Fruit Jam (RP2350-based) + +### USB Connection +- [ ] USB Type-C cable for Pico 2 +- [ ] USB connection for Fruit Jam board + +## Software Requirements + +### Development Environment +- [ ] Python 3.8 or higher +- [ ] pyserial library for USB serial communication +- [ ] pyusb library for USB device enumeration + +### Firmware Tools +- [ ] CircuitPython or MicroPython firmware +- [ ] Development IDE (VS Code recommended) +- [ ] Serial monitor tool + +## Functional Requirements + +### Device Detection (Phase 1) +- [ ] Automatically detect USB-connected RP2350 boards +- [ ] Identify board type (Pico 2 vs Fruit Jam) +- [ ] Display board information (VID, PID, serial number) +- [ ] List available serial ports + +### Development Features (Future Phases) +- [ ] Code deployment automation +- [ ] Serial communication interface +- [ ] Firmware update capability +- [ ] Debug console integration + +## Technical Specifications + +### USB Device Identifiers +**Raspberry Pi Pico 2:** +- Vendor ID (VID): 0x2E8A +- Product ID (PID): TBD based on mode +- Manufacturer: Raspberry Pi + +**Adafruit Fruit Jam:** +- Vendor ID (VID): 0x239A +- Product ID (PID): TBD based on board revision +- Manufacturer: Adafruit + +### Serial Communication +- Baud Rate: 115200 (default) +- Data Bits: 8 +- Stop Bits: 1 +- Parity: None + +## Non-Functional Requirements + +### Performance +- Device detection should complete within 5 seconds +- Support for multiple simultaneous board connections + +### Usability +- Clear console output with board identification +- Error messages for common issues (no device found, driver issues) +- Cross-platform support (macOS, Linux, Windows) + +### Reliability +- Graceful handling of device disconnection +- Timeout handling for unresponsive devices + +## Dependencies + +### Python Libraries +``` +pyserial>=3.5 +pyusb>=1.2.1 +``` + +### System Requirements +- macOS 11.0+ / Windows 10+ / Linux (recent kernel) +- USB 2.0 or higher ports +- Appropriate USB drivers installed + +## Constraints and Limitations +- Requires physical USB connection (no wireless support in Phase 1) +- Device must be in appropriate mode for detection +- Platform-specific USB permission requirements may apply + +## Success Criteria +- [ ] Script successfully detects Pico 2 when connected +- [ ] Script successfully detects Fruit Jam when connected +- [ ] Correct board identification with vendor/product information +- [ ] No false positives from other USB devices +- [ ] Clear user feedback for all detection scenarios + +## Out of Scope (Current Phase) +- Firmware programming/flashing +- Network connectivity features +- GUI application +- Advanced debugging features + +## Future Enhancements +- Support for additional RP2350-based boards +- Automated firmware deployment +- Interactive REPL access +- Board configuration management +- Multi-board orchestration + +## References +- [RP2350 Datasheet](https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf) +- [Raspberry Pi Pico 2 Documentation](https://www.raspberrypi.com/documentation/microcontrollers/pico-series.html) +- [Adafruit Fruit Jam Product Page](https://www.adafruit.com/) +- [CircuitPython Documentation](https://docs.circuitpython.org/) + +## Revision History +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2025-10-02 | Initial | Project requirements created | diff --git a/firmware/rp2350/requirements.txt b/firmware/rp2350/requirements.txt new file mode 100644 index 0000000..bb0bb7f --- /dev/null +++ b/firmware/rp2350/requirements.txt @@ -0,0 +1,8 @@ +# RP2350 Project Python Dependencies +# Install with: pip install -r requirements.txt + +# Serial communication (required) +pyserial>=3.5 + +# USB device enumeration (optional, for detailed device info) +pyusb>=1.2.1 diff --git a/firmware/rp2350/rp2350_AGENT.md b/firmware/rp2350/rp2350_AGENT.md new file mode 100644 index 0000000..757ae40 --- /dev/null +++ b/firmware/rp2350/rp2350_AGENT.md @@ -0,0 +1,221 @@ +# Pimoroni Tiny 2350 Development Guide + +## Quick Start + +**Device:** Pimoroni Tiny 2350 (RP2350 dual-core microcontroller) +**Firmware:** CircuitPython 10.0.3 +**Location:** `/dev/sdc` (when mounted) or `/mnt/rp2350/` (CIRCUITPY filesystem) +**USB Port:** /dev/ttyACM0 + +## Device Status + +- ✅ CircuitPython firmware installed +- ✅ OLED display support libraries available (adafruit_ssd1306.mpy, adafruit_displayio_ssd1306.mpy) +- ✅ I2C scanner and display utilities configured +- ✅ Font file (font5x8.bin) available + +## Current Code + +**Active script:** `code.py` on device (copied from `rp2350_code.py`) + +The script: +1. Detects CircuitPython environment on RP2350 +2. Initializes I2C bus (board.SCL, board.SDA) +3. Initializes onboard LED (GPIO25) +4. Attempts to initialize OLED display at I2C address 0x3C (0.91" 128x32) +5. Scans for I2C devices and logs results to boot_out.txt +6. Displays status on OLED ("RP2350 Ready!", "OLED: Online", etc.) +7. Blinks LED based on number of devices found + +**Backup:** `code.py.bak` contains original I2C scanner code (from CircuitPython first boot) + +## Filesystem Layout + +``` +/mnt/rp2350/ +├── code.py # Active CircuitPython script +├── code.py.bak # Original code (I2C scanner) +├── boot_out.txt # Boot log and runtime output +├── settings.toml # CircuitPython settings +├── font5x8.bin # Bitmap font for OLED +├── lib/ +│ ├── adafruit_ssd1306.mpy +│ ├── adafruit_displayio_ssd1306.mpy +│ ├── adafruit_bus_device/ +│ └── adafruit_display_text/ +└── sd/ # SD card mount point (if available) +``` + +## Mounting the Device + +### Automatic Mount (Recommended) +```bash +# Device should auto-mount to /mnt/rp2350 when connected +lsblk # Verify /dev/sdc appears +``` + +### Manual Mount +```bash +# Enter bootloader mode: Hold BOOTSEL, press RESET +# Then mount: +sudo mkdir -p /mnt/rp2350 +sudo mount /dev/sdc1 /mnt/rp2350 + +# Unmount when done: +sudo umount /mnt/rp2350 +``` + +## Uploading Code via USB + +### Method 1: Direct File Copy (Fastest) +```bash +# Mount the device +sudo mount /dev/sdc1 /mnt/rp2350 + +# Copy your script +cp my_script.py /mnt/rp2350/code.py +# or rename to code.py if it's not already named that + +# Unmount +sudo umount /mnt/rp2350 +``` + +### Method 2: Using ampy (Serial/REPL) +```bash +# Requires adafruit-ampy: pip install adafruit-ampy + +# Check port (usually /dev/ttyACM0): +ls /dev/ttyACM* + +# Upload file: +ampy --port /dev/ttyACM0 put my_script.py + +# List files on device: +ampy --port /dev/ttyACM0 ls + +# Run REPL: +ampy --port /dev/ttyACM0 repl +``` + +## Troubleshooting + +### Device not showing in lsblk +- Check USB cable is data-capable (not charging-only) +- Check dmesg for USB errors: `dmesg | grep -i "rp2350\|pimoroni"` +- Press RESET button on board +- Hold BOOTSEL then press RESET to force bootloader mode + +### Read-only filesystem error +- Device may still be in bootloader mode +- Press RESET to boot into CircuitPython normally +- CIRCUITPY filesystem should then be writable + +### OLED not initializing +- Verify OLED is connected on I2C (address 0x3C) +- Run I2C scan to check: `ampy --port /dev/ttyACM0 repl` then: + ```python + import board, busio + i2c = busio.I2C(board.SCL, board.SDA) + while not i2c.try_lock(): pass + for addr in range(0x08, 0x78): + try: + i2c.writeto(addr, b'') + print(f"Device at 0x{addr:02x}") + except: pass + i2c.unlock() + ``` + +### Serial connection not working +```bash +# Install pyserial if needed: +pip install pyserial + +# Try other common ports: +ls /dev/ttyACM* /dev/ttyUSB* /dev/tty.usbmodem* +``` + +## Key Files in Source Directory + +- **rp2350_code.py** - Main application source (deployed as code.py) +- **disp.py, disp2.py** - Display/graphics utilities +- **oled_ip.py** - OLED IP display utility +- **oled_status.py** - OLED status display utility +- **rb_sensor_driver.py** - Sensor driver module +- **query_lorawan_device.py** - LoRaWAN utilities +- **rp2350_files/** - Precompiled libs and resources for deployment + - lib/ - CircuitPython library modules + - font5x8.bin - Bitmap font file + +## Useful CircuitPython Documentation + +- [CircuitPython Welcome](https://learn.adafruit.com/welcome-to-circuitpython/) +- [CircuitPython API Reference](https://circuitpython.readthedocs.io/) +- [SSD1306 OLED Library](https://github.com/adafruit/Adafruit_CircuitPython_SSD1306) +- [Pimoroni Tiny 2350 Pinout](https://shop.pimoroni.com/products/tiny-2350) + +## Common Tasks + +### Edit and Deploy +```bash +# 1. Edit code.py on your computer +nano code.py + +# 2. Mount device +sudo mount /dev/sdc1 /mnt/rp2350 + +# 3. Copy updated code +cp code.py /mnt/rp2350/ + +# 4. Unmount +sudo umount /mnt/rp2350 + +# 5. Check boot_out.txt for logs: +sudo cat /mnt/rp2350/boot_out.txt +``` + +### View Serial Output +```bash +# Use picocom or miniterm to view REPL output +pip install pyserial +python -m serial.tools.miniterm /dev/ttyACM0 115200 + +# Or with picocom: +sudo apt install picocom +sudo picocom /dev/ttyACM0 -b 115200 +# Exit: Ctrl+A, then Ctrl+X +``` + +### Add New Libraries +1. Find library in [CircuitPython Bundle](https://github.com/adafruit/Adafruit_CircuitPython_Bundle) +2. Extract .mpy file to local rp2350_files/lib/ +3. Mount device and copy to /mnt/rp2350/lib/ +4. Unmount and restart device + +## Hardware Info + +- **Microcontroller:** Raspberry Pi RP2350 (dual-core Arm Cortex-M33) +- **RAM:** 520 KB +- **Flash:** 4 MB (with UF2 bootloader) +- **I2C Pins:** board.SCL, board.SDA (GPIO3, GPIO4 typical) +- **LED:** board.LED (GPIO25) +- **USB:** Native CDC serial over USB + +## Device Enumeration + +``` +Bus 001 Device XXX: ID 2e8a:10a4 Pimoroni Tiny 2350 +``` + +Device appears as: +- `/dev/ttyACM0` - Serial/REPL port +- `/dev/sdc1` - CIRCUITPY USB mass storage (when mounted) + +## Next Steps + +1. Connect device via USB +2. Mount filesystem: `sudo mount /dev/sdc1 /mnt/rp2350` +3. Check boot_out.txt: `cat /mnt/rp2350/boot_out.txt` +4. Edit code.py as needed +5. Unmount and reset device to test changes + +Happy coding! 🎉 diff --git a/firmware/rp2350/rp2350_code.py b/firmware/rp2350/rp2350_code.py new file mode 100755 index 0000000..90bb3e5 --- /dev/null +++ b/firmware/rp2350/rp2350_code.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +import sys + +# Detect if running on RP2350 dev board +def detect_environment(): + """Check if running on RP2350 board with CircuitPython""" + try: + import board + import busio + import digitalio + # Just check for basic CircuitPython modules + # Different RP2350 boards may have different pin names + return True + except ImportError: + return False + +if not detect_environment(): + print("Error: This script requires CircuitPython on an RP2350 board.") + print() + print("To run on your RP2350:") + print(" ampy --port /dev/ttyACM0 run rp2350_code.py") + print() + print("Or copy to board:") + print(" ampy --port /dev/ttyACM0 put rp2350_code.py") + print() + print("To mount the RP2350 filesystem on Linux:") + print(" 1. Connect the board via USB") + print(" 2. Identify the device: lsblk | grep -i usb") + print(" 3. Mount the filesystem: sudo mount /dev/sdX1 /mnt/rp2350") + print(" 4. Or use auto-mount: udevil mount /dev/sdX1") + print() + print("Then access files at /mnt/rp2350/ or your mount point") + sys.exit(1) + +import board +import busio +import time +import digitalio + +# Try to import optional display module +try: + import adafruit_ssd1306 +except ImportError: + adafruit_ssd1306 = None + +# Log file (CircuitPython filesystem root) +log_file = "boot_out.txt" + +def log(message): + """Log message to file and console""" + timestamp = time.time() + log_msg = f"[{timestamp:.1f}] {message}" + print(log_msg) + try: + with open(log_file, "a") as f: + f.write(log_msg + "\n") + except Exception as e: + print(f"Log error: {e}") + +# Initialize I2C +i2c = None +try: + i2c = busio.I2C(board.SCL, board.SDA) +except (AttributeError, RuntimeError, OSError) as e: + log(f"I2C initialization failed: {e}") + +# Initialize LED (Pimoroni Tiny2350 has an onboard LED on GPIO25) +led = None +try: + led = digitalio.DigitalInOut(board.LED) + led.direction = digitalio.Direction.OUTPUT +except AttributeError as e: + log(f"LED initialization failed: {e}") + +# OLED display initialization (0x3C is typical I2C address for 0.91" 128x32 displays) +oled = None +oled_width = 128 +oled_height = 32 +oled_address = 0x3C + +def scan_i2c(): + """Scan and return list of I2C addresses""" + if i2c is None: + return [] + devices = [] + while not i2c.try_lock(): + pass + try: + for address in range(0x08, 0x78): + try: + i2c.writeto(address, b'') + devices.append(address) + except (OSError, Exception): + pass + finally: + i2c.unlock() + return devices + +def initialize_oled(): + """Initialize OLED display if available""" + global oled + if adafruit_ssd1306 is None or i2c is None: + log("OLED driver or I2C not available") + return False + try: + oled = adafruit_ssd1306.SSD1306_I2C(oled_width, oled_height, i2c, addr=oled_address) + log("OLED display detected and initialized!") + return True + except Exception as e: + log(f"OLED not found at 0x{oled_address:02x}: {e}") + return False + +def display_message(text): + """Display message on OLED""" + if oled is None: + return + try: + oled.fill(0) + oled.text(text, 0, 0, 1) + oled.show() + except Exception as e: + log(f"OLED display error: {e}") + +def blink_led(times=3, duration=0.2): + """Blink LED""" + if led is None: + return + for _ in range(times): + led.value = True + time.sleep(duration) + led.value = False + time.sleep(duration) + +log("CircuitPython I2C Scanner Started") +log("=" * 40) + +# Scan for I2C devices +devices = scan_i2c() + +if devices: + log(f"Found {len(devices)} I2C device(s):") + for addr in devices: + log(f" 0x{addr:02x}") + blink_led(len(devices), 0.3) +else: + log("No I2C devices found") + blink_led(5, 0.1) # Fast blink if no devices + +# Try to initialize OLED +if initialize_oled(): + display_message("RP2350 Ready!") + blink_led(2, 0.2) + +# Scan for a few seconds then exit +log("Scanning for 5 seconds...") +start_time = time.time() +while time.time() - start_time < 5: + time.sleep(1) + devices = scan_i2c() + device_addrs = ', '.join([f'0x{a:02x}' for a in devices]) if devices else 'none' + log(f"Found {len(devices)} device(s): {device_addrs}") + if 0x3C in devices: + display_message("OLED: Online") + else: + if oled is not None: + log("OLED display disconnected") + +log("Scan complete, exiting.") diff --git a/firmware/rp2350/scripts/detect_board.py b/firmware/rp2350/scripts/detect_board.py new file mode 100755 index 0000000..d3738bc --- /dev/null +++ b/firmware/rp2350/scripts/detect_board.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +""" +RP2350 Board Detection Script + +This script detects USB-connected RP2350-based development boards including: +- Raspberry Pi Pico 2 +- Adafruit Fruit Jam + +Usage: + python detect_board.py [options] + +Options: + -v, --verbose Show detailed device information + -l, --list-all List all USB devices (not just RP2350 boards) + -h, --help Show this help message + +Requirements: + - pyserial >= 3.5 + - pyusb >= 1.2.1 (optional, for detailed USB info) +""" + +import sys +import argparse +from typing import List, Dict, Optional, Tuple + +try: + import serial.tools.list_ports + SERIAL_AVAILABLE = True +except ImportError: + SERIAL_AVAILABLE = False + print("Warning: pyserial not installed. Install with: pip install pyserial") + +try: + import usb.core + import usb.util + USB_AVAILABLE = True +except ImportError: + USB_AVAILABLE = False + # This is optional, we can still use serial detection + + +# Known RP2350 board configurations +KNOWN_BOARDS = { + # Raspberry Pi devices + (0x2E8A, 0x0003): "Raspberry Pi Pico (RP2040) - Boot Mode", + (0x2E8A, 0x0005): "Raspberry Pi Pico (RP2040) - MicroPython", + (0x2E8A, 0x000A): "Raspberry Pi Pico (RP2040) - CircuitPython", + (0x2E8A, 0x000F): "Raspberry Pi Pico 2 (RP2350) - Boot Mode", + (0x2E8A, 0x1000): "Raspberry Pi Pico 2 (RP2350) - Application Mode", + + # Adafruit devices + (0x239A, 0x00F1): "Adafruit Fruit Jam - CircuitPython", + (0x239A, 0x0101): "Adafruit Fruit Jam - Boot Mode", + (0x239A, 0x80F1): "Adafruit Board - CircuitPython (Generic)", + (0x239A, 0xCAFE): "Adafruit Fruit Jam RP2350 - CircuitPython", +} + +# Manufacturer names for filtering +RP2350_MANUFACTURERS = [ + "Raspberry Pi", + "Adafruit", + "MicroPython", + "CircuitPython", +] + + +class BoardInfo: + """Container for board detection information""" + + def __init__(self, port: str, vid: int, pid: int, + serial_number: Optional[str] = None, + manufacturer: Optional[str] = None, + product: Optional[str] = None, + description: Optional[str] = None): + self.port = port + self.vid = vid + self.pid = pid + self.serial_number = serial_number + self.manufacturer = manufacturer + self.product = product + self.description = description + + @property + def board_type(self) -> str: + """Get the board type from known VID/PID combinations""" + return KNOWN_BOARDS.get((self.vid, self.pid), "Unknown RP2350 Board") + + @property + def is_rp2350(self) -> bool: + """Check if this is likely an RP2350-based board""" + # Check by VID/PID + if (self.vid, self.pid) in KNOWN_BOARDS: + return True + + # Check by manufacturer name + if self.manufacturer: + for mfr in RP2350_MANUFACTURERS: + if mfr.lower() in self.manufacturer.lower(): + return True + + # Check by product description + if self.product: + rp_keywords = ["pico", "rp2040", "rp2350", "fruit jam"] + for keyword in rp_keywords: + if keyword.lower() in self.product.lower(): + return True + + return False + + def __str__(self) -> str: + """String representation of board info""" + lines = [ + f"Board Found: {self.board_type}", + f" Port: {self.port}", + f" VID:PID: {self.vid:04X}:{self.pid:04X}", + ] + + if self.manufacturer: + lines.append(f" Manufacturer: {self.manufacturer}") + if self.product: + lines.append(f" Product: {self.product}") + if self.serial_number: + lines.append(f" Serial Number: {self.serial_number}") + if self.description: + lines.append(f" Description: {self.description}") + + return "\n".join(lines) + + +def detect_boards_serial() -> List[BoardInfo]: + """ + Detect RP2350 boards using serial port enumeration. + + Returns: + List of BoardInfo objects for detected boards + """ + if not SERIAL_AVAILABLE: + return [] + + boards = [] + ports = serial.tools.list_ports.comports() + + for port in ports: + # Create BoardInfo from port information + board = BoardInfo( + port=port.device, + vid=port.vid or 0, + pid=port.pid or 0, + serial_number=port.serial_number, + manufacturer=port.manufacturer, + product=port.product, + description=port.description + ) + + boards.append(board) + + return boards + + +def detect_boards_usb() -> List[Dict]: + """ + Detect USB devices using pyusb for more detailed information. + + Returns: + List of device dictionaries with USB information + """ + if not USB_AVAILABLE: + return [] + + devices = [] + usb_devices = usb.core.find(find_all=True) + + for dev in usb_devices: + try: + device_info = { + 'vid': dev.idVendor, + 'pid': dev.idProduct, + 'manufacturer': usb.util.get_string(dev, dev.iManufacturer) if dev.iManufacturer else None, + 'product': usb.util.get_string(dev, dev.iProduct) if dev.iProduct else None, + 'serial': usb.util.get_string(dev, dev.iSerialNumber) if dev.iSerialNumber else None, + 'bus': dev.bus, + 'address': dev.address, + } + devices.append(device_info) + except (ValueError, usb.core.USBError): + # Skip devices we can't read + continue + + return devices + + +def print_board_summary(boards: List[BoardInfo], verbose: bool = False): + """ + Print a summary of detected boards. + + Args: + boards: List of detected BoardInfo objects + verbose: If True, show detailed information + """ + rp2350_boards = [b for b in boards if b.is_rp2350] + + print("\n" + "="*70) + print("RP2350 BOARD DETECTION RESULTS") + print("="*70) + + if not rp2350_boards: + print("\n❌ No RP2350-based boards detected.") + print("\nTroubleshooting:") + print(" 1. Ensure the board is connected via USB") + print(" 2. Check that USB drivers are installed") + print(" 3. Try pressing the BOOTSEL button while plugging in") + print(" 4. On Linux, you may need udev rules or sudo access") + else: + print(f"\n✅ Found {len(rp2350_boards)} RP2350 board(s):\n") + + for i, board in enumerate(rp2350_boards, 1): + print(f"\n{'─'*70}") + print(f"Device #{i}") + print('─'*70) + print(board) + + if verbose and boards: + other_boards = [b for b in boards if not b.is_rp2350] + if other_boards: + print(f"\n\n{'='*70}") + print(f"OTHER USB DEVICES ({len(other_boards)} found)") + print('='*70) + + for i, board in enumerate(other_boards, 1): + print(f"\n{'─'*70}") + print(f"Device #{i}") + print('─'*70) + print(board) + + print("\n" + "="*70 + "\n") + + +def main(): + """Main entry point for the script""" + parser = argparse.ArgumentParser( + description="Detect USB-connected RP2350 development boards", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python detect_board.py # Basic detection + python detect_board.py -v # Verbose output with all USB devices + python detect_board.py --list-all # List all serial ports + """ + ) + + parser.add_argument('-v', '--verbose', action='store_true', + help='Show detailed information including non-RP2350 devices') + parser.add_argument('-l', '--list-all', action='store_true', + help='List all serial ports, not just RP2350 boards') + + args = parser.parse_args() + + # Check if required libraries are available + if not SERIAL_AVAILABLE: + print("\n❌ Error: pyserial is required but not installed.") + print("\nInstall it with:") + print(" pip install pyserial") + return 1 + + print("\n🔍 Scanning for RP2350-based development boards...") + + # Detect boards using serial enumeration + all_boards = detect_boards_serial() + + if args.list_all: + print_board_summary(all_boards, verbose=True) + else: + print_board_summary(all_boards, verbose=args.verbose) + + # Return appropriate exit code + rp2350_boards = [b for b in all_boards if b.is_rp2350] + return 0 if rp2350_boards else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/firmware/rp2350/scripts/read_board_code.py b/firmware/rp2350/scripts/read_board_code.py new file mode 100644 index 0000000..af9487b --- /dev/null +++ b/firmware/rp2350/scripts/read_board_code.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Read code from CircuitPython board via serial REPL +""" +import serial +import time +import sys + +def read_board_code(port='/dev/cu.usbmodem214301', baudrate=115200): + """Read the code.py file from the board""" + try: + ser = serial.Serial(port, baudrate, timeout=2) + time.sleep(0.5) + + # Send Ctrl+C several times to interrupt the running program + print("Stopping running program...") + for _ in range(5): + ser.write(b'\x03') + time.sleep(0.1) + + # Clear buffer + time.sleep(0.5) + ser.read(ser.in_waiting or 10000) + time.sleep(0.3) + + # Now enter commands to read the file + print("Reading file list...") + ser.write(b'import os\r\n') + time.sleep(0.2) + ser.write(b'print(os.listdir("/"))\r\n') + time.sleep(0.5) + + output = ser.read(ser.in_waiting or 5000).decode('utf-8', errors='ignore') + print("Files on board:") + print(output) + + # Try to read code.py + print("\nReading code.py...") + ser.write(b'try:\r\n') + time.sleep(0.1) + ser.write(b' with open("/code.py", "r") as f:\r\n') + time.sleep(0.1) + ser.write(b' print("\\n=== START CODE.PY ===")\r\n') + time.sleep(0.1) + ser.write(b' print(f.read())\r\n') + time.sleep(0.1) + ser.write(b' print("\\n=== END CODE.PY ===")\r\n') + time.sleep(0.1) + ser.write(b'except Exception as e:\r\n') + time.sleep(0.1) + ser.write(b' print(f"Error reading code.py: {e}")\r\n') + time.sleep(0.1) + ser.write(b'\r\n') # Exit the try block + time.sleep(1.5) + + # Read the output + code_output = ser.read(ser.in_waiting or 20000).decode('utf-8', errors='ignore') + print(code_output) + + # Also try main.py + print("\nChecking for main.py...") + ser.write(b'try:\r\n') + time.sleep(0.1) + ser.write(b' with open("/main.py", "r") as f:\r\n') + time.sleep(0.1) + ser.write(b' print("\\n=== START MAIN.PY ===")\r\n') + time.sleep(0.1) + ser.write(b' print(f.read())\r\n') + time.sleep(0.1) + ser.write(b' print("\\n=== END MAIN.PY ===")\r\n') + time.sleep(0.1) + ser.write(b'except Exception as e:\r\n') + time.sleep(0.1) + ser.write(b' print(f"No main.py found")\r\n') + time.sleep(0.1) + ser.write(b'\r\n') + time.sleep(1.5) + + main_output = ser.read(ser.in_waiting or 20000).decode('utf-8', errors='ignore') + print(main_output) + + ser.close() + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + return 1 + + return 0 + +if __name__ == "__main__": + port = sys.argv[1] if len(sys.argv) > 1 else '/dev/cu.usbmodem214301' + sys.exit(read_board_code(port)) From f1cb413933cfcf5865ae899dd3294171c82b8fbe Mon Sep 17 00:00:00 2001 From: Markos Hudson Date: Thu, 11 Dec 2025 17:21:02 -0800 Subject: [PATCH 08/12] feat: Add CircuitPython deployment with metadata tracking - deploy-circuitpy.sh: Tracks deployment metadata in source .md files and copies to device with size-prefixed names (e.g., 1325-script.md) - Updates settings.toml for script self-awareness via os.getenv() - test_RGB_LED.py: Board-agnostic LED test (QT2040/RP2350) - self-aware-demo.py: Demo of runtime metadata reading Preserves deployment history on-device while keeping source metadata versionable. --- firmware/deploy-circuitpy.sh | 161 +++++++++++++ firmware/esp32/test_provisioning.sh | 118 ++++++++- .../self-aware-demo.md | 67 ++++++ .../self-aware-demo.py | 50 ++++ .../test_RGB_LED.py | 102 ++++++++ firmware/rp2350/pimoroni_tiny2350_oled.py | 227 ++++++++++++++++++ firmware/rp2350/rp2350_helpers.py | 47 ++++ 7 files changed, 770 insertions(+), 2 deletions(-) create mode 100755 firmware/deploy-circuitpy.sh create mode 100644 firmware/qt2040-trinkey-circuitpython/self-aware-demo.md create mode 100644 firmware/qt2040-trinkey-circuitpython/self-aware-demo.py create mode 100644 firmware/qt2040-trinkey-circuitpython/test_RGB_LED.py create mode 100755 firmware/rp2350/pimoroni_tiny2350_oled.py create mode 100644 firmware/rp2350/rp2350_helpers.py diff --git a/firmware/deploy-circuitpy.sh b/firmware/deploy-circuitpy.sh new file mode 100755 index 0000000..cd4f24f --- /dev/null +++ b/firmware/deploy-circuitpy.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +# QT2040 Trinkey Deployment Script with Provenance Tracking +# Generates metadata file named {basename}-{filesize}.md for tracking deployed code + +DEVICE_PATH="/Volumes/CIRCUITPY" +SOURCE_FILE="${1:-code.py}" + +echo "QT2040 Trinkey Deployment Script" +echo "================================" + +# Check if the device is connected +if [ ! -d "$DEVICE_PATH" ]; then + echo "❌ Error: QT2040 Trinkey not found at $DEVICE_PATH" + echo " Make sure your device is connected and mounted" + exit 1 +fi + +# Check if source file exists +if [ ! -f "$SOURCE_FILE" ]; then + echo "❌ Error: $SOURCE_FILE not found in current directory" + exit 1 +fi + +echo "📱 Found QT2040 Trinkey at $DEVICE_PATH" +echo "📄 Deploying $SOURCE_FILE..." + +# Get file metadata +BASENAME=$(basename "$SOURCE_FILE" .py) +SOURCE_DIR=$(dirname "$SOURCE_FILE") +FILESIZE=$(wc -c < "$SOURCE_FILE" | tr -d ' ') +CHECKSUM=$(shasum -a 256 "$SOURCE_FILE" | awk '{print $1}') +DATE_UPDATED=$(stat -f "%Sm" -t "%Y-%m-%d %H:%M:%S %Z" "$SOURCE_FILE") +DATE_DEPLOYED=$(date "+%Y-%m-%d %H:%M:%S %Z") + +# Source metadata file (lives next to the .py file) +SOURCE_METADATA="${SOURCE_DIR}/${BASENAME}.md" + +# Destination metadata file (size-prefixed for device) +DEST_METADATA="${FILESIZE}-${BASENAME}.md" + +echo "📝 Updating metadata: $SOURCE_METADATA" + +# Check if source metadata exists, if not create template +if [ ! -f "$SOURCE_METADATA" ]; then + echo " Creating new metadata file..." + cat > "$SOURCE_METADATA" << 'EOF' +# Script Metadata + +## Description + +Add notes about what this script does here... + +## Hardware Requirements + +- Board: (e.g., QT2040 Trinkey, RP2350 Pimoroni Tiny2350) +- Peripherals: (e.g., None, I2C OLED, NeoPixels) + +## Notes + + + +EOF +fi + +# Update/append deployment section in source metadata +# First, remove any existing deployment section +sed -i.bak '/^---$/,/^$/d' "$SOURCE_METADATA" 2>/dev/null || true +rm -f "${SOURCE_METADATA}.bak" + +# Append current deployment info +cat >> "$SOURCE_METADATA" << EOF + +--- + +## Latest Deployment + +**Deployed to:** CircuitPython Device (CIRCUITPY) +**Original Filename:** \`$SOURCE_FILE\` +**File Size:** $FILESIZE bytes +**Date File Updated:** $DATE_UPDATED +**Date Deployed:** $DATE_DEPLOYED +**SHA256 Checksum:** \`$CHECKSUM\` + +### Verification + +\`\`\`bash +# Check filesize +ls -l /Volumes/CIRCUITPY/code.py | awk '{print \$5}' +# Expected: $FILESIZE + +# Check checksum +shasum -a 256 /Volumes/CIRCUITPY/code.py +# Expected: $CHECKSUM +\`\`\` +EOF + +# Copy source metadata to destination with size prefix +cp "$SOURCE_METADATA" "$DEST_METADATA" + +# Copy the source file as code.py +cp "$SOURCE_FILE" "$DEVICE_PATH/code.py" + +if [ $? -ne 0 ]; then + echo "❌ Error: Failed to deploy $SOURCE_FILE" + exit 1 +fi + +# Copy the metadata file to device +cp "$DEST_METADATA" "$DEVICE_PATH/$DEST_METADATA" + +if [ $? -ne 0 ]; then + echo "⚠️ Warning: Failed to copy metadata file (but code.py deployed successfully)" +fi + +# Update settings.toml with current deployment info +SETTINGS_FILE="$DEVICE_PATH/settings.toml" +TEMP_SETTINGS=$(mktemp) + +echo "📝 Updating settings.toml with deployment metadata..." + +# Preserve existing settings, remove old deployment metadata +if [ -f "$SETTINGS_FILE" ] && [ -s "$SETTINGS_FILE" ]; then + grep -v "^DEPLOYED_" "$SETTINGS_FILE" > "$TEMP_SETTINGS" 2>/dev/null || true + # Add blank line if file has content + if [ -s "$TEMP_SETTINGS" ]; then + echo "" >> "$TEMP_SETTINGS" + fi +else + touch "$TEMP_SETTINGS" +fi + +# Add deployment metadata +cat >> "$TEMP_SETTINGS" << EOF +# Deployment metadata (auto-generated by deploy-circuitpy.sh) +DEPLOYED_SCRIPT = "$SOURCE_FILE" +DEPLOYED_BASENAME = "$BASENAME.py" +DEPLOYED_DATE = "$(date "+%Y-%m-%d")" +DEPLOYED_TIMESTAMP = "$DATE_DEPLOYED" +DEPLOYED_SIZE = "$FILESIZE" +DEPLOYED_CHECKSUM = "$CHECKSUM" +EOF + +# Copy to device +cp "$TEMP_SETTINGS" "$SETTINGS_FILE" +rm -f "$TEMP_SETTINGS" + +echo "✅ Successfully deployed:" +echo " • $SOURCE_FILE → code.py" +echo " • $SOURCE_METADATA (updated)" +echo " • $DEST_METADATA → device (size: ${FILESIZE} bytes)" +echo " • settings.toml updated with deployment info" +echo " • SHA256: ${CHECKSUM:0:16}..." +echo "🔄 The device should automatically restart with the new code" +echo "" +echo "💡 To identify what's running later:" +echo " ls -l /Volumes/CIRCUITPY/code.py # Look at filesize: $FILESIZE" +echo " cat /Volumes/CIRCUITPY/${DEST_METADATA} # Read full metadata" +echo " cat /Volumes/CIRCUITPY/settings.toml # Quick check" + +echo "Done!" diff --git a/firmware/esp32/test_provisioning.sh b/firmware/esp32/test_provisioning.sh index 4ab9088..e781c12 100755 --- a/firmware/esp32/test_provisioning.sh +++ b/firmware/esp32/test_provisioning.sh @@ -1,19 +1,130 @@ #!/bin/bash # Test script for BLE provisioning # Usage: ./test_provisioning.sh [ble_name] [port_name] +# ./test_provisioning.sh --help +# ./test_provisioning.sh --ports + +# Handle help and port discovery flags +if [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]]; then + echo "Usage: ./test_provisioning.sh [ble_name] [port_name]" + echo "" + echo "Arguments:" + echo " ble_name - Bluetooth name for the device (default: BAT-PRO-3-2510)" + echo " port_name - Serial port path (default: /dev/tty.usbserial-110)" + echo "" + echo "Options:" + echo " --help, -h - Show this help message" + echo " --ports, -p - Show commands to discover serial ports" + echo "" + echo "Examples:" + echo " ./test_provisioning.sh" + echo " ./test_provisioning.sh MY-DEVICE" + echo " ./test_provisioning.sh MY-DEVICE /dev/ttyUSB0" + exit 0 +fi + +if [[ "$1" == "--ports" ]] || [[ "$1" == "-p" ]]; then + show_port_discovery_commands() { + echo "" + echo "=== Commands to Find Active Serial Ports ===" + echo "" + if [[ "$OSTYPE" == "darwin"* ]]; then + echo " macOS:" + echo " ls /dev/tty.* | grep -i usb" + echo " system_profiler SPUSBDataType | grep -A 10 -i 'serial'" + echo " ioreg -p IOUSB -l -w 0 | grep -i serial" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo " Linux:" + echo " ls /dev/ttyUSB* /dev/ttyACM* 2>/dev/null" + echo " dmesg | grep tty | tail -20" + echo " lsusb" + echo " udevadm info --query=all --name=/dev/ttyUSB0" + else + echo " Windows (Git Bash/WSL):" + echo " ls /dev/ttyS* 2>/dev/null" + echo " mode" + fi + echo "" + echo " Common ESP32 port patterns:" + echo " /dev/tty.usbserial-* (macOS - CH340/CP2102)" + echo " /dev/tty.SLAB_USBtoUART* (macOS - Silicon Labs)" + echo " /dev/ttyUSB* (Linux - CH340/CP2102)" + echo " /dev/ttyACM* (Linux - Native USB)" + echo "" + } + show_port_discovery_commands + exit 0 +fi BLE_NAME="${1:-BAT-PRO-3-2510}" PORT="${2:-/dev/tty.usbserial-110}" +# Function to display commands for finding serial ports +show_port_discovery_commands() { + echo "" + echo "=== Commands to Find Active Serial Ports ===" + echo "" + if [[ "$OSTYPE" == "darwin"* ]]; then + echo " macOS:" + echo " ls /dev/tty.* | grep -i usb" + echo " system_profiler SPUSBDataType | grep -A 10 -i 'serial'" + echo " ioreg -p IOUSB -l -w 0 | grep -i serial" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo " Linux:" + echo " ls /dev/ttyUSB* /dev/ttyACM* 2>/dev/null" + echo " dmesg | grep tty | tail -20" + echo " lsusb" + echo " udevadm info --query=all --name=/dev/ttyUSB0" + else + echo " Windows (Git Bash/WSL):" + echo " ls /dev/ttyS* 2>/dev/null" + echo " mode" + fi + echo "" + echo " Common ESP32 port patterns:" + echo " /dev/tty.usbserial-* (macOS - CH340/CP2102)" + echo " /dev/tty.SLAB_USBtoUART* (macOS - Silicon Labs)" + echo " /dev/ttyUSB* (Linux - CH340/CP2102)" + echo " /dev/ttyACM* (Linux - Native USB)" + echo "" +} + +# Check if the port exists +if [ ! -e "$PORT" ]; then + echo "ERROR: Port $PORT does not exist!" + echo "" + echo "The serial port may have changed. Please check available ports." + show_port_discovery_commands + exit 1 +fi + +# Check if we have permission to access the port +if [ ! -r "$PORT" ] || [ ! -w "$PORT" ]; then + echo "ERROR: No read/write permission for $PORT" + echo "Try: sudo chmod 666 $PORT" + echo "Or add your user to the dialout group (Linux): sudo usermod -a -G dialout $USER" + exit 1 +fi + echo "Connecting to ESP32 on $PORT..." echo "Sending provisioning command: ble/name $BLE_NAME" echo "" # Configure serial port -stty -f "$PORT" 115200 cs8 -cstopb -parenb +if ! stty -f "$PORT" 115200 cs8 -cstopb -parenb 2>/dev/null; then + echo "ERROR: Failed to configure serial port $PORT" + echo "The device may have been disconnected or the port may have changed." + show_port_discovery_commands + exit 1 +fi # Send the provisioning command -echo "ble/name $BLE_NAME" > "$PORT" +if ! echo "ble/name $BLE_NAME" > "$PORT" 2>/dev/null; then + echo "ERROR: Failed to send command to $PORT" + echo "The device may have been disconnected or the port may have changed." + show_port_discovery_commands + exit 1 +fi echo "Command sent! The ESP32 should now be advertising as: $BLE_NAME" echo "" @@ -21,3 +132,6 @@ echo "To verify, you can:" echo " - Check Bluetooth settings on your phone/computer" echo " - Use 'screen $PORT 115200' to see the ESP32 output" echo " - Run: hcitool lescan (on Linux)" +echo "" +echo "If the device is not responding, the serial port may have changed." +echo "Run './test_provisioning.sh --help' to see port discovery commands." diff --git a/firmware/qt2040-trinkey-circuitpython/self-aware-demo.md b/firmware/qt2040-trinkey-circuitpython/self-aware-demo.md new file mode 100644 index 0000000..5fc4e87 --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/self-aware-demo.md @@ -0,0 +1,67 @@ +# Self-Aware Script Demo + +## Description + +Demonstrates how CircuitPython scripts can read their own deployment metadata from `settings.toml`. The script identifies itself by reading environment variables populated from the settings file, then blinks an LED to show it's running. + +## Hardware Requirements + +- Board: Any CircuitPython board (tested on RP2350 Pimoroni Tiny2350) +- Peripherals: None (uses built-in LED) + +## Features + +- Reads `DEPLOYED_SCRIPT`, `DEPLOYED_DATE`, `DEPLOYED_SIZE`, `DEPLOYED_CHECKSUM` from environment +- Auto-detects LED pin (LED_G for RP2350, LED for generic boards) +- Blinks LED 5 times to show execution + +## Notes + +This demonstrates the "self-aware script" pattern where code can identify its own provenance at runtime. + + + + +## Latest Deployment + +**Deployed to:** CircuitPython Device (CIRCUITPY) +**Original Filename:** `qt2040-trinkey-circuitpython/self-aware-demo.py` +**File Size:** 1325 bytes +**Date File Updated:** 2025-12-11 16:42:36 PST +**Date Deployed:** 2025-12-11 16:47:34 PST +**SHA256 Checksum:** `05200eb36e753bbdcffc9f5587f332c89d7d8fba6da94743ccfb8ed4b9b47267` + +### Verification + +```bash +# Check filesize +ls -l /Volumes/CIRCUITPY/code.py | awk '{print $5}' +# Expected: 1325 + +# Check checksum +shasum -a 256 /Volumes/CIRCUITPY/code.py +# Expected: 05200eb36e753bbdcffc9f5587f332c89d7d8fba6da94743ccfb8ed4b9b47267 +``` + +--- + +## Latest Deployment + +**Deployed to:** CircuitPython Device (CIRCUITPY) +**Original Filename:** `qt2040-trinkey-circuitpython/self-aware-demo.py` +**File Size:** 1325 bytes +**Date File Updated:** 2025-12-11 16:42:36 PST +**Date Deployed:** 2025-12-11 16:48:05 PST +**SHA256 Checksum:** `05200eb36e753bbdcffc9f5587f332c89d7d8fba6da94743ccfb8ed4b9b47267` + +### Verification + +```bash +# Check filesize +ls -l /Volumes/CIRCUITPY/code.py | awk '{print $5}' +# Expected: 1325 + +# Check checksum +shasum -a 256 /Volumes/CIRCUITPY/code.py +# Expected: 05200eb36e753bbdcffc9f5587f332c89d7d8fba6da94743ccfb8ed4b9b47267 +``` diff --git a/firmware/qt2040-trinkey-circuitpython/self-aware-demo.py b/firmware/qt2040-trinkey-circuitpython/self-aware-demo.py new file mode 100644 index 0000000..c33a8c7 --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/self-aware-demo.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +Self-Aware Script Demo +Reads deployment metadata from settings.toml to identify itself +""" + +import os +import time +import board +import digitalio + +print("=" * 60) +print("Self-Aware Script Demo") +print("=" * 60) + +# Read deployment metadata from settings.toml +deployed_script = os.getenv("DEPLOYED_SCRIPT", "unknown") +deployed_date = os.getenv("DEPLOYED_DATE", "unknown") +deployed_size = os.getenv("DEPLOYED_SIZE", "unknown") +deployed_checksum = os.getenv("DEPLOYED_CHECKSUM", "unknown") + +print(f"I am: {deployed_script}") +print(f"Deployed on: {deployed_date}") +print(f"My size: {deployed_size} bytes") +print(f"My checksum: {deployed_checksum[:16]}...") +print("=" * 60) +print() + +# Simple LED blink to show we're running +led = None +if hasattr(board, 'LED_G'): + led = digitalio.DigitalInOut(board.LED_G) + led.direction = digitalio.Direction.OUTPUT + print("Using LED_G (RP2350)") +elif hasattr(board, 'LED'): + led = digitalio.DigitalInOut(board.LED) + led.direction = digitalio.Direction.OUTPUT + print("Using LED (generic)") + +if led: + for i in range(5): + print(f"Blink {i+1}/5") + led.value = True + time.sleep(0.5) + led.value = False + time.sleep(0.5) + print("\nDemo complete!") +else: + print("No LED found, but metadata works!") + time.sleep(5) diff --git a/firmware/qt2040-trinkey-circuitpython/test_RGB_LED.py b/firmware/qt2040-trinkey-circuitpython/test_RGB_LED.py new file mode 100644 index 0000000..defe6e4 --- /dev/null +++ b/firmware/qt2040-trinkey-circuitpython/test_RGB_LED.py @@ -0,0 +1,102 @@ +#!/usr/bin/python3 +""" +RGB LED Test - Works on both QT2040 Trinkey and RP2350 boards +Auto-detects board type and uses appropriate LED interface +""" + +import time +import board + +print("Starting RGB LED test...") + +# Detect board capabilities and initialize appropriate LED interface +led_type = None +pixel = None +led_r = led_g = led_b = None + +# Try NeoPixel first (QT2040 Trinkey) +try: + import neopixel + if hasattr(board, 'NEOPIXEL'): + pixel = neopixel.NeoPixel(board.NEOPIXEL, 1) + pixel.brightness = 0.3 + led_type = "neopixel" + print("✓ Detected NeoPixel (QT2040 Trinkey)") +except (ImportError, AttributeError): + pass + +# Try discrete RGB LEDs (RP2350 Pimoroni Tiny2350) +if led_type is None: + try: + import digitalio + if hasattr(board, 'LED_R') and hasattr(board, 'LED_G') and hasattr(board, 'LED_B'): + led_r = digitalio.DigitalInOut(board.LED_R) + led_r.direction = digitalio.Direction.OUTPUT + led_g = digitalio.DigitalInOut(board.LED_G) + led_g.direction = digitalio.Direction.OUTPUT + led_b = digitalio.DigitalInOut(board.LED_B) + led_b.direction = digitalio.Direction.OUTPUT + led_type = "discrete_rgb" + print("✓ Detected discrete RGB LEDs (RP2350)") + except (ImportError, AttributeError): + pass + +# Fallback to single LED +if led_type is None: + try: + import digitalio + if hasattr(board, 'LED'): + led_g = digitalio.DigitalInOut(board.LED) + led_g.direction = digitalio.Direction.OUTPUT + led_type = "single_led" + print("✓ Detected single LED") + except (ImportError, AttributeError): + pass + +if led_type is None: + print("✗ No LED detected on this board!") + raise RuntimeError("No compatible LED found") + +def set_color(r, g, b): + """Set LED color - works with both NeoPixel and discrete RGB""" + if led_type == "neopixel": + pixel[0] = (r, g, b) + elif led_type == "discrete_rgb": + # Discrete LEDs: 0=on, 1=off (inverted logic on many boards) + led_r.value = not (r > 0) + led_g.value = not (g > 0) + led_b.value = not (b > 0) + elif led_type == "single_led": + # Single LED: just blink for any non-zero color + led_g.value = (r > 0 or g > 0 or b > 0) + +print("Test running...") +print("Cycling through colors: Red → Green → Blue") + +counter = 0 +colors = [ + (255, 0, 0), # Red + (0, 255, 0), # Green + (0, 0, 255), # Blue + (255, 255, 0), # Yellow + (255, 0, 255), # Magenta + (0, 255, 255), # Cyan + (255, 255, 255) # White +] + +color_names = ["Red", "Green", "Blue", "Yellow", "Magenta", "Cyan", "White"] + +while True: + color_idx = counter % len(colors) + set_color(*colors[color_idx]) + print(f"Loop {counter}: {color_names[color_idx]}") + counter += 1 + + time.sleep(2) + + if counter >= 14: # Two full cycles + break + +# Turn off LED +set_color(0, 0, 0) +print("Test completed!") diff --git a/firmware/rp2350/pimoroni_tiny2350_oled.py b/firmware/rp2350/pimoroni_tiny2350_oled.py new file mode 100755 index 0000000..82b9e5c --- /dev/null +++ b/firmware/rp2350/pimoroni_tiny2350_oled.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +import sys + +# Check if running on CircuitPython +try: + from rp2350_helpers import require_circuitpython + require_circuitpython("pimoroni_tiny2350_oled.py") +except ImportError: + # Running on CircuitPython, helper module not available + pass + +import board +import busio +import time +import digitalio +from micropython import const + +# Simple SSD1306 driver using framebuffer +class SSD1306: + SET_CONTRAST = const(0x81) + SET_ENTIRE_ON = const(0xA4) + SET_NORM_INV = const(0xA6) + SET_DISP = const(0xAE) + SET_MEM_ADDR = const(0x20) + SET_COL_ADDR = const(0x21) + SET_PAGE_ADDR = const(0x22) + SET_DISP_START_LINE = const(0x40) + SET_SEG_REMAP = const(0xA0) + SET_MUX_RATIO = const(0xA8) + SET_COM_OUT_DIR = const(0xC0) + SET_DISP_OFFSET = const(0xD3) + SET_COM_PIN_CFG = const(0xDA) + SET_DISP_CLK_DIV = const(0xD5) + SET_PRECHARGE = const(0xD9) + SET_VCOM_DESEL = const(0xDB) + SET_CHARGE_PUMP = const(0x8D) + + def __init__(self, width, height, i2c, addr=0x3C): + self.i2c = i2c + self.addr = addr + self.width = width + self.height = height + self.pages = self.height // 8 + self.buffer = bytearray(self.pages * self.width) + self.init_display() + + def write_cmd(self, cmd): + while not self.i2c.try_lock(): + pass + try: + self.i2c.writeto(self.addr, bytes([0x00, cmd])) + finally: + self.i2c.unlock() + + def write_data(self, buf): + while not self.i2c.try_lock(): + pass + try: + # Write in chunks + chunk_size = 16 + for i in range(0, len(buf), chunk_size): + chunk = buf[i:i+chunk_size] + self.i2c.writeto(self.addr, b'\x40' + chunk) + finally: + self.i2c.unlock() + + def init_display(self): + for cmd in ( + self.SET_DISP | 0x00, # off + self.SET_MEM_ADDR, 0x00, # horizontal + self.SET_DISP_START_LINE | 0x00, + self.SET_SEG_REMAP | 0x01, # column 127 mapped to SEG0 + self.SET_MUX_RATIO, self.height - 1, + self.SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0 + self.SET_DISP_OFFSET, 0x00, + self.SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12, + self.SET_DISP_CLK_DIV, 0x80, + self.SET_PRECHARGE, 0xF1, + self.SET_VCOM_DESEL, 0x30, + self.SET_CONTRAST, 0xFF, + self.SET_ENTIRE_ON, + self.SET_NORM_INV, + self.SET_CHARGE_PUMP, 0x14, + self.SET_DISP | 0x01): # on + self.write_cmd(cmd) + self.fill(0) + self.show() + + def fill(self, c): + self.buffer[:] = bytes([c & 0xFF] * len(self.buffer)) + + def pixel(self, x, y, c): + if 0 <= x < self.width and 0 <= y < self.height: + index = x + (y // 8) * self.width + bit = y % 8 + if c: + self.buffer[index] |= 1 << bit + else: + self.buffer[index] &= ~(1 << bit) + + def text(self, string, x, y, c=1): + # Simple 5x7 font + for char in string: + self.char(char, x, y, c) + x += 6 + + def char(self, c, x, y, color=1): + # Very basic 5x7 ASCII font + font = { + '0': [0x3E, 0x51, 0x49, 0x45, 0x3E], + '1': [0x00, 0x42, 0x7F, 0x40, 0x00], + '2': [0x42, 0x61, 0x51, 0x49, 0x46], + '3': [0x21, 0x41, 0x45, 0x4B, 0x31], + '4': [0x18, 0x14, 0x12, 0x7F, 0x10], + '5': [0x27, 0x45, 0x45, 0x45, 0x39], + '6': [0x3C, 0x4A, 0x49, 0x49, 0x30], + '7': [0x01, 0x71, 0x09, 0x05, 0x03], + '8': [0x36, 0x49, 0x49, 0x49, 0x36], + '9': [0x06, 0x49, 0x49, 0x29, 0x1E], + 'A': [0x7E, 0x11, 0x11, 0x11, 0x7E], + 'B': [0x7F, 0x49, 0x49, 0x49, 0x36], + 'C': [0x3E, 0x41, 0x41, 0x41, 0x22], + 'D': [0x7F, 0x41, 0x41, 0x22, 0x1C], + 'E': [0x7F, 0x49, 0x49, 0x49, 0x41], + 'F': [0x7F, 0x09, 0x09, 0x09, 0x01], + 'G': [0x3E, 0x41, 0x49, 0x49, 0x7A], + 'H': [0x7F, 0x08, 0x08, 0x08, 0x7F], + 'I': [0x00, 0x41, 0x7F, 0x41, 0x00], + 'P': [0x7F, 0x09, 0x09, 0x09, 0x06], + 'R': [0x7F, 0x09, 0x19, 0x29, 0x46], + 'S': [0x46, 0x49, 0x49, 0x49, 0x31], + 'T': [0x01, 0x01, 0x7F, 0x01, 0x01], + 'W': [0x7F, 0x20, 0x18, 0x20, 0x7F], + 'X': [0x63, 0x14, 0x08, 0x14, 0x63], + 'Y': [0x07, 0x08, 0x70, 0x08, 0x07], + ' ': [0x00, 0x00, 0x00, 0x00, 0x00], + ':': [0x00, 0x36, 0x36, 0x00, 0x00], + '!': [0x00, 0x00, 0x5F, 0x00, 0x00], + 'x': [0x44, 0x28, 0x10, 0x28, 0x44], + 's': [0x48, 0x54, 0x54, 0x54, 0x20], + } + c = c.upper() + if c not in font: + c = ' ' + for col, bits in enumerate(font[c]): + for row in range(8): + if bits & (1 << row): + self.pixel(x + col, y + row, color) + + def show(self): + self.write_cmd(self.SET_COL_ADDR) + self.write_cmd(0) + self.write_cmd(self.width - 1) + self.write_cmd(self.SET_PAGE_ADDR) + self.write_cmd(0) + self.write_cmd(self.pages - 1) + self.write_data(self.buffer) + +# Initialize RGB LED +led_g = digitalio.DigitalInOut(board.LED_G) +led_g.direction = digitalio.Direction.OUTPUT +print("Green LED initialized") + +# Initialize I2C +i2c = busio.I2C(board.SCL, board.SDA) +print("I2C initialized") + +# Scan I2C +def scan_i2c(): + devices = [] + while not i2c.try_lock(): + pass + try: + for addr in range(0x08, 0x78): + try: + i2c.writeto(addr, b'') + devices.append(addr) + except OSError: + pass + finally: + i2c.unlock() + return devices + +devices = scan_i2c() +print(f"I2C devices: {[hex(a) for a in devices]}") + +# Initialize OLED - try both sizes starting with 32 +oled = None +for height in [32, 64]: + try: + test_oled = SSD1306(128, height, i2c, addr=0x3C) + # Test if display responds correctly + test_oled.fill(0) + test_oled.show() + oled = test_oled + print(f"OLED 128x{height} initialized!") + break + except Exception as e: + print(f"Failed 128x{height}: {e}") + +if oled: + oled.fill(0) + oled.text("RP2350 READY", 5, 5) + oled.text("I2C: 0x3C", 5, 18) + oled.show() + print(f"Display updated! Size: {oled.width}x{oled.height}") + led_g.value = True + time.sleep(3) # Longer pause to see initial message + led_g.value = False + +# Main loop +print("Starting main loop...") +counter = 0 +while True: + counter += 1 + led_g.value = True + time.sleep(0.1) + led_g.value = False + + if oled: + oled.fill(0) + oled.text(f"COUNT {counter}", 5, 4) + oled.text(f"TIME {int(time.monotonic())}s", 5, 16) + oled.show() + + print(f"Loop {counter}") + time.sleep(5) diff --git a/firmware/rp2350/rp2350_helpers.py b/firmware/rp2350/rp2350_helpers.py new file mode 100644 index 0000000..500f7c0 --- /dev/null +++ b/firmware/rp2350/rp2350_helpers.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +""" +Helper module for RP2350 CircuitPython scripts. +Provides environment detection and helpful error messages for running scripts on macOS vs RP2350. +""" +import sys + + +def detect_environment(): + """Check if running on RP2350 board with CircuitPython""" + try: + import board + import busio + import digitalio + # Just check for basic CircuitPython modules + # Different RP2350 boards may have different pin names + return True + except ImportError: + return False + + +def detect_circuitpython(called_by="this script"): + """ + Check if running on CircuitPython. If not, print helpful message and exit. + + Args: + called_by: Name of the script for display in error message + """ + if not detect_environment(): + print(f"Error: {called_by} requires CircuitPython on an RP2350 board.") + print() + print("To run on your RP2350:") + print(f" 1. Copy to CIRCUITPY volume:") + print(f" cp {called_by} /Volumes/CIRCUITPY/code.py") + print() + print(" Or on Linux:") + print(f" cp {called_by} /media/$USER/CIRCUITPY/code.py") + print() + print(" 2. The script will auto-run when copied to code.py") + print() + print(" 3. To see serial output:") + print(" screen /dev/tty.usbmodem* 115200 (macOS)") + print(" screen /dev/ttyACM0 115200 (Linux)") + print() + print("Note: The board should appear as CIRCUITPY volume when plugged in.") + print(" If not, hold BOOTSEL button while plugging in to enter BOOTSEL mode.") + sys.exit(1) From 5d5b63e216ff3f941f75dd4134c258bcea44322a Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:59:56 -0800 Subject: [PATCH 09/12] Initial plan (#12) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> From b6f031fdc935c3c806a3407eec4bd7dcedef44ae Mon Sep 17 00:00:00 2001 From: Markos Date: Wed, 11 Mar 2026 09:45:31 -0700 Subject: [PATCH 10/12] enhace serial_ish.py --- firmware/rp2350/rp2350_code.py | 37 +++++++--------------------------- firmware/serial_ish.py | 20 +++++++++--------- 2 files changed, 18 insertions(+), 39 deletions(-) diff --git a/firmware/rp2350/rp2350_code.py b/firmware/rp2350/rp2350_code.py index 90bb3e5..140791e 100755 --- a/firmware/rp2350/rp2350_code.py +++ b/firmware/rp2350/rp2350_code.py @@ -1,36 +1,13 @@ #!/usr/bin/env python3 import sys -# Detect if running on RP2350 dev board -def detect_environment(): - """Check if running on RP2350 board with CircuitPython""" - try: - import board - import busio - import digitalio - # Just check for basic CircuitPython modules - # Different RP2350 boards may have different pin names - return True - except ImportError: - return False - -if not detect_environment(): - print("Error: This script requires CircuitPython on an RP2350 board.") - print() - print("To run on your RP2350:") - print(" ampy --port /dev/ttyACM0 run rp2350_code.py") - print() - print("Or copy to board:") - print(" ampy --port /dev/ttyACM0 put rp2350_code.py") - print() - print("To mount the RP2350 filesystem on Linux:") - print(" 1. Connect the board via USB") - print(" 2. Identify the device: lsblk | grep -i usb") - print(" 3. Mount the filesystem: sudo mount /dev/sdX1 /mnt/rp2350") - print(" 4. Or use auto-mount: udevil mount /dev/sdX1") - print() - print("Then access files at /mnt/rp2350/ or your mount point") - sys.exit(1) +# Check if running on CircuitPython +try: + from rp2350_helpers import detect_circuitpython + detect_circuitpython(called_by="rp2350_code.py") +except ImportError: + # Running on CircuitPython, helper module not available + pass import board import busio diff --git a/firmware/serial_ish.py b/firmware/serial_ish.py index 7007840..74d8af9 100755 --- a/firmware/serial_ish.py +++ b/firmware/serial_ish.py @@ -80,7 +80,7 @@ def is_likely(candidate): if likely: for port in likely: desc = port.description or "No description" - print(f" {port.device} | {desc}") + print(f" {port.device} \t|\t {desc}") else: print(" None detected") @@ -276,20 +276,20 @@ def main(): # Determine duration: --duration flag takes precedence, then positional, then defaults if args.duration is not None: - duration = args.duration + timeout = args.duration elif args.pos_duration is not None: - duration = args.pos_duration + timeout = args.pos_duration else: # Default duration depends on mode if args.send: - duration = 2.0 + timeout = 2.0 else: - duration = 5.0 + timeout = 5.0 - print(f"Opening serial port {port} at {baudrate} baud...") + print(f"Opening serial port {port} at {baudrate} baud... --timeout {timeout}") # Use the same duration for send mode - send_duration = duration + send_duration = timeout try: with serial.Serial(port, baudrate, timeout=0.1) as ser: @@ -298,9 +298,11 @@ def main(): elif args.send: send_mode(ser, args.send, send_duration) else: - read_mode(ser, duration) + read_mode(ser, timeout) except serial.SerialException as e: - print(f"Error: {e}") + print(f"Error connecting to {port}: {e}") + print("\nAvailable serial ports:") + list_serial_ports() sys.exit(1) except KeyboardInterrupt: print("\n\nInterrupted by user") From ee0582423b19fcdfa685f7577dc08114b1fc9acf Mon Sep 17 00:00:00 2001 From: Markos Date: Wed, 11 Mar 2026 17:18:56 -0700 Subject: [PATCH 11/12] slight terser --- firmware/serial_ish.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/firmware/serial_ish.py b/firmware/serial_ish.py index 74d8af9..64a74c2 100755 --- a/firmware/serial_ish.py +++ b/firmware/serial_ish.py @@ -254,7 +254,7 @@ def main(): print("Error: No device specified and no cached device found.") print("Please specify a device path on first use.") sys.exit(1) - print(f"Using cached device: {port}") + print(f"Using cached device: {port}",end='') used_cache = True # Handle baudrate - use cached if not specified, otherwise use default @@ -262,10 +262,10 @@ def main(): baudrate = args.baudrate elif cached_baudrate is not None: baudrate = cached_baudrate - print(f"Using cached baud rate: {baudrate}") else: baudrate = 230400 - print(f"Using default baud rate: {baudrate}") + + print(f" @ {baudrate} baud") # Save the connection details for next time (only update previous if not using cache) if not used_cache: From c83d483bba615b3cea73fd14e38c171a392c73e4 Mon Sep 17 00:00:00 2001 From: Markos-Mac Date: Wed, 3 Jun 2026 12:15:52 -0700 Subject: [PATCH 12/12] vllm-mlx install and benchmark macOS --- vllm-mlx/AGENTS.md | 41 +++++++ vllm-mlx/RESULTS.md | 50 ++++++++ vllm-mlx/results--mac-studio-m2.json | 97 +++++++++++++++ vllm-mlx/results--macbook-pro-m3.json | 62 ++++++++++ vllm-mlx/vllm-mlx--benchmarker.py | 165 ++++++++++++++++++++++++++ vllm-mlx/vllm-mlx--install.sh | 22 ++++ vllm-mlx/vllm-mlx--smoke-test.py | 7 ++ vllm-mlx/vllm-mlx--try-it.py | 40 +++++++ 8 files changed, 484 insertions(+) create mode 100644 vllm-mlx/AGENTS.md create mode 100644 vllm-mlx/RESULTS.md create mode 100644 vllm-mlx/results--mac-studio-m2.json create mode 100644 vllm-mlx/results--macbook-pro-m3.json create mode 100755 vllm-mlx/vllm-mlx--benchmarker.py create mode 100755 vllm-mlx/vllm-mlx--install.sh create mode 100755 vllm-mlx/vllm-mlx--smoke-test.py create mode 100755 vllm-mlx/vllm-mlx--try-it.py diff --git a/vllm-mlx/AGENTS.md b/vllm-mlx/AGENTS.md new file mode 100644 index 0000000..af983b5 --- /dev/null +++ b/vllm-mlx/AGENTS.md @@ -0,0 +1,41 @@ +# Copilot Instructions + +This is a feasibility scratch workspace to compare local LLM inference performance across two machines using [vllm-mlx](https://github.com/vllm-project/vllm): + +| Machine | Chip | Unified Memory | +|---|---|---| +| MacBook Pro | M3 | 63 GB | +| Mac Studio | M2 | 32 GB | + +Model: **`mlx-community/Qwen2.5-Coder-14B-Instruct-4bit`** (~8 GB weights) — chosen as a capable coding model that fits comfortably on both machines. + +## Style + +Responses should be ruthlessly concise, prioritizing brevity and providing the exact answer immediately without fluff, pleasantries, introductory summaries, or concluding remarks. + +## Setup & Usage + +**Install / reinstall (handles mlx version mismatches):** +```bash +bash vllm-mlx-install.sh +``` +This script: upgrades the Homebrew `mlx` C++ library, removes old Python wheels, recompiles `mlx` from source (required to match the system lib), then starts the server. + +**Start the server manually:** +```bash +export PATH="$HOME/Library/Python/3.14/bin:$PATH" # as-needed +vllm-mlx serve mlx-community/Qwen2.5-Coder-14B-Instruct-4bit --port 8000 --continuous-batching +``` + +**Test the running server:** +```bash +python3 vllm-mlx--try-it.py +``` +Calls `http://localhost:8000/v1` using the OpenAI-compatible API (no auth needed locally). + +## Key Details + +- **Python path**: Binaries install to `~/Library/Python/3.14/bin` — must be on `$PATH` before running `vllm-mlx`. +- **Source build required**: `mlx` must be compiled from source (`--no-binary mlx`) to avoid ABI mismatches with the Homebrew C++ lib. +- **API compatibility**: The server speaks the OpenAI chat completions API; use the full model ID (e.g. `mlx-community/Qwen2.5-Coder-14B-Instruct-4bit`) and `api_key="not-needed"` when connecting locally. +- **Model**: `mlx-community/Qwen2.5-Coder-14B-Instruct-4bit` (~8 GB, Qwen 2.5 14B coding model). diff --git a/vllm-mlx/RESULTS.md b/vllm-mlx/RESULTS.md new file mode 100644 index 0000000..0888a09 --- /dev/null +++ b/vllm-mlx/RESULTS.md @@ -0,0 +1,50 @@ +# vllm-mlx Feasibility Benchmark + +Comparing local LLM inference across two Apple Silicon machines using [vllm-mlx](https://github.com/vllm-project/vllm). + +## Model + +**`mlx-community/Qwen2.5-Coder-14B-Instruct-4bit`** (~8 GB weights) + +## Benchmark Prompt + +> Write a Python function that checks if a number is prime, with a docstring and type hints. + +## Results + +| Metric | MacBook Pro M3 (63 GB) | Mac Studio M2 (32 GB) | +|---|---|---| +| TTFT (mean) | 0.138s ± 0.060s | 0.121s ± 0.005s | +| Decode tok/s | **41.75 ± 0.22** | 40.41 ± 0.27 | +| Total time | 2.60s ± 0.064s | 2.67s ± 0.017s | +| Completion tokens | 103 | 103 | +| Runs | 5 measured + 1 warm-up | 10 measured + 1 warm-up | + +Raw results: `results--macbook-pro-m3.json`, `results--mac-studio-m2.json` + +**Verdict**: Essentially neck and neck. M3 MBP edges ahead on decode throughput (~3%); M2 Studio has more consistent TTFT. Both machines run the 14B model very comfortably. + +## Feasibility for Agentic Coding Use + +**GitHub Copilot CLI**: Not configurable — managed cloud service with a fixed backend. + +**Claude Code**: Technically possible via a LiteLLM proxy (to translate OpenAI → Anthropic API format), but the practical bottleneck is model capability rather than speed. Qwen2.5-Coder-14B handles single-shot coding tasks well (as demonstrated above), but complex multi-file agentic workflows require Sonnet-class reasoning. The hardware is not the constraint. + +## Setup + +```bash +bash vllm-mlx-install.sh +``` + +Then in a new terminal: + +```bash +export PATH="$HOME/Library/Python/3.14/bin:$PATH" +vllm-mlx serve mlx-community/Qwen2.5-Coder-14B-Instruct-4bit --port 8000 --continuous-batching +``` + +Run the benchmark: + +```bash +BENCH_MACHINE="macbook-pro-m3" python3 vllm-mlx--benchmarker.py +``` diff --git a/vllm-mlx/results--mac-studio-m2.json b/vllm-mlx/results--mac-studio-m2.json new file mode 100644 index 0000000..df997bc --- /dev/null +++ b/vllm-mlx/results--mac-studio-m2.json @@ -0,0 +1,97 @@ +[ + { + "timestamp": "2026-06-03T02:47:01.246479+00:00", + "machine": "mac-studio-m2", + "model": "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", + "prompt": "Write a Python function that checks if a number is prime, with a docstring and type hints.", + "max_tokens": 256, + "seed": 42, + "temperature": 0.0, + "warmup_runs": 1, + "measured_runs": 10, + "runs": [ + { + "ttft_s": 0.1263, + "total_s": 2.6671, + "completion_tokens": 103, + "e2e_toks_per_s": 38.62, + "decode_toks_per_s": 40.54 + }, + { + "ttft_s": 0.1237, + "total_s": 2.6645, + "completion_tokens": 103, + "e2e_toks_per_s": 38.66, + "decode_toks_per_s": 40.54 + }, + { + "ttft_s": 0.1234, + "total_s": 2.6665, + "completion_tokens": 103, + "e2e_toks_per_s": 38.63, + "decode_toks_per_s": 40.5 + }, + { + "ttft_s": 0.123, + "total_s": 2.6659, + "completion_tokens": 103, + "e2e_toks_per_s": 38.64, + "decode_toks_per_s": 40.51 + }, + { + "ttft_s": 0.125, + "total_s": 2.665, + "completion_tokens": 103, + "e2e_toks_per_s": 38.65, + "decode_toks_per_s": 40.55 + }, + { + "ttft_s": 0.1127, + "total_s": 2.6615, + "completion_tokens": 103, + "e2e_toks_per_s": 38.7, + "decode_toks_per_s": 40.41 + }, + { + "ttft_s": 0.123, + "total_s": 2.6692, + "completion_tokens": 103, + "e2e_toks_per_s": 38.59, + "decode_toks_per_s": 40.45 + }, + { + "ttft_s": 0.1237, + "total_s": 2.6672, + "completion_tokens": 103, + "e2e_toks_per_s": 38.62, + "decode_toks_per_s": 40.5 + }, + { + "ttft_s": 0.1149, + "total_s": 2.66, + "completion_tokens": 103, + "e2e_toks_per_s": 38.72, + "decode_toks_per_s": 40.47 + }, + { + "ttft_s": 0.1185, + "total_s": 2.7166, + "completion_tokens": 103, + "e2e_toks_per_s": 37.92, + "decode_toks_per_s": 39.64 + } + ], + "summary": { + "ttft_mean_s": 0.1214, + "ttft_std_s": 0.0045, + "total_mean_s": 2.6704, + "total_std_s": 0.0165, + "completion_tokens_mean": 103, + "completion_tokens_std": 0.0, + "e2e_toks_per_s_mean": 38.575, + "e2e_toks_per_s_std": 0.2333, + "decode_toks_per_s_mean": 40.411, + "decode_toks_per_s_std": 0.2744 + } + } +] \ No newline at end of file diff --git a/vllm-mlx/results--macbook-pro-m3.json b/vllm-mlx/results--macbook-pro-m3.json new file mode 100644 index 0000000..df394de --- /dev/null +++ b/vllm-mlx/results--macbook-pro-m3.json @@ -0,0 +1,62 @@ +[ + { + "timestamp": "2026-06-03T03:50:34.747770+00:00", + "machine": "macbook-pro-m3", + "model": "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", + "prompt": "Write a Python function that checks if a number is prime, with a docstring and type hints.", + "max_tokens": 256, + "seed": 42, + "temperature": 0.0, + "warmup_runs": 1, + "measured_runs": 5, + "runs": [ + { + "ttft_s": 0.2449, + "total_s": 2.7168, + "completion_tokens": 103, + "e2e_toks_per_s": 37.91, + "decode_toks_per_s": 41.67 + }, + { + "ttft_s": 0.1132, + "total_s": 2.594, + "completion_tokens": 103, + "e2e_toks_per_s": 39.71, + "decode_toks_per_s": 41.52 + }, + { + "ttft_s": 0.1104, + "total_s": 2.5808, + "completion_tokens": 103, + "e2e_toks_per_s": 39.91, + "decode_toks_per_s": 41.69 + }, + { + "ttft_s": 0.11, + "total_s": 2.5556, + "completion_tokens": 103, + "e2e_toks_per_s": 40.3, + "decode_toks_per_s": 42.12 + }, + { + "ttft_s": 0.1112, + "total_s": 2.5775, + "completion_tokens": 103, + "e2e_toks_per_s": 39.96, + "decode_toks_per_s": 41.76 + } + ], + "summary": { + "ttft_mean_s": 0.1379, + "ttft_std_s": 0.0598, + "total_mean_s": 2.6049, + "total_std_s": 0.064, + "completion_tokens_mean": 103, + "completion_tokens_std": 0.0, + "e2e_toks_per_s_mean": 39.558, + "e2e_toks_per_s_std": 0.9454, + "decode_toks_per_s_mean": 41.752, + "decode_toks_per_s_std": 0.2235 + } + } +] \ No newline at end of file diff --git a/vllm-mlx/vllm-mlx--benchmarker.py b/vllm-mlx/vllm-mlx--benchmarker.py new file mode 100755 index 0000000..90cc742 --- /dev/null +++ b/vllm-mlx/vllm-mlx--benchmarker.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +vllm-mlx benchmarker — single-prompt, batch-size-1 throughput measurement. + +Usage: + BENCH_MACHINE="mac-studio-m2" python3 vllm-mlx--benchmarker.py [--runs N] + +Results are appended to results--.json. +""" + +import argparse +import json +import os +import re +import statistics +import time +from datetime import datetime, timezone +from openai import OpenAI + +# ── Config ──────────────────────────────────────────────────────────────────── +BASE_URL = "http://localhost:8000/v1" +MODEL = "mlx-community/Qwen2.5-Coder-14B-Instruct-4bit" +PROMPT = "Write a Python function that checks if a number is prime, with a docstring and type hints." +MAX_TOKENS = 256 +SEED = 42 +TEMP = 0.0 +WARMUP = 1 +# ───────────────────────────────────────────────────────────────────────────── + +client = OpenAI(base_url=BASE_URL, api_key="not-needed") + + +def slugify(s: str) -> str: + return re.sub(r"[^a-zA-Z0-9_-]", "-", s).strip("-") + + +def run_once() -> dict: + """Run one inference pass; return timing + token stats.""" + ttft = None + completion_tokens = None + t_start = time.perf_counter() + + stream = client.chat.completions.create( + model=MODEL, + messages=[{"role": "user", "content": PROMPT}], + max_tokens=MAX_TOKENS, + temperature=TEMP, + seed=SEED, + stream=True, + stream_options={"include_usage": True}, + ) + + for chunk in stream: + # Capture TTFT on first chunk that contains actual content + if ttft is None: + delta = chunk.choices[0].delta.content if chunk.choices else None + if delta: + ttft = time.perf_counter() - t_start + + # Final chunk carries usage + if chunk.usage: + completion_tokens = chunk.usage.completion_tokens + + t_total = time.perf_counter() - t_start + + decode_time = t_total - (ttft or 0) + return { + "ttft_s": round(ttft, 4) if ttft else None, + "total_s": round(t_total, 4), + "completion_tokens": completion_tokens, + "e2e_toks_per_s": round(completion_tokens / t_total, 2) if completion_tokens else None, + "decode_toks_per_s": round(completion_tokens / decode_time, 2) if (completion_tokens and decode_time > 0) else None, + } + + +def mean_std(values): + if len(values) < 2: + return round(values[0], 4), 0.0 + return round(statistics.mean(values), 4), round(statistics.stdev(values), 4) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--runs", type=int, default=5, help="Number of measured runs (default: 5)") + args = parser.parse_args() + + machine = slugify(os.environ.get("BENCH_MACHINE", "unknown")) + print(f"Machine : {machine}") + print(f"Model : {MODEL}") + print(f"Prompt : {PROMPT}") + print(f"Runs : {WARMUP} warm-up + {args.runs} measured | max_tokens={MAX_TOKENS} seed={SEED} temp={TEMP}") + print() + + # Warm-up (excluded from stats) + print(f"[warm-up 1/{WARMUP}] ", end="", flush=True) + run_once() + print("done") + print() + + results = [] + for i in range(1, args.runs + 1): + print(f"[run {i}/{args.runs}] ", end="", flush=True) + r = run_once() + results.append(r) + print( + f"ttft={r['ttft_s']}s total={r['total_s']}s " + f"tokens={r['completion_tokens']} " + f"decode={r['decode_toks_per_s']} tok/s" + ) + + # Aggregate + print() + print("=" * 60) + + def agg(key): + vals = [r[key] for r in results if r.get(key) is not None] + return mean_std(vals) if vals else (None, None) + + ttft_mean, ttft_std = agg("ttft_s") + total_mean, total_std = agg("total_s") + tok_mean, tok_std = agg("completion_tokens") + e2e_mean, e2e_std = agg("e2e_toks_per_s") + dec_mean, dec_std = agg("decode_toks_per_s") + + print(f"TTFT : {ttft_mean}s ± {ttft_std}s") + print(f"Total time : {total_mean}s ± {total_std}s") + print(f"Completion toks : {tok_mean} ± {tok_std}") + print(f"E2E tok/s : {e2e_mean} ± {e2e_std}") + print(f"Decode tok/s : {dec_mean} ± {dec_std}") + print("=" * 60) + + # Persist + record = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "machine": machine, + "model": MODEL, + "prompt": PROMPT, + "max_tokens": MAX_TOKENS, + "seed": SEED, + "temperature": TEMP, + "warmup_runs": WARMUP, + "measured_runs": args.runs, + "runs": results, + "summary": { + "ttft_mean_s": ttft_mean, "ttft_std_s": ttft_std, + "total_mean_s": total_mean, "total_std_s": total_std, + "completion_tokens_mean": tok_mean, "completion_tokens_std": tok_std, + "e2e_toks_per_s_mean": e2e_mean, "e2e_toks_per_s_std": e2e_std, + "decode_toks_per_s_mean": dec_mean, "decode_toks_per_s_std": dec_std, + }, + } + + out_file = f"results--{machine}.json" + existing = [] + if os.path.exists(out_file): + with open(out_file) as f: + existing = json.load(f) + existing.append(record) + with open(out_file, "w") as f: + json.dump(existing, f, indent=2) + print(f"\nResults appended to {out_file}") + + +if __name__ == "__main__": + main() diff --git a/vllm-mlx/vllm-mlx--install.sh b/vllm-mlx/vllm-mlx--install.sh new file mode 100755 index 0000000..08180ea --- /dev/null +++ b/vllm-mlx/vllm-mlx--install.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +# 1. Update Homebrew and upgrade the core MLX C++ library +echo "==> Updating Homebrew and upgrading mlx library..." +brew install mlx +brew update && brew upgrade mlx + +# 2. Remove the mismatched Python packages +echo "==> Uninstalling old mlx and vllm-mlx wheels..." +python3 -m pip uninstall -y mlx vllm-mlx --break-system-packages + +# 3. Reinstall by forcing a local compilation from source +echo "==> Installing vllm-mlx and compiling mlx from source (this may take a minute)..." +python3 -m pip install mlx vllm-mlx openai --no-binary mlx --break-system-packages + +# 4. Ensure the Python 3.14 bin directory is available in the current shell session +echo "==> Ensuring binary path is in the current environment..." +export PATH="$HOME/Library/Python/3.14/bin:$PATH" + +# 5. Launch the vllm-mlx server +echo "==> Starting the vllm-mlx server..." +vllm-mlx serve mlx-community/Qwen2.5-Coder-14B-Instruct-4bit --port 8000 --continuous-batching diff --git a/vllm-mlx/vllm-mlx--smoke-test.py b/vllm-mlx/vllm-mlx--smoke-test.py new file mode 100755 index 0000000..a9eaddc --- /dev/null +++ b/vllm-mlx/vllm-mlx--smoke-test.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python3 + +from openai import OpenAI + +client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed") +r = client.chat.completions.create(model="mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", messages=[{"role": "user", "content": "Hi!"}]) +print(r.choices[0].message.content) diff --git a/vllm-mlx/vllm-mlx--try-it.py b/vllm-mlx/vllm-mlx--try-it.py new file mode 100755 index 0000000..827ae7c --- /dev/null +++ b/vllm-mlx/vllm-mlx--try-it.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 + +import time +from openai import OpenAI + +PROMPT = "Write a Python function that checks if a number is prime, with a docstring and type hints." + +client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed") + +print(f"Prompt: {PROMPT}\n") +print("=" * 60) + +chunks = [] +ttft = None +t_start = time.perf_counter() + +stream = client.chat.completions.create( + model="mlx-community/Qwen2.5-Coder-14B-Instruct-4bit", + messages=[{"role": "user", "content": PROMPT}], + stream=True, +) + +for chunk in stream: + delta = chunk.choices[0].delta.content or "" + if delta: + if ttft is None: + ttft = time.perf_counter() - t_start + chunks.append(delta) + print(delta, end="", flush=True) + +t_total = time.perf_counter() - t_start +full_response = "".join(chunks) +token_count = len(full_response.split()) # rough proxy; word count ≈ token count + +print("\n" + "=" * 60) +print(f"Time to first token : {ttft:.2f}s") +print(f"Total time : {t_total:.2f}s") +print(f"~Words generated : {token_count}") +print(f"~Words/sec : {token_count / (t_total - ttft):.1f}") +