Implement notetaking app, ST7796 display with touch support, audio beep navigation, and video streaming

This commit is contained in:
Adolfo Reyna
2026-06-08 15:04:36 -04:00
parent 070390355e
commit 3356e5d4a2
30 changed files with 3983 additions and 71 deletions
+3 -2
View File
@@ -4,9 +4,10 @@ class BatteryMonitor:
"""Utility class for monitoring battery voltage and capacity on ESP32-S3-RLCD-4.2.""" """Utility class for monitoring battery voltage and capacity on ESP32-S3-RLCD-4.2."""
def __init__(self, pin_num=4): def __init__(self, pin_num=4):
# Initialize ADC on GPIO 4 with 11dB attenuation import sys
self.adc = ADC(Pin(pin_num)) 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): def read_voltage(self):
"""Reads the battery voltage in Volts using internal calibration. """Reads the battery voltage in Volts using internal calibration.
+35 -17
View File
@@ -1,25 +1,40 @@
import bluetooth try:
import bluetooth
has_ble = True
except ImportError:
has_ble = False
import struct import struct
import time import time
# BLE UUID definitions (Nordic UART Service) if not has_ble:
_UART_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E") class BLEUART:
_UART_TX = ( def __init__(self, ble=None, name="ESP32-S3-RLCD"):
bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"), print("BLE not supported on this platform.")
bluetooth.FLAG_NOTIFY, def on_rx(self, callback): pass
) def write(self, data): return False
_UART_RX = ( def is_connected(self): return False
bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"), def scan(self, duration_ms=3000): return {}
bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE, def close(self): pass
) else:
_UART_SERVICE = (_UART_UUID, (_UART_TX, _UART_RX)) # 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 # BLE IRQ constants
_IRQ_CENTRAL_CONNECT = 1 _IRQ_CENTRAL_CONNECT = 1
_IRQ_CENTRAL_DISCONNECT = 2 _IRQ_CENTRAL_DISCONNECT = 2
_IRQ_GATTS_WRITE = 3 _IRQ_GATTS_WRITE = 3
class BLEUART: class _ActiveBLEUART:
"""A helper class to manage BLE UART (Serial Over BLE) for raw text data exchange.""" """A helper class to manage BLE UART (Serial Over BLE) for raw text data exchange."""
def __init__(self, ble=None, name="ESP32-S3-RLCD"): def __init__(self, ble=None, name="ESP32-S3-RLCD"):
@@ -174,3 +189,6 @@ class BLEUART:
self._ble.gap_disconnect(conn_handle) self._ble.gap_disconnect(conn_handle)
self._ble.active(False) self._ble.active(False)
print("BLE closed.") print("BLE closed.")
if has_ble:
BLEUART = _ActiveBLEUART
+23 -4
View File
@@ -1,17 +1,34 @@
# This file is executed on every boot (including wake-boot from deepsleep) # This file is executed on every boot (including wake-boot from deepsleep)
import network
import time 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 from machine import Pin, SPI
import wifi_config import wifi_config
def connect_wifi(): 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 # Initialize display to show connection progress
display = None display = None
try: try:
# ESP32-S3 configuration
spi = SPI(1, baudrate=20000000, polarity=0, phase=0, sck=Pin(11), mosi=Pin(12)) 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.clear(0)
display.text("ESP32-S3-RLCD-4.2", 10, 10, 1) display.text("ESP32-S3-RLCD-4.2", 10, 10, 1)
display.line(10, 20, 390, 20, 1) display.line(10, 20, 390, 20, 1)
display.text("Connecting to Wi-Fi...", 10, 35, 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) print("Display init failed in boot.py:", e)
# Initialize Wi-Fi Station # Initialize Wi-Fi Station
if not has_network:
return
try: try:
import network
network.hostname('esp32screen') network.hostname('esp32screen')
print("Hostname set to: {}".format(network.hostname())) print("Hostname set to: {}".format(network.hostname()))
except Exception as he: except Exception as he:
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+123
View File
@@ -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)
+134 -37
View File
@@ -1,8 +1,31 @@
import time import time
import network import sys
import machine import machine
from machine import Pin, SPI, I2C 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 # Import our utility classes
from shtc3_util import SHTC3 from shtc3_util import SHTC3
@@ -12,6 +35,7 @@ from button_util import BoardButtons
from rgb_led_util import BoardLED from rgb_led_util import BoardLED
from ble_util import BLEUART from ble_util import BLEUART
from mcp_server import MCPServer from mcp_server import MCPServer
from video_stream import VideoStreamServer
# LED mode options for manual cycling # LED mode options for manual cycling
led_modes = [ led_modes = [
@@ -30,32 +54,82 @@ def main():
print("=== Starting ESP32-S3-RLCD-4.2 Main Boot ===") print("=== Starting ESP32-S3-RLCD-4.2 Main Boot ===")
# 1. Initialize shared buses and peripherals # 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)) if sys.platform == 'rp2':
display = rlcd.RLCD(spi, cs=Pin(40), dc=Pin(5), rst=Pin(41)) 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))
# Configure Audio Amp control pin to save power # Touch controller
amp_pin = Pin(46, Pin.OUT, value=0) 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
if sys.platform != 'rp2':
# Configure Audio Amp control pin to save power
amp_pin = Pin(46, Pin.OUT, value=0)
# 2. Initialize utility objects # 2. Initialize utility objects
sensor = SHTC3(i2c) sensor = None
rtc_chip = PCF85063(i2c) 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() battery = BatteryMonitor()
led = BoardLED(38) led = BoardLED()
buttons = BoardButtons() buttons = None
if sys.platform != 'rp2':
buttons = BoardButtons()
ble_uart = BLEUART(name="ESP32-S3-RLCD") ble_uart = BLEUART(name="ESP32-S3-RLCD")
# Sync system clock from RTC chip # 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) # 3. Connect to Wi-Fi status check
wlan = network.WLAN(network.STA_IF) ip_addr = "Offline (USB)"
ip_addr = wlan.ifconfig()[0] if wlan.isconnected() else "Disconnected" mcp = DummyMCP()
vstream = DummyVStream()
# 4. Start MCP Server if has_network:
mcp = MCPServer(display, led, battery, sensor, rtc_chip, ble_uart) try:
mcp.start(port=80) 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 # Default to Green Breathing
led_modes[local_led_mode_idx][1](led) led_modes[local_led_mode_idx][1](led)
@@ -87,12 +161,14 @@ def main():
mcp.override_active = False mcp.override_active = False
force_dashboard_redraw = True force_dashboard_redraw = True
buttons.key.on_click(on_key_click) if buttons:
buttons.boot.on_click(on_boot_click) buttons.key.on_click(on_key_click)
buttons.boot.on_click(on_boot_click)
# Loop state # Loop state
last_dashboard_update = 0 last_dashboard_update = 0
dashboard_update_interval_ms = 5000 dashboard_update_interval_ms = 5000
last_led_update = 0
print("ESP32 MCP loop running...") print("ESP32 MCP loop running...")
@@ -100,18 +176,30 @@ def main():
while True: while True:
now = time.ticks_ms() 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 # A. Handle non-blocking MCP client connection updates
mcp.update() mcp.update()
# B. Handle non-blocking background video stream updates
vstream.update()
# C. Draw local dashboard (if not overridden by MCP draw text commands) # C. Draw local dashboard (if not overridden by MCP draw text commands or active video stream)
if not mcp.override_active: 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: if force_dashboard_redraw or time.ticks_diff(now, last_dashboard_update) >= dashboard_update_interval_ms:
force_dashboard_redraw = False force_dashboard_redraw = False
last_dashboard_update = now last_dashboard_update = now
# Fetch sensor data # 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" t_str = f"{t} C" if t is not None else "Error"
h_str = f"{h} %" if h 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" bat_str = f"{bat_v:.2f}V ({bat_p}%)" if bat_v is not None else "Error"
# Fetch current time # 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" 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 # Draw standard status dashboard layout
display.clear(0) display.clear(0)
display.text("ESP32-S3-RLCD MCP SERVER", 10, 10, 1) title_text = "RP2350-TFT MCP SERVER" if sys.platform == 'rp2' else "ESP32-S3-RLCD MCP SERVER"
display.line(10, 20, 390, 20, 1) 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_large("ENVIRONMENT", 15, 30, scale=2, c=1)
display.text(f"Temp : {t_str}", 25, 55, 1) display.text(f"Temp : {t_str}", 25, 55, 1)
display.text(f"Humid : {h_str}", 25, 70, 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_large("MCP NET CONNECTION", 15, 105, scale=2, c=1)
display.text(f"IP Address : {ip_addr}", 25, 130, 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"Port / Path : 80 /api/mcp", 25, 145, 1)
display.text(f"BLE Name : ESP32-S3-RLCD", 25, 160, 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_large("SYSTEM STATUS", 15, 195, scale=2, c=1)
display.text(f"Battery : {bat_str}", 25, 220, 1) display.text(f"Battery : {bat_str}", 25, 220, 1)
display.text(f"Time : {time_str}", 25, 235, 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.text(f"Status: {last_action_str}", 15, 265, 1)
display.show() display.show()
# D. Update NeoPixel animation smoothly (runs every 50ms) # D. Update NeoPixel animation smoothly (runs every 50ms)
mode = mcp.active_led_mode if time.ticks_diff(now, last_led_update) >= 50:
if mode == "breath": last_led_update = now
led.update_breathing(1.5) mode = mcp.active_led_mode
elif mode == "rainbow": if mode == "breath":
led.update_rainbow(0.4) led.update_breathing(1.5)
elif mode == "off": elif mode == "rainbow":
led.off() 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__": if __name__ == "__main__":
main() main()
+60 -4
View File
@@ -5,13 +5,14 @@ import time
class MCPServer: class MCPServer:
"""A lightweight JSON-RPC HTTP server implementing Model Context Protocol (MCP) endpoints.""" """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.display = display
self.led = led self.led = led
self.battery = battery self.battery = battery
self.sensor = sensor self.sensor = sensor
self.rtc = rtc self.rtc = rtc
self.ble = ble self.ble = ble
self.vstream = vstream
self.sock = None self.sock = None
self.active_led_mode = "off" # static, breath, rainbow, off self.active_led_mode = "off" # static, breath, rainbow, off
@@ -128,7 +129,7 @@ class MCPServer:
"tools": [ "tools": [
{ {
"name": "clear_screen", "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -144,8 +145,8 @@ class MCPServer:
"type": "object", "type": "object",
"properties": { "properties": {
"text": {"type": "string", "description": "The message to display"}, "text": {"type": "string", "description": "The message to display"},
"x": {"type": "integer", "description": "X coordinate (0-390)"}, "x": {"type": "integer", "description": "X coordinate (0-470)"},
"y": {"type": "integer", "description": "Y coordinate (0-290)"}, "y": {"type": "integer", "description": "Y coordinate (0-310)"},
"size": {"type": "integer", "enum": [1, 2], "description": "Text scale (1=normal, 2=large)"} "size": {"type": "integer", "enum": [1, 2], "description": "Text scale (1=normal, 2=large)"}
}, },
"required": ["text", "x", "y"] "required": ["text", "x", "y"]
@@ -297,6 +298,21 @@ class MCPServer:
}, },
"required": ["url", "filename"] "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: else:
raise RuntimeError(f"Failed to download file from '{url}'.") 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: else:
raise ValueError(f"Unknown tool: {name}") raise ValueError(f"Unknown tool: {name}")
+17
View File
@@ -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!!
+5
View File
@@ -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
View File
@@ -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
View File
@@ -6,7 +6,10 @@ import neopixel
class BoardLED: class BoardLED:
"""Utility class to control the onboard WS2812 (NeoPixel) RGB LED on GPIO 38.""" """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.np = neopixel.NeoPixel(Pin(pin_num), 1)
self.base_color = (0, 0, 0) self.base_color = (0, 0, 0)
self.off() self.off()
+7 -1
View File
@@ -129,13 +129,19 @@ class RLCD:
self.write_cmd(0x3A); self.write_data(0x11) self.write_cmd(0x3A); self.write_data(0x11)
self.write_cmd(0xB9); self.write_data(0x20) self.write_cmd(0xB9); self.write_data(0x20)
self.write_cmd(0xB8); self.write_data(0x29) 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(0x2A); self.write_data([0x12, 0x2A])
self.write_cmd(0x2B); self.write_data([0x00, 0xC7]) self.write_cmd(0x2B); self.write_data([0x00, 0xC7])
self.write_cmd(0x35); self.write_data(0x00) self.write_cmd(0x35); self.write_data(0x00)
self.write_cmd(0xD0); self.write_data(0xFF) self.write_cmd(0xD0); self.write_data(0xFF)
self.write_cmd(0x38); self.write_cmd(0x29) 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) --- # --- THE HEAVY LIFTER (Optimized) ---
@micropython.native @micropython.native
def show(self): def show(self):
+86
View File
@@ -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()
+39
View File
@@ -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}")
+24
View File
@@ -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}")
+71
View File
@@ -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()
+33
View File
@@ -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()
+84
View File
@@ -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()
+249
View File
@@ -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)
+42
View File
@@ -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
View File
@@ -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()
+82
View File
@@ -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()
+90
View File
@@ -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()
+106
View File
@@ -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()
+107
View File
@@ -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
View File
@@ -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
+71
View File
@@ -146,6 +146,76 @@ class MainHandler(tornado.web.RequestHandler):
# Serve the index.html from the same directory # Serve the index.html from the same directory
self.render("index.html") 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): class ClientWebSocketHandler(tornado.websocket.WebSocketHandler):
clients = set() clients = set()
@@ -633,6 +703,7 @@ background_tasks = set()
async def main_async(port): async def main_async(port):
app = tornado.web.Application([ app = tornado.web.Application([
(r"/", MainHandler), (r"/", MainHandler),
(r"/api/mcp", APIHandler),
(r"/ws", ClientWebSocketHandler), (r"/ws", ClientWebSocketHandler),
], template_path=os.path.dirname(os.path.abspath(__file__))) ], template_path=os.path.dirname(os.path.abspath(__file__)))
+14 -3
View File
@@ -1,6 +1,10 @@
import network try:
import network
has_network = True
except ImportError:
has_network = False
class WiFiUtil: class _ActiveWiFiUtil:
def __init__(self): def __init__(self):
self.wlan = network.WLAN(network.STA_IF) self.wlan = network.WLAN(network.STA_IF)
self.wlan.active(True) self.wlan.active(True)
@@ -11,7 +15,6 @@ class WiFiUtil:
networks = self.wlan.scan() networks = self.wlan.scan()
result = [] result = []
for net in networks: for net in networks:
# network tuple: (ssid, bssid, channel, RSSI, authmode, hidden)
try: try:
ssid = net[0].decode('utf-8') ssid = net[0].decode('utf-8')
except Exception: except Exception:
@@ -26,3 +29,11 @@ class WiFiUtil:
# Sort by signal strength (RSSI) descending # Sort by signal strength (RSSI) descending
result.sort(key=lambda x: x['rssi'], reverse=True) result.sort(key=lambda x: x['rssi'], reverse=True)
return result 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