Implement notetaking app, ST7796 display with touch support, audio beep navigation, and video streaming
This commit is contained in:
+3
-2
@@ -4,9 +4,10 @@ class BatteryMonitor:
|
||||
"""Utility class for monitoring battery voltage and capacity on ESP32-S3-RLCD-4.2."""
|
||||
|
||||
def __init__(self, pin_num=4):
|
||||
# Initialize ADC on GPIO 4 with 11dB attenuation
|
||||
import sys
|
||||
self.adc = ADC(Pin(pin_num))
|
||||
self.adc.atten(ADC.ATTN_11DB)
|
||||
if sys.platform == 'esp32':
|
||||
self.adc.atten(ADC.ATTN_11DB)
|
||||
|
||||
def read_voltage(self):
|
||||
"""Reads the battery voltage in Volts using internal calibration.
|
||||
|
||||
+35
-17
@@ -1,25 +1,40 @@
|
||||
import bluetooth
|
||||
try:
|
||||
import bluetooth
|
||||
has_ble = True
|
||||
except ImportError:
|
||||
has_ble = False
|
||||
|
||||
import struct
|
||||
import time
|
||||
|
||||
# BLE UUID definitions (Nordic UART Service)
|
||||
_UART_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
|
||||
_UART_TX = (
|
||||
bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"),
|
||||
bluetooth.FLAG_NOTIFY,
|
||||
)
|
||||
_UART_RX = (
|
||||
bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"),
|
||||
bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE,
|
||||
)
|
||||
_UART_SERVICE = (_UART_UUID, (_UART_TX, _UART_RX))
|
||||
if not has_ble:
|
||||
class BLEUART:
|
||||
def __init__(self, ble=None, name="ESP32-S3-RLCD"):
|
||||
print("BLE not supported on this platform.")
|
||||
def on_rx(self, callback): pass
|
||||
def write(self, data): return False
|
||||
def is_connected(self): return False
|
||||
def scan(self, duration_ms=3000): return {}
|
||||
def close(self): pass
|
||||
else:
|
||||
# BLE UUID definitions (Nordic UART Service)
|
||||
_UART_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
|
||||
_UART_TX = (
|
||||
bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"),
|
||||
bluetooth.FLAG_NOTIFY,
|
||||
)
|
||||
_UART_RX = (
|
||||
bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"),
|
||||
bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE,
|
||||
)
|
||||
_UART_SERVICE = (_UART_UUID, (_UART_TX, _UART_RX))
|
||||
|
||||
# BLE IRQ constants
|
||||
_IRQ_CENTRAL_CONNECT = 1
|
||||
_IRQ_CENTRAL_DISCONNECT = 2
|
||||
_IRQ_GATTS_WRITE = 3
|
||||
# BLE IRQ constants
|
||||
_IRQ_CENTRAL_CONNECT = 1
|
||||
_IRQ_CENTRAL_DISCONNECT = 2
|
||||
_IRQ_GATTS_WRITE = 3
|
||||
|
||||
class BLEUART:
|
||||
class _ActiveBLEUART:
|
||||
"""A helper class to manage BLE UART (Serial Over BLE) for raw text data exchange."""
|
||||
|
||||
def __init__(self, ble=None, name="ESP32-S3-RLCD"):
|
||||
@@ -174,3 +189,6 @@ class BLEUART:
|
||||
self._ble.gap_disconnect(conn_handle)
|
||||
self._ble.active(False)
|
||||
print("BLE closed.")
|
||||
|
||||
if has_ble:
|
||||
BLEUART = _ActiveBLEUART
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
# This file is executed on every boot (including wake-boot from deepsleep)
|
||||
import network
|
||||
import time
|
||||
import rlcd
|
||||
import sys
|
||||
|
||||
try:
|
||||
import network
|
||||
has_network = True
|
||||
except ImportError:
|
||||
has_network = False
|
||||
if sys.platform == 'rp2':
|
||||
import st7796 as display_module
|
||||
else:
|
||||
import rlcd as display_module
|
||||
from machine import Pin, SPI
|
||||
import wifi_config
|
||||
|
||||
def connect_wifi():
|
||||
if sys.platform == 'rp2':
|
||||
# Skip display and connection setup on RP2 (no network/Wi-Fi hardware)
|
||||
# Keep the sleep window to make REPL interruption easy
|
||||
time.sleep(1.5)
|
||||
return
|
||||
|
||||
# Initialize display to show connection progress
|
||||
display = None
|
||||
try:
|
||||
# ESP32-S3 configuration
|
||||
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 = display_module.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)
|
||||
@@ -21,8 +38,10 @@ def connect_wifi():
|
||||
print("Display init failed in boot.py:", e)
|
||||
|
||||
# Initialize Wi-Fi Station
|
||||
if not has_network:
|
||||
return
|
||||
|
||||
try:
|
||||
import network
|
||||
network.hostname('esp32screen')
|
||||
print("Hostname set to: {}".format(network.hostname()))
|
||||
except Exception as he:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+123
@@ -0,0 +1,123 @@
|
||||
import time
|
||||
from machine import Pin, I2C
|
||||
|
||||
class FT6336U:
|
||||
ADDR = 0x38
|
||||
|
||||
# Registers
|
||||
REG_DEV_MODE = 0x00
|
||||
REG_TD_STATUS = 0x02
|
||||
REG_P1_XH = 0x03
|
||||
REG_P1_XL = 0x04
|
||||
REG_P1_YH = 0x05
|
||||
REG_P1_YL = 0x06
|
||||
REG_CTRL = 0x86
|
||||
REG_CHIPID = 0xA3
|
||||
REG_G_MODE = 0xA4
|
||||
|
||||
# Modes
|
||||
CTRL_KEEP_ACTIVE = 0x00
|
||||
G_MODE_TRIGGER = 0x01
|
||||
|
||||
def __init__(self, i2c, rst_pin=28, int_pin=25, width=480, height=320, swap_xy=True, invert_x=True, invert_y=False):
|
||||
self.i2c = i2c
|
||||
self.rst = Pin(rst_pin, Pin.OUT)
|
||||
self.int = Pin(int_pin, Pin.IN, Pin.PULL_UP)
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.swap_xy = swap_xy
|
||||
self.invert_x = invert_x
|
||||
self.invert_y = invert_y
|
||||
self.initialized = False
|
||||
|
||||
self.reset()
|
||||
self.init_chip()
|
||||
|
||||
def reset(self):
|
||||
self.rst(0)
|
||||
time.sleep_ms(10)
|
||||
self.rst(1)
|
||||
time.sleep_ms(300) # Wait for chip to wake up
|
||||
|
||||
def read_reg(self, reg, n=1):
|
||||
try:
|
||||
return self.i2c.readfrom_mem(self.ADDR, reg, n)
|
||||
except Exception as e:
|
||||
print(f"I2C read failed at reg 0x{reg:02X}: {e}")
|
||||
return None
|
||||
|
||||
def write_reg(self, reg, val):
|
||||
try:
|
||||
self.i2c.writeto_mem(self.ADDR, reg, bytearray([val]))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"I2C write failed at reg 0x{reg:02X}: {e}")
|
||||
return False
|
||||
|
||||
def init_chip(self):
|
||||
# 1. Read Chip ID
|
||||
chip_id = self.read_reg(self.REG_CHIPID)
|
||||
if chip_id is None or chip_id[0] != 0x64:
|
||||
# Retry once
|
||||
time.sleep_ms(100)
|
||||
chip_id = self.read_reg(self.REG_CHIPID)
|
||||
if chip_id is None or chip_id[0] != 0x64:
|
||||
print(f"FT6336U error: Invalid Chip ID (got {chip_id[0] if chip_id else None}, expected 0x64)")
|
||||
self.initialized = False
|
||||
return False
|
||||
|
||||
print(f"FT6336U touch controller detected (Chip ID: 0x{chip_id[0]:02X})")
|
||||
|
||||
# 2. Configure operating mode (0 = Normal Mode)
|
||||
self.write_reg(self.REG_DEV_MODE, 0x00)
|
||||
|
||||
# 3. Configure CTRL mode (0 = Keep Active)
|
||||
self.write_reg(self.REG_CTRL, self.CTRL_KEEP_ACTIVE)
|
||||
|
||||
self.initialized = True
|
||||
return True
|
||||
|
||||
def is_touched(self):
|
||||
"""Returns True if screen is touched (the active-low INT pin is pulled LOW)."""
|
||||
return self.int() == 0
|
||||
|
||||
def read_touch(self):
|
||||
"""Reads touch point coordinates.
|
||||
Returns:
|
||||
(x, y) coordinates of first touch point, or None if no touch.
|
||||
"""
|
||||
if not self.initialized:
|
||||
return None
|
||||
|
||||
# Read TD_STATUS to check if there is an active touch
|
||||
td_status = self.read_reg(self.REG_TD_STATUS)
|
||||
if td_status is None:
|
||||
return None
|
||||
|
||||
touch_count = td_status[0] & 0x0F
|
||||
if touch_count == 0:
|
||||
return None
|
||||
|
||||
# Read P1 coordinates (6 bytes starting at P1_XH)
|
||||
buf = self.read_reg(self.REG_P1_XH, 6)
|
||||
if buf is None or len(buf) < 6:
|
||||
return None
|
||||
|
||||
raw_x = ((buf[0] & 0x0F) << 8) | buf[1]
|
||||
raw_y = ((buf[2] & 0x0F) << 8) | buf[3]
|
||||
|
||||
# Apply coordinate transformations to map touch coordinate space to screen space
|
||||
if self.swap_xy:
|
||||
raw_x, raw_y = raw_y, raw_x
|
||||
|
||||
if self.invert_x:
|
||||
raw_x = self.width - 1 - raw_x
|
||||
|
||||
if self.invert_y:
|
||||
raw_y = self.height - 1 - raw_y
|
||||
|
||||
# Clamp coordinates to screen boundaries
|
||||
x = max(0, min(self.width - 1, raw_x))
|
||||
y = max(0, min(self.height - 1, raw_y))
|
||||
|
||||
return (x, y)
|
||||
@@ -1,8 +1,31 @@
|
||||
import time
|
||||
import network
|
||||
import sys
|
||||
import machine
|
||||
from machine import Pin, SPI, I2C
|
||||
import rlcd
|
||||
|
||||
try:
|
||||
import network
|
||||
has_network = True
|
||||
except ImportError:
|
||||
has_network = False
|
||||
|
||||
if sys.platform == 'rp2':
|
||||
import st7796 as display_module
|
||||
else:
|
||||
import rlcd as display_module
|
||||
|
||||
class DummyMCP:
|
||||
def __init__(self):
|
||||
self.override_active = False
|
||||
self.active_led_mode = "off"
|
||||
def update(self): pass
|
||||
def start(self, port=80): pass
|
||||
|
||||
class DummyVStream:
|
||||
def __init__(self):
|
||||
self.active = False
|
||||
def update(self): pass
|
||||
def start(self): pass
|
||||
|
||||
# Import our utility classes
|
||||
from shtc3_util import SHTC3
|
||||
@@ -12,6 +35,7 @@ from button_util import BoardButtons
|
||||
from rgb_led_util import BoardLED
|
||||
from ble_util import BLEUART
|
||||
from mcp_server import MCPServer
|
||||
from video_stream import VideoStreamServer
|
||||
|
||||
# LED mode options for manual cycling
|
||||
led_modes = [
|
||||
@@ -30,33 +54,83 @@ def main():
|
||||
print("=== Starting ESP32-S3-RLCD-4.2 Main Boot ===")
|
||||
|
||||
# 1. Initialize shared buses and peripherals
|
||||
i2c = I2C(0, sda=Pin(13), scl=Pin(14))
|
||||
i2c = None
|
||||
if sys.platform != 'rp2':
|
||||
try:
|
||||
i2c = I2C(0, sda=Pin(13), scl=Pin(14))
|
||||
except Exception as e:
|
||||
print("Failed to initialize I2C0:", e)
|
||||
|
||||
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))
|
||||
if sys.platform == 'rp2':
|
||||
spi = SPI(1, baudrate=32000000, polarity=0, phase=0, sck=Pin(10), mosi=Pin(11))
|
||||
display = display_module.ST7796(spi, cs=Pin(7), dc=Pin(4), rst=Pin(9), bl=Pin(6))
|
||||
|
||||
# Touch controller
|
||||
touch = None
|
||||
try:
|
||||
touch_i2c = I2C(1, sda=Pin(2), scl=Pin(3), freq=400000)
|
||||
from ft6336u import FT6336U
|
||||
touch = FT6336U(touch_i2c, rst_pin=28, int_pin=25)
|
||||
except Exception as te:
|
||||
print("Failed to initialize touch:", te)
|
||||
else:
|
||||
spi = SPI(1, baudrate=20000000, polarity=0, phase=0, sck=Pin(11), mosi=Pin(12))
|
||||
display = display_module.RLCD(spi, cs=Pin(40), dc=Pin(5), rst=Pin(41))
|
||||
touch = None
|
||||
|
||||
# Configure Audio Amp control pin to save power
|
||||
amp_pin = Pin(46, Pin.OUT, value=0)
|
||||
if sys.platform != 'rp2':
|
||||
# Configure Audio Amp control pin to save power
|
||||
amp_pin = Pin(46, Pin.OUT, value=0)
|
||||
|
||||
# 2. Initialize utility objects
|
||||
sensor = SHTC3(i2c)
|
||||
rtc_chip = PCF85063(i2c)
|
||||
sensor = None
|
||||
rtc_chip = None
|
||||
if i2c is not None:
|
||||
try:
|
||||
sensor = SHTC3(i2c)
|
||||
except Exception as e:
|
||||
print("Failed to initialize SHTC3:", e)
|
||||
try:
|
||||
rtc_chip = PCF85063(i2c)
|
||||
except Exception as e:
|
||||
print("Failed to initialize PCF85063:", e)
|
||||
|
||||
battery = BatteryMonitor()
|
||||
led = BoardLED(38)
|
||||
buttons = BoardButtons()
|
||||
led = BoardLED()
|
||||
buttons = None
|
||||
if sys.platform != 'rp2':
|
||||
buttons = BoardButtons()
|
||||
ble_uart = BLEUART(name="ESP32-S3-RLCD")
|
||||
|
||||
# Sync system clock from RTC chip
|
||||
rtc_chip.sync_to_system()
|
||||
if rtc_chip:
|
||||
try:
|
||||
rtc_chip.sync_to_system()
|
||||
except Exception as e:
|
||||
print("Failed to sync clock:", e)
|
||||
|
||||
# 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)
|
||||
# 3. Connect to Wi-Fi status check
|
||||
ip_addr = "Offline (USB)"
|
||||
mcp = DummyMCP()
|
||||
vstream = DummyVStream()
|
||||
|
||||
if has_network:
|
||||
try:
|
||||
wlan = network.WLAN(network.STA_IF)
|
||||
ip_addr = wlan.ifconfig()[0] if wlan.isconnected() else "Disconnected"
|
||||
|
||||
# 4. Start background TCP/UDP Video Streaming Server
|
||||
from video_stream import VideoStreamServer
|
||||
vstream = VideoStreamServer(display, tcp_port=8081, udp_port=8082)
|
||||
vstream.start()
|
||||
|
||||
# 4b. Start MCP Server
|
||||
from mcp_server import MCPServer
|
||||
mcp = MCPServer(display, led, battery, sensor, rtc_chip, ble_uart, vstream=vstream)
|
||||
mcp.start(port=80)
|
||||
except Exception as ne:
|
||||
print("Failed to start network services:", ne)
|
||||
|
||||
# Default to Green Breathing
|
||||
led_modes[local_led_mode_idx][1](led)
|
||||
mcp.active_led_mode = led_modes[local_led_mode_idx][2]
|
||||
@@ -87,12 +161,14 @@ def main():
|
||||
mcp.override_active = False
|
||||
force_dashboard_redraw = True
|
||||
|
||||
buttons.key.on_click(on_key_click)
|
||||
buttons.boot.on_click(on_boot_click)
|
||||
if buttons:
|
||||
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
|
||||
last_led_update = 0
|
||||
|
||||
print("ESP32 MCP loop running...")
|
||||
|
||||
@@ -100,18 +176,30 @@ def main():
|
||||
while True:
|
||||
now = time.ticks_ms()
|
||||
|
||||
# Check touch interaction if available
|
||||
if touch and touch.is_touched():
|
||||
pt = touch.read_touch()
|
||||
if pt:
|
||||
tx, ty = pt
|
||||
print(f"Touch detected at: ({tx}, {ty})")
|
||||
last_action_str = f"Touch: ({tx}, {ty})"
|
||||
mcp.override_active = False
|
||||
force_dashboard_redraw = True
|
||||
|
||||
# A. Handle non-blocking MCP client connection updates
|
||||
mcp.update()
|
||||
|
||||
|
||||
# C. Draw local dashboard (if not overridden by MCP draw text commands)
|
||||
if not mcp.override_active:
|
||||
# B. Handle non-blocking background video stream updates
|
||||
vstream.update()
|
||||
|
||||
# C. Draw local dashboard (if not overridden by MCP draw text commands or active video stream)
|
||||
if not mcp.override_active and not vstream.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, h = sensor.read_sensor() if sensor else (None, None)
|
||||
t_str = f"{t} C" if t is not None else "Error"
|
||||
h_str = f"{h} %" if h is not None else "Error"
|
||||
|
||||
@@ -121,45 +209,54 @@ def main():
|
||||
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()
|
||||
dt = rtc_chip.get_datetime() if rtc_chip else None
|
||||
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)
|
||||
title_text = "RP2350-TFT MCP SERVER" if sys.platform == 'rp2' else "ESP32-S3-RLCD MCP SERVER"
|
||||
line_w = 470 if sys.platform == 'rp2' else 390
|
||||
|
||||
display.text(title_text, 10, 10, 1)
|
||||
display.line(10, 20, line_w, 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.line(10, 95, line_w, 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.line(10, 185, line_w, 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.line(10, 255, line_w, 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()
|
||||
if time.ticks_diff(now, last_led_update) >= 50:
|
||||
last_led_update = now
|
||||
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)
|
||||
# Poll rapidly if stream is active, otherwise sleep 50ms to save power
|
||||
if vstream.active:
|
||||
time.sleep_ms(2)
|
||||
else:
|
||||
time.sleep_ms(50)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+60
-4
@@ -5,13 +5,14 @@ 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):
|
||||
def __init__(self, display, led, battery, sensor, rtc, ble, vstream=None):
|
||||
self.display = display
|
||||
self.led = led
|
||||
self.battery = battery
|
||||
self.sensor = sensor
|
||||
self.rtc = rtc
|
||||
self.ble = ble
|
||||
self.vstream = vstream
|
||||
|
||||
self.sock = None
|
||||
self.active_led_mode = "off" # static, breath, rainbow, off
|
||||
@@ -128,7 +129,7 @@ class MCPServer:
|
||||
"tools": [
|
||||
{
|
||||
"name": "clear_screen",
|
||||
"description": "Clear the 400x300 screen to white (0) or black (1).",
|
||||
"description": "Clear the 480x320 screen to white (0) or black (1).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -144,8 +145,8 @@ class MCPServer:
|
||||
"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)"},
|
||||
"x": {"type": "integer", "description": "X coordinate (0-470)"},
|
||||
"y": {"type": "integer", "description": "Y coordinate (0-310)"},
|
||||
"size": {"type": "integer", "enum": [1, 2], "description": "Text scale (1=normal, 2=large)"}
|
||||
},
|
||||
"required": ["text", "x", "y"]
|
||||
@@ -297,6 +298,21 @@ class MCPServer:
|
||||
},
|
||||
"required": ["url", "filename"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_video_streaming_instructions",
|
||||
"description": "Get detailed instructions and sample Python code to stream video directly into the RLCD 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": {}}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -568,5 +584,45 @@ class MCPServer:
|
||||
else:
|
||||
raise RuntimeError(f"Failed to download file from '{url}'.")
|
||||
|
||||
elif name == "get_video_streaming_instructions":
|
||||
protocol = str(args.get("protocol", "both")).lower()
|
||||
|
||||
instructions = [
|
||||
"### RP2350 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}")
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
Hey there This is now a note taking app.
|
||||
|
||||
What do you think of this?
|
||||
|
||||
This is going to be pretty cool if you ask me!!!
|
||||
|
||||
|
||||
Now the themes are supper cool too!!!!
|
||||
|
||||
What is going to happend if I write a line that is too long would it wrapped yes!!!
|
||||
|
||||
It also scrolls for vertical overflow!!
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
Hey there buddy!
|
||||
I think you are going to like this DEVICE!
|
||||
I made it for you, so you can write your hearth into it!...
|
||||
Love you... DAD
|
||||
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import curses
|
||||
import datetime
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Screen Dimensions
|
||||
WIDTH = 400
|
||||
HEIGHT = 300
|
||||
|
||||
# Margins and Layout
|
||||
MARGIN_LEFT = 20
|
||||
MARGIN_RIGHT = 380
|
||||
TOP_LIMIT = 35
|
||||
BOTTOM_LIMIT = 265
|
||||
|
||||
# Precompute destination byte mapping for RLCD hardware buffer
|
||||
DEST_BYTE_MAP = []
|
||||
for dst_idx in range(15000):
|
||||
byte_x = dst_idx // 75
|
||||
block_y = dst_idx % 75
|
||||
x_base = 2 * byte_x
|
||||
y_base = HEIGHT - 1 - 4 * block_y
|
||||
|
||||
bits_map = []
|
||||
for local_y in range(4):
|
||||
y = y_base - local_y
|
||||
for local_x in range(2):
|
||||
x = x_base + local_x
|
||||
src_byte_idx = y * 50 + (x // 8)
|
||||
src_bit_idx = 7 - (x % 8)
|
||||
src_mask = 1 << src_bit_idx
|
||||
dst_mask = 1 << (7 - (local_y * 2 + local_x))
|
||||
bits_map.append((src_byte_idx, src_mask, dst_mask))
|
||||
DEST_BYTE_MAP.append(tuple(bits_map))
|
||||
|
||||
|
||||
def map_to_rlcd_hw_buffer(img_1bit):
|
||||
"""Highly optimized byte-level 1-bit monochrome image mapping to Waveshare RLCD buffer."""
|
||||
img_bytes = img_1bit.tobytes()
|
||||
hw_buffer = bytearray(15000)
|
||||
for dst_idx in range(15000):
|
||||
val = 0
|
||||
for src_idx, src_mask, dst_mask in DEST_BYTE_MAP[dst_idx]:
|
||||
if img_bytes[src_idx] & src_mask:
|
||||
val |= dst_mask
|
||||
hw_buffer[dst_idx] = val
|
||||
return hw_buffer
|
||||
|
||||
|
||||
def wrap_text_by_width(text, cursor_pos, font, max_width=360):
|
||||
"""
|
||||
Wraps text to fit within max_width pixels.
|
||||
Returns:
|
||||
lines: list of strings (the wrapped lines)
|
||||
line_starts: list of starting indices of each line in the raw text
|
||||
cursor_line: index of line containing cursor
|
||||
cursor_col: index of column in that line (in terms of character index in the line)
|
||||
"""
|
||||
lines = []
|
||||
line_starts = []
|
||||
cursor_line = 0
|
||||
cursor_col = 0
|
||||
|
||||
current_char_idx = 0
|
||||
paragraphs = text.split('\n')
|
||||
|
||||
# Create a dummy draw context to measure text lengths
|
||||
dummy_im = Image.new("1", (1, 1))
|
||||
dummy_draw = ImageDraw.Draw(dummy_im)
|
||||
|
||||
for p_idx, p in enumerate(paragraphs):
|
||||
if not p:
|
||||
if current_char_idx <= cursor_pos <= current_char_idx:
|
||||
cursor_line = len(lines)
|
||||
cursor_col = 0
|
||||
lines.append("")
|
||||
line_starts.append(current_char_idx)
|
||||
current_char_idx += 1 # for the '\n'
|
||||
continue
|
||||
|
||||
words = p.split(' ')
|
||||
curr_line = ""
|
||||
curr_line_start = current_char_idx
|
||||
|
||||
for w in words:
|
||||
if not curr_line:
|
||||
test_line = w
|
||||
else:
|
||||
test_line = curr_line + " " + w
|
||||
|
||||
w_len = dummy_draw.textlength(test_line, font=font)
|
||||
if w_len <= max_width:
|
||||
curr_line = test_line
|
||||
else:
|
||||
# Wrap current line
|
||||
if curr_line_start <= cursor_pos <= curr_line_start + len(curr_line):
|
||||
cursor_line = len(lines)
|
||||
cursor_col = cursor_pos - curr_line_start
|
||||
lines.append(curr_line)
|
||||
line_starts.append(curr_line_start)
|
||||
|
||||
# Check for long words
|
||||
w_start = curr_line_start + len(curr_line) + 1 # +1 for space/wrap boundary
|
||||
while dummy_draw.textlength(w, font=font) > max_width:
|
||||
part_len = 1
|
||||
while part_len <= len(w) and dummy_draw.textlength(w[:part_len], font=font) <= max_width:
|
||||
part_len += 1
|
||||
part = w[:part_len - 1]
|
||||
if not part:
|
||||
part = w[0]
|
||||
part_len = 2
|
||||
|
||||
if w_start <= cursor_pos <= w_start + len(part):
|
||||
cursor_line = len(lines)
|
||||
cursor_col = cursor_pos - w_start
|
||||
lines.append(part)
|
||||
line_starts.append(w_start)
|
||||
w_start += len(part)
|
||||
w = w[part_len - 1:]
|
||||
|
||||
curr_line = w
|
||||
curr_line_start = w_start
|
||||
|
||||
if curr_line or p_idx == len(paragraphs) - 1:
|
||||
if curr_line_start <= cursor_pos <= curr_line_start + len(curr_line):
|
||||
cursor_line = len(lines)
|
||||
cursor_col = cursor_pos - curr_line_start
|
||||
lines.append(curr_line)
|
||||
line_starts.append(curr_line_start)
|
||||
current_char_idx = curr_line_start + len(curr_line)
|
||||
|
||||
current_char_idx += 1 # for the '\n'
|
||||
|
||||
return lines, line_starts, cursor_line, cursor_col
|
||||
|
||||
|
||||
def render_screen(text, cursor_pos, scroll_top, font, char_h, line_spacing, max_lines, dark_mode, show_cursor, status_msg=""):
|
||||
# Create 1-bit image (0 = Black background)
|
||||
if dark_mode:
|
||||
bg_color = 0 # Black background
|
||||
text_color = 1 # White text
|
||||
accent_color = 1 # White elements
|
||||
rule_color = 1 # White dotted lines
|
||||
else:
|
||||
bg_color = 1 # White background
|
||||
text_color = 0 # Black text
|
||||
accent_color = 0 # Black elements
|
||||
rule_color = 0 # Black dotted lines
|
||||
|
||||
img = Image.new("1", (WIDTH, HEIGHT), bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Load clean, non-clipping UI font for header/footer
|
||||
try:
|
||||
ui_font = ImageFont.load_default(size=12)
|
||||
except TypeError:
|
||||
ui_font = ImageFont.load_default()
|
||||
|
||||
# 1. Wrap the text and get the cursor location (max_width = 360)
|
||||
lines, line_starts, cursor_line, cursor_col = wrap_text_by_width(text, cursor_pos, font, max_width=360)
|
||||
|
||||
# 2. Adjust scrolling
|
||||
if cursor_line >= scroll_top + max_lines:
|
||||
scroll_top = cursor_line - max_lines + 1
|
||||
elif cursor_line < scroll_top:
|
||||
scroll_top = cursor_line
|
||||
|
||||
# 3. Draw Header Title Bar
|
||||
draw.rectangle([0, 0, WIDTH - 1, 28], fill=text_color)
|
||||
|
||||
# Display note title + date
|
||||
today_str = datetime.date.today().strftime("%a, %b %d, %Y").upper()
|
||||
header_title = f"KID KEEPER - {today_str}"
|
||||
draw.text((15, 7), header_title, fill=bg_color, font=ui_font)
|
||||
|
||||
# Header stats (word and character count)
|
||||
word_count = len(text.split())
|
||||
char_count = len(text)
|
||||
stats_str = f"W:{word_count} C:{char_count}"
|
||||
stats_bbox = draw.textbbox((0, 0), stats_str, font=ui_font)
|
||||
stats_w = stats_bbox[2] - stats_bbox[0]
|
||||
draw.text((WIDTH - stats_w - 15, 7), stats_str, fill=bg_color, font=ui_font)
|
||||
|
||||
# 4. Draw Notebook Ruled Paper Lines (aligned with baseline of the font size)
|
||||
for i in range(max_lines):
|
||||
y_line = TOP_LIMIT + line_spacing * i + char_h + 5
|
||||
# Prevent drawing paper lines past bottom limit
|
||||
if y_line < BOTTOM_LIMIT + 5:
|
||||
for x in range(MARGIN_LEFT, MARGIN_RIGHT, 4):
|
||||
draw.point((x, y_line), fill=rule_color)
|
||||
|
||||
# 5. Draw Visible Text Lines
|
||||
visible_lines = lines[scroll_top:scroll_top + max_lines]
|
||||
for idx, line_text in enumerate(visible_lines):
|
||||
y_pos = TOP_LIMIT + line_spacing * idx + 2
|
||||
draw.text((MARGIN_LEFT, y_pos), line_text, fill=text_color, font=font)
|
||||
|
||||
# 6. Draw Blinking Text Cursor
|
||||
if show_cursor:
|
||||
cursor_line_text = lines[cursor_line]
|
||||
text_before_cursor = cursor_line_text[:cursor_col]
|
||||
cursor_x = MARGIN_LEFT + draw.textlength(text_before_cursor, font=font)
|
||||
cursor_y = TOP_LIMIT + (cursor_line - scroll_top) * line_spacing + 2
|
||||
if MARGIN_LEFT <= cursor_x <= MARGIN_RIGHT:
|
||||
# Draw a thick cursor bar (2px width)
|
||||
draw.rectangle([cursor_x, cursor_y, cursor_x + 1, cursor_y + char_h + 2], fill=text_color)
|
||||
|
||||
# 7. Draw Bottom Status Bar
|
||||
draw.line((0, BOTTOM_LIMIT + 8, WIDTH - 1, BOTTOM_LIMIT + 8), fill=accent_color)
|
||||
|
||||
help_text = "Ctrl+S: Save Ctrl+T: Theme Ctrl+F: Font Ctrl+X: Exit"
|
||||
draw.text((15, BOTTOM_LIMIT + 12), help_text, fill=text_color, font=ui_font)
|
||||
|
||||
if status_msg:
|
||||
msg_bbox = draw.textbbox((0, 0), status_msg, font=ui_font)
|
||||
msg_w = msg_bbox[2] - msg_bbox[0]
|
||||
draw.rectangle([WIDTH - msg_w - 20, BOTTOM_LIMIT + 10, WIDTH - 5, HEIGHT - 2], fill=bg_color)
|
||||
draw.text((WIDTH - msg_w - 15, BOTTOM_LIMIT + 12), status_msg, fill=text_color, font=ui_font)
|
||||
|
||||
return img, scroll_top
|
||||
|
||||
|
||||
def load_font(font_path, size):
|
||||
"""Safely loads a TTF font and computes its vertical metric using 'Hyg' ascenders/descenders."""
|
||||
try:
|
||||
if font_path and os.path.exists(font_path):
|
||||
font = ImageFont.truetype(font_path, size=size)
|
||||
else:
|
||||
font = ImageFont.load_default(size=size)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Measure character height using ascenders and descenders
|
||||
test_img = Image.new("1", (100, 100))
|
||||
test_draw = ImageDraw.Draw(test_img)
|
||||
bbox = test_draw.textbbox((0, 0), "Hyg", font=font)
|
||||
char_h = bbox[3] - bbox[1]
|
||||
if char_h <= 0:
|
||||
char_h = 12
|
||||
return font, char_h
|
||||
|
||||
|
||||
def run_editor(stdscr, args):
|
||||
# --- Curses Initialization ---
|
||||
stdscr.nodelay(True) # Non-blocking input
|
||||
curses.noecho() # Hide echoing
|
||||
stdscr.keypad(True) # Handle arrow keys
|
||||
|
||||
# Configure kid-friendly font choices
|
||||
# Format: (path, size, name)
|
||||
font_folder = "fonts"
|
||||
if not os.path.exists(font_folder):
|
||||
font_folder = os.path.expanduser("~/fonts")
|
||||
|
||||
font_options = [
|
||||
(os.path.join(font_folder, "PatrickHand.ttf"), 22, "Handwritten"),
|
||||
(os.path.join(font_folder, "CourierPrime.ttf"), 20, "Typewriter"),
|
||||
(None, 14, "Default Monospace")
|
||||
]
|
||||
font_idx = 0
|
||||
|
||||
# Load first font
|
||||
f_path, f_size, f_name = font_options[font_idx]
|
||||
font, char_h = load_font(f_path, f_size)
|
||||
|
||||
# 2. Load note from file (use date-specific note by default)
|
||||
note_text = ""
|
||||
if os.path.exists(args.file):
|
||||
try:
|
||||
with open(args.file, "r") as f:
|
||||
note_text = f.read()
|
||||
except:
|
||||
pass
|
||||
|
||||
cursor_pos = len(note_text)
|
||||
scroll_top = 0
|
||||
dark_mode = False
|
||||
|
||||
# 3. Connect to the ESP32 TCP Stream Server
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(5.0)
|
||||
try:
|
||||
sock.connect((args.ip, args.port))
|
||||
except Exception as e:
|
||||
curses.endwin()
|
||||
print(f"\nConnection failed to {args.ip}:{args.port} -- {e}")
|
||||
print("Please verify the ESP32 is powered on and connected to Wi-Fi.")
|
||||
sys.exit(1)
|
||||
|
||||
sock.setblocking(False)
|
||||
|
||||
# State variables
|
||||
last_blink_time = time.time()
|
||||
show_cursor = True
|
||||
status_msg = f"Font: {f_name}"
|
||||
status_msg_expiry = time.time() + 2.0
|
||||
redraw = True
|
||||
|
||||
while True:
|
||||
now = time.time()
|
||||
|
||||
# Calculate text layout dynamically based on current font height
|
||||
line_spacing = char_h + 8
|
||||
max_lines_on_screen = (BOTTOM_LIMIT - TOP_LIMIT) // line_spacing
|
||||
|
||||
# Cursor blink check (toggles every 500ms)
|
||||
if now - last_blink_time >= 0.5:
|
||||
show_cursor = not show_cursor
|
||||
last_blink_time = now
|
||||
redraw = True
|
||||
|
||||
# Expiry check for status message
|
||||
if status_msg and now > status_msg_expiry:
|
||||
status_msg = ""
|
||||
redraw = True
|
||||
|
||||
# Read all pending characters from curses
|
||||
keys = []
|
||||
while True:
|
||||
try:
|
||||
k = stdscr.get_wch()
|
||||
keys.append(k)
|
||||
except curses.error:
|
||||
break
|
||||
|
||||
if keys:
|
||||
redraw = True
|
||||
show_cursor = True
|
||||
last_blink_time = now
|
||||
|
||||
for key in keys:
|
||||
if isinstance(key, str):
|
||||
if key == '\x18': # Ctrl+X: Save and Exit
|
||||
status_msg = "Saving..."
|
||||
img, _ = render_screen(note_text, cursor_pos, scroll_top, font, char_h, line_spacing, max_lines_on_screen, dark_mode, False, status_msg)
|
||||
try:
|
||||
sock.sendall(map_to_rlcd_hw_buffer(img))
|
||||
except:
|
||||
pass
|
||||
try:
|
||||
with open(args.file, "w") as f:
|
||||
f.write(note_text)
|
||||
except:
|
||||
pass
|
||||
sock.close()
|
||||
return
|
||||
|
||||
elif key == '\x13': # Ctrl+S: Save Note
|
||||
try:
|
||||
with open(args.file, "w") as f:
|
||||
f.write(note_text)
|
||||
status_msg = "Saved!"
|
||||
except:
|
||||
status_msg = "Err Save"
|
||||
status_msg_expiry = now + 2.0
|
||||
|
||||
elif key == '\x14': # Ctrl+T: Toggle Theme
|
||||
dark_mode = not dark_mode
|
||||
status_msg = "Theme Dark" if dark_mode else "Theme Light"
|
||||
status_msg_expiry = now + 2.0
|
||||
|
||||
elif key == '\x06': # Ctrl+F: Cycle Fonts
|
||||
font_idx = (font_idx + 1) % len(font_options)
|
||||
f_path, f_size, f_name = font_options[font_idx]
|
||||
font, char_h = load_font(f_path, f_size)
|
||||
status_msg = f"Font: {f_name}"
|
||||
status_msg_expiry = now + 2.0
|
||||
|
||||
# Backspace
|
||||
elif key in ('\x7f', '\x08'):
|
||||
if cursor_pos > 0:
|
||||
note_text = note_text[:cursor_pos - 1] + note_text[cursor_pos:]
|
||||
cursor_pos -= 1
|
||||
|
||||
# Enter: Newline
|
||||
elif key in ('\r', '\n'):
|
||||
note_text = note_text[:cursor_pos] + "\n" + note_text[cursor_pos:]
|
||||
cursor_pos += 1
|
||||
|
||||
# Normal typed characters
|
||||
elif ord(key) >= 32:
|
||||
note_text = note_text[:cursor_pos] + key + note_text[cursor_pos:]
|
||||
cursor_pos += 1
|
||||
|
||||
elif isinstance(key, int):
|
||||
lines, line_starts, cursor_line, cursor_col = wrap_text_by_width(note_text, cursor_pos, font, max_width=360)
|
||||
|
||||
if key == curses.KEY_UP:
|
||||
if cursor_line > 0:
|
||||
target_line = cursor_line - 1
|
||||
target_col = min(cursor_col, len(lines[target_line]))
|
||||
cursor_pos = line_starts[target_line] + target_col
|
||||
|
||||
elif key == curses.KEY_DOWN:
|
||||
if cursor_line < len(lines) - 1:
|
||||
target_line = cursor_line + 1
|
||||
target_col = min(cursor_col, len(lines[target_line]))
|
||||
cursor_pos = line_starts[target_line] + target_col
|
||||
|
||||
elif key == curses.KEY_RIGHT:
|
||||
if cursor_pos < len(note_text):
|
||||
cursor_pos += 1
|
||||
|
||||
elif key == curses.KEY_LEFT:
|
||||
if cursor_pos > 0:
|
||||
cursor_pos -= 1
|
||||
|
||||
elif key == curses.KEY_BACKSPACE:
|
||||
if cursor_pos > 0:
|
||||
note_text = note_text[:cursor_pos - 1] + note_text[cursor_pos:]
|
||||
cursor_pos -= 1
|
||||
|
||||
if redraw:
|
||||
redraw = False
|
||||
# Render to RLCD and stream
|
||||
img, scroll_top = render_screen(note_text, cursor_pos, scroll_top, font, char_h, line_spacing, max_lines_on_screen, dark_mode, show_cursor, status_msg)
|
||||
try:
|
||||
raw_bytes = map_to_rlcd_hw_buffer(img)
|
||||
sock.sendall(raw_bytes)
|
||||
except BlockingIOError:
|
||||
pass
|
||||
except:
|
||||
break
|
||||
|
||||
# Draw Console UI on terminal screen
|
||||
stdscr.erase()
|
||||
h, w = stdscr.getmaxyx()
|
||||
|
||||
# Header
|
||||
stdscr.attron(curses.A_REVERSE)
|
||||
today_header = datetime.date.today().strftime("%A, %B %d, %Y")
|
||||
header_str = f" NOTE KEEPER | {today_header} "
|
||||
stdscr.addstr(0, 0, header_str + " " * (w - len(header_str) - 1))
|
||||
stdscr.attroff(curses.A_REVERSE)
|
||||
|
||||
# Note Content
|
||||
stdscr.addstr(2, 0, f"--- Note File: {args.file} (Font: {f_name}) ---", curses.A_BOLD)
|
||||
|
||||
# Wrap text to display in console
|
||||
term_lines, _, term_cursor_line, term_cursor_col = wrap_text_by_width(note_text, cursor_pos, font, max_width=360)
|
||||
|
||||
for idx, line in enumerate(term_lines):
|
||||
if 4 + idx < h - 3:
|
||||
stdscr.addstr(4 + idx, 2, line)
|
||||
|
||||
# Footer
|
||||
stdscr.attron(curses.A_REVERSE)
|
||||
footer_str = " Ctrl+S: Save | Ctrl+T: Theme | Ctrl+F: Font | Ctrl+X: Save & Exit "
|
||||
stdscr.addstr(h - 1, 0, footer_str + " " * (w - len(footer_str) - 1))
|
||||
stdscr.attroff(curses.A_REVERSE)
|
||||
|
||||
# Corner stats
|
||||
if status_msg:
|
||||
stdscr.addstr(h - 2, w - len(status_msg) - 2, f"[{status_msg}]", curses.A_BOLD)
|
||||
else:
|
||||
stdscr.addstr(h - 2, w - 18, f"Chars: {len(note_text)}")
|
||||
|
||||
# Position Terminal Cursor
|
||||
try:
|
||||
stdscr.move(4 + term_cursor_line, 2 + term_cursor_col)
|
||||
except:
|
||||
pass
|
||||
|
||||
stdscr.refresh()
|
||||
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def main():
|
||||
# 1. Determine daily default filename: note_YYYY-MM-DD.txt
|
||||
today_str = datetime.date.today().strftime("%Y-%m-%d")
|
||||
default_filename = f"note_{today_str}.txt"
|
||||
|
||||
parser = argparse.ArgumentParser(description="Curses Pi Zero Kid Note Taking Streaming App")
|
||||
parser.add_argument("--ip", default="192.168.68.123", help="Target ESP32-S3 IP Address (default: 192.168.68.123)")
|
||||
parser.add_argument("--port", type=int, default=8081, help="Streaming TCP Port (default: 8081)")
|
||||
parser.add_argument("--file", default=default_filename, help=f"Filename to persist notes (default: {default_filename})")
|
||||
args = parser.parse_args()
|
||||
|
||||
curses.wrapper(run_editor, args)
|
||||
print(f"\nExited. Note saved to {args.file}. Thank you for using Note Keeper!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+4
-1
@@ -6,7 +6,10 @@ import neopixel
|
||||
class BoardLED:
|
||||
"""Utility class to control the onboard WS2812 (NeoPixel) RGB LED on GPIO 38."""
|
||||
|
||||
def __init__(self, pin_num=38):
|
||||
def __init__(self, pin_num=None):
|
||||
import sys
|
||||
if pin_num is None:
|
||||
pin_num = 14 if sys.platform == 'rp2' else 38
|
||||
self.np = neopixel.NeoPixel(Pin(pin_num), 1)
|
||||
self.base_color = (0, 0, 0)
|
||||
self.off()
|
||||
|
||||
@@ -129,13 +129,19 @@ class RLCD:
|
||||
self.write_cmd(0x3A); self.write_data(0x11)
|
||||
self.write_cmd(0xB9); self.write_data(0x20)
|
||||
self.write_cmd(0xB8); self.write_data(0x29)
|
||||
self.write_cmd(0x20) # Inversion OFF (White Background)
|
||||
self.write_cmd(0x21) # Inversion ON (Black Background by default)
|
||||
self.write_cmd(0x2A); self.write_data([0x12, 0x2A])
|
||||
self.write_cmd(0x2B); self.write_data([0x00, 0xC7])
|
||||
self.write_cmd(0x35); self.write_data(0x00)
|
||||
self.write_cmd(0xD0); self.write_data(0xFF)
|
||||
self.write_cmd(0x38); self.write_cmd(0x29)
|
||||
|
||||
def invert(self, enable):
|
||||
if enable:
|
||||
self.write_cmd(0x21) # Inversion ON (Black Background)
|
||||
else:
|
||||
self.write_cmd(0x20) # Inversion OFF (White Background)
|
||||
|
||||
# --- THE HEAVY LIFTER (Optimized) ---
|
||||
@micropython.native
|
||||
def show(self):
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import serial
|
||||
import time
|
||||
import sys
|
||||
|
||||
PORT = '/dev/cu.usbmodem101'
|
||||
print("=== MicroPython Robust Flash Wipe Tool ===")
|
||||
print(f"Waiting for a clean, active connection on {PORT}...")
|
||||
print("Please press the physical RESET button on the board now...")
|
||||
|
||||
ser = None
|
||||
while True:
|
||||
try:
|
||||
# Open port
|
||||
ser = serial.Serial(PORT, 115200, timeout=1.0)
|
||||
# Try writing Ctrl-C to verify if the link is active
|
||||
ser.write(b'\x03')
|
||||
time.sleep(0.01)
|
||||
ser.write(b'\x03')
|
||||
|
||||
# If write succeeded, we have an active link!
|
||||
print("\n[+] Active connection established!")
|
||||
break
|
||||
except (serial.SerialException, OSError, Exception) as e:
|
||||
if ser is not None:
|
||||
try:
|
||||
ser.close()
|
||||
except:
|
||||
pass
|
||||
ser = None
|
||||
# Print dot to show progress, stay on same line
|
||||
print(".", end="")
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.1)
|
||||
|
||||
print("[*] Sending remaining Ctrl-C storm to break into REPL...")
|
||||
for _ in range(80):
|
||||
try:
|
||||
ser.write(b'\x03')
|
||||
time.sleep(0.005)
|
||||
except Exception as e:
|
||||
print(f"\n[-] Write error during storm: {e}")
|
||||
break
|
||||
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
ser.reset_input_buffer()
|
||||
except Exception as e:
|
||||
print(f"[-] Buffer reset failed: {e}")
|
||||
|
||||
# Check if we have REPL access
|
||||
print("[*] Verifying REPL responsiveness...")
|
||||
try:
|
||||
ser.write(b'\r\n')
|
||||
time.sleep(0.2)
|
||||
if ser.in_waiting:
|
||||
resp = ser.read(ser.in_waiting)
|
||||
print(f"REPL Response:\n{resp.decode('utf-8', errors='ignore')}")
|
||||
else:
|
||||
ser.write(b'\x03\r\n')
|
||||
time.sleep(0.2)
|
||||
resp = ser.read(ser.in_waiting)
|
||||
print(f"REPL Response:\n{resp.decode('utf-8', errors='ignore')}")
|
||||
except Exception as e:
|
||||
print(f"[-] REPL verification failed: {e}")
|
||||
ser.close()
|
||||
sys.exit(1)
|
||||
|
||||
print("[*] Deleting all files on the flash...")
|
||||
wipe_code = b"import os; print('FILES_BEFORE:', os.listdir()); [os.remove(f) for f in os.listdir() if not (os.stat(f)[0] & 0x4000)]; print('FILES_AFTER:', os.listdir()); print('WIPE_SUCCESS')\r\n"
|
||||
|
||||
try:
|
||||
ser.write(wipe_code)
|
||||
time.sleep(1.0)
|
||||
if ser.in_waiting:
|
||||
result = ser.read(ser.in_waiting).decode('utf-8', errors='ignore')
|
||||
print(f"Execution Output:\n{result}")
|
||||
if "WIPE_SUCCESS" in result:
|
||||
print("[+] SUCCESS! Flash has been wiped clean.")
|
||||
else:
|
||||
print("[-] Wipe did not finish successfully.")
|
||||
else:
|
||||
print("[-] No response to wipe command.")
|
||||
except Exception as e:
|
||||
print(f"[-] Error during wipe command execution: {e}")
|
||||
|
||||
ser.close()
|
||||
@@ -0,0 +1,39 @@
|
||||
import serial
|
||||
import time
|
||||
|
||||
print("Opening serial port /dev/cu.usbmodem101...")
|
||||
try:
|
||||
ser = serial.Serial('/dev/cu.usbmodem101', 115200, timeout=1.0)
|
||||
print("Opened successfully!")
|
||||
|
||||
# Clear input buffer
|
||||
ser.reset_input_buffer()
|
||||
|
||||
# Send Ctrl-C to interrupt anything
|
||||
print("Sending Ctrl-C...")
|
||||
ser.write(b'\x03')
|
||||
time.sleep(0.2)
|
||||
|
||||
# Read response
|
||||
if ser.in_waiting:
|
||||
print(f"After Ctrl-C: {ser.read(ser.in_waiting)}")
|
||||
|
||||
# Send newline
|
||||
print("Sending newline...")
|
||||
ser.write(b'\r\n')
|
||||
time.sleep(0.2)
|
||||
if ser.in_waiting:
|
||||
print(f"After newline: {ser.read(ser.in_waiting)}")
|
||||
|
||||
# Send test print command
|
||||
print("Sending test print command...")
|
||||
ser.write(b"print('REPL_ACTIVE')\r\n")
|
||||
time.sleep(0.5)
|
||||
if ser.in_waiting:
|
||||
print(f"After command: {ser.read(ser.in_waiting)}")
|
||||
else:
|
||||
print("No response to command.")
|
||||
|
||||
ser.close()
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
@@ -0,0 +1,24 @@
|
||||
import serial
|
||||
import time
|
||||
import sys
|
||||
|
||||
PORT = '/dev/cu.usbmodem101'
|
||||
print("=== MicroPython Serial Event Listener ===")
|
||||
print(f"Opening {PORT} at 115200...")
|
||||
try:
|
||||
ser = serial.Serial(PORT, 115200, timeout=1.0)
|
||||
print("Opened successfully! Press Ctrl+C on the host to stop listening.")
|
||||
print("Listening for touch events or debug logs from the board...\n")
|
||||
|
||||
# We do NOT send Ctrl-C to the board, we just read what it outputs
|
||||
while True:
|
||||
if ser.in_waiting:
|
||||
data = ser.read(ser.in_waiting)
|
||||
text = data.decode('utf-8', errors='ignore')
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.05)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped listening.")
|
||||
except Exception as e:
|
||||
print(f"\nError: {e}")
|
||||
@@ -0,0 +1,71 @@
|
||||
import serial
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
|
||||
PORT = '/dev/cu.usbmodem101'
|
||||
print("=== MicroPython Boot Interrupter & Recovery Tool ===")
|
||||
print(f"Waiting for {PORT} to appear. Please press the RESET button on the board...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
ser = serial.Serial(PORT, 115200, timeout=1.0)
|
||||
print("\n[+] Port opened successfully!")
|
||||
break
|
||||
except Exception as e:
|
||||
# Port not present or busy
|
||||
time.sleep(0.01)
|
||||
|
||||
print("[*] Sending Ctrl-C storm to halt boot sequence...")
|
||||
# Send 100 Ctrl-C interrupts in rapid succession
|
||||
for _ in range(100):
|
||||
try:
|
||||
ser.write(b'\x03')
|
||||
time.sleep(0.005)
|
||||
except Exception as e:
|
||||
print(f"Write error during storm: {e}")
|
||||
break
|
||||
|
||||
time.sleep(0.1)
|
||||
ser.reset_input_buffer()
|
||||
|
||||
# Check if we have REPL access by sending a newline and expecting a prompt
|
||||
print("[*] Verifying REPL responsiveness...")
|
||||
ser.write(b'\r\n')
|
||||
time.sleep(0.2)
|
||||
|
||||
if ser.in_waiting:
|
||||
resp = ser.read(ser.in_waiting)
|
||||
print(f"REPL Response:\n{resp.decode('utf-8', errors='ignore')}")
|
||||
else:
|
||||
print("[-] No response to newline, trying to force it...")
|
||||
ser.write(b'\x03\r\n')
|
||||
time.sleep(0.2)
|
||||
resp = ser.read(ser.in_waiting)
|
||||
print(f"REPL Response:\n{resp.decode('utf-8', errors='ignore')}")
|
||||
|
||||
# Try to rename main.py to stop it from running on subsequent boots
|
||||
print("[*] Attempting to disable main.py...")
|
||||
rename_code = b"""
|
||||
import os
|
||||
try:
|
||||
os.rename('main.py', 'main_bad.py')
|
||||
print('RENAME:OK')
|
||||
except Exception as e:
|
||||
print('RENAME:ERROR:', e)
|
||||
"""
|
||||
ser.write(rename_code + b'\r\n')
|
||||
time.sleep(0.5)
|
||||
|
||||
if ser.in_waiting:
|
||||
result = ser.read(ser.in_waiting).decode('utf-8', errors='ignore')
|
||||
print(f"Execution Output:\n{result}")
|
||||
if "RENAME:OK" in result:
|
||||
print("[+] SUCCESS! main.py has been disabled.")
|
||||
print("[+] You can now safely upload files or reboot.")
|
||||
else:
|
||||
print("[-] Rename failed or returned unexpected output.")
|
||||
else:
|
||||
print("[-] No response to rename command.")
|
||||
|
||||
ser.close()
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
|
||||
sys.path.append("/Users/adolforeyna/Projects/PiZeroNoteKeeper")
|
||||
from kernel.system import SystemContext
|
||||
|
||||
def run_test():
|
||||
context = SystemContext(target_ip="127.0.0.1", target_port=8081, pin_target=True)
|
||||
# Start with wrong port (80 instead of 8080) to force fallback
|
||||
context.mcp_port = 80
|
||||
|
||||
# Clear any initial status message from discovery
|
||||
time.sleep(0.5)
|
||||
context.status_msg = ""
|
||||
|
||||
print("Sending play_beep command starting with port 80...")
|
||||
context.play_beep(frequency=1200, duration_ms=40, volume=30)
|
||||
|
||||
# Wait for fallback to execute (requires timeout retry)
|
||||
time.sleep(2.0)
|
||||
|
||||
# Verify fallback succeeded and updated port to 8080
|
||||
if context.mcp_port == 8080:
|
||||
print("SUCCESS: Fallback automatically corrected mcp_port to 8080!")
|
||||
else:
|
||||
print("FAIL: Fallback did not correct port. Current port: {}, status: '{}'".format(context.mcp_port, context.status_msg))
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_test()
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import http.server
|
||||
import threading
|
||||
|
||||
# Add PiZeroNoteKeeper to path so we can import kernel
|
||||
sys.path.append("/Users/adolforeyna/Projects/PiZeroNoteKeeper")
|
||||
from kernel.system import SystemContext
|
||||
|
||||
# Mock HTTP Server to capture the POST request
|
||||
class MockMCPServer(http.server.BaseHTTPRequestHandler):
|
||||
received_request = None
|
||||
|
||||
def do_POST(self):
|
||||
if self.path == "/api/mcp":
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length)
|
||||
MockMCPServer.received_request = json.loads(post_data.decode('utf-8'))
|
||||
|
||||
# Send success response
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"jsonrpc": "2.0", "result": "OK", "id": "test"}).encode('utf-8'))
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format, *args):
|
||||
# Suppress logging to keep output clean
|
||||
pass
|
||||
|
||||
def run_test():
|
||||
# Start mock server
|
||||
server = http.server.HTTPServer(('127.0.0.1', 9999), MockMCPServer)
|
||||
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
server_thread.start()
|
||||
print("Mock MCP server running on http://127.0.0.1:9999")
|
||||
|
||||
# Initialize SystemContext pointing to mock server
|
||||
context = SystemContext(target_ip="127.0.0.1", target_port=8081, pin_target=True)
|
||||
# Set the discovered/active mcp_port
|
||||
context.mcp_port = 9999
|
||||
|
||||
print("Sending play_beep command...")
|
||||
context.play_beep(frequency=1200, duration_ms=40, volume=30)
|
||||
|
||||
# Wait for background thread to execute the HTTP request
|
||||
timeout = 3.0
|
||||
start_time = time.time()
|
||||
while MockMCPServer.received_request is None and (time.time() - start_time) < timeout:
|
||||
time.sleep(0.1)
|
||||
|
||||
# Shutdown server
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
|
||||
# Assert and verify
|
||||
req = MockMCPServer.received_request
|
||||
if req is None:
|
||||
print("FAIL: No request received by mock MCP server within timeout.")
|
||||
sys.exit(1)
|
||||
|
||||
print("Received request payload:")
|
||||
print(json.dumps(req, indent=2))
|
||||
|
||||
try:
|
||||
assert req["jsonrpc"] == "2.0"
|
||||
assert req["method"] == "tools/call"
|
||||
assert req["params"]["name"] == "play_tone"
|
||||
args = req["params"]["arguments"]
|
||||
assert args["frequency"] == 1200
|
||||
assert args["duration_ms"] == 40
|
||||
assert args["volume"] == 30
|
||||
print("\nSUCCESS: All request fields verified perfectly!")
|
||||
except AssertionError as e:
|
||||
print("\nFAIL: Request fields did not match expected values.", e)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_test()
|
||||
@@ -0,0 +1,249 @@
|
||||
import time
|
||||
from machine import Pin, SPI
|
||||
import framebuf
|
||||
import micropython
|
||||
|
||||
class ST7796:
|
||||
def __init__(self, spi, cs, dc, rst, bl=None, width=480, height=320, invert_color=True):
|
||||
self.spi = spi
|
||||
self.cs = cs
|
||||
self.dc = dc
|
||||
self.rst = rst
|
||||
self.bl = bl
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.invert_color = invert_color
|
||||
|
||||
# 1. 1-bit Canvas Buffer (Standard MONO_HLSB for drawing)
|
||||
self.hw_len = (self.width * self.height) // 8
|
||||
self.canvas_buffer = bytearray(self.hw_len)
|
||||
self.canvas = framebuf.FrameBuffer(self.canvas_buffer, self.width, self.height, framebuf.MONO_HLSB)
|
||||
|
||||
# Pre-allocate chunk buffer for conversion (16 rows: 480 * 16 * 2 = 15360 bytes)
|
||||
self.chunk_rows = 16
|
||||
self.row_buffer = bytearray(self.width * self.chunk_rows * 2)
|
||||
|
||||
# Initialize pins
|
||||
self.cs.init(self.cs.OUT, value=1)
|
||||
self.dc.init(self.dc.OUT, value=0)
|
||||
self.rst.init(self.rst.OUT, value=1)
|
||||
|
||||
if self.bl is not None:
|
||||
# PWM or Pin backlight control
|
||||
if isinstance(self.bl, Pin):
|
||||
self.bl.init(self.bl.OUT, value=1)
|
||||
|
||||
self.reset()
|
||||
self.init_display()
|
||||
self.clear(0)
|
||||
self.show()
|
||||
|
||||
# --- DRAWING WRAPPERS ---
|
||||
def pixel(self, x, y, c): self.canvas.pixel(x, y, c)
|
||||
def line(self, x1, y1, x2, y2, c): self.canvas.line(x1, y1, x2, y2, c)
|
||||
def rect(self, x, y, w, h, c): self.canvas.rect(x, y, w, h, c)
|
||||
def fill_rect(self, x, y, w, h, c): self.canvas.fill_rect(x, y, w, h, c)
|
||||
def text(self, msg, x, y, c=1): self.canvas.text(msg, x, y, c)
|
||||
def clear(self, c=0): self.canvas.fill(c)
|
||||
|
||||
# --- SCALABLE TEXT ---
|
||||
def text_large(self, msg, x, y, scale=2, c=1):
|
||||
char_w = 8; char_h = 8
|
||||
tmp_buf = bytearray(char_w * char_h // 8)
|
||||
tmp_fb = framebuf.FrameBuffer(tmp_buf, char_w, char_h, framebuf.MONO_HLSB)
|
||||
for char in msg:
|
||||
tmp_fb.fill(0); tmp_fb.text(char, 0, 0, 1)
|
||||
for py in range(8):
|
||||
for px in range(8):
|
||||
if tmp_fb.pixel(px, py):
|
||||
self.canvas.fill_rect(x + (px * scale), y + (py * scale), scale, scale, c)
|
||||
x += (8 * scale)
|
||||
|
||||
# --- RAW BITMAPS (1:1 scale) ---
|
||||
def bitmap(self, x, y, w, h, pixel_data):
|
||||
img = framebuf.FrameBuffer(pixel_data, w, h, framebuf.MONO_HLSB)
|
||||
self.canvas.blit(img, x, y)
|
||||
|
||||
# --- PBM FILE LOADER WITH SCALING ---
|
||||
def draw_pbm(self, filename, x, y, scale=1):
|
||||
try:
|
||||
with open(filename, 'rb') as f:
|
||||
line1 = f.readline()
|
||||
if not line1.startswith(b'P4'): print("Err: Not P4 PBM"); return
|
||||
while True:
|
||||
line = f.readline()
|
||||
if not line.startswith(b'#'): break
|
||||
dims = line.split(); w = int(dims[0]); h = int(dims[1])
|
||||
data = bytearray(f.read())
|
||||
|
||||
src_fb = framebuf.FrameBuffer(data, w, h, framebuf.MONO_HLSB)
|
||||
|
||||
if scale == 1:
|
||||
self.canvas.blit(src_fb, x, y)
|
||||
else:
|
||||
for sy in range(h):
|
||||
for sx in range(w):
|
||||
if src_fb.pixel(sx, sy):
|
||||
self.canvas.fill_rect(x + (sx * scale), y + (sy * scale), scale, scale, 1)
|
||||
print(f"Loaded {filename} (scale {scale})")
|
||||
except OSError:
|
||||
print(f"Error: Could not open {filename}")
|
||||
|
||||
# --- SCREENSHOT ---
|
||||
def save_screenshot(self, filename):
|
||||
print(f"Saving screenshot to {filename}...")
|
||||
try:
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(b'P4\n')
|
||||
f.write(f"{self.width} {self.height}\n".encode())
|
||||
f.write(self.canvas_buffer)
|
||||
print("Saved!")
|
||||
except Exception as e:
|
||||
print(f"Error saving screenshot: {e}")
|
||||
|
||||
# --- HARDWARE LOGIC ---
|
||||
def reset(self):
|
||||
self.rst(1); time.sleep_ms(5); self.rst(0); time.sleep_ms(15); self.rst(1); time.sleep_ms(15)
|
||||
|
||||
def write_cmd(self, cmd):
|
||||
self.dc(0); self.cs(0); self.spi.write(bytearray([cmd])); self.cs(1)
|
||||
|
||||
def write_data(self, data):
|
||||
self.dc(1); self.cs(0)
|
||||
if isinstance(data, int): self.spi.write(bytearray([data]))
|
||||
elif isinstance(data, list): self.spi.write(bytearray(data))
|
||||
else: self.spi.write(data)
|
||||
self.cs(1)
|
||||
|
||||
def init_display(self):
|
||||
# ST7796 Minimal Initialization Sequence (ported from working C++ codebase)
|
||||
self.write_cmd(0x01) # SWRESET
|
||||
time.sleep_ms(150)
|
||||
|
||||
self.write_cmd(0x11) # SLPOUT
|
||||
time.sleep_ms(120)
|
||||
|
||||
# Pixel Format (COLMOD) = 0x55 (16-bit color / RGB565)
|
||||
self.write_cmd(0x3A); self.write_data(0x55)
|
||||
time.sleep_ms(10)
|
||||
|
||||
# Memory Access Control (MADCTL) = 0xE0 (Landscape: MY=1, MX=1, MV=1, RGB order)
|
||||
self.write_cmd(0x36); self.write_data(0xE0)
|
||||
time.sleep_ms(10)
|
||||
|
||||
# Display Inversion Control
|
||||
if self.invert_color:
|
||||
self.write_cmd(0x21) # INVON
|
||||
else:
|
||||
self.write_cmd(0x20) # INVOFF
|
||||
time.sleep_ms(10)
|
||||
|
||||
self.write_cmd(0x13) # NORON
|
||||
time.sleep_ms(10)
|
||||
|
||||
self.write_cmd(0x29) # DISPON
|
||||
time.sleep_ms(120)
|
||||
|
||||
def invert(self, enable):
|
||||
if enable:
|
||||
self.write_cmd(0x21) # INVON
|
||||
else:
|
||||
self.write_cmd(0x20) # INVOFF
|
||||
|
||||
def set_window(self, x0, y0, x1, y1):
|
||||
# Column Address Set (CASET)
|
||||
self.write_cmd(0x2A)
|
||||
self.write_data(bytearray([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
|
||||
|
||||
# Row Address Set (RASET)
|
||||
self.write_cmd(0x2B)
|
||||
self.write_data(bytearray([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
|
||||
|
||||
# Memory Write (RAMWR)
|
||||
self.write_cmd(0x2C)
|
||||
|
||||
@micropython.native
|
||||
def _convert_rows(self, start_row, num_rows, row_buf):
|
||||
"""Converts 1-bit monochrome row segment to 16-bit RGB565 format.
|
||||
Compiles block-wise bitwise operations at native speed.
|
||||
"""
|
||||
width = self.width
|
||||
canvas_buf = self.canvas_buffer
|
||||
idx = 0
|
||||
|
||||
for y in range(start_row, start_row + num_rows):
|
||||
byte_offset = y * (width // 8)
|
||||
for x_byte_idx in range(width // 8):
|
||||
val = canvas_buf[byte_offset + x_byte_idx]
|
||||
|
||||
# Unroll 8 bits for speed
|
||||
# Bit 7
|
||||
if val & 0x80:
|
||||
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
|
||||
else:
|
||||
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
|
||||
idx += 2
|
||||
|
||||
# Bit 6
|
||||
if val & 0x40:
|
||||
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
|
||||
else:
|
||||
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
|
||||
idx += 2
|
||||
|
||||
# Bit 5
|
||||
if val & 0x20:
|
||||
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
|
||||
else:
|
||||
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
|
||||
idx += 2
|
||||
|
||||
# Bit 4
|
||||
if val & 0x10:
|
||||
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
|
||||
else:
|
||||
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
|
||||
idx += 2
|
||||
|
||||
# Bit 3
|
||||
if val & 0x08:
|
||||
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
|
||||
else:
|
||||
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
|
||||
idx += 2
|
||||
|
||||
# Bit 2
|
||||
if val & 0x04:
|
||||
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
|
||||
else:
|
||||
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
|
||||
idx += 2
|
||||
|
||||
# Bit 1
|
||||
if val & 0x02:
|
||||
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
|
||||
else:
|
||||
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
|
||||
idx += 2
|
||||
|
||||
# Bit 0
|
||||
if val & 0x01:
|
||||
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
|
||||
else:
|
||||
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
|
||||
idx += 2
|
||||
|
||||
def show(self):
|
||||
"""Refreshes the screen by writing the frame buffer segment-by-segment."""
|
||||
self.set_window(0, 0, self.width - 1, self.height - 1)
|
||||
|
||||
self.dc(1)
|
||||
self.cs(0)
|
||||
|
||||
num_chunks = self.height // self.chunk_rows
|
||||
for chunk in range(num_chunks):
|
||||
start_row = chunk * self.chunk_rows
|
||||
self._convert_rows(start_row, self.chunk_rows, self.row_buffer)
|
||||
self.spi.write(self.row_buffer)
|
||||
|
||||
self.cs(1)
|
||||
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
import os
|
||||
import datetime
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Add workspace to path to import notetaker functions
|
||||
sys.path.append("/Users/adolforeyna/Projects/MicroPython/test1/Screen")
|
||||
import notetaker
|
||||
|
||||
def test_rendering():
|
||||
output_dir = "/Users/adolforeyna/.gemini/antigravity/brain/95f1ff45-7d9b-4493-ad2c-fdf647d73805"
|
||||
text = "Adolfo Reyna Hey\nThis is a bigger font for my son!\nIt supports handwriting and typewriter styles."
|
||||
cursor_pos = len(text)
|
||||
scroll_top = 0
|
||||
|
||||
# Font folder
|
||||
font_folder = "/Users/adolforeyna/Projects/MicroPython/test1/Screen/fonts"
|
||||
|
||||
# Test cases: (font_path, font_size, font_name, output_filename)
|
||||
test_cases = [
|
||||
(os.path.join(font_folder, "PatrickHand.ttf"), 22, "Handwritten", "render_kids_handwritten.png"),
|
||||
(os.path.join(font_folder, "CourierPrime.ttf"), 20, "Typewriter", "render_kids_typewriter.png")
|
||||
]
|
||||
|
||||
for font_path, font_size, font_name, filename in test_cases:
|
||||
font, char_h = notetaker.load_font(font_path, font_size)
|
||||
line_spacing = char_h + 8
|
||||
max_lines = (notetaker.BOTTOM_LIMIT - notetaker.TOP_LIMIT) // line_spacing
|
||||
|
||||
img, scroll_top = notetaker.render_screen(
|
||||
text, cursor_pos, scroll_top, font,
|
||||
char_h, line_spacing, max_lines,
|
||||
dark_mode=False, show_cursor=True, status_msg=f"Font: {font_name}"
|
||||
)
|
||||
|
||||
dest_path = os.path.join(output_dir, filename)
|
||||
img.save(dest_path)
|
||||
print(f"Saved {font_name} layout test to {dest_path}")
|
||||
print(f" char_h: {char_h}px, line_spacing: {line_spacing}px, max_lines: {max_lines}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_rendering()
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import time
|
||||
import argparse
|
||||
import sys
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
# Dimensions of the reflective LCD
|
||||
WIDTH = 400
|
||||
HEIGHT = 300
|
||||
|
||||
def map_to_rlcd_hw_buffer(img_1bit):
|
||||
"""Converts a standard 1-bit monochrome PIL Image to the Waveshare RLCD hardware buffer layout."""
|
||||
px = img_1bit.load()
|
||||
hw_buffer = bytearray(15000)
|
||||
|
||||
for y in range(HEIGHT):
|
||||
for x in range(WIDTH):
|
||||
if px[x, y]: # True if pixel is white (1)
|
||||
inv_y = HEIGHT - 1 - y
|
||||
byte_x = x // 2
|
||||
block_y = inv_y // 4
|
||||
index = byte_x * 75 + block_y
|
||||
local_x = x % 2
|
||||
local_y = inv_y % 4
|
||||
bit = 7 - (local_y * 2 + local_x)
|
||||
hw_buffer[index] |= (1 << bit)
|
||||
|
||||
return hw_buffer
|
||||
|
||||
def send_udp_frame_chunked(sock, esp32_ip, port, frame_idx, raw_bytes):
|
||||
"""Sends a 15,000-byte frame split into 15 small UDP packets to bypass MTU and OS limits."""
|
||||
frame_id = frame_idx % 256
|
||||
for chunk_idx in range(15):
|
||||
packet = bytearray(1002)
|
||||
packet[0] = frame_id
|
||||
packet[1] = chunk_idx
|
||||
start = chunk_idx * 1000
|
||||
packet[2:1002] = raw_bytes[start : start + 1000]
|
||||
sock.sendto(packet, (esp32_ip, port))
|
||||
time.sleep(0.010) # Add small pacing delay to prevent network buffer flooding
|
||||
|
||||
def generate_animation_frame(frame_idx, mode_name):
|
||||
"""Generates a test pattern animation frame using Pillow."""
|
||||
# Create 1-bit image (0 = black background)
|
||||
img = Image.new("1", (WIDTH, HEIGHT), 0)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw header text
|
||||
draw.text((10, 10), "ESP32-S3 VIDEO STREAM TEST", fill=1)
|
||||
draw.line((10, 22, 390, 22), fill=1)
|
||||
|
||||
# Display active settings
|
||||
draw.text((15, 30), f"Protocol : {mode_name}", fill=1)
|
||||
draw.text((15, 45), f"Frame No : {frame_idx}", fill=1)
|
||||
|
||||
# 1. Draw a bouncing rectangle
|
||||
rect_w, rect_h = 60, 40
|
||||
bounce_x = int((frame_idx * 5) % (WIDTH - rect_w - 20)) + 10
|
||||
bounce_y = int(120 + 20 * (frame_idx % 10 < 5 and (frame_idx % 5) or (5 - frame_idx % 5)))
|
||||
draw.rectangle([bounce_x, bounce_y, bounce_x + rect_w, bounce_y + rect_h], outline=1, width=2)
|
||||
draw.text((bounce_x + 10, bounce_y + 15), "TCP/UDP", fill=1)
|
||||
|
||||
# 2. Draw a spinning needle / line
|
||||
import math
|
||||
angle = frame_idx * 0.1
|
||||
center_x, center_y = 300, 200
|
||||
radius = 40
|
||||
end_x = center_x + radius * math.cos(angle)
|
||||
end_y = center_y + radius * math.sin(angle)
|
||||
draw.ellipse([center_x - radius, center_y - radius, center_x + radius, center_y + radius], outline=1)
|
||||
draw.line((center_x, center_y, end_x, end_y), fill=1)
|
||||
|
||||
# 3. Dynamic scrolling text at the bottom
|
||||
scrolling_text = "Waveshare RLCD 4.2inch -- Low Power -- High Framerate Streaming in MicroPython"
|
||||
scroll_pos = (frame_idx * 3) % 400
|
||||
draw.text((10 - scroll_pos, 275), scrolling_text, fill=1)
|
||||
draw.text((410 - scroll_pos, 275), scrolling_text, fill=1)
|
||||
draw.line((10, 270, 390, 270), fill=1)
|
||||
|
||||
return img
|
||||
|
||||
def stream_camera(esp32_ip, port, protocol):
|
||||
"""Streams camera capture frames using OpenCV (requires opencv-python)."""
|
||||
try:
|
||||
import cv2
|
||||
except ImportError:
|
||||
print("Error: opencv-python is not installed. Run 'pip install opencv-python' or use default animation mode.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Opening camera stream...")
|
||||
cap = cv2.VideoCapture(0)
|
||||
if not cap.isOpened():
|
||||
print("Error: Could not open camera.")
|
||||
sys.exit(1)
|
||||
|
||||
sock = None
|
||||
if protocol == "tcp":
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect((esp32_ip, port))
|
||||
else: # udp
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# Enable broadcast if destination is broadcast
|
||||
if esp32_ip.endswith(".255") or esp32_ip == "255.255.255.255":
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
print(f"Streaming webcam to {esp32_ip}:{port} via {protocol.upper()}...")
|
||||
frame_count = 0
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
while cap.isOpened():
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
# Flip image, convert to RGB
|
||||
frame = cv2.flip(frame, 1)
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
pil_img = Image.fromarray(frame_rgb)
|
||||
|
||||
# Resize and dither
|
||||
pil_img.thumbnail((WIDTH, HEIGHT))
|
||||
bg = Image.new("1", (WIDTH, HEIGHT), 0)
|
||||
offset = ((WIDTH - pil_img.width) // 2, (HEIGHT - pil_img.height) // 2)
|
||||
bg.paste(pil_img.convert("1", dither=Image.FLOYDSTEINBERG), offset)
|
||||
|
||||
# Map pixels to hardware buffer
|
||||
raw_bytes = map_to_rlcd_hw_buffer(bg)
|
||||
|
||||
# Transmit
|
||||
if protocol == "tcp":
|
||||
sock.sendall(raw_bytes)
|
||||
else:
|
||||
send_udp_frame_chunked(sock, esp32_ip, port, frame_count, raw_bytes)
|
||||
|
||||
frame_count += 1
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= 2.0:
|
||||
print(f"Streaming performance: {frame_count / elapsed:.1f} FPS")
|
||||
frame_count = 0
|
||||
start_time = time.time()
|
||||
|
||||
# Target ~30 FPS
|
||||
time.sleep(0.033)
|
||||
|
||||
finally:
|
||||
cap.release()
|
||||
if sock:
|
||||
sock.close()
|
||||
|
||||
def stream_animation(esp32_ip, port, protocol):
|
||||
"""Streams a generated Pillow vector animation."""
|
||||
sock = None
|
||||
if protocol == "tcp":
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect((esp32_ip, port))
|
||||
else: # udp
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
if esp32_ip.endswith(".255") or esp32_ip == "255.255.255.255":
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
print(f"Streaming vector animation to {esp32_ip}:{port} via {protocol.upper()}...")
|
||||
frame_idx = 0
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Generate frame
|
||||
pil_img = generate_animation_frame(frame_idx, f"{protocol.upper()} Stream")
|
||||
|
||||
# Map pixels to hardware buffer
|
||||
raw_bytes = map_to_rlcd_hw_buffer(pil_img)
|
||||
|
||||
# Transmit
|
||||
if protocol == "tcp":
|
||||
sock.sendall(raw_bytes)
|
||||
else:
|
||||
send_udp_frame_chunked(sock, esp32_ip, port, frame_idx, raw_bytes)
|
||||
|
||||
frame_idx += 1
|
||||
|
||||
# FPS tracking printout
|
||||
if frame_idx % 60 == 0:
|
||||
elapsed = time.time() - start_time
|
||||
print(f"Streaming performance: {60 / elapsed:.1f} FPS")
|
||||
start_time = time.time()
|
||||
|
||||
# Restrict rate to ~25 FPS to match update scan limits nicely
|
||||
time.sleep(0.04)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nStreaming stopped.")
|
||||
finally:
|
||||
if sock:
|
||||
sock.close()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Host Client for ESP32-S3 TCP/UDP Video Streaming")
|
||||
parser.add_argument("--ip", default="192.168.68.123", help="Target ESP32-S3 IP Address (default: 192.168.68.123)")
|
||||
parser.add_argument("--protocol", choices=["tcp", "udp"], default="tcp", help="Streaming protocol (default: tcp)")
|
||||
parser.add_argument("--source", choices=["camera", "animation"], default="animation", help="Streaming source (default: animation)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Assign ports based on protocol selection
|
||||
port = 8081 if args.protocol == "tcp" else 8082
|
||||
|
||||
if args.source == "camera":
|
||||
stream_camera(args.ip, port, args.protocol)
|
||||
else:
|
||||
stream_animation(args.ip, port, args.protocol)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import urllib.request
|
||||
import sys
|
||||
|
||||
ESP32_IP = "192.168.68.123"
|
||||
URL = f"http://{ESP32_IP}/api/mcp"
|
||||
|
||||
def call_mcp_tool(tool_name, arguments):
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": tool_name,
|
||||
"arguments": arguments
|
||||
}
|
||||
}
|
||||
|
||||
req_data = json.dumps(payload).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
URL,
|
||||
data=req_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10.0) as response:
|
||||
resp_body = response.read().decode('utf-8')
|
||||
resp_json = json.loads(resp_body)
|
||||
if "error" in resp_json:
|
||||
print(f"Error calling {tool_name}: {resp_json['error']}")
|
||||
return None
|
||||
return resp_json.get("result", {}).get("content", [{}])[0].get("text", "")
|
||||
except Exception as e:
|
||||
print(f"Connection failed for {tool_name}: {e}")
|
||||
return None
|
||||
|
||||
def upload_file(local_path, remote_path):
|
||||
print(f"Reading local file {local_path}...")
|
||||
with open(local_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
print(f"Uploading to ESP32: {remote_path} ({len(content)} chars)...")
|
||||
res = call_mcp_tool("write_file", {"path": remote_path, "content": content})
|
||||
if res:
|
||||
print(f"Success: {res}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def main():
|
||||
print(f"--- RP2350/ESP32-S3 OTA File Uploader ({ESP32_IP}) ---")
|
||||
|
||||
# Upload new drivers, utilities, and updated scripts
|
||||
files_to_upload = [
|
||||
("st7796.py", "st7796.py"),
|
||||
("ft6336u.py", "ft6336u.py"),
|
||||
("battery_util.py", "battery_util.py"),
|
||||
("rgb_led_util.py", "rgb_led_util.py"),
|
||||
("video_stream.py", "video_stream.py"),
|
||||
("mcp_server.py", "mcp_server.py"),
|
||||
("boot.py", "boot.py"),
|
||||
("main.py", "main.py")
|
||||
]
|
||||
|
||||
for local, remote in files_to_upload:
|
||||
if not upload_file(local, remote):
|
||||
print(f"Failed to upload {local}. Stopping.")
|
||||
sys.exit(1)
|
||||
|
||||
# 4. Trigger remote reboot
|
||||
print("Sending reboot command to board...")
|
||||
reboot_code = "import machine\nmachine.reset()"
|
||||
res = call_mcp_tool("execute_python", {"code": reboot_code})
|
||||
if res:
|
||||
print("Reboot command acknowledged. The board is restarting!")
|
||||
else:
|
||||
print("Reboot request failed. You may need to manually reset the board.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
import urllib.request
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
import subprocess
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
ESP32_IP = "192.168.68.123"
|
||||
BROADCAST_IP = "192.168.68.255"
|
||||
URL = f"http://{ESP32_IP}/api/mcp"
|
||||
ARTIFACTS_DIR = "/Users/adolforeyna/.gemini/antigravity/brain/8b421e8b-10a8-4d06-a7d0-27bd82b42804"
|
||||
|
||||
def capture_screenshot(output_filename):
|
||||
print(f"Requesting screenshot from ESP32 ({ESP32_IP})...")
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_screenshot",
|
||||
"arguments": {}
|
||||
}
|
||||
}
|
||||
|
||||
req_data = json.dumps(payload).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
URL,
|
||||
data=req_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10.0) as response:
|
||||
resp_body = response.read().decode('utf-8')
|
||||
resp_json = json.loads(resp_body)
|
||||
content = resp_json.get("result", {}).get("content", [{}])[0].get("text", "")
|
||||
|
||||
if content.startswith("__PBM_BASE64__:"):
|
||||
pbm_b64 = content.split(":", 1)[1]
|
||||
pbm_bytes = base64.b64decode(pbm_b64)
|
||||
|
||||
temp_pbm = "temp_capture_bcast.pbm"
|
||||
with open(temp_pbm, "wb") as pf:
|
||||
pf.write(pbm_bytes)
|
||||
|
||||
# Convert to PNG using Pillow
|
||||
img = Image.open(temp_pbm)
|
||||
dest_path = os.path.join(ARTIFACTS_DIR, output_filename)
|
||||
img.save(dest_path)
|
||||
os.remove(temp_pbm)
|
||||
print(f"Success: Screenshot saved to {dest_path}")
|
||||
return True
|
||||
else:
|
||||
print("Error: Invalid screenshot response format.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Failed to capture screenshot: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print(f"--- Starting UDP Broadcast Stream Verification ({BROADCAST_IP}) ---")
|
||||
|
||||
# 1. Start test_stream.py targeting broadcast IP
|
||||
cmd = ["python3", "test_stream.py", "--ip", BROADCAST_IP, "--protocol", "udp", "--source", "animation"]
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
print(f"Broadcast stream started in background (PID: {proc.pid}).")
|
||||
|
||||
# 2. Wait for packets to propagate
|
||||
print("Broadcasting UDP packets... Waiting 3 seconds...")
|
||||
time.sleep(3.0)
|
||||
|
||||
# 3. Capture screen rendering on ESP32
|
||||
capture_screenshot("udp_broadcast_success.png")
|
||||
|
||||
# 4. Terminate stream client
|
||||
print(f"Terminating broadcast client process...")
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
print(f"Broadcast stream client stopped.")
|
||||
|
||||
# Let the screen return to dashboard
|
||||
print("Waiting 4 seconds to let the screen return to dashboard...")
|
||||
time.sleep(4.0)
|
||||
print("Completed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
import socket
|
||||
import time
|
||||
import urllib.request
|
||||
import json
|
||||
import base64
|
||||
import os
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ESP32_IP = "192.168.68.123"
|
||||
BROADCAST_IP = "192.168.68.255"
|
||||
PORT = 8082
|
||||
ARTIFACTS_DIR = "/Users/adolforeyna/.gemini/antigravity/brain/8b421e8b-10a8-4d06-a7d0-27bd82b42804"
|
||||
|
||||
def main():
|
||||
print(f"--- Starting UDP Broadcast Single-Frame Test ({BROADCAST_IP}) ---")
|
||||
|
||||
# 1. Generate a test frame with Pillow displaying "BROADCAST SUCCESS!"
|
||||
img = Image.new("1", (400, 300), 0)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((100, 140), "BROADCAST SUCCESS!", fill=1)
|
||||
draw.rectangle([40, 110, 360, 180], outline=1, width=2)
|
||||
|
||||
# Map to Waveshare RLCD buffer format
|
||||
px = img.load()
|
||||
hw_buffer = 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)
|
||||
hw_buffer[idx] |= (1 << bit)
|
||||
|
||||
# 2. Open UDP socket with broadcast permission
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
# Send all 15 chunks representing the single frame
|
||||
print("Broadcasting chunks...")
|
||||
frame_id = 99
|
||||
for chunk_idx in range(15):
|
||||
packet = bytearray(1002)
|
||||
packet[0] = frame_id
|
||||
packet[1] = chunk_idx
|
||||
start = chunk_idx * 1000
|
||||
packet[2:1002] = hw_buffer[start : start + 1000]
|
||||
sock.sendto(packet, (BROADCAST_IP, PORT))
|
||||
time.sleep(0.002) # Small pacing delay
|
||||
|
||||
sock.close()
|
||||
print("Frame broadcast sent.")
|
||||
|
||||
# 3. Wait 1 second for ESP32 to render and display
|
||||
time.sleep(1.0)
|
||||
|
||||
# 4. Capture screenshot from ESP32
|
||||
print(f"Requesting screenshot from ESP32 ({ESP32_IP}) to verify broadcast reception...")
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_screenshot",
|
||||
"arguments": {}
|
||||
}
|
||||
}
|
||||
|
||||
req_data = json.dumps(payload).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
f"http://{ESP32_IP}/api/mcp",
|
||||
data=req_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10.0) as response:
|
||||
resp_body = response.read().decode('utf-8')
|
||||
resp_json = json.loads(resp_body)
|
||||
content = resp_json.get("result", {}).get("content", [{}])[0].get("text", "")
|
||||
|
||||
if content.startswith("__PBM_BASE64__:"):
|
||||
pbm_b64 = content.split(":", 1)[1]
|
||||
pbm_bytes = base64.b64decode(pbm_b64)
|
||||
|
||||
temp_pbm = "temp_capture_bcast.pbm"
|
||||
with open(temp_pbm, "wb") as pf:
|
||||
pf.write(pbm_bytes)
|
||||
|
||||
# Convert to PNG using Pillow
|
||||
img = Image.open(temp_pbm)
|
||||
dest_path = os.path.join(ARTIFACTS_DIR, "udp_broadcast_success.png")
|
||||
img.save(dest_path)
|
||||
os.remove(temp_pbm)
|
||||
print(f"Success: Broadcast screenshot saved to {dest_path}")
|
||||
else:
|
||||
print("Error: Invalid screenshot response format.")
|
||||
except Exception as e:
|
||||
print(f"Failed to capture screenshot: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
import urllib.request
|
||||
import json
|
||||
import base64
|
||||
import time
|
||||
import subprocess
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
ESP32_IP = "192.168.68.123"
|
||||
URL = f"http://{ESP32_IP}/api/mcp"
|
||||
ARTIFACTS_DIR = "/Users/adolforeyna/.gemini/antigravity/brain/8b421e8b-10a8-4d06-a7d0-27bd82b42804"
|
||||
|
||||
def capture_screenshot(output_filename):
|
||||
print(f"Requesting screenshot from ESP32 ({ESP32_IP})...")
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_screenshot",
|
||||
"arguments": {}
|
||||
}
|
||||
}
|
||||
|
||||
req_data = json.dumps(payload).encode('utf-8')
|
||||
req = urllib.request.Request(
|
||||
URL,
|
||||
data=req_data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST"
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=10.0) as response:
|
||||
resp_body = response.read().decode('utf-8')
|
||||
resp_json = json.loads(resp_body)
|
||||
content = resp_json.get("result", {}).get("content", [{}])[0].get("text", "")
|
||||
|
||||
if content.startswith("__PBM_BASE64__:"):
|
||||
pbm_b64 = content.split(":", 1)[1]
|
||||
pbm_bytes = base64.b64decode(pbm_b64)
|
||||
|
||||
temp_pbm = "temp_capture.pbm"
|
||||
with open(temp_pbm, "wb") as pf:
|
||||
pf.write(pbm_bytes)
|
||||
|
||||
# Convert to PNG using Pillow
|
||||
img = Image.open(temp_pbm)
|
||||
dest_path = os.path.join(ARTIFACTS_DIR, output_filename)
|
||||
img.save(dest_path)
|
||||
os.remove(temp_pbm)
|
||||
print(f"Success: Screenshot saved to {dest_path}")
|
||||
return True
|
||||
else:
|
||||
print("Error: Invalid screenshot response format.")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Failed to capture screenshot: {e}")
|
||||
return False
|
||||
|
||||
def run_stream_test(protocol):
|
||||
print(f"\n--- Starting {protocol.upper()} Stream Verification ---")
|
||||
|
||||
# 1. Start test_stream.py in background
|
||||
cmd = ["python3", "test_stream.py", "--ip", ESP32_IP, "--protocol", protocol, "--source", "animation"]
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
print(f"Stream client process started in background (PID: {proc.pid}).")
|
||||
|
||||
# 2. Wait for frames to stream and render
|
||||
print("Streaming frames... Waiting 3 seconds...")
|
||||
time.sleep(3.0)
|
||||
|
||||
# 3. Capture screen rendering
|
||||
filename = f"{protocol}_stream_success.png"
|
||||
capture_screenshot(filename)
|
||||
|
||||
# 4. Terminate stream client
|
||||
print(f"Terminating stream client process...")
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
print(f"Stream client stopped.")
|
||||
|
||||
def main():
|
||||
if not os.path.exists(ARTIFACTS_DIR):
|
||||
os.makedirs(ARTIFACTS_DIR)
|
||||
|
||||
# Test TCP
|
||||
run_stream_test("tcp")
|
||||
|
||||
# Test Dashboard Auto-Timeout (stream timeout is 3.0s, let's wait 4.0s)
|
||||
print("\n--- Verifying Stream Inactivity Timeout ---")
|
||||
print("Waiting 4 seconds for stream to time out on ESP32...")
|
||||
time.sleep(4.0)
|
||||
capture_screenshot("dashboard_resumed.png")
|
||||
|
||||
# Test UDP
|
||||
run_stream_test("udp")
|
||||
|
||||
# Clean up stream
|
||||
print("\nWait 4 seconds to let the screen return to dashboard...")
|
||||
time.sleep(4.0)
|
||||
|
||||
print("\nVerification Completed successfully.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
import socket
|
||||
import time
|
||||
import select
|
||||
import sys
|
||||
import micropython
|
||||
|
||||
class VideoStreamServer:
|
||||
def __init__(self, display, tcp_port=8081, udp_port=8082):
|
||||
self.display = display
|
||||
self.tcp_port = tcp_port
|
||||
self.udp_port = udp_port
|
||||
|
||||
# Sockets
|
||||
self.tcp_server = None
|
||||
self.tcp_client = None
|
||||
self.udp_sock = None
|
||||
|
||||
# State
|
||||
self.active = False
|
||||
self.last_packet_time = 0
|
||||
self.timeout_ms = 3000
|
||||
|
||||
# Frame buffering (Pre-allocated to prevent memory allocations)
|
||||
self.buffer = bytearray(15000)
|
||||
self.view = memoryview(self.buffer)
|
||||
self.tcp_bytes_received = 0
|
||||
|
||||
# UDP Reassembly (15 chunks of 1000 bytes each)
|
||||
self.udp_temp_buffer = bytearray(1002)
|
||||
self.udp_view = memoryview(self.udp_temp_buffer)
|
||||
self.current_frame_id = -1
|
||||
self.chunks_received = 0 # Bitmask for chunks 0-14
|
||||
|
||||
# Performance Stats
|
||||
self.debug = False
|
||||
self.frames_drawn = 0
|
||||
self.udp_packets_received = 0
|
||||
self.udp_frames_complete = 0
|
||||
self.dropped_udp_frames = 0
|
||||
self.last_draw_ms = 0
|
||||
self.last_fps = 0.0
|
||||
|
||||
# FPS Calculation
|
||||
self.fps_start_time = time.ticks_ms()
|
||||
self.fps_frame_count = 0
|
||||
|
||||
@micropython.native
|
||||
def rlcd_to_mono(self, rlcd_buf, canvas_buf):
|
||||
# canvas_buf size: 19200 bytes (480x320)
|
||||
# Clear canvas_buf to 0
|
||||
for i in range(19200):
|
||||
canvas_buf[i] = 0
|
||||
|
||||
X_OFF = 40
|
||||
Y_OFF = 10
|
||||
|
||||
# Loop over 15000 bytes
|
||||
for index in range(15000):
|
||||
val = rlcd_buf[index]
|
||||
if val == 0:
|
||||
continue
|
||||
|
||||
byte_x = index // 75
|
||||
block_y = index % 75
|
||||
x_base = 2 * byte_x + X_OFF
|
||||
y_base = 299 - 4 * block_y + Y_OFF
|
||||
|
||||
# bit 7: local_y = 0, local_x = 0
|
||||
if val & 0x80:
|
||||
cx = x_base
|
||||
cy = y_base
|
||||
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
||||
|
||||
# bit 6: local_y = 0, local_x = 1
|
||||
if val & 0x40:
|
||||
cx = x_base + 1
|
||||
cy = y_base
|
||||
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
||||
|
||||
# bit 5: local_y = 1, local_x = 0
|
||||
if val & 0x20:
|
||||
cx = x_base
|
||||
cy = y_base - 1
|
||||
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
||||
|
||||
# bit 4: local_y = 1, local_x = 1
|
||||
if val & 0x10:
|
||||
cx = x_base + 1
|
||||
cy = y_base - 1
|
||||
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
||||
|
||||
# bit 3: local_y = 2, local_x = 0
|
||||
if val & 0x08:
|
||||
cx = x_base
|
||||
cy = y_base - 2
|
||||
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
||||
|
||||
# bit 2: local_y = 2, local_x = 1
|
||||
if val & 0x04:
|
||||
cx = x_base + 1
|
||||
cy = y_base - 2
|
||||
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
||||
|
||||
# bit 1: local_y = 3, local_x = 0
|
||||
if val & 0x02:
|
||||
cx = x_base
|
||||
cy = y_base - 3
|
||||
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
||||
|
||||
# bit 0: local_y = 3, local_x = 1
|
||||
if val & 0x01:
|
||||
cx = x_base + 1
|
||||
cy = y_base - 3
|
||||
canvas_buf[cy * 60 + (cx >> 3)] |= (1 << (7 - (cx & 7)))
|
||||
|
||||
def start(self):
|
||||
"""Initialize and start the sockets."""
|
||||
# 1. Start TCP Server
|
||||
try:
|
||||
self.tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.tcp_server.bind(('', self.tcp_port))
|
||||
self.tcp_server.listen(1)
|
||||
self.tcp_server.setblocking(False)
|
||||
print(f"TCP Stream Server started on port {self.tcp_port}")
|
||||
except Exception as e:
|
||||
print(f"Failed to start TCP stream server: {e}")
|
||||
|
||||
# 2. Start UDP Server
|
||||
try:
|
||||
self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.udp_sock.bind(('', self.udp_port))
|
||||
self.udp_sock.setblocking(False)
|
||||
print(f"UDP Stream Server started on port {self.udp_port}")
|
||||
except Exception as e:
|
||||
print(f"Failed to start UDP stream server: {e}")
|
||||
|
||||
def update(self):
|
||||
"""Updates connection states and checks for incoming video data.
|
||||
Call this periodically in the main execution loop.
|
||||
"""
|
||||
now = time.ticks_ms()
|
||||
|
||||
# Calculate dynamic FPS every 1 second
|
||||
fps_elapsed = time.ticks_diff(now, self.fps_start_time)
|
||||
if fps_elapsed >= 1000:
|
||||
self.last_fps = (self.fps_frame_count * 1000.0) / fps_elapsed
|
||||
self.fps_frame_count = 0
|
||||
self.fps_start_time = now
|
||||
|
||||
# 1. Check for inactivity timeout
|
||||
if self.active and time.ticks_diff(now, self.last_packet_time) > self.timeout_ms:
|
||||
print("Video stream timed out. Returning to dashboard.")
|
||||
self.active = False
|
||||
self.tcp_bytes_received = 0
|
||||
|
||||
# 2. Handle incoming UDP packet (if UDP is active)
|
||||
if self.udp_sock:
|
||||
while True:
|
||||
try:
|
||||
n = self.udp_sock.readinto(self.udp_temp_buffer)
|
||||
if n is None or n == 0:
|
||||
break # Socket buffer queue is empty
|
||||
|
||||
self.udp_packets_received += 1
|
||||
frame_id = self.udp_temp_buffer[0]
|
||||
chunk_idx = self.udp_temp_buffer[1]
|
||||
|
||||
if self.debug:
|
||||
print(f"[UDP] Received packet size {n}: frame={frame_id}, chunk={chunk_idx}")
|
||||
|
||||
if chunk_idx < 15:
|
||||
# If a new frame sequence starts, reset tracking and check if old frame was incomplete
|
||||
if frame_id != self.current_frame_id:
|
||||
if self.chunks_received != 0:
|
||||
self.dropped_udp_frames += 1
|
||||
self.current_frame_id = frame_id
|
||||
self.chunks_received = 0
|
||||
|
||||
# Copy chunk data directly to frame buffer
|
||||
start_offset = chunk_idx * 1000
|
||||
self.buffer[start_offset : start_offset + 1000] = self.udp_view[2:1002]
|
||||
|
||||
# Mark chunk index as received
|
||||
self.chunks_received |= (1 << chunk_idx)
|
||||
|
||||
# Check if all 15 chunks (indices 0 to 14) are received (0x7FFF)
|
||||
if self.chunks_received == 0x7FFF:
|
||||
self.udp_frames_complete += 1
|
||||
if self.debug:
|
||||
print(f"[UDP] Frame {frame_id} complete! Drawing to screen.")
|
||||
self.active = True
|
||||
self.last_packet_time = now
|
||||
self._draw_frame()
|
||||
self.chunks_received = 0 # Reset for next frame
|
||||
except OSError as e:
|
||||
break # EWOULDBLOCK (no data available)
|
||||
|
||||
# 3. Handle TCP stream
|
||||
if self.tcp_server:
|
||||
# If no client is connected, try to accept one
|
||||
if self.tcp_client is None:
|
||||
try:
|
||||
self.tcp_client, addr = self.tcp_server.accept()
|
||||
self.tcp_client.setblocking(False)
|
||||
self.tcp_bytes_received = 0
|
||||
self.active = True
|
||||
self.last_packet_time = now
|
||||
print(f"TCP Stream client connected from: {addr}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# If client is connected, read data
|
||||
if self.tcp_client is not None:
|
||||
try:
|
||||
remaining = 15000 - self.tcp_bytes_received
|
||||
n = self.tcp_client.readinto(self.view[self.tcp_bytes_received : self.tcp_bytes_received + remaining])
|
||||
|
||||
if n is not None:
|
||||
if n == 0:
|
||||
# Client closed connection
|
||||
print("TCP Stream client disconnected.")
|
||||
self.close_tcp_client()
|
||||
else:
|
||||
self.tcp_bytes_received += n
|
||||
if self.debug:
|
||||
print(f"[TCP] Received {n} bytes (Total: {self.tcp_bytes_received}/15000)")
|
||||
self.last_packet_time = now
|
||||
self.active = True
|
||||
|
||||
# If we have a full frame, render it
|
||||
if self.tcp_bytes_received == 15000:
|
||||
if self.debug:
|
||||
print("[TCP] Frame complete! Drawing to screen.")
|
||||
self.tcp_bytes_received = 0
|
||||
self._draw_frame()
|
||||
except OSError as e:
|
||||
# Connection reset or socket error
|
||||
print(f"TCP Stream socket error: {e}")
|
||||
self.close_tcp_client()
|
||||
|
||||
def _draw_frame(self):
|
||||
"""Sends the frame buffer directly to the SPI display bus."""
|
||||
draw_start = time.ticks_ms()
|
||||
|
||||
if sys.platform == 'rp2':
|
||||
# Decode the 15,000-byte RLCD buffer and write it centered on the 480x320 canvas
|
||||
self.rlcd_to_mono(self.buffer, self.display.canvas_buffer)
|
||||
self.display.show()
|
||||
else:
|
||||
# Frame boundary command sequences for the RLCD display
|
||||
self.display.write_cmd(0x2A); self.display.write_data(0x12); self.display.write_data(0x2A)
|
||||
self.display.write_cmd(0x2B); self.display.write_data(0x00); self.display.write_data(0xC7)
|
||||
self.display.write_cmd(0x2C)
|
||||
|
||||
# Send raw 15,000 bytes directly
|
||||
self.display.write_data(self.buffer)
|
||||
|
||||
self.last_draw_ms = time.ticks_diff(time.ticks_ms(), draw_start)
|
||||
self.frames_drawn += 1
|
||||
self.fps_frame_count += 1
|
||||
|
||||
def get_stats(self):
|
||||
"""Returns streaming performance counters."""
|
||||
return {
|
||||
"frames_drawn": self.frames_drawn,
|
||||
"tcp_bytes_received": self.tcp_bytes_received,
|
||||
"udp_packets_received": self.udp_packets_received,
|
||||
"udp_frames_complete": self.udp_frames_complete,
|
||||
"dropped_udp_frames": self.dropped_udp_frames,
|
||||
"last_draw_ms": self.last_draw_ms,
|
||||
"last_fps": round(self.last_fps, 1),
|
||||
"active": self.active
|
||||
}
|
||||
|
||||
def close_tcp_client(self):
|
||||
"""Safely disconnects the TCP client socket."""
|
||||
if self.tcp_client:
|
||||
try:
|
||||
self.tcp_client.close()
|
||||
except:
|
||||
pass
|
||||
self.tcp_client = None
|
||||
self.tcp_bytes_received = 0
|
||||
@@ -146,6 +146,76 @@ class MainHandler(tornado.web.RequestHandler):
|
||||
# Serve the index.html from the same directory
|
||||
self.render("index.html")
|
||||
|
||||
class APIHandler(tornado.web.RequestHandler):
|
||||
def set_default_headers(self):
|
||||
self.set_header("Access-Control-Allow-Origin", "*")
|
||||
self.set_header("Access-Control-Allow-Headers", "x-requested-with, content-type")
|
||||
self.set_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
|
||||
|
||||
def options(self):
|
||||
self.set_status(204)
|
||||
self.finish()
|
||||
|
||||
async def post(self):
|
||||
try:
|
||||
req = json.loads(self.request.body.decode('utf-8'))
|
||||
method = req.get("method")
|
||||
rpc_id = req.get("id")
|
||||
|
||||
if method == "tools/call":
|
||||
params = req.get("params", {})
|
||||
tool_name = params.get("name")
|
||||
arguments = params.get("arguments", {})
|
||||
|
||||
result = await execute_mcp_tool(tool_name, arguments)
|
||||
if isinstance(result, list):
|
||||
content = result
|
||||
else:
|
||||
content = [{"type": "text", "text": str(result)}]
|
||||
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": rpc_id,
|
||||
"result": {
|
||||
"content": content
|
||||
}
|
||||
}
|
||||
elif method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": rpc_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": "virtual-screen-mcp",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
else:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": rpc_id,
|
||||
"error": {
|
||||
"code": -32601,
|
||||
"message": f"Method {method} not found"
|
||||
}
|
||||
}
|
||||
self.write(json.dumps(resp))
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": None,
|
||||
"error": {
|
||||
"code": -32000,
|
||||
"message": str(e)
|
||||
}
|
||||
}
|
||||
self.write(json.dumps(resp))
|
||||
|
||||
class ClientWebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
clients = set()
|
||||
|
||||
@@ -633,6 +703,7 @@ background_tasks = set()
|
||||
async def main_async(port):
|
||||
app = tornado.web.Application([
|
||||
(r"/", MainHandler),
|
||||
(r"/api/mcp", APIHandler),
|
||||
(r"/ws", ClientWebSocketHandler),
|
||||
], template_path=os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
+14
-3
@@ -1,6 +1,10 @@
|
||||
import network
|
||||
try:
|
||||
import network
|
||||
has_network = True
|
||||
except ImportError:
|
||||
has_network = False
|
||||
|
||||
class WiFiUtil:
|
||||
class _ActiveWiFiUtil:
|
||||
def __init__(self):
|
||||
self.wlan = network.WLAN(network.STA_IF)
|
||||
self.wlan.active(True)
|
||||
@@ -11,7 +15,6 @@ class WiFiUtil:
|
||||
networks = self.wlan.scan()
|
||||
result = []
|
||||
for net in networks:
|
||||
# network tuple: (ssid, bssid, channel, RSSI, authmode, hidden)
|
||||
try:
|
||||
ssid = net[0].decode('utf-8')
|
||||
except Exception:
|
||||
@@ -26,3 +29,11 @@ class WiFiUtil:
|
||||
# Sort by signal strength (RSSI) descending
|
||||
result.sort(key=lambda x: x['rssi'], reverse=True)
|
||||
return result
|
||||
|
||||
class _DummyWiFiUtil:
|
||||
def __init__(self):
|
||||
print("Wi-Fi not supported on this platform.")
|
||||
def scan(self):
|
||||
return []
|
||||
|
||||
WiFiUtil = _ActiveWiFiUtil if has_network else _DummyWiFiUtil
|
||||
|
||||
Reference in New Issue
Block a user