diff --git a/battery_util.py b/battery_util.py
index 91ca8d7..79e1f4e 100644
--- a/battery_util.py
+++ b/battery_util.py
@@ -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.
diff --git a/ble_util.py b/ble_util.py
index 0ee0aab..af826e5 100644
--- a/ble_util.py
+++ b/ble_util.py
@@ -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
diff --git a/boot.py b/boot.py
index 0c89e31..8b5bbfc 100644
--- a/boot.py
+++ b/boot.py
@@ -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:
diff --git a/fonts/CourierPrime.ttf b/fonts/CourierPrime.ttf
new file mode 100644
index 0000000..2a0f8bd
Binary files /dev/null and b/fonts/CourierPrime.ttf differ
diff --git a/fonts/PatrickHand.ttf b/fonts/PatrickHand.ttf
new file mode 100644
index 0000000..fb45ccd
Binary files /dev/null and b/fonts/PatrickHand.ttf differ
diff --git a/fonts/SpecialElite.ttf b/fonts/SpecialElite.ttf
new file mode 100644
index 0000000..c842f35
--- /dev/null
+++ b/fonts/SpecialElite.ttf
@@ -0,0 +1,1483 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Page not found · GitHub · GitHub
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ You can’t perform that action at this time.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ft6336u.py b/ft6336u.py
new file mode 100644
index 0000000..9122bfd
--- /dev/null
+++ b/ft6336u.py
@@ -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)
diff --git a/main.py b/main.py
index b3d502a..1a3fcfc 100644
--- a/main.py
+++ b/main.py
@@ -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()
diff --git a/mcp_server.py b/mcp_server.py
index 4878c78..c852eee 100644
--- a/mcp_server.py
+++ b/mcp_server.py
@@ -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}")
diff --git a/note.txt b/note.txt
new file mode 100644
index 0000000..69866d1
--- /dev/null
+++ b/note.txt
@@ -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!!
+
+
+
+
+
diff --git a/note_2026-06-04.txt b/note_2026-06-04.txt
new file mode 100644
index 0000000..cbebc7b
--- /dev/null
+++ b/note_2026-06-04.txt
@@ -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
+
diff --git a/notetaker.py b/notetaker.py
new file mode 100644
index 0000000..edda74d
--- /dev/null
+++ b/notetaker.py
@@ -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()
diff --git a/rgb_led_util.py b/rgb_led_util.py
index 8365bf5..95447e9 100644
--- a/rgb_led_util.py
+++ b/rgb_led_util.py
@@ -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()
diff --git a/rlcd.py b/rlcd.py
index 766551a..a0ad832 100644
--- a/rlcd.py
+++ b/rlcd.py
@@ -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):
diff --git a/scratch/delete_all.py b/scratch/delete_all.py
new file mode 100644
index 0000000..00a5ee1
--- /dev/null
+++ b/scratch/delete_all.py
@@ -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()
diff --git a/scratch/interact_repl.py b/scratch/interact_repl.py
new file mode 100644
index 0000000..16a1e9c
--- /dev/null
+++ b/scratch/interact_repl.py
@@ -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}")
diff --git a/scratch/listen_touch.py b/scratch/listen_touch.py
new file mode 100644
index 0000000..88491dc
--- /dev/null
+++ b/scratch/listen_touch.py
@@ -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}")
diff --git a/scratch/nuke_boot.py b/scratch/nuke_boot.py
new file mode 100644
index 0000000..3433ac8
--- /dev/null
+++ b/scratch/nuke_boot.py
@@ -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()
diff --git a/scratch/verify_actual_virtual_mcp.py b/scratch/verify_actual_virtual_mcp.py
new file mode 100644
index 0000000..653b33c
--- /dev/null
+++ b/scratch/verify_actual_virtual_mcp.py
@@ -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()
diff --git a/scratch/verify_launcher_beep.py b/scratch/verify_launcher_beep.py
new file mode 100644
index 0000000..2fc66c2
--- /dev/null
+++ b/scratch/verify_launcher_beep.py
@@ -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()
diff --git a/st7796.py b/st7796.py
new file mode 100644
index 0000000..c28ce01
--- /dev/null
+++ b/st7796.py
@@ -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)
diff --git a/test_local_notetaker.py b/test_local_notetaker.py
new file mode 100644
index 0000000..ca02c3d
--- /dev/null
+++ b/test_local_notetaker.py
@@ -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()
diff --git a/test_stream.py b/test_stream.py
new file mode 100644
index 0000000..0cf31d1
--- /dev/null
+++ b/test_stream.py
@@ -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()
diff --git a/upload_files.py b/upload_files.py
new file mode 100644
index 0000000..7b7c5ee
--- /dev/null
+++ b/upload_files.py
@@ -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()
diff --git a/verify_broadcast.py b/verify_broadcast.py
new file mode 100644
index 0000000..708b93d
--- /dev/null
+++ b/verify_broadcast.py
@@ -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()
diff --git a/verify_broadcast_single_frame.py b/verify_broadcast_single_frame.py
new file mode 100644
index 0000000..5547d61
--- /dev/null
+++ b/verify_broadcast_single_frame.py
@@ -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()
diff --git a/verify_streaming.py b/verify_streaming.py
new file mode 100644
index 0000000..82762fc
--- /dev/null
+++ b/verify_streaming.py
@@ -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()
diff --git a/video_stream.py b/video_stream.py
new file mode 100644
index 0000000..49258dc
--- /dev/null
+++ b/video_stream.py
@@ -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
diff --git a/virtual_screen_mcp/server.py b/virtual_screen_mcp/server.py
index a07bd13..52c8f07 100644
--- a/virtual_screen_mcp/server.py
+++ b/virtual_screen_mcp/server.py
@@ -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__)))
diff --git a/wifi_util.py b/wifi_util.py
index 60bd2c4..388d93c 100644
--- a/wifi_util.py
+++ b/wifi_util.py
@@ -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