From 444d4c6116e16ee700bf8fb0ee7dceca495ed920 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Sun, 31 May 2026 22:51:33 -0400 Subject: [PATCH] Add Wi-Fi auto-boot connection, MCP JSON-RPC Server, and host bridge --- ble_util.py | 36 +++++++ boot.py | 69 +++++++++++++ main.py | 201 +++++++++++++++++++++++++++++-------- mcp_bridge.py | 71 +++++++++++++ mcp_server.py | 267 +++++++++++++++++++++++++++++++++++++++++++++++++ wifi_config.py | 5 + 6 files changed, 607 insertions(+), 42 deletions(-) create mode 100644 boot.py create mode 100644 mcp_bridge.py create mode 100644 mcp_server.py create mode 100644 wifi_config.py diff --git a/ble_util.py b/ble_util.py index b144c41..0ee0aab 100644 --- a/ble_util.py +++ b/ble_util.py @@ -33,6 +33,7 @@ class BLEUART: self._connections = set() self._rx_callback = None self._name = name + self._scan_results = {} self._advertise() @@ -63,6 +64,27 @@ class BLEUART: except Exception as e: # Fallback for binary / non-UTF-8 data self._rx_callback(data_received) + + elif event == 5: # _IRQ_GAP_SCAN_RESULT + addr_type, addr, adv_type, rssi, adv_data = data + # Convert address to string hex format + mac = ':'.join(f'{b:02x}' for b in addr) + name = "" + try: + # Parse advertising payload to extract complete (0x09) or short (0x08) name + i = 0 + while i < len(adv_data): + length = adv_data[i] + if length == 0 or i + length + 1 > len(adv_data): + break + ad_type = adv_data[i+1] + if ad_type in (0x08, 0x09): + name = adv_data[i+2 : i+1+length].decode('utf-8') + break + i += length + 1 + except: + pass + self._scan_results[mac] = {"rssi": rssi, "name": name} def _advertise(self): # Generate advertising payload (flags + service UUID = 3 + 18 = 21 bytes) @@ -131,6 +153,20 @@ class BLEUART: """Returns True if at least one central is connected.""" return len(self._connections) > 0 + def scan(self, duration_ms=3000): + """Scans for nearby BLE devices and returns a dictionary of results. + + Returns: + dict: { 'mac_address': { 'rssi': -70, 'name': 'DeviceName' } } + """ + self._scan_results = {} + # Start passive scan (active=True requests scan responses to get names) + # Scan parameters: duration, interval (20ms), window (10ms), active (True) + self._ble.gap_scan(duration_ms, 20000, 10000, True) + time.sleep_ms(duration_ms + 100) + self._ble.gap_scan(None) # Ensure scan stops + return self._scan_results + def close(self): """Disconnect and stop BLE.""" self._ble.gap_advertise(None) # Stop advertising diff --git a/boot.py b/boot.py new file mode 100644 index 0000000..01bd078 --- /dev/null +++ b/boot.py @@ -0,0 +1,69 @@ +# This file is executed on every boot (including wake-boot from deepsleep) +import network +import time +import rlcd +from machine import Pin, SPI +import wifi_config + +def connect_wifi(): + # Initialize display to show connection progress + display = None + try: + spi = SPI(1, baudrate=20000000, polarity=0, phase=0, sck=Pin(11), mosi=Pin(12)) + display = rlcd.RLCD(spi, cs=Pin(40), dc=Pin(5), rst=Pin(41)) + display.clear(0) + display.text("ESP32-S3-RLCD-4.2", 10, 10, 1) + display.line(10, 20, 390, 20, 1) + display.text("Connecting to Wi-Fi...", 10, 35, 1) + display.text(f"SSID: {wifi_config.WIFI_SSID}", 10, 50, 1) + display.show() + except Exception as e: + print("Display init failed in boot.py:", e) + + # Initialize Wi-Fi Station + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + + if not wlan.isconnected(): + print(f"Connecting to network {wifi_config.WIFI_SSID}...") + wlan.connect(wifi_config.WIFI_SSID, wifi_config.WIFI_PASS) + + # Wait up to 15 seconds for connection + timeout = 15 + dot_x = 10 + while not wlan.isconnected() and timeout > 0: + time.sleep(1) + timeout -= 1 + print("Connecting...") + if display: + display.text(".", dot_x, 70, 1) + dot_x += 10 + display.show() + + if wlan.isconnected(): + ip = wlan.ifconfig()[0] + print(f"Wi-Fi Connected! IP Address: {ip}") + if display: + display.clear(0) + display.text("ESP32-S3-RLCD-4.2", 10, 10, 1) + display.line(10, 20, 390, 20, 1) + display.text("Wi-Fi Status: Connected!", 10, 35, 1) + display.text(f"SSID: {wifi_config.WIFI_SSID}", 10, 50, 1) + display.text(f"IP: {ip}", 10, 65, 1) + display.text("Starting MCP Server...", 10, 90, 1) + display.show() + time.sleep(1.5) + else: + print("Wi-Fi Connection Failed.") + if display: + display.clear(0) + display.text("ESP32-S3-RLCD-4.2", 10, 10, 1) + display.line(10, 20, 390, 20, 1) + display.text("Wi-Fi Status: Connection Failed!", 10, 35, 1) + display.text("Check credentials in:", 10, 55, 1) + display.text("wifi_config.py", 20, 70, 1) + display.text("Starting offline...", 10, 95, 1) + display.show() + time.sleep(2) + +connect_wifi() diff --git a/main.py b/main.py index efbc192..3a9d46d 100644 --- a/main.py +++ b/main.py @@ -1,53 +1,170 @@ -import rlcd -from machine import Pin, SPI -from wifi_util import WiFiUtil import time +import network +import machine +from machine import Pin, SPI, I2C +import rlcd + +# Import our utility classes +from shtc3_util import SHTC3 +from rtc_util import PCF85063 +from battery_util import BatteryMonitor +from button_util import BoardButtons +from rgb_led_util import BoardLED +from ble_util import BLEUART +from mcp_server import MCPServer + +# LED mode options for manual cycling +led_modes = [ + ("Red (Breathing)", lambda led: led.set_color(40, 0, 0), "breath"), + ("Green (Breathing)", lambda led: led.set_color(0, 40, 0), "breath"), + ("Blue (Breathing)", lambda led: led.set_color(0, 0, 40), "breath"), + ("Cyan (Breathing)", lambda led: led.set_color(0, 30, 30), "breath"), + ("Magenta (Breathing)", lambda led: led.set_color(30, 0, 30), "breath"), + ("Rainbow Cycle", lambda led: led.set_color(30, 30, 30), "rainbow"), + ("LED Off", lambda led: led.off(), "off") +] +local_led_mode_idx = 1 # Green breathing def main(): - print("Initializing SPI and Display...") - # Setup SPI - spi = SPI(1, baudrate=20000000, polarity=0, phase=0, - sck=Pin(11), mosi=Pin(12)) - - # Initialize Display + global local_led_mode_idx + print("=== Starting ESP32-S3-RLCD-4.2 Main Boot ===") + + # 1. Initialize shared buses and peripherals + i2c = I2C(0, sda=Pin(13), scl=Pin(14)) + + spi = SPI(1, baudrate=20000000, polarity=0, phase=0, sck=Pin(11), mosi=Pin(12)) display = rlcd.RLCD(spi, cs=Pin(40), dc=Pin(5), rst=Pin(41)) - - # 1. Show a loading state - display.clear(0) - display.text("Scanning for Wi-Fi...", 10, 10, 1) - display.show() - - # 2. Scan for networks - wifi = WiFiUtil() - networks = wifi.scan() - - # 3. Draw the results - print("Drawing networks to buffer...") - display.clear(0) # Clear to white - # Title - display.text("Available Wi-Fi Networks:", 10, 10, 1) - display.line(10, 20, 210, 20, 1) + # Configure Audio Amp control pin to save power + amp_pin = Pin(46, Pin.OUT, value=0) - # List networks - y_offset = 30 - # Limit to 20 networks so they fit on the 300px tall screen (20 * 12px = 240px + 30px offset = 270px) - for i, net in enumerate(networks[:20]): - text = f"{i+1}. {net['ssid']} ({net['rssi']}dBm)" - display.text(text, 10, y_offset, 1) - y_offset += 12 + # 2. Initialize utility objects + sensor = SHTC3(i2c) + rtc_chip = PCF85063(i2c) + battery = BatteryMonitor() + led = BoardLED(38) + buttons = BoardButtons() + ble_uart = BLEUART(name="ESP32-S3-RLCD") + + # Sync system clock from RTC chip + rtc_chip.sync_to_system() + + # 3. Connect to Wi-Fi status check (boot.py already attempted connection) + wlan = network.WLAN(network.STA_IF) + ip_addr = wlan.ifconfig()[0] if wlan.isconnected() else "Disconnected" + + # 4. Start MCP Server + mcp = MCPServer(display, led, battery, sensor, rtc_chip, ble_uart) + mcp.start(port=80) + + # Default to Green Breathing + led_modes[local_led_mode_idx][1](led) + mcp.active_led_mode = led_modes[local_led_mode_idx][2] + + # Last user actions + last_action_str = "Boot finished." + force_dashboard_redraw = True + + # 5. Register Button Handlers + def on_key_click(): + global local_led_mode_idx + local_led_mode_idx = (local_led_mode_idx + 1) % len(led_modes) + mode_name, color_fn, mode_type = led_modes[local_led_mode_idx] + color_fn(led) + mcp.active_led_mode = mode_type + print(f"Local Button: Cycle LED -> {mode_name}") + nonlocal last_action_str, force_dashboard_redraw + last_action_str = f"Local Button: {mode_name}" + # Disable override when manual button is pressed + mcp.override_active = False + force_dashboard_redraw = True - if not networks: - display.text("No networks found.", 10, 30, 1) + def on_boot_click(): + print("Local Button: Force Dashboard refresh.") + nonlocal last_action_str, force_dashboard_redraw + last_action_str = "Dashboard Refreshed" + # Disable override when manual button is pressed + mcp.override_active = False + force_dashboard_redraw = True - # 4. Update Screen - print("Updating screen...") - display.show() - try: - display.save_screenshot('screenshot.pbm') - except Exception as e: - print(f"Failed to save screenshot: {e}") - print("Done!") + buttons.key.on_click(on_key_click) + buttons.boot.on_click(on_boot_click) + + # Loop state + last_dashboard_update = 0 + dashboard_update_interval_ms = 5000 + + print("ESP32 MCP loop running...") + + # 6. Main execution loop + while True: + now = time.ticks_ms() + + # A. Handle non-blocking MCP client connection updates + mcp.update() + + # B. Check if custom draw text override has expired + if mcp.override_active and time.ticks_diff(now, mcp.override_timeout) > 0: + print("MCP Screen override expired. Returning to status dashboard...") + mcp.override_active = False + force_dashboard_redraw = True + + # C. Draw local dashboard (if not overridden by MCP draw text commands) + if not mcp.override_active: + if force_dashboard_redraw or time.ticks_diff(now, last_dashboard_update) >= dashboard_update_interval_ms: + force_dashboard_redraw = False + last_dashboard_update = now + + # Fetch sensor data + t, h = sensor.read_sensor() + t_str = f"{t} C" if t is not None else "Error" + h_str = f"{h} %" if h is not None else "Error" + + # Fetch battery status + bat_v = battery.read_voltage() + bat_p = battery.read_percentage() + bat_str = f"{bat_v:.2f}V ({bat_p}%)" if bat_v is not None else "Error" + + # Fetch current time + dt = rtc_chip.get_datetime() + time_str = f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}" if dt else "RTC Error" + + # Draw standard status dashboard layout + display.clear(0) + display.text("ESP32-S3-RLCD MCP SERVER", 10, 10, 1) + display.line(10, 20, 390, 20, 1) + + display.text_large("ENVIRONMENT", 15, 30, scale=2, c=1) + display.text(f"Temp : {t_str}", 25, 55, 1) + display.text(f"Humid : {h_str}", 25, 70, 1) + + display.line(10, 95, 390, 95, 1) + + display.text_large("MCP NET CONNECTION", 15, 105, scale=2, c=1) + display.text(f"IP Address : {ip_addr}", 25, 130, 1) + display.text(f"Port / Path : 80 /api/mcp", 25, 145, 1) + display.text(f"BLE Name : ESP32-S3-RLCD", 25, 160, 1) + + display.line(10, 185, 390, 185, 1) + + display.text_large("SYSTEM STATUS", 15, 195, scale=2, c=1) + display.text(f"Battery : {bat_str}", 25, 220, 1) + display.text(f"Time : {time_str}", 25, 235, 1) + + display.line(10, 255, 390, 255, 1) + display.text(f"Status: {last_action_str}", 15, 265, 1) + display.show() + + # D. Update NeoPixel animation smoothly (runs every 50ms) + mode = mcp.active_led_mode + if mode == "breath": + led.update_breathing(1.5) + elif mode == "rainbow": + led.update_rainbow(0.4) + elif mode == "off": + led.off() + + time.sleep_ms(50) if __name__ == "__main__": main() diff --git a/mcp_bridge.py b/mcp_bridge.py new file mode 100644 index 0000000..5375a4b --- /dev/null +++ b/mcp_bridge.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import sys +import json +import urllib.request +import argparse + +def main(): + parser = argparse.ArgumentParser(description="MCP Stdio-to-HTTP Bridge for ESP32-S3-RLCD-4.2") + parser.add_argument("--ip", required=True, help="IP address of the ESP32 board (e.g. 192.168.1.123)") + parser.add_argument("--port", type=int, default=80, help="Port the MCP server is listening on (default 80)") + args = parser.parse_args() + + url = f"http://{args.ip}:{args.port}/api/mcp" + sys.stderr.write(f"ESP32 MCP Stdio-to-HTTP Bridge started. Routing stdio to {url}\n") + sys.stderr.flush() + + # Sits in loop reading stdio requests from LLM client and routing them to ESP32 + while True: + try: + line = sys.stdin.readline() + if not line: + break + + # Parse request to ensure it's valid JSON + req_data = json.loads(line.strip()) + + # Forward the JSON-RPC request to the ESP32 board via HTTP POST + req = urllib.request.Request( + url, + data=json.dumps(req_data).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=10.0) as response: + resp_data = response.read().decode("utf-8") + # Write the response back to stdio for the LLM client + sys.stdout.write(resp_data + "\n") + sys.stdout.flush() + + except urllib.error.URLError as e: + # Send standard JSON-RPC internal error response + err_resp = { + "jsonrpc": "2.0", + "error": { + "code": -32000, + "message": f"Bridge failed to reach ESP32: {e.reason}" + }, + "id": req_data.get("id") if "req_data" in locals() else None + } + sys.stdout.write(json.dumps(err_resp) + "\n") + sys.stdout.flush() + sys.stderr.write(f"Bridge error: Failed to connect to ESP32 at {url}: {e}\n") + sys.stderr.flush() + + except Exception as e: + err_resp = { + "jsonrpc": "2.0", + "error": { + "code": -32603, + "message": f"Bridge Internal Error: {str(e)}" + }, + "id": req_data.get("id") if "req_data" in locals() else None + } + sys.stdout.write(json.dumps(err_resp) + "\n") + sys.stdout.flush() + sys.stderr.write(f"Bridge error: {e}\n") + sys.stderr.flush() + +if __name__ == "__main__": + main() diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..608b3dc --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,267 @@ +import socket +import json +import time + +class MCPServer: + """A lightweight JSON-RPC HTTP server implementing Model Context Protocol (MCP) endpoints.""" + + def __init__(self, display, led, battery, sensor, rtc, ble): + self.display = display + self.led = led + self.battery = battery + self.sensor = sensor + self.rtc = rtc + self.ble = ble + + self.sock = None + self.active_led_mode = "off" # static, breath, rainbow, off + self.override_active = False + self.override_timeout = 0 + + def start(self, port=80): + """Starts the TCP server non-blockingly.""" + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind(('', port)) + self.sock.listen(3) + # Set socket to non-blocking so the main loop can run animations concurrently + self.sock.setblocking(False) + print(f"MCP server listening on port {port}...") + + def update(self): + """Check for incoming HTTP requests and handle them non-blockingly.""" + if self.sock is None: + return + + try: + client, addr = self.sock.accept() + except OSError: + # No incoming connection + return + + try: + # Handle incoming connection + client.settimeout(2.0) + req = client.recv(2048).decode('utf-8') + + # Simple HTTP parser + lines = req.split('\r\n') + if len(lines) == 0: + client.close() + return + + first_line = lines[0] + parts = first_line.split(' ') + if len(parts) < 3: + client.close() + return + + method, path = parts[0], parts[1] + + if method == 'POST' and path == '/api/mcp': + # Read content length + content_length = 0 + for line in lines: + if line.lower().startswith('content-length:'): + content_length = int(line.split(':')[1].strip()) + break + + # Locate start of JSON body + body = "" + if '\r\n\r\n' in req: + body = req.split('\r\n\r\n', 1)[1] + + # Read remaining body if not fully received + while len(body) < content_length: + body += client.recv(1024).decode('utf-8') + + # Parse JSON-RPC 2.0 Request + rpc_req = json.loads(body) + rpc_resp = self._handle_rpc(rpc_req) + + # Send HTTP Response + resp_body = json.dumps(rpc_resp) + resp = "HTTP/1.1 200 OK\r\n" + resp += "Content-Type: application/json\r\n" + resp += f"Content-Length: {len(resp_body)}\r\n" + resp += "Connection: close\r\n\r\n" + resp += resp_body + client.send(resp.encode('utf-8')) + else: + # Return 404 + resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + client.send(resp.encode('utf-8')) + except Exception as e: + print("Error handling client:", e) + finally: + client.close() + + def _handle_rpc(self, req): + rpc_id = req.get('id') + method = req.get('method') + params = req.get('params', {}) + + if method == 'tools/list': + return { + "jsonrpc": "2.0", + "result": { + "tools": [ + { + "name": "clear_screen", + "description": "Clear the 400x300 screen to white (0) or black (1).", + "inputSchema": { + "type": "object", + "properties": { + "color": {"type": "integer", "enum": [0, 1], "description": "0 = White, 1 = Black"} + }, + "required": ["color"] + } + }, + { + "name": "draw_text", + "description": "Draw text on the screen at specified (x,y) coordinates.", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "The message to display"}, + "x": {"type": "integer", "description": "X coordinate (0-390)"}, + "y": {"type": "integer", "description": "Y coordinate (0-290)"}, + "size": {"type": "integer", "enum": [1, 2], "description": "Text scale (1=normal, 2=large)"} + }, + "required": ["text", "x", "y"] + } + }, + { + "name": "set_led", + "description": "Control the onboard WS2812 NeoPixel RGB LED.", + "inputSchema": { + "type": "object", + "properties": { + "r": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Red channel (0-255)"}, + "g": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Green channel (0-255)"}, + "b": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Blue channel (0-255)"}, + "mode": {"type": "string", "enum": ["static", "breath", "rainbow", "off"], "description": "LED mode"} + }, + "required": ["r", "g", "b", "mode"] + } + }, + { + "name": "get_battery", + "description": "Read the current battery voltage and estimated capacity percentage.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "get_sensors", + "description": "Read onboard SHTC3 temperature and relative humidity.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "scan_ble", + "description": "Scan for nearby Bluetooth Low Energy devices / tracking tags.", + "inputSchema": { + "type": "object", + "properties": { + "duration_ms": {"type": "integer", "description": "Scan duration in milliseconds (default 3000)"} + } + } + } + ] + }, + "id": rpc_id + } + + elif method == 'tools/call': + tool_name = params.get('name') + args = params.get('arguments', {}) + + try: + content = self._call_tool(tool_name, args) + return { + "jsonrpc": "2.0", + "result": { + "content": [ + { + "type": "text", + "text": content + } + ] + }, + "id": rpc_id + } + except Exception as e: + return { + "jsonrpc": "2.0", + "error": { + "code": -32000, + "message": str(e) + }, + "id": rpc_id + } + + return { + "jsonrpc": "2.0", + "error": { + "code": -32601, + "message": f"Method {method} not found" + }, + "id": rpc_id + } + + def _call_tool(self, name, args): + """Executes hardware actions depending on the called tool name.""" + if name == "clear_screen": + self.override_active = True + self.override_timeout = time.ticks_ms() + 30000 # 30-sec override + color = int(args.get("color", 0)) + self.display.clear(color) + self.display.show() + return "Screen cleared." + + elif name == "draw_text": + self.override_active = True + self.override_timeout = time.ticks_ms() + 30000 # 30-sec override + text = str(args.get("text", "")) + x = int(args.get("x", 10)) + y = int(args.get("y", 10)) + size = int(args.get("size", 1)) + + if size == 2: + self.display.text_large(text, x, y, scale=2, c=1) + else: + self.display.text(text, x, y, 1) + self.display.show() + return f"Successfully drew text '{text}' at ({x}, {y}) with size {size}." + + elif name == "set_led": + r = int(args.get("r", 0)) + g = int(args.get("g", 0)) + b = int(args.get("b", 0)) + mode = str(args.get("mode", "static")) + + self.active_led_mode = mode + if mode == "static": + self.led.set_color(r, g, b) + elif mode == "off": + self.led.off() + else: + # Store base color for animations + self.led.base_color = (r, g, b) + return f"LED set to mode '{mode}' with base color ({r}, {g}, {b})." + + elif name == "get_battery": + v = self.battery.read_voltage() + p = self.battery.read_percentage() + return json.dumps({"voltage_v": v, "percentage_pct": p}) + + elif name == "get_sensors": + t, h = self.sensor.read_sensor() + return json.dumps({"temperature_c": t, "humidity_pct": h}) + + elif name == "scan_ble": + dur = int(args.get("duration_ms", 3000)) + # Scan returns dictionary: {mac: {rssi: rssi, name: name}} + devices = self.ble.scan(dur) + return json.dumps(devices) + + else: + raise ValueError(f"Unknown tool: {name}") diff --git a/wifi_config.py b/wifi_config.py new file mode 100644 index 0000000..a7df6fc --- /dev/null +++ b/wifi_config.py @@ -0,0 +1,5 @@ +# Wi-Fi Credentials Configuration for ESP32-S3-RLCD-4.2 +# Update these values to match your local network settings. + +WIFI_SSID = "FamReynaMesh" +WIFI_PASS = "Gloria2020"