Compare commits

41 Commits

Author SHA1 Message Date
1731cff9c9 set log level in release build to info 2025-11-04 15:35:53 +01:00
336249fd12 added gitattributes 2025-11-04 15:26:24 +01:00
5ffeb969e2 chache idmap on client 2025-11-04 15:00:12 +01:00
a4ab0bad48 pessimistic id mapping loading on frontend 2025-11-04 14:47:46 +01:00
02798d90c4 improved error handeling for sdcard functions 2025-11-03 18:35:10 +01:00
07d51264f9 changed storage method of ID mappings 2025-11-03 16:13:41 +01:00
6cc6df6298 changed TallyID deserializer to string insteand of str 2025-11-03 16:13:02 +01:00
7d78aa8c4c enabled RTC 2025-11-03 15:47:21 +01:00
Philipp_EndevourOS
fb8d98da28 added counter for all mappings in ui 2025-10-31 18:12:07 +01:00
8100748f8a change sse event listner 2025-10-28 16:08:42 +01:00
Philipp_EndevourOS
f22eb91b67 bump vite version 2025-10-27 20:36:01 +01:00
Philipp_EndevourOS
a04400a3a0 rtc will only setup with buildtime when it is larger then actual time in rtc 2025-10-27 19:51:29 +01:00
Philipp_EndevourOS
6f7561a295 deleted some imports 2025-10-27 19:50:19 +01:00
Philipp_EndevourOS
279e2f7454 chanched startup routine 2025-10-27 19:32:53 +01:00
Philipp_EndevourOS
610840311f added red led flash in inti to see a failer on init routine 2025-10-27 19:29:33 +01:00
56d2dbfa7c removed unused code 2025-10-27 16:38:02 +01:00
35f12a4c45 pass current day to IDStore constructor 2025-10-27 16:08:18 +01:00
7346fb42bd improved networks stack & DHCP server 2025-10-27 15:33:19 +01:00
c0bf8399a3 removed empty files 2025-10-27 15:31:16 +01:00
9852534dc6 improved wifi setup 2025-10-27 15:23:06 +01:00
d63e9e964d improved hardware init 2025-10-27 15:15:01 +01:00
16ea1db55f Merge branch 'feature/newlib' 2025-10-27 14:34:31 +01:00
a0ed04a560 moved LED and feedback to new lib 2025-10-27 14:25:26 +01:00
Philipp_EndevourOS
4e988e8f01 changed LED amount to 1 2025-10-27 14:16:36 +01:00
009f6cbb2e changed to new ESP libs WIP 2025-10-27 13:56:13 +01:00
Philipp_EndevourOS
967da9fc30 feedback ack turns LED off after lighting green 2025-10-25 15:04:05 +02:00
00cb7efedb unwraps now give a warning 2025-10-24 13:24:44 +02:00
ebbec7885e added missing persist mapping 2025-10-23 17:55:29 +02:00
7ecd2052d8 handle error in sse event route 2025-10-23 17:37:46 +02:00
96512c8a12 added wifi password set at compiletime 2025-10-23 16:55:16 +02:00
c3eaff03d9 implemented year select for export csv 2025-10-23 16:33:15 +02:00
4bf89626b9 changed defaults in csv generation 2025-10-23 16:32:55 +02:00
7c0c0699b5 added umlaut user to mock data 2025-10-23 16:32:38 +02:00
1ea70e4993 improved panic restart 2025-10-20 15:13:16 +02:00
770dca5b0f enabled download of exported csv 2025-10-20 14:30:22 +02:00
2e75ba2908 simplified csv generator 2025-10-20 14:30:22 +02:00
141c1aa9cb added downloadBlob function 2025-10-20 14:30:22 +02:00
Philipp_EndevourOS
4abbd844d2 added software resett after 10min when panic 2025-10-20 13:35:14 +02:00
7346b47816 added csv exporting logic 2025-10-17 13:10:23 +02:00
cd63dd3ee4 added ExportModal 2025-10-17 13:09:23 +02:00
f5d4ae1e05 added new routes & more mock data to server mock 2025-10-17 13:08:51 +02:00
37 changed files with 1818 additions and 1138 deletions

View File

@@ -12,3 +12,7 @@ target = "riscv32imac-unknown-none-elf"
[unstable] [unstable]
build-std = ["alloc", "core"] build-std = ["alloc", "core"]
[env]
WIFI_PASSWD = "hunter22"
WIFI_SSID = "fwa"

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
/pcb/bom/ibom.html linguist-vendored

925
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,64 +12,45 @@ bench = false
[dependencies] [dependencies]
esp-bootloader-esp-idf = "0.1.0" esp-bootloader-esp-idf = "0.1.0"
embassy-net = { version = "0.7.0", features = [ esp-hal = { version = "1.0.0-rc.1", features = ["esp32c6", "unstable"] }
"dhcpv4", esp-alloc = "0.9.0"
"medium-ethernet", esp-println = { version = "0.16.0", features = ["esp32c6", "log-04"] }
"tcp", esp-radio = { version = "0.16.0", features = ["esp32c6","esp-alloc", "wifi", "log-04", "smoltcp","unstable"]}
"udp", esp-rtos = { version = "0.1.1", features = ["esp32c6", "embassy", "esp-radio", "esp-alloc"] }
] }
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",
] }
critical-section = "1.2.0" critical-section = "1.2.0"
embassy-executor = { version = "0.7.0", features = ["nightly"] } log = { version = "0.4", features = ["release_max_level_info"]}
embassy-time = { version = "0.4.0", features = ["generic-queue-8"] } static_cell = { version = "2.1.1", features = ["nightly"] }
esp-hal-embassy = { version = "0.9.0", features = ["esp32c6"] }
esp-wifi = { version = "0.15.0", features = [
"wifi",
"builtin-scheduler",
"esp-alloc",
"esp32c6",
"log-04",
] }
heapless = { version = "0.8.0", default-features = false } heapless = { version = "0.8.0", default-features = false }
static_cell = { version = "2.1.0", features = ["nightly"] } chrono = { version = "0.4.41", default-features = false }
esp-println = { version = "0.15.0", features = ["esp32c6", "log-04"] }
log = { version = "0.4" } 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-dhcp = { version = "0.6.0", features = ["log"] }
edge-nal = "0.5.0" edge-nal = "0.5.0"
edge-nal-embassy = { version = "0.6.0", features = ["log"] } edge-nal-embassy = { version = "0.6.0", features = ["log"] }
picoserve = { git = "https://github.com/sammhicks/picoserve.git", rev = "400df53f61137e1bb2883ec610fc191bfe551a3a", features = ["embassy", "log", "json"] } 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" 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"]} 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" smart-leds = "0.4.0"
serde = { version = "1.0.219", default-features = false, features = ["derive", "alloc"] }
embedded-sdmmc = "0.8.0" embedded-sdmmc = "0.8.0"
embedded-hal-bus = "0.3.0" embedded-hal-bus = "0.3.0"
serde_json = { version = "1.0.143", default-features = false, features = ["alloc"]} thiserror = { version = "2.0.17", default-features = false }
embassy-futures = { version = "0.1.2", features = ["log"] }
[profile.dev] [profile.dev]
# Rust debug is too slow. # Rust debug is too slow.

View File

@@ -1,3 +1,2 @@
pub mod nfc_reader; pub mod nfc_reader;
pub mod rtc; pub mod rtc;
pub mod buzzer;

View File

View File

@@ -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(())
}
}

View File

@@ -1,7 +1,6 @@
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use ds3231::{ use ds3231::{
Config, DS3231, DS3231Error, InterruptControl, Oscillator, SquareWaveFrequency, Config, DS3231, InterruptControl, Oscillator, SquareWaveFrequency, TimeRepresentation,
TimeRepresentation,
}; };
use esp_hal::{ use esp_hal::{
Async, Async,
@@ -15,10 +14,6 @@ include!(concat!(env!("OUT_DIR"), "/build_time.rs"));
const RTC_ADDRESS: u8 = 0x68; 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 { pub struct RTCClock {
dev: DS3231<I2c<'static, Async>>, dev: DS3231<I2c<'static, Async>>,
} }
@@ -34,10 +29,7 @@ impl RTCClock {
pub async fn get_time(&mut self) -> u64 { pub async fn get_time(&mut self) -> u64 {
match self.dev.datetime().await { match self.dev.datetime().await {
Ok(datetime) => { Ok(datetime) => datetime.and_utc().timestamp() as u64,
let utc_time = datetime.and_utc().timestamp() as u64;
utc_time
}
Err(e) => { Err(e) => {
FEEDBACK_STATE.signal(feedback::FeedbackState::Error); FEEDBACK_STATE.signal(feedback::FeedbackState::Error);
error!("Failed to read RTC datetime: {:?}", e); error!("Failed to read RTC datetime: {:?}", e);
@@ -47,33 +39,6 @@ impl RTCClock {
} }
} }
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>> { pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>> {
let mut rtc: DS3231<I2c<'static, Async>> = DS3231::new(i2c, RTC_ADDRESS); let mut rtc: DS3231<I2c<'static, Async>> = DS3231::new(i2c, RTC_ADDRESS);
let naive_dt = Utc let naive_dt = Utc
@@ -87,7 +52,7 @@ pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>>
square_wave_frequency: SquareWaveFrequency::Hz1, square_wave_frequency: SquareWaveFrequency::Hz1,
interrupt_control: InterruptControl::Interrupt, // Enable interrupt mode interrupt_control: InterruptControl::Interrupt, // Enable interrupt mode
battery_backed_square_wave: false, battery_backed_square_wave: false,
oscillator_enable: Oscillator::Disabled, oscillator_enable: Oscillator::Enabled,
}; };
match rtc.configure(&rtc_config).await { match rtc.configure(&rtc_config).await {
@@ -99,11 +64,13 @@ pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>>
} }
} }
rtc.set_datetime(&naive_dt).await.unwrap_or_else(|e| { if rtc.datetime().await.unwrap() < naive_dt {
FEEDBACK_STATE.signal(feedback::FeedbackState::Error); rtc.set_datetime(&naive_dt).await.unwrap_or_else(|e| {
error!("Failed to set RTC datetime: {:?}", e); FEEDBACK_STATE.signal(feedback::FeedbackState::Error);
}); error!("Failed to set RTC datetime: {:?}", e);
info!("RTC datetime set to: {}", naive_dt); });
info!("RTC datetime set to: {}", naive_dt);
}
match rtc.status().await { match rtc.status().await {
Ok(mut status) => { Ok(mut status) => {
@@ -117,12 +84,7 @@ pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>>
Err(e) => info!("Failed to read status: {:?}", e), Err(e) => info!("Failed to read status: {:?}", e),
} }
info!("RTC time is: {:?}", rtc.datetime().await.unwrap());
rtc 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)
}

View File

@@ -1,11 +1,12 @@
use embassy_time::{Duration, Timer}; use embassy_time::{Duration, Timer};
use esp_hal::{peripherals, rmt::ConstChannelAccess}; use esp_hal::gpio::Output;
use esp_hal_smartled::SmartLedsAdapterAsync; use esp_hal_smartled::SmartLedsAdapterAsync;
use log::debug; use log::debug;
use smart_leds::SmartLedsWriteAsync; use smart_leds::SmartLedsWriteAsync;
use smart_leds::colors::{BLACK, GREEN, RED, YELLOW}; use smart_leds::colors::{BLACK, GREEN, RED, YELLOW};
use smart_leds::{brightness, colors::BLUE}; use smart_leds::{brightness, colors::BLUE};
use crate::init::hardware;
use crate::{FEEDBACK_STATE, init}; use crate::{FEEDBACK_STATE, init};
#[derive(Copy, Clone, Debug)] #[derive(Copy, Clone, Debug)]
@@ -24,14 +25,10 @@ const LED_LEVEL: u8 = 255;
#[embassy_executor::task] #[embassy_executor::task]
pub async fn feedback_task( pub async fn feedback_task(
mut led: SmartLedsAdapterAsync< mut led: SmartLedsAdapterAsync<'static, { hardware::LED_BUFFER_SIZE }>,
ConstChannelAccess<esp_hal::rmt::Tx, 0>, mut buzzer: Output<'static>,
{ init::hardware::LED_BUFFER_SIZE },
>,
buzzer: peripherals::GPIO21<'static>,
) { ) {
debug!("Starting feedback task"); debug!("Starting feedback task");
let mut buzzer = init::hardware::setup_buzzer(buzzer);
loop { loop {
let feedback_state = FEEDBACK_STATE.wait().await; let feedback_state = FEEDBACK_STATE.wait().await;
match feedback_state { match feedback_state {
@@ -46,6 +43,12 @@ pub async fn feedback_task(
Timer::after(Duration::from_millis(100)).await; Timer::after(Duration::from_millis(100)).await;
buzzer.set_low(); buzzer.set_low();
Timer::after(Duration::from_millis(50)).await; Timer::after(Duration::from_millis(50)).await;
led.write(brightness(
[BLACK; init::hardware::NUM_LEDS].into_iter(),
LED_LEVEL,
))
.await
.unwrap();
} }
FeedbackState::Nack => { FeedbackState::Nack => {
led.write(brightness( led.write(brightness(
@@ -101,6 +104,8 @@ pub async fn feedback_task(
buzzer.set_high(); buzzer.set_high();
Timer::after(Duration::from_millis(100)).await; Timer::after(Duration::from_millis(100)).await;
buzzer.set_low(); buzzer.set_low();
Timer::after(Duration::from_secs(2)).await;
led.write(brightness( led.write(brightness(
[BLACK; init::hardware::NUM_LEDS].into_iter(), [BLACK; init::hardware::NUM_LEDS].into_iter(),
LED_LEVEL, LED_LEVEL,
@@ -118,7 +123,7 @@ pub async fn feedback_task(
} }
FeedbackState::Idle => { FeedbackState::Idle => {
led.write(brightness( led.write(brightness(
[GREEN; init::hardware::NUM_LEDS].into_iter(), [BLACK; init::hardware::NUM_LEDS].into_iter(),
LED_LEVEL, LED_LEVEL,
)) ))
.await .await
@@ -128,106 +133,3 @@ pub async fn feedback_task(
debug!("Feedback state: {:?}", feedback_state); 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(())
}
*/

View File

@@ -1,17 +1,16 @@
use core::cell::RefCell;
use critical_section::Mutex;
use embassy_executor::Spawner; use embassy_executor::Spawner;
use embassy_net::Stack; use embassy_net::Stack;
use embassy_time::{Duration, Timer}; use embassy_time::{Duration, Timer};
use esp_hal::Blocking; 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::i2c::master::Config;
use esp_hal::peripherals::{ use esp_hal::peripherals::{
GPIO0, GPIO1, GPIO16, GPIO17, GPIO18, GPIO19, GPIO20, GPIO21, GPIO22, GPIO23, I2C0, RMT, SPI2, GPIO1, GPIO16, GPIO17, GPIO18, GPIO19, GPIO20, GPIO21, GPIO22, GPIO23, I2C0, RMT, SPI2, UART1,
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::spi::master::{Config as Spi_config, Spi};
use esp_hal::system::software_reset;
use esp_hal::time::Rate; use esp_hal::time::Rate;
use esp_hal::timer::timg::TimerGroup; use esp_hal::timer::timg::TimerGroup;
use esp_hal::{ use esp_hal::{
@@ -19,12 +18,15 @@ use esp_hal::{
clock::CpuClock, clock::CpuClock,
gpio::{Output, OutputConfig}, gpio::{Output, OutputConfig},
i2c::master::I2c, i2c::master::I2c,
timer::systimer::SystemTimer,
uart::Uart, uart::Uart,
}; };
use esp_hal_smartled::{SmartLedsAdapterAsync, buffer_size_async}; use esp_hal_smartled::{SmartLedsAdapterAsync, buffer_size_async};
use esp_println::logger::init_logger; use esp_println::logger::init_logger;
use log::{debug, error}; 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::network;
use crate::init::sd_card::{SDCardPersistence, setup_sdcard}; use crate::init::sd_card::{SDCardPersistence, setup_sdcard};
@@ -47,122 +49,137 @@ use crate::init::wifi;
* *
*************************************************/ *************************************************/
pub const NUM_LEDS: usize = 66; pub const NUM_LEDS: usize = 1;
pub const LED_BUFFER_SIZE: usize = NUM_LEDS * 25; pub const LED_BUFFER_SIZE: usize = buffer_size_async(NUM_LEDS);
static SD_DET: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));
#[panic_handler] #[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! { fn panic(info: &core::panic::PanicInfo) -> ! {
loop { let delay = Delay::new();
error!("PANIC: {info}"); error!("PANIC: {info}");
} delay.delay(esp_hal::time::Duration::from_secs(30));
software_reset()
} }
esp_bootloader_esp_idf::esp_app_desc!(); esp_bootloader_esp_idf::esp_app_desc!();
pub async fn hardware_init( #[derive(Error, Debug)]
spawner: &mut Spawner, pub enum HardwareInitError {
) -> ( #[error("Failed to etup UART")]
Uart<'static, Async>, Uart(#[from] esp_hal::uart::ConfigError),
Stack<'static>,
I2c<'static, Async>,
SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE>,
GPIO21<'static>,
GPIO0<'static>,
SDCardPersistence,
) {
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
esp_alloc::heap_allocator!(size: 72 * 1024); #[error("Failed to setup I2C")]
I2C(#[from] esp_hal::i2c::master::ConfigError),
let timer0 = SystemTimer::new(peripherals.SYSTIMER); #[error("Failed to setup SPI")]
esp_hal_embassy::init(timer0.alarm0); Spi(#[from] esp_hal::spi::master::ConfigError),
init_logger(log::LevelFilter::Debug); #[error("Failed to setuo LED")]
Led(#[from] esp_hal::rmt::Error),
let timer1 = TimerGroup::new(peripherals.TIMG0); #[error("Failed to setup wifi")]
let mut rng = esp_hal::rng::Rng::new(peripherals.RNG); Wifi(#[from] wifi::WifiError),
let network_seed = (rng.random() as u64) << 32 | rng.random() as u64; }
wifi::set_antenna_mode(peripherals.GPIO3, peripherals.GPIO14).await; pub struct AppHardware {
let interfaces = wifi::setup_wifi(timer1.timer0, rng, peripherals.WIFI, spawner); pub uart: Uart<'static, Async>,
let stack = network::setup_network(network_seed, interfaces.ap, spawner); 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,
}
Timer::after(Duration::from_millis(1)).await; 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);
let uart_device = setup_uart(peripherals.UART1, peripherals.GPIO16, peripherals.GPIO17); esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 65536);
let i2c_device = setup_i2c(peripherals.I2C0, peripherals.GPIO22, peripherals.GPIO23); 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);
let sd_det_gpio = peripherals.GPIO0; init_logger(log::LevelFilter::Debug);
let spi_bus = setup_spi( let mut led = setup_led(peripherals.RMT, peripherals.GPIO1)?;
peripherals.SPI2, let _ = led.write(brightness(
peripherals.GPIO19, [RED; NUM_LEDS].into_iter(),
peripherals.GPIO20, 255,
peripherals.GPIO18, ))
); .await;
let sd_cs_pin = Output::new( let rng = esp_hal::rng::Rng::new();
peripherals.GPIO2, let network_seed = (rng.random() as u64) << 32 | rng.random() as u64;
esp_hal::gpio::Level::High,
OutputConfig::default(),
);
let vol_mgr = setup_sdcard(spi_bus, sd_cs_pin); wifi::set_antenna_mode(peripherals.GPIO3, peripherals.GPIO14).await;
let interfaces = wifi::setup_wifi(peripherals.WIFI, spawner)?;
let network_stack = network::setup_network(network_seed, interfaces.ap, spawner);
let buzzer_gpio = peripherals.GPIO21; Timer::after(Duration::from_millis(1)).await;
Timer::after(Duration::from_millis(500)).await; let uart_device = setup_uart(peripherals.UART1, peripherals.GPIO16, peripherals.GPIO17)?;
let led = setup_led(peripherals.RMT, peripherals.GPIO1); let i2c_device = setup_i2c(peripherals.I2C0, peripherals.GPIO22, peripherals.GPIO23)?;
debug!("hardware init done"); let sd_det_gpio = peripherals.GPIO0;
( let spi_bus = setup_spi(
uart_device, peripherals.SPI2,
stack, peripherals.GPIO19,
i2c_device, peripherals.GPIO20,
led, peripherals.GPIO18,
buzzer_gpio, )?;
sd_det_gpio,
vol_mgr, let sd_cs_pin = Output::new(
) peripherals.GPIO2,
esp_hal::gpio::Level::High,
OutputConfig::default(),
);
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;
debug!("hardware init done");
Ok(Self {
uart: uart_device,
network_stack,
i2c: i2c_device,
buzzer,
sd_present: sd_det_gpio.into(),
led,
sdcard: vol_mgr,
})
}
} }
fn setup_uart( fn setup_uart(
uart1: UART1<'static>, uart1: UART1<'static>,
uart_tx: GPIO16<'static>, uart_tx: GPIO16<'static>,
uart_rx: GPIO17<'static>, uart_rx: GPIO17<'static>,
) -> Uart<'static, Async> { ) -> Result<Uart<'static, Async>, esp_hal::uart::ConfigError> {
let uard_device = Uart::new(uart1, esp_hal::uart::Config::default().with_baudrate(9600)); 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())
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!
}
}
} }
fn setup_i2c( fn setup_i2c(
i2c0: I2C0<'static>, i2c0: I2C0<'static>,
sda: GPIO22<'static>, sda: GPIO22<'static>,
scl: GPIO23<'static>, scl: GPIO23<'static>,
) -> I2c<'static, Async> { ) -> Result<I2c<'static, Async>, esp_hal::i2c::master::ConfigError> {
debug!("init I2C"); debug!("init I2C");
let config = Config::default().with_frequency(Rate::from_khz(400)); 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(), let i2c = I2c::new(i2c0, config)?;
Err(e) => {
error!("Failed to initialize I2C: {:?}", e); Ok(i2c.with_sda(sda).with_scl(scl).into_async())
panic!(); //TODO panic!
}
};
i2c
} }
fn setup_spi( fn setup_spi(
@@ -170,39 +187,34 @@ fn setup_spi(
sck: GPIO19<'static>, sck: GPIO19<'static>,
miso: GPIO20<'static>, miso: GPIO20<'static>,
mosi: GPIO18<'static>, mosi: GPIO18<'static>,
) -> Spi<'static, Blocking> { ) -> Result<Spi<'static, Blocking>, esp_hal::spi::master::ConfigError> {
let spi = match Spi::new(spi2, Spi_config::default()) { let spi = Spi::new(spi2, Spi_config::default())?;
Ok(spi) => spi.with_sck(sck).with_miso(miso).with_mosi(mosi), Ok(spi.with_sck(sck).with_miso(miso).with_mosi(mosi))
Err(e) => panic!("Failed to initialize SPI: {:?}", e),
};
spi
} }
pub fn setup_buzzer(buzzer_gpio: GPIO21<'static>) -> Output<'static> { pub fn setup_buzzer(buzzer_gpio: GPIO21<'static>) -> Output<'static> {
let config = esp_hal::gpio::OutputConfig::default() let config = esp_hal::gpio::OutputConfig::default()
.with_drive_strength(esp_hal::gpio::DriveStrength::_40mA); .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( fn setup_led<'a>(
rmt: RMT<'static>, rmt: RMT<'a>,
led_gpio: GPIO1<'static>, led_gpio: GPIO1<'a>,
) -> SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE> { ) -> Result<esp_hal_smartled::SmartLedsAdapterAsync<'a, LED_BUFFER_SIZE>, esp_hal::rmt::Error> {
debug!("setup led");
let rmt: Rmt<'_, esp_hal::Async> = { let rmt: Rmt<'_, esp_hal::Async> = {
let frequency: Rate = Rate::from_mhz(80); let frequency: Rate = Rate::from_mhz(80);
Rmt::new(rmt, frequency) Rmt::new(rmt, frequency)
} }?
.expect("Failed to initialize RMT")
.into_async(); .into_async();
let rmt_channel = rmt.channel0; 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> = Ok(SmartLedsAdapterAsync::new(
SmartLedsAdapterAsync::new(rmt_channel, led_gpio, rmt_buffer); rmt_channel,
led_gpio,
led rmt_buffer,
))
} }

View File

@@ -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_executor::Spawner;
use embassy_net::{Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4}; use embassy_net::{Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
use embassy_time::{Duration, Timer}; use embassy_time::{Duration, Timer};
use esp_wifi::wifi::WifiDevice; use esp_radio::wifi::WifiDevice;
use static_cell::make_static; use static_cell::StaticCell;
use crate::webserver::WEB_TAKS_SIZE; use crate::webserver::WEB_TAKS_SIZE;
pub const NETWORK_STACK_SIZE: usize = WEB_TAKS_SIZE + 2; // + 2 for other network taks. Breaks pub const NETWORK_STACK_SIZE: usize = WEB_TAKS_SIZE + 2; // + 2 for other network taks. Breaks without
// 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> { pub fn setup_network<'a>(seed: u64, wifi: WifiDevice<'static>, spawner: 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");
let config = embassy_net::Config::ipv4_static(StaticConfigV4 { let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
address: Ipv4Cidr::new(gw_ip_addr, 24), address: Ipv4Cidr::new(GW_IP, 24),
gateway: Some(gw_ip_addr), gateway: Some(GW_IP),
dns_servers: Default::default(), dns_servers: Default::default(),
}); });
let (stack, runner) = embassy_net::new( static NETWORK_STACK: StaticCell<StackResources<NETWORK_STACK_SIZE>> = StaticCell::new();
wifi, let nw_stack = NETWORK_STACK.init(StackResources::new());
config,
make_static!(StackResources::<NETWORK_STACK_SIZE>::new()), let (stack, runner) = embassy_net::new(wifi, config, nw_stack, seed);
seed,
);
spawner.must_spawn(net_task(runner)); spawner.must_spawn(net_task(runner));
spawner.must_spawn(run_dhcp(stack, gw_ip_addr_str)); spawner.must_spawn(run_dhcp(stack));
stack stack
} }
#[embassy_executor::task] #[embassy_executor::task]
async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) { async fn run_dhcp(stack: Stack<'static>) {
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");
let mut buf = [0u8; 1500]; let mut buf = [0u8; 1500];
let mut gw_buf = [Ipv4Addr::UNSPECIFIED]; 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, DEFAULT_SERVER_PORT,
))) )))
.await .await
.unwrap(); .expect("Failed to bind socket for DHCP server");
loop { loop {
_ = io::server::run( _ = io::server::run(
&mut Server::<_, 64>::new_with_et(ip), &mut Server::<_, 64>::new_with_et(GW_IP),
&ServerOptions::new(ip, Some(&mut gw_buf)), &ServerOptions::new(GW_IP, Some(&mut gw_buf)),
&mut bound_socket, &mut bound_socket,
&mut buf, &mut buf,
) )

View File

@@ -1,10 +1,16 @@
use alloc::vec::Vec; use alloc::{string::ToString, vec::Vec};
use core::str::from_utf8;
use embassy_time::Delay; use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice; use embedded_hal_bus::spi::ExclusiveDevice;
use embedded_sdmmc::{SdCard, ShortFileName, TimeSource, Timestamp, VolumeIdx, VolumeManager}; use embedded_sdmmc::{
SdCard, SdCardError, ShortFileName, TimeSource, Timestamp, VolumeIdx, VolumeManager,
};
use esp_hal::{Blocking, gpio::Output, spi::master::Spi}; use esp_hal::{Blocking, gpio::Output, spi::master::Spi};
use thiserror::Error;
use crate::store::{AttendanceDay, IDMapping, day::Day, persistence::Persistence}; use crate::store::{
AttendanceDay, day::Day, mapping_loader::Name, persistence::Persistence, tally_id::TallyID,
};
pub struct DummyTimesource; pub struct DummyTimesource;
@@ -39,113 +45,231 @@ pub struct SDCardPersistence {
} }
impl SDCardPersistence { impl SDCardPersistence {
const MAPPING_FILENAME: &'static str = "MAPPING.JS"; const MAPPING_DIRNAME: &'static str = "MAPPINGS";
fn generate_filename(day: Day) -> ShortFileName { fn generate_filename_for_day(day: Day) -> Result<ShortFileName, PersistenceError> {
let basename = day.to_string(); let basename = day.to_string();
let mut filename: heapless::String<11> = heapless::String::new(); let mut filename: heapless::String<11> = heapless::String::new();
filename.push_str(&basename).unwrap(); filename
filename.push_str(".js").unwrap(); .push_str(&basename)
.map_err(|_| PersistenceError::DayFilename)?;
filename
.push_str(".JS")
.map_err(|_| PersistenceError::DayFilename)?;
ShortFileName::create_from_str(&filename).unwrap() ShortFileName::create_from_str(&filename).map_err(|_| PersistenceError::DayFilename)
}
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 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)?,
))
}
fn get_tallyid_from_path(
dirname: &ShortFileName,
filename: &ShortFileName,
) -> Result<TallyID, PersistenceError> {
let mut id_str: heapless::String<12> = heapless::String::new();
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)?;
let id: TallyID = id_str
.try_into()
.map_err(|_| PersistenceError::IDFilename)?;
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 { impl Persistence for SDCardPersistence {
async fn load_day(&mut self, day: Day) -> Option<AttendanceDay> { type Error = PersistenceError;
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap();
let mut root_dir = vol_0.open_root_dir().unwrap();
let filename = Self::generate_filename(day); 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); let file = root_dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadOnly);
if file.is_err() { if file.is_err() {
return None; return Err(PersistenceError::NotFound);
} }
let mut open_file = file.unwrap(); let mut open_file = file?;
let mut read_buffer: [u8; 1024] = [0; 1024]; let mut read_buffer: [u8; 1024] = [0; 1024];
let read = open_file.read(&mut read_buffer).unwrap(); let read = open_file.read(&mut read_buffer)?;
open_file.close().unwrap(); open_file.close()?;
let day: AttendanceDay = serde_json::from_slice(&read_buffer[..read]).unwrap(); let day: AttendanceDay = serde_json::from_slice(&read_buffer[..read])?;
Some(day) Ok(day)
} }
async fn save_day(&mut self, day: Day, data: &AttendanceDay) { async fn save_day(&mut self, day: Day, data: &AttendanceDay) -> Result<(), Self::Error> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap(); let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
let mut root_dir = vol_0.open_root_dir().unwrap(); let mut root_dir = vol_0.open_root_dir()?;
let filename = Self::generate_filename(day); let filename = Self::generate_filename_for_day(day)?;
let mut file = root_dir let mut file =
.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadWriteCreateOrTruncate) root_dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)?;
.unwrap();
file.write(&serde_json::to_vec(data).unwrap()).unwrap(); file.write(&serde_json::to_vec(data)?)?;
file.flush().unwrap(); file.flush()?;
file.close().unwrap(); file.close()?;
Ok(())
} }
async fn load_mapping(&mut self) -> Option<crate::store::IDMapping> { async fn list_days(&mut self) -> Result<Vec<Day>, Self::Error> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap(); let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
let mut root_dir = vol_0.open_root_dir().unwrap(); let mut root_dir = vol_0.open_root_dir()?;
let file = let mut days_dir = root_dir.open_dir(".")?;
root_dir.open_file_in_dir(Self::MAPPING_FILENAME, embedded_sdmmc::Mode::ReadOnly);
if file.is_err() {
return None;
}
let mut open_file = file.unwrap();
let mut read_buffer: [u8; 1024] = [0; 1024];
let read = open_file.read(&mut read_buffer).unwrap();
open_file.close().unwrap();
let mapping: IDMapping = serde_json::from_slice(&read_buffer[..read]).unwrap();
Some(mapping)
}
async fn save_mapping(&mut self, data: &crate::store::IDMapping) {
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(
Self::MAPPING_FILENAME,
embedded_sdmmc::Mode::ReadWriteCreateOrTruncate,
)
.unwrap();
file.write(&serde_json::to_vec(data).unwrap()).unwrap();
file.flush().unwrap();
file.close().unwrap();
}
async fn list_days(&mut self) -> Vec<Day> {
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(".").unwrap();
let mut days: Vec<Day> = Vec::new(); let mut days: Vec<Day> = Vec::new();
days_dir days_dir.iterate_dir(|e| {
.iterate_dir(|e| { let filename = e.name.clone();
let filename = e.name.clone();
if let Ok(day) = filename.try_into() { if let Ok(day) = filename.try_into() {
days.push(day); 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);
} }
}) }
.unwrap(); }
days Ok(ids)
} }
} }

View File

@@ -2,9 +2,13 @@ use embassy_executor::Spawner;
use embassy_time::{Duration, Timer}; use embassy_time::{Duration, Timer};
use esp_hal::gpio::{Output, OutputConfig}; use esp_hal::gpio::{Output, OutputConfig};
use esp_hal::peripherals::{GPIO3, GPIO14, WIFI}; use esp_hal::peripherals::{GPIO3, GPIO14, WIFI};
use esp_wifi::wifi::{AccessPointConfiguration, Configuration, WifiController, WifiEvent, WifiState}; use esp_radio::Controller;
use esp_wifi::{EspWifiRngSource, EspWifiTimerSource, wifi::Interfaces}; use esp_radio::wifi::{
use static_cell::make_static; 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>) { 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()); 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(); antenna_mode.set_low();
} }
pub fn setup_wifi<'d: 'static>( #[derive(Error, Debug)]
timer: impl EspWifiTimerSource + 'd, pub enum WifiError {
rng: impl EspWifiRngSource + 'd, #[error("Failed to init radio")]
wifi: WIFI<'static>, Init(#[from] esp_radio::InitializationError),
spawner: &mut Spawner,
) -> Interfaces<'d> {
let esp_wifi_ctrl = make_static!(esp_wifi::init(timer, rng).unwrap());
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)); #[error("Failed to spawn wifi task")]
Spawn(#[from] embassy_executor::SpawnError),
interfaces
} }
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] #[embassy_executor::task]
async fn connection(mut controller: WifiController<'static>) { async fn connection(mut controller: WifiController<'static>) {
debug!("start connection task");
debug!("Device capabilities: {:?}", controller.capabilities());
loop { loop {
match esp_wifi::wifi::wifi_state() { if esp_radio::wifi::ap_state() == WifiApState::Started {
WifiState::ApStarted => { // wait until we're no longer connected
// wait until we're no longer connected controller.wait_for_event(WifiEvent::ApStop).await;
controller.wait_for_event(WifiEvent::ApStop).await; Timer::after(Duration::from_millis(5000)).await
Timer::after(Duration::from_millis(5000)).await
}
_ => {}
} }
if !matches!(controller.is_started(), Ok(true)) { if !matches!(controller.is_started(), Ok(true)) {
let client_config = Configuration::AccessPoint(AccessPointConfiguration { let client_config = ModeConfig::AccessPoint(
ssid: "esp-wifi".try_into().unwrap(), AccessPointConfig::default()
..Default::default() .with_ssid(env!("WIFI_SSID").try_into().unwrap())
}); .with_password(env!("WIFI_PASSWD").try_into().unwrap())
controller.set_configuration(&client_config).unwrap(); .with_auth_method(esp_radio::wifi::AuthMethod::Wpa2Personal),
);
controller.set_config(&client_config).unwrap();
debug!("Starting wifi");
controller.start_async().await.unwrap(); controller.start_async().await.unwrap();
debug!("Wifi started!");
} }
} }
} }

View File

@@ -2,6 +2,7 @@
#![no_main] #![no_main]
#![feature(type_alias_impl_trait)] #![feature(type_alias_impl_trait)]
#![feature(impl_trait_in_assoc_type)] #![feature(impl_trait_in_assoc_type)]
#![warn(clippy::unwrap_used)]
use alloc::rc::Rc; use alloc::rc::Rc;
use embassy_executor::Spawner; use embassy_executor::Spawner;
@@ -16,16 +17,16 @@ use embassy_sync::{
signal::Signal, signal::Signal,
}; };
use embassy_time::{Duration, Timer}; use embassy_time::{Duration, Timer};
use esp_hal::gpio::Input; use esp_hal::gpio::InputConfig;
use esp_hal::{gpio::InputConfig, peripherals}; use esp_hal::gpio::{AnyPin, Input};
use log::{debug, info}; use log::{debug, info};
use static_cell::make_static; use static_cell::StaticCell;
extern crate alloc; extern crate alloc;
use crate::{ use crate::{
init::sd_card::SDCardPersistence, init::{hardware::AppHardware, sd_card::SDCardPersistence},
store::{IDStore, day::Day, tally_id::TallyID}, store::{IDStore, day::Day, mapping_loader::MappingLoader, tally_id::TallyID},
webserver::start_webserver, 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 TallySubscriber = Subscriber<'static, NoopRawMutex, TallyID, 8, 2, 1>;
type UsedStore = IDStore<SDCardPersistence>; type UsedStore = IDStore<SDCardPersistence>;
#[esp_hal_embassy::main] static CHAN: StaticCell<TallyChannel> = StaticCell::new();
async fn main(mut spawner: Spawner) {
let (uart_device, stack, i2c, led, buzzer_gpio, sd_det_gpio, persistence_layer) = #[esp_rtos::main]
init::hardware::hardware_init(&mut spawner).await; async fn main(spawner: Spawner) -> ! {
let app_hardware = AppHardware::init(spawner).await.unwrap();
info!("Starting up..."); 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 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 publisher: TallyPublisher = chan.publisher().unwrap();
let mut sub: TallySubscriber = chan.subscriber().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 ***********************************/ /****************************** Spawning tasks ***********************************/
debug!("spawing NFC reader task..."); debug!("spawing NFC reader task...");
spawner.must_spawn(drivers::nfc_reader::rfid_reader_task( spawner.must_spawn(drivers::nfc_reader::rfid_reader_task(
uart_device, app_hardware.uart,
publisher, publisher,
)); ));
debug!("spawing feedback task.."); 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"); 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"); debug!("everything spawned");
@@ -98,7 +116,7 @@ async fn main(mut spawner: Spawner) {
} }
#[embassy_executor::task] #[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()); let mut sd_det = Input::new(sd_det_gpio, InputConfig::default());
sd_det.wait_for(esp_hal::gpio::Event::AnyEdge).await; sd_det.wait_for(esp_hal::gpio::Event::AnyEdge).await;

View File

@@ -1,33 +0,0 @@
use alloc::collections::BTreeMap;
use alloc::string::String;
use serde::{Deserialize, Serialize};
use crate::store::tally_id::TallyID;
#[derive(Clone, Serialize, Deserialize)]
pub struct Name {
pub first: String,
pub last: String,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct IDMapping {
#[serde(flatten)]
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);
}
}

View File

@@ -1,8 +1,10 @@
use alloc::rc::Rc;
use alloc::vec::Vec; use alloc::vec::Vec;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
use embassy_sync::mutex::Mutex;
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use super::IDMapping;
use crate::store::day::Day; use crate::store::day::Day;
use crate::store::persistence::Persistence; use crate::store::persistence::Persistence;
use crate::store::tally_id::TallyID; use crate::store::tally_id::TallyID;
@@ -34,42 +36,36 @@ impl AttendanceDay {
#[derive(Clone)] #[derive(Clone)]
pub struct IDStore<T: Persistence> { pub struct IDStore<T: Persistence> {
pub current_day: AttendanceDay, current_day: AttendanceDay,
pub mapping: IDMapping, persistence_layer: Rc<Mutex<CriticalSectionRawMutex, T>>,
persistence_layer: T,
} }
impl<T: Persistence> IDStore<T> { impl<T: Persistence> IDStore<T> {
pub async fn new_from_storage(mut persistence_layer: T) -> Self { pub async fn new_from_storage(
let mapping = match persistence_layer.load_mapping().await { persistence_layer: Rc<Mutex<CriticalSectionRawMutex, T>>,
Some(map) => map, current_date: Day,
None => IDMapping::new(), ) -> Self {
};
let current_date: Day = Day::new(1);
let day = persistence_layer let day = persistence_layer
.lock()
.await
.load_day(current_date) .load_day(current_date)
.await .await
.unwrap_or(AttendanceDay::new(current_date)); .unwrap_or(AttendanceDay::new(current_date));
Self { Self {
current_day: day, current_day: day,
mapping,
persistence_layer, persistence_layer,
} }
} }
async fn persist_day(&mut self) { async fn persist_day(&mut self) -> Result<(), T::Error> {
self.persistence_layer self.persistence_layer
.lock()
.await
.save_day(self.current_day.date, &self.current_day) .save_day(self.current_day.date, &self.current_day)
.await .await
} }
async fn persist_mapping(&mut self) {
self.persistence_layer.save_mapping(&self.mapping).await
}
/// Add a new id for the current day /// Add a new id for the current day
/// Returns false if ID is already present at 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: Day) -> bool { pub async fn add_id(&mut self, id: TallyID, current_date: Day) -> bool {
@@ -92,21 +88,25 @@ impl<T: Persistence> IDStore<T> {
} }
/// Load and return a AttendanceDay. Nothing more. Nothing less. /// Load and return a AttendanceDay. Nothing more. Nothing less.
pub async fn load_day(&mut self, day: Day) -> Option<AttendanceDay> { pub async fn load_day(&mut self, day: Day) -> Result<AttendanceDay, T::Error> {
if day == self.current_day.date { if day == self.current_day.date {
return Some(self.current_day.clone()); return Ok(self.current_day.clone());
} }
self.persistence_layer.load_day(day).await self.persistence_layer.lock().await.load_day(day).await
} }
pub async fn list_days_in_timespan(&mut self, from: Day, to: Day) -> Vec<Day> { pub async fn list_days_in_timespan(
let all_days = self.persistence_layer.list_days().await; &mut self,
from: Day,
to: Day,
) -> Result<Vec<Day>, T::Error> {
let all_days = self.persistence_layer.lock().await.list_days().await?;
all_days Ok(all_days
.into_iter() .into_iter()
.filter(|e| *e >= from) .filter(|e| *e >= from)
.filter(|e| *e <= to) .filter(|e| *e <= to)
.collect() .collect())
} }
} }

View 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
}
}

View File

@@ -1,9 +1,7 @@
pub use id_mapping::{IDMapping, Name};
pub use id_store::{IDStore,AttendanceDay}; pub use id_store::{IDStore,AttendanceDay};
mod id_mapping;
pub mod persistence; pub mod persistence;
mod id_store; mod id_store;
pub mod tally_id; pub mod tally_id;
pub mod day; pub mod day;
pub mod mapping_loader;

View File

@@ -1,12 +1,15 @@
use alloc::vec::Vec; use alloc::vec::Vec;
use crate::store::{IDMapping, day::Day, id_store::AttendanceDay}; use crate::store::{day::Day, id_store::AttendanceDay, mapping_loader::Name, tally_id::TallyID};
pub trait Persistence { pub trait Persistence {
async fn load_day(&mut self, day: Day) -> Option<AttendanceDay>; type Error: core::error::Error;
async fn save_day(&mut self, day: Day, data: &AttendanceDay);
async fn list_days(&mut self) -> Vec<Day>;
async fn load_mapping(&mut self) -> Option<IDMapping>; async fn load_day(&mut self, day: Day) -> Result<AttendanceDay, Self::Error>;
async fn save_mapping(&mut self, data: &IDMapping); 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>;
} }

View File

@@ -1,3 +1,4 @@
use alloc::string::String;
use core::{fmt::Display, str::FromStr}; use core::{fmt::Display, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
@@ -102,7 +103,7 @@ impl<'de> Deserialize<'de> for TallyID {
where where
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
let s = <&str>::deserialize(deserializer)?; let s = <String>::deserialize(deserializer)?;
TallyID::from_str(s).map_err(|_| de::Error::custom("Failed to parse Tally ID")) TallyID::from_str(&s).map_err(|_| de::Error::custom("Failed to parse Tally ID"))
} }
} }

View File

@@ -1,4 +1,4 @@
use esp_println::dbg; use log::error;
use picoserve::{ use picoserve::{
extract::{Json, Query, State}, extract::{Json, Query, State},
response::{self, IntoResponse}, response::{self, IntoResponse},
@@ -6,7 +6,7 @@ use picoserve::{
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
store::{self, Name, day::Day, tally_id::TallyID}, store::{day::Day, mapping_loader::Name, tally_id::TallyID},
webserver::{app::AppState, sse::IDEvents}, webserver::{app::AppState, sse::IDEvents},
}; };
@@ -28,10 +28,40 @@ pub struct QueryDay {
day: Option<u32>, 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 // GET /api/mapping
pub async fn get_mapping(State(state): State<AppState>) -> impl IntoResponse { pub async fn get_mapping(
let store = state.store.lock().await; State(state): State<AppState>,
response::Json(store.mapping.clone()) 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 // POST /api/mapping
@@ -39,13 +69,30 @@ pub async fn add_mapping(
State(state): State<AppState>, State(state): State<AppState>,
Json(data): Json<NewMapping>, Json(data): Json<NewMapping>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let mut store = state.store.lock().await; let loader = state.mapping_loader.lock().await;
store.mapping.add_mapping(data.id, data.name); match loader.set_mapping(data.id, data.name).await {
Ok(_) => (response::StatusCode::CREATED, ""),
Err(_) => (
response::StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_SERVER_ERROR",
),
}
} }
// SSE /api/idevent // SSE /api/idevent
pub async fn get_idevent(State(state): State<AppState>) -> impl IntoResponse { pub async fn get_idevent(
response::EventStream(IDEvents(state.chan.subscriber().unwrap())) 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 // GET /api/days
@@ -58,9 +105,13 @@ pub async fn get_days(
let mut store = state.store.lock().await; let mut store = state.store.lock().await;
let days = store.list_days_in_timespan(from_day, to_day).await; match store.list_days_in_timespan(from_day, to_day).await {
Ok(days) => Ok(response::Json(days)),
response::Json(days) Err(_) => Err((
response::StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error",
)),
}
} }
// GET /api/day // GET /api/day
@@ -68,7 +119,6 @@ pub async fn get_day(
State(state): State<AppState>, State(state): State<AppState>,
Query(QueryDay { timestamp, day }): Query<QueryDay>, Query(QueryDay { timestamp, day }): Query<QueryDay>,
) -> Result<impl IntoResponse, impl IntoResponse> { ) -> Result<impl IntoResponse, impl IntoResponse> {
let parsed_day = timestamp let parsed_day = timestamp
.map(Day::new_from_timestamp) .map(Day::new_from_timestamp)
.or_else(|| day.map(Day::new)) .or_else(|| day.map(Day::new))
@@ -77,7 +127,10 @@ pub async fn get_day(
let mut store = state.store.lock().await; let mut store = state.store.lock().await;
match store.load_day(parsed_day).await { match store.load_day(parsed_day).await {
Some(att_day) => Ok(response::Json(att_day)), Ok(att_day) => Ok(response::Json(att_day)),
None => Err((response::StatusCode::NOT_FOUND, "Not found")), Err(_) => Err((
response::StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error",
)),
} }
} }

View File

@@ -4,8 +4,10 @@ use picoserve::{AppWithStateBuilder, routing::get};
use crate::{ use crate::{
TallyChannel, UsedStore, TallyChannel, UsedStore,
init::sd_card::SDCardPersistence,
store::mapping_loader::MappingLoader,
webserver::{ webserver::{
api::{add_mapping, get_day, get_days, get_idevent, get_mapping}, api::{add_mapping, get_day, get_days, get_idevent, get_mapping, get_mappings},
assets::Assets, assets::Assets,
}, },
}; };
@@ -14,6 +16,7 @@ use crate::{
pub struct AppState { pub struct AppState {
pub store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>, pub store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
pub chan: &'static TallyChannel, pub chan: &'static TallyChannel,
pub mapping_loader: Rc<Mutex<CriticalSectionRawMutex, MappingLoader<SDCardPersistence>>>,
} }
pub struct AppProps; pub struct AppProps;
@@ -25,6 +28,7 @@ impl AppWithStateBuilder for AppProps {
fn build_app(self) -> picoserve::Router<Self::PathRouter, AppState> { fn build_app(self) -> picoserve::Router<Self::PathRouter, AppState> {
picoserve::Router::from_service(Assets) picoserve::Router::from_service(Assets)
.route("/api/mapping", get(get_mapping).post(add_mapping)) .route("/api/mapping", get(get_mapping).post(add_mapping))
.route("/api/mappings", get(get_mappings))
.route("/api/idevent", get(get_idevent)) .route("/api/idevent", get(get_idevent))
.route("/api/days", get(get_days)) .route("/api/days", get(get_days))
.route("/api/day", get(get_day)) .route("/api/day", get(get_day))

View File

@@ -38,7 +38,7 @@ impl<State, CurrentPathParameters>
); );
response_writer response_writer
.write_response(request.body_connection.finalize().await.unwrap(), response) .write_response(request.body_connection.finalize().await?, response)
.await .await
} }
None => { None => {
@@ -68,10 +68,7 @@ impl Content for StaticAsset {
self.0.len() self.0.len()
} }
async fn write_content<W: embedded_io_async::Write>( async fn write_content<W: edge_nal::io::Write>(self, mut writer: W) -> Result<(), W::Error> {
self,
mut writer: W,
) -> Result<(), W::Error> {
writer.write_all(self.0).await writer.write_all(self.0).await
} }
} }

View File

@@ -8,6 +8,8 @@ use static_cell::make_static;
use crate::{ use crate::{
TallyChannel, UsedStore, TallyChannel, UsedStore,
init::sd_card::SDCardPersistence,
store::mapping_loader::{self, MappingLoader},
webserver::app::{AppProps, AppState}, webserver::app::{AppProps, AppState},
}; };
@@ -16,17 +18,23 @@ mod app;
mod assets; mod assets;
mod sse; 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( pub fn start_webserver(
spawner: &mut Spawner, spawner: Spawner,
stack: Stack<'static>, stack: Stack<'static>,
store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>, store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
chan: &'static TallyChannel, chan: &'static TallyChannel,
mapping_loader: MappingLoader<SDCardPersistence>,
) { ) {
let app = make_static!(AppProps.build_app()); 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 { let config = make_static!(picoserve::Config::new(picoserve::Timeouts {
start_read_request: Some(Duration::from_secs(5)), start_read_request: Some(Duration::from_secs(5)),

108
web/mock/data.json Normal file
View 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"
]
}
]
}

View File

@@ -1,20 +1,14 @@
import express from "express"; import express from "express";
import bodyParser from "body-parser"; import bodyParser from "body-parser";
import mockData from "./data.json" with {type: "json"};
const app = express(); const app = express();
const port = 3000; const port = 3000;
app.use(bodyParser.json()); const SECS_IN_DAY = 86_400;
let mappings = [ app.use(bodyParser.json());
[
"123456789ABC",
{
first: "Feuerwehrman",
last: "Sam",
},
],
];
function generateRandomId() { function generateRandomId() {
const chars = "ABCDEF0123456789"; const chars = "ABCDEF0123456789";
@@ -27,7 +21,7 @@ function generateRandomId() {
// GET /api/mapping // GET /api/mapping
app.get("/api/mapping", (req, res) => { app.get("/api/mapping", (req, res) => {
res.json(mappings); res.json(mockData.mapping);
}); });
// POST /api/mapping // POST /api/mapping
@@ -45,11 +39,45 @@ app.post("/api/mapping", (req, res) => {
} }
// Add new mapping // Add new mapping
mappings.push([id, name]); mockData.mappings.push([id, name]);
res.status(201).send(""); 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 // SSE route: /api/idevent
app.get("/api/idevent", (req, res) => { app.get("/api/idevent", (req, res) => {

14
web/package-lock.json generated
View File

@@ -19,7 +19,7 @@
"svelte": "^5.28.1", "svelte": "^5.28.1",
"svelte-check": "^4.1.6", "svelte-check": "^4.1.6",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.3.5" "vite": "^6.4.1"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@@ -771,6 +771,7 @@
"integrity": "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==", "integrity": "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1",
"debug": "^4.4.0", "debug": "^4.4.0",
@@ -1100,6 +1101,7 @@
"integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2211,6 +2213,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -2577,6 +2580,7 @@
"integrity": "sha512-QIYtKnJGkubWXtNkrUBKVCvyo9gjcccdbnvXfwsGNhvbeNNdQjRDTa/BiQcJ2kWXbXPQbWKyT7CUu53KIj1rfw==", "integrity": "sha512-QIYtKnJGkubWXtNkrUBKVCvyo9gjcccdbnvXfwsGNhvbeNNdQjRDTa/BiQcJ2kWXbXPQbWKyT7CUu53KIj1rfw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -2700,6 +2704,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2729,10 +2734,11 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "6.3.5", "version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.4.4", "fdir": "^6.4.4",

View File

@@ -15,7 +15,7 @@
"svelte": "^5.28.1", "svelte": "^5.28.1",
"svelte-check": "^4.1.6", "svelte-check": "^4.1.6",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.3.5", "vite": "^6.4.1",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"express": "^5.1.0" "express": "^5.1.0"
}, },

View File

@@ -3,18 +3,33 @@
import IDTable from "./lib/IDTable.svelte"; import IDTable from "./lib/IDTable.svelte";
import LastId from "./lib/LastID.svelte"; import LastId from "./lib/LastID.svelte";
import AddIDModal from "./lib/AddIDModal.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 lastID: string = $state("");
let mapping: IDMap | null = $state(null);
let addModal: AddIDModal; 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"); let sse = new EventSource("/api/idevent");
sse.addEventListener("msg", function (e) {
sse.onmessage = (e) => {
lastID = e.data; lastID = e.data;
}; });
}); });
</script> </script>
@@ -25,13 +40,14 @@
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Anwesenheit</h1> <h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Anwesenheit</h1>
</div> </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" 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" onclick={() => {
download="anwesenheit.csv" exportModal.open();
}}
> >
Download CSV Export CSV
</a> </button>
<div class="pt-3 pb-2"> <div class="pt-3 pb-2">
<LastId <LastId
@@ -42,15 +58,42 @@
/> />
</div> </div>
<div> <div>
<IDTable bind:this={idTable} onEdit={(id,firstName,lastName)=>{ {#if mapping}
addModal.open(id,firstName,lastName); <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> </div>
<AddIDModal <AddIDModal
bind:this={addModal} bind:this={addModal}
onSubmitted={() => { onSubmitted={async (id, firstName, lastname) => {
idTable.reloadData(); 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> </main>

View File

@@ -1,7 +1,11 @@
<script lang="ts"> <script lang="ts">
import Modal from "./Modal.svelte"; 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 displayID = $state("");
let firstName = $state(""); let firstName = $state("");
@@ -9,7 +13,11 @@
let modal: Modal; let modal: Modal;
export function open(presetID: string, presetFirstName?: string, presetLastName?: string) { export function open(
presetID: string,
presetFirstName?: string,
presetLastName?: string,
) {
displayID = presetID; displayID = presetID;
firstName = presetFirstName ?? ""; firstName = presetFirstName ?? "";
@@ -33,13 +41,14 @@
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(data), body: JSON.stringify(data),
}).then(() => { }).then((res) => {
onSubmitted?.(); if (res.status == 201) {
onSubmitted?.(displayID, firstName, lastName);
}
firstName = "";
lastName = "";
displayID = "";
}); });
firstName = "";
lastName = "";
displayID = "";
} }
</script> </script>

31
web/src/lib/Day.ts Normal file
View 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;
}

View 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>

View File

@@ -7,21 +7,55 @@ export interface Name {
last: string, last: string,
} }
function stupidSerdeFix(pairs: [string, Name][]): IDMap { async function fetchIDList(): Promise<string[]> {
const map: IDMap = {}; let res = await fetch("/api/mappings");
for (const [key, value] of pairs) { let data = await res.json();
map[key] = value; 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; return map;
} }
export async function fetchMapping(): Promise<IDMap> { const CACHE_KEY = "idmap";
let res = await fetch("/api/mapping");
let data = await res.json(); export function cacheMappingInLocalstore(mapping: IDMap) {
if (!localStorage) {
console.error("localStorage is not available");
return;
}
return stupidSerdeFix(data); 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) { export async function addMapping(id: string, firstName: string, lastName: string) {

View File

@@ -1,16 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { type IDMap } from "./IDMapping";
import { fetchMapping, type IDMap } from "./IDMapping";
let data: IDMap | undefined = $state();
let { let {
onEdit, onEdit,
}: { onEdit?: (id: string, firstName: string, lastName: string) => void } = data,
$props(); }: {
onEdit?: (id: string, firstName: string, lastName: string) => void;
export async function reloadData() { data: IDMap;
data = await fetchMapping(); } = $props();
}
let rows = $derived( let rows = $derived(
data data
@@ -44,69 +41,61 @@
if (sortKey !== key) return ""; if (sortKey !== key) return "";
return sortDirection === "asc" ? "▲" : "▼"; return sortDirection === "asc" ? "▲" : "▼";
} }
onMount(async () => {
await reloadData();
});
</script> </script>
{#if data == null} <div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto">
Loading... <table class="px-10">
{:else} <thead>
<div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto"> <tr>
<table class="px-10"> <th
<thead> class="text-left pr-5 pl-2 cursor-pointer select-none"
<tr> onclick={() => {
<th handleSortClick("id");
class="text-left pr-5 pl-2 cursor-pointer select-none" }}
onclick={() => { >
handleSortClick("id"); ID
}} <span class="indicator">{indicator("id")}</span>
> </th>
ID <th
<span class="indicator">{indicator("id")}</span> class="text-left pr-5 cursor-pointer select-none"
</th> onclick={() => {
<th handleSortClick("last");
class="text-left pr-5 cursor-pointer select-none" }}
onclick={() => { >
handleSortClick("last"); Nachname
}} <span class="indicator">{indicator("last")}</span>
> </th>
Nachname <th
<span class="indicator">{indicator("last")}</span> class="text-left pr-5 cursor-pointer select-none"
</th> onclick={() => {
<th handleSortClick("first");
class="text-left pr-5 cursor-pointer select-none" }}
onclick={() => { >Vorname
handleSortClick("first");
}}
>Vorname
<span class="indicator">{indicator("first")}</span> <span class="indicator">{indicator("first")}</span>
</th> </th>
<th> </th> <th> </th>
</tr>
</thead>
<tbody>
{#each rowsSorted as row}
<tr class="even:bg-indigo-600">
<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
>
</tr> </tr>
</thead> {/each}
<tbody> </tbody>
{#each rowsSorted as row} </table>
<tr class="even:bg-indigo-600"> </div>
<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
>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<style lang="css" scoped> <style lang="css" scoped>
@reference "../app.css"; @reference "../app.css";

103
web/src/lib/csv.ts Normal file
View 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);
}

View 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
View 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;
}