diff --git a/Cargo.lock b/Cargo.lock index 234e4d4..86b5e34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "anyhow" version = "1.0.95" @@ -59,6 +65,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cfg-if" version = "1.0.0" @@ -80,6 +92,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8a42181e0652c2997ae4d217f25b63c5337a52fd2279736e97b832fa0a3cff" +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -604,6 +625,16 @@ dependencies = [ "vcell", ] +[[package]] +name = "flate2" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -694,6 +725,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "include_file_compress" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6823f4ab0232f685fd0320c7210f39a5264bae4b223e45c1910e04d795df75" +dependencies = [ + "cast", + "flate2", + "quote", + "syn", + "thiserror", +] + [[package]] name = "indexmap" version = "2.7.0" @@ -755,6 +799,7 @@ version = "0.26.0" dependencies = [ "itoa", "maud_macros", + "picoserve", ] [[package]] @@ -784,6 +829,21 @@ dependencies = [ "serde", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8402cab7aefae129c6977bb0ff1b8fd9a04eb5b51efc50a70bea51cda0c7924" +dependencies = [ + "adler2", +] + [[package]] name = "mutex-trait" version = "0.2.0" @@ -805,6 +865,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -845,6 +915,7 @@ dependencies = [ "futures-util", "heapless", "lhash", + "log", "ryu", "serde", "serde-json-core", @@ -1092,6 +1163,16 @@ dependencies = [ "managed", ] +[[package]] +name = "sntpc" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2927eedca9d3b301b1eb88b81a2e415666ec5a84eb24f1c2129c08fc98ff18be" +dependencies = [ + "cfg-if", + "embassy-net", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -1155,6 +1236,26 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.8.19" @@ -1220,9 +1321,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" [[package]] -name = "wallas-embassy" +name = "wallas-esp32c3" version = "0.1.0" dependencies = [ + "chrono", "critical-section", "embassy-executor", "embassy-net", @@ -1237,11 +1339,15 @@ dependencies = [ "esp-println", "esp-wifi", "heapless", + "include_file_compress", "log", "maud", + "nom", "picoserve", "rand_core", + "serde", "smoltcp 0.11.0", + "sntpc", "static_cell", ] diff --git a/Cargo.toml b/Cargo.toml index 3623531..192debc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ esp-alloc = { version = "0.5.0" } embedded-io = "0.6.1" embedded-io-async = "0.6.1" -embassy-net = { version = "0.5.0", features = [ "tcp", "udp", "dhcpv4", "medium-ethernet"] } +embassy-net = { version = "0.5.0", features = [ "tcp", "udp", "dhcpv4", "dns", "medium-ethernet"] } esp-wifi = { version = "0.11.0", default-features=false, features = [ "esp32c3", @@ -31,6 +31,7 @@ esp-wifi = { version = "0.11.0", default-features=false, features = [ ] } embassy-sync = "0.6.1" rand_core = "0.6.4" +nom = { version = "7.1.3", default-features = false, features = [ "alloc" ] } heapless = { version = "0.8.0", default-features = false } smoltcp = { version = "0.11.0", default-features = false, features = [ "medium-ethernet", @@ -44,16 +45,22 @@ smoltcp = { version = "0.11.0", default-features = false, features = [ "socket-udp", ] } embassy-executor = { version = "0.6.0", features = [ - "task-arena-size-40960" + "task-arena-size-163840" ] } embassy-time = { version = "0.3.1", features = ["generic-queue-8"] } esp-hal-embassy = { version = "0.5.0", features = ["esp32c3"] } static_cell = { version = "2.1.0", features = ["nightly"] } critical-section = "1.2.0" -maud = { path = "/home/jda/src/rust/maud/target/package/maud-0.26.0", features = ["alloc"] } +maud = { path = "/home/jda/src/rust/maud/target/package/maud-0.26.0", features = ["alloc", "picoserve"] } picoserve = { version = "0.13.3", default-features = false, features = [ - "embassy" + "alloc", + "embassy", + "log", ] } +sntpc = { version = "0.5.1", default-features = false, features = [ "embassy-socket" ] } +chrono = { version = "0.4.39", default-features = false, features = [ "alloc" ] } +serde = { version = "1.0.217", default-features = false } +include_file_compress = "0.1.3" [profile.dev] # Rust debug is too slow. diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..f05297f --- /dev/null +++ b/src/database.rs @@ -0,0 +1,159 @@ + + +use embassy_sync::blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex, RawMutex}; +use embassy_sync::blocking_mutex::Mutex; +use embassy_time::Instant; +use alloc::vec::Vec; +use core::cell::RefCell; +use crate::serial; + +pub struct DataPoint { + pub instant: Instant, + t1: i8, + t2: i8, + pub target: i8, + pub current: i8, +} + +impl Copy for DataPoint { } + +impl Clone for DataPoint { + fn clone(&self) -> Self { + *self + } +} + +impl DataPoint { + pub const fn default() -> Self { + DataPoint { instant: Instant::from_ticks(0), t1: 0, t2: 0, target: 0, current: 0 } + } +} + +struct RingBuffer { + buf: [T; CAP], + index: usize, + has_wrapped: bool, +} + +impl RingBuffer { + pub const fn new(def: T) -> Self { + Self { + buf: [def; CAP], index: 0, has_wrapped: false + } + } + + pub fn size(&self) -> usize { + if self.has_wrapped { + CAP + } else { self.index } + } + + /** + * Get latest pushed item (youngest item) + */ + pub fn get_latest(&self) -> Option { + if self.index == 0 && self.has_wrapped { + Some(self.buf[CAP-1]) + } else if self.index == 0 { + None + } else { + Some(self.buf[self.index-1]) + } + } + + /** + * Get oldest item in the ringbuffer + */ + pub fn get_first(&self) -> Option { + if self.has_wrapped { + Some(self.buf[self.index]) + } else if self.index > 0 { + Some(self.buf[0]) + } else { + None + } + } + + /** + * Get all items in the buffer, oldest at index 0, + * youngest at index CAP. + */ + pub fn get_all(&self) -> Vec { + let size = self.size(); + let mut res = Vec::::with_capacity(size); + let mut i = if self.has_wrapped { self.index } else { 0 }; + + for _ in 0..size { + res.push(self.buf[i]); + i = i + 1; + if i >= CAP { + i = 0; + } + } + + res + } + + /** + * Push an item to the buffer + */ + pub fn push(&mut self, val: T) { + self.buf[self.index] = val; + self.index = self.index + 1; + if self.index >= CAP { + self.has_wrapped = true; + self.index = 0; + } + } +} + +pub struct MutexRingBuffer { + inner: Mutex>>, +} + +impl MutexRingBuffer { + pub const fn new(def: T) -> Self { + Self { + inner: Mutex::new(RefCell::new(RingBuffer::new(def))), + } + } + + pub fn push(&self, val: T) { + self.inner.lock(|rc| { + let mut rb = rc.borrow_mut(); + rb.push(val); + }); + } + + pub fn get_all(&self) -> Vec { + self.inner.lock(|rc| { + let rb = rc.borrow(); + rb.get_all() + }) + } + + pub fn get_latest(&self) -> Option { + self.inner.lock(|rc| { + let rb = rc.borrow(); + rb.get_latest() + }) + } +} + +pub static DATAPOINT_BUFFER: MutexRingBuffer = MutexRingBuffer::new(DataPoint::default()); + +pub fn database_spawn(spawner: embassy_executor::Spawner) { + let subscriber = serial::DOMAIN_MESSAGE_CHANNEL.subscriber().unwrap(); + spawner.spawn(data_subscriber(subscriber)).ok(); +} + +#[embassy_executor::task] +async fn data_subscriber (mut subscriber: serial::DomainMessageSubscriber<'static>) { + loop { + let data = subscriber.next_message_pure().await; + if let serial::DomainMessage::WallasData(i, t1, t2, target, current) = data { + DATAPOINT_BUFFER.push(DataPoint{instant: i, t1: t1, t2: t2, target: target, current: current}); + } + } +} + diff --git a/src/httpd.rs b/src/httpd.rs index fac1c78..d77e45c 100644 --- a/src/httpd.rs +++ b/src/httpd.rs @@ -1,9 +1,16 @@ -use picoserve::{make_static, routing::get, AppBuilder, AppRouter, Router}; -use picoserve::routing::PathRouter; -use embassy_time::Duration; +use picoserve::routing::{get, get_service}; +use picoserve::response::{IntoResponse, File, Json}; +use embassy_time::{Duration, Instant}; use embassy_net::Stack; +use maud::{DOCTYPE, html, Markup}; +use alloc::vec::Vec; +use crate::sntp_client::get_now; +use crate::sntp_client::get_instant; +use serde::{Serialize, Serializer}; +use serde::ser::SerializeSeq; +use include_file_compress::include_file_compress_deflate; -use static_cell::StaticCell; +use crate::database::DATAPOINT_BUFFER; static PICO_CONFIG : picoserve::Config = picoserve::Config::new( picoserve::Timeouts { @@ -12,32 +19,44 @@ static PICO_CONFIG : picoserve::Config = picoserve::Config::new( write: Some(Duration::from_secs(1)), }).keep_connection_alive(); -/** -struct AppProps; - -impl AppBuilder for AppProps { - type PathRouter = impl picoserve::routing::PathRouter; - - fn build_app(self) -> picoserve::Router { - picoserve::Router::new().route("/", get(|| async move { "Hello World" })) - } -} -*/ pub fn httpd_spawn(spawner: embassy_executor::Spawner, size: usize, stack: Stack<'static>) -> Result<(), super::Error> { - for i in 0..size { + for i in 0..size { spawner.must_spawn(web_task(i, stack)); } Ok(()) } +static DEFLATE_CACHEABLE_CONTENT_HEADERS: &'static [(&'static str, &'static str); 2] = &[("Content-Encoding", "deflate"), ("Cache-Control", "public, s-maxage=28800, max-age=28800")]; +static CACHEABLE_IMAGE_HEADERS: &'static [(&'static str, &'static str); 1] = &[DEFLATE_CACHEABLE_CONTENT_HEADERS[1]]; +static PNG_CONTENT_TYPE: &'static str = "image/png"; +static CSS_CONTENT_TYPE: &'static str = "text/css"; +static JS_CONTENT_TYPE: &'static str = "text/javascript"; + #[embassy_executor::task(pool_size = super::MAX_CONCURRENT_SOCKETS)] async fn web_task( id: usize, stack: Stack<'static>, ) -> ! { + let api_router = picoserve::Router::new() + .route("/latest", get(|| async { latest() })) + .route("/allreadings", get(|| async { allreadings() })); + let image_router = picoserve::Router::new() + .route("/risc-v-logo.png", get_service(File::with_content_type_and_headers(&PNG_CONTENT_TYPE, + include_bytes!("static/risc-v-logo.png"), CACHEABLE_IMAGE_HEADERS))) + .route("/espressif-logo.png", get_service(File::with_content_type_and_headers(&PNG_CONTENT_TYPE, + include_bytes!("static/espressif-logo.png"), CACHEABLE_IMAGE_HEADERS))) + .route("/wallas-logo.png", get_service(File::with_content_type_and_headers(&PNG_CONTENT_TYPE, + include_bytes!("static/wallas-logo.png"), CACHEABLE_IMAGE_HEADERS))); let app = - picoserve::Router::new().route("/", get(|| async { "Hello World" })).route("/test", get(|| async { "Test" })); + picoserve::Router::new() + .nest("/api/v1", api_router) + .route("/", get(|| async { index() })) + .route("/styles.css", get_service(File::with_content_type_and_headers(&CSS_CONTENT_TYPE, + include_file_compress_deflate!("src/static/styles.css", 5), DEFLATE_CACHEABLE_CONTENT_HEADERS))) + .route("/app.js", get_service(File::with_content_type_and_headers(&JS_CONTENT_TYPE, + include_file_compress_deflate!("src/static/app.js", 5), DEFLATE_CACHEABLE_CONTENT_HEADERS))) + .nest("/images", image_router); let port = 80; let mut tcp_rx_buffer = [0; 1024]; @@ -56,3 +75,123 @@ async fn web_task( ) .await } + +fn page(heading: &str, content: Markup) -> Markup { + html! { + (DOCTYPE) + html { + head { + link rel="stylesheet" type="text/css" href="/styles.css"; + script src="app.js" {}; + title { (heading) } + } + body { + h1 { (heading) } + (content) + div #footer { + a href="https://riscv.org/" { + img src="/images/risc-v-logo.png"; + }; + a href="https://wallas.fi/" { + img src="/images/wallas-logo.png"; + }; + a href="https://www.espressif.com/" { + img src="/images/espressif-logo.png"; + }; + br; + "RISC-V is a registered trademark of RISC-V International · Wallas is a registered trademark of Wallas-Marin Oy · Espressif is a registered trademark of Espressif Systems" + } + } + + } + } +} + +#[derive(Serialize, Clone)] +struct TemperatureReading { + #[serde(serialize_with = "instant_to_string")] + time: Instant, + target: i8, + temperature: i8, +} + +fn instant_to_string(val: &Instant, s: S) -> Result +where + S: Serializer +{ + s.serialize_str(get_instant(val).as_str()) +} + +fn option_instant_to_string(oval: &Option, s: S) -> Result +where + S: Serializer +{ + match oval { + Some(val) => s.serialize_str(get_instant(val).as_str()), + None => s.serialize_str("none"), + } +} + + +#[derive(Serialize)] +#[serde(transparent)] +struct AllTemperatureReadings { + #[serde(serialize_with = "vec_temperature_reading")] + v: Vec, +} + +/** + * Implement serialization as alloc::vec::Vec does not implement 'Serialize' + */ +fn vec_temperature_reading(v: &Vec, s: S) -> Result +where + S: Serializer +{ + let mut seq = s.serialize_seq(Some(v.len())).unwrap(); + for e in v { + seq.serialize_element(e)? + } + seq.end() +} + +fn allreadings() -> impl IntoResponse { + Json(AllTemperatureReadings { + v: DATAPOINT_BUFFER.get_all().iter() + .map(|d| TemperatureReading { time: d.instant, target: d.target, temperature: d.current }) + .collect::>() }) +} + +#[derive(Serialize)] +struct LatestResponse { + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "option_instant_to_string")] + time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, +} + +impl LatestResponse { + pub fn none () -> Self { + LatestResponse { time: None, target: None, temperature: None } + } + + pub fn some (time: Instant, target: i8, temperature: i8) -> Self { + LatestResponse { time: Some(time), target: Some(target), temperature: Some(temperature) } + } +} + +fn latest() -> impl IntoResponse { + match DATAPOINT_BUFFER.get_latest() { + None => Json(LatestResponse::none()), + Some(datapoint) => Json(LatestResponse::some(datapoint.instant, datapoint.target, datapoint.current)) + } +} + +fn index() -> impl IntoResponse { + page("Wallas 22GB Wifi Extension", html! { + p .intro { "Beta version of ESP32C3 based Wifi extension to the Wallas 361062 Control Panel for the DT/GB Heaters" } + p #latest { "Waiting for latest reading ..." } + // p { "Time is now " (get_now()) } + }) +} diff --git a/src/main.rs b/src/main.rs index 2eb30e3..50ae80f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,7 +17,7 @@ use esp_hal::timer::systimer::SystemTimer; use esp_hal_embassy::init as initialize_embassy; use esp_hal::timer::systimer::Target; use embassy_executor::Spawner; -use embassy_time::{Duration, Timer}; +// use embassy_time::{Duration, Timer}; use esp_hal::timer::timg::TimerGroup; /** @@ -31,7 +31,7 @@ use self::random::RngWrapper; */ mod wifi; use self::wifi::connect as connect_to_wifi; -use self::wifi::Error as WifiError; +// use self::wifi::Error as WifiError; /** * httpd @@ -45,6 +45,20 @@ use httpd::httpd_spawn; mod serial; use serial::serial_spawn; +/** + * sntp client + */ +mod sntp_client; +use sntp_client::sntp_client_spawn; +pub use sntp_client::get_now; + +/** + * database + */ +mod database; +use database::database_spawn; + + /// SSID for WiFi network const WIFI_SSID: &str = env!("WIFI_SSID"); @@ -54,7 +68,12 @@ const WIFI_PASSWORD: &str = env!("WIFI_PASSWORD"); /// Size of heap for dynamically-allocated memory const HEAP_MEMORY_SIZE: usize = 72 * 1024; -const MAX_CONCURRENT_SOCKETS: usize = 5; +const HTTPD_SOCKETS: usize = 8; +const DHCP_SOCKETS: usize = 1; +const SNTP_SOCKETS: usize = 1; +const DNS_SOCKETS: usize = 1; +const MAX_CONCURRENT_SOCKETS: usize = HTTPD_SOCKETS + DHCP_SOCKETS + DNS_SOCKETS + SNTP_SOCKETS; + #[main] async fn main(spawner: Spawner) { @@ -92,10 +111,14 @@ async fn main_fallible( let stack = connect_to_wifi(spawner, TimerGroup::new(peripherals.TIMG0), rng, peripherals.WIFI, peripherals.RADIO_CLK, (ssid, password)).await.unwrap(); - httpd_spawn(spawner, MAX_CONCURRENT_SOCKETS-1, stack); + let _ = httpd_spawn(spawner, HTTPD_SOCKETS, stack); + + let _ = sntp_client_spawn(spawner, stack); serial_spawn(spawner, peripherals.UART0.into(), peripherals.GPIO20.into(), peripherals.GPIO21.into()); + let _ = database_spawn(spawner); + info!("firmware done booting"); // we got here - all is fine diff --git a/src/serial.rs b/src/serial.rs index 7665a8e..343da49 100644 --- a/src/serial.rs +++ b/src/serial.rs @@ -1,36 +1,221 @@ use esp_hal::{ // clock::ClockControl, - peripherals::{Peripherals}, - prelude::*, - uart::{AtCmdConfig, AnyUart, Uart, UartRx, UartTx, Config}, + // peripherals::{Peripherals}, + uart::{AnyUart, Uart, UartRx, UartTx, Config}, gpio::AnyPin, Async, }; -use log::{info, error}; +use log::{info, error, debug}; -const BUFFER_SIZE: usize = 64; +// Channel stuff +// use embassy_sync::channel::Channel; +use embassy_sync::pubsub::{PubSubChannel, Subscriber}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_time::{Instant, Timer}; +use heapless::Vec; + +const BUFFER_SIZE: usize = 256; +type BaseMessage = heapless::String; + +#[derive(Debug, Clone)] +pub enum DomainMessage { + AtOk, + WallasData(Instant, i8, i8, i8, i8), +} + +#[derive(Debug, Clone)] +pub enum DomainCommand { + Start, + Stop, + Ventilate, + Temperature(i8), +} + +type DomainMessageChannel = PubSubChannel; +pub type DomainMessageSubscriber<'a> = Subscriber<'a, CriticalSectionRawMutex, DomainMessage, 3, 1, 1>; + +pub static DOMAIN_MESSAGE_CHANNEL: DomainMessageChannel = DomainMessageChannel::new(); + +type DomainCommandChannel = PubSubChannel; + +static DOMAIN_COMMAND_CHANNEL: DomainCommandChannel = DomainCommandChannel::new(); pub fn serial_spawn(spawner: embassy_executor::Spawner, peri_uart: AnyUart, rx_pin: AnyPin, tx_pin: AnyPin) { // Initialize and configure UART0 let config = Config::default().rx_fifo_full_threshold(BUFFER_SIZE as u16); let my_uart = Uart::new_with_config(peri_uart, config, rx_pin, tx_pin).unwrap().into_async(); // Split UART0 to create seperate Tx and Rx handles - let (rx, _tx) = my_uart.split(); + let (rx, tx) = my_uart.split(); spawner.spawn(reader(rx)).ok(); - //spawner.spawn(writer(tx)).ok(); + spawner.spawn(writer(tx)).ok(); + spawner.spawn(fakedata()).ok(); +} + +#[embassy_executor::task] +async fn fakedata() { + let domain_publisher = DOMAIN_MESSAGE_CHANNEL.publisher().unwrap(); + let mut target: i8 = 22; + let mut current: i8 = 10; + let mut direction: i8 = 1; + loop { + Timer::after_secs(15).await; + domain_publisher.publish_immediate(DomainMessage::WallasData(Instant::now(), 0, 0, target, current)); + current = current + direction; + if current >= 22 || current <= 5 { + direction = direction * -1; + if target == 22 { + target = 5; + } else { + target = 22; + } + } + } +} + + +fn at_ok_parser (i: &str) -> Option<()> { + match nom::sequence::tuple(( + nom::bytes::complete::tag("AT+OK"), + nom::character::complete::crlf::<&str, nom::error::Error<&str>> + ))(i) { + Ok((_residual, (_, _))) => { + Some(()) + } + Err(_e) => { + None + } + } +} + +fn at_wallas_parser (i: &str) -> Option<(i8, i8, i8, i8)> { + match nom::sequence::tuple(( + nom::bytes::complete::tag("AT+WALLAS="), + nom::character::complete::i8, + nom::character::complete::char(','), + nom::character::complete::i8, + nom::character::complete::char(','), + nom::character::complete::i8, + nom::character::complete::char(','), + nom::character::complete::i8, + nom::character::complete::crlf::<&str, nom::error::Error<&str>> + ))(i) { + Ok((_residual, (_, t1, _, t2, _, t3, _, t4, _))) => { + Some((t1, t2, t3, t4)) + } + Err(_e) => { + None + } + } +} + +fn serial_receive(msg: &str) { + let domain_publisher = DOMAIN_MESSAGE_CHANNEL.publisher().unwrap(); + loop { + if let Some(()) = at_ok_parser(&msg) { + domain_publisher.publish_immediate(DomainMessage::AtOk); + } else if let Some((t1, t2, t3, t4)) = at_wallas_parser(&msg) { + domain_publisher.publish_immediate(DomainMessage::WallasData(Instant::now(), t1, t2, t3, t4)); + } else { + error!("msg received but unmatched '{}'", &msg) + } + } +} + +#[embassy_executor::task] +async fn writer(mut tx: UartTx<'static, Async>) { + let mut wbuf: [u8; 32] = [0u8; 32]; + let mut domain_subscriber = DOMAIN_COMMAND_CHANNEL.subscriber().unwrap(); + loop { + let cmd = domain_subscriber.next_message_pure().await; + let _ = embedded_io_async::Write::write(&mut tx, + match &cmd { + DomainCommand::Start => b"START\r\n", + DomainCommand::Stop => b"STOP\r\n", + DomainCommand::Ventilate => b"VENT\r\n", + DomainCommand::Temperature(temp) => { + let msg = alloc::format!("TEMP={}\r\n", temp); + wbuf.copy_from_slice(msg.as_bytes()); + &wbuf[0..msg.len()] + } + } + ).await; + } } #[embassy_executor::task] async fn reader(mut rx: UartRx<'static, Async>) { let mut rbuf: [u8; BUFFER_SIZE] = [0u8; BUFFER_SIZE]; loop { - let r = embedded_io_async::Read::read(&mut rx, &mut rbuf[0..]).await; - match r { - Ok(len) => { + let mut offset: usize = 0; + let mut eaten: usize = 0; + let mut msg : Option = None; + loop { + if let Some(base_msg) = msg { + //BASE_CHANNEL.send(base_msg).await; + serial_receive(&base_msg); + msg = None; + } + if eaten != 0 { + for n in 0..offset { + rbuf[n] = rbuf[n+eaten]; + } + eaten = 0; + } + let r = embedded_io_async::Read::read(&mut rx, &mut rbuf[offset..]).await; + match r { + Ok(len) => { + let new_offset = len + offset; + // send_line will send two numbers: new offset (after eaten bytes have been + // cleared) - and 'eaten' how many bytes have been consumed + let send_line = |start: usize, end: usize| -> (usize, usize, Option) { + // send rbuf[start, end] somewhere + let msg : BaseMessage = BaseMessage::from_utf8(Vec::from_slice(&rbuf[start..end]).unwrap()).unwrap(); + if end == new_offset { + (0, 0, Some(msg)) + } else { + let residual = new_offset - end; + /* + for n in 0..residual + rbuf[n] = rbuf[end+n]; + } */ + (residual, end, Some(msg)) + } + }; + for i in offset..new_offset { + if rbuf[i] == 0x0d { + let next = i+1; + if next < new_offset && rbuf[next] == 0x0a { + (offset, eaten, msg) = send_line(0, offset+next); + continue; + } else { + (offset, eaten, msg) = send_line(0, offset+i); + continue; + } + } else if rbuf[i] == 0x0a { + let next = i+1; + if next < new_offset && rbuf[next] == 0x0a { + (offset, eaten, msg) = send_line(0, offset+next); + continue; + } else { + (offset, eaten, msg) = send_line(0, offset+1); + continue; + } + } + } + // if buffer is full... then consider this a line, even withour cr/lf + if new_offset == BUFFER_SIZE { + debug!("serial receive BUFFER_SIZE characters, but no cr/lf so far"); + send_line(0, new_offset); + offset = 0; + continue; + } + offset = offset + len; info!("Read: {len}, data: {:?}", &rbuf[..len]); } Err(e) => error!("RX Error: {:?}", e), } + } } +} diff --git a/src/sntp_client.rs b/src/sntp_client.rs index bc5a427..fe51570 100644 --- a/src/sntp_client.rs +++ b/src/sntp_client.rs @@ -20,18 +20,22 @@ struct Timestamp { static UTC_DATETIME: Watch = Watch::new(); pub fn get_now() -> String { + get_instant(&Instant::now()) +} + +pub fn get_instant(instant: &Instant) -> String { let offset = UTC_DATETIME.try_get(); match offset { None => { - alloc::format!("LAUNCH+{}s", Instant::now().as_secs()) + alloc::format!("LAUNCH+{}s", instant.as_secs()) }, Some(duration) => { - let now = Instant::now() + duration; - let micros: i64 = now.as_micros() as i64; + let time = *instant + duration; + let micros: i64 = time.as_micros() as i64; let dt = DateTime::::from_timestamp_micros(micros); match dt { Some(val) => alloc::format!("{}", val.format("%Y-%m-%d %H:%M:%S")), - None => alloc::format!("LAUNCH+{}s", Instant::now().as_secs()) + None => alloc::format!("LAUNCH+{}s", instant.as_secs()) } } } diff --git a/src/static/app.js b/src/static/app.js new file mode 100644 index 0000000..0a7e3dd --- /dev/null +++ b/src/static/app.js @@ -0,0 +1,23 @@ +/* app.js */ + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +document.addEventListener("DOMContentLoaded", () => DOMLoaded(), false); + +async function DOMLoaded() { + await sleep(1000); + while (true) { + fetch("/api/v1/latest").then((response) => response.json()).then((json) => { + if (json.time) { + time = json.time; + temperature = json.temperature; + target = json.target; + document.getElementById("latest").innerHTML = `Temperature was ${temperature}°C at ${time} UTC, target temperature was ${target}°C`; + } + }) + await sleep(10000); + } +}; + diff --git a/src/static/espressif-logo.png b/src/static/espressif-logo.png index 697cc2c..dcb399f 100644 Binary files a/src/static/espressif-logo.png and b/src/static/espressif-logo.png differ diff --git a/src/static/risc-v-logo.png b/src/static/risc-v-logo.png index 0d8ff64..6cd2e9d 100644 Binary files a/src/static/risc-v-logo.png and b/src/static/risc-v-logo.png differ diff --git a/src/static/styles.css b/src/static/styles.css index 9d943b8..07053ac 100644 --- a/src/static/styles.css +++ b/src/static/styles.css @@ -1,3 +1,7 @@ +html, body { + margin: 0; + padding: 0; +} body { font-family: serif; text-align: center; @@ -16,7 +20,21 @@ p.intro { padding: 0.2em; margin: 0.3em 10vw 0.3em 10vw; } +p#latest { + border: 1px solid #B15C1B; + padding: 0.2em; + margin: 0.3em 10vw 0.3em 10vw; +} div#footer { position: absolute; bottom: 0px; + margin: 0; + text-align: center; + font-size: 70%; + width: 100%; + padding-bottom: 0.3em; +} +div#footer > a > img { + padding-left: 3vw; + padding-right: 3vw; } diff --git a/src/static/wallas-logo.png b/src/static/wallas-logo.png index 8773608..619726c 100644 Binary files a/src/static/wallas-logo.png and b/src/static/wallas-logo.png differ