mirror of
https://github.com/Djeeberjr/fw-anwesenheit.git
synced 2026-05-01 02:59:09 +00:00
Compare commits
27 Commits
6421074931
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cab2533fab | ||
|
|
5f65cc7a73 | ||
|
|
03e6a9036f | ||
| 16ea1db55f | |||
| a0ed04a560 | |||
|
|
4e988e8f01 | ||
| 009f6cbb2e | |||
|
|
967da9fc30 | ||
| 00cb7efedb | |||
| ebbec7885e | |||
| 7ecd2052d8 | |||
| 96512c8a12 | |||
| c3eaff03d9 | |||
| 4bf89626b9 | |||
| 7c0c0699b5 | |||
| 1ea70e4993 | |||
| 770dca5b0f | |||
| 2e75ba2908 | |||
| 141c1aa9cb | |||
|
|
4abbd844d2 | ||
| 7346b47816 | |||
| cd63dd3ee4 | |||
| f5d4ae1e05 | |||
| bd3f6731fd | |||
| 6fdcf7679f | |||
|
|
c4d6ed45f1 | ||
|
|
41adf7353d |
@@ -12,3 +12,7 @@ target = "riscv32imac-unknown-none-elf"
|
|||||||
|
|
||||||
[unstable]
|
[unstable]
|
||||||
build-std = ["alloc", "core"]
|
build-std = ["alloc", "core"]
|
||||||
|
|
||||||
|
[env]
|
||||||
|
WIFI_PASSWD = "hunter22"
|
||||||
|
WIFI_SSID = "fwa"
|
||||||
|
|||||||
924
Cargo.lock
generated
924
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
76
Cargo.toml
76
Cargo.toml
@@ -12,64 +12,44 @@ bench = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
esp-bootloader-esp-idf = "0.1.0"
|
esp-bootloader-esp-idf = "0.1.0"
|
||||||
embassy-net = { version = "0.7.0", features = [
|
esp-hal = { version = "1.0.0-rc.1", features = ["esp32c6", "unstable"] }
|
||||||
"dhcpv4",
|
esp-alloc = "0.9.0"
|
||||||
"medium-ethernet",
|
esp-println = { version = "0.16.0", features = ["esp32c6", "log-04"] }
|
||||||
"tcp",
|
esp-radio = { version = "0.16.0", features = ["esp32c6","esp-alloc", "wifi", "log-04", "smoltcp","unstable"]}
|
||||||
"udp",
|
esp-rtos = { version = "0.1.1", features = ["esp32c6", "embassy", "esp-radio", "esp-alloc"] }
|
||||||
] }
|
|
||||||
embedded-hal = "=1.0.0"
|
|
||||||
embedded-io = "0.6.1"
|
|
||||||
embedded-io-async = "0.6.1"
|
|
||||||
esp-alloc = "0.8.0"
|
|
||||||
esp-hal = { version = "1.0.0-beta.1", features = ["esp32c6", "unstable"] }
|
|
||||||
smoltcp = { version = "0.12.0", default-features = false, features = [
|
|
||||||
"medium-ethernet",
|
|
||||||
"multicast",
|
|
||||||
"proto-dhcpv4",
|
|
||||||
"proto-dns",
|
|
||||||
"proto-ipv4",
|
|
||||||
"socket-dns",
|
|
||||||
"socket-icmp",
|
|
||||||
"socket-raw",
|
|
||||||
"socket-tcp",
|
|
||||||
"socket-udp",
|
|
||||||
] }
|
|
||||||
# for more networking protocol support see https://crates.io/crates/edge-net
|
|
||||||
bleps = { git = "https://github.com/bjoernQ/bleps", package = "bleps", rev = "a5148d8ae679e021b78f53fd33afb8bb35d0b62e", features = [
|
|
||||||
"async",
|
|
||||||
"macros",
|
|
||||||
] }
|
|
||||||
critical-section = "1.2.0"
|
critical-section = "1.2.0"
|
||||||
embassy-executor = { version = "0.7.0", features = ["nightly"] }
|
|
||||||
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" }
|
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-dhcp = { version = "0.6.0", features = ["log"] }
|
||||||
edge-nal = "0.5.0"
|
edge-nal = "0.5.0"
|
||||||
edge-nal-embassy = { version = "0.6.0", features = ["log"] }
|
edge-nal-embassy = { version = "0.6.0", features = ["log"] }
|
||||||
|
|
||||||
picoserve = { git = "https://github.com/sammhicks/picoserve.git", rev = "400df53f61137e1bb2883ec610fc191bfe551a3a", features = ["embassy", "log", "json"] }
|
picoserve = { git = "https://github.com/sammhicks/picoserve.git", rev = "400df53f61137e1bb2883ec610fc191bfe551a3a", features = ["embassy", "log", "json"] }
|
||||||
embassy-sync = { version = "0.7.0", features = ["log"] }
|
|
||||||
ds3231 = { version = "0.3.0", features = ["async", "temperature_f32"] }
|
|
||||||
chrono = { version = "0.4.41", default-features = false }
|
|
||||||
dir-embed = "0.3.0"
|
dir-embed = "0.3.0"
|
||||||
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 = { 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-sdmmc = "0.8.0"
|
||||||
embedded-hal-bus = "0.3.0"
|
embedded-hal-bus = "0.3.0"
|
||||||
serde_json = { version = "1.0.143", default-features = false, features = ["alloc"]}
|
|
||||||
embassy-futures = { version = "0.1.2", features = ["log"] }
|
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
# Rust debug is too slow.
|
# Rust debug is too slow.
|
||||||
|
|||||||
@@ -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
|
|
||||||
133
src/feedback.rs
133
src/feedback.rs
@@ -1,11 +1,13 @@
|
|||||||
use embassy_time::{Duration, Timer};
|
use embassy_time::{Duration, Timer};
|
||||||
use esp_hal::{peripherals, rmt::ConstChannelAccess};
|
use esp_hal::rmt::Rmt;
|
||||||
use esp_hal_smartled::SmartLedsAdapterAsync;
|
use esp_hal::peripherals;
|
||||||
|
use esp_hal_smartled::{SmartLedsAdapterAsync, buffer_size_async};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use smart_leds::SmartLedsWriteAsync;
|
use smart_leds::SmartLedsWriteAsync;
|
||||||
use smart_leds::colors::{BLACK, GREEN, RED, YELLOW};
|
use smart_leds::colors::{BLACK, GREEN, RED, YELLOW};
|
||||||
use smart_leds::{brightness, colors::BLUE};
|
use smart_leds::{brightness, colors::BLUE};
|
||||||
|
|
||||||
|
use crate::init::hardware;
|
||||||
use crate::{FEEDBACK_STATE, init};
|
use crate::{FEEDBACK_STATE, init};
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
#[derive(Copy, Clone, Debug)]
|
||||||
@@ -24,14 +26,18 @@ const LED_LEVEL: u8 = 255;
|
|||||||
|
|
||||||
#[embassy_executor::task]
|
#[embassy_executor::task]
|
||||||
pub async fn feedback_task(
|
pub async fn feedback_task(
|
||||||
mut led: SmartLedsAdapterAsync<
|
rmt: Rmt<'static, esp_hal::Async>,
|
||||||
ConstChannelAccess<esp_hal::rmt::Tx, 0>,
|
led_gpio: peripherals::GPIO1<'static>,
|
||||||
{ init::hardware::LED_BUFFER_SIZE },
|
buzzer_gpio: peripherals::GPIO21<'static>,
|
||||||
>,
|
|
||||||
buzzer: peripherals::GPIO21<'static>,
|
|
||||||
) {
|
) {
|
||||||
debug!("Starting feedback task");
|
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 {
|
loop {
|
||||||
let feedback_state = FEEDBACK_STATE.wait().await;
|
let feedback_state = FEEDBACK_STATE.wait().await;
|
||||||
match feedback_state {
|
match feedback_state {
|
||||||
@@ -46,6 +52,12 @@ pub async fn feedback_task(
|
|||||||
Timer::after(Duration::from_millis(100)).await;
|
Timer::after(Duration::from_millis(100)).await;
|
||||||
buzzer.set_low();
|
buzzer.set_low();
|
||||||
Timer::after(Duration::from_millis(50)).await;
|
Timer::after(Duration::from_millis(50)).await;
|
||||||
|
led.write(brightness(
|
||||||
|
[BLACK; init::hardware::NUM_LEDS].into_iter(),
|
||||||
|
LED_LEVEL,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
}
|
}
|
||||||
FeedbackState::Nack => {
|
FeedbackState::Nack => {
|
||||||
led.write(brightness(
|
led.write(brightness(
|
||||||
@@ -118,7 +130,7 @@ pub async fn feedback_task(
|
|||||||
}
|
}
|
||||||
FeedbackState::Idle => {
|
FeedbackState::Idle => {
|
||||||
led.write(brightness(
|
led.write(brightness(
|
||||||
[GREEN; init::hardware::NUM_LEDS].into_iter(),
|
[BLACK; init::hardware::NUM_LEDS].into_iter(),
|
||||||
LED_LEVEL,
|
LED_LEVEL,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
@@ -128,106 +140,3 @@ pub async fn feedback_task(
|
|||||||
debug!("Feedback state: {:?}", feedback_state);
|
debug!("Feedback state: {:?}", feedback_state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// async fn beep_ack() {
|
|
||||||
// buzzer.set_high();
|
|
||||||
// buzzer.set_low();
|
|
||||||
// //Timer::after(Duration::from_millis(100)).await;
|
|
||||||
// }
|
|
||||||
|
|
||||||
/* pub async fn failure(&mut self) {
|
|
||||||
let buzzer_handle = Self::beep_nak(&mut self.buzzer);
|
|
||||||
let led_handle = Self::flash_led_for_duration(&mut self.led, RED, LED_BLINK_DURATION);
|
|
||||||
|
|
||||||
let (buzzer_result, _) = join!(buzzer_handle, led_handle);
|
|
||||||
|
|
||||||
buzzer_result.unwrap_or_else(|err| { error!("Failed to buzz: {err}");
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = self.led_to_status();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn activate_error_state(&mut self) -> Result<()> {
|
|
||||||
self.led.turn_on(RED)?;
|
|
||||||
Self::beep_nak(&mut self.buzzer).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn startup(&mut self){
|
|
||||||
self.device_status = DeviceStatus::Ready;
|
|
||||||
|
|
||||||
let led_handle = Self::flash_led_for_duration(&mut self.led, GREEN, Duration::from_secs(1));
|
|
||||||
let buzzer_handle = Self::beep_startup(&mut self.buzzer);
|
|
||||||
|
|
||||||
let (buzzer_result, led_result) = join!(buzzer_handle, led_handle);
|
|
||||||
|
|
||||||
buzzer_result.unwrap_or_else(|err| {
|
|
||||||
error!("Failed to buzz: {err}");
|
|
||||||
});
|
|
||||||
|
|
||||||
led_result.unwrap_or_else(|err| {
|
|
||||||
error!("Failed to blink led: {err}");
|
|
||||||
});
|
|
||||||
|
|
||||||
let _ = self.led_to_status();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async fn flash_led_for_duration(led: &mut L, color: RGB8, duration: Duration) -> Result<()> {
|
|
||||||
led.turn_on(color)?;
|
|
||||||
|
|
||||||
sleep(duration).await;
|
|
||||||
|
|
||||||
led.turn_off()?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn beep_ack(buzzer: &mut B) -> Result<()> {
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(1200.0, Duration::from_millis(100))
|
|
||||||
.await?;
|
|
||||||
sleep(Duration::from_millis(10)).await;
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(2000.0, Duration::from_millis(50))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn beep_nak(buzzer: &mut B) -> Result<()> {
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(600.0, Duration::from_millis(150))
|
|
||||||
.await?;
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(600.0, Duration::from_millis(150))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn beep_startup(buzzer: &mut B) -> Result<()> {
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(523.0, Duration::from_millis(150))
|
|
||||||
.await?;
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(659.0, Duration::from_millis(150))
|
|
||||||
.await?;
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(784.0, Duration::from_millis(150))
|
|
||||||
.await?;
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(1046.0, Duration::from_millis(200))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
sleep(Duration::from_millis(100)).await;
|
|
||||||
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(784.0, Duration::from_millis(100))
|
|
||||||
.await?;
|
|
||||||
buzzer
|
|
||||||
.modulated_tone(880.0, Duration::from_millis(200))
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|||||||
@@ -4,14 +4,16 @@ use embassy_executor::Spawner;
|
|||||||
use embassy_net::Stack;
|
use embassy_net::Stack;
|
||||||
use embassy_time::{Duration, Timer};
|
use embassy_time::{Duration, Timer};
|
||||||
use esp_hal::Blocking;
|
use esp_hal::Blocking;
|
||||||
|
use esp_hal::delay::Delay;
|
||||||
use esp_hal::gpio::Input;
|
use esp_hal::gpio::Input;
|
||||||
use esp_hal::i2c::master::Config;
|
use esp_hal::i2c::master::Config;
|
||||||
use esp_hal::peripherals::{
|
use esp_hal::peripherals::{
|
||||||
GPIO0, GPIO1, GPIO16, GPIO17, GPIO18, GPIO19, GPIO20, GPIO21, GPIO22, GPIO23, I2C0, RMT, SPI2,
|
GPIO0, GPIO1, GPIO16, GPIO17, GPIO18, GPIO19, GPIO20, GPIO21, GPIO22, GPIO23, I2C0, RMT, SPI2,
|
||||||
UART1,
|
UART1,
|
||||||
};
|
};
|
||||||
use esp_hal::rmt::{ConstChannelAccess, Rmt};
|
use esp_hal::rmt::Rmt;
|
||||||
use esp_hal::spi::master::{Config as Spi_config, Spi};
|
use esp_hal::spi::master::{Config as Spi_config, Spi};
|
||||||
|
use esp_hal::system::software_reset;
|
||||||
use esp_hal::time::Rate;
|
use esp_hal::time::Rate;
|
||||||
use esp_hal::timer::timg::TimerGroup;
|
use esp_hal::timer::timg::TimerGroup;
|
||||||
use esp_hal::{
|
use esp_hal::{
|
||||||
@@ -47,47 +49,50 @@ use crate::init::wifi;
|
|||||||
*
|
*
|
||||||
*************************************************/
|
*************************************************/
|
||||||
|
|
||||||
pub const NUM_LEDS: usize = 66;
|
pub const NUM_LEDS: usize = 1;
|
||||||
pub const LED_BUFFER_SIZE: usize = NUM_LEDS * 25;
|
|
||||||
|
|
||||||
static SD_DET: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));
|
static SD_DET: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));
|
||||||
|
|
||||||
#[panic_handler]
|
#[panic_handler]
|
||||||
fn panic(info: &core::panic::PanicInfo) -> ! {
|
fn panic(info: &core::panic::PanicInfo) -> ! {
|
||||||
loop {
|
let delay = Delay::new();
|
||||||
error!("PANIC: {info}");
|
error!("PANIC: {info}");
|
||||||
}
|
delay.delay(esp_hal::time::Duration::from_secs(30));
|
||||||
|
software_reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
esp_bootloader_esp_idf::esp_app_desc!();
|
esp_bootloader_esp_idf::esp_app_desc!();
|
||||||
|
|
||||||
pub async fn hardware_init(
|
pub async fn hardware_init(
|
||||||
spawner: &mut Spawner,
|
spawner: Spawner,
|
||||||
) -> (
|
) -> (
|
||||||
Uart<'static, Async>,
|
Uart<'static, Async>,
|
||||||
Stack<'static>,
|
Stack<'static>,
|
||||||
I2c<'static, Async>,
|
I2c<'static, Async>,
|
||||||
SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE>,
|
Rmt<'static, esp_hal::Async>,
|
||||||
|
GPIO1<'static>,
|
||||||
GPIO21<'static>,
|
GPIO21<'static>,
|
||||||
GPIO0<'static>,
|
GPIO0<'static>,
|
||||||
|
SmartLedsAdapterAsync<'static, LED_BUFFER_SIZE>,
|
||||||
SDCardPersistence,
|
SDCardPersistence,
|
||||||
) {
|
) {
|
||||||
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
|
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
|
||||||
let peripherals = esp_hal::init(config);
|
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);
|
let timg0 = TimerGroup::new(peripherals.TIMG0);
|
||||||
esp_hal_embassy::init(timer0.alarm0);
|
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);
|
init_logger(log::LevelFilter::Debug);
|
||||||
|
|
||||||
let timer1 = TimerGroup::new(peripherals.TIMG0);
|
let rng = esp_hal::rng::Rng::new();
|
||||||
let mut rng = esp_hal::rng::Rng::new(peripherals.RNG);
|
|
||||||
let network_seed = (rng.random() as u64) << 32 | rng.random() as u64;
|
let network_seed = (rng.random() as u64) << 32 | rng.random() as u64;
|
||||||
|
|
||||||
wifi::set_antenna_mode(peripherals.GPIO3, peripherals.GPIO14).await;
|
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);
|
let stack = network::setup_network(network_seed, interfaces.ap, spawner);
|
||||||
|
|
||||||
Timer::after(Duration::from_millis(1)).await;
|
Timer::after(Duration::from_millis(1)).await;
|
||||||
@@ -98,6 +103,10 @@ pub async fn hardware_init(
|
|||||||
|
|
||||||
let sd_det_gpio = peripherals.GPIO0;
|
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(
|
let spi_bus = setup_spi(
|
||||||
peripherals.SPI2,
|
peripherals.SPI2,
|
||||||
peripherals.GPIO19,
|
peripherals.GPIO19,
|
||||||
@@ -113,21 +122,24 @@ pub async fn hardware_init(
|
|||||||
|
|
||||||
let vol_mgr = setup_sdcard(spi_bus, sd_cs_pin);
|
let vol_mgr = setup_sdcard(spi_bus, sd_cs_pin);
|
||||||
|
|
||||||
|
let led_gpio = peripherals.GPIO1;
|
||||||
let buzzer_gpio = peripherals.GPIO21;
|
let buzzer_gpio = peripherals.GPIO21;
|
||||||
|
|
||||||
Timer::after(Duration::from_millis(500)).await;
|
|
||||||
|
|
||||||
let led = setup_led(peripherals.RMT, peripherals.GPIO1);
|
let led = setup_led(peripherals.RMT, peripherals.GPIO1);
|
||||||
|
|
||||||
|
Timer::after(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
debug!("hardware init done");
|
debug!("hardware init done");
|
||||||
|
|
||||||
(
|
(
|
||||||
uart_device,
|
uart_device,
|
||||||
stack,
|
stack,
|
||||||
i2c_device,
|
i2c_device,
|
||||||
led,
|
rmt,
|
||||||
|
led_gpio,
|
||||||
buzzer_gpio,
|
buzzer_gpio,
|
||||||
sd_det_gpio,
|
sd_det_gpio,
|
||||||
|
led,
|
||||||
vol_mgr,
|
vol_mgr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -186,11 +198,10 @@ pub fn setup_buzzer(buzzer_gpio: GPIO21<'static>) -> Output<'static> {
|
|||||||
buzzer
|
buzzer
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup_led(
|
fn setup_led<'a>(
|
||||||
rmt: RMT<'static>,
|
rmt: RMT<'a>,
|
||||||
led_gpio: GPIO1<'static>,
|
led_gpio: GPIO1<'a>,
|
||||||
) -> SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE> {
|
) -> esp_hal_smartled::SmartLedsAdapterAsync<'a, LED_BUFFER_SIZE> {
|
||||||
debug!("setup led");
|
|
||||||
let rmt: Rmt<'_, esp_hal::Async> = {
|
let rmt: Rmt<'_, esp_hal::Async> = {
|
||||||
let frequency: Rate = Rate::from_mhz(80);
|
let frequency: Rate = Rate::from_mhz(80);
|
||||||
Rmt::new(rmt, frequency)
|
Rmt::new(rmt, frequency)
|
||||||
@@ -199,10 +210,7 @@ fn setup_led(
|
|||||||
.into_async();
|
.into_async();
|
||||||
|
|
||||||
let rmt_channel = rmt.channel0;
|
let rmt_channel = rmt.channel0;
|
||||||
let rmt_buffer = [0_u32; buffer_size_async(NUM_LEDS)];
|
let rmt_buffer = [esp_hal::rmt::PulseCode::default(); LED_BUFFER_SIZE];
|
||||||
|
|
||||||
let led: SmartLedsAdapterAsync<_, LED_BUFFER_SIZE> =
|
SmartLedsAdapterAsync::new(rmt_channel, led_gpio, rmt_buffer)
|
||||||
SmartLedsAdapterAsync::new(rmt_channel, led_gpio, rmt_buffer);
|
|
||||||
|
|
||||||
led
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use core::{net::Ipv4Addr, str::FromStr};
|
|||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
use embassy_net::{Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
|
use embassy_net::{Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
|
||||||
use embassy_time::{Duration, Timer};
|
use embassy_time::{Duration, Timer};
|
||||||
use esp_wifi::wifi::WifiDevice;
|
use esp_radio::wifi::WifiDevice;
|
||||||
use static_cell::make_static;
|
use static_cell::make_static;
|
||||||
|
|
||||||
use crate::webserver::WEB_TAKS_SIZE;
|
use crate::webserver::WEB_TAKS_SIZE;
|
||||||
@@ -10,7 +10,7 @@ use crate::webserver::WEB_TAKS_SIZE;
|
|||||||
pub const NETWORK_STACK_SIZE: usize = WEB_TAKS_SIZE + 2; // + 2 for other network taks. Breaks
|
pub const NETWORK_STACK_SIZE: usize = WEB_TAKS_SIZE + 2; // + 2 for other network taks. Breaks
|
||||||
// without
|
// without
|
||||||
|
|
||||||
pub 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_str = "192.168.2.1";
|
||||||
let gw_ip_addr = Ipv4Addr::from_str(gw_ip_addr_str).expect("failed to parse gateway ip");
|
let gw_ip_addr = Ipv4Addr::from_str(gw_ip_addr_str).expect("failed to parse gateway ip");
|
||||||
let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
|
let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
|
||||||
@@ -19,12 +19,10 @@ pub fn setup_network<'a>(seed: u64, wifi: WifiDevice<'static>, spawner: &mut Spa
|
|||||||
dns_servers: Default::default(),
|
dns_servers: Default::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let (stack, runner) = embassy_net::new(
|
let nw_stack: &'static mut StackResources<NETWORK_STACK_SIZE> =
|
||||||
wifi,
|
make_static!(StackResources::<NETWORK_STACK_SIZE>::new());
|
||||||
config,
|
|
||||||
make_static!(StackResources::<NETWORK_STACK_SIZE>::new()),
|
let (stack, runner) = embassy_net::new(wifi, config, nw_stack, seed);
|
||||||
seed,
|
|
||||||
);
|
|
||||||
|
|
||||||
spawner.must_spawn(net_task(runner));
|
spawner.must_spawn(net_task(runner));
|
||||||
spawner.must_spawn(run_dhcp(stack, gw_ip_addr_str));
|
spawner.must_spawn(run_dhcp(stack, gw_ip_addr_str));
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ use embassy_executor::Spawner;
|
|||||||
use embassy_time::{Duration, Timer};
|
use embassy_time::{Duration, Timer};
|
||||||
use esp_hal::gpio::{Output, OutputConfig};
|
use esp_hal::gpio::{Output, OutputConfig};
|
||||||
use esp_hal::peripherals::{GPIO3, GPIO14, WIFI};
|
use esp_hal::peripherals::{GPIO3, GPIO14, WIFI};
|
||||||
use esp_wifi::wifi::{AccessPointConfiguration, Configuration, WifiController, WifiEvent, WifiState};
|
use esp_radio::Controller;
|
||||||
use esp_wifi::{EspWifiRngSource, EspWifiTimerSource, wifi::Interfaces};
|
use esp_radio::wifi::{
|
||||||
use static_cell::make_static;
|
AccessPointConfig, Interfaces, ModeConfig, WifiApState, WifiController, WifiEvent,
|
||||||
|
};
|
||||||
|
use log::debug;
|
||||||
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
|
static ESP_WIFI_CTRL: StaticCell<Controller<'static>> = StaticCell::new();
|
||||||
|
|
||||||
pub async fn set_antenna_mode(gpio3: GPIO3<'static>, gpio14: GPIO14<'static>) {
|
pub async fn set_antenna_mode(gpio3: GPIO3<'static>, gpio14: GPIO14<'static>) {
|
||||||
let mut rf_switch = Output::new(gpio3, esp_hal::gpio::Level::Low, OutputConfig::default());
|
let mut rf_switch = Output::new(gpio3, esp_hal::gpio::Level::Low, OutputConfig::default());
|
||||||
@@ -18,26 +23,23 @@ pub async fn set_antenna_mode(gpio3: GPIO3<'static>, gpio14: GPIO14<'static>) {
|
|||||||
antenna_mode.set_low();
|
antenna_mode.set_low();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn setup_wifi<'d: 'static>(
|
pub fn setup_wifi<'d: 'static>(wifi: WIFI<'static>, spawner: Spawner) -> Interfaces<'d> {
|
||||||
timer: impl EspWifiTimerSource + 'd,
|
let esp_wifi_ctrl = ESP_WIFI_CTRL.init(esp_radio::init().unwrap());
|
||||||
rng: impl EspWifiRngSource + 'd,
|
|
||||||
wifi: WIFI<'static>,
|
|
||||||
spawner: &mut Spawner,
|
|
||||||
) -> Interfaces<'d> {
|
|
||||||
let esp_wifi_ctrl = make_static!(esp_wifi::init(timer, rng).unwrap());
|
|
||||||
|
|
||||||
let (controller, interfaces) = esp_wifi::wifi::new(esp_wifi_ctrl, wifi).unwrap();
|
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));
|
spawner.must_spawn(connection(controller));
|
||||||
|
|
||||||
interfaces
|
interfaces
|
||||||
}
|
}
|
||||||
|
|
||||||
#[embassy_executor::task]
|
#[embassy_executor::task]
|
||||||
async fn connection(mut controller: WifiController<'static>) {
|
async fn connection(mut controller: WifiController<'static>) {
|
||||||
|
debug!("start connection task");
|
||||||
|
debug!("Device capabilities: {:?}", controller.capabilities());
|
||||||
loop {
|
loop {
|
||||||
match esp_wifi::wifi::wifi_state() {
|
match esp_radio::wifi::ap_state() {
|
||||||
WifiState::ApStarted => {
|
WifiApState::Started => {
|
||||||
// wait until we're no longer connected
|
// wait until we're no longer connected
|
||||||
controller.wait_for_event(WifiEvent::ApStop).await;
|
controller.wait_for_event(WifiEvent::ApStop).await;
|
||||||
Timer::after(Duration::from_millis(5000)).await
|
Timer::after(Duration::from_millis(5000)).await
|
||||||
@@ -45,12 +47,16 @@ async fn connection(mut controller: WifiController<'static>) {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
if !matches!(controller.is_started(), Ok(true)) {
|
if !matches!(controller.is_started(), Ok(true)) {
|
||||||
let client_config = Configuration::AccessPoint(AccessPointConfiguration {
|
let client_config = ModeConfig::AccessPoint(
|
||||||
ssid: "esp-wifi".try_into().unwrap(),
|
AccessPointConfig::default()
|
||||||
..Default::default()
|
.with_ssid(env!("WIFI_SSID").try_into().unwrap())
|
||||||
});
|
.with_password(env!("WIFI_PASSWD").try_into().unwrap())
|
||||||
controller.set_configuration(&client_config).unwrap();
|
.with_auth_method(esp_radio::wifi::AuthMethod::Wpa2Personal),
|
||||||
|
);
|
||||||
|
controller.set_config(&client_config).unwrap();
|
||||||
|
debug!("Starting wifi");
|
||||||
controller.start_async().await.unwrap();
|
controller.start_async().await.unwrap();
|
||||||
|
debug!("Wifi started!");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/main.rs
21
src/main.rs
@@ -2,6 +2,7 @@
|
|||||||
#![no_main]
|
#![no_main]
|
||||||
#![feature(type_alias_impl_trait)]
|
#![feature(type_alias_impl_trait)]
|
||||||
#![feature(impl_trait_in_assoc_type)]
|
#![feature(impl_trait_in_assoc_type)]
|
||||||
|
#![warn(clippy::unwrap_used)]
|
||||||
|
|
||||||
use alloc::rc::Rc;
|
use alloc::rc::Rc;
|
||||||
use embassy_executor::Spawner;
|
use embassy_executor::Spawner;
|
||||||
@@ -19,7 +20,7 @@ use embassy_time::{Duration, Timer};
|
|||||||
use esp_hal::gpio::Input;
|
use esp_hal::gpio::Input;
|
||||||
use esp_hal::{gpio::InputConfig, peripherals};
|
use esp_hal::{gpio::InputConfig, peripherals};
|
||||||
use log::{debug, info};
|
use log::{debug, info};
|
||||||
use static_cell::make_static;
|
use static_cell::StaticCell;
|
||||||
|
|
||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
|
|
||||||
@@ -42,25 +43,27 @@ type TallyPublisher = Publisher<'static, NoopRawMutex, TallyID, 8, 2, 1>;
|
|||||||
type TallySubscriber = Subscriber<'static, NoopRawMutex, TallyID, 8, 2, 1>;
|
type TallySubscriber = Subscriber<'static, NoopRawMutex, TallyID, 8, 2, 1>;
|
||||||
type UsedStore = IDStore<SDCardPersistence>;
|
type UsedStore = IDStore<SDCardPersistence>;
|
||||||
|
|
||||||
#[esp_hal_embassy::main]
|
static CHAN: StaticCell<TallyChannel> = StaticCell::new();
|
||||||
async fn main(mut spawner: Spawner) {
|
|
||||||
let (uart_device, stack, _i2c, _led, buzzer_gpio, sd_det_gpio, persistence_layer) =
|
#[esp_rtos::main]
|
||||||
init::hardware::hardware_init(&mut spawner).await;
|
async fn main(spawner: Spawner) -> ! {
|
||||||
|
let (uart_device, stack, i2c, rmt, led_gpio, buzzer_gpio, sd_det_gpio, persistence_layer) =
|
||||||
|
init::hardware::hardware_init(spawner).await;
|
||||||
|
|
||||||
info!("Starting up...");
|
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 store: UsedStore = IDStore::new_from_storage(persistence_layer).await;
|
||||||
let shared_store = Rc::new(Mutex::new(store));
|
let shared_store = Rc::new(Mutex::new(store));
|
||||||
|
|
||||||
let chan: &'static mut TallyChannel = make_static!(PubSubChannel::new());
|
let chan: &'static mut TallyChannel = CHAN.init(PubSubChannel::new());
|
||||||
let publisher: TallyPublisher = chan.publisher().unwrap();
|
let publisher: TallyPublisher = chan.publisher().unwrap();
|
||||||
let mut sub: TallySubscriber = chan.subscriber().unwrap();
|
let mut sub: TallySubscriber = chan.subscriber().unwrap();
|
||||||
|
|
||||||
wait_for_stack_up(stack).await;
|
wait_for_stack_up(stack).await;
|
||||||
|
|
||||||
start_webserver(&mut spawner, stack, shared_store.clone(), chan);
|
start_webserver(spawner, stack, shared_store.clone(), chan);
|
||||||
|
|
||||||
/****************************** Spawning tasks ***********************************/
|
/****************************** Spawning tasks ***********************************/
|
||||||
debug!("spawing NFC reader task...");
|
debug!("spawing NFC reader task...");
|
||||||
@@ -70,7 +73,7 @@ async fn main(mut spawner: Spawner) {
|
|||||||
));
|
));
|
||||||
|
|
||||||
debug!("spawing feedback task..");
|
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");
|
debug!("spawn sd detect task");
|
||||||
spawner.must_spawn(sd_detect_task(sd_det_gpio));
|
spawner.must_spawn(sd_detect_task(sd_det_gpio));
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ impl<T: Persistence> IDStore<T> {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn persist_mapping(&mut self) {
|
pub async fn persist_mapping(&mut self) {
|
||||||
self.persistence_layer.save_mapping(&self.mapping).await
|
self.persistence_layer.save_mapping(&self.mapping).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use esp_println::dbg;
|
use log::error;
|
||||||
use picoserve::{
|
use picoserve::{
|
||||||
extract::{Json, Query, State},
|
extract::{Json, Query, State},
|
||||||
response::{self, IntoResponse},
|
response::{self, IntoResponse},
|
||||||
@@ -6,7 +6,7 @@ use picoserve::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
store::{self, Name, day::Day, tally_id::TallyID},
|
store::{Name, day::Day, tally_id::TallyID},
|
||||||
webserver::{app::AppState, sse::IDEvents},
|
webserver::{app::AppState, sse::IDEvents},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,11 +41,23 @@ pub async fn add_mapping(
|
|||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut store = state.store.lock().await;
|
let mut store = state.store.lock().await;
|
||||||
store.mapping.add_mapping(data.id, data.name);
|
store.mapping.add_mapping(data.id, data.name);
|
||||||
|
store.persist_mapping().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE /api/idevent
|
// SSE /api/idevent
|
||||||
pub async fn get_idevent(State(state): State<AppState>) -> impl IntoResponse {
|
pub async fn get_idevent(
|
||||||
response::EventStream(IDEvents(state.chan.subscriber().unwrap()))
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||||
|
match state.chan.subscriber() {
|
||||||
|
Ok(chan) => Ok(response::EventStream(IDEvents(chan))),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to create SSE: {:?}", e);
|
||||||
|
Err((
|
||||||
|
response::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"Internal server error",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/days
|
// GET /api/days
|
||||||
@@ -68,7 +80,6 @@ pub async fn get_day(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(QueryDay { timestamp, day }): Query<QueryDay>,
|
Query(QueryDay { timestamp, day }): Query<QueryDay>,
|
||||||
) -> Result<impl IntoResponse, impl IntoResponse> {
|
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||||
|
|
||||||
let parsed_day = timestamp
|
let parsed_day = timestamp
|
||||||
.map(Day::new_from_timestamp)
|
.map(Day::new_from_timestamp)
|
||||||
.or_else(|| day.map(Day::new))
|
.or_else(|| day.map(Day::new))
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ impl<State, CurrentPathParameters>
|
|||||||
);
|
);
|
||||||
|
|
||||||
response_writer
|
response_writer
|
||||||
.write_response(request.body_connection.finalize().await.unwrap(), response)
|
.write_response(request.body_connection.finalize().await?, response)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -68,10 +68,7 @@ impl Content for StaticAsset {
|
|||||||
self.0.len()
|
self.0.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_content<W: embedded_io_async::Write>(
|
async fn write_content<W: edge_nal::io::Write>(self, mut writer: W) -> Result<(), W::Error> {
|
||||||
self,
|
|
||||||
mut writer: W,
|
|
||||||
) -> Result<(), W::Error> {
|
|
||||||
writer.write_all(self.0).await
|
writer.write_all(self.0).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ mod app;
|
|||||||
mod assets;
|
mod assets;
|
||||||
mod sse;
|
mod sse;
|
||||||
|
|
||||||
pub const WEB_TAKS_SIZE: usize = 3; // Up this number if request start fail with Timeouts.
|
pub const WEB_TAKS_SIZE: usize = 5; // Up this number if request start fail with Timeouts.
|
||||||
|
|
||||||
pub fn start_webserver(
|
pub fn start_webserver(
|
||||||
spawner: &mut Spawner,
|
spawner: Spawner,
|
||||||
stack: Stack<'static>,
|
stack: Stack<'static>,
|
||||||
store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
|
store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
|
||||||
chan: &'static TallyChannel,
|
chan: &'static TallyChannel,
|
||||||
|
|||||||
108
web/mock/data.json
Normal file
108
web/mock/data.json
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"mapping": [
|
||||||
|
[
|
||||||
|
"123456789ABC",
|
||||||
|
{
|
||||||
|
"first": "Feuerwehrman",
|
||||||
|
"last": "Sam"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"A1B2C3D4E5F6",
|
||||||
|
{
|
||||||
|
"first": "Luna",
|
||||||
|
"last": "Starforge"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"0F1E2D3C4B5A",
|
||||||
|
{
|
||||||
|
"first": "Gareth",
|
||||||
|
"last": "Ironwill"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"ABCDEF123456",
|
||||||
|
{
|
||||||
|
"first": "Nina",
|
||||||
|
"last": "Skylark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"654321FEDCBA",
|
||||||
|
{
|
||||||
|
"first": "Tobias",
|
||||||
|
"last": "Marrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"DEADBEEFCAFE",
|
||||||
|
{
|
||||||
|
"first": "Astra",
|
||||||
|
"last": "Vale"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"BADA55C0FFEE",
|
||||||
|
{
|
||||||
|
"first": "Rowan",
|
||||||
|
"last": "Tempest"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"C001D00D1337",
|
||||||
|
{
|
||||||
|
"first": "Juniper",
|
||||||
|
"last": "Voss"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"A0B1DB0D133B",
|
||||||
|
{
|
||||||
|
"first": "Öäü",
|
||||||
|
"last": "ßẞ"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"days": [
|
||||||
|
{
|
||||||
|
"date": 20372,
|
||||||
|
"ids": [
|
||||||
|
"123456789ABC",
|
||||||
|
"A1B2C3D4E5F6",
|
||||||
|
"A0B1DB0D133B"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 20373,
|
||||||
|
"ids": [
|
||||||
|
"0F1E2D3C4B5A",
|
||||||
|
"ABCDEF123456"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 20374,
|
||||||
|
"ids": [
|
||||||
|
"654321FEDCBA",
|
||||||
|
"DEADBEEFCAFE",
|
||||||
|
"BADA55C0FFEE",
|
||||||
|
"A0B1DB0D133B"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 20375,
|
||||||
|
"ids": [
|
||||||
|
"C001D00D1337",
|
||||||
|
"A1B2C3D4E5F6",
|
||||||
|
"123456789ABC"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": 20376,
|
||||||
|
"ids": [
|
||||||
|
"N0T3X1ST1D0",
|
||||||
|
"654321FEDCBA"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,20 +1,14 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import bodyParser from "body-parser";
|
import bodyParser from "body-parser";
|
||||||
|
|
||||||
|
import mockData from "./data.json" with {type: "json"};
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
|
|
||||||
app.use(bodyParser.json());
|
const SECS_IN_DAY = 86_400;
|
||||||
|
|
||||||
let mappings = [
|
app.use(bodyParser.json());
|
||||||
[
|
|
||||||
"123456789ABC",
|
|
||||||
{
|
|
||||||
first: "Feuerwehrman",
|
|
||||||
last: "Sam",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
function generateRandomId() {
|
function generateRandomId() {
|
||||||
const chars = "ABCDEF0123456789";
|
const chars = "ABCDEF0123456789";
|
||||||
@@ -27,7 +21,7 @@ function generateRandomId() {
|
|||||||
|
|
||||||
// GET /api/mapping
|
// GET /api/mapping
|
||||||
app.get("/api/mapping", (req, res) => {
|
app.get("/api/mapping", (req, res) => {
|
||||||
res.json(mappings);
|
res.json(mockData.mapping);
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/mapping
|
// POST /api/mapping
|
||||||
@@ -45,11 +39,45 @@ app.post("/api/mapping", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add new mapping
|
// Add new mapping
|
||||||
mappings.push([id, name]);
|
mockData.mappings.push([id, name]);
|
||||||
|
|
||||||
res.status(201).send("");
|
res.status(201).send("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get("/api/day", (req, res) => {
|
||||||
|
let day;
|
||||||
|
|
||||||
|
if (req.query.day) {
|
||||||
|
day = parseInt(req.query.day, 10);
|
||||||
|
}else if (req.query.timestamp) {
|
||||||
|
let ts = parseInt(req.query.timestamp, 10);
|
||||||
|
day = ts / SECS_IN_DAY;
|
||||||
|
}else {
|
||||||
|
return res.status(400).json({ error: "Missing or invalid 'day' parameter" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(day)) {
|
||||||
|
return res.status(400).json({ error: "Missing or invalid 'day' parameter" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let foundDay = mockData.days.find(e => e.date == day);
|
||||||
|
|
||||||
|
if (!foundDay) {
|
||||||
|
return res.status(404).send("Not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(foundDay);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/days", (req,res) => {
|
||||||
|
|
||||||
|
let qFrom = parseInt(req.query.from) / SECS_IN_DAY;
|
||||||
|
let qTo = parseInt(req.query.to) / SECS_IN_DAY;
|
||||||
|
|
||||||
|
let days = mockData.days.filter(e => e.date >= qFrom && e.date <= qTo).map(e => e.date);
|
||||||
|
|
||||||
|
res.status(200).json(days);
|
||||||
|
});
|
||||||
|
|
||||||
// SSE route: /api/idevent
|
// SSE route: /api/idevent
|
||||||
app.get("/api/idevent", (req, res) => {
|
app.get("/api/idevent", (req, res) => {
|
||||||
|
|||||||
@@ -3,15 +3,21 @@
|
|||||||
import IDTable from "./lib/IDTable.svelte";
|
import IDTable from "./lib/IDTable.svelte";
|
||||||
import LastId from "./lib/LastID.svelte";
|
import LastId from "./lib/LastID.svelte";
|
||||||
import AddIDModal from "./lib/AddIDModal.svelte";
|
import AddIDModal from "./lib/AddIDModal.svelte";
|
||||||
|
import ExportModal from "./lib/ExportModal.svelte";
|
||||||
|
import { generateCSVFile } from "./lib/exporting";
|
||||||
|
import { fetchMapping, type IDMap } from "./lib/IDMapping";
|
||||||
|
import { downloadBlob } from "./lib/downloadBlob";
|
||||||
|
|
||||||
let lastID: string = $state("");
|
let lastID: string = $state("");
|
||||||
|
let mapping: IDMap | null = $state(null);
|
||||||
|
|
||||||
let addModal: AddIDModal;
|
let addModal: AddIDModal;
|
||||||
let idTable: IDTable;
|
let exportModal: ExportModal;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
mapping = await fetchMapping();
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
let sse = new EventSource("/api/idevent");
|
let sse = new EventSource("/api/idevent");
|
||||||
|
|
||||||
sse.onmessage = (e) => {
|
sse.onmessage = (e) => {
|
||||||
lastID = e.data;
|
lastID = e.data;
|
||||||
};
|
};
|
||||||
@@ -25,13 +31,14 @@
|
|||||||
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Anwesenheit</h1>
|
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Anwesenheit</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<button
|
||||||
class="px-6 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
class="px-6 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
||||||
href="/api/csv"
|
onclick={() => {
|
||||||
download="anwesenheit.csv"
|
exportModal.open();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Download CSV
|
Export CSV
|
||||||
</a>
|
</button>
|
||||||
|
|
||||||
<div class="pt-3 pb-2">
|
<div class="pt-3 pb-2">
|
||||||
<LastId
|
<LastId
|
||||||
@@ -42,15 +49,32 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<IDTable bind:this={idTable} onEdit={(id,firstName,lastName)=>{
|
{#if mapping}
|
||||||
|
<IDTable
|
||||||
|
data={mapping}
|
||||||
|
onEdit={(id, firstName, lastName) => {
|
||||||
addModal.open(id, firstName, lastName);
|
addModal.open(id, firstName, lastName);
|
||||||
}}/>
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddIDModal
|
<AddIDModal
|
||||||
bind:this={addModal}
|
bind:this={addModal}
|
||||||
onSubmitted={() => {
|
onSubmitted={async () => {
|
||||||
idTable.reloadData();
|
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>
|
</main>
|
||||||
|
|||||||
31
web/src/lib/Day.ts
Normal file
31
web/src/lib/Day.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export type Day = number;
|
||||||
|
|
||||||
|
export interface AttendanceDay {
|
||||||
|
date: Day,
|
||||||
|
ids: string[],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dayToDate(day: Day): Date {
|
||||||
|
const SEC_PER_DAY = 86_400;
|
||||||
|
|
||||||
|
return new Date(day * SEC_PER_DAY * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDay(day: Day): Promise<AttendanceDay> {
|
||||||
|
let res = await fetch("/api/day?" + (new URLSearchParams({ day: day.toString() }).toString()));
|
||||||
|
|
||||||
|
let json = await res.json();
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDays(from: Date, to: Date): Promise<Day[]> {
|
||||||
|
let q = new URLSearchParams({ from: (from.getTime() / 1000).toString(), to: (to.getTime() / 1000).toString() });
|
||||||
|
|
||||||
|
let res = await fetch("/api/days?" + q);
|
||||||
|
|
||||||
|
let json = await res.json();
|
||||||
|
|
||||||
|
return json;
|
||||||
|
}
|
||||||
|
|
||||||
142
web/src/lib/ExportModal.svelte
Normal file
142
web/src/lib/ExportModal.svelte
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Modal from "./Modal.svelte";
|
||||||
|
|
||||||
|
let { onSubmitted }: { onSubmitted?: (from: Date, to: Date) => void } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let modal: Modal;
|
||||||
|
|
||||||
|
let fromDate: string | undefined = $state();
|
||||||
|
let toDate: string | undefined = $state();
|
||||||
|
|
||||||
|
let selectedYear: number = $state(new Date().getFullYear());
|
||||||
|
|
||||||
|
let selectedTab = $state(0);
|
||||||
|
|
||||||
|
export function open() {
|
||||||
|
modal.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateYears() {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const startingYear = 2020;
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Array(currentYear + 1 - startingYear),
|
||||||
|
(_, i) => i + startingYear,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onsubmit(e: SubmitEvent) {
|
||||||
|
let from: Date;
|
||||||
|
let to: Date;
|
||||||
|
|
||||||
|
switch (selectedTab) {
|
||||||
|
case 0:
|
||||||
|
if (!fromDate || !toDate) {
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
from = new Date(fromDate);
|
||||||
|
to = new Date(toDate);
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
from = new Date(selectedYear, 0);
|
||||||
|
to = new Date(selectedYear + 1, 0);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.error("Invalid tab");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmitted?.(from, to);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:this={modal}>
|
||||||
|
<div class="flex">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
selectedTab = 0;
|
||||||
|
}}
|
||||||
|
class="tab {selectedTab === 0 ? 'tab-active' : ''}"
|
||||||
|
>
|
||||||
|
Datum
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
selectedTab = 1;
|
||||||
|
}}
|
||||||
|
class="tab {selectedTab === 1 ? 'tab-active' : ''}"
|
||||||
|
>
|
||||||
|
Jahr
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" {onsubmit} class="flex flex-col">
|
||||||
|
{#if selectedTab === 0}
|
||||||
|
<div>
|
||||||
|
<label class="form-row">
|
||||||
|
<span>Von:</span>
|
||||||
|
<input type="date" class="form-input" bind:value={fromDate} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="form-row">
|
||||||
|
<span>Bis:</span>
|
||||||
|
<input type="date" class="form-input" bind:value={toDate} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedTab === 1}
|
||||||
|
<div>
|
||||||
|
<label class="form-row">
|
||||||
|
<span>Kalendar Jahr:</span>
|
||||||
|
<select class="form-input" bind:value={selectedYear}>
|
||||||
|
{#each generateYears() as year}
|
||||||
|
<option value={year}>{year}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-3">
|
||||||
|
<button
|
||||||
|
type="reset"
|
||||||
|
class="mr-5 px-2 py-1 bg-red-500 rounded-2xl shadow-md"
|
||||||
|
onclick={() => {
|
||||||
|
modal.close();
|
||||||
|
|
||||||
|
fromDate = undefined;
|
||||||
|
toDate = undefined;
|
||||||
|
}}>Abbrechen</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="px-2 py-1 bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
|
||||||
|
>Export CSV</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@reference "../app.css";
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
@apply px-4 py-2 rounded-t-lg bg-indigo-600 hover:bg-indigo-700 font-medium border-b-2 border-transparent cursor-pointer transition-colors duration-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-active {
|
||||||
|
@apply px-4 py-2 bg-indigo-500 font-semibold border-b-2 border-blue-600 shadow-sm cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
@apply flex justify-between my-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input {
|
||||||
|
@apply ml-20;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,16 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { type IDMap } from "./IDMapping";
|
||||||
import { fetchMapping, type IDMap } from "./IDMapping";
|
|
||||||
let data: IDMap | undefined = $state();
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
onEdit,
|
onEdit,
|
||||||
}: { onEdit?: (id: string, firstName: string, lastName: string) => void } =
|
data,
|
||||||
$props();
|
}: {
|
||||||
|
onEdit?: (id: string, firstName: string, lastName: string) => void;
|
||||||
export async function reloadData() {
|
data: IDMap;
|
||||||
data = await fetchMapping();
|
} = $props();
|
||||||
}
|
|
||||||
|
|
||||||
let rows = $derived(
|
let rows = $derived(
|
||||||
data
|
data
|
||||||
@@ -44,15 +41,8 @@
|
|||||||
if (sortKey !== key) return "";
|
if (sortKey !== key) return "";
|
||||||
return sortDirection === "asc" ? "▲" : "▼";
|
return sortDirection === "asc" ? "▲" : "▼";
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
await reloadData();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data == null}
|
|
||||||
Loading...
|
|
||||||
{:else}
|
|
||||||
<div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto">
|
<div class="bg-indigo-500 py-2 rounded-2xl overflow-x-auto">
|
||||||
<table class="px-10">
|
<table class="px-10">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -106,7 +96,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="css" scoped>
|
<style lang="css" scoped>
|
||||||
@reference "../app.css";
|
@reference "../app.css";
|
||||||
|
|||||||
103
web/src/lib/csv.ts
Normal file
103
web/src/lib/csv.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
interface CSVOptions {
|
||||||
|
delimiter?: string;
|
||||||
|
headerOrder?: string[];
|
||||||
|
eol?: string;
|
||||||
|
includeBOM?: boolean;
|
||||||
|
nullString?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RowObject = Record<string, any>;
|
||||||
|
type InputRows = RowObject[];
|
||||||
|
|
||||||
|
export function generateCSVString(input: InputRows, opts: CSVOptions = {}): string {
|
||||||
|
const {
|
||||||
|
delimiter = ";",
|
||||||
|
headerOrder,
|
||||||
|
eol = "\r\n",
|
||||||
|
includeBOM = true,
|
||||||
|
nullString = "",
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
// Check if the value need quoting
|
||||||
|
// Usually not needed in our use case but still in case
|
||||||
|
const needsQuoting = (s: string) =>
|
||||||
|
s.includes(delimiter) || s.includes('"') || s.includes("\n") || s.includes("\r") || /^\s|\s$/.test(s);
|
||||||
|
|
||||||
|
// Transform the value of a cell in a SAFE string
|
||||||
|
const escapeCell = (raw: any): string => {
|
||||||
|
if (raw === null || raw === undefined) return nullString;
|
||||||
|
|
||||||
|
let s = stringify(raw);
|
||||||
|
|
||||||
|
// Replace quotes
|
||||||
|
if (s.includes('"')) s = s.replace(/"/g, '""');
|
||||||
|
|
||||||
|
return needsQuoting(s) ? `"${s}"` : s;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transform the value of a cell into a string
|
||||||
|
function stringify(v: any): string {
|
||||||
|
if (v === null || v === undefined) return nullString;
|
||||||
|
if (v instanceof Date) return v.toLocaleDateString();
|
||||||
|
if (typeof v === "boolean") return v ? "X" : "";
|
||||||
|
|
||||||
|
if (typeof v === "object") {
|
||||||
|
// CHeck if array and join with "|"
|
||||||
|
if (Array.isArray(v)) return v.map(item => (item === null || item === undefined ? nullString : String(item))).join("|");
|
||||||
|
|
||||||
|
// If all fails parse it via json
|
||||||
|
// Should also not happen in our use case
|
||||||
|
try { return JSON.stringify(v); } catch { return String(v); }
|
||||||
|
}
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objs = (input as RowObject[]) || [];
|
||||||
|
|
||||||
|
// Derive headers in the order keys are first encountered (fixed: no reduce)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const derivedOrder: string[] = [];
|
||||||
|
for (const obj of objs) {
|
||||||
|
if (!obj || typeof obj !== "object") continue;
|
||||||
|
for (const k of Object.keys(obj)) {
|
||||||
|
if (!seen.has(k)) {
|
||||||
|
seen.add(k);
|
||||||
|
derivedOrder.push(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply headerOrder if provided: put listed columns first (in the order provided),
|
||||||
|
// then append the remaining derived headers in their derived order.
|
||||||
|
let finalHeaders = derivedOrder.slice();
|
||||||
|
if (Array.isArray(headerOrder) && headerOrder.length > 0) {
|
||||||
|
const headSet = new Set(headerOrder);
|
||||||
|
const first = headerOrder.filter(h => seen.has(h)); // keep only headers that actually exist
|
||||||
|
const rest = derivedOrder.filter(h => !headSet.has(h));
|
||||||
|
finalHeaders = first.concat(rest);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowsOut: string[] = [];
|
||||||
|
|
||||||
|
if (finalHeaders.length > 0) {
|
||||||
|
rowsOut.push(finalHeaders.map(h => escapeCell(h)).join(delimiter));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse every row
|
||||||
|
for (const obj of objs) {
|
||||||
|
|
||||||
|
// Check if obj is null or somthing else we can't convert
|
||||||
|
if (!obj || typeof obj !== "object") {
|
||||||
|
|
||||||
|
// produce empty row with same number of columns
|
||||||
|
rowsOut.push(finalHeaders.map(() => escapeCell(null)).join(delimiter));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For every header check if a value on our row exist and print it
|
||||||
|
const row = finalHeaders.map(col => escapeCell(col in obj ? (obj as any)[col] : null)).join(delimiter);
|
||||||
|
rowsOut.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (includeBOM ? "\uFEFF" : "") + rowsOut.join(eol);
|
||||||
|
}
|
||||||
8
web/src/lib/downloadBlob.ts
Normal file
8
web/src/lib/downloadBlob.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function downloadBlob(filename: string, content: string, mimeType = "text/plain") {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
53
web/src/lib/exporting.ts
Normal file
53
web/src/lib/exporting.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { generateCSVString } from "./csv";
|
||||||
|
import { dayToDate, fetchDay, fetchDays, type AttendanceDay, type Day } from "./Day";
|
||||||
|
import type { IDMap } from "./IDMapping";
|
||||||
|
|
||||||
|
interface CSVRow {
|
||||||
|
ID: string
|
||||||
|
Vorname: string
|
||||||
|
Nachname: string
|
||||||
|
[key: string]: string | boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareRows(mapping: IDMap, days: AttendanceDay[]): CSVRow[] {
|
||||||
|
let csvData: CSVRow[] = [];
|
||||||
|
|
||||||
|
const allIDs = Object.keys(mapping);
|
||||||
|
|
||||||
|
for (const id of allIDs) {
|
||||||
|
const name = mapping[id];
|
||||||
|
const row: CSVRow = {
|
||||||
|
ID: id,
|
||||||
|
Vorname: name.first,
|
||||||
|
Nachname: name.last,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
const dayKey = dayToDate(day.date).toLocaleDateString();
|
||||||
|
row[dayKey] = day.ids.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
csvData.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return csvData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDays(from: Date, to: Date): Promise<AttendanceDay[]> {
|
||||||
|
const recordedDays: Day[] = await fetchDays(from, to);
|
||||||
|
let days: AttendanceDay[] = [];
|
||||||
|
|
||||||
|
for (const day of recordedDays) {
|
||||||
|
days.push(await fetchDay(day))
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCSVFile(from: Date, to: Date, mapping: IDMap): Promise<string> {
|
||||||
|
const days = await getDays(from, to);
|
||||||
|
const rows = prepareRows(mapping, days);
|
||||||
|
const csvString = generateCSVString(rows);
|
||||||
|
|
||||||
|
return csvString;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user