Add Wi-Fi auto-boot connection, MCP JSON-RPC Server, and host bridge
This commit is contained in:
+36
@@ -33,6 +33,7 @@ class BLEUART:
|
|||||||
self._connections = set()
|
self._connections = set()
|
||||||
self._rx_callback = None
|
self._rx_callback = None
|
||||||
self._name = name
|
self._name = name
|
||||||
|
self._scan_results = {}
|
||||||
|
|
||||||
self._advertise()
|
self._advertise()
|
||||||
|
|
||||||
@@ -63,6 +64,27 @@ class BLEUART:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Fallback for binary / non-UTF-8 data
|
# Fallback for binary / non-UTF-8 data
|
||||||
self._rx_callback(data_received)
|
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):
|
def _advertise(self):
|
||||||
# Generate advertising payload (flags + service UUID = 3 + 18 = 21 bytes)
|
# 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."""
|
"""Returns True if at least one central is connected."""
|
||||||
return len(self._connections) > 0
|
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):
|
def close(self):
|
||||||
"""Disconnect and stop BLE."""
|
"""Disconnect and stop BLE."""
|
||||||
self._ble.gap_advertise(None) # Stop advertising
|
self._ble.gap_advertise(None) # Stop advertising
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -1,53 +1,170 @@
|
|||||||
import rlcd
|
|
||||||
from machine import Pin, SPI
|
|
||||||
from wifi_util import WiFiUtil
|
|
||||||
import time
|
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():
|
def main():
|
||||||
print("Initializing SPI and Display...")
|
global local_led_mode_idx
|
||||||
# Setup SPI
|
print("=== Starting ESP32-S3-RLCD-4.2 Main Boot ===")
|
||||||
spi = SPI(1, baudrate=20000000, polarity=0, phase=0,
|
|
||||||
sck=Pin(11), mosi=Pin(12))
|
# 1. Initialize shared buses and peripherals
|
||||||
|
i2c = I2C(0, sda=Pin(13), scl=Pin(14))
|
||||||
# Initialize Display
|
|
||||||
|
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 = 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
|
# Configure Audio Amp control pin to save power
|
||||||
display.text("Available Wi-Fi Networks:", 10, 10, 1)
|
amp_pin = Pin(46, Pin.OUT, value=0)
|
||||||
display.line(10, 20, 210, 20, 1)
|
|
||||||
|
|
||||||
# List networks
|
# 2. Initialize utility objects
|
||||||
y_offset = 30
|
sensor = SHTC3(i2c)
|
||||||
# Limit to 20 networks so they fit on the 300px tall screen (20 * 12px = 240px + 30px offset = 270px)
|
rtc_chip = PCF85063(i2c)
|
||||||
for i, net in enumerate(networks[:20]):
|
battery = BatteryMonitor()
|
||||||
text = f"{i+1}. {net['ssid']} ({net['rssi']}dBm)"
|
led = BoardLED(38)
|
||||||
display.text(text, 10, y_offset, 1)
|
buttons = BoardButtons()
|
||||||
y_offset += 12
|
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:
|
def on_boot_click():
|
||||||
display.text("No networks found.", 10, 30, 1)
|
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
|
buttons.key.on_click(on_key_click)
|
||||||
print("Updating screen...")
|
buttons.boot.on_click(on_boot_click)
|
||||||
display.show()
|
|
||||||
try:
|
# Loop state
|
||||||
display.save_screenshot('screenshot.pbm')
|
last_dashboard_update = 0
|
||||||
except Exception as e:
|
dashboard_update_interval_ms = 5000
|
||||||
print(f"Failed to save screenshot: {e}")
|
|
||||||
print("Done!")
|
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__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -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()
|
||||||
+267
@@ -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}")
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user