Files

1093 lines
50 KiB
Python

"""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)