end-to-end working: ESP32 connects to server, renders widgets
boot logo (procedural hexagon + K), WiFi (WPA auto-detect with retries), TCP client connects and receives ScreenUpdate/DataUpdate messages, display renders widget data. Makefile with esp-flash/server/desktop targets. known issues: boot logo not cleared, text overlaps, occasional reconnect
This commit is contained in:
61
Makefile
Normal file
61
Makefile
Normal file
@@ -0,0 +1,61 @@
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
ESP_PORT ?= /dev/ttyACM0
|
||||
|
||||
# Run the full local check suite — same order as CI would.
|
||||
check: fmt-check clippy test
|
||||
@echo "✅ All checks passed"
|
||||
|
||||
# Apply rustfmt to all files.
|
||||
fmt:
|
||||
cargo fmt
|
||||
|
||||
# Check formatting without modifying files (CI-safe).
|
||||
fmt-check:
|
||||
cargo fmt --check
|
||||
|
||||
# Run Clippy and treat warnings as errors.
|
||||
clippy:
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
# Run the test suite.
|
||||
test:
|
||||
cargo test
|
||||
|
||||
# Apply fmt + clippy auto-fixes in one shot.
|
||||
fix:
|
||||
cargo fmt
|
||||
cargo clippy --fix --allow-dirty --allow-staged
|
||||
|
||||
# Start the K-Frame server.
|
||||
server:
|
||||
cargo run --bin bootstrap
|
||||
|
||||
# Start the desktop client.
|
||||
desktop:
|
||||
cargo run --bin client-desktop
|
||||
|
||||
# Build ESP32 firmware. Requires env: KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR
|
||||
esp-build:
|
||||
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
|
||||
cd crates/client-esp32 && cargo build --release
|
||||
|
||||
# Flash ESP32 firmware.
|
||||
esp-flash:
|
||||
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
|
||||
cd crates/client-esp32 && cargo espflash flash --port $(ESP_PORT) --release
|
||||
|
||||
# Flash and monitor ESP32.
|
||||
esp-run:
|
||||
@test -n "$(KFRAME_WIFI_SSID)" || (echo "Set KFRAME_WIFI_SSID, KFRAME_WIFI_PASS, KFRAME_SERVER_ADDR" && exit 1)
|
||||
cd crates/client-esp32 && cargo espflash flash --port $(ESP_PORT) --release --monitor
|
||||
|
||||
# Monitor ESP32 serial output.
|
||||
esp-monitor:
|
||||
cd crates/client-esp32 && cargo espflash monitor --port $(ESP_PORT)
|
||||
|
||||
# Erase ESP32 flash (recovery).
|
||||
esp-erase:
|
||||
esptool --port $(ESP_PORT) erase-flash
|
||||
|
||||
.PHONY: check fmt fmt-check clippy test fix server desktop esp-build esp-flash esp-run esp-monitor esp-erase
|
||||
@@ -81,13 +81,9 @@ async fn main() {
|
||||
all_changed.extend(changed_portfolio);
|
||||
|
||||
if !all_changed.is_empty() {
|
||||
if counter == 1 {
|
||||
if let Some(l) = &layout {
|
||||
broadcaster.push_screen_update(l, &all_changed).await.unwrap();
|
||||
}
|
||||
} else {
|
||||
broadcaster.push_data_update(&all_changed).await.unwrap();
|
||||
}
|
||||
println!("Pushed {} widget updates (tick {counter})", all_changed.len());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,8 @@ CONFIG_SPI_MASTER_IN_IRAM=y
|
||||
|
||||
# Use single large app partition (no OTA)
|
||||
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y
|
||||
|
||||
# Watchdog
|
||||
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30
|
||||
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n
|
||||
CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n
|
||||
|
||||
118
crates/client-esp32/src/boot/logo.rs
Normal file
118
crates/client-esp32/src/boot/logo.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
use embedded_graphics::{
|
||||
pixelcolor::Rgb565,
|
||||
prelude::*,
|
||||
primitives::{Triangle, PrimitiveStyle},
|
||||
};
|
||||
|
||||
fn rgb(r: u8, g: u8, b: u8) -> Rgb565 {
|
||||
Rgb565::new(r >> 3, g >> 2, b >> 3)
|
||||
}
|
||||
|
||||
fn orange() -> Rgb565 { rgb(0xF7, 0x94, 0x1D) }
|
||||
fn dark() -> Rgb565 { rgb(0x1A, 0x1A, 0x1A) }
|
||||
fn light() -> Rgb565 { rgb(0xDB, 0xDB, 0xDB) }
|
||||
fn bg() -> Rgb565 { rgb(0x1E, 0x1E, 0x1E) }
|
||||
|
||||
fn p(x: i32, y: i32) -> Point {
|
||||
Point::new(x, y)
|
||||
}
|
||||
|
||||
fn scaled(points: &[(f32, f32)], scale: f32, cx: f32, cy: f32) -> Vec<(i32, i32)> {
|
||||
points.iter().map(|(x, y)| {
|
||||
((x * scale + cx) as i32, (y * scale + cy) as i32)
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn fill_triangles<D: DrawTarget<Color = Rgb565>>(
|
||||
display: &mut D,
|
||||
tris: &[(usize, usize, usize)],
|
||||
pts: &[(i32, i32)],
|
||||
color: Rgb565,
|
||||
) {
|
||||
let style = PrimitiveStyle::with_fill(color);
|
||||
for &(a, b, c) in tris {
|
||||
let _ = Triangle::new(
|
||||
p(pts[a].0, pts[a].1),
|
||||
p(pts[b].0, pts[b].1),
|
||||
p(pts[c].0, pts[c].1),
|
||||
)
|
||||
.into_styled(style)
|
||||
.draw(display);
|
||||
}
|
||||
}
|
||||
|
||||
// Outer hexagon vertices (150x150 viewBox)
|
||||
const OUTER_HEX: [(f32, f32); 6] = [
|
||||
(75.0, 0.0), (150.0, 37.5), (150.0, 112.5),
|
||||
(75.0, 150.0), (0.0, 112.5), (0.0, 37.5),
|
||||
];
|
||||
|
||||
// Converted from SVG: (150,75)(112.5,150)(37.5,150)(0,75)(37.5,0)(112.5,0)
|
||||
// Reordered to start from top for cleaner fan
|
||||
const OUTER_HEX_SVG: [(f32, f32); 6] = [
|
||||
(37.5, 0.0), (112.5, 0.0), (150.0, 75.0),
|
||||
(112.5, 150.0), (37.5, 150.0), (0.0, 75.0),
|
||||
];
|
||||
|
||||
const INNER_HEX_SVG: [(f32, f32); 6] = [
|
||||
(49.25, 23.0), (101.75, 23.0), (128.0, 75.5),
|
||||
(101.75, 128.0), (49.25, 128.0), (23.0, 75.5),
|
||||
];
|
||||
|
||||
// Fan triangulation works for convex hexagons
|
||||
const HEX_TRIS: [(usize, usize, usize); 4] = [
|
||||
(0, 1, 2), (0, 2, 3), (0, 3, 4), (0, 4, 5),
|
||||
];
|
||||
|
||||
// K letter vertices from SVG path, in order
|
||||
const K_POINTS: [(f32, f32); 10] = [
|
||||
(53.41, 49.02), // 0: top-left vertical bar
|
||||
(63.66, 49.02), // 1: top-right vertical bar
|
||||
(63.66, 72.87), // 2: inner notch (where diagonals meet bar)
|
||||
(83.48, 49.02), // 3: top arm inner
|
||||
(95.85, 49.02), // 4: top arm outer
|
||||
(74.17, 75.51), // 5: center junction
|
||||
(95.85, 101.07), // 6: bottom arm outer
|
||||
(82.95, 101.07), // 7: bottom arm inner
|
||||
(67.38, 82.23), // 8: bottom notch
|
||||
(53.41, 98.04), // 9: bottom-left vertical bar
|
||||
];
|
||||
|
||||
// Manual triangulation for the concave K shape
|
||||
const K_TRIS: [(usize, usize, usize); 8] = [
|
||||
// Vertical bar
|
||||
(0, 1, 9),
|
||||
(1, 8, 9),
|
||||
// Upper diagonal
|
||||
(1, 2, 5),
|
||||
(2, 3, 5),
|
||||
(3, 4, 5),
|
||||
// Lower diagonal
|
||||
(1, 5, 8),
|
||||
(5, 7, 8),
|
||||
(5, 6, 7),
|
||||
];
|
||||
|
||||
pub fn draw_logo<D: DrawTarget<Color = Rgb565>>(
|
||||
display: &mut D,
|
||||
screen_w: u16,
|
||||
screen_h: u16,
|
||||
) {
|
||||
let logo_size = (screen_h as f32 * 0.7).min(screen_w as f32 * 0.8);
|
||||
let scale = logo_size / 150.0;
|
||||
let cx = (screen_w as f32 - 150.0 * scale) / 2.0;
|
||||
let cy = (screen_h as f32 - 150.0 * scale) / 2.0;
|
||||
|
||||
let outer = scaled(&OUTER_HEX_SVG, scale, cx, cy);
|
||||
fill_triangles(display, &HEX_TRIS, &outer, orange());
|
||||
|
||||
let inner = scaled(&INNER_HEX_SVG, scale, cx, cy);
|
||||
fill_triangles(display, &HEX_TRIS, &inner, dark());
|
||||
|
||||
let k = scaled(&K_POINTS, scale, cx, cy);
|
||||
fill_triangles(display, &K_TRIS, &k, light());
|
||||
}
|
||||
|
||||
pub fn draw_background<D: DrawTarget<Color = Rgb565>>(display: &mut D) {
|
||||
let _ = display.clear(bg());
|
||||
}
|
||||
14
crates/client-esp32/src/boot/mod.rs
Normal file
14
crates/client-esp32/src/boot/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
mod logo;
|
||||
|
||||
use embedded_graphics::pixelcolor::Rgb565;
|
||||
use embedded_graphics::prelude::*;
|
||||
use esp_idf_hal::delay::Ets;
|
||||
use crate::config::{SCREEN_WIDTH, SCREEN_HEIGHT};
|
||||
|
||||
pub fn run<D: DrawTarget<Color = Rgb565>>(display: &mut D) {
|
||||
logo::draw_background(display);
|
||||
Ets::delay_ms(200);
|
||||
|
||||
logo::draw_logo(display, SCREEN_WIDTH, SCREEN_HEIGHT);
|
||||
Ets::delay_ms(1500);
|
||||
}
|
||||
@@ -11,6 +11,10 @@ pub const SCREEN: BoundingBox = BoundingBox {
|
||||
height: SCREEN_HEIGHT,
|
||||
};
|
||||
|
||||
// Physical panel dimensions (before rotation)
|
||||
pub const PANEL_WIDTH: u16 = 240;
|
||||
pub const PANEL_HEIGHT: u16 = 320;
|
||||
|
||||
pub const SPI_BAUDRATE: Hertz = Hertz(26_000_000);
|
||||
pub const SPI_BUFFER_SIZE: usize = 512;
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use esp_idf_hal::delay::{Delay, Ets};
|
||||
use esp_idf_hal::gpio::{AnyIOPin, AnyOutputPin, PinDriver};
|
||||
use esp_idf_hal::spi::{SpiDeviceDriver, SpiDriver, SpiDriverConfig, SPI2, config::Config as SpiConfig};
|
||||
use mipidsi::{Builder, models::ILI9341Rgb565, options::{Orientation, Rotation}, interface::SpiInterface};
|
||||
use mipidsi::{Builder, models::ILI9341Rgb565, options::{ColorOrder, Orientation, Rotation}, interface::SpiInterface};
|
||||
use embedded_graphics::pixelcolor::Rgb565;
|
||||
use embedded_graphics::prelude::*;
|
||||
use log::info;
|
||||
|
||||
use crate::config::{SCREEN_WIDTH, SCREEN_HEIGHT, SPI_BAUDRATE, SPI_BUFFER_SIZE};
|
||||
use crate::config::{self, SPI_BAUDRATE, SPI_BUFFER_SIZE};
|
||||
use crate::adapters::display::Esp32DisplayAdapter;
|
||||
use crate::boot;
|
||||
|
||||
pub struct DisplayHardware<'d> {
|
||||
pub spi: SPI2<'d>,
|
||||
@@ -40,19 +41,20 @@ pub fn init(hw: DisplayHardware<'static>) -> Esp32DisplayAdapter {
|
||||
rst_pin.set_high().unwrap();
|
||||
Ets::delay_ms(120);
|
||||
|
||||
// Keep RST pin high — dropping PinDriver may release the GPIO
|
||||
let rst_pin: &'static mut _ = Box::leak(Box::new(rst_pin));
|
||||
let _rst_pin: &'static mut _ = Box::leak(Box::new(rst_pin));
|
||||
|
||||
let buf: &'static mut [u8; SPI_BUFFER_SIZE] = Box::leak(Box::new([0u8; SPI_BUFFER_SIZE]));
|
||||
let di = SpiInterface::new(spi_device, dc_pin, buf);
|
||||
|
||||
info!("Initializing ILI9341...");
|
||||
let mut raw_display = Builder::new(ILI9341Rgb565, di)
|
||||
.display_size(240, 320)
|
||||
.display_size(config::PANEL_WIDTH, config::PANEL_HEIGHT)
|
||||
.orientation(Orientation { rotation: Rotation::Deg90, mirrored: true })
|
||||
.color_order(ColorOrder::Bgr)
|
||||
.init(&mut Delay::new_default())
|
||||
.expect("Display init failed");
|
||||
|
||||
raw_display.clear(Rgb565::BLACK).unwrap();
|
||||
boot::run(&mut raw_display);
|
||||
|
||||
Esp32DisplayAdapter::new(raw_display)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use esp_idf_hal::modem::Modem;
|
||||
use esp_idf_svc::eventloop::EspSystemEventLoop;
|
||||
use esp_idf_svc::nvs::EspDefaultNvsPartition;
|
||||
use esp_idf_svc::wifi::{
|
||||
AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi,
|
||||
};
|
||||
use log::info;
|
||||
use log::{info, error};
|
||||
|
||||
const MAX_RETRIES: u32 = 5;
|
||||
const RETRY_DELAY: Duration = Duration::from_secs(3);
|
||||
|
||||
pub fn init<'d>(
|
||||
modem: Modem<'d>,
|
||||
@@ -22,17 +27,30 @@ pub fn init<'d>(
|
||||
let config = Configuration::Client(ClientConfiguration {
|
||||
ssid: ssid.try_into().unwrap(),
|
||||
password: password.try_into().unwrap(),
|
||||
auth_method: AuthMethod::WPA2Personal,
|
||||
auth_method: AuthMethod::WPA,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
wifi.set_configuration(&config).map_err(|e| format!("wifi config: {e:?}"))?;
|
||||
wifi.start().map_err(|e| format!("wifi start: {e:?}"))?;
|
||||
|
||||
info!("WiFi started, connecting...");
|
||||
wifi.connect().map_err(|e| format!("wifi connect: {e:?}"))?;
|
||||
wifi.wait_netif_up().map_err(|e| format!("wifi netif: {e:?}"))?;
|
||||
info!("WiFi started, connecting to '{ssid}'...");
|
||||
|
||||
for attempt in 1..=MAX_RETRIES {
|
||||
match wifi.connect() {
|
||||
Ok(()) => {
|
||||
wifi.wait_netif_up().map_err(|e| format!("wifi netif: {e:?}"))?;
|
||||
info!("WiFi connected");
|
||||
Ok(wifi)
|
||||
return Ok(wifi);
|
||||
}
|
||||
Err(e) => {
|
||||
error!("WiFi connect attempt {attempt}/{MAX_RETRIES} failed: {e:?}");
|
||||
if attempt < MAX_RETRIES {
|
||||
thread::sleep(RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("WiFi failed after {MAX_RETRIES} attempts"))
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
mod adapters;
|
||||
mod boot;
|
||||
mod config;
|
||||
mod hal;
|
||||
mod tasks;
|
||||
|
||||
use client_domain::{BoundingBox, DisplayPort};
|
||||
use std::sync::mpsc;
|
||||
use esp_idf_hal::peripherals::Peripherals;
|
||||
use esp_idf_svc::eventloop::EspSystemEventLoop;
|
||||
use esp_idf_svc::nvs::EspDefaultNvsPartition;
|
||||
use log::info;
|
||||
|
||||
const WIFI_SSID: &str = env!("KFRAME_WIFI_SSID");
|
||||
const WIFI_PASS: &str = env!("KFRAME_WIFI_PASS");
|
||||
const SERVER_ADDR: &str = env!("KFRAME_SERVER_ADDR");
|
||||
|
||||
fn main() {
|
||||
esp_idf_svc::sys::link_patches();
|
||||
esp_idf_svc::log::EspLogger::initialize_default();
|
||||
@@ -14,8 +21,10 @@ fn main() {
|
||||
info!("=== K-Frame ESP32 ===");
|
||||
|
||||
let peripherals = Peripherals::take().unwrap();
|
||||
let sysloop = EspSystemEventLoop::take().unwrap();
|
||||
let nvs = EspDefaultNvsPartition::take().unwrap();
|
||||
|
||||
let mut display = hal::display::init(hal::display::DisplayHardware {
|
||||
let display = hal::display::init(hal::display::DisplayHardware {
|
||||
spi: peripherals.spi2,
|
||||
sclk: peripherals.pins.gpio18.into(),
|
||||
mosi: peripherals.pins.gpio23.into(),
|
||||
@@ -23,23 +32,13 @@ fn main() {
|
||||
dc: peripherals.pins.gpio21.into(),
|
||||
rst: peripherals.pins.gpio22.into(),
|
||||
});
|
||||
info!("Display initialized");
|
||||
info!("Display ready");
|
||||
|
||||
display.fill_background(config::SCREEN).unwrap();
|
||||
display.draw_text(
|
||||
"K-Frame",
|
||||
10, 10,
|
||||
BoundingBox::new(10, 10, 220, 40),
|
||||
).unwrap();
|
||||
display.draw_text(
|
||||
"Display test OK",
|
||||
10, 60,
|
||||
BoundingBox::new(10, 60, 220, 40),
|
||||
).unwrap();
|
||||
display.flush().unwrap();
|
||||
info!("Connecting WiFi...");
|
||||
let _wifi = hal::wifi::init(peripherals.modem, sysloop, nvs, WIFI_SSID, WIFI_PASS)
|
||||
.expect("WiFi init failed");
|
||||
|
||||
info!("Display test complete — looping forever");
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
let (tx, rx) = mpsc::channel();
|
||||
tasks::network::spawn(SERVER_ADDR.into(), tx);
|
||||
tasks::render::run(config::SCREEN, display, rx);
|
||||
}
|
||||
|
||||
6410
other/logo_splash.h
Normal file
6410
other/logo_splash.h
Normal file
File diff suppressed because it is too large
Load Diff
43
other/logo_to_rgb565.py
Normal file
43
other/logo_to_rgb565.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from PIL import Image
|
||||
|
||||
INPUT = "/home/gabriel/Downloads/Logo.png"
|
||||
OUTPUT = "/home/gabriel/Downloads/logo_splash.h"
|
||||
DISPLAY_W, DISPLAY_H = 240, 320
|
||||
BG_COLOR = (30, 30, 30) # dark background, change to (0,0,0) for pure black
|
||||
|
||||
img = Image.open(INPUT).convert("RGB")
|
||||
|
||||
# Scale logo to fit within display, preserving aspect ratio
|
||||
img.thumbnail((DISPLAY_W, DISPLAY_H), Image.LANCZOS)
|
||||
|
||||
# Center on display canvas
|
||||
canvas = Image.new("RGB", (DISPLAY_W, DISPLAY_H), BG_COLOR)
|
||||
x = (DISPLAY_W - img.width) // 2
|
||||
y = (DISPLAY_H - img.height) // 2
|
||||
canvas.paste(img, (x, y))
|
||||
|
||||
# Convert to RGB565
|
||||
pixels = []
|
||||
for r, g, b in canvas.getdata():
|
||||
rgb565 = ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)
|
||||
# Swap bytes for ILI9341 (big-endian over SPI)
|
||||
pixels.append(((rgb565 & 0xFF) << 8) | (rgb565 >> 8))
|
||||
|
||||
# Write header file
|
||||
with open(OUTPUT, "w") as f:
|
||||
f.write("#pragma once\n")
|
||||
f.write("#include <pgmspace.h>\n\n")
|
||||
f.write(f"// {DISPLAY_W}x{DISPLAY_H} RGB565, big-endian\n")
|
||||
f.write(f"const uint16_t SPLASH_WIDTH = {DISPLAY_W};\n")
|
||||
f.write(f"const uint16_t SPLASH_HEIGHT = {DISPLAY_H};\n\n")
|
||||
f.write("const uint16_t splash_logo[] PROGMEM = {\n")
|
||||
for i, p in enumerate(pixels):
|
||||
if i % 12 == 0:
|
||||
f.write(" ")
|
||||
f.write(f"0x{p:04X},")
|
||||
if i % 12 == 11:
|
||||
f.write("\n")
|
||||
f.write("\n};\n")
|
||||
|
||||
print(f"Done — {len(pixels)} pixels, {len(pixels)*2/1024:.1f} KB")
|
||||
print(f"Output: {OUTPUT}")
|
||||
Reference in New Issue
Block a user