"""CircuitPython MCP Server implementation. Provides a lightweight JSON-RPC HTTP server and UDP discovery responder, allowing a host PC to invoke hardware controls, screen drawing, and file/audio utility tools. """ import json import time import binascii import sys import io import builtins import os try: import adafruit_framebuf except ImportError: adafruit_framebuf = None class MCPServer: def __init__(self, display, led, battery, sensor, rtc, ble, pool, vstream=None, touch=None, session=None, audio=None): self.display = display self.led = led self.battery = battery self.sensor = sensor self.rtc = rtc self.ble = ble self.pool = pool self.vstream = vstream self.touch = touch self.session = session self.audio = audio self.sock = None self.udp_sock = None self.udp_buffer = bytearray(128) self.active_led_mode = "off" # static, breath, rainbow, off self.override_active = False self.port = 80 def start(self, port=80): """Starts the TCP server non-blockingly.""" self.port = port try: self.sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM) try: self.sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1) except: pass self.sock.bind(("", port)) self.sock.listen(3) self.sock.setblocking(False) print(f"MCP server listening on port {port}...") except Exception as e: print(f"Failed to start TCP MCP server on port {port}: {e}") self.sock = None # Setup UDP socket for auto-discovery on port 5000 try: self.udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM) try: self.udp_sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1) except: pass self.udp_sock.bind(("", 5000)) self.udp_sock.setblocking(False) print("UDP Discovery responder listening on port 5000...") except Exception as ue: print(f"Failed to bind UDP Discovery socket: {ue}") self.udp_sock = None def restart(self): """Re-initializes the listening sockets after a fatal error.""" print("Restarting MCP Server sockets...") if self.sock: try: self.sock.close() except: pass self.sock = None if self.udp_sock: try: self.udp_sock.close() except: pass self.udp_sock = None time.sleep(0.1) self.start(self.port) def _send_all(self, client, data): total_sent = 0 while total_sent < len(data): try: sent = client.send(data[total_sent:]) if sent is None or sent == 0: time.sleep(0.01) continue total_sent += sent except OSError as e: # EWOULDBLOCK or EAGAIN time.sleep(0.01) continue def _recv(self, client, size): buf = bytearray(size) try: n = client.recv_into(buf) return buf[:n] except OSError: return b"" def update(self): """Check for incoming HTTP requests and handle them non-blockingly.""" # 1. Handle UDP Discovery queries if self.udp_sock is not None: try: n, addr = self.udp_sock.recvfrom_into(self.udp_buffer) if n > 0: data = self.udp_buffer[:n] if data == b"DISCOVER_SCREEN": # Respond back to discovery query self.udp_sock.sendto(b"SCREEN_IP_80", addr) except OSError: pass # 2. Check for incoming HTTP TCP connections if self.sock is None: return client = None try: client, addr = self.sock.accept() except OSError as e: import errno err = getattr(e, 'errno', None) if err is None and e.args: err = e.args[0] ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN) if err in (errno.EAGAIN, ewouldblock) or err is None: # No incoming connection return # Fatal socket error print(f"Fatal socket error in MCP Server accept(): {e}. Re-initializing...") self.restart() return if client is not None: try: client.settimeout(2.0) req_bytes = self._recv(client, 2048) if not req_bytes: client.close() return header_end = req_bytes.find(b'\r\n\r\n') if header_end == -1: header_end = len(req_bytes) body_start_idx = len(req_bytes) else: body_start_idx = header_end + 4 header_text = req_bytes[:header_end].decode('utf-8', 'ignore') lines = header_text.split('\r\n') if len(lines) == 0 or not 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 body_bytes = bytearray(req_bytes[body_start_idx:]) while len(body_bytes) < content_length: chunk = self._recv(client, min(1024, content_length - len(body_bytes))) if not chunk: break body_bytes.extend(chunk) body_text = body_bytes.decode('utf-8', 'ignore') rpc_req = json.loads(body_text) rpc_resp = self._handle_rpc(rpc_req) 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 self._send_all(client, resp.encode('utf-8')) elif method == 'POST' and path.startswith('/api/screen/raw'): # Read content length content_length = 0 for line in lines: if line.lower().startswith('content-length:'): content_length = int(line.split(':')[1].strip()) break body_bytes = bytearray(req_bytes[body_start_idx:]) while len(body_bytes) < content_length: chunk = self._recv(client, min(1024, content_length - len(body_bytes))) if not chunk: break body_bytes.extend(chunk) # Parse query parameters from path (e.g. /api/screen/raw?x=0&y=0&w=320&h=240&format=mono) width = getattr(self.display, 'width', 320) height = getattr(self.display, 'height', 240) x, y, w, h = 0, 0, width, height fmt = None if '?' in path: q_str = path.split('?', 1)[1] for param in q_str.split('&'): if '=' in param: k, v = param.split('=', 1) if k == 'x': x = int(v) elif k == 'y': y = int(v) elif k == 'w': w = int(v) elif k == 'h': h = int(v) elif k == 'format': fmt = str(v) # Auto-detect format if not explicitly specified expected_mono_len = (w * h) // 8 expected_color_len = w * h * 2 is_mono = (fmt in ('mono', '1bit')) if fmt is None and len(body_bytes) == expected_mono_len and expected_mono_len != expected_color_len: is_mono = True self.override_active = True if is_mono: if hasattr(self.display, 'canvas_buffer'): if x == 0 and y == 0 and w == width and h == height: self.display.canvas_buffer[:] = body_bytes else: if x == 0 and (w % 8) == 0: dest_stride_bytes = width // 8 src_stride_bytes = w // 8 for cy in range(h): dy = y + cy if 0 <= dy < height: dest_offset = dy * dest_stride_bytes src_offset = cy * src_stride_bytes self.display.canvas_buffer[dest_offset : dest_offset + src_stride_bytes] = body_bytes[src_offset : src_offset + src_stride_bytes] else: import adafruit_framebuf src_fb = adafruit_framebuf.FrameBuffer(body_bytes, w, h, adafruit_framebuf.MHMSB) for cy in range(h): for cx in range(w): px = src_fb.pixel(cx, cy) self.display.canvas.pixel(x + cx, y + cy, px) self.display.show() else: if hasattr(self.display, 'draw_rgb565'): self.display.draw_rgb565(x, y, w, h, body_bytes, sync_canvas=False) resp_body = "OK" resp = "HTTP/1.1 200 OK\r\n" resp += "Content-Type: text/plain\r\n" resp += f"Content-Length: {len(resp_body)}\r\n" resp += "Connection: close\r\n\r\n" resp += resp_body self._send_all(client, 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" self._send_all(client, 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', {}) # Get active display dimensions dynamically to report correct screen resolution width = getattr(self.display, 'width', 320) height = getattr(self.display, 'height', 240) if method == 'tools/list': return { "jsonrpc": "2.0", "result": { "tools": [ { "name": "clear_screen", "description": f"Clear the {width}x{height} 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": f"Draw text on the screen at specified (x,y) coordinates. Screen resolution is {width}x{height}.", "inputSchema": { "type": "object", "properties": { "text": {"type": "string", "description": "The message to display"}, "x": {"type": "integer", "description": f"X coordinate (0-{width-1})"}, "y": {"type": "integer", "description": f"Y coordinate (0-{height-1})"}, "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_touch", "description": "Read the current touch state and coordinates from the capacitive touchscreen.", "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)"} } } }, { "name": "get_screenshot", "description": "Capture the current screen rendering as a PNG image (PBM format in base64).", "inputSchema": {"type": "object", "properties": {}} }, { "name": "draw_image", "description": f"Draw an image (PNG, JPEG, GIF, BMP, etc.) on the screen. The image will be converted to 1-bit monochrome and fit to display boundaries (max {width}x{height}).", "inputSchema": { "type": "object", "properties": { "image_base64": {"type": "string", "description": "Base64 encoded string of the source image file"}, "x": {"type": "integer", "description": "X coordinate to place the image (default 0)", "default": 0}, "y": {"type": "integer", "description": "Y coordinate to place the image (default 0)", "default": 0}, "dither": {"type": "boolean", "description": "Whether to use Floyd-Steinberg dithering (default true)", "default": True} }, "required": ["image_base64"] } }, { "name": "draw_color_bmp", "description": f"Draw a color BMP image on the {width}x{height} color screen at specified (x,y) coordinates.", "inputSchema": { "type": "object", "properties": { "bmp_base64": {"type": "string", "description": "Base64 encoded BMP image file (uncompressed 24-bit or 32-bit format)"}, "x": {"type": "integer", "description": "X coordinate to place the image (default 0)", "default": 0}, "y": {"type": "integer", "description": "Y coordinate to place the image (default 0)", "default": 0} }, "required": ["bmp_base64"] } }, { "name": "get_capabilities", "description": "Get screen capabilities (resolution, color support, and supported formats).", "inputSchema": {"type": "object", "properties": {}} }, { "name": "sync_time", "description": "Synchronize the hardware and system clock with an internet NTP server.", "inputSchema": {"type": "object", "properties": {}} }, { "name": "draw_raw_rgb565", "description": f"Draw raw RGB565 pixel data on the {width}x{height} screen at specified (x,y) coordinates with width (w) and height (h).", "inputSchema": { "type": "object", "properties": { "rgb565_base64": {"type": "string", "description": "Base64 encoded raw RGB565 pixel data (Big-Endian, 2 bytes per pixel)"}, "x": {"type": "integer", "description": "X coordinate to place the image"}, "y": {"type": "integer", "description": "Y coordinate to place the image"}, "w": {"type": "integer", "description": "Width of the raw pixel block"}, "h": {"type": "integer", "description": "Height of the raw pixel block"} }, "required": ["rgb565_base64", "x", "y", "w", "h"] } }, { "name": "write_file", "description": "Write a file (e.g., python script or config) to the board's flash storage.", "inputSchema": { "type": "object", "properties": { "path": {"type": "string", "description": "The destination file path (e.g. 'my_script.py')"}, "content": {"type": "string", "description": "The text content of the file"} }, "required": ["path", "content"] } }, { "name": "read_file", "description": "Read a text file from the board's flash storage.", "inputSchema": { "type": "object", "properties": { "path": {"type": "string", "description": "The file path to read (e.g. 'boot.py')"} }, "required": ["path"] } }, { "name": "execute_python", "description": "Execute arbitrary Python code dynamically on the board. Standard output (prints) and errors will be captured and returned.", "inputSchema": { "type": "object", "properties": { "code": {"type": "string", "description": "The Python code to execute"} }, "required": ["code"] } }, { "name": "play_tone", "description": "Play a pure sine wave tone/beep on the board speaker.", "inputSchema": { "type": "object", "properties": { "frequency": {"type": "integer", "description": "Frequency of the tone in Hz (e.g. 440 for A4, default 440)", "default": 440}, "duration_ms": {"type": "integer", "description": "Duration of the tone in milliseconds (default 1000)", "default": 1000}, "volume": {"type": "integer", "description": "Volume of the tone from 0 to 100 (default 50)", "default": 50} } } }, { "name": "play_audio", "description": "Play a standard WAV/MP3 audio file on the speaker.", "inputSchema": { "type": "object", "properties": { "filename": {"type": "string", "description": "WAV/MP3 file path to play (e.g. 'response.wav')"}, "volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50} }, "required": ["filename"] } }, { "name": "record_voice", "description": "Record voice command from the microphone to a raw stereo PCM file.", "inputSchema": { "type": "object", "properties": { "duration_sec": {"type": "integer", "description": "Duration in seconds (default 4)", "default": 4}, "filename": {"type": "string", "description": "Destination file path (e.g. 'recording.pcm')", "default": "recording.pcm"} } } }, { "name": "play_audio_base64", "description": "Decode a base64-encoded WAV file and play it directly on the speaker.", "inputSchema": { "type": "object", "properties": { "wav_base64": {"type": "string", "description": "Base64 encoded string of the WAV audio file"}, "volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50} }, "required": ["wav_base64"] } }, { "name": "play_gif", "description": "Play an animated GIF file on the screen.", "inputSchema": { "type": "object", "properties": { "filename": {"type": "string", "description": "GIF file path to play (e.g. 'test.gif')"}, "loops": {"type": "integer", "description": "Number of loops, -1 for infinite (default 1)", "default": 1}, "x": {"type": "integer", "description": "X coordinate to place the GIF (default 0)", "default": 0}, "y": {"type": "integer", "description": "Y coordinate to place the GIF (default 0)", "default": 0}, "max_frames": {"type": "integer", "description": "Maximum frames to play per loop (default -1)", "default": -1}, "clear_between_frames": {"type": "boolean", "description": "Clear the screen between frames (default false)", "default": False} }, "required": ["filename"] } }, { "name": "download_file", "description": "Download a file from a URL over Wi-Fi directly to the board storage or microSD card.", "inputSchema": { "type": "object", "properties": { "url": {"type": "string", "description": "The URL of the file to download"}, "filename": {"type": "string", "description": "The destination filename (e.g. 'podcast1.wav')"}, "use_sd": {"type": "boolean", "description": "Save to microSD card if true, or local flash if false (default true)", "default": True} }, "required": ["url", "filename"] } }, { "name": "get_video_streaming_instructions", "description": "Get detailed instructions and sample Python code to stream video directly into the screen using TCP or UDP.", "inputSchema": { "type": "object", "properties": { "protocol": {"type": "string", "enum": ["tcp", "udp", "both"], "description": "The streaming protocol to query (default 'both')"} } } }, { "name": "get_stream_stats", "description": "Get real-time device-side performance and frame-rate statistics for the TCP/UDP video stream.", "inputSchema": {"type": "object", "properties": {}} }, { "name": "set_backlight", "description": "Adjust the brightness of the LCD backlight.", "inputSchema": { "type": "object", "properties": { "brightness": {"type": "integer", "minimum": 0, "maximum": 100, "description": "Backlight brightness percentage (0-100)"} }, "required": ["brightness"] } }, { "name": "set_screen_power", "description": "Turn the screen/display on or off.", "inputSchema": { "type": "object", "properties": { "power": {"type": "boolean", "description": "True to turn display ON, False to turn display OFF"} }, "required": ["power"] } } ] }, "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.""" width = getattr(self.display, 'width', 320) height = getattr(self.display, 'height', 240) if name == "clear_screen": self.override_active = True color = int(args.get("color", 0)) self.display.clear(color) self.display.show() return "Screen cleared." elif name == "set_backlight": brightness = int(args.get("brightness", 100)) if hasattr(self.display, "set_brightness"): self.display.set_brightness(brightness) return f"Backlight brightness set to {brightness}%." else: return "Backlight brightness control not supported on this display." elif name == "set_screen_power": power = bool(args.get("power", True)) if hasattr(self.display, "set_power"): self.display.set_power(power) status = "ON" if power else "OFF" return f"Screen power set to {status}." else: return "Screen power control not supported on this display." elif name == "draw_text": self.override_active = True 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, color=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 == "get_touch": if self.touch is None: return json.dumps({"error": "Touchscreen not configured on this device."}) is_t = self.touch.is_touched() x, y = None, None if is_t: pt = self.touch.read_touch() if pt: x, y = pt return json.dumps({"is_touched": is_t, "x": x, "y": y}) elif name == "scan_ble": dur = int(args.get("duration_ms", 3000)) devices = self.ble.scan(dur) return json.dumps(devices) elif name == "get_screenshot": # Direct base64 PBM capture from canvas_buffer memory (no flash writes!) header = f"P4\n{width} {height}\n".encode() pbm_data = header + self.display.canvas_buffer b64 = binascii.b2a_base64(pbm_data).decode('utf-8').strip() return f"__PBM_BASE64__:{b64}" elif name == "draw_image": self.override_active = True pbm_b64 = args.get("pbm_base64") or args.get("image_base64") x = int(args.get("x", 0)) y = int(args.get("y", 0)) if not pbm_b64: raise ValueError("Missing image/pbm base64 parameter") pbm_bytes = binascii.a2b_base64(pbm_b64) # In-memory PBM parsing and blitting using adafruit_framebuf try: self._draw_pbm_from_bytes(pbm_bytes, x, y) self.display.show() return "Image drawn successfully." except Exception as e: raise RuntimeError(f"Failed to draw image: {e}") elif name == "draw_color_bmp": self.override_active = True bmp_b64 = args.get("bmp_base64") x = int(args.get("x", 0)) y = int(args.get("y", 0)) if not bmp_b64: raise ValueError("Missing bmp_base64 parameter") bmp_bytes = binascii.a2b_base64(bmp_b64) filename = "temp_recv.bmp" with open(filename, "wb") as f: f.write(bmp_bytes) try: if hasattr(self.display, "draw_bmp"): success = self.display.draw_bmp(filename, x, y) if not success: raise RuntimeError("draw_bmp returned False") else: raise RuntimeError("Display driver does not support draw_bmp.") finally: try: os.remove(filename) except: pass return "Color BMP image displayed successfully." elif name == "get_capabilities": is_color = self.display.__class__.__name__ == "ILI9341" formats = ["rgb565_base64", "bmp_base64", "pbm_base64"] if is_color else ["pbm_base64", "bmp_base64", "rgb565_base64"] return json.dumps({ "color": is_color, "width": width, "height": height, "formats": formats }) elif name == "sync_time": # NTP over native sockets using custom UDP logic tz_offset = -4 try: import wifi_config tz_offset = getattr(wifi_config, "TZ_OFFSET", -4) except ImportError: pass success = False utc_sec = None for attempt in range(3): try: # NTP request: packet = bytearray(48) packet[0] = 0b00100011 # Client mode addr = ("pool.ntp.org", 123) s = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM) s.settimeout(2.0) try: s.sendto(packet, addr) buf = bytearray(48) n, from_addr = s.recvfrom_into(buf) if n >= 48: import struct # Extract Transmit Timestamp (seconds since 1900) val = struct.unpack("!I", buf[40:44])[0] utc_sec = val - 2208988800 # Convert to Unix Epoch success = True break finally: s.close() except Exception as e: time.sleep(0.2) if success and utc_sec is not None: local_sec = utc_sec + int(tz_offset * 3600) t = time.localtime(local_sec) dt = (t[0], t[1], t[2], t[6], t[3], t[4], t[5]) if self.rtc: self.rtc.set_datetime(dt) self.rtc.sync_to_system() t_str = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d} {t[3]:02d}:{t[4]:02d}:{t[5]:02d}" return f"Successfully synchronized board clock with NTP server. Local time: {t_str}" raise RuntimeError("Failed to sync clock with NTP server.") elif name == "draw_raw_rgb565": self.override_active = True rgb_b64 = args.get("rgb565_base64") x = int(args.get("x", 0)) y = int(args.get("y", 0)) w = int(args.get("w", 0)) h = int(args.get("h", 0)) if not rgb_b64: raise ValueError("Missing rgb565_base64 parameter") rgb_bytes = binascii.a2b_base64(rgb_b64) if len(rgb_bytes) < w * h * 2: raise ValueError(f"RGB565 data size too small (expected {w * h * 2} bytes, got {len(rgb_bytes)} bytes)") if hasattr(self.display, "draw_rgb565"): try: self.display.draw_rgb565(x, y, w, h, rgb_bytes) except Exception as e: raise RuntimeError(f"Failed to draw RGB565: {e}") else: raise RuntimeError("Display does not support draw_rgb565.") return "Raw RGB565 data displayed successfully." elif name == "write_file": path = str(args.get("path")) content = str(args.get("content")) # Auto-create parent directories on the device if present in the path parts = path.split('/') if len(parts) > 1: dir_path = "" for part in parts[:-1]: if part: dir_path = dir_path + "/" + part if dir_path else part try: os.mkdir(dir_path) except OSError: pass # Directory likely already exists with open(path, 'w') as f: f.write(content) return f"Successfully wrote {len(content)} characters to '{path}'." elif name == "read_file": path = str(args.get("path")) with open(path, 'r') as f: content = f.read() return content elif name == "execute_python": code = str(args.get("code")) output_buffer = [] old_print = builtins.print def custom_print(*args, **kwargs): sep = kwargs.get('sep', ' ') end = kwargs.get('end', '\n') str_args = [str(arg) for arg in args] msg = sep.join(str_args) + end output_buffer.append(msg) builtins.print = custom_print error = None ldict = { "mcp": self, "display": self.display, "vstream": self.vstream, "time": time } try: exec(code, ldict) except Exception as e: # Capture traceback safely try: import traceback tb_file = io.StringIO() traceback.print_exception(e, tb_file) error_msg = tb_file.getvalue() except: error_msg = str(e) output_buffer.append(f"\nExecution Failed:\n{error_msg}") error = e finally: builtins.print = old_print output = "".join(output_buffer) if error: return output return f"Execution Succeeded. Console output:\n{output}" elif name == "play_tone": if self.audio is None: raise RuntimeError("Audio player is not configured on this device.") freq = int(args.get("frequency", 440)) duration = int(args.get("duration_ms", 1000)) vol = int(args.get("volume", 50)) duration = max(50, min(5000, duration)) freq = max(50, min(10000, freq)) vol = max(0, min(100, vol)) success = self.audio.play_tone(frequency=freq, duration_ms=duration, volume=vol) if success: return f"Played tone of {freq}Hz for {duration}ms at volume {vol}." else: raise RuntimeError("Failed to play tone.") elif name == "play_audio": if self.audio is None: raise RuntimeError("Audio player is not configured on this device.") filename = str(args.get("filename")) vol = int(args.get("volume", 50)) vol = max(0, min(100, vol)) if filename.lower().endswith(".mp3"): success = self.audio.play_mp3(filename, volume=vol) else: success = self.audio.play_wav(filename, volume=vol) if success: return f"Successfully played audio file '{filename}'." else: raise RuntimeError(f"Failed to play audio file '{filename}'. Check format.") elif name == "record_voice": # friendly exception for CircuitPython lacking I2SIn raise RuntimeError("Audio recording is not supported on CircuitPython (I2S input not implemented on ESP32-S3).") elif name == "play_audio_base64": if self.audio is None: raise RuntimeError("Audio player is not configured on this device.") b64_data = args.get("wav_base64") vol = int(args.get("volume", 50)) vol = max(0, min(100, vol)) if not b64_data: raise ValueError("Missing wav_base64 parameter.") audio_bytes = binascii.a2b_base64(b64_data) filename = "temp_play.wav" with open(filename, "wb") as f: f.write(audio_bytes) try: success = self.audio.play_wav(filename, volume=vol) finally: try: os.remove(filename) except: pass if success: return "Successfully played base64 audio stream." else: raise RuntimeError("Failed to play decoded audio stream. Check format.") elif name == "play_gif": filename = str(args.get("filename")) loops = int(args.get("loops", 1)) x = int(args.get("x", 0)) y = int(args.get("y", 0)) max_frames = int(args.get("max_frames", -1)) clear_between_frames = bool(args.get("clear_between_frames", False)) try: # Temporarily disable dashboard redraws so the GIF plays cleanly self.override_active = True from gif_player import GIFPlayer player = GIFPlayer(self.display) player.play( filename, loops=loops, x=x, y=y, max_frames=max_frames, clear_between_frames=clear_between_frames ) return f"Successfully played GIF file '{filename}'." except Exception as e: raise RuntimeError(f"Failed to play GIF file '{filename}': {e}") finally: self.override_active = False elif name == "download_file": if self.session is None: raise RuntimeError("Wi-Fi connection/network request session is not active.") url = str(args.get("url")) filename = str(args.get("filename")) use_sd = bool(args.get("use_sd", True)) from download_cp import download_file saved_path = download_file(url, filename, self.session, use_sd=use_sd) if saved_path: return f"Successfully downloaded file to '{saved_path}'." else: raise RuntimeError(f"Failed to download file from '{url}'.") elif name == "get_video_streaming_instructions": protocol = str(args.get("protocol", "both")).lower() instructions = [ "### ESP32-S3 TFT Video Streaming Instructions", "Dimensions: 400x300 (decoded and centered automatically on the 480x320 screen), 1-bit monochrome (Floyd-Steinberg dithered).", "Frame Buffer Size: 15,000 bytes. The host must convert and map standard pixels into the specific RLCD hardware buffer layout before sending.", "Mapping logic (Python):", " def map_to_rlcd(pil_img):", " img_1bit = pil_img.convert('1', dither=1)", " px = img_1bit.load()", " buf = bytearray(15000)", " for y in range(300):", " for x in range(400):", " if px[x, y]:", " inv_y = 299 - y", " bx = x // 2", " by = inv_y // 4", " idx = bx * 75 + by", " lx, ly = x % 2, inv_y % 4", " bit = 7 - (ly * 2 + lx)", " buf[idx] |= (1 << bit)", " return buf" ] if protocol in ("tcp", "both"): instructions.append("\n**TCP Streaming (Port 8081):**") instructions.append("Open a TCP connection to the device's IP on port 8081 and send consecutive 15,000-byte frame blocks.") if protocol in ("udp", "both"): instructions.append("\n**UDP Streaming / Broadcast (Port 8082):**") instructions.append("Split the 15,000-byte frame into 15 chunks of 1,000 bytes each. Send each chunk as a 1002-byte packet: byte 0 = frame_id (0-255), byte 1 = chunk_idx (0-14), bytes 2..1001 = chunk payload. Send to port 8082 (unicast or broadcast).") return "\n".join(instructions) elif name == "get_stream_stats": if self.vstream is None: return json.dumps({"error": "Video stream server not initialized."}) return json.dumps(self.vstream.get_stats()) else: raise ValueError(f"Unknown tool: {name}") def _draw_pbm_from_bytes(self, pbm_bytes, x, y): if adafruit_framebuf is None: raise RuntimeError("adafruit_framebuf not loaded") idx = 0 newline_idx = pbm_bytes.find(b'\n', idx) if newline_idx == -1: raise ValueError("Invalid PBM format") line1 = pbm_bytes[idx:newline_idx] if not line1.startswith(b'P4'): raise ValueError("Not P4 PBM") idx = newline_idx + 1 while True: newline_idx = pbm_bytes.find(b'\n', idx) if newline_idx == -1: raise ValueError("Invalid PBM format") line = pbm_bytes[idx:newline_idx] idx = newline_idx + 1 if not line.startswith(b'#'): break dims = line.split() if len(dims) < 2: raise ValueError("Invalid PBM dimensions") w = int(dims[0]) h = int(dims[1]) data = pbm_bytes[idx:] width = getattr(self.display, 'width', 320) height = getattr(self.display, 'height', 240) if hasattr(self.display, 'canvas_buffer'): if x == 0 and y == 0 and w == width and h == height: self.display.canvas_buffer[:] = data else: if x == 0 and (w % 8) == 0: dest_stride_bytes = width // 8 src_stride_bytes = w // 8 for cy in range(h): dy = y + cy if 0 <= dy < height: dest_offset = dy * dest_stride_bytes src_offset = cy * src_stride_bytes self.display.canvas_buffer[dest_offset : dest_offset + src_stride_bytes] = data[src_offset : src_offset + src_stride_bytes] else: src_fb = adafruit_framebuf.FrameBuffer(data, w, h, adafruit_framebuf.MHMSB) for cy in range(h): for cx in range(w): px = src_fb.pixel(cx, cy) self.display.canvas.pixel(x + cx, y + cy, px)