Compare commits
11 Commits
60b394f24d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 5278dde5a1 | |||
| 974dd6f515 | |||
| f55ac6dd21 | |||
| c88b401675 | |||
| 716cf2355f | |||
| 4b566b22a7 | |||
| a5cbb1e759 | |||
| efe2539ceb | |||
| ff60803f88 | |||
| 6b54fb570a | |||
| 86ccf24333 |
17
.cargo/config.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[target.riscv32imc-unknown-none-elf]
|
||||||
|
runner = "espflash flash --monitor --chip esp32c3"
|
||||||
|
|
||||||
|
[env]
|
||||||
|
|
||||||
|
[build]
|
||||||
|
rustflags = [
|
||||||
|
# Required to obtain backtraces (e.g. when using the "esp-backtrace" crate.)
|
||||||
|
# NOTE: May negatively impact performance of produced code
|
||||||
|
"-C", "force-frame-pointers",
|
||||||
|
]
|
||||||
|
|
||||||
|
target = "riscv32imc-unknown-none-elf"
|
||||||
|
|
||||||
|
[unstable]
|
||||||
|
build-std = ["core"]
|
||||||
|
|
||||||
1270
Cargo.lock
generated
65
Cargo.toml
@@ -1,41 +1,38 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wallas-esp32c3"
|
name = "wallas-esp32c3"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
esp-backtrace = { version = "0.14.2", features = [
|
esp-backtrace = { version = "0.18.0", features = [
|
||||||
"esp32c3",
|
"esp32c3",
|
||||||
"exception-handler",
|
|
||||||
"panic-handler",
|
"panic-handler",
|
||||||
"println",
|
"println",
|
||||||
]}
|
]}
|
||||||
|
esp-hal = { version = "1.0.0-rc.1", features = [
|
||||||
esp-hal = { version = "0.22.0", features = [
|
|
||||||
"esp32c3",
|
"esp32c3",
|
||||||
|
"unstable",
|
||||||
] }
|
] }
|
||||||
esp-println = { version = "0.12.0", features = ["esp32c3", "log"] }
|
esp-println = { version = "0.16.0", features = ["esp32c3", "log-04"] }
|
||||||
log = { version = "0.4.21" }
|
log = { version = "0.4.28" }
|
||||||
esp-alloc = { version = "0.5.0" }
|
esp-alloc = "0.9.0"
|
||||||
embedded-io = "0.6.1"
|
esp-rtos = { version = "0.1.1", features = ["esp-radio", "embassy", "log-04", "esp32c3"] }
|
||||||
|
embedded-io = "0.7.1"
|
||||||
|
embedded-io-async = "0.7.0"
|
||||||
|
embassy-net = { version = "0.7.1", features = [ "tcp", "udp", "dhcpv4", "dns", "medium-ethernet"] }
|
||||||
|
|
||||||
embedded-io-async = "0.6.1"
|
esp-radio = { version = "0.16.0", default-features=false, features = [
|
||||||
embassy-net = { version = "0.5.0", features = [ "tcp", "udp", "dhcpv4", "medium-ethernet"] }
|
|
||||||
|
|
||||||
esp-wifi = { version = "0.11.0", default-features=false, features = [
|
|
||||||
"esp32c3",
|
"esp32c3",
|
||||||
"utils",
|
|
||||||
"wifi",
|
"wifi",
|
||||||
"esp-alloc",
|
"esp-alloc",
|
||||||
"log",
|
|
||||||
] }
|
] }
|
||||||
embassy-sync = "0.6.1"
|
embassy-sync = "0.7.2"
|
||||||
rand_core = "0.6.4"
|
rand_core = "0.9.3"
|
||||||
heapless = { version = "0.8.0", default-features = false }
|
nom = { version = "8", default-features = false, features = [ "alloc" ] }
|
||||||
smoltcp = { version = "0.11.0", default-features = false, features = [
|
heapless = { version = "0.9.1", default-features = false }
|
||||||
|
smoltcp = { version = "0.12.0", default-features = false, features = [
|
||||||
"medium-ethernet",
|
"medium-ethernet",
|
||||||
"proto-dhcpv4",
|
"proto-dhcpv4",
|
||||||
"proto-igmp",
|
|
||||||
"proto-ipv4",
|
"proto-ipv4",
|
||||||
"socket-dhcpv4",
|
"socket-dhcpv4",
|
||||||
"socket-icmp",
|
"socket-icmp",
|
||||||
@@ -43,17 +40,20 @@ smoltcp = { version = "0.11.0", default-features = false, features = [
|
|||||||
"socket-tcp",
|
"socket-tcp",
|
||||||
"socket-udp",
|
"socket-udp",
|
||||||
] }
|
] }
|
||||||
embassy-executor = { version = "0.6.0", features = [
|
embassy-executor = "0.9.1"
|
||||||
"task-arena-size-40960"
|
embassy-time = { version = "0.5.0", features = ["generic-queue-8"] }
|
||||||
] }
|
static_cell = { version = "2.1.1" }
|
||||||
embassy-time = { version = "0.3.1", features = ["generic-queue-8"] }
|
# critical-section = "1.2.0"
|
||||||
esp-hal-embassy = { version = "0.5.0", features = ["esp32c3"] }
|
maud = { git = "https://github.com/jakobdalsgaard/maud.git", features = ["alloc", "picoserve"] }
|
||||||
static_cell = { version = "2.1.0", features = ["nightly"] }
|
picoserve = { version = "0.16.0", default-features = false, features = [
|
||||||
critical-section = "1.2.0"
|
"alloc",
|
||||||
maud = { path = "/home/jda/src/rust/maud/target/package/maud-0.26.0", features = ["alloc"] }
|
"embassy",
|
||||||
picoserve = { version = "0.13.3", default-features = false, features = [
|
"log",
|
||||||
"embassy"
|
|
||||||
] }
|
] }
|
||||||
|
sntpc = { version = "0.6.1", default-features = false, features = [ "embassy-socket" ] }
|
||||||
|
chrono = { version = "0.4.42", default-features = false, features = [ "alloc" ] }
|
||||||
|
serde = { version = "1.0.228", default-features = false }
|
||||||
|
include_file_compress = "0.1.3"
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
# Rust debug is too slow.
|
# Rust debug is too slow.
|
||||||
@@ -68,3 +68,6 @@ incremental = false
|
|||||||
lto = 'fat'
|
lto = 'fat'
|
||||||
opt-level = 's'
|
opt-level = 's'
|
||||||
overflow-checks = false
|
overflow-checks = false
|
||||||
|
|
||||||
|
[profile.dev.package.esp-wifi]
|
||||||
|
opt-level = 3
|
||||||
|
|||||||
1
build.rs
@@ -1,3 +1,4 @@
|
|||||||
fn main() {
|
fn main() {
|
||||||
println!("cargo:rustc-link-arg-bins=-Tlinkall.x");
|
println!("cargo:rustc-link-arg-bins=-Tlinkall.x");
|
||||||
|
println!("cargo:rustc-env=CARGO_PKG_VERSION_SAFE={}", env!("CARGO_PKG_VERSION").replace(".", "_"));
|
||||||
}
|
}
|
||||||
|
|||||||
29
readme.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
Wallas DT/GB Heater Controller on an ESP32C3
|
||||||
|
============================================
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
As described in the chapter on Risc-V target installation in the [Rust on ESP Book](https://docs.esp-rs.org/book/installation/riscv.html),
|
||||||
|
you should install the ESP32c3 target for your rust toolchain by doing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup toolchain install stable --component rust-src
|
||||||
|
rustup target add riscv32imc-unknown-none-elf
|
||||||
|
```
|
||||||
|
|
||||||
|
Now checkout this project.
|
||||||
|
It is advised to run the `--release` version of the application, thus to build and run, do:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Currently the application will not discover wireless network and do configuration of it; a known network, along with
|
||||||
|
encryption key is compiled into it - therefore, before building, run this and set the information: network name, network password
|
||||||
|
and desired log level:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
. ./set-env.sh
|
||||||
|
```
|
||||||
|
|
||||||
BIN
resources/7193_wallas-uus-logo-1.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
resources/espressif-systems-logo-png_seeklogo-407805.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
resources/risc-v-core.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
7
set-env.sh
Normal file → Executable file
@@ -1,5 +1,6 @@
|
|||||||
echo "Execute as '. ./set-env.sh'"
|
echo "Execute as '. ./set-env.sh'"
|
||||||
read -p "wifi ssid " WIFI_SSID
|
read -p "wifi ssid " WIFI_SSID
|
||||||
read -p "wifi password " WIFI_PASSWORD
|
read -s -p "wifi password " WIFI_PASSWORD
|
||||||
read -p "esp loglevel (trace, debug, info, warn, error) " ESP_LOGLEVEL
|
echo
|
||||||
export WIFI_SSID WIFI_PASSWORD ESP_LOGLEVEL
|
read -p "esp loglevel (trace, debug, info, warn, error) " ESP_LOG
|
||||||
|
export WIFI_SSID WIFI_PASSWORD ESP_LOG
|
||||||
|
|||||||
159
src/database.rs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
|
||||||
|
|
||||||
|
use embassy_sync::blocking_mutex::raw::{CriticalSectionRawMutex, 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,
|
||||||
|
pub t1: i8,
|
||||||
|
pub 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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
259
src/httpd.rs
@@ -1,43 +1,78 @@
|
|||||||
use picoserve::{make_static, routing::get, AppBuilder, AppRouter, Router};
|
use picoserve::routing::{get, get_service, parse_path_segment};
|
||||||
use picoserve::routing::PathRouter;
|
use picoserve::response::{IntoResponse, File, Json};
|
||||||
use embassy_time::Duration;
|
use picoserve::response::status::StatusCode;
|
||||||
|
use embassy_time::{Duration, Instant, with_timeout};
|
||||||
use embassy_net::Stack;
|
use embassy_net::Stack;
|
||||||
|
use maud::{DOCTYPE, html, Markup};
|
||||||
|
use alloc::vec::Vec;
|
||||||
|
use crate::sntp_client::get_instant;
|
||||||
|
use serde::{Serialize, Serializer};
|
||||||
|
use serde::ser::SerializeSeq;
|
||||||
|
use include_file_compress::include_file_compress_deflate;
|
||||||
|
use crate::serial;
|
||||||
|
use crate::database::DATAPOINT_BUFFER;
|
||||||
|
|
||||||
use static_cell::StaticCell;
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
|
use embassy_sync::mutex::Mutex;
|
||||||
|
|
||||||
|
|
||||||
|
const STYLES_CSS_FILENAME: &str = concat!("/styles-", env!("CARGO_PKG_VERSION_SAFE"), ".css");
|
||||||
|
const APP_JS_FILENAME: &str = concat!("/app-", env!("CARGO_PKG_VERSION_SAFE"), ".js");
|
||||||
|
|
||||||
static PICO_CONFIG : picoserve::Config<Duration> = picoserve::Config::new(
|
static PICO_CONFIG : picoserve::Config<Duration> = picoserve::Config::new(
|
||||||
picoserve::Timeouts {
|
picoserve::Timeouts {
|
||||||
start_read_request: Some(Duration::from_secs(5)),
|
start_read_request: Some(Duration::from_secs(1)),
|
||||||
|
persistent_start_read_request: Some(Duration::from_secs(1)),
|
||||||
read_request: Some(Duration::from_secs(1)),
|
read_request: Some(Duration::from_secs(1)),
|
||||||
write: Some(Duration::from_secs(1)),
|
write: Some(Duration::from_secs(1)),
|
||||||
}).keep_connection_alive();
|
}).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> {
|
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));
|
spawner.must_spawn(web_task(i, stack));
|
||||||
}
|
}
|
||||||
Ok(())
|
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)]
|
#[embassy_executor::task(pool_size = super::MAX_CONCURRENT_SOCKETS)]
|
||||||
async fn web_task(
|
async fn web_task(
|
||||||
id: usize,
|
id: usize,
|
||||||
stack: Stack<'static>,
|
stack: Stack<'static>,
|
||||||
) -> ! {
|
) -> ! {
|
||||||
|
let api_router = picoserve::Router::new()
|
||||||
|
.route("/latest", get(|| async { latest() }))
|
||||||
|
.route("/allreadings", get(|| async { allreadings() }))
|
||||||
|
.route("/metrics", get(|| async { metrics () }))
|
||||||
|
.route("/cmd/start", get(|| { cmd_start() }))
|
||||||
|
.route("/cmd/stop", get(|| { cmd_stop() }))
|
||||||
|
.route("/cmd/ventilate", get(|| { cmd_ventilate() }))
|
||||||
|
.route(("/cmd/temperature", parse_path_segment::<i8>()), get(|t| { cmd_temperature(t) }));
|
||||||
|
|
||||||
|
let image_router = picoserve::Router::new()
|
||||||
|
.route("/favicon.png", get_service(File::with_content_type_and_headers(&PNG_CONTENT_TYPE,
|
||||||
|
include_bytes!("static/favicon.png"), CACHEABLE_IMAGE_HEADERS)))
|
||||||
|
.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 =
|
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_FILENAME, 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_FILENAME, 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 port = 80;
|
||||||
let mut tcp_rx_buffer = [0; 1024];
|
let mut tcp_rx_buffer = [0; 1024];
|
||||||
@@ -56,3 +91,191 @@ async fn web_task(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn page(heading: &str, content: Markup) -> Markup {
|
||||||
|
html! {
|
||||||
|
(DOCTYPE)
|
||||||
|
html {
|
||||||
|
head {
|
||||||
|
link rel="stylesheet" type="text/css" href=(STYLES_CSS_FILENAME);
|
||||||
|
link rel="icon" type="image/png" href="/images/favicon.png";
|
||||||
|
script src=(APP_JS_FILENAME) {};
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
dialog #message .dialog-center {};
|
||||||
|
dialog #set-target .dialog-center {
|
||||||
|
span ."cancel-x" { "🗙" };
|
||||||
|
form {
|
||||||
|
div #temperature-form {
|
||||||
|
input #target type="range" min="5" max="25" name="target";
|
||||||
|
br;
|
||||||
|
label #target-label for="target" { "Target temperature in °C" };
|
||||||
|
br;
|
||||||
|
button #set-target { "Set" };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 metrics() -> impl IntoResponse {
|
||||||
|
match DATAPOINT_BUFFER.get_latest() {
|
||||||
|
None => "".into(),
|
||||||
|
Some(datapoint) => {
|
||||||
|
alloc::format!(include_str!("static/prometheus-template.txt"), target = datapoint.target, temperature = datapoint.current, command = datapoint.t1, state = datapoint.t2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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! {
|
||||||
|
div .opaque { p .intro { "Beta version of ESP32C3 based Wifi extension to the Wallas 361062 Control Panel for the DT/GB Heaters" } }
|
||||||
|
div .opaque { p #graph { "Waiting for data ..." } }
|
||||||
|
div .opaque { p #latest { "Waiting for latest reading ..." } }
|
||||||
|
button #button-start .button { "Start heater" }
|
||||||
|
button #button-stop .button { "Stop heater" }
|
||||||
|
button #button-ventilate .button { "Start ventilator" }
|
||||||
|
button #button-target .button { "Set target temperature" }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_for_ok () -> () {
|
||||||
|
let mut subscriber = serial::DOMAIN_MESSAGE_CHANNEL.subscriber().unwrap();
|
||||||
|
loop {
|
||||||
|
let data = subscriber.next_message_pure().await;
|
||||||
|
if let serial::DomainMessage::AtOk = data {
|
||||||
|
return ();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CMDLOCK: Mutex<CriticalSectionRawMutex, ()> = Mutex::new(());
|
||||||
|
|
||||||
|
async fn cmd(c: serial::DomainCommand) -> impl IntoResponse {
|
||||||
|
match CMDLOCK.try_lock() {
|
||||||
|
Err(_) => (StatusCode::LOCKED, ""),
|
||||||
|
_ => {
|
||||||
|
serial::DOMAIN_COMMAND_CHANNEL.publisher().unwrap().publish_immediate(c);
|
||||||
|
match with_timeout(Duration::from_secs(5), wait_for_ok()).await {
|
||||||
|
Ok(()) => (StatusCode::NO_CONTENT, ""),
|
||||||
|
Err(_) => (StatusCode::GATEWAY_TIMEOUT, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_start() -> impl IntoResponse {
|
||||||
|
cmd(serial::DomainCommand::Start).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_stop() -> impl IntoResponse {
|
||||||
|
cmd(serial::DomainCommand::Stop).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_ventilate() -> impl IntoResponse {
|
||||||
|
cmd(serial::DomainCommand::Ventilate).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_temperature(t: i8) -> impl IntoResponse {
|
||||||
|
cmd(serial::DomainCommand::Temperature(t)).await
|
||||||
|
}
|
||||||
|
|||||||
67
src/main.rs
@@ -2,23 +2,18 @@
|
|||||||
#![no_main]
|
#![no_main]
|
||||||
|
|
||||||
use esp_backtrace as _;
|
use esp_backtrace as _;
|
||||||
use esp_hal::prelude::*;
|
|
||||||
use log::{info, error};
|
use log::{info, error};
|
||||||
|
|
||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
|
|
||||||
use heapless::String;
|
use heapless::String;
|
||||||
use esp_hal::rng::Rng;
|
use esp_hal::rng::Rng;
|
||||||
use esp_hal::timer::systimer::SystemTimer;
|
use esp_hal::clock::CpuClock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Embassy includes
|
* Embassy includes
|
||||||
*/
|
*/
|
||||||
use esp_hal_embassy::init as initialize_embassy;
|
|
||||||
use esp_hal::timer::systimer::Target;
|
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
use embassy_time::{Duration, Timer};
|
|
||||||
use esp_hal::timer::timg::TimerGroup;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rng functionality
|
* Rng functionality
|
||||||
@@ -31,7 +26,7 @@ use self::random::RngWrapper;
|
|||||||
*/
|
*/
|
||||||
mod wifi;
|
mod wifi;
|
||||||
use self::wifi::connect as connect_to_wifi;
|
use self::wifi::connect as connect_to_wifi;
|
||||||
use self::wifi::Error as WifiError;
|
// use self::wifi::Error as WifiError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* httpd
|
* httpd
|
||||||
@@ -45,6 +40,24 @@ use httpd::httpd_spawn;
|
|||||||
mod serial;
|
mod serial;
|
||||||
use serial::serial_spawn;
|
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;
|
||||||
|
|
||||||
|
|
||||||
|
/// CARGO_PKG_VERSION
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
pub const VERSION_SAFE: &str = env!("CARGO_PKG_VERSION_SAFE");
|
||||||
|
|
||||||
/// SSID for WiFi network
|
/// SSID for WiFi network
|
||||||
const WIFI_SSID: &str = env!("WIFI_SSID");
|
const WIFI_SSID: &str = env!("WIFI_SSID");
|
||||||
|
|
||||||
@@ -54,12 +67,18 @@ const WIFI_PASSWORD: &str = env!("WIFI_PASSWORD");
|
|||||||
/// Size of heap for dynamically-allocated memory
|
/// Size of heap for dynamically-allocated memory
|
||||||
const HEAP_MEMORY_SIZE: usize = 72 * 1024;
|
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]
|
// esp_bootloader_esp_idf::esp_app_desc!();
|
||||||
|
|
||||||
|
#[esp_rtos::main]
|
||||||
async fn main(spawner: Spawner) {
|
async fn main(spawner: Spawner) {
|
||||||
|
|
||||||
info!("booting firmware");
|
info!("booting firmware version {}", VERSION);
|
||||||
|
|
||||||
if let Err(error) = main_fallible(spawner).await {
|
if let Err(error) = main_fallible(spawner).await {
|
||||||
error!("Error while running firmware: {error:?}");
|
error!("Error while running firmware: {error:?}");
|
||||||
@@ -71,30 +90,28 @@ async fn main(spawner: Spawner) {
|
|||||||
async fn main_fallible(
|
async fn main_fallible(
|
||||||
spawner: Spawner,
|
spawner: Spawner,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
let peripherals = esp_hal::init({
|
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
|
||||||
let mut config = esp_hal::Config::default();
|
let peripherals = esp_hal::init(config);
|
||||||
config.cpu_clock = CpuClock::max();
|
|
||||||
config
|
|
||||||
});
|
|
||||||
|
|
||||||
esp_alloc::heap_allocator!(HEAP_MEMORY_SIZE);
|
esp_alloc::heap_allocator!(size: HEAP_MEMORY_SIZE);
|
||||||
|
|
||||||
esp_println::logger::init_logger_from_env();
|
esp_println::logger::init_logger_from_env();
|
||||||
|
|
||||||
let systimer = SystemTimer::new(peripherals.SYSTIMER).split::<Target>();
|
let rng = Rng::new();
|
||||||
initialize_embassy(systimer.alarm0);
|
|
||||||
|
|
||||||
let rng = Rng::new(peripherals.RNG);
|
let ssid = String::<32>::try_from(WIFI_SSID).map_err(|_| Error::ParseCredentials)?;
|
||||||
|
|
||||||
let ssid = String::<32>::try_from(WIFI_SSID).map_err(|()| Error::ParseCredentials)?;
|
|
||||||
let password =
|
let password =
|
||||||
String::<64>::try_from(WIFI_PASSWORD).map_err(|()| Error::ParseCredentials)?;
|
String::<64>::try_from(WIFI_PASSWORD).map_err(|_| Error::ParseCredentials)?;
|
||||||
|
|
||||||
let stack = connect_to_wifi(spawner, TimerGroup::new(peripherals.TIMG0), rng, peripherals.WIFI, peripherals.RADIO_CLK, (ssid, password)).await.unwrap();
|
let stack = connect_to_wifi(spawner, peripherals.TIMG1, peripherals.SW_INTERRUPT, peripherals.WIFI, rng, (ssid, password)).await.unwrap();
|
||||||
|
|
||||||
httpd_spawn(spawner, MAX_CONCURRENT_SOCKETS-1, stack);
|
let _ = httpd_spawn(spawner, HTTPD_SOCKETS, stack);
|
||||||
|
|
||||||
serial_spawn(spawner, peripherals.UART0.into(), peripherals.GPIO20.into(), peripherals.GPIO21.into());
|
let _ = sntp_client_spawn(spawner, stack);
|
||||||
|
|
||||||
|
serial_spawn(spawner, peripherals.UART0.into());
|
||||||
|
|
||||||
|
let _ = database_spawn(spawner);
|
||||||
|
|
||||||
info!("firmware done booting");
|
info!("firmware done booting");
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
//! Random numbers generator
|
//! Random numbers generator
|
||||||
|
|
||||||
use rand_core::CryptoRng;
|
use rand_core::CryptoRng;
|
||||||
use rand_core::Error;
|
|
||||||
use rand_core::RngCore;
|
use rand_core::RngCore;
|
||||||
|
|
||||||
use esp_hal::rng::Rng;
|
use esp_hal::rng::Rng;
|
||||||
@@ -39,10 +38,12 @@ impl RngCore for RngWrapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Error> {
|
fn try_fill_bytes(&mut self, dest: &mut [u8]) -> Result<(), Error> {
|
||||||
self.fill_bytes(dest);
|
self.fill_bytes(dest);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CryptoRng for RngWrapper {}
|
impl CryptoRng for RngWrapper {}
|
||||||
|
|||||||
215
src/serial.rs
@@ -1,36 +1,223 @@
|
|||||||
use esp_hal::{
|
use esp_hal::{
|
||||||
// clock::ClockControl,
|
// clock::ClockControl,
|
||||||
peripherals::{Peripherals},
|
// peripherals::{Peripherals},
|
||||||
prelude::*,
|
uart::{AnyUart, Uart, UartRx, UartTx, Config},
|
||||||
uart::{AtCmdConfig, AnyUart, Uart, UartRx, UartTx, Config},
|
|
||||||
gpio::AnyPin,
|
|
||||||
Async,
|
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;
|
||||||
|
use nom::Parser;
|
||||||
|
|
||||||
pub fn serial_spawn(spawner: embassy_executor::Spawner, peri_uart: AnyUart, rx_pin: AnyPin, tx_pin: AnyPin) {
|
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, 2, 1>;
|
||||||
|
pub type DomainMessageSubscriber<'a> = Subscriber<'a, CriticalSectionRawMutex, DomainMessage, 3, 2, 1>;
|
||||||
|
|
||||||
|
pub static DOMAIN_MESSAGE_CHANNEL: DomainMessageChannel = DomainMessageChannel::new();
|
||||||
|
|
||||||
|
type DomainCommandChannel = PubSubChannel<CriticalSectionRawMutex, DomainCommand, 3, 1, 1>;
|
||||||
|
|
||||||
|
pub static DOMAIN_COMMAND_CHANNEL: DomainCommandChannel = DomainCommandChannel::new();
|
||||||
|
|
||||||
|
pub fn serial_spawn(spawner: embassy_executor::Spawner, peri_uart: AnyUart<'static>) {
|
||||||
// Initialize and configure UART0
|
// Initialize and configure UART0
|
||||||
let config = Config::default().rx_fifo_full_threshold(BUFFER_SIZE as u16);
|
let config = Config::default();
|
||||||
let my_uart = Uart::new_with_config(peri_uart, config, rx_pin, tx_pin).unwrap().into_async();
|
// removed rx_pin, tx_pin
|
||||||
|
let my_uart = Uart::new(peri_uart, config).unwrap().into_async();
|
||||||
// Split UART0 to create seperate Tx and Rx handles
|
// 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(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::bytes::complete::tag("AT+OK"),
|
||||||
|
nom::character::complete::crlf::<&str, nom::error::Error<&str>>
|
||||||
|
).parse(i) {
|
||||||
|
Ok((_residual, (_, _))) => {
|
||||||
|
Some(())
|
||||||
|
}
|
||||||
|
Err(_e) => {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at_wallas_parser (i: &str) -> Option<(i8, i8, i8, i8)> {
|
||||||
|
match (
|
||||||
|
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>>
|
||||||
|
).parse(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;
|
||||||
|
info!("Writing message {:?} to serial port", &cmd);
|
||||||
|
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[0..msg.len()].copy_from_slice(msg.as_bytes());
|
||||||
|
&wbuf[0..msg.len()]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[embassy_executor::task]
|
#[embassy_executor::task]
|
||||||
async fn reader(mut rx: UartRx<'static, Async>) {
|
async fn reader(mut rx: UartRx<'static, Async>) {
|
||||||
let mut rbuf: [u8; BUFFER_SIZE] = [0u8; BUFFER_SIZE];
|
let mut rbuf: [u8; BUFFER_SIZE] = [0u8; BUFFER_SIZE];
|
||||||
loop {
|
loop {
|
||||||
let r = embedded_io_async::Read::read(&mut rx, &mut rbuf[0..]).await;
|
let mut offset: usize = 0;
|
||||||
match r {
|
let mut eaten: usize = 0;
|
||||||
Ok(len) => {
|
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]);
|
info!("Read: {len}, data: {:?}", &rbuf[..len]);
|
||||||
}
|
}
|
||||||
Err(e) => error!("RX Error: {:?}", e),
|
Err(e) => error!("RX Error: {:?}", e),
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
103
src/sntp_client.rs
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
|
||||||
|
use core::net::{IpAddr, SocketAddr};
|
||||||
|
use embassy_net::Stack;
|
||||||
|
use embassy_net::dns::DnsQueryType;
|
||||||
|
use embassy_time::{Duration, Instant, Timer};
|
||||||
|
use embassy_net::udp::{PacketMetadata, UdpSocket};
|
||||||
|
use embassy_sync::watch::Watch;
|
||||||
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
|
use alloc::string::String;
|
||||||
|
|
||||||
|
use log::error;
|
||||||
|
use sntpc::{get_time, NtpContext, NtpTimestampGenerator};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[derive(Copy, Clone)]
|
||||||
|
struct Timestamp {
|
||||||
|
tstamp: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
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.as_secs())
|
||||||
|
},
|
||||||
|
Some(duration) => {
|
||||||
|
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.as_secs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl NtpTimestampGenerator for Timestamp {
|
||||||
|
fn init(&mut self) {
|
||||||
|
self.tstamp = Instant::now();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp_sec(&self) -> u64 {
|
||||||
|
self.tstamp.as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timestamp_subsec_micros(&self) -> u32 {
|
||||||
|
(self.tstamp.as_micros() % 1_000_000) as u32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sntp_client_spawn(spawner: embassy_executor::Spawner, stack: Stack<'static>) {
|
||||||
|
|
||||||
|
spawner.must_spawn(sntp_client(stack));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[embassy_executor::task]
|
||||||
|
async fn sntp_client(stack: Stack<'static>) {
|
||||||
|
|
||||||
|
// Create UDP socket
|
||||||
|
let mut rx_meta = [PacketMetadata::EMPTY; 16];
|
||||||
|
let mut rx_buffer = [0; 4096];
|
||||||
|
let mut tx_meta = [PacketMetadata::EMPTY; 16];
|
||||||
|
let mut tx_buffer = [0; 4096];
|
||||||
|
|
||||||
|
let mut socket = UdpSocket::new(stack, &mut rx_meta, &mut rx_buffer, &mut tx_meta, &mut tx_buffer);
|
||||||
|
socket.bind(123).unwrap();
|
||||||
|
|
||||||
|
let context = NtpContext::new(Timestamp{tstamp: Instant::now()});
|
||||||
|
let server_list : alloc::vec::Vec<&str> = alloc::vec!["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org", "3.pool.ntp.org"];
|
||||||
|
let mut current_server = 2;
|
||||||
|
|
||||||
|
let sender = UTC_DATETIME.sender();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let dns_res = stack.dns_query (server_list[current_server], DnsQueryType::A).await.expect("Failed to resolve NTP server");
|
||||||
|
let addr: IpAddr = dns_res[0].into();
|
||||||
|
let result =
|
||||||
|
get_time(SocketAddr::from((addr, 123)), &socket, context).await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(time) => {
|
||||||
|
let offset = Duration::from_micros(time.offset as u64);
|
||||||
|
sender.send(offset);
|
||||||
|
Timer::after(Duration::from_secs(7*24*3600)).await; // redo SNTP check in a week
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error getting time: {:?}", e);
|
||||||
|
current_server = (current_server + 1) % 4;
|
||||||
|
Timer::after(Duration::from_secs(30)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
187
src/static/app.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* app.js */
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => main(), false);
|
||||||
|
|
||||||
|
async function main () {
|
||||||
|
|
||||||
|
var pgraph = document.querySelector("#graph");
|
||||||
|
if (pgraph) LoadGraph(pgraph);
|
||||||
|
|
||||||
|
var platest = document.querySelector("#latest");
|
||||||
|
if (platest) LoadLatest(platest);
|
||||||
|
|
||||||
|
var bstart = document.querySelector("#button-start");
|
||||||
|
if (bstart) bstart.addEventListener("click",
|
||||||
|
() => wallas_command("start", "Wallas Heater did not acknowledge the start command", "Wallas heater started"));
|
||||||
|
|
||||||
|
var bstop = document.querySelector("#button-stop");
|
||||||
|
if (bstop) bstop.addEventListener("click",
|
||||||
|
() => wallas_command("stop", "Wallas Heater did not acknowledge the stop command", "Wallas heater stopped"));
|
||||||
|
|
||||||
|
var bventilate = document.querySelector("#button-ventilate");
|
||||||
|
if (bventilate) bventilate.addEventListener("click",
|
||||||
|
() => wallas_command("ventilate", "Wallas Heater did not acknowledge the ventilate command", "Wallas heater is ventilating"));
|
||||||
|
|
||||||
|
var btarget = document.querySelector("#button-target");
|
||||||
|
if (btarget) {
|
||||||
|
btarget.addEventListener("click", set_temperature_dialog);
|
||||||
|
var target_label = document.querySelector("label#target-label");
|
||||||
|
document.querySelector("input#target").addEventListener("input", (i) => { target_label.innerHTML = `Target temperature is ${i.target.value}°C`; });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function LoadLatest(platest) {
|
||||||
|
await sleep(500);
|
||||||
|
while (true) {
|
||||||
|
fetch("/api/v1/latest").then((response) => response.json()).then((json) => {
|
||||||
|
if (json.time) {
|
||||||
|
time = json.time;
|
||||||
|
temperature = json.temperature;
|
||||||
|
target = json.target;
|
||||||
|
platest.innerHTML = `Temperature was ${temperature}°C at ${time} UTC, target temperature was ${target}°C`;
|
||||||
|
}
|
||||||
|
}).catch((error) => { console.log("server unable to respond") });
|
||||||
|
await sleep(10000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const svg_xmlns = "http://www.w3.org/2000/svg";
|
||||||
|
function appendSVGElement(parent, name, attributes, text = null) {
|
||||||
|
var elem = document.createElementNS(svg_xmlns, name);
|
||||||
|
for (var aname in attributes) {
|
||||||
|
elem.setAttributeNS(null, aname, attributes[aname]);
|
||||||
|
}
|
||||||
|
if (text != null) {
|
||||||
|
textnode = document.createTextNode(text);
|
||||||
|
elem.appendChild(textnode);
|
||||||
|
}
|
||||||
|
return parent.appendChild(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendElement(parent, name, attributes, text = null) {
|
||||||
|
var elem = document.createElement(name);
|
||||||
|
for (var aname in attributes) {
|
||||||
|
elem.setAttribute(aname, attributes[aname]);
|
||||||
|
}
|
||||||
|
if (text != null) {
|
||||||
|
textnode = document.createTextNode(text);
|
||||||
|
elem.appendChild(textnode);
|
||||||
|
}
|
||||||
|
return parent.appendChild(elem);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function LoadGraph(pgraph) {
|
||||||
|
await sleep(1000);
|
||||||
|
while (true) {
|
||||||
|
fetch("/api/v1/allreadings").then((response) => response.json()).then((json) => {
|
||||||
|
if (json instanceof Array && json.length > 0) {
|
||||||
|
var width = pgraph.offsetWidth;
|
||||||
|
var height = pgraph.offsetHeight;
|
||||||
|
var temp_y = y => (25 - y) * height/35;
|
||||||
|
var temp_i = i => (99 - i) * (width - 48)/100;
|
||||||
|
var graph = document.createDocumentFragment();
|
||||||
|
var svg = appendSVGElement(graph, "svg", {"width": "100%", "height": "100%", "viewBox": `0 0 ${width} ${height}`});
|
||||||
|
var target_points = [];
|
||||||
|
var temperature_points = [];
|
||||||
|
for (var i = 0; i < json.length && i < 99; i++) {
|
||||||
|
var r = json[json.length - i - 1];
|
||||||
|
var x_coord = temp_i(i);
|
||||||
|
var temperature_y = temp_y(r.temperature);
|
||||||
|
temperature_points.push(`${x_coord},${temperature_y}`);
|
||||||
|
var target_y = temp_y(r.target);
|
||||||
|
target_points.push(`${x_coord},${target_y}`);
|
||||||
|
}
|
||||||
|
temperature_points_str = temperature_points.join(" ");
|
||||||
|
target_points_str = target_points.join(" ");
|
||||||
|
|
||||||
|
// Horizontal lines
|
||||||
|
// zero line
|
||||||
|
appendSVGElement(svg, "polyline", {"class": "grid grid-zero", "points": ""+temp_i(100)+","+temp_y(0)+" "+temp_i(0)+","+temp_y(0)});
|
||||||
|
// ten line
|
||||||
|
appendSVGElement(svg, "polyline", {"class": "grid", "points": ""+temp_i(100)+","+temp_y(10)+" "+temp_i(0)+","+temp_y(10)});
|
||||||
|
// twenty line
|
||||||
|
appendSVGElement(svg, "polyline", {"class": "grid", "points": ""+temp_i(100)+","+temp_y(20)+" "+temp_i(0)+","+temp_y(20)});
|
||||||
|
|
||||||
|
// Vertical lines
|
||||||
|
appendSVGElement(svg, "polyline", {"class": "grid", "points": ""+temp_i(10)+","+temp_y(24)+" "+temp_i(10)+","+temp_y(-9)});
|
||||||
|
appendSVGElement(svg, "polyline", {"class": "grid", "points": ""+temp_i(30)+","+temp_y(24)+" "+temp_i(30)+","+temp_y(-9)});
|
||||||
|
appendSVGElement(svg, "polyline", {"class": "grid", "points": ""+temp_i(50)+","+temp_y(24)+" "+temp_i(50)+","+temp_y(-9)});
|
||||||
|
appendSVGElement(svg, "polyline", {"class": "grid", "points": ""+temp_i(70)+","+temp_y(24)+" "+temp_i(70)+","+temp_y(-9)});
|
||||||
|
appendSVGElement(svg, "polyline", {"class": "grid", "points": ""+temp_i(90)+","+temp_y(24)+" "+temp_i(90)+","+temp_y(-9)});
|
||||||
|
// text
|
||||||
|
appendSVGElement(svg, "text", {"x": temp_i(0), "y": temp_y(0), "dx": 6, "dy": 5, "fill": "#000000"}, "0°C");
|
||||||
|
appendSVGElement(svg, "text", {"x": temp_i(0), "y": temp_y(10), "dx": 6, "dy": 5, "fill": "#000000"}, "10°C");
|
||||||
|
appendSVGElement(svg, "text", {"x": temp_i(0), "y": temp_y(20), "dx": 6, "dy": 5, "fill": "#000000"}, "20°C");
|
||||||
|
|
||||||
|
|
||||||
|
target_polyline = appendSVGElement(svg, "polyline", {"class": "data target", "points": target_points_str});
|
||||||
|
temperature_polyline = appendSVGElement(svg, "polyline", {"class": "data temperature", "points": temperature_points_str});
|
||||||
|
pgraph.replaceChildren(graph);
|
||||||
|
}
|
||||||
|
}).catch((error) => { console.log("server unable to respond"); });
|
||||||
|
await sleep(10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function set_temperature_dialog() {
|
||||||
|
var target = null;
|
||||||
|
await fetch("/api/v1/latest").then((response) => response.json()).then((json) => {
|
||||||
|
if (json.target) {
|
||||||
|
if (json.target < 5) {
|
||||||
|
target = 5;
|
||||||
|
} else if (json.target > 25) {
|
||||||
|
target = 25;
|
||||||
|
} else {
|
||||||
|
target = json.target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch((error) => { console.log("server unable to respond"); });
|
||||||
|
if (target == null) return;
|
||||||
|
var dialog = document.querySelector("dialog#set-target");
|
||||||
|
dialog.showModal();
|
||||||
|
var target_label = document.querySelector("label#target-label");
|
||||||
|
var target_input = document.querySelector("input#target");
|
||||||
|
console.log(target);
|
||||||
|
target_input.value = target;
|
||||||
|
target_label.innerHTML = `Target temperature is ${target}°C`;
|
||||||
|
target_input.addEventListener("input", (i) => { target_label.innerHTML = `Target temperature is ${i.target.value}°C`; });
|
||||||
|
document.querySelector("span.cancel-x").addEventListener("click", () => { dialog.close(); });
|
||||||
|
document.querySelector("button#set-target").addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dialog.close();
|
||||||
|
var temperature = target_input.value;
|
||||||
|
wallas_command(`temperature/${temperature}`, "Wallas Heater did not accept the command", "Temperature target set to ${temperature}°C");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wallas_command(cmd, no_atok, success) {
|
||||||
|
var dialog = document.querySelector("dialog#message");
|
||||||
|
dialog.addEventListener('cancel', (event) => { event.preventDefault(); });
|
||||||
|
dialog.innerHTML = "Sending command....";
|
||||||
|
dialog.showModal();
|
||||||
|
fetch(`/api/v1/cmd/${cmd}`).then((response) => {
|
||||||
|
if (response.status == 423) {
|
||||||
|
dialog.innerHTML = "Busy executing other command, try again later";
|
||||||
|
} else if (response.status == 504) {
|
||||||
|
dialog.innerHTML = no_atok;
|
||||||
|
} else if (response.status == 204) {
|
||||||
|
dialog.innerHTML = success;
|
||||||
|
} else {
|
||||||
|
dialog.innerHTML = "Controller has trouble communicating with Wallas Heater, try again later";
|
||||||
|
}
|
||||||
|
}).catch((error) => { console.log("server unable to respond");
|
||||||
|
dialog.innerHTML = "Controller is unable to respond"; });
|
||||||
|
setTimeout(() => {
|
||||||
|
dialog.close();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
BIN
src/static/espressif-logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src/static/favicon.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
9
src/static/prometheus-template.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# TYPE wallas_heater_target gauge
|
||||||
|
wallas_heater_target {target}
|
||||||
|
# TYPE wallas_heater_temperature gauge
|
||||||
|
wallas_heater_temperature {temperature}
|
||||||
|
# TYPE wallas_heater_command gauge
|
||||||
|
wallas_heater_command {command}
|
||||||
|
# TYPE wallas_heater_state gauge
|
||||||
|
wallas_heter_state {state}
|
||||||
|
|
||||||
BIN
src/static/risc-v-logo.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
116
src/static/styles.css
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: serif;
|
||||||
|
text-align: center;
|
||||||
|
color: #321C0B;
|
||||||
|
background-color: #D5E4F6;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #B15C1B;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
color: #2969B2;
|
||||||
|
}
|
||||||
|
p.intro {
|
||||||
|
border: 1px solid #B15C1B;
|
||||||
|
padding: 0.2em;
|
||||||
|
margin: 0.3em 10vw 0.3em 10vw;
|
||||||
|
}
|
||||||
|
div.opaque {
|
||||||
|
background: rgba(213, 228, 246, 0.8);
|
||||||
|
}
|
||||||
|
p#graph {
|
||||||
|
border: 1px solid #B15C1B;
|
||||||
|
padding: 0.2em;
|
||||||
|
margin: 0.3em 15vw 0.3em 15vw;
|
||||||
|
height: 30vw;
|
||||||
|
line-height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
p#latest {
|
||||||
|
border: 1px solid #B15C1B;
|
||||||
|
padding: 0.2em;
|
||||||
|
margin: 0.3em 10vw 0.3em 10vw;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
span.cancel-x {
|
||||||
|
font-weight: bold;
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5em;
|
||||||
|
right: 0.5em;
|
||||||
|
cursor: default;
|
||||||
|
color: #9f0000;
|
||||||
|
}
|
||||||
|
span.cancel-x:hover {
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
div#footer {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 70%;
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
dialog#set-target {
|
||||||
|
padding: 1em;
|
||||||
|
width: 25em;
|
||||||
|
}
|
||||||
|
dialog#set-target form {
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
dialog#set-target button {
|
||||||
|
margin-top: 2em;
|
||||||
|
}
|
||||||
|
dialog.dialog-center {
|
||||||
|
position: fixed;
|
||||||
|
background: rgb(213, 228, 246);
|
||||||
|
z-index: 3;
|
||||||
|
width: 25em;
|
||||||
|
border: 2px solid #ef7606;
|
||||||
|
}
|
||||||
|
div#footer > a > img {
|
||||||
|
padding-left: 3vw;
|
||||||
|
padding-right: 3vw;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
background-color: rgb(160, 171, 185);
|
||||||
|
border: 2px solid rgb(160, 171, 185);
|
||||||
|
padding: 15px;
|
||||||
|
margin: 0 0.5em 0 0.5em;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
border: 2px solid black;
|
||||||
|
background-color: #ef7606;
|
||||||
|
}
|
||||||
|
polyline.data {
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
polyline.temperature {
|
||||||
|
stroke: #0079d9;
|
||||||
|
}
|
||||||
|
polyline.target {
|
||||||
|
stroke: #B15C1B;
|
||||||
|
stroke-dasharray: 0.5,1;
|
||||||
|
}
|
||||||
|
polyline.grid {
|
||||||
|
stroke-width: 1;
|
||||||
|
fill: none;
|
||||||
|
stroke: rgb(160, 171, 185);
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-dasharray: 5,7;
|
||||||
|
}
|
||||||
|
polyline.grid-zero {
|
||||||
|
stroke: rgb(107, 114, 123);
|
||||||
|
stroke-dasharray: 5,3;
|
||||||
|
}
|
||||||
BIN
src/static/wallas-logo.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
102
src/wifi.rs
@@ -1,36 +1,27 @@
|
|||||||
// Copyright Claudio Mattera 2024.
|
use alloc::string::ToString;
|
||||||
//
|
|
||||||
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
||||||
|
|
||||||
//! Functions and task for WiFi connection
|
|
||||||
|
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use log::error;
|
use log::error;
|
||||||
use log::info;
|
use log::info;
|
||||||
|
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
|
|
||||||
use embassy_net::new as new_network_stack;
|
|
||||||
|
|
||||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
|
||||||
use embassy_sync::signal::Signal;
|
use embassy_sync::signal::Signal;
|
||||||
|
|
||||||
use esp_wifi::init as initialize_wifi;
|
#[cfg(target_arch = "riscv32")]
|
||||||
use esp_wifi::wifi::new_with_mode as new_wifi_with_mode;
|
use esp_hal::interrupt::software::SoftwareInterruptControl;
|
||||||
use esp_wifi::wifi::wifi_state;
|
|
||||||
use esp_wifi::wifi::ClientConfiguration;
|
|
||||||
use esp_wifi::wifi::Configuration;
|
|
||||||
use esp_wifi::wifi::WifiController;
|
|
||||||
use esp_wifi::wifi::WifiDevice;
|
|
||||||
use esp_wifi::wifi::WifiError as EspWifiError;
|
|
||||||
use esp_wifi::wifi::WifiEvent;
|
|
||||||
use esp_wifi::wifi::WifiStaDevice;
|
|
||||||
use esp_wifi::wifi::WifiState;
|
|
||||||
use esp_wifi::EspWifiController;
|
|
||||||
use esp_wifi::InitializationError as WifiInitializationError;
|
|
||||||
|
|
||||||
|
use esp_radio::{
|
||||||
|
Controller,
|
||||||
|
wifi::{
|
||||||
|
ClientConfig,
|
||||||
|
ModeConfig,
|
||||||
|
WifiController,
|
||||||
|
WifiDevice,
|
||||||
|
WifiEvent,
|
||||||
|
WifiStaState,
|
||||||
|
WifiError,
|
||||||
|
},
|
||||||
|
};
|
||||||
use embassy_net::Config;
|
use embassy_net::Config;
|
||||||
use embassy_net::DhcpConfig;
|
use embassy_net::DhcpConfig;
|
||||||
use embassy_net::Runner;
|
use embassy_net::Runner;
|
||||||
@@ -40,12 +31,11 @@ use embassy_net::StackResources;
|
|||||||
use embassy_time::Duration;
|
use embassy_time::Duration;
|
||||||
use embassy_time::Timer;
|
use embassy_time::Timer;
|
||||||
|
|
||||||
use esp_hal::peripherals::RADIO_CLK;
|
use esp_hal::peripherals::{WIFI, SW_INTERRUPT};
|
||||||
use esp_hal::peripherals::TIMG0;
|
|
||||||
use esp_hal::peripherals::WIFI;
|
|
||||||
use esp_hal::rng::Rng;
|
use esp_hal::rng::Rng;
|
||||||
use esp_hal::timer::timg::TimerGroup;
|
use esp_hal::timer::timg::TimerGroup;
|
||||||
use esp_hal::Blocking;
|
use esp_hal::timer::timg::TimerGroupInstance;
|
||||||
|
// use esp_hal::Blocking;
|
||||||
|
|
||||||
use heapless::String;
|
use heapless::String;
|
||||||
|
|
||||||
@@ -62,34 +52,46 @@ static STACK_COUNT : usize = super::MAX_CONCURRENT_SOCKETS;
|
|||||||
static STACK_RESOURCES: StaticCell<StackResources<STACK_COUNT>> = StaticCell::new();
|
static STACK_RESOURCES: StaticCell<StackResources<STACK_COUNT>> = StaticCell::new();
|
||||||
|
|
||||||
/// Static cell for WiFi controller
|
/// Static cell for WiFi controller
|
||||||
static WIFI_CONTROLLER: StaticCell<EspWifiController<'static>> = StaticCell::new();
|
static WIFI_CONTROLLER: StaticCell<Controller<'static>> = StaticCell::new();
|
||||||
|
|
||||||
/// Signal to request to stop WiFi
|
/// Signal to request to stop WiFi
|
||||||
pub static STOP_WIFI_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
|
pub static STOP_WIFI_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
|
||||||
|
|
||||||
/// Connect to WiFi
|
/// Connect to WiFi
|
||||||
pub async fn connect(
|
pub async fn connect<T>(
|
||||||
spawner: Spawner,
|
spawner: Spawner,
|
||||||
timg0: TimerGroup<'static, TIMG0, Blocking>,
|
timer1: T,
|
||||||
|
interrupt: SW_INTERRUPT<'static>,
|
||||||
|
wifi: WIFI<'static>,
|
||||||
rng: Rng,
|
rng: Rng,
|
||||||
wifi: WIFI,
|
|
||||||
radio_clock_control: RADIO_CLK,
|
|
||||||
(ssid, password): (String<32>, String<64>),
|
(ssid, password): (String<32>, String<64>),
|
||||||
) -> Result<Stack<'static>, Error> {
|
) -> Result<Stack<'static>, WifiError>
|
||||||
|
where
|
||||||
|
T: TimerGroupInstance + 'static,
|
||||||
|
{
|
||||||
let mut rng_wrapper = RngWrapper::from(rng);
|
let mut rng_wrapper = RngWrapper::from(rng);
|
||||||
let seed = rng_wrapper.next_u64();
|
let seed = rng_wrapper.next_u64();
|
||||||
debug!("Use random seed 0x{seed:016x}");
|
debug!("Use random seed 0x{seed:016x}");
|
||||||
|
|
||||||
let wifi_controller = initialize_wifi(timg0.timer0, rng, radio_clock_control)?;
|
let timg0 = TimerGroup::new(timer1);
|
||||||
let wifi_controller: &'static mut _ = WIFI_CONTROLLER.init(wifi_controller);
|
#[cfg(target_arch = "riscv32")]
|
||||||
|
let sw_int = SoftwareInterruptControl::new(interrupt);
|
||||||
let (wifi_interface, controller) = new_wifi_with_mode(wifi_controller, wifi, WifiStaDevice)?;
|
esp_rtos::start(
|
||||||
|
timg0.timer0,
|
||||||
|
#[cfg(target_arch = "riscv32")]
|
||||||
|
sw_int.software_interrupt0,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
let controller = WIFI_CONTROLLER.init(esp_radio::init().unwrap());
|
||||||
|
let (controller, interfaces) = esp_radio::wifi::new(controller, wifi, Default::default()).unwrap();
|
||||||
|
let wifi_interface = interfaces.sta;
|
||||||
|
|
||||||
let config = Config::dhcpv4(DhcpConfig::default());
|
let config = Config::dhcpv4(DhcpConfig::default());
|
||||||
|
|
||||||
debug!("Initialize network stack");
|
debug!("Initialize network stack");
|
||||||
let stack_resources: &'static mut _ = STACK_RESOURCES.init(StackResources::new());
|
let stack_resources: &'static mut _ = STACK_RESOURCES.init(StackResources::new());
|
||||||
let (stack, runner) = new_network_stack(wifi_interface, config, stack_resources, seed);
|
let (stack, runner) = embassy_net::new(wifi_interface, config, stack_resources, seed);
|
||||||
|
|
||||||
spawner.must_spawn(connection(controller, ssid, password));
|
spawner.must_spawn(connection(controller, ssid, password));
|
||||||
spawner.must_spawn(net_task(runner));
|
spawner.must_spawn(net_task(runner));
|
||||||
@@ -116,7 +118,7 @@ pub async fn connect(
|
|||||||
|
|
||||||
/// Task for ongoing network processing
|
/// Task for ongoing network processing
|
||||||
#[embassy_executor::task]
|
#[embassy_executor::task]
|
||||||
async fn net_task(mut runner: Runner<'static, WifiDevice<'static, WifiStaDevice>>) {
|
async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
|
||||||
runner.run().await;
|
runner.run().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,23 +137,23 @@ async fn connection_fallible(
|
|||||||
mut controller: WifiController<'static>,
|
mut controller: WifiController<'static>,
|
||||||
ssid: String<32>,
|
ssid: String<32>,
|
||||||
password: String<64>,
|
password: String<64>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), WifiError> {
|
||||||
debug!("Start connection");
|
debug!("Start connection");
|
||||||
debug!("Device capabilities: {:?}", controller.capabilities());
|
debug!("Device capabilities: {:?}", controller.capabilities());
|
||||||
loop {
|
loop {
|
||||||
if wifi_state() == WifiState::StaConnected {
|
if esp_radio::wifi::sta_state() == WifiStaState::Connected {
|
||||||
// wait until we're no longer connected
|
// wait until we're no longer connected
|
||||||
controller.wait_for_event(WifiEvent::StaDisconnected).await;
|
controller.wait_for_event(WifiEvent::StaDisconnected).await;
|
||||||
Timer::after(Duration::from_millis(5000)).await;
|
Timer::after(Duration::from_millis(5000)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !matches!(controller.is_started(), Ok(true)) {
|
if !matches!(controller.is_started(), Ok(true)) {
|
||||||
let client_config = Configuration::Client(ClientConfiguration {
|
let client_config = ModeConfig::Client(
|
||||||
ssid: ssid.clone(),
|
ClientConfig::default()
|
||||||
password: password.clone(),
|
.with_ssid(ssid.clone().to_string())
|
||||||
..Default::default()
|
.with_password(password.clone().to_string()),
|
||||||
});
|
);
|
||||||
controller.set_configuration(&client_config)?;
|
controller.set_config(&client_config)?;
|
||||||
debug!("Starting WiFi controller");
|
debug!("Starting WiFi controller");
|
||||||
controller.start_async().await?;
|
controller.start_async().await?;
|
||||||
debug!("WiFi controller started");
|
debug!("WiFi controller started");
|
||||||
@@ -180,6 +182,7 @@ async fn connection_fallible(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
/// Error within WiFi connection
|
/// Error within WiFi connection
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
@@ -201,3 +204,4 @@ impl From<EspWifiError> for Error {
|
|||||||
Self::Wifi(error)
|
Self::Wifi(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||