mirror of
https://github.com/Djeeberjr/fw-anwesenheit.git
synced 2026-05-01 02:59:09 +00:00
Compare commits
63 Commits
0f5ca88ae4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1731cff9c9 | |||
| 336249fd12 | |||
| 5ffeb969e2 | |||
| a4ab0bad48 | |||
| 02798d90c4 | |||
| 07d51264f9 | |||
| 6cc6df6298 | |||
| 7d78aa8c4c | |||
|
|
fb8d98da28 | ||
| 8100748f8a | |||
|
|
f22eb91b67 | ||
|
|
a04400a3a0 | ||
|
|
6f7561a295 | ||
|
|
279e2f7454 | ||
|
|
610840311f | ||
| 56d2dbfa7c | |||
| 35f12a4c45 | |||
| 7346fb42bd | |||
| c0bf8399a3 | |||
| 9852534dc6 | |||
| d63e9e964d | |||
| 16ea1db55f | |||
| a0ed04a560 | |||
|
|
4e988e8f01 | ||
| 009f6cbb2e | |||
|
|
967da9fc30 | ||
| 00cb7efedb | |||
| ebbec7885e | |||
| 7ecd2052d8 | |||
| 96512c8a12 | |||
| c3eaff03d9 | |||
| 4bf89626b9 | |||
| 7c0c0699b5 | |||
| 1ea70e4993 | |||
| 770dca5b0f | |||
| 2e75ba2908 | |||
| 141c1aa9cb | |||
|
|
4abbd844d2 | ||
| 7346b47816 | |||
| cd63dd3ee4 | |||
| f5d4ae1e05 | |||
| bd3f6731fd | |||
| 6fdcf7679f | |||
|
|
c4d6ed45f1 | ||
|
|
41adf7353d | ||
| 6421074931 | |||
| a34dc18381 | |||
| 252e63c607 | |||
| 99848f0e6d | |||
| f46cdc4d29 | |||
| a8d64f6af5 | |||
| 8fb6bac651 | |||
| 7eb18376e1 | |||
| b8bba28bda | |||
| 5c0ad18b94 | |||
| 75130e2d20 | |||
| 6b2c56f3e5 | |||
| 2980d34394 | |||
| 9b926f7a34 | |||
| f1b471c6d8 | |||
| 030a372949 | |||
| 211961a770 | |||
| dfe5197ab8 |
@@ -12,3 +12,7 @@ target = "riscv32imac-unknown-none-elf"
|
||||
|
||||
[unstable]
|
||||
build-std = ["alloc", "core"]
|
||||
|
||||
[env]
|
||||
WIFI_PASSWD = "hunter22"
|
||||
WIFI_SSID = "fwa"
|
||||
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/pcb/bom/ibom.html linguist-vendored
|
||||
942
Cargo.lock
generated
942
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
75
Cargo.toml
75
Cargo.toml
@@ -12,64 +12,45 @@ bench = false
|
||||
|
||||
[dependencies]
|
||||
esp-bootloader-esp-idf = "0.1.0"
|
||||
embassy-net = { version = "0.7.0", features = [
|
||||
"dhcpv4",
|
||||
"medium-ethernet",
|
||||
"tcp",
|
||||
"udp",
|
||||
] }
|
||||
embedded-hal = "=1.0.0"
|
||||
embedded-io = "0.6.1"
|
||||
embedded-io-async = "0.6.1"
|
||||
esp-alloc = "0.8.0"
|
||||
esp-hal = { version = "1.0.0-beta.1", features = ["esp32c6", "unstable"] }
|
||||
smoltcp = { version = "0.12.0", default-features = false, features = [
|
||||
"medium-ethernet",
|
||||
"multicast",
|
||||
"proto-dhcpv4",
|
||||
"proto-dns",
|
||||
"proto-ipv4",
|
||||
"socket-dns",
|
||||
"socket-icmp",
|
||||
"socket-raw",
|
||||
"socket-tcp",
|
||||
"socket-udp",
|
||||
] }
|
||||
# for more networking protocol support see https://crates.io/crates/edge-net
|
||||
bleps = { git = "https://github.com/bjoernQ/bleps", package = "bleps", rev = "a5148d8ae679e021b78f53fd33afb8bb35d0b62e", features = [
|
||||
"async",
|
||||
"macros",
|
||||
] }
|
||||
esp-hal = { version = "1.0.0-rc.1", features = ["esp32c6", "unstable"] }
|
||||
esp-alloc = "0.9.0"
|
||||
esp-println = { version = "0.16.0", features = ["esp32c6", "log-04"] }
|
||||
esp-radio = { version = "0.16.0", features = ["esp32c6","esp-alloc", "wifi", "log-04", "smoltcp","unstable"]}
|
||||
esp-rtos = { version = "0.1.1", features = ["esp32c6", "embassy", "esp-radio", "esp-alloc"] }
|
||||
|
||||
critical-section = "1.2.0"
|
||||
embassy-executor = { version = "0.7.0", features = ["nightly"] }
|
||||
embassy-time = { version = "0.4.0", features = ["generic-queue-8"] }
|
||||
esp-hal-embassy = { version = "0.9.0", features = ["esp32c6"] }
|
||||
esp-wifi = { version = "0.15.0", features = [
|
||||
"wifi",
|
||||
"builtin-scheduler",
|
||||
"esp-alloc",
|
||||
"esp32c6",
|
||||
"log-04",
|
||||
] }
|
||||
log = { version = "0.4", features = ["release_max_level_info"]}
|
||||
static_cell = { version = "2.1.1", features = ["nightly"] }
|
||||
heapless = { version = "0.8.0", default-features = false }
|
||||
static_cell = { version = "2.1.0", features = ["nightly"] }
|
||||
esp-println = { version = "0.15.0", features = ["esp32c6", "log-04"] }
|
||||
log = { version = "0.4" }
|
||||
chrono = { version = "0.4.41", default-features = false }
|
||||
|
||||
embedded-hal = "1.0.0"
|
||||
embedded-io = "0.7.1"
|
||||
embedded-io-async = "0.7.0"
|
||||
embassy-executor = { version = "0.9.0", features = [] }
|
||||
embassy-time = { version = "0.5.0", features = [] }
|
||||
embassy-futures = { version = "0.1.2", features = ["log"] }
|
||||
embassy-sync = { version = "0.7.2", features = ["log"] }
|
||||
|
||||
embassy-net = { version = "0.7.0", features = [ "dhcpv4", "medium-ethernet", "tcp", "udp" ] }
|
||||
smoltcp = { version = "0.12.0", default-features = false, features = [ "medium-ethernet", "multicast", "proto-dhcpv4", "proto-dns", "proto-ipv4", "socket-dns", "socket-icmp", "socket-raw", "socket-tcp", "socket-udp" ] }
|
||||
bleps = { git = "https://github.com/bjoernQ/bleps", package = "bleps", rev = "a5148d8ae679e021b78f53fd33afb8bb35d0b62e", features = [ "async", "macros" ] }
|
||||
edge-dhcp = { version = "0.6.0", features = ["log"] }
|
||||
edge-nal = "0.5.0"
|
||||
edge-nal-embassy = { version = "0.6.0", features = ["log"] }
|
||||
|
||||
picoserve = { git = "https://github.com/sammhicks/picoserve.git", rev = "400df53f61137e1bb2883ec610fc191bfe551a3a", features = ["embassy", "log", "json"] }
|
||||
embassy-sync = { version = "0.7.0", features = ["log"] }
|
||||
ds3231 = { version = "0.3.0", features = ["async", "temperature_f32"] }
|
||||
chrono = { version = "0.4.41", default-features = false }
|
||||
dir-embed = "0.3.0"
|
||||
serde = { version = "1.0.219", default-features = false, features = ["derive", "alloc"] }
|
||||
serde_json = { version = "1.0.143", default-features = false, features = ["alloc"]}
|
||||
|
||||
ds3231 = { version = "0.3.0", features = ["async", "temperature_f32"] }
|
||||
esp-hal-smartled = { git = "https://github.com/esp-rs/esp-hal-community.git", package = "esp-hal-smartled", branch = "main", features = ["esp32c6"]}
|
||||
smart-leds = "0.4.0"
|
||||
serde = { version = "1.0.219", default-features = false, features = ["derive", "alloc"] }
|
||||
|
||||
embedded-sdmmc = "0.8.0"
|
||||
embedded-hal-bus = "0.3.0"
|
||||
serde_json = { version = "1.0.143", default-features = false, features = ["alloc"]}
|
||||
embassy-futures = { version = "0.1.2", features = ["log"] }
|
||||
thiserror = { version = "2.0.17", default-features = false }
|
||||
|
||||
[profile.dev]
|
||||
# Rust debug is too slow.
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod nfc_reader;
|
||||
pub mod rtc;
|
||||
pub mod buzzer;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
const DEVICE_TYPE_CODE: u8 = 0b10100000;
|
||||
|
||||
const DEVICE_ADDRESS_CODE: u8 = 0b000000; // 3 bits for device address | default A0 = 0 A1 = 0 A2 = 0
|
||||
|
||||
const WRITE_CODE: u8 = 0b00000000; // 0 for write
|
||||
const READ_CODE: u8 = 0b00000001; // 1 for read
|
||||
|
||||
const DEVICE_ADDRESS_WRITE: u8 = DEVICE_TYPE_CODE | DEVICE_ADDRESS_CODE | WRITE_CODE; // I2C address write for FRAM
|
||||
const DEVICE_ADDRESS_READ: u8 = DEVICE_TYPE_CODE | DEVICE_ADDRESS_CODE | READ_CODE; // I2C address read for FRAM
|
||||
@@ -1,44 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use log::debug;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::hardware::{Buzzer, Hotspot, StatusLed};
|
||||
|
||||
pub struct MockBuzzer {}
|
||||
|
||||
impl Buzzer for MockBuzzer {
|
||||
async fn modulated_tone(&mut self, frequency_hz: f64, duration: Duration) -> Result<()> {
|
||||
debug!("MockBuzzer: modulte tone: {frequency_hz} Hz");
|
||||
sleep(duration).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockLed {}
|
||||
|
||||
impl StatusLed for MockLed {
|
||||
fn turn_off(&mut self) -> Result<()> {
|
||||
debug!("Turn mock LED off");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn turn_on(&mut self, color: rgb::RGB8) -> Result<()> {
|
||||
debug!("Turn mock LED on to: {color}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MockHotspot {}
|
||||
|
||||
impl Hotspot for MockHotspot {
|
||||
async fn enable_hotspot(&self) -> Result<()> {
|
||||
debug!("Mockhotspot: Enable hotspot");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disable_hotspot(&self) -> Result<()> {
|
||||
debug!("Mockhotspot: Disable hotspot");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use embassy_time::{Duration, Timer};
|
||||
use esp_hal::{Async, uart::Uart};
|
||||
use log::{debug, info};
|
||||
use log::{debug, info, warn};
|
||||
|
||||
use crate::TallyPublisher;
|
||||
|
||||
@@ -17,7 +17,15 @@ pub async fn rfid_reader_task(mut uart_device: Uart<'static, Async>, chan: Tally
|
||||
core::fmt::Write::write_fmt(&mut hex_str, format_args!("{:02X} ", byte)).ok();
|
||||
}
|
||||
info!("Read {n} bytes from UART: {hex_str}");
|
||||
chan.publish(uart_buffer[..8].try_into().unwrap()).await;
|
||||
|
||||
match extract_id(&uart_buffer) {
|
||||
Some(read) => {
|
||||
chan.publish(read.try_into().unwrap()).await;
|
||||
}
|
||||
None => {
|
||||
warn!("Invalid read from the RFID reader");
|
||||
}
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error reading from UART: {e}");
|
||||
@@ -26,3 +34,35 @@ pub async fn rfid_reader_task(mut uart_device: Uart<'static, Async>, chan: Tally
|
||||
Timer::after(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Scans the UART output and retuns the first propper read ID
|
||||
/// This ensures that only valid ID are parsed
|
||||
///
|
||||
/// A valid read looks like this:
|
||||
/// The first byte is always 0x02 (Start of text)
|
||||
/// Followed by 12 Bytes of chars
|
||||
/// Ended by 0x03 (End of text)
|
||||
pub fn extract_id(buffer: &[u8]) -> Option<[u8; 12]> {
|
||||
const STX: u8 = 0x02; // Start of Text ASCII char
|
||||
const ETX: u8 = 0x03; // End of Text ASCII char
|
||||
const ID_LENGTH: usize = 12;
|
||||
const MINIMUM_SEQUENCE: usize = ID_LENGTH + 2; // STX + 12 bytes + ETX
|
||||
|
||||
if buffer.len() < MINIMUM_SEQUENCE {
|
||||
return None;
|
||||
}
|
||||
|
||||
for window_start in 0..=buffer.len() - MINIMUM_SEQUENCE {
|
||||
if buffer[window_start] == STX {
|
||||
let id_end = window_start + ID_LENGTH + 1;
|
||||
|
||||
if buffer[id_end] == ETX {
|
||||
let mut id = [0u8; ID_LENGTH];
|
||||
id.copy_from_slice(&buffer[window_start + 1..id_end]);
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use chrono::{TimeZone, Utc};
|
||||
use ds3231::{
|
||||
Config, DS3231, DS3231Error, InterruptControl, Oscillator, SquareWaveFrequency,
|
||||
TimeRepresentation,
|
||||
Config, DS3231, InterruptControl, Oscillator, SquareWaveFrequency, TimeRepresentation,
|
||||
};
|
||||
use esp_hal::{
|
||||
Async,
|
||||
@@ -9,16 +8,12 @@ use esp_hal::{
|
||||
};
|
||||
use log::{debug, error, info};
|
||||
|
||||
use crate::{FEEDBACK_STATE, drivers, feedback, store::Date};
|
||||
use crate::{FEEDBACK_STATE, drivers, feedback};
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/build_time.rs"));
|
||||
|
||||
const RTC_ADDRESS: u8 = 0x68;
|
||||
|
||||
const SECS_PER_DAY: u64 = 86_400;
|
||||
const UNIX_OFFSET_DAYS: u64 = 719_163; // Days from 0000-03-01 to 1970-01-01
|
||||
const UTC_PLUS_ONE: u64 = 3600;
|
||||
|
||||
pub struct RTCClock {
|
||||
dev: DS3231<I2c<'static, Async>>,
|
||||
}
|
||||
@@ -34,10 +29,7 @@ impl RTCClock {
|
||||
|
||||
pub async fn get_time(&mut self) -> u64 {
|
||||
match self.dev.datetime().await {
|
||||
Ok(datetime) => {
|
||||
let utc_time = datetime.and_utc().timestamp() as u64;
|
||||
utc_time
|
||||
}
|
||||
Ok(datetime) => datetime.and_utc().timestamp() as u64,
|
||||
Err(e) => {
|
||||
FEEDBACK_STATE.signal(feedback::FeedbackState::Error);
|
||||
error!("Failed to read RTC datetime: {:?}", e);
|
||||
@@ -45,58 +37,6 @@ impl RTCClock {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_date(&mut self) -> Date {
|
||||
let (year, month, day) = unix_to_ymd_string(self.get_time().await);
|
||||
|
||||
let mut buffer: Date = [0; 10];
|
||||
|
||||
// Write YYYY
|
||||
buffer[0] = b'0' + ((year / 1000) % 10) as u8;
|
||||
buffer[1] = b'0' + ((year / 100) % 10) as u8;
|
||||
buffer[2] = b'0' + ((year / 10) % 10) as u8;
|
||||
buffer[3] = b'0' + (year % 10) as u8;
|
||||
buffer[4] = b'.';
|
||||
|
||||
// Write MM
|
||||
buffer[5] = b'0' + (month / 10) as u8;
|
||||
buffer[6] = b'0' + (month % 10) as u8;
|
||||
|
||||
buffer[7] = b'.';
|
||||
|
||||
// Write DD
|
||||
buffer[8] = b'0' + (day / 10) as u8;
|
||||
buffer[9] = b'0' + (day % 10) as u8;
|
||||
|
||||
buffer
|
||||
}
|
||||
}
|
||||
|
||||
fn unix_to_ymd_string(timestamp: u64) -> (u16, u8, u8) {
|
||||
// Apply UTC+1 offset
|
||||
let ts = timestamp + UTC_PLUS_ONE;
|
||||
|
||||
// Convert to total days since UNIX epoch
|
||||
let days_since_epoch = ts / SECS_PER_DAY;
|
||||
|
||||
// Convert to proleptic Gregorian date
|
||||
civil_from_days(days_since_epoch as i64 + UNIX_OFFSET_DAYS as i64)
|
||||
}
|
||||
|
||||
// This function returns (year, month, day).
|
||||
// Based on the algorithm by Howard Hinnant.
|
||||
fn civil_from_days(z: i64) -> (u16, u8, u8) {
|
||||
let mut z = z;
|
||||
z -= 60; // shift epoch for algorithm
|
||||
let era = (z >= 0).then_some(z).unwrap_or(z - 146096) / 146097;
|
||||
let doe = z - era * 146097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let m = mp + (if mp < 10 { 3 } else { -9 }); // [1, 12]
|
||||
((y + (m <= 2) as i64) as u16, m as u8, d as u8)
|
||||
}
|
||||
|
||||
pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>> {
|
||||
@@ -112,7 +52,7 @@ pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>>
|
||||
square_wave_frequency: SquareWaveFrequency::Hz1,
|
||||
interrupt_control: InterruptControl::Interrupt, // Enable interrupt mode
|
||||
battery_backed_square_wave: false,
|
||||
oscillator_enable: Oscillator::Disabled,
|
||||
oscillator_enable: Oscillator::Enabled,
|
||||
};
|
||||
|
||||
match rtc.configure(&rtc_config).await {
|
||||
@@ -124,11 +64,13 @@ pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>>
|
||||
}
|
||||
}
|
||||
|
||||
if rtc.datetime().await.unwrap() < naive_dt {
|
||||
rtc.set_datetime(&naive_dt).await.unwrap_or_else(|e| {
|
||||
FEEDBACK_STATE.signal(feedback::FeedbackState::Error);
|
||||
error!("Failed to set RTC datetime: {:?}", e);
|
||||
});
|
||||
info!("RTC datetime set to: {}", naive_dt);
|
||||
}
|
||||
|
||||
match rtc.status().await {
|
||||
Ok(mut status) => {
|
||||
@@ -142,12 +84,7 @@ pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>>
|
||||
Err(e) => info!("Failed to read status: {:?}", e),
|
||||
}
|
||||
|
||||
info!("RTC time is: {:?}", rtc.datetime().await.unwrap());
|
||||
|
||||
rtc
|
||||
}
|
||||
|
||||
pub async fn read_rtc_time<'a>(
|
||||
rtc: &'a mut DS3231<I2c<'static, Async>>,
|
||||
) -> Result<u64, DS3231Error<esp_hal::i2c::master::Error>> {
|
||||
let timestamp_result = rtc.datetime().await?;
|
||||
Ok(timestamp_result.and_utc().timestamp() as u64)
|
||||
}
|
||||
|
||||
124
src/feedback.rs
124
src/feedback.rs
@@ -1,11 +1,12 @@
|
||||
use embassy_time::{Duration, Timer};
|
||||
use esp_hal::{peripherals, rmt::ConstChannelAccess};
|
||||
use esp_hal::gpio::Output;
|
||||
use esp_hal_smartled::SmartLedsAdapterAsync;
|
||||
use log::debug;
|
||||
use smart_leds::SmartLedsWriteAsync;
|
||||
use smart_leds::colors::{BLACK, GREEN, RED, YELLOW};
|
||||
use smart_leds::{brightness, colors::BLUE};
|
||||
|
||||
use crate::init::hardware;
|
||||
use crate::{FEEDBACK_STATE, init};
|
||||
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
@@ -24,14 +25,10 @@ const LED_LEVEL: u8 = 255;
|
||||
|
||||
#[embassy_executor::task]
|
||||
pub async fn feedback_task(
|
||||
mut led: SmartLedsAdapterAsync<
|
||||
ConstChannelAccess<esp_hal::rmt::Tx, 0>,
|
||||
{ init::hardware::LED_BUFFER_SIZE },
|
||||
>,
|
||||
buzzer: peripherals::GPIO21<'static>,
|
||||
mut led: SmartLedsAdapterAsync<'static, { hardware::LED_BUFFER_SIZE }>,
|
||||
mut buzzer: Output<'static>,
|
||||
) {
|
||||
debug!("Starting feedback task");
|
||||
let mut buzzer = init::hardware::setup_buzzer(buzzer);
|
||||
loop {
|
||||
let feedback_state = FEEDBACK_STATE.wait().await;
|
||||
match feedback_state {
|
||||
@@ -46,6 +43,12 @@ pub async fn feedback_task(
|
||||
Timer::after(Duration::from_millis(100)).await;
|
||||
buzzer.set_low();
|
||||
Timer::after(Duration::from_millis(50)).await;
|
||||
led.write(brightness(
|
||||
[BLACK; init::hardware::NUM_LEDS].into_iter(),
|
||||
LED_LEVEL,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
FeedbackState::Nack => {
|
||||
led.write(brightness(
|
||||
@@ -101,6 +104,8 @@ pub async fn feedback_task(
|
||||
buzzer.set_high();
|
||||
Timer::after(Duration::from_millis(100)).await;
|
||||
buzzer.set_low();
|
||||
|
||||
Timer::after(Duration::from_secs(2)).await;
|
||||
led.write(brightness(
|
||||
[BLACK; init::hardware::NUM_LEDS].into_iter(),
|
||||
LED_LEVEL,
|
||||
@@ -118,7 +123,7 @@ pub async fn feedback_task(
|
||||
}
|
||||
FeedbackState::Idle => {
|
||||
led.write(brightness(
|
||||
[GREEN; init::hardware::NUM_LEDS].into_iter(),
|
||||
[BLACK; init::hardware::NUM_LEDS].into_iter(),
|
||||
LED_LEVEL,
|
||||
))
|
||||
.await
|
||||
@@ -128,106 +133,3 @@ pub async fn feedback_task(
|
||||
debug!("Feedback state: {:?}", feedback_state);
|
||||
}
|
||||
}
|
||||
|
||||
// async fn beep_ack() {
|
||||
// buzzer.set_high();
|
||||
// buzzer.set_low();
|
||||
// //Timer::after(Duration::from_millis(100)).await;
|
||||
// }
|
||||
|
||||
/* pub async fn failure(&mut self) {
|
||||
let buzzer_handle = Self::beep_nak(&mut self.buzzer);
|
||||
let led_handle = Self::flash_led_for_duration(&mut self.led, RED, LED_BLINK_DURATION);
|
||||
|
||||
let (buzzer_result, _) = join!(buzzer_handle, led_handle);
|
||||
|
||||
buzzer_result.unwrap_or_else(|err| { error!("Failed to buzz: {err}");
|
||||
});
|
||||
|
||||
let _ = self.led_to_status();
|
||||
}
|
||||
|
||||
pub async fn activate_error_state(&mut self) -> Result<()> {
|
||||
self.led.turn_on(RED)?;
|
||||
Self::beep_nak(&mut self.buzzer).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn startup(&mut self){
|
||||
self.device_status = DeviceStatus::Ready;
|
||||
|
||||
let led_handle = Self::flash_led_for_duration(&mut self.led, GREEN, Duration::from_secs(1));
|
||||
let buzzer_handle = Self::beep_startup(&mut self.buzzer);
|
||||
|
||||
let (buzzer_result, led_result) = join!(buzzer_handle, led_handle);
|
||||
|
||||
buzzer_result.unwrap_or_else(|err| {
|
||||
error!("Failed to buzz: {err}");
|
||||
});
|
||||
|
||||
led_result.unwrap_or_else(|err| {
|
||||
error!("Failed to blink led: {err}");
|
||||
});
|
||||
|
||||
let _ = self.led_to_status();
|
||||
}
|
||||
|
||||
|
||||
async fn flash_led_for_duration(led: &mut L, color: RGB8, duration: Duration) -> Result<()> {
|
||||
led.turn_on(color)?;
|
||||
|
||||
sleep(duration).await;
|
||||
|
||||
led.turn_off()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn beep_ack(buzzer: &mut B) -> Result<()> {
|
||||
buzzer
|
||||
.modulated_tone(1200.0, Duration::from_millis(100))
|
||||
.await?;
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
buzzer
|
||||
.modulated_tone(2000.0, Duration::from_millis(50))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn beep_nak(buzzer: &mut B) -> Result<()> {
|
||||
buzzer
|
||||
.modulated_tone(600.0, Duration::from_millis(150))
|
||||
.await?;
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
buzzer
|
||||
.modulated_tone(600.0, Duration::from_millis(150))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn beep_startup(buzzer: &mut B) -> Result<()> {
|
||||
buzzer
|
||||
.modulated_tone(523.0, Duration::from_millis(150))
|
||||
.await?;
|
||||
buzzer
|
||||
.modulated_tone(659.0, Duration::from_millis(150))
|
||||
.await?;
|
||||
buzzer
|
||||
.modulated_tone(784.0, Duration::from_millis(150))
|
||||
.await?;
|
||||
buzzer
|
||||
.modulated_tone(1046.0, Duration::from_millis(200))
|
||||
.await?;
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
buzzer
|
||||
.modulated_tone(784.0, Duration::from_millis(100))
|
||||
.await?;
|
||||
buzzer
|
||||
.modulated_tone(880.0, Duration::from_millis(200))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
use core::cell::RefCell;
|
||||
use critical_section::Mutex;
|
||||
use embassy_executor::Spawner;
|
||||
use embassy_net::Stack;
|
||||
use embassy_time::{Duration, Timer};
|
||||
use esp_hal::Blocking;
|
||||
use esp_hal::gpio::Input;
|
||||
use esp_hal::delay::Delay;
|
||||
use esp_hal::gpio::AnyPin;
|
||||
use esp_hal::i2c::master::Config;
|
||||
use esp_hal::peripherals::{
|
||||
GPIO0, GPIO1, GPIO16, GPIO17, GPIO18, GPIO19, GPIO20, GPIO21, GPIO22, GPIO23, I2C0, RMT, SPI2,
|
||||
UART1,
|
||||
GPIO1, GPIO16, GPIO17, GPIO18, GPIO19, GPIO20, GPIO21, GPIO22, GPIO23, I2C0, RMT, SPI2, UART1,
|
||||
};
|
||||
use esp_hal::rmt::{ConstChannelAccess, Rmt};
|
||||
use esp_hal::rmt::Rmt;
|
||||
use esp_hal::spi::master::{Config as Spi_config, Spi};
|
||||
use esp_hal::system::software_reset;
|
||||
use esp_hal::time::Rate;
|
||||
use esp_hal::timer::timg::TimerGroup;
|
||||
use esp_hal::{
|
||||
@@ -19,12 +18,15 @@ use esp_hal::{
|
||||
clock::CpuClock,
|
||||
gpio::{Output, OutputConfig},
|
||||
i2c::master::I2c,
|
||||
timer::systimer::SystemTimer,
|
||||
uart::Uart,
|
||||
};
|
||||
use esp_hal_smartled::{SmartLedsAdapterAsync, buffer_size_async};
|
||||
use esp_println::logger::init_logger;
|
||||
use log::{debug, error};
|
||||
use smart_leds::SmartLedsWriteAsync;
|
||||
use smart_leds::brightness;
|
||||
use smart_leds::colors::RED;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::init::network;
|
||||
use crate::init::sd_card::{SDCardPersistence, setup_sdcard};
|
||||
@@ -47,54 +49,80 @@ use crate::init::wifi;
|
||||
*
|
||||
*************************************************/
|
||||
|
||||
pub const NUM_LEDS: usize = 66;
|
||||
pub const LED_BUFFER_SIZE: usize = NUM_LEDS * 25;
|
||||
|
||||
static SD_DET: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));
|
||||
pub const NUM_LEDS: usize = 1;
|
||||
pub const LED_BUFFER_SIZE: usize = buffer_size_async(NUM_LEDS);
|
||||
|
||||
#[panic_handler]
|
||||
fn panic(info: &core::panic::PanicInfo) -> ! {
|
||||
loop {
|
||||
let delay = Delay::new();
|
||||
error!("PANIC: {info}");
|
||||
}
|
||||
delay.delay(esp_hal::time::Duration::from_secs(30));
|
||||
software_reset()
|
||||
}
|
||||
|
||||
esp_bootloader_esp_idf::esp_app_desc!();
|
||||
|
||||
pub async fn hardware_init(
|
||||
spawner: &mut Spawner,
|
||||
) -> (
|
||||
Uart<'static, Async>,
|
||||
Stack<'static>,
|
||||
I2c<'static, Async>,
|
||||
SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE>,
|
||||
GPIO21<'static>,
|
||||
GPIO0<'static>,
|
||||
SDCardPersistence,
|
||||
) {
|
||||
#[derive(Error, Debug)]
|
||||
pub enum HardwareInitError {
|
||||
#[error("Failed to etup UART")]
|
||||
Uart(#[from] esp_hal::uart::ConfigError),
|
||||
|
||||
#[error("Failed to setup I2C")]
|
||||
I2C(#[from] esp_hal::i2c::master::ConfigError),
|
||||
|
||||
#[error("Failed to setup SPI")]
|
||||
Spi(#[from] esp_hal::spi::master::ConfigError),
|
||||
|
||||
#[error("Failed to setuo LED")]
|
||||
Led(#[from] esp_hal::rmt::Error),
|
||||
|
||||
#[error("Failed to setup wifi")]
|
||||
Wifi(#[from] wifi::WifiError),
|
||||
}
|
||||
|
||||
pub struct AppHardware {
|
||||
pub uart: Uart<'static, Async>,
|
||||
pub network_stack: Stack<'static>,
|
||||
pub i2c: I2c<'static, Async>,
|
||||
pub buzzer: Output<'static>,
|
||||
pub sd_present: AnyPin<'static>,
|
||||
pub led: SmartLedsAdapterAsync<'static, LED_BUFFER_SIZE>,
|
||||
pub sdcard: SDCardPersistence,
|
||||
}
|
||||
|
||||
impl AppHardware {
|
||||
pub async fn init(spawner: Spawner) -> Result<Self, HardwareInitError> {
|
||||
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
|
||||
let peripherals = esp_hal::init(config);
|
||||
|
||||
esp_alloc::heap_allocator!(size: 72 * 1024);
|
||||
esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 65536);
|
||||
|
||||
let timer0 = SystemTimer::new(peripherals.SYSTIMER);
|
||||
esp_hal_embassy::init(timer0.alarm0);
|
||||
let timg0 = TimerGroup::new(peripherals.TIMG0);
|
||||
let sw_interrupt =
|
||||
esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
|
||||
esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
|
||||
|
||||
init_logger(log::LevelFilter::Debug);
|
||||
|
||||
let timer1 = TimerGroup::new(peripherals.TIMG0);
|
||||
let mut rng = esp_hal::rng::Rng::new(peripherals.RNG);
|
||||
let mut led = setup_led(peripherals.RMT, peripherals.GPIO1)?;
|
||||
let _ = led.write(brightness(
|
||||
[RED; NUM_LEDS].into_iter(),
|
||||
255,
|
||||
))
|
||||
.await;
|
||||
|
||||
let rng = esp_hal::rng::Rng::new();
|
||||
let network_seed = (rng.random() as u64) << 32 | rng.random() as u64;
|
||||
|
||||
wifi::set_antenna_mode(peripherals.GPIO3, peripherals.GPIO14).await;
|
||||
let interfaces = wifi::setup_wifi(timer1.timer0, rng, peripherals.WIFI, spawner);
|
||||
let stack = network::setup_network(network_seed, interfaces.ap, spawner);
|
||||
let interfaces = wifi::setup_wifi(peripherals.WIFI, spawner)?;
|
||||
let network_stack = network::setup_network(network_seed, interfaces.ap, spawner);
|
||||
|
||||
Timer::after(Duration::from_millis(1)).await;
|
||||
|
||||
let uart_device = setup_uart(peripherals.UART1, peripherals.GPIO16, peripherals.GPIO17);
|
||||
let uart_device = setup_uart(peripherals.UART1, peripherals.GPIO16, peripherals.GPIO17)?;
|
||||
|
||||
let i2c_device = setup_i2c(peripherals.I2C0, peripherals.GPIO22, peripherals.GPIO23);
|
||||
let i2c_device = setup_i2c(peripherals.I2C0, peripherals.GPIO22, peripherals.GPIO23)?;
|
||||
|
||||
let sd_det_gpio = peripherals.GPIO0;
|
||||
|
||||
@@ -103,7 +131,7 @@ pub async fn hardware_init(
|
||||
peripherals.GPIO19,
|
||||
peripherals.GPIO20,
|
||||
peripherals.GPIO18,
|
||||
);
|
||||
)?;
|
||||
|
||||
let sd_cs_pin = Output::new(
|
||||
peripherals.GPIO2,
|
||||
@@ -114,55 +142,44 @@ pub async fn hardware_init(
|
||||
let vol_mgr = setup_sdcard(spi_bus, sd_cs_pin);
|
||||
|
||||
let buzzer_gpio = peripherals.GPIO21;
|
||||
let buzzer = setup_buzzer(buzzer_gpio);
|
||||
|
||||
Timer::after(Duration::from_millis(500)).await;
|
||||
|
||||
let led = setup_led(peripherals.RMT, peripherals.GPIO1);
|
||||
|
||||
debug!("hardware init done");
|
||||
|
||||
(
|
||||
uart_device,
|
||||
stack,
|
||||
i2c_device,
|
||||
Ok(Self {
|
||||
uart: uart_device,
|
||||
network_stack,
|
||||
i2c: i2c_device,
|
||||
buzzer,
|
||||
sd_present: sd_det_gpio.into(),
|
||||
led,
|
||||
buzzer_gpio,
|
||||
sd_det_gpio,
|
||||
vol_mgr,
|
||||
)
|
||||
sdcard: vol_mgr,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_uart(
|
||||
uart1: UART1<'static>,
|
||||
uart_tx: GPIO16<'static>,
|
||||
uart_rx: GPIO17<'static>,
|
||||
) -> Uart<'static, Async> {
|
||||
let uard_device = Uart::new(uart1, esp_hal::uart::Config::default().with_baudrate(9600));
|
||||
|
||||
match uard_device {
|
||||
Ok(block) => block.with_rx(uart_rx).with_tx(uart_tx).into_async(),
|
||||
Err(e) => {
|
||||
error!("Failed to initialize UART: {e}");
|
||||
panic!(); //TODO panic!
|
||||
}
|
||||
}
|
||||
) -> Result<Uart<'static, Async>, esp_hal::uart::ConfigError> {
|
||||
let uart_device = Uart::new(uart1, esp_hal::uart::Config::default().with_baudrate(9600))?;
|
||||
Ok(uart_device.with_rx(uart_rx).with_tx(uart_tx).into_async())
|
||||
}
|
||||
|
||||
fn setup_i2c(
|
||||
i2c0: I2C0<'static>,
|
||||
sda: GPIO22<'static>,
|
||||
scl: GPIO23<'static>,
|
||||
) -> I2c<'static, Async> {
|
||||
) -> Result<I2c<'static, Async>, esp_hal::i2c::master::ConfigError> {
|
||||
debug!("init I2C");
|
||||
let config = Config::default().with_frequency(Rate::from_khz(400));
|
||||
let i2c = match I2c::new(i2c0, config) {
|
||||
Ok(i2c) => i2c.with_sda(sda).with_scl(scl).into_async(),
|
||||
Err(e) => {
|
||||
error!("Failed to initialize I2C: {:?}", e);
|
||||
panic!(); //TODO panic!
|
||||
}
|
||||
};
|
||||
i2c
|
||||
|
||||
let i2c = I2c::new(i2c0, config)?;
|
||||
|
||||
Ok(i2c.with_sda(sda).with_scl(scl).into_async())
|
||||
}
|
||||
|
||||
fn setup_spi(
|
||||
@@ -170,39 +187,34 @@ fn setup_spi(
|
||||
sck: GPIO19<'static>,
|
||||
miso: GPIO20<'static>,
|
||||
mosi: GPIO18<'static>,
|
||||
) -> Spi<'static, Blocking> {
|
||||
let spi = match Spi::new(spi2, Spi_config::default()) {
|
||||
Ok(spi) => spi.with_sck(sck).with_miso(miso).with_mosi(mosi),
|
||||
Err(e) => panic!("Failed to initialize SPI: {:?}", e),
|
||||
};
|
||||
spi
|
||||
) -> Result<Spi<'static, Blocking>, esp_hal::spi::master::ConfigError> {
|
||||
let spi = Spi::new(spi2, Spi_config::default())?;
|
||||
Ok(spi.with_sck(sck).with_miso(miso).with_mosi(mosi))
|
||||
}
|
||||
|
||||
pub fn setup_buzzer(buzzer_gpio: GPIO21<'static>) -> Output<'static> {
|
||||
let config = esp_hal::gpio::OutputConfig::default()
|
||||
.with_drive_strength(esp_hal::gpio::DriveStrength::_40mA);
|
||||
let buzzer = Output::new(buzzer_gpio, esp_hal::gpio::Level::Low, config);
|
||||
|
||||
buzzer
|
||||
Output::new(buzzer_gpio, esp_hal::gpio::Level::Low, config)
|
||||
}
|
||||
|
||||
fn setup_led(
|
||||
rmt: RMT<'static>,
|
||||
led_gpio: GPIO1<'static>,
|
||||
) -> SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE> {
|
||||
debug!("setup led");
|
||||
fn setup_led<'a>(
|
||||
rmt: RMT<'a>,
|
||||
led_gpio: GPIO1<'a>,
|
||||
) -> Result<esp_hal_smartled::SmartLedsAdapterAsync<'a, LED_BUFFER_SIZE>, esp_hal::rmt::Error> {
|
||||
let rmt: Rmt<'_, esp_hal::Async> = {
|
||||
let frequency: Rate = Rate::from_mhz(80);
|
||||
Rmt::new(rmt, frequency)
|
||||
}
|
||||
.expect("Failed to initialize RMT")
|
||||
}?
|
||||
.into_async();
|
||||
|
||||
let rmt_channel = rmt.channel0;
|
||||
let rmt_buffer = [0_u32; buffer_size_async(NUM_LEDS)];
|
||||
let rmt_buffer = [esp_hal::rmt::PulseCode::default(); LED_BUFFER_SIZE];
|
||||
|
||||
let led: SmartLedsAdapterAsync<_, LED_BUFFER_SIZE> =
|
||||
SmartLedsAdapterAsync::new(rmt_channel, led_gpio, rmt_buffer);
|
||||
|
||||
led
|
||||
Ok(SmartLedsAdapterAsync::new(
|
||||
rmt_channel,
|
||||
led_gpio,
|
||||
rmt_buffer,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1,50 +1,41 @@
|
||||
use core::{net::Ipv4Addr, str::FromStr};
|
||||
use core::net::{Ipv4Addr, SocketAddrV4};
|
||||
use edge_dhcp::{
|
||||
io::{self, DEFAULT_SERVER_PORT},
|
||||
server::{Server, ServerOptions},
|
||||
};
|
||||
use edge_nal::UdpBind;
|
||||
use edge_nal_embassy::{Udp, UdpBuffers};
|
||||
use embassy_executor::Spawner;
|
||||
use embassy_net::{Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
|
||||
use embassy_time::{Duration, Timer};
|
||||
use esp_wifi::wifi::WifiDevice;
|
||||
use static_cell::make_static;
|
||||
use esp_radio::wifi::WifiDevice;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
use crate::webserver::WEB_TAKS_SIZE;
|
||||
|
||||
pub const NETWORK_STACK_SIZE: usize = WEB_TAKS_SIZE + 2; // + 2 for other network taks. Breaks
|
||||
// without
|
||||
pub const NETWORK_STACK_SIZE: usize = WEB_TAKS_SIZE + 2; // + 2 for other network taks. Breaks without
|
||||
pub const GW_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 2, 1);
|
||||
|
||||
pub fn setup_network<'a>(seed: u64, wifi: WifiDevice<'static>, spawner: &mut Spawner) -> Stack<'a> {
|
||||
let gw_ip_addr_str = "192.168.2.1";
|
||||
let gw_ip_addr = Ipv4Addr::from_str(gw_ip_addr_str).expect("failed to parse gateway ip");
|
||||
pub fn setup_network<'a>(seed: u64, wifi: WifiDevice<'static>, spawner: Spawner) -> Stack<'a> {
|
||||
let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
|
||||
address: Ipv4Cidr::new(gw_ip_addr, 24),
|
||||
gateway: Some(gw_ip_addr),
|
||||
address: Ipv4Cidr::new(GW_IP, 24),
|
||||
gateway: Some(GW_IP),
|
||||
dns_servers: Default::default(),
|
||||
});
|
||||
|
||||
let (stack, runner) = embassy_net::new(
|
||||
wifi,
|
||||
config,
|
||||
make_static!(StackResources::<NETWORK_STACK_SIZE>::new()),
|
||||
seed,
|
||||
);
|
||||
static NETWORK_STACK: StaticCell<StackResources<NETWORK_STACK_SIZE>> = StaticCell::new();
|
||||
let nw_stack = NETWORK_STACK.init(StackResources::new());
|
||||
|
||||
let (stack, runner) = embassy_net::new(wifi, config, nw_stack, seed);
|
||||
|
||||
spawner.must_spawn(net_task(runner));
|
||||
spawner.must_spawn(run_dhcp(stack, gw_ip_addr_str));
|
||||
spawner.must_spawn(run_dhcp(stack));
|
||||
|
||||
stack
|
||||
}
|
||||
|
||||
#[embassy_executor::task]
|
||||
async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) {
|
||||
use core::net::{Ipv4Addr, SocketAddrV4};
|
||||
|
||||
use edge_dhcp::{
|
||||
io::{self, DEFAULT_SERVER_PORT},
|
||||
server::{Server, ServerOptions},
|
||||
};
|
||||
use edge_nal::UdpBind;
|
||||
use edge_nal_embassy::{Udp, UdpBuffers};
|
||||
|
||||
let ip = Ipv4Addr::from_str(gw_ip_addr).expect("dhcp task failed to parse gw ip");
|
||||
|
||||
async fn run_dhcp(stack: Stack<'static>) {
|
||||
let mut buf = [0u8; 1500];
|
||||
|
||||
let mut gw_buf = [Ipv4Addr::UNSPECIFIED];
|
||||
@@ -57,12 +48,12 @@ async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) {
|
||||
DEFAULT_SERVER_PORT,
|
||||
)))
|
||||
.await
|
||||
.unwrap();
|
||||
.expect("Failed to bind socket for DHCP server");
|
||||
|
||||
loop {
|
||||
_ = io::server::run(
|
||||
&mut Server::<_, 64>::new_with_et(ip),
|
||||
&ServerOptions::new(ip, Some(&mut gw_buf)),
|
||||
&mut Server::<_, 64>::new_with_et(GW_IP),
|
||||
&ServerOptions::new(GW_IP, Some(&mut gw_buf)),
|
||||
&mut bound_socket,
|
||||
&mut buf,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
use alloc::vec::Vec;
|
||||
use alloc::{string::ToString, vec::Vec};
|
||||
use core::str::from_utf8;
|
||||
use embassy_time::Delay;
|
||||
use embedded_hal_bus::spi::ExclusiveDevice;
|
||||
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};
|
||||
use embedded_sdmmc::{
|
||||
SdCard, SdCardError, ShortFileName, TimeSource, Timestamp, VolumeIdx, VolumeManager,
|
||||
};
|
||||
use esp_hal::{Blocking, gpio::Output, spi::master::Spi};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::store::{AttendanceDay, Date, persistence::Persistence};
|
||||
use crate::store::{
|
||||
AttendanceDay, day::Day, mapping_loader::Name, persistence::Persistence, tally_id::TallyID,
|
||||
};
|
||||
|
||||
pub struct DummyTimesource;
|
||||
|
||||
@@ -38,58 +44,232 @@ pub struct SDCardPersistence {
|
||||
vol_mgr: VolMgr,
|
||||
}
|
||||
|
||||
impl Persistence for SDCardPersistence {
|
||||
async fn load_day(&mut self, day: crate::store::Date) -> Option<AttendanceDay> {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap();
|
||||
let mut root_dir = vol_0.open_root_dir().unwrap();
|
||||
let mut file = root_dir.open_file_in_dir("day.jsn", embedded_sdmmc::Mode::ReadOnly);
|
||||
impl SDCardPersistence {
|
||||
const MAPPING_DIRNAME: &'static str = "MAPPINGS";
|
||||
|
||||
if let Err(e) = file {
|
||||
return None;
|
||||
}
|
||||
let mut open_file = file.unwrap();
|
||||
fn generate_filename_for_day(day: Day) -> Result<ShortFileName, PersistenceError> {
|
||||
let basename = day.to_string();
|
||||
let mut filename: heapless::String<11> = heapless::String::new();
|
||||
filename
|
||||
.push_str(&basename)
|
||||
.map_err(|_| PersistenceError::DayFilename)?;
|
||||
filename
|
||||
.push_str(".JS")
|
||||
.map_err(|_| PersistenceError::DayFilename)?;
|
||||
|
||||
let mut read_buffer: [u8; 1024] = [0; 1024];
|
||||
let read = open_file.read(&mut read_buffer).unwrap();
|
||||
open_file.close().unwrap();
|
||||
|
||||
let day: AttendanceDay = serde_json::from_slice(&read_buffer[..read]).unwrap();
|
||||
|
||||
Some(day)
|
||||
ShortFileName::create_from_str(&filename).map_err(|_| PersistenceError::DayFilename)
|
||||
}
|
||||
|
||||
async fn save_day(&mut self, day: Date, data: &AttendanceDay) {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap();
|
||||
let mut root_dir = vol_0.open_root_dir().unwrap();
|
||||
fn generate_path_for_id(
|
||||
id: TallyID,
|
||||
) -> Result<(ShortFileName, ShortFileName), PersistenceError> {
|
||||
let basename: heapless::String<12> = id.into();
|
||||
let (dir, file) = basename.split_at(6);
|
||||
|
||||
let mut file = root_dir
|
||||
.open_file_in_dir("day.jsn", embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)
|
||||
.unwrap();
|
||||
file.write(&serde_json::to_vec(data).unwrap()).unwrap();
|
||||
file.flush();
|
||||
file.close();
|
||||
let mut filename: heapless::String<11> = heapless::String::new();
|
||||
filename
|
||||
.push_str(file)
|
||||
.map_err(|_| PersistenceError::IDFilename)?;
|
||||
filename
|
||||
.push_str(".JS")
|
||||
.map_err(|_| PersistenceError::IDFilename)?;
|
||||
|
||||
let mut dirname: heapless::String<11> = heapless::String::new();
|
||||
dirname
|
||||
.push_str(dir)
|
||||
.map_err(|_| PersistenceError::IDFilename)?;
|
||||
|
||||
Ok((
|
||||
ShortFileName::create_from_str(&dirname).map_err(|_| PersistenceError::IDFilename)?,
|
||||
ShortFileName::create_from_str(&filename).map_err(|_| PersistenceError::IDFilename)?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn load_mapping(&mut self) -> Option<crate::store::IDMapping> {
|
||||
todo!()
|
||||
}
|
||||
fn get_tallyid_from_path(
|
||||
dirname: &ShortFileName,
|
||||
filename: &ShortFileName,
|
||||
) -> Result<TallyID, PersistenceError> {
|
||||
let mut id_str: heapless::String<12> = heapless::String::new();
|
||||
|
||||
async fn save_mapping(&mut self, data: &crate::store::IDMapping) {
|
||||
todo!()
|
||||
}
|
||||
id_str
|
||||
.push_str(&dirname.to_string())
|
||||
.map_err(|_| PersistenceError::IDFilename)?;
|
||||
id_str
|
||||
.push_str(from_utf8(filename.base_name()).map_err(|_| PersistenceError::IDFilename)?)
|
||||
.map_err(|_| PersistenceError::IDFilename)?;
|
||||
|
||||
async fn list_days(&mut self) -> Vec<Date> {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap();
|
||||
let mut root_dir = vol_0.open_root_dir().unwrap();
|
||||
let mut days_dir = root_dir.open_dir("days").unwrap();
|
||||
let id: TallyID = id_str
|
||||
.try_into()
|
||||
.map_err(|_| PersistenceError::IDFilename)?;
|
||||
|
||||
let mut days: Vec<[u8; 10]> = Vec::new();
|
||||
days_dir
|
||||
.iterate_dir(|e| {
|
||||
days.push([0; 10]);
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
days
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum PersistenceError {
|
||||
#[error("Failed to interact with SD card")]
|
||||
SdCard(embedded_sdmmc::Error<SdCardError>),
|
||||
|
||||
#[error("Failed to parse data")]
|
||||
Parseing(#[from] serde_json::Error),
|
||||
|
||||
#[error("Failed to parse Day and Filename")]
|
||||
DayFilename,
|
||||
|
||||
#[error("Failed to parse TallyID for file path")]
|
||||
IDFilename,
|
||||
|
||||
#[error("Item not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
impl From<embedded_sdmmc::Error<SdCardError>> for PersistenceError {
|
||||
fn from(err: embedded_sdmmc::Error<SdCardError>) -> Self {
|
||||
PersistenceError::SdCard(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl Persistence for SDCardPersistence {
|
||||
type Error = PersistenceError;
|
||||
|
||||
async fn load_day(&mut self, day: Day) -> Result<AttendanceDay, Self::Error> {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
|
||||
let mut root_dir = vol_0.open_root_dir()?;
|
||||
|
||||
let filename = Self::generate_filename_for_day(day)?;
|
||||
let file = root_dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadOnly);
|
||||
|
||||
if file.is_err() {
|
||||
return Err(PersistenceError::NotFound);
|
||||
}
|
||||
|
||||
let mut open_file = file?;
|
||||
|
||||
let mut read_buffer: [u8; 1024] = [0; 1024];
|
||||
let read = open_file.read(&mut read_buffer)?;
|
||||
open_file.close()?;
|
||||
|
||||
let day: AttendanceDay = serde_json::from_slice(&read_buffer[..read])?;
|
||||
|
||||
Ok(day)
|
||||
}
|
||||
|
||||
async fn save_day(&mut self, day: Day, data: &AttendanceDay) -> Result<(), Self::Error> {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
|
||||
let mut root_dir = vol_0.open_root_dir()?;
|
||||
|
||||
let filename = Self::generate_filename_for_day(day)?;
|
||||
|
||||
let mut file =
|
||||
root_dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)?;
|
||||
|
||||
file.write(&serde_json::to_vec(data)?)?;
|
||||
|
||||
file.flush()?;
|
||||
file.close()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_days(&mut self) -> Result<Vec<Day>, Self::Error> {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
|
||||
let mut root_dir = vol_0.open_root_dir()?;
|
||||
|
||||
let mut days_dir = root_dir.open_dir(".")?;
|
||||
|
||||
let mut days: Vec<Day> = Vec::new();
|
||||
days_dir.iterate_dir(|e| {
|
||||
let filename = e.name.clone();
|
||||
|
||||
if let Ok(day) = filename.try_into() {
|
||||
days.push(day);
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(days)
|
||||
}
|
||||
|
||||
async fn load_mapping_for_id(
|
||||
&mut self,
|
||||
id: crate::store::tally_id::TallyID,
|
||||
) -> Result<crate::store::mapping_loader::Name, Self::Error> {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
|
||||
let mut root_dir = vol_0.open_root_dir()?;
|
||||
let mut mapping_dir = root_dir.open_dir(Self::MAPPING_DIRNAME)?;
|
||||
|
||||
let (dirname, filename) = Self::generate_path_for_id(id)?;
|
||||
|
||||
let mut dir = mapping_dir.open_dir(dirname)?;
|
||||
let mut file = dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadOnly)?;
|
||||
|
||||
let mut read_buffer: [u8; 1024] = [0; 1024];
|
||||
let read_bytes = file.read(&mut read_buffer)?;
|
||||
file.close()?;
|
||||
|
||||
let mapping: Name = serde_json::from_slice(&read_buffer[..read_bytes])?;
|
||||
|
||||
Ok(mapping)
|
||||
}
|
||||
|
||||
async fn save_mapping_for_id(
|
||||
&mut self,
|
||||
id: crate::store::tally_id::TallyID,
|
||||
name: crate::store::mapping_loader::Name,
|
||||
) -> Result<(), Self::Error> {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
|
||||
let mut root_dir = vol_0.open_root_dir()?;
|
||||
let mut mapping_dir = root_dir.open_dir(Self::MAPPING_DIRNAME)?;
|
||||
|
||||
let (dirname, filename) = Self::generate_path_for_id(id)?;
|
||||
|
||||
let mut dir = if let Ok(dir) = mapping_dir.open_dir(&dirname) {
|
||||
dir
|
||||
} else {
|
||||
mapping_dir.make_dir_in_dir(&dirname)?;
|
||||
mapping_dir.open_dir(&dirname)?
|
||||
};
|
||||
|
||||
let mut file =
|
||||
dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)?;
|
||||
|
||||
file.write(&serde_json::to_vec(&name)?)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_mappings(&mut self) -> Result<Vec<TallyID>, Self::Error> {
|
||||
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
|
||||
let mut root_dir = vol_0.open_root_dir()?;
|
||||
let mut mapping_dir = root_dir.open_dir(Self::MAPPING_DIRNAME)?;
|
||||
let mut ids: Vec<TallyID> = Vec::new();
|
||||
|
||||
let mut dir_names = Vec::new();
|
||||
mapping_dir.iterate_dir(|entry| {
|
||||
if entry.attributes.is_directory()
|
||||
&& entry.name.to_string() != "."
|
||||
&& entry.name.to_string() != ".."
|
||||
{
|
||||
dir_names.push(entry.name.clone());
|
||||
}
|
||||
})?;
|
||||
|
||||
for dirname in dir_names {
|
||||
if let Ok(mut subdir) = mapping_dir.open_dir(&dirname) {
|
||||
let mut file_names = Vec::new();
|
||||
subdir.iterate_dir(|file_entry| {
|
||||
if !file_entry.attributes.is_directory() {
|
||||
file_names.push(file_entry.name.clone());
|
||||
}
|
||||
})?;
|
||||
|
||||
for filename in file_names {
|
||||
let id = Self::get_tallyid_from_path(&dirname, &filename)?;
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ids)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ use embassy_executor::Spawner;
|
||||
use embassy_time::{Duration, Timer};
|
||||
use esp_hal::gpio::{Output, OutputConfig};
|
||||
use esp_hal::peripherals::{GPIO3, GPIO14, WIFI};
|
||||
use esp_wifi::wifi::{AccessPointConfiguration, Configuration, WifiController, WifiEvent, WifiState};
|
||||
use esp_wifi::{EspWifiRngSource, EspWifiTimerSource, wifi::Interfaces};
|
||||
use static_cell::make_static;
|
||||
use esp_radio::Controller;
|
||||
use esp_radio::wifi::{
|
||||
AccessPointConfig, Interfaces, ModeConfig, WifiApState, WifiController, WifiEvent,
|
||||
};
|
||||
use log::debug;
|
||||
use static_cell::StaticCell;
|
||||
use thiserror::Error;
|
||||
|
||||
pub async fn set_antenna_mode(gpio3: GPIO3<'static>, gpio14: GPIO14<'static>) {
|
||||
let mut rf_switch = Output::new(gpio3, esp_hal::gpio::Level::Low, OutputConfig::default());
|
||||
@@ -18,39 +22,56 @@ pub async fn set_antenna_mode(gpio3: GPIO3<'static>, gpio14: GPIO14<'static>) {
|
||||
antenna_mode.set_low();
|
||||
}
|
||||
|
||||
pub fn setup_wifi<'d: 'static>(
|
||||
timer: impl EspWifiTimerSource + 'd,
|
||||
rng: impl EspWifiRngSource + 'd,
|
||||
wifi: WIFI<'static>,
|
||||
spawner: &mut Spawner,
|
||||
) -> Interfaces<'d> {
|
||||
let esp_wifi_ctrl = make_static!(esp_wifi::init(timer, rng).unwrap());
|
||||
#[derive(Error, Debug)]
|
||||
pub enum WifiError {
|
||||
#[error("Failed to init radio")]
|
||||
Init(#[from] esp_radio::InitializationError),
|
||||
|
||||
let (controller, interfaces) = esp_wifi::wifi::new(esp_wifi_ctrl, wifi).unwrap();
|
||||
#[error("Failed to init wifi")]
|
||||
Wifi(#[from] esp_radio::wifi::WifiError),
|
||||
|
||||
spawner.must_spawn(connection(controller));
|
||||
|
||||
interfaces
|
||||
#[error("Failed to spawn wifi task")]
|
||||
Spawn(#[from] embassy_executor::SpawnError),
|
||||
}
|
||||
|
||||
pub fn setup_wifi<'d: 'static>(
|
||||
wifi: WIFI<'static>,
|
||||
spawner: Spawner,
|
||||
) -> Result<Interfaces<'d>, WifiError> {
|
||||
static ESP_WIFI_CTRL: StaticCell<Controller<'static>> = StaticCell::new();
|
||||
|
||||
let esp_wifi_ctrl = ESP_WIFI_CTRL.init(esp_radio::init()?);
|
||||
|
||||
let config = esp_radio::wifi::Config::default();
|
||||
let (controller, interfaces) = esp_radio::wifi::new(esp_wifi_ctrl, wifi, config)?;
|
||||
|
||||
spawner.spawn(connection(controller))?;
|
||||
|
||||
Ok(interfaces)
|
||||
}
|
||||
#[embassy_executor::task]
|
||||
async fn connection(mut controller: WifiController<'static>) {
|
||||
debug!("start connection task");
|
||||
debug!("Device capabilities: {:?}", controller.capabilities());
|
||||
|
||||
loop {
|
||||
match esp_wifi::wifi::wifi_state() {
|
||||
WifiState::ApStarted => {
|
||||
if esp_radio::wifi::ap_state() == WifiApState::Started {
|
||||
// wait until we're no longer connected
|
||||
controller.wait_for_event(WifiEvent::ApStop).await;
|
||||
Timer::after(Duration::from_millis(5000)).await
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !matches!(controller.is_started(), Ok(true)) {
|
||||
let client_config = Configuration::AccessPoint(AccessPointConfiguration {
|
||||
ssid: "esp-wifi".try_into().unwrap(),
|
||||
..Default::default()
|
||||
});
|
||||
controller.set_configuration(&client_config).unwrap();
|
||||
let client_config = ModeConfig::AccessPoint(
|
||||
AccessPointConfig::default()
|
||||
.with_ssid(env!("WIFI_SSID").try_into().unwrap())
|
||||
.with_password(env!("WIFI_PASSWD").try_into().unwrap())
|
||||
.with_auth_method(esp_radio::wifi::AuthMethod::Wpa2Personal),
|
||||
);
|
||||
controller.set_config(&client_config).unwrap();
|
||||
debug!("Starting wifi");
|
||||
controller.start_async().await.unwrap();
|
||||
debug!("Wifi started!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
56
src/main.rs
56
src/main.rs
@@ -2,6 +2,7 @@
|
||||
#![no_main]
|
||||
#![feature(type_alias_impl_trait)]
|
||||
#![feature(impl_trait_in_assoc_type)]
|
||||
#![warn(clippy::unwrap_used)]
|
||||
|
||||
use alloc::rc::Rc;
|
||||
use embassy_executor::Spawner;
|
||||
@@ -16,16 +17,16 @@ use embassy_sync::{
|
||||
signal::Signal,
|
||||
};
|
||||
use embassy_time::{Duration, Timer};
|
||||
use esp_hal::gpio::Input;
|
||||
use esp_hal::{gpio::InputConfig, peripherals};
|
||||
use esp_hal::gpio::InputConfig;
|
||||
use esp_hal::gpio::{AnyPin, Input};
|
||||
use log::{debug, info};
|
||||
use static_cell::make_static;
|
||||
use static_cell::StaticCell;
|
||||
|
||||
extern crate alloc;
|
||||
|
||||
use crate::{
|
||||
init::sd_card::SDCardPersistence,
|
||||
store::{Date, IDStore, TallyID},
|
||||
init::{hardware::AppHardware, sd_card::SDCardPersistence},
|
||||
store::{IDStore, day::Day, mapping_loader::MappingLoader, tally_id::TallyID},
|
||||
webserver::start_webserver,
|
||||
};
|
||||
|
||||
@@ -42,38 +43,55 @@ type TallyPublisher = Publisher<'static, NoopRawMutex, TallyID, 8, 2, 1>;
|
||||
type TallySubscriber = Subscriber<'static, NoopRawMutex, TallyID, 8, 2, 1>;
|
||||
type UsedStore = IDStore<SDCardPersistence>;
|
||||
|
||||
#[esp_hal_embassy::main]
|
||||
async fn main(mut spawner: Spawner) {
|
||||
let (uart_device, stack, _i2c, _led, buzzer_gpio, sd_det_gpio, persistence_layer) =
|
||||
init::hardware::hardware_init(&mut spawner).await;
|
||||
static CHAN: StaticCell<TallyChannel> = StaticCell::new();
|
||||
|
||||
#[esp_rtos::main]
|
||||
async fn main(spawner: Spawner) -> ! {
|
||||
let app_hardware = AppHardware::init(spawner).await.unwrap();
|
||||
|
||||
info!("Starting up...");
|
||||
|
||||
let mut rtc = drivers::rtc::RTCClock::new(_i2c).await;
|
||||
let mut rtc = drivers::rtc::RTCClock::new(app_hardware.i2c).await;
|
||||
|
||||
let store: UsedStore = IDStore::new_from_storage(persistence_layer).await;
|
||||
let current_day: Day = rtc.get_time().await.into();
|
||||
|
||||
let shared_sdcard: Rc<Mutex<CriticalSectionRawMutex, SDCardPersistence>> =
|
||||
Rc::new(Mutex::new(app_hardware.sdcard));
|
||||
|
||||
let store: UsedStore = IDStore::new_from_storage(shared_sdcard.clone(), current_day).await;
|
||||
let shared_store = Rc::new(Mutex::new(store));
|
||||
|
||||
let chan: &'static mut TallyChannel = make_static!(PubSubChannel::new());
|
||||
let mapping_loader = MappingLoader::new(shared_sdcard.clone());
|
||||
|
||||
let chan: &'static mut TallyChannel = CHAN.init(PubSubChannel::new());
|
||||
let publisher: TallyPublisher = chan.publisher().unwrap();
|
||||
let mut sub: TallySubscriber = chan.subscriber().unwrap();
|
||||
|
||||
wait_for_stack_up(stack).await;
|
||||
wait_for_stack_up(app_hardware.network_stack).await;
|
||||
|
||||
start_webserver(&mut spawner, stack, shared_store.clone(), chan);
|
||||
start_webserver(
|
||||
spawner,
|
||||
app_hardware.network_stack,
|
||||
shared_store.clone(),
|
||||
chan,
|
||||
mapping_loader,
|
||||
);
|
||||
|
||||
/****************************** Spawning tasks ***********************************/
|
||||
debug!("spawing NFC reader task...");
|
||||
spawner.must_spawn(drivers::nfc_reader::rfid_reader_task(
|
||||
uart_device,
|
||||
app_hardware.uart,
|
||||
publisher,
|
||||
));
|
||||
|
||||
debug!("spawing feedback task..");
|
||||
spawner.must_spawn(feedback::feedback_task(_led, buzzer_gpio));
|
||||
spawner.must_spawn(feedback::feedback_task(
|
||||
app_hardware.led,
|
||||
app_hardware.buzzer,
|
||||
));
|
||||
|
||||
debug!("spawn sd detect task");
|
||||
spawner.must_spawn(sd_detect_task(sd_det_gpio));
|
||||
spawner.must_spawn(sd_detect_task(app_hardware.sd_present));
|
||||
/******************************************************************************/
|
||||
|
||||
debug!("everything spawned");
|
||||
@@ -86,7 +104,7 @@ async fn main(mut spawner: Spawner) {
|
||||
Message(msg) => {
|
||||
debug!("Got message: {msg:?}");
|
||||
|
||||
let day: Date = rtc.get_date().await;
|
||||
let day: Day = rtc.get_time().await.into();
|
||||
let added = shared_store.lock().await.add_id(msg, day).await;
|
||||
|
||||
if added {
|
||||
@@ -98,7 +116,7 @@ async fn main(mut spawner: Spawner) {
|
||||
}
|
||||
|
||||
#[embassy_executor::task]
|
||||
async fn sd_detect_task(sd_det_gpio: peripherals::GPIO0<'static>) {
|
||||
async fn sd_detect_task(sd_det_gpio: AnyPin<'static>) {
|
||||
let mut sd_det = Input::new(sd_det_gpio, InputConfig::default());
|
||||
sd_det.wait_for(esp_hal::gpio::Event::AnyEdge).await;
|
||||
|
||||
|
||||
63
src/store/day.rs
Normal file
63
src/store/day.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use core::fmt::Write;
|
||||
|
||||
use embedded_sdmmc::ShortFileName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Day(u32);
|
||||
|
||||
impl Day {
|
||||
const SECONDS_PER_DAY: u64 = 86_400;
|
||||
|
||||
pub fn new(daystamp: u32) -> Self {
|
||||
Day(daystamp)
|
||||
}
|
||||
|
||||
pub fn new_from_timestamp(time: u64) -> Self {
|
||||
let day = time / Self::SECONDS_PER_DAY;
|
||||
|
||||
if day > u32::MAX as u64 {
|
||||
// TBH this would only happen if about 11 million years have passed
|
||||
// I sure hope i don't have to work on this project any more then
|
||||
// So we just cap it at this
|
||||
Day(u32::MAX)
|
||||
} else {
|
||||
Day(day as u32)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_timestamp(self) -> u64 {
|
||||
(self.0 as u64) * Self::SECONDS_PER_DAY
|
||||
}
|
||||
|
||||
pub fn to_string(self) -> heapless::String<8> {
|
||||
let mut s: heapless::String<8> = heapless::String::new();
|
||||
write!(s, "{:08X}", self.0).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
pub fn from_hex_str(s: &str) -> Result<Self, &'static str> {
|
||||
if s.len() > 8 {
|
||||
return Err("hex string too long");
|
||||
}
|
||||
|
||||
u32::from_str_radix(s, 16)
|
||||
.map_err(|_| "invalid hex string")
|
||||
.map(Day)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u64> for Day {
|
||||
fn from(value: u64) -> Self {
|
||||
Self::new_from_timestamp(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ShortFileName> for Day {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: ShortFileName) -> Result<Self, Self::Error> {
|
||||
let name = core::str::from_utf8(value.base_name()).map_err(|_| ())?;
|
||||
Self::from_hex_str(name).map_err(|_| ())
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use alloc::collections::BTreeMap;
|
||||
use alloc::string::String;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::TallyID;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Name {
|
||||
pub first: String,
|
||||
pub last: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct IDMapping {
|
||||
id_map: BTreeMap<TallyID, Name>,
|
||||
}
|
||||
|
||||
impl IDMapping {
|
||||
pub fn new() -> Self {
|
||||
IDMapping {
|
||||
id_map: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn map(&self, id: &TallyID) -> Option<&Name> {
|
||||
self.id_map.get(id)
|
||||
}
|
||||
|
||||
pub fn add_mapping(&mut self, id: TallyID, name: Name) {
|
||||
self.id_map.insert(id, name);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,22 @@
|
||||
use alloc::rc::Rc;
|
||||
use alloc::vec::Vec;
|
||||
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
|
||||
use embassy_sync::mutex::Mutex;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::Date;
|
||||
use super::IDMapping;
|
||||
use super::TallyID;
|
||||
use crate::store::day::Day;
|
||||
use crate::store::persistence::Persistence;
|
||||
use crate::store::tally_id::TallyID;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
pub struct AttendanceDay {
|
||||
date: Date,
|
||||
date: Day,
|
||||
ids: Vec<TallyID>,
|
||||
}
|
||||
|
||||
impl AttendanceDay {
|
||||
pub fn new(date: Date) -> Self {
|
||||
pub fn new(date: Day) -> Self {
|
||||
Self {
|
||||
date,
|
||||
ids: Vec::new(),
|
||||
@@ -34,45 +36,39 @@ impl AttendanceDay {
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct IDStore<T: Persistence> {
|
||||
pub current_day: AttendanceDay,
|
||||
pub mapping: IDMapping,
|
||||
persistence_layer: T,
|
||||
current_day: AttendanceDay,
|
||||
persistence_layer: Rc<Mutex<CriticalSectionRawMutex, T>>,
|
||||
}
|
||||
|
||||
impl<T: Persistence> IDStore<T> {
|
||||
pub async fn new_from_storage(mut persistence_layer: T) -> Self {
|
||||
// let mapping = match persistence_layer.load_mapping().await {
|
||||
// Some(map) => map,
|
||||
// None => IDMapping::new(),
|
||||
// };
|
||||
|
||||
let current_date: Date = [0; 10];
|
||||
|
||||
pub async fn new_from_storage(
|
||||
persistence_layer: Rc<Mutex<CriticalSectionRawMutex, T>>,
|
||||
current_date: Day,
|
||||
) -> Self {
|
||||
let day = persistence_layer
|
||||
.lock()
|
||||
.await
|
||||
.load_day(current_date)
|
||||
.await
|
||||
.unwrap_or(AttendanceDay::new(current_date));
|
||||
|
||||
Self {
|
||||
current_day: day,
|
||||
mapping: IDMapping::new(),
|
||||
persistence_layer,
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_day(&mut self) {
|
||||
async fn persist_day(&mut self) -> Result<(), T::Error> {
|
||||
self.persistence_layer
|
||||
.lock()
|
||||
.await
|
||||
.save_day(self.current_day.date, &self.current_day)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn persist_mapping(&mut self) {
|
||||
self.persistence_layer.save_mapping(&self.mapping).await
|
||||
}
|
||||
|
||||
/// Add a new id for the current day
|
||||
/// Returns false if ID is already present at the current day.
|
||||
pub async fn add_id(&mut self, id: TallyID, current_date: Date) -> bool {
|
||||
pub async fn add_id(&mut self, id: TallyID, current_date: Day) -> bool {
|
||||
if self.current_day.date == current_date {
|
||||
let changed = self.current_day.add_id(id);
|
||||
if changed {
|
||||
@@ -90,4 +86,27 @@ impl<T: Persistence> IDStore<T> {
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Load and return a AttendanceDay. Nothing more. Nothing less.
|
||||
pub async fn load_day(&mut self, day: Day) -> Result<AttendanceDay, T::Error> {
|
||||
if day == self.current_day.date {
|
||||
return Ok(self.current_day.clone());
|
||||
}
|
||||
|
||||
self.persistence_layer.lock().await.load_day(day).await
|
||||
}
|
||||
|
||||
pub async fn list_days_in_timespan(
|
||||
&mut self,
|
||||
from: Day,
|
||||
to: Day,
|
||||
) -> Result<Vec<Day>, T::Error> {
|
||||
let all_days = self.persistence_layer.lock().await.list_days().await?;
|
||||
|
||||
Ok(all_days
|
||||
.into_iter()
|
||||
.filter(|e| *e >= from)
|
||||
.filter(|e| *e <= to)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
31
src/store/mapping_loader.rs
Normal file
31
src/store/mapping_loader.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use alloc::{rc::Rc, string::String, vec::Vec};
|
||||
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::store::{persistence::Persistence, tally_id::TallyID};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Name {
|
||||
pub first: String,
|
||||
pub last: String,
|
||||
}
|
||||
|
||||
pub struct MappingLoader<T: Persistence>(Rc<Mutex<CriticalSectionRawMutex, T>>);
|
||||
|
||||
impl<T: Persistence> MappingLoader<T> {
|
||||
pub fn new(persistence_layer: Rc<Mutex<CriticalSectionRawMutex, T>>) -> Self {
|
||||
Self(persistence_layer)
|
||||
}
|
||||
|
||||
pub async fn get_mapping(&self, id: TallyID) -> Result<Name, T::Error> {
|
||||
self.0.lock().await.load_mapping_for_id(id).await
|
||||
}
|
||||
|
||||
pub async fn set_mapping(&self, id: TallyID, name: Name) -> Result<(), T::Error> {
|
||||
self.0.lock().await.save_mapping_for_id(id, name).await
|
||||
}
|
||||
|
||||
pub async fn list_mappings(&self) -> Result<Vec<TallyID>,T::Error> {
|
||||
self.0.lock().await.list_mappings().await
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,7 @@
|
||||
use heapless::String;
|
||||
|
||||
pub use id_mapping::{IDMapping, Name};
|
||||
pub use id_store::{IDStore,AttendanceDay};
|
||||
|
||||
mod id_mapping;
|
||||
pub mod persistence;
|
||||
mod id_store;
|
||||
|
||||
pub type TallyID = [u8; 6];
|
||||
pub type Date = [u8; 10];
|
||||
|
||||
pub fn hex_string_to_tally_id(s: &str) -> Option<TallyID> {
|
||||
let bytes = s.as_bytes();
|
||||
if bytes.len() != 12 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out: TallyID = [0;6];
|
||||
for i in 0..6 {
|
||||
let hi = hex_val(bytes[2 * i])?;
|
||||
let lo = hex_val(bytes[2 * i + 1])?;
|
||||
out[i] = (hi << 4) | lo;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn tally_id_to_hex_string(bytes: TallyID) -> Option<String<12>> {
|
||||
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut s: String<12> = String::new();
|
||||
|
||||
for &b in &bytes {
|
||||
s.push(HEX_CHARS[(b >> 4) as usize] as char).ok()?;
|
||||
s.push(HEX_CHARS[(b & 0x0F) as usize] as char).ok()?;
|
||||
}
|
||||
|
||||
Some(s)
|
||||
}
|
||||
|
||||
fn hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub mod tally_id;
|
||||
pub mod day;
|
||||
pub mod mapping_loader;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use alloc::vec::Vec;
|
||||
|
||||
use crate::store::{Date, IDMapping, id_store::AttendanceDay};
|
||||
use crate::store::{day::Day, id_store::AttendanceDay, mapping_loader::Name, tally_id::TallyID};
|
||||
|
||||
pub trait Persistence {
|
||||
async fn load_day(&mut self, day: Date) -> Option<AttendanceDay>;
|
||||
async fn save_day(&mut self, day: Date, data: &AttendanceDay);
|
||||
async fn list_days(&mut self) -> Vec<Date>;
|
||||
type Error: core::error::Error;
|
||||
|
||||
async fn load_mapping(&mut self) -> Option<IDMapping>;
|
||||
async fn save_mapping(&mut self, data: &IDMapping);
|
||||
async fn load_day(&mut self, day: Day) -> Result<AttendanceDay, Self::Error>;
|
||||
async fn save_day(&mut self, day: Day, data: &AttendanceDay) -> Result<(), Self::Error>;
|
||||
async fn list_days(&mut self) -> Result<Vec<Day>, Self::Error>;
|
||||
|
||||
async fn load_mapping_for_id(&mut self, id: TallyID) -> Result<Name, Self::Error>;
|
||||
async fn save_mapping_for_id(&mut self, id: TallyID, name: Name) -> Result<(), Self::Error>;
|
||||
async fn list_mappings(&mut self) -> Result<Vec<TallyID>, Self::Error>;
|
||||
}
|
||||
|
||||
109
src/store/tally_id.rs
Normal file
109
src/store/tally_id.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use alloc::string::String;
|
||||
use core::{fmt::Display, str::FromStr};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct TallyID([u8; 6]);
|
||||
|
||||
impl FromStr for TallyID {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
s.as_bytes().try_into()
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<heapless::String<12>> for TallyID {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: heapless::String<12>) -> Result<Self, Self::Error> {
|
||||
let bytes = value.as_bytes();
|
||||
|
||||
let mut out: [u8; 6] = [0; 6];
|
||||
for i in 0..6 {
|
||||
let hi = hex_val(bytes[2 * i])?;
|
||||
let lo = hex_val(bytes[2 * i + 1])?;
|
||||
out[i] = (hi << 4) | lo;
|
||||
}
|
||||
|
||||
Ok(TallyID(out))
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_val(b: u8) -> Result<u8, ()> {
|
||||
match b {
|
||||
b'0'..=b'9' => Ok(b - b'0'),
|
||||
b'a'..=b'f' => Ok(b - b'a' + 10),
|
||||
b'A'..=b'F' => Ok(b - b'A' + 10),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TallyID> for heapless::String<12> {
|
||||
fn from(value: TallyID) -> Self {
|
||||
const HEX_CHARS: &[u8; 16] = b"0123456789ABCDEF";
|
||||
let mut s: Self = Self::new();
|
||||
|
||||
for &b in &value.0 {
|
||||
// Should be safe to unwrap since the string is already long enough
|
||||
s.push(HEX_CHARS[(b >> 4) as usize] as char).unwrap();
|
||||
s.push(HEX_CHARS[(b & 0x0F) as usize] as char).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// From a array of hex chars
|
||||
impl TryFrom<&[u8]> for TallyID {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||
if value.len() != 12 {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let mut out: [u8; 6] = [0; 6];
|
||||
for i in 0..6 {
|
||||
let hi = hex_val(value[2 * i])?;
|
||||
let lo = hex_val(value[2 * i + 1])?;
|
||||
out[i] = (hi << 4) | lo;
|
||||
}
|
||||
|
||||
Ok(TallyID(out))
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<[u8; 12]> for TallyID {
|
||||
type Error = ();
|
||||
|
||||
fn try_from(value: [u8; 12]) -> Result<Self, Self::Error> {
|
||||
Self::try_from(&value as &[u8])
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for TallyID {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
let s: heapless::String<12> = (*self).into();
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for TallyID {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
let s: heapless::String<12> = (*self).into();
|
||||
serializer.serialize_str(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for TallyID {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let s = <String>::deserialize(deserializer)?;
|
||||
TallyID::from_str(&s).map_err(|_| de::Error::custom("Failed to parse Tally ID"))
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,136 @@
|
||||
use alloc::string::String;
|
||||
use log::error;
|
||||
use picoserve::{
|
||||
extract::{Json, State},
|
||||
extract::{Json, Query, State},
|
||||
response::{self, IntoResponse},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
store::{Name, hex_string_to_tally_id},
|
||||
store::{day::Day, mapping_loader::Name, tally_id::TallyID},
|
||||
webserver::{app::AppState, sse::IDEvents},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewMapping {
|
||||
id: String,
|
||||
id: TallyID,
|
||||
name: Name,
|
||||
}
|
||||
|
||||
/*
|
||||
* #[get("/api/idevent")]
|
||||
* #[get("/api/csv")]
|
||||
* #[get("/api/mapping")]
|
||||
* #[post("/api/mapping", format = "json", data = "<new_mapping>")]
|
||||
* struct NewMapping {
|
||||
* id: String,
|
||||
* name: Name,
|
||||
* }
|
||||
*/
|
||||
|
||||
pub async fn get_mapping(State(state): State<AppState>) -> impl IntoResponse {
|
||||
let store = state.store.lock().await;
|
||||
response::Json(store.mapping.clone())
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryTimespan {
|
||||
from: u64,
|
||||
to: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryDay {
|
||||
timestamp: Option<u64>,
|
||||
day: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct QueryMapping {
|
||||
id: TallyID,
|
||||
}
|
||||
|
||||
// GET /api/mappings
|
||||
pub async fn get_mappings(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let loader = state.mapping_loader.lock().await;
|
||||
|
||||
match loader.list_mappings().await {
|
||||
Ok(ids) => Ok(response::Json(ids)),
|
||||
Err(_) => Err((
|
||||
response::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_SERVER_ERROR",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/mapping
|
||||
pub async fn get_mapping(
|
||||
State(state): State<AppState>,
|
||||
Query(QueryMapping { id }): Query<QueryMapping>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let loader = state.mapping_loader.lock().await;
|
||||
|
||||
match loader.get_mapping(id).await {
|
||||
Ok(name) => Ok(response::Json(name)),
|
||||
Err(_) => Err((
|
||||
response::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_SERVER_ERROR",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/mapping
|
||||
pub async fn add_mapping(
|
||||
State(state): State<AppState>,
|
||||
Json(data): Json<NewMapping>,
|
||||
) -> impl IntoResponse {
|
||||
let mut store = state.store.lock().await;
|
||||
let tally_id = hex_string_to_tally_id(&data.id).unwrap();
|
||||
store.mapping.add_mapping(tally_id, data.name);
|
||||
let loader = state.mapping_loader.lock().await;
|
||||
match loader.set_mapping(data.id, data.name).await {
|
||||
Ok(_) => (response::StatusCode::CREATED, ""),
|
||||
Err(_) => (
|
||||
response::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"INTERNAL_SERVER_ERROR",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_idevent(State(state): State<AppState>) -> impl IntoResponse {
|
||||
response::EventStream(IDEvents(state.chan.subscriber().unwrap()))
|
||||
// SSE /api/idevent
|
||||
pub async fn get_idevent(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
match state.chan.subscriber() {
|
||||
Ok(chan) => Ok(response::EventStream(IDEvents(chan))),
|
||||
Err(e) => {
|
||||
error!("Failed to create SSE: {:?}", e);
|
||||
Err((
|
||||
response::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/days
|
||||
pub async fn get_days(
|
||||
State(state): State<AppState>,
|
||||
Query(QueryTimespan { from, to }): Query<QueryTimespan>,
|
||||
) -> impl IntoResponse {
|
||||
let from_day = Day::new_from_timestamp(from);
|
||||
let to_day = Day::new_from_timestamp(to);
|
||||
|
||||
let mut store = state.store.lock().await;
|
||||
|
||||
match store.list_days_in_timespan(from_day, to_day).await {
|
||||
Ok(days) => Ok(response::Json(days)),
|
||||
Err(_) => Err((
|
||||
response::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/day
|
||||
pub async fn get_day(
|
||||
State(state): State<AppState>,
|
||||
Query(QueryDay { timestamp, day }): Query<QueryDay>,
|
||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||
let parsed_day = timestamp
|
||||
.map(Day::new_from_timestamp)
|
||||
.or_else(|| day.map(Day::new))
|
||||
.ok_or((response::StatusCode::NOT_FOUND, "Not found"))?;
|
||||
|
||||
let mut store = state.store.lock().await;
|
||||
|
||||
match store.load_day(parsed_day).await {
|
||||
Ok(att_day) => Ok(response::Json(att_day)),
|
||||
Err(_) => Err((
|
||||
response::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Internal server error",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ use picoserve::{AppWithStateBuilder, routing::get};
|
||||
|
||||
use crate::{
|
||||
TallyChannel, UsedStore,
|
||||
init::sd_card::SDCardPersistence,
|
||||
store::mapping_loader::MappingLoader,
|
||||
webserver::{
|
||||
api::{add_mapping, get_idevent, get_mapping},
|
||||
api::{add_mapping, get_day, get_days, get_idevent, get_mapping, get_mappings},
|
||||
assets::Assets,
|
||||
},
|
||||
};
|
||||
@@ -14,6 +16,7 @@ use crate::{
|
||||
pub struct AppState {
|
||||
pub store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
|
||||
pub chan: &'static TallyChannel,
|
||||
pub mapping_loader: Rc<Mutex<CriticalSectionRawMutex, MappingLoader<SDCardPersistence>>>,
|
||||
}
|
||||
|
||||
pub struct AppProps;
|
||||
@@ -25,6 +28,9 @@ impl AppWithStateBuilder for AppProps {
|
||||
fn build_app(self) -> picoserve::Router<Self::PathRouter, AppState> {
|
||||
picoserve::Router::from_service(Assets)
|
||||
.route("/api/mapping", get(get_mapping).post(add_mapping))
|
||||
.route("/api/mappings", get(get_mappings))
|
||||
.route("/api/idevent", get(get_idevent))
|
||||
.route("/api/days", get(get_days))
|
||||
.route("/api/day", get(get_day))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ impl<State, CurrentPathParameters>
|
||||
);
|
||||
|
||||
response_writer
|
||||
.write_response(request.body_connection.finalize().await.unwrap(), response)
|
||||
.write_response(request.body_connection.finalize().await?, response)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
@@ -68,10 +68,7 @@ impl Content for StaticAsset {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
async fn write_content<W: embedded_io_async::Write>(
|
||||
self,
|
||||
mut writer: W,
|
||||
) -> Result<(), W::Error> {
|
||||
async fn write_content<W: edge_nal::io::Write>(self, mut writer: W) -> Result<(), W::Error> {
|
||||
writer.write_all(self.0).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use static_cell::make_static;
|
||||
|
||||
use crate::{
|
||||
TallyChannel, UsedStore,
|
||||
init::sd_card::SDCardPersistence,
|
||||
store::mapping_loader::{self, MappingLoader},
|
||||
webserver::app::{AppProps, AppState},
|
||||
};
|
||||
|
||||
@@ -16,17 +18,23 @@ mod app;
|
||||
mod assets;
|
||||
mod sse;
|
||||
|
||||
pub const WEB_TAKS_SIZE: usize = 3; // Up this number if request start fail with Timeouts.
|
||||
pub const WEB_TAKS_SIZE: usize = 5; // Up this number if request start fail with Timeouts.
|
||||
|
||||
pub fn start_webserver(
|
||||
spawner: &mut Spawner,
|
||||
spawner: Spawner,
|
||||
stack: Stack<'static>,
|
||||
store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
|
||||
chan: &'static TallyChannel,
|
||||
mapping_loader: MappingLoader<SDCardPersistence>,
|
||||
) {
|
||||
let app = make_static!(AppProps.build_app());
|
||||
|
||||
let state = make_static!(AppState { store, chan });
|
||||
let shared_mapping_loader = Rc::new(Mutex::new(mapping_loader));
|
||||
let state = make_static!(AppState {
|
||||
store,
|
||||
chan,
|
||||
mapping_loader: shared_mapping_loader
|
||||
});
|
||||
|
||||
let config = make_static!(picoserve::Config::new(picoserve::Timeouts {
|
||||
start_read_request: Some(Duration::from_secs(5)),
|
||||
|
||||
@@ -2,7 +2,7 @@ use embassy_time::{Duration, Timer};
|
||||
use log::warn;
|
||||
use picoserve::response;
|
||||
|
||||
use crate::{TallySubscriber, store::tally_id_to_hex_string};
|
||||
use crate::TallySubscriber;
|
||||
|
||||
pub struct IDEvents(pub TallySubscriber);
|
||||
|
||||
@@ -18,9 +18,8 @@ impl response::sse::EventSource for IDEvents {
|
||||
match sel.await {
|
||||
embassy_futures::select::Either::First(msg) => match msg {
|
||||
embassy_sync::pubsub::WaitResult::Message(id) => {
|
||||
writer
|
||||
.write_event("msg", tally_id_to_hex_string(id).unwrap().as_str())
|
||||
.await?
|
||||
let id_str: heapless::String<12> = id.into();
|
||||
writer.write_event("msg", id_str.as_str()).await?
|
||||
}
|
||||
embassy_sync::pubsub::WaitResult::Lagged(_) => {
|
||||
warn!("SSE subscriber got lagged");
|
||||
|
||||
108
web/mock/data.json
Normal file
108
web/mock/data.json
Normal file
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"mapping": [
|
||||
[
|
||||
"123456789ABC",
|
||||
{
|
||||
"first": "Feuerwehrman",
|
||||
"last": "Sam"
|
||||
}
|
||||
],
|
||||
[
|
||||
"A1B2C3D4E5F6",
|
||||
{
|
||||
"first": "Luna",
|
||||
"last": "Starforge"
|
||||
}
|
||||
],
|
||||
[
|
||||
"0F1E2D3C4B5A",
|
||||
{
|
||||
"first": "Gareth",
|
||||
"last": "Ironwill"
|
||||
}
|
||||
],
|
||||
[
|
||||
"ABCDEF123456",
|
||||
{
|
||||
"first": "Nina",
|
||||
"last": "Skylark"
|
||||
}
|
||||
],
|
||||
[
|
||||
"654321FEDCBA",
|
||||
{
|
||||
"first": "Tobias",
|
||||
"last": "Marrow"
|
||||
}
|
||||
],
|
||||
[
|
||||
"DEADBEEFCAFE",
|
||||
{
|
||||
"first": "Astra",
|
||||
"last": "Vale"
|
||||
}
|
||||
],
|
||||
[
|
||||
"BADA55C0FFEE",
|
||||
{
|
||||
"first": "Rowan",
|
||||
"last": "Tempest"
|
||||
}
|
||||
],
|
||||
[
|
||||
"C001D00D1337",
|
||||
{
|
||||
"first": "Juniper",
|
||||
"last": "Voss"
|
||||
}
|
||||
],
|
||||
[
|
||||
"A0B1DB0D133B",
|
||||
{
|
||||
"first": "Öäü",
|
||||
"last": "ßẞ"
|
||||
}
|
||||
]
|
||||
],
|
||||
"days": [
|
||||
{
|
||||
"date": 20372,
|
||||
"ids": [
|
||||
"123456789ABC",
|
||||
"A1B2C3D4E5F6",
|
||||
"A0B1DB0D133B"
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": 20373,
|
||||
"ids": [
|
||||
"0F1E2D3C4B5A",
|
||||
"ABCDEF123456"
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": 20374,
|
||||
"ids": [
|
||||
"654321FEDCBA",
|
||||
"DEADBEEFCAFE",
|
||||
"BADA55C0FFEE",
|
||||
"A0B1DB0D133B"
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": 20375,
|
||||
"ids": [
|
||||
"C001D00D1337",
|
||||
"A1B2C3D4E5F6",
|
||||
"123456789ABC"
|
||||
]
|
||||
},
|
||||
{
|
||||
"date": 20376,
|
||||
"ids": [
|
||||
"N0T3X1ST1D0",
|
||||
"654321FEDCBA"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
111
web/mock/server.js
Normal file
111
web/mock/server.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import express from "express";
|
||||
import bodyParser from "body-parser";
|
||||
|
||||
import mockData from "./data.json" with {type: "json"};
|
||||
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
|
||||
const SECS_IN_DAY = 86_400;
|
||||
|
||||
app.use(bodyParser.json());
|
||||
|
||||
function generateRandomId() {
|
||||
const chars = "ABCDEF0123456789";
|
||||
let id = "";
|
||||
for (let i = 0; i < 12; i++) {
|
||||
id += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
// GET /api/mapping
|
||||
app.get("/api/mapping", (req, res) => {
|
||||
res.json(mockData.mapping);
|
||||
});
|
||||
|
||||
// POST /api/mapping
|
||||
app.post("/api/mapping", (req, res) => {
|
||||
const { id, name } = req.body;
|
||||
|
||||
if (!id || !name || !name.first || !name.last) {
|
||||
return res.status(400).json({ error: "Invalid request body" });
|
||||
}
|
||||
|
||||
// Check if ID already exists
|
||||
const existing = mappings.find((entry) => entry[0] === id);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: "ID already exists" });
|
||||
}
|
||||
|
||||
// Add new mapping
|
||||
mockData.mappings.push([id, name]);
|
||||
|
||||
res.status(201).send("");
|
||||
});
|
||||
|
||||
app.get("/api/day", (req, res) => {
|
||||
let day;
|
||||
|
||||
if (req.query.day) {
|
||||
day = parseInt(req.query.day, 10);
|
||||
}else if (req.query.timestamp) {
|
||||
let ts = parseInt(req.query.timestamp, 10);
|
||||
day = ts / SECS_IN_DAY;
|
||||
}else {
|
||||
return res.status(400).json({ error: "Missing or invalid 'day' parameter" });
|
||||
}
|
||||
|
||||
if (isNaN(day)) {
|
||||
return res.status(400).json({ error: "Missing or invalid 'day' parameter" });
|
||||
}
|
||||
|
||||
let foundDay = mockData.days.find(e => e.date == day);
|
||||
|
||||
if (!foundDay) {
|
||||
return res.status(404).send("Not found");
|
||||
}
|
||||
|
||||
res.status(200).json(foundDay);
|
||||
});
|
||||
|
||||
app.get("/api/days", (req,res) => {
|
||||
|
||||
let qFrom = parseInt(req.query.from) / SECS_IN_DAY;
|
||||
let qTo = parseInt(req.query.to) / SECS_IN_DAY;
|
||||
|
||||
let days = mockData.days.filter(e => e.date >= qFrom && e.date <= qTo).map(e => e.date);
|
||||
|
||||
res.status(200).json(days);
|
||||
});
|
||||
|
||||
// SSE route: /api/idevent
|
||||
app.get("/api/idevent", (req, res) => {
|
||||
// Set headers for SSE
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
|
||||
res.flushHeaders(); // flush the headers to establish SSE connection
|
||||
|
||||
// Send initial event
|
||||
const sendEvent = () => {
|
||||
const id = generateRandomId();
|
||||
res.write(`data: ${id}\n\n`);
|
||||
};
|
||||
|
||||
// Send immediately and then every 10 seconds
|
||||
sendEvent();
|
||||
const interval = setInterval(sendEvent, 10000);
|
||||
|
||||
// When client closes connection, stop interval
|
||||
req.on("close", () => {
|
||||
clearInterval(interval);
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
|
||||
// Start the server
|
||||
app.listen(port, () => {
|
||||
console.log(`Mock API server running at http://localhost:${port}`);
|
||||
});
|
||||
887
web/package-lock.json
generated
887
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,9 @@
|
||||
"svelte": "^5.28.1",
|
||||
"svelte-check": "^4.1.6",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
"vite": "^6.4.1",
|
||||
"body-parser": "^2.2.0",
|
||||
"express": "^5.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
|
||||
@@ -3,18 +3,33 @@
|
||||
import IDTable from "./lib/IDTable.svelte";
|
||||
import LastId from "./lib/LastID.svelte";
|
||||
import AddIDModal from "./lib/AddIDModal.svelte";
|
||||
import ExportModal from "./lib/ExportModal.svelte";
|
||||
import { generateCSVFile } from "./lib/exporting";
|
||||
import {
|
||||
cacheMappingInLocalstore,
|
||||
fetchMapping,
|
||||
loadCachedMappingFromLocalstore,
|
||||
type IDMap,
|
||||
} from "./lib/IDMapping";
|
||||
import { downloadBlob } from "./lib/downloadBlob";
|
||||
|
||||
let lastID: string = $state("");
|
||||
let mapping: IDMap | null = $state(null);
|
||||
|
||||
let addModal: AddIDModal;
|
||||
let idTable: IDTable;
|
||||
let exportModal: ExportModal;
|
||||
|
||||
onMount(async () => {
|
||||
mapping = loadCachedMappingFromLocalstore();
|
||||
|
||||
let fetchedMapping = await fetchMapping();
|
||||
mapping = fetchedMapping;
|
||||
cacheMappingInLocalstore(fetchedMapping);
|
||||
|
||||
onMount(() => {
|
||||
let sse = new EventSource("/api/idevent");
|
||||
|
||||
sse.onmessage = (e) => {
|
||||
sse.addEventListener("msg", function (e) {
|
||||
lastID = e.data;
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -25,13 +40,14 @@
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Anwesenheit</h1>
|
||||
</div>
|
||||
|
||||
<a
|
||||
<button
|
||||
class="px-6 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
||||
href="/api/csv"
|
||||
download="anwesenheit.csv"
|
||||
onclick={() => {
|
||||
exportModal.open();
|
||||
}}
|
||||
>
|
||||
Download CSV
|
||||
</a>
|
||||
Export CSV
|
||||
</button>
|
||||
|
||||
<div class="pt-3 pb-2">
|
||||
<LastId
|
||||
@@ -42,15 +58,42 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<IDTable bind:this={idTable} onEdit={(id,firstName,lastName)=>{
|
||||
addModal.open(id,firstName,lastName);
|
||||
}}/>
|
||||
{#if mapping}
|
||||
<IDTable
|
||||
data={mapping}
|
||||
onEdit={(id, firstName, lastName) => {
|
||||
addModal.open(id, firstName, lastName);
|
||||
}}
|
||||
/>
|
||||
<span>Gesammmte einträge: {Object.keys(mapping).length}</span>
|
||||
{:else}
|
||||
Lade ...
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<AddIDModal
|
||||
bind:this={addModal}
|
||||
onSubmitted={() => {
|
||||
idTable.reloadData();
|
||||
onSubmitted={async (id, firstName, lastname) => {
|
||||
if (mapping == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapping[id] = {
|
||||
first: firstName,
|
||||
last: lastname,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
<ExportModal
|
||||
bind:this={exportModal}
|
||||
onSubmitted={async (from, to) => {
|
||||
if (!mapping) {
|
||||
return;
|
||||
}
|
||||
let csvFile = await generateCSVFile(from, to, mapping);
|
||||
|
||||
downloadBlob("export.csv", csvFile, "text/csv");
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script lang="ts">
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
let { onSubmitted }: { onSubmitted?: () => void } = $props();
|
||||
let {
|
||||
onSubmitted,
|
||||
}: {
|
||||
onSubmitted?: (id: string, firstName: string, lastName: string) => void;
|
||||
} = $props();
|
||||
|
||||
let displayID = $state("");
|
||||
let firstName = $state("");
|
||||
@@ -9,7 +13,11 @@
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
export function open(presetID: string, presetFirstName?: string, presetLastName?: string) {
|
||||
export function open(
|
||||
presetID: string,
|
||||
presetFirstName?: string,
|
||||
presetLastName?: string,
|
||||
) {
|
||||
displayID = presetID;
|
||||
|
||||
firstName = presetFirstName ?? "";
|
||||
@@ -33,13 +41,14 @@
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
}).then(() => {
|
||||
onSubmitted?.();
|
||||
});
|
||||
|
||||
}).then((res) => {
|
||||
if (res.status == 201) {
|
||||
onSubmitted?.(displayID, firstName, lastName);
|
||||
}
|
||||
firstName = "";
|
||||
lastName = "";
|
||||
displayID = "";
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
31
web/src/lib/Day.ts
Normal file
31
web/src/lib/Day.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export type Day = number;
|
||||
|
||||
export interface AttendanceDay {
|
||||
date: Day,
|
||||
ids: string[],
|
||||
}
|
||||
|
||||
export function dayToDate(day: Day): Date {
|
||||
const SEC_PER_DAY = 86_400;
|
||||
|
||||
return new Date(day * SEC_PER_DAY * 1000);
|
||||
}
|
||||
|
||||
export async function fetchDay(day: Day): Promise<AttendanceDay> {
|
||||
let res = await fetch("/api/day?" + (new URLSearchParams({ day: day.toString() }).toString()));
|
||||
|
||||
let json = await res.json();
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function fetchDays(from: Date, to: Date): Promise<Day[]> {
|
||||
let q = new URLSearchParams({ from: (from.getTime() / 1000).toString(), to: (to.getTime() / 1000).toString() });
|
||||
|
||||
let res = await fetch("/api/days?" + q);
|
||||
|
||||
let json = await res.json();
|
||||
|
||||
return json;
|
||||
}
|
||||
|
||||
142
web/src/lib/ExportModal.svelte
Normal file
142
web/src/lib/ExportModal.svelte
Normal file
@@ -0,0 +1,142 @@
|
||||
<script lang="ts">
|
||||
import Modal from "./Modal.svelte";
|
||||
|
||||
let { onSubmitted }: { onSubmitted?: (from: Date, to: Date) => void } =
|
||||
$props();
|
||||
|
||||
let modal: Modal;
|
||||
|
||||
let fromDate: string | undefined = $state();
|
||||
let toDate: string | undefined = $state();
|
||||
|
||||
let selectedYear: number = $state(new Date().getFullYear());
|
||||
|
||||
let selectedTab = $state(0);
|
||||
|
||||
export function open() {
|
||||
modal.open();
|
||||
}
|
||||
|
||||
function generateYears() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const startingYear = 2020;
|
||||
|
||||
return Array.from(
|
||||
new Array(currentYear + 1 - startingYear),
|
||||
(_, i) => i + startingYear,
|
||||
);
|
||||
}
|
||||
|
||||
function onsubmit(e: SubmitEvent) {
|
||||
let from: Date;
|
||||
let to: Date;
|
||||
|
||||
switch (selectedTab) {
|
||||
case 0:
|
||||
if (!fromDate || !toDate) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
from = new Date(fromDate);
|
||||
to = new Date(toDate);
|
||||
break;
|
||||
case 1:
|
||||
from = new Date(selectedYear, 0);
|
||||
to = new Date(selectedYear + 1, 0);
|
||||
break;
|
||||
default:
|
||||
console.error("Invalid tab");
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmitted?.(from, to);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:this={modal}>
|
||||
<div class="flex">
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedTab = 0;
|
||||
}}
|
||||
class="tab {selectedTab === 0 ? 'tab-active' : ''}"
|
||||
>
|
||||
Datum
|
||||
</button>
|
||||
<button
|
||||
onclick={() => {
|
||||
selectedTab = 1;
|
||||
}}
|
||||
class="tab {selectedTab === 1 ? 'tab-active' : ''}"
|
||||
>
|
||||
Jahr
|
||||
</button>
|
||||
</div>
|
||||
<form method="dialog" {onsubmit} class="flex flex-col">
|
||||
{#if selectedTab === 0}
|
||||
<div>
|
||||
<label class="form-row">
|
||||
<span>Von:</span>
|
||||
<input type="date" class="form-input" bind:value={fromDate} />
|
||||
</label>
|
||||
|
||||
<label class="form-row">
|
||||
<span>Bis:</span>
|
||||
<input type="date" class="form-input" bind:value={toDate} />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedTab === 1}
|
||||
<div>
|
||||
<label class="form-row">
|
||||
<span>Kalendar Jahr:</span>
|
||||
<select class="form-input" bind:value={selectedYear}>
|
||||
{#each generateYears() as year}
|
||||
<option value={year}>{year}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end mt-3">
|
||||
<button
|
||||
type="reset"
|
||||
class="mr-5 px-2 py-1 bg-red-500 rounded-2xl shadow-md"
|
||||
onclick={() => {
|
||||
modal.close();
|
||||
|
||||
fromDate = undefined;
|
||||
toDate = undefined;
|
||||
}}>Abbrechen</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-2 py-1 bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
||||
>Export CSV</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<style scoped>
|
||||
@reference "../app.css";
|
||||
|
||||
.tab {
|
||||
@apply px-4 py-2 rounded-t-lg bg-indigo-600 hover:bg-indigo-700 font-medium border-b-2 border-transparent cursor-pointer transition-colors duration-200;
|
||||
}
|
||||
|
||||
.tab-active {
|
||||
@apply px-4 py-2 bg-indigo-500 font-semibold border-b-2 border-blue-600 shadow-sm cursor-pointer;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@apply flex justify-between my-1;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply ml-20;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,3 @@
|
||||
export interface IDMapping {
|
||||
id_map: IDMap
|
||||
}
|
||||
|
||||
export interface IDMap {
|
||||
[name: string]: Name
|
||||
}
|
||||
@@ -11,6 +7,57 @@ export interface Name {
|
||||
last: string,
|
||||
}
|
||||
|
||||
async function fetchIDList(): Promise<string[]> {
|
||||
let res = await fetch("/api/mappings");
|
||||
let data = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchID(id: string): Promise<Name> {
|
||||
let res = await fetch("/api/mapping?id=" + id);
|
||||
let data = await res.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function fetchMapping(): Promise<IDMap> {
|
||||
let ids = await fetchIDList();
|
||||
|
||||
let map: IDMap = {};
|
||||
|
||||
for (const id of ids) {
|
||||
let id_name = await fetchID(id);
|
||||
map[id] = id_name;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
const CACHE_KEY = "idmap";
|
||||
|
||||
export function cacheMappingInLocalstore(mapping: IDMap) {
|
||||
if (!localStorage) {
|
||||
console.error("localStorage is not available");
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(mapping));
|
||||
}
|
||||
|
||||
export function loadCachedMappingFromLocalstore(): IDMap | null {
|
||||
if (!localStorage) {
|
||||
console.error("localStorage is not available");
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = localStorage.getItem(CACHE_KEY);
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapping = JSON.parse(data);
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export async function addMapping(id: string, firstName: string, lastName: string) {
|
||||
let req = await fetch("/api/mapping", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { IDMapping } from "./IDMapping";
|
||||
let data: IDMapping | undefined = $state();
|
||||
import { type IDMap } from "./IDMapping";
|
||||
|
||||
let { onEdit }: { onEdit?: (string,string,string) => void } = $props();
|
||||
|
||||
export async function reloadData() {
|
||||
let res = await fetch("/api/mapping");
|
||||
|
||||
data = await res.json();
|
||||
}
|
||||
let {
|
||||
onEdit,
|
||||
data,
|
||||
}: {
|
||||
onEdit?: (id: string, firstName: string, lastName: string) => void;
|
||||
data: IDMap;
|
||||
} = $props();
|
||||
|
||||
let rows = $derived(
|
||||
data
|
||||
? Object.entries(data.id_map).map(([id, value]) => ({
|
||||
? Object.entries(data).map(([id, value]) => ({
|
||||
id,
|
||||
...value,
|
||||
}))
|
||||
@@ -43,17 +41,9 @@
|
||||
if (sortKey !== key) return "";
|
||||
return sortDirection === "asc" ? "▲" : "▼";
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await reloadData();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
{#if data == null}
|
||||
Loading...
|
||||
{:else}
|
||||
<div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto">
|
||||
<div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto">
|
||||
<table class="px-10">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -84,8 +74,7 @@
|
||||
|
||||
<span class="indicator">{indicator("first")}</span>
|
||||
</th>
|
||||
<th>
|
||||
</th>
|
||||
<th> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -94,19 +83,22 @@
|
||||
<td class="whitespace-nowrap pr-5 pl-2 py-1">{row.id}</td>
|
||||
<td class="whitespace-nowrap pr-5">{row.last}</td>
|
||||
<td class="whitespace-nowrap pr-5">{row.first}</td>
|
||||
<td class="pr-5" ><button onclick={()=>{
|
||||
onEdit && onEdit(row.id,row.first,row.last);
|
||||
}} class="cursor-pointer">🔧</button></td>
|
||||
<td class="pr-5"
|
||||
><button
|
||||
onclick={() => {
|
||||
onEdit && onEdit(row.id, row.first, row.last);
|
||||
}}
|
||||
class="cursor-pointer">🔧</button
|
||||
></td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="css" scoped>
|
||||
@reference "../app.css";
|
||||
|
||||
.indicator {
|
||||
@apply ml-1 w-4 inline-block;
|
||||
}
|
||||
|
||||
103
web/src/lib/csv.ts
Normal file
103
web/src/lib/csv.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
interface CSVOptions {
|
||||
delimiter?: string;
|
||||
headerOrder?: string[];
|
||||
eol?: string;
|
||||
includeBOM?: boolean;
|
||||
nullString?: string;
|
||||
}
|
||||
|
||||
type RowObject = Record<string, any>;
|
||||
type InputRows = RowObject[];
|
||||
|
||||
export function generateCSVString(input: InputRows, opts: CSVOptions = {}): string {
|
||||
const {
|
||||
delimiter = ";",
|
||||
headerOrder,
|
||||
eol = "\r\n",
|
||||
includeBOM = true,
|
||||
nullString = "",
|
||||
} = opts;
|
||||
|
||||
// Check if the value need quoting
|
||||
// Usually not needed in our use case but still in case
|
||||
const needsQuoting = (s: string) =>
|
||||
s.includes(delimiter) || s.includes('"') || s.includes("\n") || s.includes("\r") || /^\s|\s$/.test(s);
|
||||
|
||||
// Transform the value of a cell in a SAFE string
|
||||
const escapeCell = (raw: any): string => {
|
||||
if (raw === null || raw === undefined) return nullString;
|
||||
|
||||
let s = stringify(raw);
|
||||
|
||||
// Replace quotes
|
||||
if (s.includes('"')) s = s.replace(/"/g, '""');
|
||||
|
||||
return needsQuoting(s) ? `"${s}"` : s;
|
||||
};
|
||||
|
||||
// Transform the value of a cell into a string
|
||||
function stringify(v: any): string {
|
||||
if (v === null || v === undefined) return nullString;
|
||||
if (v instanceof Date) return v.toLocaleDateString();
|
||||
if (typeof v === "boolean") return v ? "X" : "";
|
||||
|
||||
if (typeof v === "object") {
|
||||
// CHeck if array and join with "|"
|
||||
if (Array.isArray(v)) return v.map(item => (item === null || item === undefined ? nullString : String(item))).join("|");
|
||||
|
||||
// If all fails parse it via json
|
||||
// Should also not happen in our use case
|
||||
try { return JSON.stringify(v); } catch { return String(v); }
|
||||
}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
const objs = (input as RowObject[]) || [];
|
||||
|
||||
// Derive headers in the order keys are first encountered (fixed: no reduce)
|
||||
const seen = new Set<string>();
|
||||
const derivedOrder: string[] = [];
|
||||
for (const obj of objs) {
|
||||
if (!obj || typeof obj !== "object") continue;
|
||||
for (const k of Object.keys(obj)) {
|
||||
if (!seen.has(k)) {
|
||||
seen.add(k);
|
||||
derivedOrder.push(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply headerOrder if provided: put listed columns first (in the order provided),
|
||||
// then append the remaining derived headers in their derived order.
|
||||
let finalHeaders = derivedOrder.slice();
|
||||
if (Array.isArray(headerOrder) && headerOrder.length > 0) {
|
||||
const headSet = new Set(headerOrder);
|
||||
const first = headerOrder.filter(h => seen.has(h)); // keep only headers that actually exist
|
||||
const rest = derivedOrder.filter(h => !headSet.has(h));
|
||||
finalHeaders = first.concat(rest);
|
||||
}
|
||||
|
||||
const rowsOut: string[] = [];
|
||||
|
||||
if (finalHeaders.length > 0) {
|
||||
rowsOut.push(finalHeaders.map(h => escapeCell(h)).join(delimiter));
|
||||
}
|
||||
|
||||
// Parse every row
|
||||
for (const obj of objs) {
|
||||
|
||||
// Check if obj is null or somthing else we can't convert
|
||||
if (!obj || typeof obj !== "object") {
|
||||
|
||||
// produce empty row with same number of columns
|
||||
rowsOut.push(finalHeaders.map(() => escapeCell(null)).join(delimiter));
|
||||
continue;
|
||||
}
|
||||
|
||||
// For every header check if a value on our row exist and print it
|
||||
const row = finalHeaders.map(col => escapeCell(col in obj ? (obj as any)[col] : null)).join(delimiter);
|
||||
rowsOut.push(row);
|
||||
}
|
||||
|
||||
return (includeBOM ? "\uFEFF" : "") + rowsOut.join(eol);
|
||||
}
|
||||
8
web/src/lib/downloadBlob.ts
Normal file
8
web/src/lib/downloadBlob.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function downloadBlob(filename: string, content: string, mimeType = "text/plain") {
|
||||
const blob = new Blob([content], { type: mimeType });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
53
web/src/lib/exporting.ts
Normal file
53
web/src/lib/exporting.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { generateCSVString } from "./csv";
|
||||
import { dayToDate, fetchDay, fetchDays, type AttendanceDay, type Day } from "./Day";
|
||||
import type { IDMap } from "./IDMapping";
|
||||
|
||||
interface CSVRow {
|
||||
ID: string
|
||||
Vorname: string
|
||||
Nachname: string
|
||||
[key: string]: string | boolean
|
||||
}
|
||||
|
||||
function prepareRows(mapping: IDMap, days: AttendanceDay[]): CSVRow[] {
|
||||
let csvData: CSVRow[] = [];
|
||||
|
||||
const allIDs = Object.keys(mapping);
|
||||
|
||||
for (const id of allIDs) {
|
||||
const name = mapping[id];
|
||||
const row: CSVRow = {
|
||||
ID: id,
|
||||
Vorname: name.first,
|
||||
Nachname: name.last,
|
||||
};
|
||||
|
||||
for (const day of days) {
|
||||
const dayKey = dayToDate(day.date).toLocaleDateString();
|
||||
row[dayKey] = day.ids.includes(id);
|
||||
}
|
||||
|
||||
csvData.push(row);
|
||||
}
|
||||
|
||||
return csvData;
|
||||
}
|
||||
|
||||
async function getDays(from: Date, to: Date): Promise<AttendanceDay[]> {
|
||||
const recordedDays: Day[] = await fetchDays(from, to);
|
||||
let days: AttendanceDay[] = [];
|
||||
|
||||
for (const day of recordedDays) {
|
||||
days.push(await fetchDay(day))
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
export async function generateCSVFile(from: Date, to: Date, mapping: IDMap): Promise<string> {
|
||||
const days = await getDays(from, to);
|
||||
const rows = prepareRows(mapping, days);
|
||||
const csvString = generateCSVString(rows);
|
||||
|
||||
return csvString;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:8080",
|
||||
target: "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user