diff --git a/README.md b/README.md index 758ec27..245673b 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Other benefits: | **RAK3401 1W** | SX1262 + SKY66122 (30 dBm) | GPS (u-blox MAX-7Q, optional), I2C sensors | | **RAK WisMesh Tag** | SX1262 | GPS (AT6558R), accelerometer, RGB LEDs, buzzer | | **ThinkNode M1** | SX1262 | GPS, e-paper display (SSD1681), QSPI flash, buzzer, RGB LEDs | +| **LilyGo T-Echo** | SX1262 (TCXO 1.8V) | GPS (L76K), 1.54" e-paper (SSD1681), BME280, QSPI flash, touch-button backlight | | **Ikoka Nano 30dBm** | SX1262 (E22-900M30S, 30dBm PA) | RGB LEDs | ### ESP32 diff --git a/build.sh b/build.sh index 6fbe551..eb12c72 100644 --- a/build.sh +++ b/build.sh @@ -17,6 +17,7 @@ nRF_boards=( ikoka_nano_30dbm sensecap_solar xiao_nrf52840 + lilygo_techo ) ESP32_boards=( diff --git a/zephcore/CMakeLists.txt b/zephcore/CMakeLists.txt index e3deecd..9736677 100644 --- a/zephcore/CMakeLists.txt +++ b/zephcore/CMakeLists.txt @@ -222,7 +222,7 @@ set(ZEPHCORE_COMMON_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/zephcore_com # Detect platform from board name and add platform-specific common config # For boards with qualifiers like "wio_tracker_l1/nrf52840", check both the full BOARD # string and the BOARD_QUALIFIERS which contains just the qualifier (e.g., "nrf52840") -if(BOARD MATCHES ".*nrf52.*" OR BOARD MATCHES "rak4631" OR BOARD MATCHES "rak_wismesh_tag" OR BOARD MATCHES "rak3401_1watt" OR BOARD MATCHES "wio_tracker" OR BOARD MATCHES "ikoka_nano" OR BOARD MATCHES "t1000_e" OR BOARD MATCHES "thinknode_m1" OR BOARD MATCHES "thinknode_m3" OR BOARD MATCHES "thinknode_m6" OR BOARD MATCHES "promicro_lr2021" OR BOARD MATCHES "sensecap_solar" OR BOARD MATCHES "xiao_nrf52840") +if(BOARD MATCHES ".*nrf52.*" OR BOARD MATCHES "rak4631" OR BOARD MATCHES "rak_wismesh_tag" OR BOARD MATCHES "rak3401_1watt" OR BOARD MATCHES "wio_tracker" OR BOARD MATCHES "ikoka_nano" OR BOARD MATCHES "t1000_e" OR BOARD MATCHES "thinknode_m1" OR BOARD MATCHES "thinknode_m3" OR BOARD MATCHES "thinknode_m6" OR BOARD MATCHES "promicro_lr2021" OR BOARD MATCHES "sensecap_solar" OR BOARD MATCHES "xiao_nrf52840" OR BOARD MATCHES "lilygo_techo") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/nrf52_common.conf") elseif(DEFINED BOARD_QUALIFIERS AND BOARD_QUALIFIERS MATCHES ".*nrf52.*") set(ZEPHCORE_PLATFORM_CONF "${CMAKE_CURRENT_SOURCE_DIR}/boards/common/nrf52_common.conf") diff --git a/zephcore/Kconfig b/zephcore/Kconfig index bd85e2a..481695a 100644 --- a/zephcore/Kconfig +++ b/zephcore/Kconfig @@ -541,6 +541,44 @@ config ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS Time in milliseconds before the display turns off after the last user interaction. Set to 0 to disable. +config ZEPHCORE_UI_CONFIRM_WINDOW_MS + int "Double-press confirmation window (ms)" + default 500 + depends on ZEPHCORE_UI_DISPLAY + help + Maximum time between the two KEY_ENTER presses that confirm + destructive actions (shutdown, DFU, offgrid toggle). The default + 500 ms suits boards where ENTER is a direct short tap. Boards + with only one button that emit ENTER via a zephyr,input-longpress + filter (T-Echo, RAK4631 Pocket) need a longer window — two + 1 s holds plus the release gap do not fit in 500 ms. Set to + 3000 on such boards. + +config ZEPHCORE_DISPLAY_INSET + int "Display inset margin (pixels)" + default 0 + depends on ZEPHCORE_UI_DISPLAY + help + Shrink the usable drawing area by this many pixels on each side. + mc_display_width/height() report the reduced size and all draw + primitives are offset by this amount. Useful on panels (e.g. + LilyGo T-Echo SSD1681) where the outermost rows/columns show + hardware artefacts — a small inset hides them behind a clean + background margin. Set to 0 on boards without edge artefacts. + +config ZEPHCORE_DISPLAY_LARGE_FONT + bool "Use a larger built-in CFB font" + default n + depends on ZEPHCORE_UI_DISPLAY && CHARACTER_FRAMEBUFFER_USE_DEFAULT_FONTS + help + Select the smallest Zephyr built-in CFB font whose height is + >= 16 pixels (typically the 10x16 font). Intended for larger + e-paper panels (T-Echo, ThinkNode M1) where the default 6x8 + glyph is too small to read comfortably. The ui_pages.c layout + auto-centers its content rows using the active font height, so + no per-page changes are needed. Requires Zephyr's default CFB + fonts to be compiled in (CONFIG_CHARACTER_FRAMEBUFFER_USE_DEFAULT_FONTS). + config ZEPHCORE_EASTER_EGG_DOOM bool "Enable Doom raycaster easter egg" default n diff --git a/zephcore/boards/example_board/README.md b/zephcore/boards/example_board/README.md index 50ec8a9..000872a 100644 --- a/zephcore/boards/example_board/README.md +++ b/zephcore/boards/example_board/README.md @@ -20,6 +20,7 @@ Supported Boards | ThinkNode M6 | `west build -b thinknode_m6 zephcore` | UF2 drag-drop or `west flash` | | RAK WisMesh Tag | `west build -b rak_wismesh_tag zephcore` | UF2 drag-drop or `west flash` | | Ikoka Nano 30dBm | `west build -b ikoka_nano_30dbm zephcore` | UF2 drag-drop | +| LilyGo T-Echo | `west build -b lilygo_techo zephcore` | UF2 drag-drop or `west flash` | UF2 flash: Double-tap reset button, drag `build/zephyr/zephyr.uf2` to the USB drive. SWD flash: `west flash` (requires J-Link, pyocd, or nrfjprog connected). diff --git a/zephcore/boards/nrf52840/lilygo_techo/Kconfig.lilygo_techo b/zephcore/boards/nrf52840/lilygo_techo/Kconfig.lilygo_techo new file mode 100644 index 0000000..7a2ce7e --- /dev/null +++ b/zephcore/boards/nrf52840/lilygo_techo/Kconfig.lilygo_techo @@ -0,0 +1,14 @@ +# LilyGo T-Echo board configuration +# Copyright (c) 2026 ZephCore +# SPDX-License-Identifier: Apache-2.0 + +config BOARD_LILYGO_TECHO + select SOC_NRF52840_QIAA + +# Board power enable (P0.12) + LoRa power enable (P1.05) must go HIGH +# before the SX1262 and QSPI flash respond. QSPI NOR init priority is 41 — +# the fixed regulators that drive these rails have to run first. +if REGULATOR_FIXED +config REGULATOR_FIXED_INIT_PRIORITY + default 30 +endif diff --git a/zephcore/boards/nrf52840/lilygo_techo/board.cmake b/zephcore/boards/nrf52840/lilygo_techo/board.cmake new file mode 100644 index 0000000..f7ae420 --- /dev/null +++ b/zephcore/boards/nrf52840/lilygo_techo/board.cmake @@ -0,0 +1,13 @@ +# Copyright (c) 2026 ZephCore +# SPDX-License-Identifier: Apache-2.0 + +board_runner_args(jlink "--device=nRF52840_xxAA" "--speed=4000") +board_runner_args(pyocd "--target=nrf52840" "--frequency=4000000") + +set(OPENOCD_NRF5_SUBFAMILY "nrf52") + +include(${ZEPHYR_BASE}/boards/common/jlink.board.cmake) +include(${ZEPHYR_BASE}/boards/common/pyocd.board.cmake) +include(${ZEPHYR_BASE}/boards/common/openocd-nrf5.board.cmake) +include(${ZEPHYR_BASE}/boards/common/nrfjprog.board.cmake) +include(${ZEPHYR_BASE}/boards/common/nrfutil.board.cmake) diff --git a/zephcore/boards/nrf52840/lilygo_techo/board.conf b/zephcore/boards/nrf52840/lilygo_techo/board.conf new file mode 100644 index 0000000..682a971 --- /dev/null +++ b/zephcore/boards/nrf52840/lilygo_techo/board.conf @@ -0,0 +1,41 @@ +# LilyGo T-Echo (nRF52840 + SX1262 + SSD1681 EPD + L76K GPS) +# Board-specific configuration +# +# Hardware: +# - nRF52840 SoC, SX1262 LoRa (22 dBm, TCXO 1.8V) +# - 1.54" SSD1681 e-paper (200x200 mono) with front-light on P1.11 +# - L76K GPS module on UART0 @ 9600 baud (on-off via P1.02) +# - MX25R1635F 2MB QSPI flash +# - BME280 at I2C 0x77 (alt address) +# - Battery ADC on AIN2 (P0.04), 150K+150K voltage divider +# - Single user button (P1.10, active-low) — no buzzer, e-ink device +# - 3-color LEDs (active-low): RED=P0.13, BLUE=P0.14, GREEN=P0.15 (TX-LED) + +# Board identification (matches Arduino MeshCore variant name) +CONFIG_ZEPHCORE_BOARD_NAME="LilyGo T-Echo" + +# Device Information Service model name +CONFIG_BT_DIS_MODEL_NUMBER_STR="LilyGo T-Echo" + +# SoftDevice firmware ID — T-Echo Adafruit bootloader ships with s140 v6.1.1 +# (platformio.ini uses nrf52840_s140_v6.ld → app at 0x26000). +CONFIG_ZEPHCORE_SD_FWID=0x00B6 + +# ========== RAM budget ========== +# SSD1681 200x200 CFB framebuffer needs 5000 bytes from k_malloc. +# Default heap (2048) is sized for SSD1306 128x64 (1024 bytes). +CONFIG_HEAP_MEM_POOL_SIZE=6144 + +# Shrink RTT buffer to free RAM (4096 → 1024, saves 3KB). +CONFIG_SEGGER_RTT_BUFFER_SIZE_UP=1024 + +# ========== Display cosmetics ========== +# 200x200 SSD1681 — the default 6x8 font is too small to read comfortably. +# Switch to the smallest Zephyr built-in CFB font >= 16 px (typically 10x16). +# (Earlier edge-row artefacts were fixed via the partial-update border +# waveform in the DTS, so no inset is needed — full 200x200 is usable.) +CONFIG_ZEPHCORE_DISPLAY_LARGE_FONT=y + +# Confirmation window — single-button board uses 1 s longpress for ENTER, +# so two ENTERs span >2 s. Widen the double-press window accordingly. +CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 diff --git a/zephcore/boards/nrf52840/lilygo_techo/board.overlay b/zephcore/boards/nrf52840/lilygo_techo/board.overlay new file mode 100644 index 0000000..8a37472 --- /dev/null +++ b/zephcore/boards/nrf52840/lilygo_techo/board.overlay @@ -0,0 +1,7 @@ +/* + * LilyGo T-Echo — DTS is self-contained. + * This overlay is intentionally empty; all nodes live in lilygo_techo.dts. + * Any user-level tweaks (e.g. swapping EPD rotation) can be added here. + * + * SPDX-License-Identifier: Apache-2.0 + */ diff --git a/zephcore/boards/nrf52840/lilygo_techo/board.yml b/zephcore/boards/nrf52840/lilygo_techo/board.yml new file mode 100644 index 0000000..12ecea1 --- /dev/null +++ b/zephcore/boards/nrf52840/lilygo_techo/board.yml @@ -0,0 +1,9 @@ +# Copyright (c) 2026 ZephCore +# SPDX-License-Identifier: Apache-2.0 + +board: + name: lilygo_techo + full_name: LilyGo T-Echo (nRF52840 + SX1262 + SSD1681 EPD + L76K GPS) + vendor: lilygo + socs: + - name: nrf52840 diff --git a/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo-pinctrl.dtsi b/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo-pinctrl.dtsi new file mode 100644 index 0000000..5108a92 --- /dev/null +++ b/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo-pinctrl.dtsi @@ -0,0 +1,119 @@ +/* + * LilyGo T-Echo pin control definitions + * Copyright (c) 2026 ZephCore + * + * SPDX-License-Identifier: Apache-2.0 + * + * Pin mappings (from Arduino MeshCore variant.h): + * + * SPI2 (LoRa SX1262): SCK=P0.19, MISO=P0.23, MOSI=P0.22, CS=P0.24 + * SPI3 (EPD SSD1681): SCK=P0.31, MISO=P1.06, MOSI=P0.29 + * I2C0 (BME280): SDA=P0.26, SCL=P0.27 + * UART0 (L76K GPS 9600):TX=P1.08 (→GPS RX), RX=P1.09 (←GPS TX) + * QSPI (MX25R1635F): SCK=P1.14, CS=P1.15, IO0=P1.12, IO1=P1.13, + * IO2=P0.07, IO3=P0.05 + */ + +&pinctrl { + /* UART0 for L76K GPS at 9600 baud */ + uart0_default: uart0_default { + group1 { + psels = ; /* P1.08 → GPS RX */ + }; + group2 { + psels = ; /* P1.09 ← GPS TX */ + bias-pull-up; + }; + }; + + uart0_sleep: uart0_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; + + /* I2C0 for BME280 sensor */ + i2c0_default: i2c0_default { + group1 { + psels = , /* P0.26 */ + ; /* P0.27 */ + }; + }; + + i2c0_sleep: i2c0_sleep { + group1 { + psels = , + ; + low-power-enable; + }; + }; + + /* SPI2 for LoRa SX1262 */ + spi2_default: spi2_default { + group1 { + psels = , /* P0.19 */ + , /* P0.22 */ + ; /* P0.23 */ + }; + }; + + spi2_sleep: spi2_sleep { + group1 { + psels = , + , + ; + low-power-enable; + }; + }; + + /* SPI3 for E-Paper SSD1681 — MISO is routed on T-Echo (P1.06) + * even though the SSD1681 is write-only; Nordic SPIM needs MISO + * configured to avoid driver config errors. */ + spi3_default: spi3_default { + group1 { + psels = , /* P0.31 */ + , /* P0.29 */ + ; /* P1.06 */ + }; + }; + + spi3_sleep: spi3_sleep { + group1 { + psels = , + , + ; + low-power-enable; + }; + }; + + /* QSPI for MX25R1635F 2MB external flash — identical pin routing + * to ThinkNode M1, T-Echo re-uses Adafruit's reference layout. */ + qspi_default: qspi_default { + group1 { + psels = , + , + , + , + , + ; + }; + }; + + qspi_sleep: qspi_sleep { + group1 { + psels = , + , + , + , + ; + low-power-enable; + }; + group2 { + psels = ; + low-power-enable; + bias-pull-up; + }; + }; +}; diff --git a/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo.dts b/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo.dts new file mode 100644 index 0000000..9039941 --- /dev/null +++ b/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo.dts @@ -0,0 +1,409 @@ +/* + * LilyGo T-Echo — nRF52840 + SX1262 + SSD1681 1.54" EPD + L76K GPS + * Copyright (c) 2026 ZephCore + * + * SPDX-License-Identifier: Apache-2.0 + * + * Hardware: + * - nRF52840 SoC with BLE 5 + * - SX1262 LoRa radio (22 dBm, DIO2 as RF switch, external TCXO 1.8V) + * - SSD1681 1.54" 200x200 mono e-paper display (SPI) with front-light (P1.11) + * - MX25R1635F 2MB QSPI flash + * - L76K GPS on UART0 (9600 baud, on/off via P1.02) + * - BME280 environment sensor on I2C0 @ 0x77 (alt address) + * - Battery ADC on AIN2 (P0.04), 150K+150K voltage divider + * - Single user button (P1.10, active-low, INPUT_PULLUP) + * - 3-color LEDs active-low: RED=P0.13, BLUE=P0.14, GREEN=P0.15 + * + * Power rails (all must be HIGH for peripherals to function): + * - Board power enable P0.12 (main peripheral power) + * - LoRa power enable P1.05 (also gates GPS reset — shared signal) + * - TCXO power enable P0.21 (external 32 MHz TCXO for SX1262) + * + * Secondary "backlight" touch input (P0.11): + * - Capacitive touch pad (TTP223-style) that presents an active-low + * momentary output. Its only job is to wake the e-paper front-light: + * emits INPUT_KEY_BRIGHTNESSUP, which hits ui_task's default branch — + * no page action, but the display-on / auto-off-reset path still runs, + * so the backlight lights up for CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS. + * + * Note: Arduino ENV_INCLUDE_BME280 sits at 0x77; sensors-i2c.dtsi default + * is 0x76 — we override below. + */ + +/dts-v1/; +#include +#include "lilygo_techo-pinctrl.dtsi" +#include +#include +#include + +/ { + model = "LilyGo T-Echo"; + compatible = "lilygo,techo"; + + chosen { + zephyr,sram = &sram0; + zephyr,flash = &flash0; + zephyr,code-partition = &code_partition; + zephyr,console = &cdc_acm_uart; + zephyr,shell-uart = &cdc_acm_uart; + zephyr,display = &epd; + }; + + aliases { + lora0 = &lora; + /* LEDs: green blinks on LoRa TX (matches Arduino P_LORA_TX_LED=LED_GREEN), + * blue acts as activity/heartbeat. Red stays available for errors. */ + led0 = &led_blue; + led1 = &led_green; + lora-tx-led = &led_green; + sw0 = &user_button; + watchdog0 = &wdt0; + gps-enable = &gps_enable_pin; + }; + + /* ---- LEDs (active-LOW per Arduino variant.cpp: LED_STATE_ON=LOW) ---- */ + leds { + compatible = "gpio-leds"; + + led_red: led_0 { + gpios = <&gpio0 13 GPIO_ACTIVE_LOW>; + label = "Red LED"; + }; + + led_blue: led_1 { + gpios = <&gpio0 14 GPIO_ACTIVE_LOW>; + label = "Blue LED"; + }; + + led_green: led_2 { + gpios = <&gpio0 15 GPIO_ACTIVE_LOW>; + label = "Green LED (LoRa TX)"; + }; + }; + + /* ---- Main user button (P1.10) ---- + * + * Button interaction scheme (matches RAK4631's single-button mapping): + * short press (release < 1s) → KEY_A → feeds multi-tap filter: + * 1 tap → KEY_1 = next page + * 2 taps → KEY_LEFT = previous page + * long press (≥1s) → KEY_ENTER = activate / confirm */ + buttons: buttons { + compatible = "gpio-keys"; + + user_button: button_0 { + gpios = <&gpio1 10 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; + zephyr,code = ; + label = "User Button"; + }; + }; + + /* short-press → KEY_A (multi-tap), long-press (≥1s) → KEY_ENTER */ + user_btn_longpress { + compatible = "zephyr,input-longpress"; + input = <&buttons>; + input-codes = ; + short-codes = ; + long-codes = ; + long-delay-ms = <1000>; + }; + + page_btn_multitap { + compatible = "zephcore,input-multi-tap"; + input-codes = ; + tap-codes = ; + tap-delay-ms = <400>; + }; + + /* ---- Backlight touch input (P0.11) ---- + * + * Separate gpio-keys node (not under `buttons`) so this event does + * NOT feed the longpress/multitap filter chain above. Emits + * INPUT_KEY_BRIGHTNESSUP — ui_task.c treats unknown codes as + * display-wake only: a tap lights the front-light for + * CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS, just like any other + * board's "tap to wake display". */ + backlight_btn: backlight-btn { + compatible = "gpio-keys"; + + backlight_button: button_0 { + gpios = <&gpio0 11 (GPIO_PULL_UP | GPIO_ACTIVE_LOW)>; + zephyr,code = ; + label = "Backlight Touch"; + }; + }; + + /* ---- Power control ---- + * + * Three rails must come up in order: + * 1. pwr_enable (P0.12) — board-wide peripheral power + * 2. lora_pwr_enable (P1.05) — SX1262 power (shared with GPS reset; + * HIGH releases GPS from reset too — active-LOW GPS reset) + * 3. tcxo_enable (P0.21) — external TCXO for SX1262 + * + * These are all regulator-fixed with regulator-boot-on so Zephyr's + * regulator framework brings them up before the SX1262 driver init. */ + + pwr_enable: pwr-enable { + compatible = "regulator-fixed"; + regulator-name = "pwr-enable"; + enable-gpios = <&gpio0 12 GPIO_ACTIVE_HIGH>; + regulator-boot-on; + }; + + lora_pwr_enable: lora-pwr-enable { + compatible = "regulator-fixed"; + regulator-name = "lora-pwr-enable"; + enable-gpios = <&gpio1 5 GPIO_ACTIVE_HIGH>; + regulator-boot-on; + startup-delay-us = <10000>; + }; + + tcxo_enable: tcxo-enable { + compatible = "regulator-fixed"; + regulator-name = "tcxo-enable"; + enable-gpios = <&gpio0 21 GPIO_ACTIVE_HIGH>; + regulator-boot-on; + }; + + /* E-paper front-light enable (P1.11). Boot-on so the display is lit + * out of the box; display.c can drive this off when entering auto-off. */ + disp_pwr_enable: disp-pwr-enable { + compatible = "regulator-fixed"; + regulator-name = "disp-pwr-enable"; + enable-gpios = <&gpio1 11 GPIO_ACTIVE_HIGH>; + }; + + /* ---- GPS power control ---- */ + gps_en: gps-enable { + compatible = "gpio-leds"; + gps_enable_pin: gps_enable { + gpios = <&gpio1 2 GPIO_ACTIVE_HIGH>; + label = "GPS Enable"; + }; + }; + + /* ---- Battery ADC ---- + * 150K+150K voltage divider (2:1) on AIN2 (P0.04). Full-scale 7.2V + * with ADC_REF_INTERNAL (0.6V) × gain 1/6 × divider 2.0. */ + zephyr,user { + io-channels = <&adc 2>; + vbat-mv-multiplier = <7200>; + }; + + /* ---- E-Paper display (MIPI DBI SPI) ---- + * SSD1681 1.54" 200x200 monochrome EPD on SPI3. */ + mipi_dbi { + compatible = "zephyr,mipi-dbi-spi"; + spi-dev = <&spi3>; + dc-gpios = <&gpio0 28 GPIO_ACTIVE_HIGH>; + reset-gpios = <&gpio0 2 GPIO_ACTIVE_LOW>; + #address-cells = <1>; + #size-cells = <0>; + + epd: ssd16xxfb@0 { + compatible = "solomon,ssd1681"; + mipi-max-frequency = <4000000>; + reg = <0>; + width = <200>; + height = <200>; + rotation = <180>; + busy-gpios = <&gpio0 3 GPIO_ACTIVE_HIGH>; + + /* Use internal temperature sensor for OTP waveform selection. */ + tssv = <0x80>; + + /* OTP waveforms — no explicit LUT needed for SSD1681. + * border-waveform controls the 1-px border during refresh: + * full 0x05 = border follows source (clean full reset) + * partial 0x80 = HiZ (GxEPD convention; prevents the + * 1-px left/bottom edge residue that 0x3C + * leaves on this SSD1681 revision). */ + full { border-waveform = <0x05>; }; + partial { border-waveform = <0x80>; }; + }; + }; +}; + +/* ---- SoC configuration ---- */ + +®0 { + status = "okay"; +}; + +®1 { + regulator-initial-mode = ; +}; + +&adc { + status = "okay"; + #address-cells = <1>; + #size-cells = <0>; + + /* Battery voltage on P0.04 (AIN2) */ + channel@2 { + reg = <2>; + zephyr,gain = "ADC_GAIN_1_6"; + zephyr,reference = "ADC_REF_INTERNAL"; + zephyr,acquisition-time = ; + zephyr,input-positive = ; + zephyr,resolution = <12>; + }; +}; + +&uicr { + nfct-pins-as-gpios; +}; + +&gpiote { + status = "okay"; +}; + +&gpio0 { + status = "okay"; +}; + +&gpio1 { + status = "okay"; +}; + +/* ---- GPS on UART0 (9600 baud) ---- + * L76K behaves like Air530Z (PCAS/NMEA). Driver manages power via + * on-off-gpios (P1.02 = GPS_EN); no deferred-init needed at 9600 baud. */ +&uart0 { + compatible = "nordic,nrf-uarte"; + status = "okay"; + current-speed = <9600>; + pinctrl-0 = <&uart0_default>; + pinctrl-1 = <&uart0_sleep>; + pinctrl-names = "default", "sleep"; + + gnss: gnss { + compatible = "luatos,air530z"; + on-off-gpios = <&gpio1 2 GPIO_ACTIVE_HIGH>; + }; +}; + +/* ---- I2C0 for environment sensors ---- */ +&i2c0 { + compatible = "nordic,nrf-twim"; + status = "okay"; + clock-frequency = ; + pinctrl-0 = <&i2c0_default>; + pinctrl-1 = <&i2c0_sleep>; + pinctrl-names = "default", "sleep"; + + /* Pull in the full sensor list — runtime auto-detect takes care of + * devices that aren't populated. T-Echo factory sensor is BME280 at + * the alt address 0x77, added separately below. */ + #include "../../common/sensors-i2c.dtsi" +}; + +/* T-Echo BME280 sits at I2C 0x77 (alt address), conflicting with the + * BMP388 node from sensors-i2c.dtsi. Remove BMP388 and replace with + * BME280 at 0x77 (same bosch,bme280 driver). */ +/delete-node/ &bmp388; + +&i2c0 { + bme280_alt: bme280@77 { + compatible = "bosch,bme280"; + reg = <0x77>; + }; +}; + +/* ---- SPI2 for LoRa SX1262 ---- */ +&spi2 { + compatible = "nordic,nrf-spim"; + status = "okay"; + pinctrl-0 = <&spi2_default>; + pinctrl-1 = <&spi2_sleep>; + pinctrl-names = "default", "sleep"; + cs-gpios = <&gpio0 24 GPIO_ACTIVE_LOW>; /* P0.24 LORA_CS */ + + lora: lora@0 { + compatible = "semtech,sx1262"; + reg = <0>; + spi-max-frequency = <8000000>; + + /* LoRa control pins */ + reset-gpios = <&gpio0 25 GPIO_ACTIVE_LOW>; /* P0.25 */ + busy-gpios = <&gpio0 17 GPIO_ACTIVE_HIGH>; /* P0.17 */ + dio1-gpios = <&gpio0 20 (GPIO_PULL_DOWN | GPIO_ACTIVE_HIGH)>; /* P0.20 */ + + /* DIO2 drives RF switch for TX */ + dio2-tx-enable; + + /* External TCXO on DIO3 — 1.8V reference */ + dio3-tcxo-voltage = ; + tcxo-power-startup-delay-ms = <10>; + + /* RX boosted mode for better sensitivity (matches + * Arduino SX126X_RX_BOOSTED_GAIN=1). */ + rx-boosted; + }; +}; + +/* ---- SPI3 for E-Paper display ---- */ +&spi3 { + compatible = "nordic,nrf-spim"; + status = "okay"; + pinctrl-0 = <&spi3_default>; + pinctrl-1 = <&spi3_sleep>; + pinctrl-names = "default", "sleep"; + cs-gpios = <&gpio0 30 GPIO_ACTIVE_LOW>; /* P0.30 DISP_CS */ +}; + +/* ---- QSPI Flash — MX25R1635F 2MB ---- */ +&qspi { + status = "okay"; + pinctrl-0 = <&qspi_default>; + pinctrl-1 = <&qspi_sleep>; + pinctrl-names = "default", "sleep"; + + qspi_flash: qspi_flash@0 { + compatible = "nordic,qspi-nor"; + reg = <0>; + writeoc = "pp"; + readoc = "fastread"; + sck-frequency = <16000000>; + jedec-id = [c2 28 15]; /* MX25R1635F */ + size = <16777216>; /* 2MB = 16Mbit */ + has-dpd; + t-enter-dpd = <10000>; + t-exit-dpd = <35000>; + }; +}; + +/* ---- USB CDC for console/CLI ---- */ +zephyr_udc0: &usbd { + compatible = "nordic,nrf-usbd"; + status = "okay"; + + cdc_acm_uart: cdc_acm_uart { + compatible = "zephyr,cdc-acm-uart"; + }; +}; + +/* ---- Flash partitions (SoftDevice v6, app@0x26000) ---- */ +#include "../../common/nrf52_partitions_sdv6.dtsi" + +/* ---- External QSPI flash partitions ---- */ +&qspi_flash { + partitions { + compatible = "fixed-partitions"; + #address-cells = <1>; + #size-cells = <1>; + + /* Full 2MB for LittleFS storage */ + qspi_storage_partition: partition@0 { + label = "qspi_storage"; + reg = <0x00000000 0x00200000>; + }; + }; +}; + +/* ---- External QSPI /ext automount ---- */ +#include "../../common/qspi-ext.dtsi" +#include "../../common/nrf52_wakeup.dtsi" diff --git a/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo_defconfig b/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo_defconfig new file mode 100644 index 0000000..2294dbe --- /dev/null +++ b/zephcore/boards/nrf52840/lilygo_techo/lilygo_techo_defconfig @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: Apache-2.0 +# LilyGo T-Echo default configuration + +# Enable MPU +CONFIG_ARM_MPU=y +CONFIG_HW_STACK_PROTECTION=y + +# Enable GPIO +CONFIG_GPIO=y + +# Enable console on USB CDC +CONFIG_CONSOLE=y +CONFIG_UART_CONSOLE=y +CONFIG_SERIAL=y +CONFIG_UART_LINE_CTRL=y + +# Enable flash/NVS storage +CONFIG_FLASH=y +CONFIG_FLASH_MAP=y +CONFIG_NVS=y + +# Pinctrl +CONFIG_PINCTRL=y + +# SPI for LoRa + EPD display +CONFIG_SPI=y + +# ADC for battery +CONFIG_ADC=y diff --git a/zephcore/boards/nrf52840/thinknode_m1/board.conf b/zephcore/boards/nrf52840/thinknode_m1/board.conf index d79b63e..12b7484 100644 --- a/zephcore/boards/nrf52840/thinknode_m1/board.conf +++ b/zephcore/boards/nrf52840/thinknode_m1/board.conf @@ -25,3 +25,12 @@ CONFIG_HEAP_MEM_POOL_SIZE=6144 # Shrink RTT buffer to free RAM (4096 → 1024, saves 3KB). # Only used during J-Link debugging, 1KB is Zephyr's default. CONFIG_SEGGER_RTT_BUFFER_SIZE_UP=1024 + +# ========== UI / display ========== +# 200x200 SSD1681 — the default 6x8 font is too small to read comfortably. +# Switch to the smallest Zephyr built-in CFB font >= 16 px (typically 10x16). +CONFIG_ZEPHCORE_DISPLAY_LARGE_FONT=y + +# Single-button board with longpress filter: a "double long-press" is +# physically slower than 500 ms, so widen the confirmation window. +CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS=3000 diff --git a/zephcore/boards/nrf52840/thinknode_m1/thinknode_m1.dts b/zephcore/boards/nrf52840/thinknode_m1/thinknode_m1.dts index 03d3ec7..8e12d43 100644 --- a/zephcore/boards/nrf52840/thinknode_m1/thinknode_m1.dts +++ b/zephcore/boards/nrf52840/thinknode_m1/thinknode_m1.dts @@ -200,9 +200,11 @@ /* OTP waveforms — no explicit LUT needed for SSD1681. * border-waveform controls the 1-px border during refresh: * full 0x05 = border follows source (clean full reset) - * partial 0x3c = Hi-Z (border held, no border flash) */ + * partial 0x80 = HiZ (GxEPD convention; avoids the 1-px + * edge residue that 0x3C leaves on this + * SSD1681 revision). */ full { border-waveform = <0x05>; }; - partial { border-waveform = <0x3c>; }; + partial { border-waveform = <0x80>; }; }; }; }; diff --git a/zephcore/boards/supported_boards.md b/zephcore/boards/supported_boards.md index 30781ab..744e6f2 100644 --- a/zephcore/boards/supported_boards.md +++ b/zephcore/boards/supported_boards.md @@ -16,6 +16,7 @@ rak_wismesh_tag ikoka_nano_30dbm sensecap_solar xiao_nrf52840 +lilygo_techo ``` > **RAK WisMesh Pocket** (WisBlock pocket): use `-b rak4631` — same board string and firmware as **RAK4631**. diff --git a/zephcore/helpers/ui/display.c b/zephcore/helpers/ui/display.c index 645dafb..e394e07 100644 --- a/zephcore/helpers/ui/display.c +++ b/zephcore/helpers/ui/display.c @@ -1,408 +1,445 @@ -/* - * ZephCore - Display Abstraction (CFB) - * Copyright (c) 2025 ZephCore - * SPDX-License-Identifier: Apache-2.0 - * - * Wraps Zephyr's Character Framebuffer (CFB) subsystem. - * Auto-detects any Zephyr-supported display from devicetree: - * 1. "zephyr,display" chosen node (standard — works for any display) - * 2. Legacy nodelabels: sh1106, ssd1306 (backwards compat) - * - * Resolution is queried from the driver at runtime — no hardcoded - * dimensions. Layout code should use mc_display_width/height(). - * - * Auto-off timer turns display off after CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS. - */ - -#include "display.h" -#include "doom_game.h" - -#include -#include -#include -#include -#include -#include -#include - -#include -LOG_MODULE_REGISTER(zephcore_display, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); - -/* ========== State ========== */ - -static const struct device *disp_dev; -static bool disp_on; -static bool disp_initialized; - -/* Runtime display geometry (queried from driver) */ -static uint16_t disp_width; -static uint16_t disp_height; -static uint8_t font_w; -static uint8_t font_h; -static bool is_epd; /* true for e-paper displays */ - -/* Optional display backlight regulator (e.g. e-paper frontlight). - * Boards define a "disp_pwr_enable" regulator-fixed node to gate the - * backlight circuit. When present, backlight follows display on/off. */ -#if DT_NODE_EXISTS(DT_NODELABEL(disp_pwr_enable)) -static const struct device *backlight_reg = - DEVICE_DT_GET_OR_NULL(DT_NODELABEL(disp_pwr_enable)); -#else -static const struct device *backlight_reg; -#endif - -static bool backlight_on; - -static inline void backlight_set(bool on) -{ - if (backlight_reg && device_is_ready(backlight_reg) && on != backlight_on) { - if (on) { - regulator_enable(backlight_reg); - } else { - regulator_disable(backlight_reg); - } - backlight_on = on; - } -} - -/* Auto-off work */ -static struct k_work_delayable auto_off_work; - -static void auto_off_handler(struct k_work *work) -{ - ARG_UNUSED(work); - /* Don't blank display while Doom easter egg is playing */ - if (doom_game_is_running()) { - return; - } - /* E-paper content persists without power — blanking wastes a full - * refresh cycle (~2s) for no benefit. Just turn off the backlight - * and mark display "off" so the next button press triggers - * mc_display_on() → backlight restore. */ - if (is_epd) { - backlight_set(false); - disp_on = false; - return; - } - if (disp_on) { - mc_display_off(); - } -} - -/* ========== Early blanking ========== - * OLED controllers (SSD1306, SH1106) turn the display ON during driver init, - * showing stale VRAM from before reset. Our mc_display_init() runs much later - * (after BLE, LoRa, etc.), so there's a visible garbage flash. - * - * Fix: SYS_INIT hook runs right after the driver, sending "Display OFF" before - * main() starts. This is harmless for non-OLED displays (blanking is a no-op - * or already blanked). */ -static int display_early_blank(void) -{ - const struct device *dev = NULL; - - /* Try standard chosen node first */ -#if DT_HAS_CHOSEN(zephyr_display) - dev = DEVICE_DT_GET_OR_NULL(DT_CHOSEN(zephyr_display)); -#endif - /* Legacy nodelabel fallback */ - if (!dev) { - dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(sh1106)); - } - if (!dev) { - dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(ssd1306)); - } - if (dev && device_is_ready(dev)) { - /* EPD displays are bistable and already show clean white after - * driver init's full refresh. Calling blanking_on here would - * leave blanking_on=true so the subsequent blanking_off in - * mc_display_init() triggers an extra unnecessary full refresh. - * Skip blanking for EPD; OLED still needs it to hide stale VRAM. */ - struct display_capabilities caps; - - display_get_capabilities(dev, &caps); - if (!(caps.screen_info & SCREEN_INFO_EPD)) { - display_blanking_on(dev); - } - } - return 0; -} -SYS_INIT(display_early_blank, APPLICATION, 99); - -/* ========== Public API ========== */ - -int mc_display_init(void) -{ - /* Find display device from devicetree. - * Priority: zephyr,display chosen > sh1106 nodelabel > ssd1306 nodelabel. - * This supports any Zephyr display driver (SSD1306, SH1106, ST7735, - * ILI9341, SSD1681 e-ink, etc.) via the standard chosen mechanism. */ -#if DT_HAS_CHOSEN(zephyr_display) - disp_dev = DEVICE_DT_GET_OR_NULL(DT_CHOSEN(zephyr_display)); -#endif - if (!disp_dev) { - disp_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(sh1106)); - } - if (!disp_dev) { - disp_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(ssd1306)); - } - - if (!disp_dev || !device_is_ready(disp_dev)) { - LOG_INF("no display found - display disabled"); - return -ENODEV; - } - - /* Query actual resolution from display driver */ - struct display_capabilities caps; - - display_get_capabilities(disp_dev, &caps); - disp_width = caps.x_resolution; - disp_height = caps.y_resolution; - is_epd = (caps.screen_info & SCREEN_INFO_EPD) != 0; - - LOG_INF("display: %ux%u%s", disp_width, disp_height, - is_epd ? " (e-paper)" : ""); - - /* OLED: blank before CFB init so stale VRAM isn't visible while we - * build the first frame. EPD: driver init already performed a clean - * full refresh — the panel shows white. Skip blanking to avoid the - * extra full refresh that blanking_off would trigger. */ - if (!is_epd) { - display_blanking_on(disp_dev); - } - - /* Initialize CFB */ - int ret = cfb_framebuffer_init(disp_dev); - - if (ret) { - LOG_ERR("CFB init failed: %d", ret); - return ret; - } - - /* Select smallest font — scan all registered fonts and pick the one - * with the smallest height for best text density. Our custom 6x8 - * Latin-1 font will typically win. */ - int num_fonts = cfb_get_numof_fonts(disp_dev); - - LOG_DBG("display: %d fonts available", num_fonts); - - int best_idx = 0; - uint8_t best_h = 255; - - for (int i = 0; i < num_fonts; i++) { - uint8_t fw = 0, fh = 0; - - cfb_get_font_size(disp_dev, i, &fw, &fh); - LOG_DBG(" font[%d]: %ux%u", i, fw, fh); - if (fh < best_h) { - best_h = fh; - best_idx = i; - } - } - - cfb_framebuffer_set_font(disp_dev, best_idx); - cfb_get_font_size(disp_dev, best_idx, &font_w, &font_h); - LOG_INF("display: selected font[%d] (%ux%u)", best_idx, font_w, font_h); - - /* CFB inversion no longer needed — Zephyr commit 2374ef62f97 fixed - * the MONO10/MONO01 polarity logic in cfb_framebuffer_finalize(). - * SSD1306 OLED reports MONO01 by default, which CFB now handles - * correctly (white pixels on black background) without manual invert. */ - - /* Clear CPU-side framebuffer (zeroes the RAM buffer — no SPI transfer). */ - cfb_framebuffer_clear(disp_dev, false); - - /* Unblank the display so the driver uses partial refresh for all - * subsequent renders (ssd16xx: partial_refresh = !blanking_on). - * OLED: also push a blank frame first to clear stale VRAM. - * EPD: skip the frame push — partial refresh will write real content. */ - if (!is_epd) { - cfb_framebuffer_finalize(disp_dev); - } - display_blanking_off(disp_dev); - backlight_set(true); - disp_on = true; - disp_initialized = true; - - /* Set up auto-off timer and schedule initial timeout */ - k_work_init_delayable(&auto_off_work, auto_off_handler); - mc_display_reset_auto_off(); - - LOG_INF("display initialized (%ux%u, font %ux%u)", - disp_width, disp_height, font_w, font_h); - return 0; -} - -uint16_t mc_display_width(void) -{ - return disp_width; -} - -uint16_t mc_display_height(void) -{ - return disp_height; -} - -uint8_t mc_display_font_width(void) -{ - return font_w; -} - -uint8_t mc_display_font_height(void) -{ - return font_h; -} - -void mc_display_on(void) -{ - if (!disp_initialized) { - return; - } - - if (!disp_on) { - /* EPD: content persists (bistable) — no need to unblank, - * just restore backlight. OLED: actually unblank. */ - if (!is_epd) { - display_blanking_off(disp_dev); - } - disp_on = true; - } - backlight_set(true); - - mc_display_reset_auto_off(); -} - -void mc_display_off(void) -{ - if (!disp_initialized) { - return; - } - - if (disp_on) { - display_blanking_on(disp_dev); - backlight_set(false); - disp_on = false; - } -} - -bool mc_display_is_on(void) -{ - return disp_on; -} - -bool mc_display_is_epd(void) -{ - return is_epd; -} - -void mc_display_clear(void) -{ - if (!disp_initialized) { - return; - } - - cfb_framebuffer_clear(disp_dev, false); -} - -void mc_display_text(int x, int y, const char *text, bool invert) -{ - if (!disp_initialized || !text) { - return; - } - - if (invert) { - cfb_framebuffer_invert(disp_dev); - } - - cfb_print(disp_dev, text, x, y); - - if (invert) { - cfb_framebuffer_invert(disp_dev); - } -} - -void mc_display_fill_rect(int x, int y, int w, int h) -{ - if (!disp_initialized) { - return; - } - - /* CFB doesn't have a native fill_rect, so we draw line by line */ - for (int row = y; row < y + h && row < disp_height; row++) { - struct cfb_position start = { .x = x, .y = row }; - struct cfb_position end = { .x = x + w - 1, .y = row }; - cfb_draw_line(disp_dev, &start, &end); - } -} - -void mc_display_hline(int x, int y, int w) -{ - if (!disp_initialized) { - return; - } - - struct cfb_position start = { .x = x, .y = y }; - struct cfb_position end = { .x = x + w - 1, .y = y }; - cfb_draw_line(disp_dev, &start, &end); -} - -void mc_display_xbm(int x, int y, const uint8_t *data, int w, int h) -{ - if (!disp_initialized || !data) { - return; - } - - /* Adafruit drawBitmap format (MSB first): row-major, bit 7 = leftmost. - * This matches the Arduino MeshCore logo data from icons.h. - * Each row is padded to byte boundary: bytes_per_row = (w+7)/8 */ - int bytes_per_row = (w + 7) / 8; - - for (int row = 0; row < h; row++) { - for (int col = 0; col < w; col++) { - int byte_idx = row * bytes_per_row + col / 8; - int bit_idx = 7 - (col % 8); /* MSB first */ - - if (data[byte_idx] & (1 << bit_idx)) { - struct cfb_position pos = { - .x = (int16_t)(x + col), - .y = (int16_t)(y + row) - }; - cfb_draw_point(disp_dev, &pos); - } - } - } -} - -void mc_display_finalize(void) -{ - if (!disp_initialized) { - return; - } - - /* Don't let CFB overwrite display while Doom is rendering directly */ - if (doom_game_is_running()) { - return; - } - - cfb_framebuffer_finalize(disp_dev); -} - -void mc_display_reset_auto_off(void) -{ - if (!disp_initialized) { - return; - } - -#ifdef CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS - uint32_t timeout = CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS; - - if (timeout > 0) { - k_work_reschedule(&auto_off_work, K_MSEC(timeout)); - } -#endif -} - -const struct device *mc_display_get_device(void) -{ - return disp_initialized ? disp_dev : NULL; -} +/* + * ZephCore - Display Abstraction (CFB) + * Copyright (c) 2025 ZephCore + * SPDX-License-Identifier: Apache-2.0 + * + * Wraps Zephyr's Character Framebuffer (CFB) subsystem. + * Auto-detects any Zephyr-supported display from devicetree: + * 1. "zephyr,display" chosen node (standard — works for any display) + * 2. Legacy nodelabels: sh1106, ssd1306 (backwards compat) + * + * Resolution is queried from the driver at runtime — no hardcoded + * dimensions. Layout code should use mc_display_width/height(). + * + * Auto-off timer turns display off after CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS. + */ + +#include "display.h" +#include "doom_game.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +LOG_MODULE_REGISTER(zephcore_display, CONFIG_ZEPHCORE_BOARD_LOG_LEVEL); + +/* ========== State ========== */ + +static const struct device *disp_dev; +static bool disp_on; +static bool disp_initialized; + +/* Runtime display geometry (queried from driver) */ +static uint16_t disp_width; +static uint16_t disp_height; +static uint8_t font_w; +static uint8_t font_h; +static bool is_epd; /* true for e-paper displays */ + +/* Optional symmetric inset (pixels). Shrinks reported width/height and + * offsets all draw primitives so panels with edge artefacts can hide them + * behind a clean background margin. */ +#define DISP_INSET ((int)CONFIG_ZEPHCORE_DISPLAY_INSET) + +/* Optional display backlight regulator (e.g. e-paper frontlight). + * Boards define a "disp_pwr_enable" regulator-fixed node to gate the + * backlight circuit. When present, backlight follows display on/off. */ +#if DT_NODE_EXISTS(DT_NODELABEL(disp_pwr_enable)) +static const struct device *backlight_reg = + DEVICE_DT_GET_OR_NULL(DT_NODELABEL(disp_pwr_enable)); +#else +static const struct device *backlight_reg; +#endif + +static bool backlight_on; + +static inline void backlight_set(bool on) +{ + if (backlight_reg && device_is_ready(backlight_reg) && on != backlight_on) { + if (on) { + regulator_enable(backlight_reg); + } else { + regulator_disable(backlight_reg); + } + backlight_on = on; + } +} + +/* Auto-off work */ +static struct k_work_delayable auto_off_work; + +static void auto_off_handler(struct k_work *work) +{ + ARG_UNUSED(work); + /* Don't blank display while Doom easter egg is playing */ + if (doom_game_is_running()) { + return; + } + /* E-paper content persists without power — blanking wastes a full + * refresh cycle (~2s) for no benefit. Just turn off the backlight + * and mark display "off" so the next button press triggers + * mc_display_on() → backlight restore. */ + if (is_epd) { + backlight_set(false); + disp_on = false; + return; + } + if (disp_on) { + mc_display_off(); + } +} + +/* ========== Early blanking ========== + * OLED controllers (SSD1306, SH1106) turn the display ON during driver init, + * showing stale VRAM from before reset. Our mc_display_init() runs much later + * (after BLE, LoRa, etc.), so there's a visible garbage flash. + * + * Fix: SYS_INIT hook runs right after the driver, sending "Display OFF" before + * main() starts. This is harmless for non-OLED displays (blanking is a no-op + * or already blanked). */ +static int display_early_blank(void) +{ + const struct device *dev = NULL; + + /* Try standard chosen node first */ +#if DT_HAS_CHOSEN(zephyr_display) + dev = DEVICE_DT_GET_OR_NULL(DT_CHOSEN(zephyr_display)); +#endif + /* Legacy nodelabel fallback */ + if (!dev) { + dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(sh1106)); + } + if (!dev) { + dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(ssd1306)); + } + if (dev && device_is_ready(dev)) { + /* EPD displays are bistable and already show clean white after + * driver init's full refresh. Calling blanking_on here would + * leave blanking_on=true so the subsequent blanking_off in + * mc_display_init() triggers an extra unnecessary full refresh. + * Skip blanking for EPD; OLED still needs it to hide stale VRAM. */ + struct display_capabilities caps; + + display_get_capabilities(dev, &caps); + if (!(caps.screen_info & SCREEN_INFO_EPD)) { + display_blanking_on(dev); + } + } + return 0; +} +SYS_INIT(display_early_blank, APPLICATION, 99); + +/* ========== Public API ========== */ + +int mc_display_init(void) +{ + /* Find display device from devicetree. + * Priority: zephyr,display chosen > sh1106 nodelabel > ssd1306 nodelabel. + * This supports any Zephyr display driver (SSD1306, SH1106, ST7735, + * ILI9341, SSD1681 e-ink, etc.) via the standard chosen mechanism. */ +#if DT_HAS_CHOSEN(zephyr_display) + disp_dev = DEVICE_DT_GET_OR_NULL(DT_CHOSEN(zephyr_display)); +#endif + if (!disp_dev) { + disp_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(sh1106)); + } + if (!disp_dev) { + disp_dev = DEVICE_DT_GET_OR_NULL(DT_NODELABEL(ssd1306)); + } + + if (!disp_dev || !device_is_ready(disp_dev)) { + LOG_INF("no display found - display disabled"); + return -ENODEV; + } + + /* Query actual resolution from display driver */ + struct display_capabilities caps; + + display_get_capabilities(disp_dev, &caps); + disp_width = caps.x_resolution; + disp_height = caps.y_resolution; + is_epd = (caps.screen_info & SCREEN_INFO_EPD) != 0; + + LOG_INF("display: %ux%u%s", disp_width, disp_height, + is_epd ? " (e-paper)" : ""); + + /* OLED: blank before CFB init so stale VRAM isn't visible while we + * build the first frame. EPD: driver init already performed a clean + * full refresh — the panel shows white. Skip blanking to avoid the + * extra full refresh that blanking_off would trigger. */ + if (!is_epd) { + display_blanking_on(disp_dev); + } + + /* Initialize CFB */ + int ret = cfb_framebuffer_init(disp_dev); + + if (ret) { + LOG_ERR("CFB init failed: %d", ret); + return ret; + } + + /* Font selection. + * Default: smallest height for best text density — our custom 6x8 + * Latin-1 font typically wins on OLEDs. + * LARGE_FONT: smallest font whose height is >= 16 — picks Zephyr's + * built-in 10x16 (cfb_fonts.c) for larger e-paper panels where 6x8 + * is too small to read. Falls back to smallest-overall if no tall + * font is compiled in. */ + const bool want_large = IS_ENABLED(CONFIG_ZEPHCORE_DISPLAY_LARGE_FONT); + int num_fonts = cfb_get_numof_fonts(disp_dev); + + LOG_DBG("display: %d fonts available", num_fonts); + + int best_idx = -1; + uint8_t best_h = 255; + + for (int i = 0; i < num_fonts; i++) { + uint8_t fw = 0, fh = 0; + + cfb_get_font_size(disp_dev, i, &fw, &fh); + LOG_DBG(" font[%d]: %ux%u", i, fw, fh); + if (want_large) { + if (fh >= 16 && fh < best_h) { + best_h = fh; + best_idx = i; + } + } else { + if (fh < best_h) { + best_h = fh; + best_idx = i; + } + } + } + if (best_idx < 0) { + /* No font satisfied the LARGE_FONT threshold — fall back to + * the smallest so we still render something. */ + best_idx = 0; + for (int i = 0; i < num_fonts; i++) { + uint8_t fw = 0, fh = 0; + + cfb_get_font_size(disp_dev, i, &fw, &fh); + if (fh < best_h) { + best_h = fh; + best_idx = i; + } + } + } + + cfb_framebuffer_set_font(disp_dev, best_idx); + cfb_get_font_size(disp_dev, best_idx, &font_w, &font_h); + LOG_INF("display: selected font[%d] (%ux%u)", best_idx, font_w, font_h); + + /* CFB inversion no longer needed — Zephyr commit 2374ef62f97 fixed + * the MONO10/MONO01 polarity logic in cfb_framebuffer_finalize(). + * SSD1306 OLED reports MONO01 by default, which CFB now handles + * correctly (white pixels on black background) without manual invert. */ + + /* Clear CPU-side framebuffer (zeroes the RAM buffer — no SPI transfer). */ + cfb_framebuffer_clear(disp_dev, false); + + /* Unblank the display so the driver uses partial refresh for all + * subsequent renders (ssd16xx: partial_refresh = !blanking_on). + * OLED: also push a blank frame first to clear stale VRAM. + * EPD: skip the frame push — partial refresh will write real content. */ + if (!is_epd) { + cfb_framebuffer_finalize(disp_dev); + } + display_blanking_off(disp_dev); + backlight_set(true); + disp_on = true; + disp_initialized = true; + + /* Set up auto-off timer and schedule initial timeout */ + k_work_init_delayable(&auto_off_work, auto_off_handler); + mc_display_reset_auto_off(); + + LOG_INF("display initialized (%ux%u, font %ux%u)", + disp_width, disp_height, font_w, font_h); + return 0; +} + +uint16_t mc_display_width(void) +{ + int w = (int)disp_width - 2 * DISP_INSET; + + return (w > 0) ? (uint16_t)w : 0; +} + +uint16_t mc_display_height(void) +{ + int h = (int)disp_height - 2 * DISP_INSET; + + return (h > 0) ? (uint16_t)h : 0; +} + +uint8_t mc_display_font_width(void) +{ + return font_w; +} + +uint8_t mc_display_font_height(void) +{ + return font_h; +} + +void mc_display_on(void) +{ + if (!disp_initialized) { + return; + } + + if (!disp_on) { + /* EPD: content persists (bistable) — no need to unblank, + * just restore backlight. OLED: actually unblank. */ + if (!is_epd) { + display_blanking_off(disp_dev); + } + disp_on = true; + } + backlight_set(true); + + mc_display_reset_auto_off(); +} + +void mc_display_off(void) +{ + if (!disp_initialized) { + return; + } + + if (disp_on) { + display_blanking_on(disp_dev); + backlight_set(false); + disp_on = false; + } +} + +bool mc_display_is_on(void) +{ + return disp_on; +} + +bool mc_display_is_epd(void) +{ + return is_epd; +} + +void mc_display_clear(void) +{ + if (!disp_initialized) { + return; + } + + cfb_framebuffer_clear(disp_dev, false); +} + +void mc_display_text(int x, int y, const char *text, bool invert) +{ + if (!disp_initialized || !text) { + return; + } + + if (invert) { + cfb_framebuffer_invert(disp_dev); + } + + cfb_print(disp_dev, text, x + DISP_INSET, y + DISP_INSET); + + if (invert) { + cfb_framebuffer_invert(disp_dev); + } +} + +void mc_display_fill_rect(int x, int y, int w, int h) +{ + if (!disp_initialized) { + return; + } + + /* CFB doesn't have a native fill_rect, so we draw line by line */ + const int row_clamp = (int)disp_height - DISP_INSET; + + for (int row = y + DISP_INSET; row < y + h + DISP_INSET && row < row_clamp; row++) { + struct cfb_position start = { .x = x + DISP_INSET, .y = row }; + struct cfb_position end = { .x = x + w - 1 + DISP_INSET, .y = row }; + cfb_draw_line(disp_dev, &start, &end); + } +} + +void mc_display_hline(int x, int y, int w) +{ + if (!disp_initialized) { + return; + } + + struct cfb_position start = { .x = x + DISP_INSET, .y = y + DISP_INSET }; + struct cfb_position end = { .x = x + w - 1 + DISP_INSET, .y = y + DISP_INSET }; + cfb_draw_line(disp_dev, &start, &end); +} + +void mc_display_xbm(int x, int y, const uint8_t *data, int w, int h) +{ + if (!disp_initialized || !data) { + return; + } + + /* Adafruit drawBitmap format (MSB first): row-major, bit 7 = leftmost. + * This matches the Arduino MeshCore logo data from icons.h. + * Each row is padded to byte boundary: bytes_per_row = (w+7)/8 */ + int bytes_per_row = (w + 7) / 8; + + for (int row = 0; row < h; row++) { + for (int col = 0; col < w; col++) { + int byte_idx = row * bytes_per_row + col / 8; + int bit_idx = 7 - (col % 8); /* MSB first */ + + if (data[byte_idx] & (1 << bit_idx)) { + struct cfb_position pos = { + .x = (int16_t)(x + col + DISP_INSET), + .y = (int16_t)(y + row + DISP_INSET) + }; + cfb_draw_point(disp_dev, &pos); + } + } + } +} + +void mc_display_finalize(void) +{ + if (!disp_initialized) { + return; + } + + /* Don't let CFB overwrite display while Doom is rendering directly */ + if (doom_game_is_running()) { + return; + } + + cfb_framebuffer_finalize(disp_dev); +} + +void mc_display_reset_auto_off(void) +{ + if (!disp_initialized) { + return; + } + +#ifdef CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS + uint32_t timeout = CONFIG_ZEPHCORE_UI_DISPLAY_AUTO_OFF_MS; + + if (timeout > 0) { + k_work_reschedule(&auto_off_work, K_MSEC(timeout)); + } +#endif +} + +const struct device *mc_display_get_device(void) +{ + return disp_initialized ? disp_dev : NULL; +} diff --git a/zephcore/helpers/ui/ui_pages.c b/zephcore/helpers/ui/ui_pages.c index 0c31dd3..778276d 100644 --- a/zephcore/helpers/ui/ui_pages.c +++ b/zephcore/helpers/ui/ui_pages.c @@ -142,6 +142,23 @@ void utf8_to_latin1(char *dst, const char *src, size_t dst_size) #define CONTENT_Y (SEP_Y + 4) /* below separator */ #define MAX_CHARS (DISP_W / (FONT_W ? FONT_W : 1)) +/* Vertically center `total` rows of text within the content area and + * return the y pixel position for row `idx` (0-based). This replaces + * the old hand-picked offsets (CONTENT_Y+16, +28, +34, ...) that were + * calibrated for the 6x8 font and broke when switching to 10x16. + * Works for any font size / display resolution. */ +static inline int centered_row(int idx, int total) +{ + int content_h = (int)DISP_H - CONTENT_Y; + int used_h = total * LINE_H; + int top = (content_h - used_h) / 2; + + if (top < 0) { + top = 0; + } + return CONTENT_Y + top + idx * LINE_H; +} + /* ========== Global State ========== */ static struct ui_state state; @@ -284,29 +301,27 @@ static void draw_centered(int y, const char *text) static void render_messages(void) { - /* Large centered message count (matching Arduino "MSG: X") */ + /* 3 centered rows: msg count, BLE status, offgrid status */ char buf[24]; snprintf(buf, sizeof(buf), "MSG: %u", state.msg_count); - draw_centered(CONTENT_Y + 2, buf); + draw_centered(centered_row(0, 3), buf); - /* Connection status centered below */ if (state.ble_connected) { - draw_centered(CONTENT_Y + 16, "< Connected >"); + draw_centered(centered_row(1, 3), "< Connected >"); } else { - draw_centered(CONTENT_Y + 16, "Waiting for app..."); + draw_centered(centered_row(1, 3), "Waiting for app..."); } - /* Offgrid mode status */ snprintf(buf, sizeof(buf), "Offgrid: %s", state.offgrid_enabled ? "ON" : "OFF"); - draw_centered(CONTENT_Y + 32, buf); + draw_centered(centered_row(2, 3), buf); } static void render_recent(void) { if (state.recent_count == 0) { - draw_centered(CONTENT_Y + 10, "(no contacts heard)"); + draw_centered(centered_row(0, 1), "(no contacts heard)"); return; } @@ -384,17 +399,17 @@ static void render_radio(void) static void render_bluetooth(void) { if (!state.ble_enabled) { - draw_centered(CONTENT_Y + 10, "BLE: OFF"); - draw_centered(CONTENT_Y + 28, "Press to Enable"); + draw_centered(centered_row(0, 2), "BLE: OFF"); + draw_centered(centered_row(1, 2), "Press to Enable"); return; } if (state.ble_connected) { - draw_centered(CONTENT_Y + 4, "BLE: Connected"); - draw_centered(CONTENT_Y + 20, "Press to Disable"); + draw_centered(centered_row(0, 2), "BLE: Connected"); + draw_centered(centered_row(1, 2), "Press to Disable"); } else { - draw_centered(CONTENT_Y + 4, "BLE: Advertising"); - draw_centered(CONTENT_Y + 20, "Press to Disable"); + draw_centered(centered_row(0, 2), "BLE: Advertising"); + draw_centered(centered_row(1, 2), "Press to Disable"); } } @@ -405,19 +420,19 @@ static void render_advert(void) bool just_sent = (state.advert_sent_time > 0 && (now - state.advert_sent_time) < 2000); - draw_centered(CONTENT_Y, "Send Zero-Hop Advert"); + draw_centered(centered_row(0, 3), "Send Zero-Hop Advert"); if (just_sent) { if (state.advert_was_flood) { - draw_centered(CONTENT_Y + 16, ">> Flood Sent! <<"); + draw_centered(centered_row(1, 3), ">> Flood Sent! <<"); } else { - draw_centered(CONTENT_Y + 16, ">>> Sent! <<<"); + draw_centered(centered_row(1, 3), ">>> Sent! <<<"); } } else { - draw_centered(CONTENT_Y + 16, "Press to Send"); + draw_centered(centered_row(1, 3), "Press to Send"); } - draw_centered(CONTENT_Y + 34, "(Double Press: Flood)"); + draw_centered(centered_row(2, 3), "(2x Press: Flood)"); } /* Format seconds into compact time string: "3m20s", "1h05m", "12s" */ @@ -574,24 +589,24 @@ static void render_sensors(void) static void render_offgrid(void) { - /* Check if confirmation has expired (500ms) */ + /* Check if confirmation has expired */ if (state.offgrid_confirm_time != 0 && - (k_uptime_get_32() - state.offgrid_confirm_time) > 500) { + (k_uptime_get_32() - state.offgrid_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.offgrid_confirm_time = 0; } - draw_centered(CONTENT_Y + 2, "Offgrid Mode"); + draw_centered(centered_row(0, 3), "Offgrid Mode"); char buf[24]; snprintf(buf, sizeof(buf), "Status: %s", state.offgrid_enabled ? "ON" : "OFF"); - draw_centered(CONTENT_Y + 16, buf); + draw_centered(centered_row(1, 3), buf); if (state.offgrid_confirm_time != 0) { - draw_centered(CONTENT_Y + 34, "Press to confirm"); + draw_centered(centered_row(2, 3), "Press to confirm"); } else { - draw_centered(CONTENT_Y + 34, + draw_centered(centered_row(2, 3), state.offgrid_enabled ? "Press to Disable" : "Press to Enable"); } @@ -599,33 +614,33 @@ static void render_offgrid(void) static void render_dfu(void) { - /* Check if confirmation has expired (500ms) */ + /* Check if confirmation has expired */ if (state.dfu_confirm_time != 0 && - (k_uptime_get_32() - state.dfu_confirm_time) > 500) { + (k_uptime_get_32() - state.dfu_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.dfu_confirm_time = 0; } - draw_centered(CONTENT_Y + 10, "BLE DFU Update"); + draw_centered(centered_row(0, 2), "BLE DFU Update"); if (state.dfu_confirm_time != 0) { - draw_centered(CONTENT_Y + 28, "Press to confirm"); + draw_centered(centered_row(1, 2), "Press to confirm"); } else { - draw_centered(CONTENT_Y + 28, "Press to Run"); + draw_centered(centered_row(1, 2), "Press to Run"); } } static void render_shutdown(void) { - /* Check if confirmation has expired (500ms) */ + /* Check if confirmation has expired */ if (state.shutdown_confirm_time != 0 && - (k_uptime_get_32() - state.shutdown_confirm_time) > 500) { + (k_uptime_get_32() - state.shutdown_confirm_time) > CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { state.shutdown_confirm_time = 0; } - draw_centered(CONTENT_Y + 10, "Power Off"); + draw_centered(centered_row(0, 2), "Power Off"); if (state.shutdown_confirm_time != 0) { - draw_centered(CONTENT_Y + 28, "Press to confirm"); + draw_centered(centered_row(1, 2), "Press to confirm"); } else { - draw_centered(CONTENT_Y + 28, "Press to Run"); + draw_centered(centered_row(1, 2), "Press to Run"); } } diff --git a/zephcore/helpers/ui/ui_task.c b/zephcore/helpers/ui/ui_task.c index 10077ba..0c38d52 100644 --- a/zephcore/helpers/ui/ui_task.c +++ b/zephcore/helpers/ui/ui_task.c @@ -353,12 +353,12 @@ static void action_page_enter(void) break; case UI_PAGE_OFFGRID: { - /* Double-press confirmation (500ms window) */ + /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ struct ui_state *st_og = get_state(); uint32_t now_og = k_uptime_get_32(); if (st_og->offgrid_confirm_time != 0 && - (now_og - st_og->offgrid_confirm_time) <= 500) { + (now_og - st_og->offgrid_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { /* Confirmed — toggle offgrid mode */ bool new_state = !st_og->offgrid_enabled; st_og->offgrid_enabled = new_state; @@ -374,12 +374,12 @@ static void action_page_enter(void) } case UI_PAGE_DFU: { - /* Double-press confirmation (500ms window) */ + /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ struct ui_state *st_dfu = get_state(); uint32_t now_dfu = k_uptime_get_32(); if (st_dfu->dfu_confirm_time != 0 && - (now_dfu - st_dfu->dfu_confirm_time) <= 500) { + (now_dfu - st_dfu->dfu_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { /* Confirmed — reboot into BLE DFU */ action_enter_dfu(); } else { @@ -391,12 +391,12 @@ static void action_page_enter(void) } case UI_PAGE_SHUTDOWN: { - /* Double-press confirmation (500ms window) */ + /* Double-press confirmation (CONFIRM_WINDOW_MS window) */ struct ui_state *st = get_state(); uint32_t now = k_uptime_get_32(); if (st->shutdown_confirm_time != 0 && - (now - st->shutdown_confirm_time) <= 500) { + (now - st->shutdown_confirm_time) <= CONFIG_ZEPHCORE_UI_CONFIRM_WINDOW_MS) { /* Confirmed — shut down */ action_deep_sleep(); } else {