Skip to content

Commit 029f68d

Browse files
Add board Fri3d 2026 (unfinished and untested)
1 parent c9a4abb commit 029f68d

File tree

2 files changed

+399
-0
lines changed

2 files changed

+399
-0
lines changed
Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
# Hardware initialization for Fri3d Camp 2024 Badge
2+
from machine import Pin, SPI, SDCard
3+
import st7789
4+
import lcd_bus
5+
import machine
6+
import cst816s
7+
import i2c
8+
import math
9+
10+
import micropython
11+
import gc
12+
13+
import lvgl as lv
14+
import task_handler
15+
16+
import mpos.ui
17+
import mpos.ui.focus_direction
18+
19+
20+
# Pin configuration
21+
SPI_BUS = 2
22+
SPI_FREQ = 40000000
23+
#SPI_FREQ = 20000000 # also works but I guess higher is better
24+
LCD_SCLK = 7
25+
LCD_MOSI = 6
26+
LCD_MISO = 8
27+
LCD_DC = 4
28+
LCD_CS = 5
29+
#LCD_BL = 1 # backlight can't be controlled on this hardware
30+
LCD_RST = 48
31+
32+
TFT_HOR_RES=320
33+
TFT_VER_RES=240
34+
35+
spi_bus = machine.SPI.Bus(
36+
host=SPI_BUS,
37+
mosi=LCD_MOSI,
38+
miso=LCD_MISO,
39+
sck=LCD_SCLK
40+
)
41+
display_bus = lcd_bus.SPIBus(
42+
spi_bus=spi_bus,
43+
freq=SPI_FREQ,
44+
dc=LCD_DC,
45+
cs=LCD_CS
46+
)
47+
48+
# lv.color_format_get_size(lv.COLOR_FORMAT.RGB565) = 2 bytes per pixel * 320 * 240 px = 153600 bytes
49+
# The default was /10 so 15360 bytes.
50+
# /2 = 76800 shows something on display and then hangs the board
51+
# /2 = 38400 works and pretty high framerate but camera gets ESP_FAIL
52+
# /2 = 19200 works, including camera at 9FPS
53+
# 28800 is between the two and still works with camera!
54+
# 30720 is /5 and is already too much
55+
#_BUFFER_SIZE = const(28800)
56+
buffersize = const(28800)
57+
fb1 = display_bus.allocate_framebuffer(buffersize, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA)
58+
fb2 = display_bus.allocate_framebuffer(buffersize, lcd_bus.MEMORY_INTERNAL | lcd_bus.MEMORY_DMA)
59+
60+
STATE_HIGH = 1
61+
STATE_LOW = 0
62+
63+
# see ./lvgl_micropython/api_drivers/py_api_drivers/frozen/display/display_driver_framework.py
64+
mpos.ui.main_display = st7789.ST7789(
65+
data_bus=display_bus,
66+
frame_buffer1=fb1,
67+
frame_buffer2=fb2,
68+
display_width=TFT_VER_RES,
69+
display_height=TFT_HOR_RES,
70+
color_space=lv.COLOR_FORMAT.RGB565,
71+
color_byte_order=st7789.BYTE_ORDER_BGR,
72+
rgb565_byte_swap=True,
73+
reset_pin=LCD_RST, # doesn't seem needed
74+
reset_state=STATE_LOW # doesn't seem needed
75+
)
76+
77+
mpos.ui.main_display.init()
78+
mpos.ui.main_display.set_power(True)
79+
mpos.ui.main_display.set_backlight(100)
80+
mpos.ui.main_display.set_color_inversion(False)
81+
82+
# Touch handling:
83+
# touch pad interrupt TP Int is on ESP.IO13
84+
i2c_bus = i2c.I2C.Bus(host=I2C_BUS, scl=TP_SCL, sda=TP_SDA, freq=I2C_FREQ, use_locks=False)
85+
touch_dev = i2c.I2C.Device(bus=i2c_bus, dev_id=TP_ADDR, reg_bits=TP_REGBITS)
86+
indev=cst816s.CST816S(touch_dev,startup_rotation=lv.DISPLAY_ROTATION._180) # button in top left, good
87+
88+
lv.init()
89+
mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._270) # must be done after initializing display and creating the touch drivers, to ensure proper handling
90+
mpos.ui.main_display.set_params(0x36, bytearray([0x28]))
91+
92+
# Button and joystick handling code:
93+
from machine import ADC, Pin
94+
import time
95+
96+
btn_x = Pin(38, Pin.IN, Pin.PULL_UP) # X
97+
btn_y = Pin(41, Pin.IN, Pin.PULL_UP) # Y
98+
btn_a = Pin(39, Pin.IN, Pin.PULL_UP) # A
99+
btn_b = Pin(40, Pin.IN, Pin.PULL_UP) # B
100+
btn_start = Pin(0, Pin.IN, Pin.PULL_UP) # START
101+
btn_menu = Pin(45, Pin.IN, Pin.PULL_UP) # START
102+
103+
ADC_KEY_MAP = [
104+
{'key': 'UP', 'unit': 1, 'channel': 2, 'min': 3072, 'max': 4096},
105+
{'key': 'DOWN', 'unit': 1, 'channel': 2, 'min': 0, 'max': 1024},
106+
{'key': 'RIGHT', 'unit': 1, 'channel': 0, 'min': 3072, 'max': 4096},
107+
{'key': 'LEFT', 'unit': 1, 'channel': 0, 'min': 0, 'max': 1024},
108+
]
109+
110+
# Initialize ADC for the two channels
111+
adc_up_down = ADC(Pin(3)) # ADC1_CHANNEL_2 (GPIO 33)
112+
adc_up_down.atten(ADC.ATTN_11DB) # 0-3.3V range
113+
adc_left_right = ADC(Pin(1)) # ADC1_CHANNEL_0 (GPIO 36)
114+
adc_left_right.atten(ADC.ATTN_11DB) # 0-3.3V range
115+
116+
def read_joystick():
117+
# Read ADC values
118+
val_up_down = adc_up_down.read()
119+
val_left_right = adc_left_right.read()
120+
121+
# Check each key's range
122+
for mapping in ADC_KEY_MAP:
123+
adc_val = val_up_down if mapping['channel'] == 2 else val_left_right
124+
if mapping['min'] <= adc_val <= mapping['max']:
125+
return mapping['key']
126+
return None # No key triggered
127+
128+
# Rotate: UP = 0°, RIGHT = 90°, DOWN = 180°, LEFT = 270°
129+
def read_joystick_angle(threshold=0.1):
130+
# Read ADC values
131+
val_up_down = adc_up_down.read()
132+
val_left_right = adc_left_right.read()
133+
134+
#if time.time() < 60:
135+
# print(f"val_up_down: {val_up_down}")
136+
# print(f"val_left_right: {val_left_right}")
137+
138+
# Normalize to [-1, 1]
139+
x = (val_left_right - 2048) / 2048 # Positive x = RIGHT
140+
y = (val_up_down - 2048) / 2048 # Positive y = UP
141+
#if time.time() < 60:
142+
# print(f"x,y = {x},{y}")
143+
144+
# Check if joystick is near center
145+
magnitude = math.sqrt(x*x + y*y)
146+
#if time.time() < 60:
147+
# print(f"magnitude: {magnitude}")
148+
if magnitude < threshold:
149+
return None # Neutral position
150+
151+
# Calculate angle in degrees with UP = 0°, clockwise
152+
angle_rad = math.atan2(x, y)
153+
angle_deg = math.degrees(angle_rad)
154+
angle_deg = (angle_deg + 360) % 360 # Normalize to [0, 360)
155+
return angle_deg
156+
157+
# Key repeat configuration
158+
# This whole debounce logic is only necessary because LVGL 9.2.2 seems to have an issue where
159+
# the lv_keyboard widget doesn't handle PRESSING (long presses) properly, it loses focus.
160+
REPEAT_INITIAL_DELAY_MS = 300 # Delay before first repeat
161+
REPEAT_RATE_MS = 100 # Interval between repeats
162+
last_key = None
163+
last_state = lv.INDEV_STATE.RELEASED
164+
key_press_start = 0 # Time when key was first pressed
165+
last_repeat_time = 0 # Time of last repeat event
166+
167+
# Read callback
168+
# Warning: This gets called several times per second, and if it outputs continuous debugging on the serial line,
169+
# that will break tools like mpremote from working properly to upload new files over the serial line, thus needing a reflash.
170+
def keypad_read_cb(indev, data):
171+
global last_key, last_state, key_press_start, last_repeat_time
172+
data.continue_reading = False
173+
since_last_repeat = 0
174+
175+
# Check buttons and joystick
176+
current_key = None
177+
current_time = time.ticks_ms()
178+
179+
# Check buttons
180+
if btn_x.value() == 0:
181+
current_key = lv.KEY.ESC
182+
elif btn_y.value() == 0:
183+
current_key = ord("Y")
184+
elif btn_a.value() == 0:
185+
current_key = lv.KEY.ENTER
186+
elif btn_b.value() == 0:
187+
current_key = ord("B")
188+
elif btn_menu.value() == 0:
189+
current_key = lv.KEY.HOME
190+
elif btn_start.value() == 0:
191+
current_key = lv.KEY.END
192+
else:
193+
# Check joystick
194+
angle = read_joystick_angle(0.30) # 0.25-0.27 is right on the edge so 0.30 should be good
195+
if angle:
196+
if angle > 45 and angle < 135:
197+
current_key = lv.KEY.RIGHT
198+
elif angle > 135 and angle < 225:
199+
current_key = lv.KEY.DOWN
200+
elif angle > 225 and angle < 315:
201+
current_key = lv.KEY.LEFT
202+
elif angle < 45 or angle > 315:
203+
current_key = lv.KEY.UP
204+
else:
205+
print(f"WARNING: unhandled joystick angle {angle}") # maybe we could also handle diagonals?
206+
207+
# Key repeat logic
208+
if current_key:
209+
if current_key != last_key:
210+
# New key press
211+
data.key = current_key
212+
data.state = lv.INDEV_STATE.PRESSED
213+
last_key = current_key
214+
last_state = lv.INDEV_STATE.PRESSED
215+
key_press_start = current_time
216+
last_repeat_time = current_time
217+
else: # same key
218+
# Key held: Check for repeat
219+
elapsed = time.ticks_diff(current_time, key_press_start)
220+
since_last_repeat = time.ticks_diff(current_time, last_repeat_time)
221+
if elapsed >= REPEAT_INITIAL_DELAY_MS and since_last_repeat >= REPEAT_RATE_MS:
222+
# Send a new PRESSED/RELEASED pair for repeat
223+
data.key = current_key
224+
data.state = lv.INDEV_STATE.PRESSED if last_state == lv.INDEV_STATE.RELEASED else lv.INDEV_STATE.RELEASED
225+
last_state = data.state
226+
last_repeat_time = current_time
227+
else:
228+
# No repeat yet, send RELEASED to avoid PRESSING
229+
data.state = lv.INDEV_STATE.RELEASED
230+
last_state = lv.INDEV_STATE.RELEASED
231+
else:
232+
# No key pressed
233+
data.key = last_key if last_key else lv.KEY.ENTER
234+
data.state = lv.INDEV_STATE.RELEASED
235+
last_key = None
236+
last_state = lv.INDEV_STATE.RELEASED
237+
key_press_start = 0
238+
last_repeat_time = 0
239+
240+
# Handle ESC for back navigation (only on initial PRESSED)
241+
if last_state == lv.INDEV_STATE.PRESSED:
242+
if current_key == lv.KEY.ESC and since_last_repeat == 0:
243+
mpos.ui.back_screen()
244+
elif current_key == lv.KEY.RIGHT:
245+
mpos.ui.focus_direction.move_focus_direction(90)
246+
elif current_key == lv.KEY.LEFT:
247+
mpos.ui.focus_direction.move_focus_direction(270)
248+
elif current_key == lv.KEY.UP:
249+
mpos.ui.focus_direction.move_focus_direction(0)
250+
elif current_key == lv.KEY.DOWN:
251+
mpos.ui.focus_direction.move_focus_direction(180)
252+
253+
group = lv.group_create()
254+
group.set_default()
255+
256+
# Create and set up the input device
257+
indev = lv.indev_create()
258+
indev.set_type(lv.INDEV_TYPE.KEYPAD)
259+
indev.set_read_cb(keypad_read_cb)
260+
indev.set_group(group) # is this needed? maybe better to move the default group creation to main.py so it's available everywhere...
261+
disp = lv.display_get_default() # NOQA
262+
indev.set_display(disp) # different from display
263+
indev.enable(True) # NOQA
264+
265+
# Battery voltage ADC measuring
266+
# NOTE: GPIO13 is on ADC2, which requires WiFi to be disabled during reading on ESP32-S3.
267+
# battery_voltage.py handles this automatically: disables WiFi, reads ADC, reconnects WiFi.
268+
import mpos.battery_voltage
269+
"""
270+
best fit on battery power:
271+
2482 is 4.180
272+
2470 is 4.170
273+
2457 is 4.147
274+
# 2444 is 4.12
275+
2433 is 4.109
276+
2429 is 4.102
277+
2393 is 4.044
278+
2369 is 4.000
279+
2343 is 3.957
280+
2319 is 3.916
281+
2269 is 3.831
282+
2227 is 3.769
283+
"""
284+
def adc_to_voltage(adc_value):
285+
"""
286+
Convert raw ADC value to battery voltage using calibrated linear function.
287+
Calibration data shows linear relationship: voltage = -0.0016237 * adc + 8.2035
288+
This is ~10x more accurate than simple scaling (error ~0.01V vs ~0.1V).
289+
"""
290+
return (0.001651* adc_value + 0.08709)
291+
292+
mpos.battery_voltage.init_adc(13, adc_to_voltage)
293+
294+
import mpos.sdcard
295+
mpos.sdcard.init(spi_bus, cs_pin=14)
296+
297+
# === AUDIO HARDWARE ===
298+
from machine import PWM, Pin
299+
from mpos import AudioFlinger
300+
301+
# Initialize buzzer (GPIO 46)
302+
buzzer = PWM(Pin(46), freq=550, duty=0)
303+
304+
# I2S pin configuration for audio output (DAC) and input (microphone)
305+
# Note: I2S is created per-stream, not at boot (only one instance can exist)
306+
# The DAC uses BCK (bit clock) on GPIO 2, while the microphone uses SCLK on GPIO 17
307+
# See schematics: DAC has BCK=2, WS=47, SD=16; Microphone has SCLK=17, WS=47, DIN=15
308+
i2s_pins = {
309+
# Output (DAC/speaker) pins
310+
'sck': 2, # BCK - Bit Clock for DAC output
311+
'ws': 47, # Word Select / LRCLK (shared between DAC and mic)
312+
'sd': 16, # Serial Data OUT (speaker/DAC)
313+
# Input (microphone) pins
314+
'sck_in': 17, # SCLK - Serial Clock for microphone input
315+
'sd_in': 15, # DIN - Serial Data IN (microphone)
316+
}
317+
318+
# Initialize AudioFlinger with I2S and buzzer
319+
AudioFlinger(i2s_pins=i2s_pins, buzzer_instance=buzzer)
320+
321+
# === LED HARDWARE ===
322+
import mpos.lights as LightsManager
323+
324+
# Initialize 5 NeoPixel LEDs (GPIO 12)
325+
LightsManager.init(neopixel_pin=12, num_leds=5)
326+
327+
# === SENSOR HARDWARE ===
328+
import mpos.sensor_manager as SensorManager
329+
330+
# Create I2C bus for IMU (different pins from display)
331+
from machine import I2C
332+
imu_i2c = I2C(0, sda=Pin(9), scl=Pin(18))
333+
SensorManager.init(imu_i2c, address=0x6B, mounted_position=SensorManager.FACING_EARTH)
334+
335+
print("Fri3d hardware: Audio, LEDs, and sensors initialized")
336+
337+
# === STARTUP "WOW" EFFECT ===
338+
import time
339+
import _thread
340+
341+
def startup_wow_effect():
342+
"""
343+
Epic startup effect with rainbow LED chase and upbeat startup jingle.
344+
Runs in background thread to avoid blocking boot.
345+
"""
346+
try:
347+
# Startup jingle: Happy upbeat sequence (ascending scale with flourish)
348+
startup_jingle = "Startup:d=8,o=6,b=200:c,d,e,g,4c7,4e,4c7"
349+
#startup_jingle = "ShortBeeps:d=32,o=5,b=320:c6,c7"
350+
351+
# Start the jingle
352+
AudioFlinger.play_rtttl(
353+
startup_jingle,
354+
stream_type=AudioFlinger.STREAM_NOTIFICATION,
355+
volume=60
356+
)
357+
358+
# Rainbow colors for the 5 LEDs
359+
rainbow = [
360+
(255, 0, 0), # Red
361+
(255, 128, 0), # Orange
362+
(255, 255, 0), # Yellow
363+
(0, 255, 0), # Green
364+
(0, 0, 255), # Blue
365+
]
366+
367+
# Rainbow sweep effect (3 passes, getting faster)
368+
for pass_num in range(3):
369+
for i in range(5):
370+
# Light up LEDs progressively
371+
for j in range(i + 1):
372+
LightsManager.set_led(j, *rainbow[j])
373+
LightsManager.write()
374+
time.sleep_ms(80 - pass_num * 20) # Speed up each pass
375+
376+
# Flash all LEDs bright white
377+
LightsManager.set_all(255, 255, 255)
378+
LightsManager.write()
379+
time.sleep_ms(150)
380+
381+
# Rainbow finale
382+
for i in range(5):
383+
LightsManager.set_led(i, *rainbow[i])
384+
LightsManager.write()
385+
time.sleep_ms(300)
386+
387+
# Fade out
388+
LightsManager.clear()
389+
LightsManager.write()
390+
391+
except Exception as e:
392+
print(f"Startup effect error: {e}")
393+
394+
_thread.stack_size(mpos.apps.good_stack_size()) # default stack size won't work, crashes!
395+
_thread.start_new_thread(startup_wow_effect, ())
396+
397+
print("fri3d_2024.py finished")

0 commit comments

Comments
 (0)