Added data, api/v1 and js
This commit is contained in:
108
Cargo.lock
generated
108
Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
15
Cargo.toml
15
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.
|
||||
|
||||
159
src/database.rs
Normal file
159
src/database.rs
Normal file
@@ -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<T: Copy, const CAP: usize> {
|
||||
buf: [T; CAP],
|
||||
index: usize,
|
||||
has_wrapped: bool,
|
||||
}
|
||||
|
||||
impl<T: Copy, const CAP: usize> RingBuffer<T, CAP> {
|
||||
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<T> {
|
||||
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<T> {
|
||||
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<T> {
|
||||
let size = self.size();
|
||||
let mut res = Vec::<T>::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<M: RawMutex, T: Copy, const CAP: usize> {
|
||||
inner: Mutex<M, RefCell<RingBuffer<T, CAP>>>,
|
||||
}
|
||||
|
||||
impl<M: RawMutex, T: Copy, const CAP: usize> MutexRingBuffer<M, T, CAP> {
|
||||
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<T> {
|
||||
self.inner.lock(|rc| {
|
||||
let rb = rc.borrow();
|
||||
rb.get_all()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_latest(&self) -> Option<T> {
|
||||
self.inner.lock(|rc| {
|
||||
let rb = rc.borrow();
|
||||
rb.get_latest()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub static DATAPOINT_BUFFER: MutexRingBuffer<CriticalSectionRawMutex, DataPoint, 200> = 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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
173
src/httpd.rs
173
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<Duration> = picoserve::Config::new(
|
||||
picoserve::Timeouts {
|
||||
@@ -12,32 +19,44 @@ static PICO_CONFIG : picoserve::Config<Duration> = 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<Self::PathRouter> {
|
||||
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<S>(val: &Instant, s: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer
|
||||
{
|
||||
s.serialize_str(get_instant(val).as_str())
|
||||
}
|
||||
|
||||
fn option_instant_to_string<S>(oval: &Option<Instant>, s: S) -> Result<S::Ok, S::Error>
|
||||
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<TemperatureReading>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement serialization as alloc::vec::Vec does not implement 'Serialize'
|
||||
*/
|
||||
fn vec_temperature_reading<S>(v: &Vec<TemperatureReading>, s: S) -> Result<S::Ok, S::Error>
|
||||
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::<Vec<TemperatureReading>>() })
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LatestResponse {
|
||||
#[serde(skip_serializing_if = "Option::is_none", serialize_with = "option_instant_to_string")]
|
||||
time: Option<Instant>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
target: Option<i8>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
temperature: Option<i8>,
|
||||
}
|
||||
|
||||
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()) }
|
||||
})
|
||||
}
|
||||
|
||||
31
src/main.rs
31
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
|
||||
|
||||
205
src/serial.rs
205
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<BUFFER_SIZE>;
|
||||
|
||||
#[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<CriticalSectionRawMutex, DomainMessage, 3, 1, 1>;
|
||||
pub type DomainMessageSubscriber<'a> = Subscriber<'a, CriticalSectionRawMutex, DomainMessage, 3, 1, 1>;
|
||||
|
||||
pub static DOMAIN_MESSAGE_CHANNEL: DomainMessageChannel = DomainMessageChannel::new();
|
||||
|
||||
type DomainCommandChannel = PubSubChannel<CriticalSectionRawMutex, DomainCommand, 3, 1, 1>;
|
||||
|
||||
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<BaseMessage> = 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<BaseMessage>) {
|
||||
// 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),
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,18 +20,22 @@ struct Timestamp {
|
||||
static UTC_DATETIME: Watch<CriticalSectionRawMutex, Duration, 2> = 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::<Utc>::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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
src/static/app.js
Normal file
23
src/static/app.js
Normal file
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.9 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 7.2 KiB |
Reference in New Issue
Block a user