add all crates: domain, protocol, application, client, adapters, ESP32 firmware

Server: domain (entities, value objects, ports), protocol (postcard wire types),
application (config service, data projection), adapters (config-memory, tcp-server),
bootstrap (composition root with fake data).

Client: client-domain (layout engine, render tree, HAL ports),
client-application (message handling, repaint commands),
adapters (tcp-client, display-terminal), client-desktop (end-to-end working).

ESP32: client-esp32 firmware with ILI9341 display over SPI, WiFi networking.
Display test verified on hardware — landscape orientation, text rendering works.

60 workspace tests, all passing.
This commit is contained in:
2026-06-18 21:43:59 +02:00
parent 6ad76b98a2
commit 557cceb498
83 changed files with 5844 additions and 1 deletions

View File

@@ -0,0 +1,13 @@
[build]
target = "xtensa-esp32-espidf"
[target.xtensa-esp32-espidf]
linker = "ldproxy"
runner = "espflash flash --monitor"
[unstable]
build-std = ["std", "panic_abort"]
[env]
MCU = "esp32"
ESP_IDF_VERSION = "v5.4"

2
crates/client-esp32/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
target/
.embuild/

1820
crates/client-esp32/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
[package]
name = "client-esp32"
version = "0.1.0"
edition = "2021"
[dependencies]
domain = { path = "../domain" }
protocol = { path = "../protocol" }
client-domain = { path = "../client-domain" }
client-application = { path = "../client-application" }
esp-idf-hal = "0.46"
esp-idf-svc = { version = "0.52", features = ["experimental"] }
esp-idf-sys = "0.37"
mipidsi = "0.10"
embedded-graphics = "0.8"
embedded-text = "0.7"
embedded-hal-bus = "0.3"
serde = { version = "1.0", default-features = false, features = [
"derive",
"alloc",
] }
postcard = { version = "1.1", default-features = false, features = ["alloc"] }
log = "0.4"
[build-dependencies]
embuild = "0.33"

View File

@@ -0,0 +1,3 @@
fn main() {
embuild::espidf::sysenv::output();
}

View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "esp"

View File

@@ -0,0 +1,16 @@
# K-Frame ESP32 firmware config
# WiFi
CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=10
CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=32
# Task stack sizes
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=8192
# SPI
CONFIG_SPI_MASTER_IN_IRAM=y
# Use single large app partition (no OTA)
CONFIG_PARTITION_TABLE_SINGLE_APP_LARGE=y

View File

@@ -0,0 +1,124 @@
use client_domain::{BoundingBox, DisplayPort};
use embedded_graphics::{
mono_font::{ascii::FONT_6X10, ascii::FONT_10X20, MonoTextStyle},
pixelcolor::Rgb565,
prelude::*,
primitives::{PrimitiveStyle, Rectangle},
text::Text,
};
use embedded_text::{TextBox, style::TextBoxStyleBuilder};
#[derive(Debug)]
pub enum DisplayError {
Draw(String),
}
impl std::fmt::Display for DisplayError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DisplayError::Draw(e) => write!(f, "draw: {e}"),
}
}
}
pub struct Esp32DisplayAdapter {
inner: Box<dyn ErasedDisplay>,
}
trait ErasedDisplay {
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), DisplayError>;
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), DisplayError>;
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), DisplayError>;
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), DisplayError>;
fn flush(&mut self) -> Result<(), DisplayError>;
}
impl<D> ErasedDisplay for D
where
D: DrawTarget<Color = Rgb565>,
D::Error: std::fmt::Debug,
{
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), DisplayError> {
Rectangle::new(
Point::new(bounds.x as i32, bounds.y as i32),
Size::new(bounds.width as u32, bounds.height as u32),
)
.into_styled(PrimitiveStyle::with_fill(Rgb565::BLACK))
.draw(self)
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
Ok(())
}
fn draw_text(&mut self, text: &str, _x: u16, _y: u16, bounds: BoundingBox) -> Result<(), DisplayError> {
let style = MonoTextStyle::new(&FONT_6X10, Rgb565::WHITE);
let textbox_style = TextBoxStyleBuilder::new().build();
let rect = Rectangle::new(
Point::new(bounds.x as i32, bounds.y as i32),
Size::new(bounds.width as u32, bounds.height as u32),
);
TextBox::with_textbox_style(text, rect, style, textbox_style)
.draw(self)
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
Ok(())
}
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), DisplayError> {
let style = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE);
let icon_char = match icon {
"sunny" | "clear" => "*",
"cloud_rain" | "rain" => "~",
"cloud" | "cloudy" => "=",
"dollar" | "money" => "$",
"music" | "note" => "#",
_ => "?",
};
Text::new(icon_char, Point::new(x as i32, y as i32 + 20), style)
.draw(self)
.map_err(|e| DisplayError::Draw(format!("{e:?}")))?;
Ok(())
}
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), DisplayError> {
self.clear_region(bounds)
}
fn flush(&mut self) -> Result<(), DisplayError> {
Ok(())
}
}
impl Esp32DisplayAdapter {
pub fn new<D>(display: D) -> Self
where
D: DrawTarget<Color = Rgb565> + 'static,
D::Error: std::fmt::Debug,
{
Self {
inner: Box::new(display),
}
}
}
impl DisplayPort for Esp32DisplayAdapter {
type Error = DisplayError;
fn clear_region(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
self.inner.clear_region(bounds)
}
fn draw_text(&mut self, text: &str, x: u16, y: u16, bounds: BoundingBox) -> Result<(), Self::Error> {
self.inner.draw_text(text, x, y, bounds)
}
fn draw_icon(&mut self, icon: &str, x: u16, y: u16) -> Result<(), Self::Error> {
self.inner.draw_icon(icon, x, y)
}
fn fill_background(&mut self, bounds: BoundingBox) -> Result<(), Self::Error> {
self.inner.fill_background(bounds)
}
fn flush(&mut self) -> Result<(), Self::Error> {
self.inner.flush()
}
}

View File

@@ -0,0 +1,2 @@
pub mod display;
pub mod network;

View File

@@ -0,0 +1,85 @@
use std::io::{Read, Write};
use std::net::TcpStream;
use client_domain::NetworkPort;
use protocol::MAX_FRAME_SIZE;
use crate::config::NET_READ_TIMEOUT;
use log::info;
#[derive(Debug)]
pub enum NetworkError {
Io(std::io::Error),
NotConnected,
FrameTooLarge(usize),
}
impl std::fmt::Display for NetworkError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
NetworkError::Io(e) => write!(f, "io: {e}"),
NetworkError::NotConnected => write!(f, "not connected"),
NetworkError::FrameTooLarge(n) => write!(f, "frame too large: {n}"),
}
}
}
pub struct Esp32Network {
stream: Option<TcpStream>,
}
impl Esp32Network {
pub fn new() -> Self {
Self { stream: None }
}
}
impl NetworkPort for Esp32Network {
type Error = NetworkError;
fn connect(&mut self, addr: &str) -> Result<(), Self::Error> {
info!("TCP connecting to {addr}...");
let stream = TcpStream::connect(addr).map_err(NetworkError::Io)?;
stream.set_nonblocking(true).map_err(NetworkError::Io)?;
stream.set_read_timeout(Some(NET_READ_TIMEOUT)).map_err(NetworkError::Io)?;
self.stream = Some(stream);
info!("TCP connected");
Ok(())
}
fn disconnect(&mut self) -> Result<(), Self::Error> {
self.stream = None;
Ok(())
}
fn send(&mut self, data: &[u8]) -> Result<(), Self::Error> {
let stream = self.stream.as_mut().ok_or(NetworkError::NotConnected)?;
stream.write_all(data).map_err(NetworkError::Io)
}
fn receive(&mut self) -> Result<Option<Vec<u8>>, Self::Error> {
let stream = self.stream.as_mut().ok_or(NetworkError::NotConnected)?;
let mut len_buf = [0u8; 4];
match stream.read_exact(&mut len_buf) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => return Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => return Ok(None),
Err(e) => return Err(NetworkError::Io(e)),
}
let len = u32::from_be_bytes(len_buf) as usize;
if len > MAX_FRAME_SIZE {
return Err(NetworkError::FrameTooLarge(len));
}
let mut payload = vec![0u8; len];
stream.set_nonblocking(false).map_err(NetworkError::Io)?;
stream.read_exact(&mut payload).map_err(NetworkError::Io)?;
stream.set_nonblocking(true).map_err(NetworkError::Io)?;
Ok(Some(payload))
}
fn is_connected(&self) -> bool {
self.stream.is_some()
}
}

View File

@@ -0,0 +1,22 @@
use std::time::Duration;
use esp_idf_hal::units::Hertz;
use client_domain::BoundingBox;
pub const SCREEN_WIDTH: u16 = 320;
pub const SCREEN_HEIGHT: u16 = 240;
pub const SCREEN: BoundingBox = BoundingBox {
x: 0,
y: 0,
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT,
};
pub const SPI_BAUDRATE: Hertz = Hertz(26_000_000);
pub const SPI_BUFFER_SIZE: usize = 512;
pub const NET_THREAD_STACK_SIZE: usize = 8192;
pub const NET_READ_TIMEOUT: Duration = Duration::from_millis(10);
pub const NET_POLL_INTERVAL: Duration = Duration::from_millis(50);
pub const NET_RECONNECT_DELAY: Duration = Duration::from_secs(2);
pub const RENDER_POLL_INTERVAL: Duration = Duration::from_millis(100);

View File

@@ -0,0 +1,58 @@
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 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::adapters::display::Esp32DisplayAdapter;
pub struct DisplayHardware<'d> {
pub spi: SPI2<'d>,
pub sclk: AnyOutputPin<'d>,
pub mosi: AnyOutputPin<'d>,
pub cs: AnyOutputPin<'d>,
pub dc: AnyOutputPin<'d>,
pub rst: AnyOutputPin<'d>,
}
pub fn init(hw: DisplayHardware<'static>) -> Esp32DisplayAdapter {
let spi_driver = SpiDriver::new(
hw.spi, hw.sclk, hw.mosi,
None::<AnyIOPin>,
&SpiDriverConfig::new(),
).expect("SPI driver init failed");
let spi_config = SpiConfig::new().baudrate(SPI_BAUDRATE);
let spi_device = SpiDeviceDriver::new(spi_driver, Some(hw.cs), &spi_config)
.expect("SPI device init failed");
let dc_pin = PinDriver::output(hw.dc).expect("DC pin failed");
let mut rst_pin = PinDriver::output(hw.rst).expect("RST pin failed");
info!("Hardware reset...");
rst_pin.set_high().unwrap();
Ets::delay_ms(10);
rst_pin.set_low().unwrap();
Ets::delay_ms(10);
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 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)
.orientation(Orientation { rotation: Rotation::Deg90, mirrored: true })
.init(&mut Delay::new_default())
.expect("Display init failed");
raw_display.clear(Rgb565::BLACK).unwrap();
Esp32DisplayAdapter::new(raw_display)
}

View File

@@ -0,0 +1,2 @@
pub mod display;
pub mod wifi;

View File

@@ -0,0 +1,38 @@
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;
pub fn init<'d>(
modem: Modem<'d>,
sysloop: EspSystemEventLoop,
nvs: EspDefaultNvsPartition,
ssid: &str,
password: &str,
) -> Result<BlockingWifi<EspWifi<'d>>, String> {
let esp_wifi = EspWifi::new(modem, sysloop.clone(), Some(nvs))
.map_err(|e| format!("wifi new: {e:?}"))?;
let mut wifi = BlockingWifi::wrap(esp_wifi, sysloop)
.map_err(|e| format!("wifi wrap: {e:?}"))?;
let config = Configuration::Client(ClientConfiguration {
ssid: ssid.try_into().unwrap(),
password: password.try_into().unwrap(),
auth_method: AuthMethod::WPA2Personal,
..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 connected");
Ok(wifi)
}

View File

@@ -0,0 +1,45 @@
mod adapters;
mod config;
mod hal;
mod tasks;
use client_domain::{BoundingBox, DisplayPort};
use esp_idf_hal::peripherals::Peripherals;
use log::info;
fn main() {
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
info!("=== K-Frame ESP32 ===");
let peripherals = Peripherals::take().unwrap();
let mut display = hal::display::init(hal::display::DisplayHardware {
spi: peripherals.spi2,
sclk: peripherals.pins.gpio18.into(),
mosi: peripherals.pins.gpio23.into(),
cs: peripherals.pins.gpio26.into(),
dc: peripherals.pins.gpio21.into(),
rst: peripherals.pins.gpio22.into(),
});
info!("Display initialized");
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!("Display test complete — looping forever");
loop {
std::thread::sleep(std::time::Duration::from_secs(1));
}
}

View File

@@ -0,0 +1,2 @@
pub mod network;
pub mod render;

View File

@@ -0,0 +1,50 @@
use std::sync::mpsc;
use std::thread;
use client_domain::NetworkPort;
use protocol::{ServerMessage, decode_server_message};
use crate::config::{NET_THREAD_STACK_SIZE, NET_POLL_INTERVAL, NET_RECONNECT_DELAY};
use crate::adapters::network::Esp32Network;
use log::*;
pub fn spawn(server_addr: String, tx: mpsc::Sender<ServerMessage>) {
thread::Builder::new()
.stack_size(NET_THREAD_STACK_SIZE)
.name("net".into())
.spawn(move || run(server_addr, tx))
.expect("failed to spawn network thread");
}
fn run(server_addr: String, tx: mpsc::Sender<ServerMessage>) {
let mut net = Esp32Network::new();
loop {
if !net.is_connected() {
info!("Connecting to server {server_addr}...");
match net.connect(&server_addr) {
Ok(()) => info!("Server connected"),
Err(e) => {
error!("Connection failed: {e}, retrying...");
thread::sleep(NET_RECONNECT_DELAY);
continue;
}
}
}
match net.receive() {
Ok(Some(payload)) => {
match decode_server_message(&payload) {
Ok(msg) => { let _ = tx.send(msg); }
Err(e) => error!("Decode error: {e}"),
}
}
Ok(None) => {
thread::sleep(NET_POLL_INTERVAL);
}
Err(e) => {
error!("Receive error: {e}, reconnecting...");
let _ = net.disconnect();
thread::sleep(NET_RECONNECT_DELAY);
}
}
}
}

View File

@@ -0,0 +1,46 @@
use std::sync::mpsc;
use client_domain::{BoundingBox, DisplayPort};
use client_application::ClientApp;
use protocol::ServerMessage;
use crate::config::RENDER_POLL_INTERVAL;
use crate::adapters::display::Esp32DisplayAdapter;
use log::*;
pub fn run(
screen: BoundingBox,
mut display: Esp32DisplayAdapter,
rx: mpsc::Receiver<ServerMessage>,
) {
let mut app = ClientApp::new(screen);
info!("Render loop started");
loop {
match rx.recv_timeout(RENDER_POLL_INTERVAL) {
Ok(msg) => {
let repaints = app.handle_message(msg);
for cmd in &repaints {
display.clear_region(cmd.bounds).unwrap();
for kv in &cmd.state.data {
if let protocol::WireValue::String(s) = &kv.value {
display.draw_text(
&format!("{}: {s}", kv.key),
cmd.bounds.x,
cmd.bounds.y,
cmd.bounds,
).unwrap();
}
}
}
if !repaints.is_empty() {
display.flush().unwrap();
}
}
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(mpsc::RecvTimeoutError::Disconnected) => {
error!("Network thread died");
break;
}
}
}
}