|
| 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