Migrate MCP and utilities to CircuitPython, including color GIF and volume fixes

This commit is contained in:
Adolfo Reyna
2026-06-22 10:13:10 -04:00
parent 2863f21459
commit 8f871e499e
53 changed files with 4239 additions and 108 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+90 -10
View File
@@ -5,13 +5,17 @@ playback using CircuitPython's audiomp3 + audiobusio stack.
"""
import time
import array
import math
import audiobusio
import audiomp3
import audiocore
import digitalio
import pwmio
class ES8311:
ADDR = 0x18
@@ -63,20 +67,29 @@ class ES8311:
def set_volume(self, volume):
volume = max(0, min(100, int(volume)))
self._write(0x32, int(volume * 255 / 100))
if volume <= 0:
reg_val = 0
elif volume <= 50:
# 1..50 maps to 0..191 (0xBF = 0dB)
reg_val = int((volume / 50.0) * 191)
else:
# 51..100 maps to 192..255 (0xFF = +32dB)
reg_val = 191 + int(((volume - 50) / 50.0) * (255 - 191))
self._write(0x32, reg_val)
class BoardAudio:
def __init__(self, i2c, *, bit_clock, word_select, data, mclk, amp):
def __init__(self, i2c, *, bit_clock, word_select, data, mclk, amp, amp_active_level=1):
self.i2c = i2c
self.bit_clock_pin = bit_clock
self.word_select_pin = word_select
self.data_pin = data
self.mclk_pin = mclk
self.amp_pin = amp
self.amp_active_level = amp_active_level
self._mclk = None
self._amp = digitalio.DigitalInOut(amp)
self._amp.switch_to_output(value=False)
self._amp.switch_to_output(value=not amp_active_level)
self.codec = ES8311(i2c)
def start_mclk(self):
@@ -88,7 +101,7 @@ class BoardAudio:
frequency=12288000,
duty_cycle=32768,
variable_frequency=False,
)
)
def stop_mclk(self):
if self._mclk is not None:
@@ -106,25 +119,92 @@ class BoardAudio:
try:
f = open(filename, "rb")
decoder = audiomp3.MP3Decoder(f)
# Use decoder-derived rate if available, else fall back.
sample_rate = getattr(decoder, "sample_rate", 44100)
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = True
self._amp.value = self.amp_active_level
audio.play(decoder)
while audio.playing:
time.sleep(0.05)
return True
finally:
self._amp.value = False
self._amp.value = not self.amp_active_level
if audio is not None:
audio.deinit()
if decoder is not None:
decoder.deinit()
if f is not None:
f.close()
# Leave MCLK running for repeated playback; call stop_mclk() before
# deep sleep if power matters.
def play_wav(self, filename, volume=60):
"""Play a WAV file from CIRCUITPY storage over the onboard speaker."""
self.start_mclk()
self.codec.init()
self.codec.set_volume(volume)
wav = None
audio = None
f = None
try:
f = open(filename, "rb")
wav = audiocore.WaveFile(f)
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = self.amp_active_level
audio.play(wav)
while audio.playing:
time.sleep(0.05)
return True
except Exception as e:
print(f"Error playing WAV: {e}")
return False
finally:
self._amp.value = not self.amp_active_level
if audio is not None:
audio.deinit()
if wav is not None:
wav.deinit()
if f is not None:
f.close()
def play_tone(self, frequency=440, duration_ms=1000, volume=50):
"""Generate and play a pure sine wave tone on the speaker."""
self.start_mclk()
self.codec.init()
self.codec.set_volume(volume)
sample_rate = 16000
length = int(sample_rate / frequency)
if length < 4:
length = 4
sine_wave = array.array("h", [0] * length)
for i in range(length):
sine_wave[i] = int(math.sin(2 * math.pi * i / length) * 32767)
sample = audiocore.RawSample(sine_wave, sample_rate=sample_rate)
audio = None
try:
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = self.amp_active_level
audio.play(sample, loop=True)
time.sleep(duration_ms / 1000.0)
audio.stop()
return True
except Exception as e:
print(f"Error playing tone: {e}")
return False
finally:
self._amp.value = not self.amp_active_level
if audio is not None:
audio.deinit()
sample.deinit()
+42
View File
@@ -0,0 +1,42 @@
"""CircuitPython Battery Monitor utility.
Wraps analogio.AnalogIn to read voltage via a 2x divider and estimate capacity.
"""
import analogio
import board
class BatteryMonitor:
def __init__(self, pin=board.IO9):
self.adc = analogio.AnalogIn(pin)
def read_voltage(self):
try:
# AnalogIn value is 16-bit (0-65535). Map to 3.3V reference.
# Divider is 2x, so actual voltage is scale * 2.
raw = self.adc.value
voltage = (raw / 65535.0) * 3.3 * 2.0
return round(voltage, 3)
except Exception as e:
print(f"Error reading battery ADC: {e}")
return None
def read_percentage(self):
voltage = self.read_voltage()
if voltage is None:
return 0
v_min = 3.0
v_max = 4.2
if voltage <= v_min:
return 0
if voltage >= v_max:
return 100
pct = (voltage - v_min) / (v_max - v_min) * 100.0
return int(pct)
def get_status_summary(self):
v = self.read_voltage()
p = self.read_percentage()
if v is None:
return "Battery: Error"
return f"Battery: {v:.2f}V ({p}%)"
+101
View File
@@ -0,0 +1,101 @@
"""CircuitPython BLE UART utility.
Wraps the adafruit_ble library to advertise Nordic UART Service and scan for nearby devices.
"""
import time
from adafruit_ble import BLERadio
from adafruit_ble.services.nordic import UARTService
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
class BLEUART:
def __init__(self, ble=None, name="ESP32-S3-RLCD"):
self.ble = BLERadio()
self.ble.name = name
self.uart = UARTService()
self.advertisement = ProvideServicesAdvertisement(self.uart)
self.rx_callback = None
self._is_advertising = False
self._start_advertise()
def _start_advertise(self):
if not self.ble.connected and not self._is_advertising:
try:
self.ble.start_advertising(self.advertisement)
self._is_advertising = True
print(f"BLE advertising started as '{self.ble.name}'")
except Exception as e:
print(f"Failed to start BLE advertising: {e}")
def on_rx(self, callback):
self.rx_callback = callback
return callback
def update(self):
"""Polls the UART stream for incoming data and triggers callbacks."""
if self.ble.connected:
self._is_advertising = False
try:
if self.uart.in_waiting:
data = self.uart.read(self.uart.in_waiting)
if self.rx_callback and data:
try:
decoded = data.decode("utf-8").strip()
self.rx_callback(decoded)
except:
self.rx_callback(data)
except Exception as e:
print(f"Error reading BLE RX: {e}")
else:
if not self._is_advertising:
self._start_advertise()
def write(self, data):
if not self.ble.connected:
return False
if isinstance(data, str):
data = data.encode("utf-8")
try:
self.uart.write(data)
return True
except Exception as e:
print(f"Error sending BLE data: {e}")
return False
def is_connected(self):
return self.ble.connected
def scan(self, duration_ms=3000):
"""Scans for nearby BLE devices."""
duration_s = duration_ms / 1000.0
results = {}
was_advertising = self._is_advertising
if was_advertising:
try:
self.ble.stop_advertising()
except:
pass
self._is_advertising = False
try:
print("Starting BLE scan...")
for adv in self.ble.start_scan(timeout=duration_s):
# Clean up address representation (mac address)
mac = str(adv.address).replace("Address(", "").replace(")", "").strip()
name = adv.complete_name or ""
results[mac] = {"rssi": adv.rssi, "name": name}
self.ble.stop_scan()
except Exception as e:
print(f"BLE scan error: {e}")
if was_advertising:
self._start_advertise()
return results
def close(self):
try:
self.ble.stop_advertising()
except:
pass
self._is_advertising = False
print("BLE closed.")
+73
View File
@@ -0,0 +1,73 @@
"""CircuitPython polled and debounced Button driver.
Replaces the MicroPython Pin IRQ implementation with active loop polling.
"""
import time
import digitalio
class Button:
def __init__(self, pin_obj, name="Button", debounce_ms=50, long_press_ms=800):
self.pin = digitalio.DigitalInOut(pin_obj)
self.pin.switch_to_input(pull=digitalio.Pull.UP)
self.name = name
self.debounce_s = debounce_ms / 1000.0
self.long_press_s = long_press_ms / 1000.0
self.last_state = True
self.press_time = 0.0
self.last_debounce_time = 0.0
self.click_callback = None
self.long_press_callback = None
def is_pressed(self):
return not self.pin.value
def on_click(self, callback):
self.click_callback = callback
return callback
def on_long_press(self, callback):
self.long_press_callback = callback
return callback
def update(self):
now = time.monotonic()
val = self.pin.value
if (now - self.last_debounce_time) < self.debounce_s:
return
if val != self.last_state:
self.last_debounce_time = now
self.last_state = val
if not val:
# Pressed (Active Low)
self.press_time = now
else:
# Released
if self.press_time > 0.0:
duration = now - self.press_time
self.press_time = 0.0
if duration >= self.long_press_s:
if self.long_press_callback:
self.long_press_callback()
else:
if self.click_callback:
self.click_callback()
class BoardButtons:
def __init__(self, boot_pin, key_pin=None):
self.boot = Button(boot_pin, "BOOT")
if key_pin is not None:
self.key = Button(key_pin, "KEY")
else:
self.key = None
def update(self):
self.boot.update()
if self.key is not None:
self.key.update()
+66
View File
@@ -0,0 +1,66 @@
"""CircuitPython file download utility.
Downloads files over Wi-Fi in chunks to local flash or microSD card storage.
"""
import os
from sd_cp import SDCardManager
def download_file(url, dest_filename, session, use_sd=True):
"""Downloads a file from a URL over the network.
Args:
url (str): Source URL.
dest_filename (str): Target filename.
session (Session): adafruit_requests Session object.
use_sd (bool): Save to microSD card if True, else local flash.
"""
dest_path = dest_filename
sd_manager = None
if use_sd:
sd_manager = SDCardManager(mount_point='/sd')
if not sd_manager.mount():
print("Download Error: Could not mount SD card.")
return None
dest_path = f"/sd/{dest_filename}"
print(f"Starting download from: {url} -> {dest_path}")
try:
res = session.get(url)
except Exception as e:
print(f"HTTP Connection failed: {e}")
return None
if res.status_code != 200:
print(f"HTTP Error: Received status code {res.status_code}")
res.close()
return None
try:
chunk_size = 4096
total_downloaded = 0
with open(dest_path, 'wb') as f:
for chunk in res.iter_content(chunk_size):
if not chunk:
break
f.write(chunk)
total_downloaded += len(chunk)
if total_downloaded % (chunk_size * 25) == 0:
print(f"Downloaded {total_downloaded // 1024} KB...")
print(f"Download complete! Saved {total_downloaded} bytes to '{dest_path}'.")
return dest_path
except Exception as e:
print(f"Error writing to file: {e}")
try:
os.remove(dest_path)
except:
pass
return None
finally:
res.close()
+144
View File
@@ -0,0 +1,144 @@
"""CircuitPython FT6336U touch controller driver.
Ports the MicroPython ft6336u.py driver using busio.I2C and digitalio.DigitalInOut.
"""
import time
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, int_pin, width=480, height=320, swap_xy=True, invert_x=True, invert_y=False):
self.i2c = i2c
self.rst = rst_pin
self.int = int_pin
self.width = width
self.height = height
self.swap_xy = swap_xy
self.invert_x = invert_x
self.invert_y = invert_y
self.initialized = False
# Configure reset and interrupt pins
self.rst.switch_to_output(value=True)
self.int.switch_to_input(pull=None) # Typically pulled up externally or on-board
self.reset()
self.init_chip()
def reset(self):
self.rst.value = False
time.sleep(0.010)
self.rst.value = True
time.sleep(0.300) # Wait for chip to wake up
def read_reg(self, reg, n=1):
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, bytes([reg]))
buf = bytearray(n)
self.i2c.readfrom_into(self.ADDR, buf)
return buf
except Exception as e:
print(f"I2C read failed at reg 0x{reg:02X}: {e}")
return None
finally:
self.i2c.unlock()
def write_reg(self, reg, val):
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, bytes([reg, val]))
return True
except Exception as e:
print(f"I2C write failed at reg 0x{reg:02X}: {e}")
return False
finally:
self.i2c.unlock()
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:
time.sleep(0.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 by checking the TD_STATUS register."""
if not self.initialized:
return False
td_status = self.read_reg(self.REG_TD_STATUS)
if td_status is None:
return False
touch_count = td_status[0] & 0x0F
if 0 < touch_count < 3:
# Read P1 coordinates to clear register/interrupt state on the chip
self.read_reg(self.REG_P1_XH, 6)
return True
return False
def read_touch(self):
"""Reads touch point coordinates."""
if not self.initialized:
return None
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
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]
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
x = max(0, min(self.width - 1, raw_x))
y = max(0, min(self.height - 1, raw_y))
return (x, y)
+10 -3
View File
@@ -10,7 +10,7 @@ class GIFPlayer:
def __init__(self, display):
self.display = display
def play(self, filename, *, loops=1, x=40, y=0, threshold=1, clear_between_frames=False):
def play(self, filename, *, loops=1, x=40, y=0, threshold=1, clear_between_frames=False, max_frames=-1):
"""Decode a GIF from disk and push frames to the reflective LCD.
CircuitPython gifio currently supports GIFs up to 320 pixels wide, so
@@ -23,14 +23,21 @@ class GIFPlayer:
try:
count = 0
while loops < 0 or count < loops:
frame_count = 0
while True:
if max_frames > 0 and frame_count >= max_frames:
break
delay = gif.next_frame()
if delay is None:
break
if clear_between_frames:
self.display.clear(0)
self.display.draw_bitmap_threshold(gif.bitmap, x, y, threshold=threshold)
self.display.show()
if hasattr(self.display, "draw_bitmap_color"):
self.display.draw_bitmap_color(gif.bitmap, gif.palette, x, y)
else:
self.display.draw_bitmap_threshold(gif.bitmap, x, y, threshold=threshold)
self.display.show()
frame_count += 1
# gifio delay is seconds in modern CircuitPython builds. If
# an older build plays 100x too slowly, divide by 100 here.
time.sleep(max(0.0, delay))
+481
View File
@@ -0,0 +1,481 @@
"""CircuitPython ILI9341 driver for Hosyond ESP32-S3 Touchscreen board.
This driver wraps adafruit_framebuf using a 1-bit MONO_HLSB canvas buffer,
then converts it row-by-row to 16-bit RGB565 via a lookup table (LUT) during show().
"""
import time
try:
import adafruit_framebuf
except ImportError:
adafruit_framebuf = None
class ILI9341:
WIDTH = 320
HEIGHT = 240
def __init__(self, spi, cs, dc, rst=None, bl=None, width=WIDTH, height=HEIGHT, invert_color=True):
if adafruit_framebuf is None:
raise RuntimeError("adafruit_framebuf is required in CIRCUITPY/lib")
self.spi = spi
self.cs = cs
self.dc = dc
self.rst = rst
self.width = width
self.height = height
self.invert_color = invert_color
# 1-bit canvas buffer (1 = White/On, 0 = Black/Off)
self.hw_len = (width * height) // 8
self.canvas_buffer = bytearray(self.hw_len)
self.canvas = adafruit_framebuf.FrameBuffer(
self.canvas_buffer,
width,
height,
adafruit_framebuf.MHMSB, # Matches MONO_HLSB (most significant bit first)
)
# Pre-allocate chunk buffer for conversion (16 rows: 320 * 16 * 2 = 10,240 bytes)
self.chunk_rows = 16
self.row_buffer = bytearray(width * self.chunk_rows * 2)
# Precompute lookup table for fast 1-bit to 16-bit conversion
# Each byte (8 pixels) maps to 16 bytes of RGB565 (8 pixels * 2 bytes)
self.lut = []
for i in range(256):
entry = bytearray(16)
for bit in range(8):
if i & (1 << (7 - bit)):
# White pixel: 0xFFFF (High byte: 0xFF, Low byte: 0xFF)
entry[bit * 2] = 0xFF
entry[bit * 2 + 1] = 0xFF
else:
# Black pixel: 0x0000
entry[bit * 2] = 0x00
entry[bit * 2 + 1] = 0x00
self.lut.append(bytes(entry))
# Setup CS and DC
self.cs.switch_to_output(value=True)
self.dc.switch_to_output(value=False)
# Setup Reset if present
if self.rst is not None:
self.rst.switch_to_output(value=True)
# Setup Backlight PWM if present
if bl is not None:
import pwmio
self.bl_pwm = pwmio.PWMOut(bl, frequency=1000, duty_cycle=65535)
else:
self.bl_pwm = None
self.reset()
self.init_display()
self.clear(0)
self.show()
def reset(self):
if self.rst is not None:
self.rst.value = True
time.sleep(0.005)
self.rst.value = False
time.sleep(0.015)
self.rst.value = True
time.sleep(0.015)
else:
# Software reset command if no reset pin
self.write_cmd(0x01)
time.sleep(0.150)
def _lock_spi(self):
while not self.spi.try_lock():
pass
self.spi.configure(baudrate=40000000, phase=0, polarity=0)
def _unlock_spi(self):
self.spi.unlock()
def write_cmd(self, cmd):
self._lock_spi()
try:
self.cs.value = False
self.dc.value = False
self.spi.write(bytes([cmd & 0xFF]))
self.cs.value = True
finally:
self._unlock_spi()
def write_data(self, data):
if isinstance(data, int):
payload = bytes([data & 0xFF])
elif isinstance(data, (bytes, bytearray, memoryview)):
payload = data
else:
payload = bytes(data)
self._lock_spi()
try:
self.cs.value = False
self.dc.value = True
self.spi.write(payload)
self.cs.value = True
finally:
self._unlock_spi()
def init_display(self):
# SWRESET
self.write_cmd(0x01)
time.sleep(0.150)
self.write_cmd(0xCF); self.write_data(b"\x00\xC1\x30")
self.write_cmd(0xED); self.write_data(b"\x64\x03\x12\x81")
self.write_cmd(0xE8); self.write_data(b"\x85\x00\x78")
self.write_cmd(0xCB); self.write_data(b"\x39\x2C\x00\x34\x02")
self.write_cmd(0xF7); self.write_data(b"\x20")
self.write_cmd(0xEA); self.write_data(b"\x00\x00")
self.write_cmd(0xC0); self.write_data(b"\x13") # Power Control 1
self.write_cmd(0xC1); self.write_data(b"\x13") # Power Control 2
self.write_cmd(0xC5); self.write_data(b"\x22\x35") # VCOM Control 1
self.write_cmd(0xC7); self.write_data(b"\xBD") # VCOM Control 2
# Memory Access Control (MADCTL) = 0x68 (Landscape: MV=1, MX=1, MY=0, BGR color filter)
self.write_cmd(0x36); self.write_data(b"\x68")
self.write_cmd(0xB6); self.write_data(b"\x0A\xA2") # Display Function Control
self.write_cmd(0x3A); self.write_data(b"\x55") # Pixel Format (COLMOD) = 16-bit RGB565
self.write_cmd(0xF6); self.write_data(b"\x01\x30")
self.write_cmd(0xB1); self.write_data(b"\x00\x1B") # Frame Rate Control
self.write_cmd(0xF2); self.write_data(b"\x00")
self.write_cmd(0x26); self.write_data(b"\x01") # Gamma Curve
self.write_cmd(0xE0); self.write_data(b"\x0F\x35\x31\x0B\x0E\x06\x49\xA7\x33\x07\x0F\x03\x0C\x0A\x00")
self.write_cmd(0xE1); self.write_data(b"\x00\x0A\x0F\x04\x11\x08\x36\x58\x4D\x07\x10\x0C\x32\x34\x0F")
if self.invert_color:
self.write_cmd(0x21) # INVON
else:
self.write_cmd(0x20) # INVOFF
self.write_cmd(0x11) # SLPOUT
time.sleep(0.120)
self.write_cmd(0x29) # DISPON
time.sleep(0.010)
def invert(self, enable):
self.write_cmd(0x21 if enable else 0x20)
def set_window(self, x0, y0, x1, y1):
self.write_cmd(0x2A)
self.write_data(bytes([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
self.write_cmd(0x2B)
self.write_data(bytes([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
self.write_cmd(0x2C)
def clear(self, color=0):
self.canvas.fill(1 if color else 0)
def pixel(self, x, y, color):
self.canvas.pixel(x, y, 1 if color else 0)
def line(self, x0, y0, x1, y1, color):
self.canvas.line(x0, y0, x1, y1, 1 if color else 0)
def rect(self, x, y, width, height, color):
self.canvas.rect(x, y, width, height, 1 if color else 0)
def fill_rect(self, x, y, width, height, color):
self.canvas.fill_rect(x, y, width, height, 1 if color else 0)
def text(self, text, x, y, color=1):
self.canvas.text(str(text), x, y, 1 if color else 0)
def text_large(self, text, x, y, scale=2, color=1):
tmp = bytearray(8)
fb = adafruit_framebuf.FrameBuffer(tmp, 8, 8, adafruit_framebuf.MHMSB)
color = 1 if color else 0
for ch in str(text):
fb.fill(0)
fb.text(ch, 0, 0, 1)
for py in range(8):
for px in range(8):
if fb.pixel(px, py):
self.canvas.fill_rect(x + px * scale, y + py * scale, scale, scale, color)
x += 8 * scale
def draw_bitmap_threshold(self, bitmap, x=0, y=0, threshold=1):
width = min(getattr(bitmap, "width", self.width), self.width - x)
height = min(getattr(bitmap, "height", self.height), self.height - y)
for yy in range(height):
for xx in range(width):
self.canvas.pixel(x + xx, y + yy, 1 if bitmap[xx, yy] >= threshold else 0)
def draw_bitmap_color(self, bitmap, palette, x=0, y=0):
width = min(getattr(bitmap, "width", self.width), self.width - x)
height = min(getattr(bitmap, "height", self.height), self.height - y)
row_buf = bytearray(width * 2)
for yy in range(height):
idx = 0
for xx in range(width):
val = bitmap[xx, yy]
if palette is None:
rgb = val
else:
color = palette[val]
if isinstance(color, tuple) or isinstance(color, list):
r, g, b = color[0], color[1], color[2]
elif isinstance(color, int):
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
else:
r, g, b = 0, 0, 0
r5 = r >> 3
g6 = g >> 2
b5 = b >> 3
rgb = (r5 << 11) | (g6 << 5) | b5
row_buf[idx] = (rgb >> 8) & 0xFF
row_buf[idx + 1] = rgb & 0xFF
idx += 2
self.draw_rgb565(x, yy + y, width, 1, row_buf, sync_canvas=False)
def show(self):
"""Optimized conversion of 1-bit frame buffer to 16-bit RGB565 over SPI."""
self.set_window(0, 0, self.width - 1, self.height - 1)
self.dc.value = True
self.cs.value = False
lut = self.lut
canvas_buf = self.canvas_buffer
row_buf = self.row_buffer
width_bytes = self.width // 8 # 40 bytes per row
num_chunks = self.height // self.chunk_rows # 240 // 16 = 15 chunks
for chunk in range(num_chunks):
start_row = chunk * self.chunk_rows
idx = 0
# Loop for 16 rows * 40 bytes/row = 640 bytes. Slice assignment maps directly to LUT.
for y in range(start_row, start_row + self.chunk_rows):
offset = y * width_bytes
for x_byte_idx in range(width_bytes):
val = canvas_buf[offset + x_byte_idx]
row_buf[idx : idx + 16] = lut[val]
idx += 16
self._lock_spi()
try:
self.spi.write(row_buf)
finally:
self._unlock_spi()
self.cs.value = True
def set_brightness(self, level):
if self.bl_pwm is not None:
level = max(0, min(100, level))
self.bl_pwm.duty_cycle = int(level * 65535 / 100)
def set_power(self, on):
if on:
self.write_cmd(0x11) # SLPOUT
time.sleep(0.120)
self.write_cmd(0x29) # DISPON
if self.bl_pwm is not None:
self.bl_pwm.duty_cycle = 65535
else:
self.write_cmd(0x28) # DISPOFF
self.write_cmd(0x10) # SLPIN
time.sleep(0.010)
if self.bl_pwm is not None:
self.bl_pwm.duty_cycle = 0
def _update_mono_canvas_rgb565(self, x, y, w, h, data):
for cy in range(h):
screen_y = y + cy
if screen_y < 0 or screen_y >= self.height:
continue
for cx in range(w):
screen_x = x + cx
if screen_x < 0 or screen_x >= self.width:
continue
idx = (cy * w + cx) * 2
h_byte = data[idx]
l_byte = data[idx + 1]
# Extract RGB from RGB565
r = (h_byte & 0xF8)
g = ((h_byte & 0x07) << 5) | ((l_byte & 0xE0) >> 3)
b = (l_byte & 0x1F) << 3
# Convert to luminance
lum = (r * 299 + g * 587 + b * 114) // 1000
mono = 1 if lum >= 128 else 0
self.canvas.pixel(screen_x, screen_y, mono)
def draw_rgb565(self, x, y, w, h, data, sync_canvas=True):
"""Draw raw RGB565 pixel data on the screen at specified (x,y) with width and height."""
# Clip coordinates
x_start = max(0, x)
x_end = min(self.width - 1, x + w - 1)
y_start = max(0, y)
y_end = min(self.height - 1, y + h - 1)
if x_start > x_end or y_start > y_end:
return True
# Fast path: if completely visible on screen, draw in one go
if x_start == x and x_end == x + w - 1 and y_start == y and y_end == y + h - 1:
self.set_window(x_start, y_start, x_end, y_end)
self.dc.value = True
self.cs.value = False
self._lock_spi()
try:
self.spi.write(data)
finally:
self._unlock_spi()
self.cs.value = True
else:
# Slow path: row-by-row clipping
for cy in range(y_start, y_end + 1):
src_y = cy - y
src_row_offset = (src_y * w + (x_start - x)) * 2
row_len_bytes = (x_end - x_start + 1) * 2
self.set_window(x_start, cy, x_end, cy)
self.dc.value = True
self.cs.value = False
self._lock_spi()
try:
self.spi.write(memoryview(data)[src_row_offset : src_row_offset + row_len_bytes])
finally:
self._unlock_spi()
self.cs.value = True
# Sync the internal 1-bit canvas buffer
if sync_canvas:
self._update_mono_canvas_rgb565(x, y, w, h, data)
return True
def _convert_bgr24_to_rgb565(self, bgr_buf, rgb565_buf, width, src_offset, num_pixels):
idx = 0
for i in range(src_offset, src_offset + num_pixels):
b = bgr_buf[i * 3]
g = bgr_buf[i * 3 + 1]
r = bgr_buf[i * 3 + 2]
r_5 = r >> 3
g_6 = g >> 2
b_5 = b >> 3
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
idx += 2
def _convert_bgra32_to_rgb565(self, bgra_buf, rgb565_buf, width, src_offset, num_pixels):
idx = 0
for i in range(src_offset, src_offset + num_pixels):
b = bgra_buf[i * 4]
g = bgra_buf[i * 4 + 1]
r = bgra_buf[i * 4 + 2]
r_5 = r >> 3
g_6 = g >> 2
b_5 = b >> 3
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
idx += 2
def draw_bmp(self, filename, x=0, y=0):
import struct
try:
with open(filename, 'rb') as f:
header = f.read(54)
if len(header) < 54 or header[0:2] != b'BM':
print("Err: Not a valid BMP file")
return False
pixel_offset = struct.unpack('<I', header[10:14])[0]
width, height = struct.unpack('<ii', header[18:26])
planes, bpp = struct.unpack('<HH', header[26:30])
compression = struct.unpack('<I', header[30:34])[0]
if bpp not in (24, 32):
print("Err: Only 24-bit and 32-bit BMP formats supported")
return False
if compression != 0:
print("Err: Only uncompressed BMP supported")
return False
f.seek(pixel_offset)
bottom_up = True
if height < 0:
height = -height
bottom_up = False
row_bytes = (width * bpp) // 8
row_padded = ((width * bpp + 31) // 32) * 4
read_buf = bytearray(row_padded)
rgb565_buf = bytearray(width * 2)
for row_idx in range(height):
n = f.readinto(read_buf)
if n < row_padded:
break
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
if screen_y < 0 or screen_y >= self.height:
continue
x_start = x
x_end = x + width - 1
if x_start >= self.width or x_end < 0:
continue
win_x0 = max(0, x_start)
win_x1 = min(self.width - 1, x_end)
if win_x1 < win_x0:
continue
src_offset_pixels = win_x0 - x_start
win_w = win_x1 - win_x0 + 1
# Convert pixel data to RGB565 row buffer
if bpp == 24:
self._convert_bgr24_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
elif bpp == 32:
self._convert_bgra32_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
# Draw directly to the screen via SPI window
self.set_window(win_x0, screen_y, win_x1, screen_y)
self.dc.value = True
self.cs.value = False
self._lock_spi()
try:
self.spi.write(memoryview(rgb565_buf)[:win_w * 2])
finally:
self._unlock_spi()
self.cs.value = True
# Also update internal 1-bit canvas buffer for screenshots/refresh consistency
for px in range(win_w):
screen_x = win_x0 + px
src_px = src_offset_pixels + px
if bpp == 24:
b = read_buf[src_px * 3]
g = read_buf[src_px * 3 + 1]
r = read_buf[src_px * 3 + 2]
else:
b = read_buf[src_px * 4]
g = read_buf[src_px * 4 + 1]
r = read_buf[src_px * 4 + 2]
# 0 = Black, 1 = White in conversion for MONO_HLSB canvas
lum = (r * 299 + g * 587 + b * 114) // 1000
mono_c = 1 if lum >= 128 else 0
self.canvas.pixel(screen_x, screen_y, mono_c)
return True
except Exception as e:
print("Error drawing BMP:", e)
return False
File diff suppressed because it is too large Load Diff
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
"""CircuitPython NeoPixel LED utility.
Uses the neopixel library and time.monotonic() to run breathing and rainbow animations.
"""
import math
import time
import neopixel
class BoardLED:
def __init__(self, pin):
# Initialize 1 NeoPixel on the specified pin
self.np = neopixel.NeoPixel(pin, 1, brightness=1.0, auto_write=False)
self.base_color = (0, 0, 0)
self.off()
def set_color(self, r, g, b):
self.base_color = (r, g, b)
self.np[0] = (r, g, b)
self.np.show()
def off(self):
self.set_color(0, 0, 0)
def update_breathing(self, speed_factor=1.0):
if self.base_color == (0, 0, 0):
return
t = time.monotonic() * speed_factor
# Sine wave from 0.05 to 1.0
factor = 0.525 + 0.475 * math.sin(t * math.pi)
br = int(self.base_color[0] * factor)
bg = int(self.base_color[1] * factor)
bb = int(self.base_color[2] * factor)
self.np[0] = (br, bg, bb)
self.np.show()
def update_rainbow(self, speed_factor=0.2):
t = time.monotonic() * speed_factor
r = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi)))
g = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi + 2.0 * math.pi / 3.0)))
b = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi + 4.0 * math.pi / 3.0)))
self.np[0] = (r, g, b)
self.np.show()
+132
View File
@@ -0,0 +1,132 @@
"""CircuitPython PCF85063 Real-Time Clock (RTC) driver.
Synchronizes the hardware RTC with CircuitPython's native rtc.RTC() system clock.
"""
import rtc
import time
class PCF85063:
ADDR = 0x51
TIME_REG_START = 0x04
def __init__(self, i2c):
self.i2c = i2c
self._init_rtc()
def read_reg(self, reg, n=1):
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, bytes([reg]))
buf = bytearray(n)
self.i2c.readfrom_into(self.ADDR, buf)
return buf
except Exception as e:
print(f"RTC read failed at reg 0x{reg:02X}: {e}")
return None
finally:
self.i2c.unlock()
def write_reg(self, reg, data):
while not self.i2c.try_lock():
pass
try:
payload = bytearray([reg])
if isinstance(data, (bytes, bytearray, list)):
payload.extend(data)
else:
payload.append(data)
self.i2c.writeto(self.ADDR, payload)
return True
except Exception as e:
print(f"RTC write failed at reg 0x{reg:02X}: {e}")
return False
finally:
self.i2c.unlock()
def _init_rtc(self):
ctrl1 = self.read_reg(0x00, 1)
if ctrl1 is not None and (ctrl1[0] & 0x20):
print("RTC oscillator was stopped. Starting oscillator...")
self.write_reg(0x00, 0x00)
def _dec2bcd(self, val):
return (val // 10 << 4) | (val % 10)
def _bcd2dec(self, val):
return ((val >> 4) * 10) + (val & 0x0F)
def get_datetime(self):
"""Reads current time from hardware RTC.
Returns:
tuple: (year, month, day, weekday, hour, minute, second) or None on error.
"""
data = self.read_reg(self.TIME_REG_START, 7)
if data is None:
return None
second = self._bcd2dec(data[0] & 0x7F)
minute = self._bcd2dec(data[1] & 0x7F)
hour = self._bcd2dec(data[2] & 0x3F)
day = self._bcd2dec(data[3] & 0x3F)
weekday = data[4] & 0x07
month = self._bcd2dec(data[5] & 0x1F)
year = 2000 + self._bcd2dec(data[6])
return (year, month, day, weekday, hour, minute, second)
def set_datetime(self, dt):
"""Sets the hardware RTC time.
Args:
dt (tuple): (year, month, day, weekday, hour, minute, second)
"""
try:
year, month, day, weekday, hour, minute, second = dt
reg_year = year % 100
data = bytearray(7)
data[0] = self._dec2bcd(second) & 0x7F
data[1] = self._dec2bcd(minute)
data[2] = self._dec2bcd(hour)
data[3] = self._dec2bcd(day)
data[4] = weekday & 0x07
data[5] = self._dec2bcd(month)
data[6] = self._dec2bcd(reg_year)
return self.write_reg(self.TIME_REG_START, data)
except Exception as e:
print(f"Error setting PCF85063 RTC: {e}")
return False
def sync_to_system(self):
"""Synchronizes the CircuitPython system time from the hardware RTC."""
dt = self.get_datetime()
if dt:
year, month, day, weekday, hour, minute, second = dt
r = rtc.RTC()
r.datetime = time.struct_time((year, month, day, hour, minute, second, weekday, -1, -1))
print(f"System clock synced to RTC: {year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}")
return True
return False
def sync_from_system(self):
"""Synchronizes the hardware RTC time from the system clock."""
try:
t = time.localtime()
dt = (t.tm_year, t.tm_mon, t.tm_mday, t.tm_wday, t.tm_hour, t.tm_min, t.tm_sec)
success = self.set_datetime(dt)
if success:
print(f"RTC synced from System: {t.tm_year:04d}-{t.tm_mon:02d}-{t.tm_mday:02d} {t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}")
return success
except Exception as e:
print(f"Error syncing RTC from system: {e}")
return False
def get_time_string(self):
dt = self.get_datetime()
if dt:
return f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}"
return "0000-00-00 00:00:00"
+85
View File
@@ -0,0 +1,85 @@
"""CircuitPython SD card utility using sdioio and storage.
Uses SDMMC 1-bit mode on Pins: sck=board.IO2, cmd=board.IO1, data0=board.IO3.
"""
import os
import board
import sdioio
import storage
class SDCardManager:
def __init__(self, mount_point='/sd'):
self.mount_point = mount_point
self.sd = None
self.mounted = False
def mount(self):
if self.mounted:
print(f"SD card already mounted at {self.mount_point}")
return True
try:
print("Initializing SDCard (SDMMC 1-bit mode: sck=2, cmd=1, d0=3)...")
self.sd = sdioio.SDCard(clock=board.IO2, command=board.IO1, data=[board.IO3], frequency=20000000)
vfs = storage.VfsFat(self.sd)
print(f"Mounting SD card to {self.mount_point}...")
storage.mount(vfs, self.mount_point)
self.mounted = True
print("SD card mounted successfully!")
return True
except Exception as e:
print(f"Failed to mount SD card: {e}")
self.sd = None
self.mounted = False
return False
def unmount(self):
if not self.mounted:
return True
try:
print(f"Unmounting SD card from {self.mount_point}...")
storage.umount(self.mount_point)
if self.sd:
try:
self.sd.deinit()
except:
pass
self.mounted = False
self.sd = None
print("SD card unmounted.")
return True
except Exception as e:
print(f"Failed to unmount SD card: {e}")
return False
def is_mounted(self):
return self.mounted
def list_files(self):
if not self.mounted:
print("SD card is not mounted.")
return None
try:
return os.listdir(self.mount_point)
except Exception as e:
print(f"Error listing SD card files: {e}")
return None
def get_info(self):
if not self.mounted:
return None
try:
stat = os.statvfs(self.mount_point)
block_size = stat[0]
total_blocks = stat[2]
free_blocks = stat[3]
return {
"total_bytes": total_blocks * block_size,
"free_bytes": free_blocks * block_size
}
except Exception as e:
print(f"Error getting SD card info: {e}")
return None
+101
View File
@@ -0,0 +1,101 @@
"""CircuitPython SHTC3 temperature and humidity sensor driver.
Uses standard I2C transactions with bus locking.
"""
import time
class SHTC3:
ADDR = 0x70
WAKE = b'\x35\x17'
SLEEP = b'\xB0\x98'
MEASURE = b'\x78\x66' # High precision, T first, clock stretching disabled
def __init__(self, i2c):
self.i2c = i2c
def _crc8(self, data):
crc = 0xFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x80:
crc = (crc << 1) ^ 0x31
else:
crc <<= 1
crc &= 0xFF
return crc
def read_sensor(self):
try:
# 1. Wakeup
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, self.WAKE)
finally:
self.i2c.unlock()
time.sleep(0.001)
# 2. Trigger Measurement
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, self.MEASURE)
finally:
self.i2c.unlock()
time.sleep(0.015)
# 3. Read 6 bytes of data
# bytes 0, 1: Temp, byte 2: Temp CRC
# bytes 3, 4: Hum, byte 5: Hum CRC
buf = bytearray(6)
while not self.i2c.try_lock():
pass
try:
self.i2c.readfrom_into(self.ADDR, buf)
finally:
self.i2c.unlock()
# 4. Enter sleep mode
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, self.SLEEP)
finally:
self.i2c.unlock()
# Verify CRC
t_data = buf[0:2]
t_crc = buf[2]
h_data = buf[3:5]
h_crc = buf[5]
if self._crc8(t_data) != t_crc:
print("SHTC3 Temp CRC error")
return None, None
if self._crc8(h_data) != h_crc:
print("SHTC3 Hum CRC error")
return None, None
raw_t = (buf[0] << 8) | buf[1]
raw_h = (buf[3] << 8) | buf[4]
temp = -45.0 + 175.0 * (raw_t / 65536.0)
hum = 100.0 * (raw_h / 65536.0)
return round(temp, 2), round(hum, 2)
except Exception as e:
print(f"Error reading SHTC3 sensor: {e}")
try:
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, self.SLEEP)
finally:
self.i2c.unlock()
except:
pass
return None, None
+572
View File
@@ -0,0 +1,572 @@
"""CircuitPython Video Stream Server utility.
Listens for incoming TCP/UDP video frames and draws them centered and cropped on the display.
"""
import time
class VideoStreamServer:
def __init__(self, display, pool, tcp_port=8081, udp_port=8082, color_port=8083, color_udp_port=8084):
self.display = display
self.pool = pool
self.tcp_port = tcp_port
self.udp_port = udp_port
self.color_port = color_port
self.color_udp_port = color_udp_port
# Sockets
self.tcp_server = None
self.tcp_client = None
self.udp_sock = None
self.color_server = None
self.color_client = None
self.color_udp_sock = None
# State
self.active = False
self.last_packet_time = 0
self.timeout_s = 3.0
# Frame buffering
self.buffer = bytearray(15000)
self.view = memoryview(self.buffer)
self.tcp_bytes_received = 0
# UDP Reassembly
self.udp_temp_buffer = bytearray(1002)
self.current_frame_id = -1
self.chunks_received = 0
self.color_chunks_mask = 0
# Color buffering (320x240 RGB565 is 153,600 bytes)
self.color_buffer = bytearray(153600)
self.color_view = memoryview(self.color_buffer)
self.color_bytes_received = 0
self.color_header = bytearray(16)
self.color_header_received = 0
self.color_payload_len = 0
self.color_x = 0
self.color_y = 0
self.color_w = 0
self.color_h = 0
# 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.monotonic()
self.fps_frame_count = 0
def start(self):
"""Initializes TCP and UDP sockets."""
# 1. Start TCP Server (Mono)
try:
self.tcp_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
try:
self.tcp_server.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
except:
pass
self.tcp_server.bind(("", self.tcp_port))
self.tcp_server.listen(1)
self.tcp_server.setblocking(False)
print(f"Video TCP Stream server listening on port {self.tcp_port}...")
except Exception as e:
print(f"Failed to start TCP stream server: {e}")
# 2. Start UDP Server (Mono)
try:
self.udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
try:
self.udp_sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
except:
pass
self.udp_sock.bind(("", self.udp_port))
self.udp_sock.setblocking(False)
print(f"Video UDP Stream responder listening on port {self.udp_port}...")
except Exception as e:
print(f"Failed to start UDP stream server: {e}")
# 3. Start Color TCP Server
try:
self.color_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
try:
self.color_server.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
except:
pass
self.color_server.bind(("", self.color_port))
self.color_server.listen(1)
self.color_server.setblocking(False)
print(f"Video Color TCP Stream server listening on port {self.color_port}...")
except Exception as e:
print(f"Failed to start Color TCP server: {e}")
# 4. Start Color UDP Server
try:
self.color_udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
try:
self.color_udp_sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
except:
pass
self.color_udp_sock.bind(("", self.color_udp_port))
self.color_udp_sock.setblocking(False)
print(f"Video Color UDP Stream responder listening on port {self.color_udp_port}...")
except Exception as e:
print(f"Failed to start Color UDP stream server: {e}")
def restart_tcp_server(self):
print("Restarting TCP Stream Server...")
self.close_tcp_client()
if self.tcp_server:
try:
self.tcp_server.close()
except:
pass
self.tcp_server = None
time.sleep(0.1)
try:
self.tcp_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
self.tcp_server.bind(("", self.tcp_port))
self.tcp_server.listen(1)
self.tcp_server.setblocking(False)
except Exception as e:
print(f"Restart TCP Server failed: {e}")
def restart_udp_sock(self):
print("Restarting UDP Stream Socket...")
if self.udp_sock:
try:
self.udp_sock.close()
except:
pass
self.udp_sock = None
time.sleep(0.1)
try:
self.udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
self.udp_sock.bind(("", self.udp_port))
self.udp_sock.setblocking(False)
except Exception as e:
print(f"Restart UDP Socket failed: {e}")
def restart_color_server(self):
print("Restarting Color TCP Stream Server...")
self.close_color_client()
if self.color_server:
try:
self.color_server.close()
except:
pass
self.color_server = None
time.sleep(0.1)
try:
self.color_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
self.color_server.bind(("", self.color_port))
self.color_server.listen(1)
self.color_server.setblocking(False)
except Exception as e:
print(f"Restart Color TCP Server failed: {e}")
def restart_color_udp_sock(self):
print("Restarting Color UDP Stream Socket...")
if self.color_udp_sock:
try:
self.color_udp_sock.close()
except:
pass
self.color_udp_sock = None
time.sleep(0.1)
try:
self.color_udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
self.color_udp_sock.bind(("", self.color_udp_port))
self.color_udp_sock.setblocking(False)
except Exception as e:
print(f"Restart Color UDP Socket failed: {e}")
def update(self):
"""Non-blocking socket check for streaming updates."""
now = time.monotonic()
# Check Stream Active Timeout
if self.active and (now - self.last_packet_time) > self.timeout_s:
print("Video stream timed out. Returning to dashboard.")
self.active = False
self.close_tcp_client()
self.close_color_client()
# Calculate FPS periodically
fps_elapsed = now - self.fps_start_time
if fps_elapsed >= 2.0:
self.last_fps = self.fps_frame_count / fps_elapsed
self.fps_frame_count = 0
self.fps_start_time = now
# 1. Handle UDP reassembly (Mono)
if self.udp_sock:
while True:
try:
# recv_into returns number of bytes read
n = self.udp_sock.recv_into(self.udp_temp_buffer)
if n == 0:
break
self.udp_packets_received += 1
frame_id = self.udp_temp_buffer[0]
chunk_idx = self.udp_temp_buffer[1]
if chunk_idx < 15:
self.active = True
self.last_packet_time = now
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 payload to self.buffer
start_offset = chunk_idx * 1000
self.buffer[start_offset : start_offset + 1000] = self.udp_temp_buffer[2:1002]
self.chunks_received |= (1 << chunk_idx)
if self.chunks_received == 0x7FFF:
self.udp_frames_complete += 1
self.active = True
self.last_packet_time = now
self._draw_frame()
self.chunks_received = 0
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
break
print(f"UDP Socket error: {e}")
self.restart_udp_sock()
break
# 1B. Handle UDP reassembly (Color)
if self.color_udp_sock:
while True:
try:
n = self.color_udp_sock.recv_into(self.udp_temp_buffer)
if n == 0:
break
self.udp_packets_received += 1
frame_id = self.udp_temp_buffer[0]
chunk_idx = self.udp_temp_buffer[1]
if chunk_idx < 154:
self.active = True
self.last_packet_time = now
if frame_id != self.current_frame_id:
self.current_frame_id = frame_id
self.color_chunks_mask = 0
# Copy payload to self.color_buffer
start_offset = chunk_idx * 1000
if start_offset + 1000 <= 153600:
self.color_buffer[start_offset : start_offset + 1000] = self.udp_temp_buffer[2:1002]
self.color_chunks_mask |= (1 << chunk_idx)
if self.color_chunks_mask == 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:
self.udp_frames_complete += 1
self.active = True
self.last_packet_time = now
self.color_x = 0
self.color_y = 0
self.color_w = 320
self.color_h = 240
self.color_payload_len = 153600
self._draw_color_frame()
self.color_chunks_mask = 0
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
break
print(f"Color UDP Socket error: {e}")
self.restart_color_udp_sock()
break
# 2. Handle TCP stream (Mono)
if self.tcp_server:
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 as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
print(f"TCP Accept error: {e}")
self.restart_tcp_server()
if self.tcp_client is not None:
retries = 0
while self.tcp_bytes_received < 15000:
remaining = 15000 - self.tcp_bytes_received
slice_view = self.view[self.tcp_bytes_received : self.tcp_bytes_received + remaining]
try:
n = self.tcp_client.recv_into(slice_view)
if n > 0:
self.tcp_bytes_received += n
self.last_packet_time = now
self.active = True
retries = 0
elif n == 0:
print("TCP Stream client disconnected.")
self.close_tcp_client()
break
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
retries += 1
if retries > 15:
break
time.sleep(0.001)
else:
print(f"TCP Stream recv error: {e}")
self.close_tcp_client()
break
if self.tcp_bytes_received == 15000:
self.tcp_bytes_received = 0
self._draw_frame()
# 3. Handle Color TCP stream
if self.color_server:
if self.color_client is None:
try:
self.color_client, addr = self.color_server.accept()
self.color_client.setblocking(False)
self.color_bytes_received = 0
self.color_header_received = 0
self.color_payload_len = 0
self.active = True
self.last_packet_time = now
print(f"Color TCP Stream client connected from: {addr}")
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
print(f"Color TCP Accept error: {e}")
self.restart_color_server()
if self.color_client is not None:
try:
# Read header (16 bytes)
if self.color_header_received < 16:
start_h = time.monotonic()
while self.color_header_received < 16:
if (time.monotonic() - start_h) > 0.100:
break
remaining_h = 16 - self.color_header_received
slice_h = memoryview(self.color_header)[self.color_header_received : self.color_header_received + remaining_h]
n = self.color_client.recv_into(slice_h)
if n > 0:
self.color_header_received += n
elif n == 0:
self.close_color_client()
return
if self.color_header_received == 16:
# Detect version byte at index 4
version = self.color_header[4]
if version == 1:
# stream_color.py format: sig (4B), version (1B), format (1B), width (2B), height (2B), payload_len (4B), reserved (2B)
self.color_x = 0
self.color_y = 0
self.color_w = (self.color_header[6] << 8) | self.color_header[7]
self.color_h = (self.color_header[8] << 8) | self.color_header[9]
self.color_payload_len = (self.color_header[10] << 24) | (self.color_header[11] << 16) | (self.color_header[12] << 8) | self.color_header[13]
else:
# iPhone app format: sig (4B), x (2B), y (2B), width (2B), height (2B), payload_len (4B)
self.color_x = (self.color_header[4] << 8) | self.color_header[5]
self.color_y = (self.color_header[6] << 8) | self.color_header[7]
self.color_w = (self.color_header[8] << 8) | self.color_header[9]
self.color_h = (self.color_header[10] << 8) | self.color_header[11]
self.color_payload_len = (self.color_header[12] << 24) | (self.color_header[13] << 16) | (self.color_header[14] << 8) | self.color_header[15]
# Safety check:
if self.color_payload_len > len(self.color_buffer):
print(f"Warning: Color payload length {self.color_payload_len} exceeds preallocated buffer {len(self.color_buffer)}. Closing connection.")
self.close_color_client()
return
self.color_bytes_received = 0
# Read payload
if self.color_header_received == 16 and self.color_payload_len > 0:
retries = 0
while self.color_bytes_received < self.color_payload_len:
remaining_p = self.color_payload_len - self.color_bytes_received
slice_p = self.color_view[self.color_bytes_received : self.color_bytes_received + remaining_p]
try:
n = self.color_client.recv_into(slice_p)
if n > 0:
self.color_bytes_received += n
self.last_packet_time = now
self.active = True
retries = 0
elif n == 0:
self.close_color_client()
break
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
retries += 1
if retries > 25: # max 25ms wait total per frame
break
time.sleep(0.001)
else:
print(f"Color TCP Stream recv error during payload: {e}")
self.close_color_client()
break
if self.color_bytes_received == self.color_payload_len:
self._draw_color_frame()
self.color_header_received = 0
self.color_payload_len = 0
self.color_bytes_received = 0
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
print(f"Color TCP Stream recv error: {e}")
self.close_color_client()
def rlcd_to_mono_cp(self, rlcd_buf, canvas_buf, width, height):
for i in range(len(canvas_buf)):
canvas_buf[i] = 0
dx = (400 - width) // 2
dy = (300 - height) // 2
width_bytes = width // 8
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
y_base = 299 - 4 * block_y
for local_y in range(4):
for local_x in range(2):
bit = 7 - (local_y * 2 + local_x)
if val & (1 << bit):
x = x_base + local_x
y = y_base - local_y
screen_x = x - dx
screen_y = y - dy
if 0 <= screen_x < width and 0 <= screen_y < height:
byte_idx = screen_y * width_bytes + (screen_x >> 3)
bit_idx = 7 - (screen_x & 7)
canvas_buf[byte_idx] |= (1 << bit_idx)
def _draw_frame(self):
draw_start = time.monotonic()
# Check if RLCD display vs standard ILI9341 display
disp_name = self.display.__class__.__name__
if disp_name == "RLCD":
# Direct SPI write commands for RLCD layout
self.display.write_cmd(0x2A)
self.display.write_data([0x12, 0x2A])
self.display.write_cmd(0x2B)
self.display.write_data([0x00, 0xC7])
self.display.write_cmd(0x2C)
self.display.write_data(self.buffer)
else:
# Map 400x300 RLCD buffer into the ILI9341 320x240 canvas buffer
self.rlcd_to_mono_cp(self.buffer, self.display.canvas_buffer, self.display.width, self.display.height)
self.display.show()
self.last_draw_ms = int((time.monotonic() - draw_start) * 1000)
self.frames_drawn += 1
self.fps_frame_count += 1
def _draw_color_frame(self):
draw_start = time.monotonic()
if hasattr(self.display, "draw_rgb565"):
self.display.draw_rgb565(
self.color_x,
self.color_y,
self.color_w,
self.color_h,
self.color_view[:self.color_payload_len],
sync_canvas=False,
)
else:
# Fallback if no raw RGB565 method is exposed (e.g. standard RLCD)
pass
self.last_draw_ms = int((time.monotonic() - draw_start) * 1000)
self.frames_drawn += 1
self.fps_frame_count += 1
def get_stats(self):
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):
if self.tcp_client:
try:
self.tcp_client.close()
except:
pass
self.tcp_client = None
self.tcp_bytes_received = 0
def close_color_client(self):
if self.color_client:
try:
self.color_client.close()
except:
pass
self.color_client = None
self.color_bytes_received = 0
self.color_header_received = 0
self.color_payload_len = 0