Compare commits

...

49 Commits

Author SHA1 Message Date
Philipp_EndevourOS
cab2533fab deleted some imports 2025-10-27 15:19:51 +01:00
Philipp_EndevourOS
5f65cc7a73 Merge remote-tracking branch 'origin/main' into feature/newlib 2025-10-27 15:11:04 +01:00
Philipp_EndevourOS
03e6a9036f implemented LED for new lib !esp-hhal-smartled is not released! 2025-10-27 15:08:22 +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
bd3f6731fd renamed vars in main 2025-10-15 16:03:36 +02:00
6fdcf7679f Revert "updated panic handler"
This reverts commit c4d6ed45f1.
2025-10-15 15:50:26 +02:00
Philipp_EndevourOS
c4d6ed45f1 updated panic handler 2025-10-14 14:35:25 +02:00
Philipp_EndevourOS
41adf7353d delete old FRAM driver 2025-10-14 14:17:40 +02:00
6421074931 added routes for listing days & getting days
/api/days?from=...&to=...
/api/day?day=...
2025-10-13 16:32:28 +02:00
a34dc18381 implemented load_day & list_day_in_timespan in IDStore 2025-10-13 16:31:15 +02:00
252e63c607 fixed RFID reader outputting un-aligned IDs in buffer 2025-10-11 14:48:01 +02:00
99848f0e6d added TryFrom<[u8;12]> to TallyID 2025-10-11 14:47:29 +02:00
f46cdc4d29 implemented Display for TallyID 2025-10-11 14:31:26 +02:00
a8d64f6af5 fixed list_day for sd_card 2025-10-11 14:16:28 +02:00
8fb6bac651 added Ord to Day 2025-10-11 14:16:12 +02:00
7eb18376e1 fixed changed /api/mapping structure in frontend 2025-10-10 16:09:53 +02:00
b8bba28bda added mock server for frontedn dev 2025-10-10 16:09:17 +02:00
5c0ad18b94 re-enabled mapping loading 2025-10-10 02:02:00 +02:00
75130e2d20 implemented missing load & save mapping functions 2025-10-10 01:58:25 +02:00
6b2c56f3e5 added Deserialize to IDMapping 2025-10-10 01:57:48 +02:00
2980d34394 flatten IDMapping for serde 2025-10-10 01:50:04 +02:00
9b926f7a34 propper mapping of day to filename 2025-10-10 01:49:32 +02:00
f1b471c6d8 changed Day implementation 2025-10-10 01:12:46 +02:00
030a372949 updated cargo lock 2025-10-09 01:47:34 +02:00
211961a770 fixed RFID read id parser 2025-10-08 16:21:16 +02:00
dfe5197ab8 changed TallyID to a struct instead of a type alias 2025-10-08 16:14:10 +02:00
0f5ca88ae4 fixed many warning by removing unused imports
Removed a lot of imports — believe me, so many imports were totally
unnecessary. Nobody’s seen imports like these. Cleaned up the code, made
it faster, smarter, the best code. People are talking about it!
Tremendous work by me. Some say i am the best at it. It may be true.
2025-10-08 01:54:51 +02:00
9dd2f88cbc implemented SSE 2025-10-08 01:44:32 +02:00
aa91d69f0b explicit type of tallyid channels 2025-10-08 01:43:48 +02:00
b13ae76bc5 moved TallyID str function to right module 2025-10-08 01:42:25 +02:00
36 changed files with 2653 additions and 938 deletions

View File

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

941
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,64 +12,44 @@ 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",
] }
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" }
static_cell = { version = "2.1.1", features = ["nightly"] }
heapless = { version = "0.8.0", default-features = false }
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"
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"] }
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", rev = "ab4316534d90e3a12785907f043f6899faee0f20", package = "esp-hal-smartled", features = ["esp32c6"]}
smart-leds = "0.4.0"
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"] }
[profile.dev]
# Rust debug is too slow.

View File

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

View File

@@ -1,8 +1,8 @@
use embassy_time::{Duration, Timer};
use esp_hal::{Async, uart::Uart};
use log::{debug, info};
use log::{debug, info, warn};
use crate::{TallyPublisher, store::TallyID};
use crate::TallyPublisher;
#[embassy_executor::task]
pub async fn rfid_reader_task(mut uart_device: Uart<'static, Async>, chan: 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
}

View File

@@ -1,3 +1,4 @@
use chrono::{TimeZone, Utc};
use ds3231::{
Config, DS3231, DS3231Error, InterruptControl, Oscillator, SquareWaveFrequency,
TimeRepresentation,
@@ -8,8 +9,7 @@ use esp_hal::{
};
use log::{debug, error, info};
use crate::{FEEDBACK_STATE, drivers, feedback, store::Date};
use chrono::{TimeZone, Utc};
use crate::{FEEDBACK_STATE, drivers, feedback};
include!(concat!(env!("OUT_DIR"), "/build_time.rs"));
@@ -45,60 +45,34 @@ 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;
// 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 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)
}
// 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)
}
// 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>> {
let mut rtc: DS3231<I2c<'static, Async>> = DS3231::new(i2c, RTC_ADDRESS);

View File

@@ -1,12 +1,13 @@
use embassy_time::{Delay, Duration, Timer};
use esp_hal::{delay, gpio::Output, peripherals, rmt::ConstChannelAccess};
use esp_hal_smartled::SmartLedsAdapterAsync;
use init::hardware;
use log::{debug, error, info};
use embassy_time::{Duration, Timer};
use esp_hal::rmt::Rmt;
use esp_hal::peripherals;
use esp_hal_smartled::{SmartLedsAdapterAsync, buffer_size_async};
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)]
@@ -25,14 +26,18 @@ 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>,
rmt: Rmt<'static, esp_hal::Async>,
led_gpio: peripherals::GPIO1<'static>,
buzzer_gpio: peripherals::GPIO21<'static>,
) {
debug!("Starting feedback task");
let mut buzzer = init::hardware::setup_buzzer(buzzer);
let rmt_channel = rmt.channel0;
let rmt_buffer = [esp_hal::rmt::PulseCode::default(); buffer_size_async(hardware::NUM_LEDS)];
let mut led = SmartLedsAdapterAsync::new(rmt_channel, led_gpio, rmt_buffer);
let mut buzzer = init::hardware::setup_buzzer(buzzer_gpio);
loop {
let feedback_state = FEEDBACK_STATE.wait().await;
match feedback_state {
@@ -47,6 +52,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(
@@ -119,7 +130,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
@@ -129,106 +140,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(())
}
*/

View File

@@ -1,22 +1,19 @@
use core::cell::RefCell;
use bleps::att::Att;
use critical_section::Mutex;
use ds3231::InterruptControl;
use embassy_executor::Spawner;
use embassy_net::Stack;
use embassy_time::{Duration, Timer};
use esp_hal::gpio::{Input, InputConfig};
use esp_hal::Blocking;
use esp_hal::delay::Delay;
use esp_hal::gpio::Input;
use esp_hal::i2c::master::Config;
use esp_hal::peripherals::{
GPIO0, 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::Blocking;
use esp_hal::system::software_reset;
use esp_hal::time::Rate;
use esp_hal::timer::timg::TimerGroup;
use esp_hal::{
@@ -28,16 +25,12 @@ use esp_hal::{
uart::Uart,
};
use esp_hal_smartled::{SmartLedsAdapterAsync, buffer_size_async};
use esp_println::dbg;
use esp_println::logger::init_logger;
use log::{debug, error, info};
use log::{debug, error};
use crate::FEEDBACK_STATE;
use crate::init::network;
use crate::init::sd_card::{setup_sdcard, SDCardPersistence};
use crate::init::sd_card::{SDCardPersistence, setup_sdcard};
use crate::init::wifi;
use crate::store::AttendanceDay;
use crate::store::persistence::Persistence;
/*************************************************
* GPIO Pinout Xiao Esp32c6
@@ -56,47 +49,50 @@ use crate::store::persistence::Persistence;
*
*************************************************/
pub const NUM_LEDS: usize = 66;
pub const LED_BUFFER_SIZE: usize = NUM_LEDS * 25;
pub const NUM_LEDS: usize = 1;
static SD_DET: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));
#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
loop {
error!("PANIC: {info}");
}
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,
spawner: Spawner,
) -> (
Uart<'static, Async>,
Stack<'static>,
I2c<'static, Async>,
SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE>,
Rmt<'static, esp_hal::Async>,
GPIO1<'static>,
GPIO21<'static>,
GPIO0<'static>,
SmartLedsAdapterAsync<'static, LED_BUFFER_SIZE>,
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);
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 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 interfaces = wifi::setup_wifi(peripherals.WIFI, spawner);
let stack = network::setup_network(network_seed, interfaces.ap, spawner);
Timer::after(Duration::from_millis(1)).await;
@@ -107,6 +103,10 @@ pub async fn hardware_init(
let sd_det_gpio = peripherals.GPIO0;
let rmt: Rmt<'_, esp_hal::Async> = {let frequency: Rate = Rate::from_mhz(80);
Rmt::new(peripherals.RMT, frequency)} .expect("Failed to initialize RMT")
.into_async();
let spi_bus = setup_spi(
peripherals.SPI2,
peripherals.GPIO19,
@@ -122,21 +122,24 @@ pub async fn hardware_init(
let vol_mgr = setup_sdcard(spi_bus, sd_cs_pin);
let led_gpio = peripherals.GPIO1;
let buzzer_gpio = peripherals.GPIO21;
Timer::after(Duration::from_millis(500)).await;
let led = setup_led(peripherals.RMT, peripherals.GPIO1);
Timer::after(Duration::from_millis(500)).await;
debug!("hardware init done");
(
uart_device,
stack,
i2c_device,
led,
rmt,
led_gpio,
buzzer_gpio,
sd_det_gpio,
led,
vol_mgr,
)
}
@@ -195,11 +198,10 @@ pub fn setup_buzzer(buzzer_gpio: GPIO21<'static>) -> Output<'static> {
buzzer
}
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>,
) -> esp_hal_smartled::SmartLedsAdapterAsync<'a, LED_BUFFER_SIZE> {
let rmt: Rmt<'_, esp_hal::Async> = {
let frequency: Rate = Rate::from_mhz(80);
Rmt::new(rmt, frequency)
@@ -208,10 +210,7 @@ fn setup_led(
.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
SmartLedsAdapterAsync::new(rmt_channel, led_gpio, rmt_buffer)
}

View File

@@ -1,17 +1,16 @@
use core::{net::Ipv4Addr, str::FromStr};
use embassy_executor::Spawner;
use embassy_net::{Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
use embassy_time::{Duration, Timer};
use esp_wifi::wifi::WifiDevice;
use esp_radio::wifi::WifiDevice;
use static_cell::make_static;
use crate::webserver::WEB_TAKS_SIZE;
pub const NETWORK_STACK_SIZE: usize = WEB_TAKS_SIZE + 2; // + 2 for other network taks. Breaks
// without
// without
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 {
@@ -20,12 +19,10 @@ pub fn setup_network<'a>(seed: u64, wifi: WifiDevice<'static>, spawner: &mut Spa
dns_servers: Default::default(),
});
let (stack, runner) = embassy_net::new(
wifi,
config,
make_static!(StackResources::<NETWORK_STACK_SIZE>::new()),
seed,
);
let nw_stack: &'static mut StackResources<NETWORK_STACK_SIZE> =
make_static!(StackResources::<NETWORK_STACK_SIZE>::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));

View File

@@ -1,10 +1,10 @@
use alloc::vec::Vec;
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};
use embedded_sdmmc::{SdCard, ShortFileName, TimeSource, Timestamp, VolumeIdx, VolumeManager};
use esp_hal::{Blocking, gpio::Output, spi::master::Spi};
use crate::store::{AttendanceDay, Date, persistence::Persistence};
use crate::store::{AttendanceDay, IDMapping, day::Day, persistence::Persistence};
pub struct DummyTimesource;
@@ -38,15 +38,31 @@ pub struct SDCardPersistence {
vol_mgr: VolMgr,
}
impl SDCardPersistence {
const MAPPING_FILENAME: &'static str = "MAPPING.JS";
fn generate_filename(day: Day) -> ShortFileName {
let basename = day.to_string();
let mut filename: heapless::String<11> = heapless::String::new();
filename.push_str(&basename).unwrap();
filename.push_str(".js").unwrap();
ShortFileName::create_from_str(&filename).unwrap()
}
}
impl Persistence for SDCardPersistence {
async fn load_day(&mut self, day: crate::store::Date) -> Option<AttendanceDay> {
async fn load_day(&mut self, day: Day) -> 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);
if let Err(e) = file {
let filename = Self::generate_filename(day);
let file = root_dir.open_file_in_dir(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];
@@ -58,35 +74,75 @@ impl Persistence for SDCardPersistence {
Some(day)
}
async fn save_day(&mut self, day: Date, data: &AttendanceDay) {
async fn save_day(&mut self, day: Day, data: &AttendanceDay) {
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);
let mut file = root_dir
.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)
.unwrap();
file.write(&serde_json::to_vec(data).unwrap()).unwrap();
file.flush().unwrap();
file.close().unwrap();
}
async fn load_mapping(&mut self) -> Option<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 file =
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("day.jsn", embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)
.open_file_in_dir(
Self::MAPPING_FILENAME,
embedded_sdmmc::Mode::ReadWriteCreateOrTruncate,
)
.unwrap();
file.write(&serde_json::to_vec(data).unwrap()).unwrap();
file.flush();
file.close();
file.flush().unwrap();
file.close().unwrap();
}
async fn load_mapping(&mut self) -> Option<crate::store::IDMapping> {
todo!()
}
async fn save_mapping(&mut self, data: &crate::store::IDMapping) {
todo!()
}
async fn list_days(&mut self) -> Vec<Date> {
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("days").unwrap();
let mut days: Vec<[u8; 10]> = Vec::new();
let mut days_dir = root_dir.open_dir(".").unwrap();
let mut days: Vec<Day> = Vec::new();
days_dir
.iterate_dir(|e| {
days.push([0; 10]);
let filename = e.name.clone();
if let Ok(day) = filename.try_into() {
days.push(day);
}
})
.unwrap();

View File

@@ -2,9 +2,14 @@ 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;
static ESP_WIFI_CTRL: StaticCell<Controller<'static>> = StaticCell::new();
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,26 +23,23 @@ 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());
pub fn setup_wifi<'d: 'static>(wifi: WIFI<'static>, spawner: Spawner) -> Interfaces<'d> {
let esp_wifi_ctrl = ESP_WIFI_CTRL.init(esp_radio::init().unwrap());
let (controller, interfaces) = esp_wifi::wifi::new(esp_wifi_ctrl, wifi).unwrap();
let config = esp_radio::wifi::Config::default();
let (controller, interfaces) = esp_radio::wifi::new(esp_wifi_ctrl, wifi, config).unwrap();
spawner.must_spawn(connection(controller));
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 => {
match 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
@@ -45,12 +47,16 @@ async fn connection(mut controller: WifiController<'static>) {
_ => {}
}
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!");
}
}
}

View File

@@ -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;
@@ -10,7 +11,7 @@ use embassy_sync::{
blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex},
mutex::Mutex,
pubsub::{
PubSubChannel, Publisher,
PubSubChannel, Publisher, Subscriber,
WaitResult::{Lagged, Message},
},
signal::Signal,
@@ -19,16 +20,16 @@ use embassy_time::{Duration, Timer};
use esp_hal::gpio::Input;
use esp_hal::{gpio::InputConfig, peripherals};
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},
store::{IDStore, day::Day, tally_id::TallyID},
webserver::start_webserver,
};
extern crate alloc;
mod drivers;
mod feedback;
mod init;
@@ -39,27 +40,30 @@ static FEEDBACK_STATE: Signal<CriticalSectionRawMutex, feedback::FeedbackState>
type TallyChannel = PubSubChannel<NoopRawMutex, TallyID, 8, 2, 1>;
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 (uart_device, stack, i2c, rmt, led_gpio, buzzer_gpio, sd_det_gpio, persistence_layer) =
init::hardware::hardware_init(spawner).await;
info!("Starting up...");
let mut rtc = drivers::rtc::RTCClock::new(_i2c).await;
let mut rtc = drivers::rtc::RTCClock::new(i2c).await;
let store: UsedStore = IDStore::new_from_storage(persistence_layer).await;
let shared_store = Rc::new(Mutex::new(store));
let chan: &'static mut TallyChannel = make_static!(PubSubChannel::new());
let publisher = chan.publisher().unwrap();
let mut sub = chan.subscriber().unwrap();
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;
start_webserver(&mut spawner, stack, shared_store.clone());
start_webserver(spawner, stack, shared_store.clone(), chan);
/****************************** Spawning tasks ***********************************/
debug!("spawing NFC reader task...");
@@ -69,7 +73,7 @@ async fn main(mut spawner: Spawner) {
));
debug!("spawing feedback task..");
spawner.must_spawn(feedback::feedback_task(_led, buzzer_gpio));
spawner.must_spawn(feedback::feedback_task(rmt, led_gpio, buzzer_gpio));
debug!("spawn sd detect task");
spawner.must_spawn(sd_detect_task(sd_det_gpio));
@@ -85,7 +89,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 {

63
src/store/day.rs Normal file
View 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(|_| ())
}
}

View File

@@ -1,16 +1,18 @@
use super::TallyID;
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)]
#[derive(Clone, Serialize, Deserialize)]
pub struct IDMapping {
#[serde(flatten)]
id_map: BTreeMap<TallyID, Name>,
}

View File

@@ -1,22 +1,20 @@
use crate::drivers::rtc;
use crate::drivers::rtc::RTCClock;
use crate::store::persistence::Persistence;
use super::Date;
use super::IDMapping;
use super::TallyID;
use alloc::vec::Vec;
use serde::Deserialize;
use serde::Serialize;
use super::IDMapping;
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(),
@@ -43,12 +41,12 @@ pub struct IDStore<T: Persistence> {
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 mapping = match persistence_layer.load_mapping().await {
Some(map) => map,
None => IDMapping::new(),
};
let current_date: Date = [0; 10];
let current_date: Day = Day::new(1);
let day = persistence_layer
.load_day(current_date)
@@ -57,7 +55,7 @@ impl<T: Persistence> IDStore<T> {
Self {
current_day: day,
mapping: IDMapping::new(),
mapping,
persistence_layer,
}
}
@@ -68,14 +66,13 @@ impl<T: Persistence> IDStore<T> {
.await
}
async fn persist_mapping(&mut self) {
pub 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 {
@@ -93,4 +90,23 @@ impl<T: Persistence> IDStore<T> {
}
changed
}
/// Load and return a AttendanceDay. Nothing more. Nothing less.
pub async fn load_day(&mut self, day: Day) -> Option<AttendanceDay> {
if day == self.current_day.date {
return Some(self.current_day.clone());
}
self.persistence_layer.load_day(day).await
}
pub async fn list_days_in_timespan(&mut self, from: Day, to: Day) -> Vec<Day> {
let all_days = self.persistence_layer.list_days().await;
all_days
.into_iter()
.filter(|e| *e >= from)
.filter(|e| *e <= to)
.collect()
}
}

View File

@@ -1,9 +1,9 @@
mod id_mapping;
pub mod persistence;
mod id_store;
pub use id_mapping::{IDMapping, Name};
pub use id_store::{IDStore,AttendanceDay};
pub type TallyID = [u8; 6];
pub type Date = [u8; 10];
mod id_mapping;
pub mod persistence;
mod id_store;
pub mod tally_id;
pub mod day;

View File

@@ -1,11 +1,11 @@
use alloc::vec::Vec;
use crate::store::{Date, IDMapping, id_store::AttendanceDay};
use crate::store::{IDMapping, day::Day, id_store::AttendanceDay};
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>;
async fn load_day(&mut self, day: Day) -> Option<AttendanceDay>;
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 save_mapping(&mut self, data: &IDMapping);

108
src/store/tally_id.rs Normal file
View File

@@ -0,0 +1,108 @@
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 = <&str>::deserialize(deserializer)?;
TallyID::from_str(s).map_err(|_| de::Error::custom("Failed to parse Tally ID"))
}
}

View File

@@ -1,66 +1,94 @@
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, TallyID},
webserver::app::AppState,
store::{Name, day::Day, tally_id::TallyID},
webserver::{app::AppState, sse::IDEvents},
};
#[derive(Deserialize)]
pub struct NewMapping {
id: String,
id: TallyID,
name: Name,
}
pub fn hex_string_to_tally_id(s: &str) -> Option<TallyID> {
let bytes = s.as_bytes();
if bytes.len() != 24 {
return None;
}
let mut out = [0u8; 12];
for i in 0..12 {
let hi = hex_val(bytes[2 * i])?;
let lo = hex_val(bytes[2 * i + 1])?;
out[i] = (hi << 4) | lo;
}
Some(out)
#[derive(Deserialize)]
pub struct QueryTimespan {
from: u64,
to: u64,
}
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,
}
#[derive(Deserialize)]
pub struct QueryDay {
timestamp: Option<u64>,
day: Option<u32>,
}
/*
* #[get("/api/idevent")]
* #[get("/api/csv")]
* #[get("/api/mapping")]
* #[post("/api/mapping", format = "json", data = "<new_mapping>")]
* struct NewMapping {
* id: String,
* name: Name,
* }
*/
// GET /api/mapping
pub async fn get_mapping(State(state): State<AppState>) -> impl IntoResponse {
let store = state.store.lock().await;
response::Json(store.mapping.clone())
}
// 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);
store.mapping.add_mapping(data.id, data.name);
store.persist_mapping().await;
}
// 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;
let days = store.list_days_in_timespan(from_day, to_day).await;
response::Json(days)
}
// 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 {
Some(att_day) => Ok(response::Json(att_day)),
None => Err((response::StatusCode::NOT_FOUND, "Not found")),
}
}

View File

@@ -3,15 +3,17 @@ use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex};
use picoserve::{AppWithStateBuilder, routing::get};
use crate::{
TallyChannel, UsedStore,
webserver::{
api::{add_mapping, get_mapping},
api::{add_mapping, get_day, get_days, get_idevent, get_mapping},
assets::Assets,
}, UsedStore,
},
};
#[derive(Clone)]
pub struct AppState {
pub store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
pub chan: &'static TallyChannel,
}
pub struct AppProps;
@@ -23,9 +25,8 @@ 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/idevent",
// get(move || response::EventStream(Events(self.chan))),
// )
.route("/api/idevent", get(get_idevent))
.route("/api/days", get(get_days))
.route("/api/day", get(get_day))
}
}

View File

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

View File

@@ -7,25 +7,26 @@ use picoserve::{AppRouter, AppWithStateBuilder};
use static_cell::make_static;
use crate::{
UsedStore,
TallyChannel, UsedStore,
webserver::app::{AppProps, AppState},
};
mod assets;
// mod sse;
mod api;
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,
) {
let app = make_static!(AppProps.build_app());
let state = make_static!(AppState { store });
let state = make_static!(AppState { store, chan });
let config = make_static!(picoserve::Config::new(picoserve::Timeouts {
start_read_request: Some(Duration::from_secs(5)),

View File

@@ -2,9 +2,11 @@ use embassy_time::{Duration, Timer};
use log::warn;
use picoserve::response;
pub struct Events(pub TallySubscriber);
use crate::TallySubscriber;
impl response::sse::EventSource for Events {
pub struct IDEvents(pub TallySubscriber);
impl response::sse::EventSource for IDEvents {
async fn write_events<W: picoserve::io::Write>(
mut self,
mut writer: response::sse::EventWriter<W>,
@@ -16,7 +18,8 @@ impl response::sse::EventSource for Events {
match sel.await {
embassy_futures::select::Either::First(msg) => match msg {
embassy_sync::pubsub::WaitResult::Message(id) => {
writer.write_event("msg", id.to_string().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
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"
]
}
]
}

111
web/mock/server.js Normal file
View 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}`);
});

871
web/package-lock.json generated
View File

@@ -14,6 +14,8 @@
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"body-parser": "^2.2.0",
"express": "^5.1.0",
"svelte": "^5.28.1",
"svelte-check": "^4.1.6",
"typescript": "~5.8.3",
@@ -1078,6 +1080,20 @@
"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
@@ -1111,6 +1127,68 @@
"node": ">= 0.4"
}
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -1146,6 +1224,49 @@
"node": ">=6"
}
},
"node_modules/content-disposition": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
"integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -1174,6 +1295,16 @@
"node": ">=0.10.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@@ -1183,6 +1314,38 @@
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/enhanced-resolve": {
"version": "5.18.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
@@ -1196,6 +1359,39 @@
"node": ">=10.13.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
@@ -1236,6 +1432,13 @@
"@esbuild/win32-x64": "0.25.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"dev": true,
"license": "MIT"
},
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
@@ -1253,6 +1456,59 @@
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.0",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
@@ -1267,6 +1523,44 @@
}
}
},
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
"integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1281,12 +1575,164 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/http-errors/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true,
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1560,6 +2006,62 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@@ -1631,6 +2133,73 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"dev": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1677,6 +2246,79 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.7.0",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -1730,6 +2372,23 @@
"fsevents": "~2.3.2"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -1743,6 +2402,156 @@
"node": ">=6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "^4.3.5",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"mime-types": "^3.0.1",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"dev": true,
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1752,6 +2561,16 @@
"node": ">=0.10.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/svelte": {
"version": "5.30.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.30.1.tgz",
@@ -1850,6 +2669,31 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -1864,6 +2708,26 @@
"node": ">=14.17"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vite": {
"version": "6.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
@@ -1957,6 +2821,13 @@
}
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",

View File

@@ -15,7 +15,9 @@
"svelte": "^5.28.1",
"svelte-check": "^4.1.6",
"typescript": "~5.8.3",
"vite": "^6.3.5"
"vite": "^6.3.5",
"body-parser": "^2.2.0",
"express": "^5.1.0"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.7",

View File

@@ -3,15 +3,21 @@
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 { fetchMapping, 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 = await fetchMapping();
onMount(() => {
let sse = new EventSource("/api/idevent");
sse.onmessage = (e) => {
lastID = e.data;
};
@@ -25,13 +31,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 +49,32 @@
/>
</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);
}}
/>
{/if}
</div>
<AddIDModal
bind:this={addModal}
onSubmitted={() => {
idTable.reloadData();
onSubmitted={async () => {
mapping = await fetchMapping();
}}
/>
<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>

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

@@ -1,7 +1,3 @@
export interface IDMapping {
id_map: IDMap
}
export interface IDMap {
[name: string]: Name
}
@@ -11,6 +7,23 @@ export interface Name {
last: string,
}
function stupidSerdeFix(pairs: [string, Name][]): IDMap {
const map: IDMap = {};
for (const [key, value] of pairs) {
map[key] = value;
}
return map;
}
export async function fetchMapping(): Promise<IDMap> {
let res = await fetch("/api/mapping");
let data = await res.json();
return stupidSerdeFix(data);
}
export async function addMapping(id: string, firstName: string, lastName: string) {
let req = await fetch("/api/mapping", {
method: "POST",

View File

@@ -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,70 +41,64 @@
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">
<table class="px-10">
<thead>
<tr>
<th
class="text-left pr-5 pl-2 cursor-pointer select-none"
onclick={() => {
handleSortClick("id");
}}
>
ID
<span class="indicator">{indicator("id")}</span>
</th>
<th
class="text-left pr-5 cursor-pointer select-none"
onclick={() => {
handleSortClick("last");
}}
>
Nachname
<span class="indicator">{indicator("last")}</span>
</th>
<th
class="text-left pr-5 cursor-pointer select-none"
onclick={() => {
handleSortClick("first");
}}
>Vorname
<div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto">
<table class="px-10">
<thead>
<tr>
<th
class="text-left pr-5 pl-2 cursor-pointer select-none"
onclick={() => {
handleSortClick("id");
}}
>
ID
<span class="indicator">{indicator("id")}</span>
</th>
<th
class="text-left pr-5 cursor-pointer select-none"
onclick={() => {
handleSortClick("last");
}}
>
Nachname
<span class="indicator">{indicator("last")}</span>
</th>
<th
class="text-left pr-5 cursor-pointer select-none"
onclick={() => {
handleSortClick("first");
}}
>Vorname
<span class="indicator">{indicator("first")}</span>
</th>
<th>
</th>
<span class="indicator">{indicator("first")}</span>
</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>
</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>
{/each}
</tbody>
</table>
</div>
{/if}
{/each}
</tbody>
</table>
</div>
<style lang="css" scoped>
@reference "../app.css";
.indicator {
@apply ml-1 w-4 inline-block;
}

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

View File

@@ -11,7 +11,7 @@ export default defineConfig({
server: {
proxy: {
"/api": {
target: "http://localhost:8080",
target: "http://localhost:3000",
},
},
},