Compare commits

2 Commits

Author SHA1 Message Date
Philipp
a63aacdc4e added printfile for top 2025-10-01 00:57:44 +02:00
Philipp
bc4dc20012 fixed error with LED 2025-09-30 18:51:32 +02:00
58 changed files with 1932 additions and 3986 deletions

View File

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

1
.gitattributes vendored
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

942
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,45 +12,63 @@ bench = false
[dependencies]
esp-bootloader-esp-idf = "0.1.0"
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"] }
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",
] }
critical-section = "1.2.0"
log = { version = "0.4", features = ["release_max_level_info"]}
static_cell = { version = "2.1.1", features = ["nightly"] }
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 }
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" ] }
static_cell = { version = "2.1.0", features = ["nightly"] }
esp-println = { version = "0.15.0", features = ["esp32c6", "log-04"] }
log = { version = "0.4" }
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"] }
dir-embed = "0.3.0"
serde = { version = "1.0.219", default-features = false, features = ["derive", "alloc"] }
serde_json = { version = "1.0.143", default-features = false, features = ["alloc"]}
picoserve = { version = "0.16.0", features = ["embassy", "log"] }
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"] }
embedded-sdmmc = "0.8.0"
embedded-hal-bus = "0.3.0"
thiserror = { version = "2.0.17", default-features = false }
serde_json = { version = "1.0.143", default-features = false, features = ["alloc"]}
[profile.dev]
# Rust debug is too slow.

View File

@@ -1,6 +1,32 @@
# fw-anwesenheit
![PXL_20251004_141110955 MP (1)](https://github.com/user-attachments/assets/afffd664-507d-439a-a428-2477b2fe4de2)
# Setup
![PXL_20251001_103957322 MP~2](https://github.com/user-attachments/assets/8bc182df-a93d-4923-b8d8-88b56d1aa441)
In order to use the LED we need to enable the SPI interface on the Rpi.
You can enable it by running `sudo raspi-config`, or by manually adding `dtparam=spi=on` to `/boot/firmware/config.txt`.
Enable PWM -> add dtoverlay=pwm to /boot/config.txt
I²C fpr RTC `sudo raspi-config` -> interface -> enable I²C
# Config
Flags:
`--error` or `-e`: Enters error state. The LED turns red and the hotspot is activated. This state gets called from systemd if the service is in a failure state.
Environment variables:
- `PM3_BIN`: Path to the pm3 binary. Seach in path if not set. Can also be set to the `pm3_mock.sh` for testing.
- `LOG_LEVEL`: Can be set to either "debug","warn","error","trace" or "info". Defaults to "warn" in production.
- `HTTP_PORT`: What port to listen on. Defaults to 80.
- `HOTSPOT_IDS`: A semicolon seperated list of ids to activate the hotspot with e.g. `578B5DF2;c1532b57`.
- `HOTSPOT_SSID`: Set the hotspot ssid. Defaults to "fwa".
- `HOTSPOT_PW`: Set the hotspot password. Default to "a9LG2kUVrsRRVUo1". Recommended to change.
Systemd:
The service is run as a systemd service. There are two service `fwa.service` and `fwa-fail.service`. They read their config
from a env file located at `/etc/fwa.env`. See example [env file](service/fwa.env).
# Building
Run `make package` to create `.deb` file. [Cross](https://github.com/cross-rs/cross) is used for building the rust code.

0
pcb/FETCH_HEAD Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"board": {
"active_layer": 7,
"active_layer": 6,
"active_layer_preset": "",
"auto_track_width": true,
"hidden_netclasses": [],
@@ -49,11 +49,7 @@
"conflict_shadows",
"shapes"
],
<<<<<<< HEAD
"visible_layers": "00000000_00000000_0fffffff_fffff8aa",
=======
"visible_layers": "00000000_00000000_0fffffff_fffff8ab",
>>>>>>> 15c64e4 (updated enclousure top 3mf)
"visible_layers": "00000000_00000000_0fffffff_fffff8ef",
"zone_display_mode": 0
},
"git": {

View File

@@ -6157,7 +6157,7 @@
)
)
(symbol
(lib_id "power:+3.3V")
(lib_id "power:+5V")
(at 158.75 95.25 270)
(unit 1)
(exclude_from_sim no)
@@ -6165,8 +6165,8 @@
(on_board yes)
(dnp no)
(fields_autoplaced yes)
(uuid "2ae7a711-ee06-4c71-80b4-273d6f6d7ce6")
(property "Reference" "#PWR015"
(uuid "2964c836-6055-454e-b889-14ec73d51d79")
(property "Reference" "#PWR06"
(at 154.94 95.25 0)
(effects
(font
@@ -6175,7 +6175,7 @@
(hide yes)
)
)
(property "Value" "+3.3V"
(property "Value" "+5V"
(at 162.56 95.2499 90)
(effects
(font
@@ -6202,7 +6202,7 @@
(hide yes)
)
)
(property "Description" "Power symbol creates a global label with name \"+3.3V\""
(property "Description" "Power symbol creates a global label with name \"+5V\""
(at 158.75 95.25 0)
(effects
(font
@@ -6212,12 +6212,12 @@
)
)
(pin "1"
(uuid "04891225-ec31-465b-8d9c-5b738e413865")
(uuid "4da695c4-c5ff-46c5-b130-7514e0da8e01")
)
(instances
(project "fw-anwesenheit"
(project ""
(path "/ccbf1fda-befd-42da-bcb2-5d3829184012"
(reference "#PWR015")
(reference "#PWR06")
(unit 1)
)
)

Binary file not shown.

View File

@@ -1,213 +1,213 @@
P CODE 00
P UNITS CUST 0
P arrayDim N
317GND VIA MD0118PA00X+035433Y-045241X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+047750Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+046550Y-043700X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+046950Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+044450Y-033050X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043600Y-040300X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050950Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038800Y-048850X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+034150Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+040550Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+044550Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+040120Y-047880X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048850Y-037100X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+049500Y-036550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+036500Y-038600X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+042300Y-042500X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+040900Y-042500X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048750Y-038400X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+041150Y-049250X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038800Y-049100X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+034850Y-040600X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+051100Y-045600X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+047150Y-036350X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048100Y-050150X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+034950Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+033051Y-044472X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043600Y-041800X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+044650Y-036950X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+046340Y-045450X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038450Y-048600X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+047750Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+049600Y-045500X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050787Y-045776X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+034050Y-039300X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+049350Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038950Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+042850Y-047400X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038600Y-041300X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050500Y-045950X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048100Y-049850X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050950Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+037200Y-048900X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+035750Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038600Y-041800X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+033200Y-049950X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+049950Y-038400X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050150Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+033350Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+044550Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043600Y-039800X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043600Y-041300X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+042950Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+047250Y-039150X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+046340Y-044709X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+041350Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+041200Y-038450X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048400Y-049850X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+037350Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+037350Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050750Y-036850X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+032550Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+037550Y-046600X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+045450Y-036150X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+046950Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048420Y-050150X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038950Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043850Y-042500X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038150Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+045350Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+046150Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038100Y-048600X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+046650Y-039000X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043750Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048550Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+045350Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043750Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050150Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+051100Y-045950X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+046150Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038800Y-048600X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048050Y-039000X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043500Y-045350X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048550Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+040550Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+036550Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+049350Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050650Y-033050X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+041350Y-044650X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+042150Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038600Y-040300X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+043600Y-040800X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038100Y-048850X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038150Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+041350Y-044300X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+047250Y-046950X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+042950Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+041526Y-041339X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+050500Y-045600X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048850Y-037400X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+039750Y-031750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+037645Y-038800X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+039750Y-050550X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038450Y-049100X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038600Y-040800X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+041640Y-047740X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038450Y-048850X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+048228Y-046885X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+038100Y-049100X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+050480Y-044960X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038820Y-050080X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+050480Y-044600X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038460Y-050080X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+051040Y-044620X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+047360Y-044720X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038160Y-049860X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+037200Y-049500X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038820Y-049640X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+034451Y-049801X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+051040Y-044980X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038820Y-049860X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+047150Y-043700X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038460Y-049640X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038460Y-049860X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+049600Y-044900X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+050780Y-044780X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038160Y-050080X0177Y0000R000S-2119174445
317+5V VIA MD0118PA00X+038160Y-049640X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+041050Y-047400X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+032874Y-036304X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+049606Y-040320X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038255Y-038800X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+040400Y-037150X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+047244Y-047638X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+035595Y-038976X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+048000Y-037450X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+046457Y-040320X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+043350Y-048650X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+040450Y-048550X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038800Y-037150X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+046350Y-048650X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+048150Y-044050X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+041300Y-047400X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+048031Y-040320X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038450Y-047600X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+048000Y-037100X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+041300Y-047150X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+046600Y-048650X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038800Y-047850X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+041050Y-047150X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+048100Y-038400X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+050394Y-040354X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+043650Y-048650X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038450Y-048100X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038450Y-047850X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038800Y-048100X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+045669Y-040320X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+040350Y-038800X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+032750Y-043500X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+035595Y-038189X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+048819Y-040320X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+047244Y-040320X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038800Y-047600X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038100Y-048100X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+048228Y-047603X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038100Y-047850X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+036100Y-050350X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+038100Y-047600X0177Y0000R000S-2119174445
317+3.3V VIA MD0118PA00X+048150Y-045550X0177Y0000R000S-2119174445
317NET-(R3-PAD1) VIA MD0118PA00X+035433Y-042948X0177Y0000R000S-2119174445
317/SD_DECT VIA MD0118PA00X+050138Y-037264X0177Y0000R000S-2119174445
317/SD_DECT VIA MD0118PA00X+044800Y-049900X0177Y0000R000S-2119174445
317/MISO VIA MD0118PA00X+038450Y-045800X0177Y0000R000S-2119174445
317/MISO VIA MD0118PA00X+049272Y-037264X0177Y0000R000S-2119174445
317/SPI_SCL VIA MD0118PA00X+048400Y-037264X0177Y0000R000S-2119174445
317/SPI_SCL VIA MD0118PA00X+038450Y-044900X0177Y0000R000S-2119174445
317/SPI_SCL VIA MD0118PA00X+048031Y-039601X0177Y0000R000S-2119174445
317/MOSI VIA MD0118PA00X+037500Y-044550X0177Y0000R000S-2119174445
317/MOSI VIA MD0118PA00X+047539Y-037264X0177Y0000R000S-2119174445
317/SPI_CS VIA MD0118PA00X+046250Y-046000X0177Y0000R000S-2119174445
317/SPI_CS VIA MD0118PA00X+047106Y-037264X0177Y0000R000S-2119174445
317/BUZZER VIA MD0118PA00X+044450Y-046650X0177Y0000R000S-2119174445
317-GPIO16_D6_TX) VIA MD0118PA00X+036480Y-047320X0177Y0000R000S-2119174445
317-GPIO16_D6_TX) VIA MD0118PA00X+045700Y-046650X0177Y0000R000S-2119174445
317/I2C_SCL VIA MD0118PA00X+043000Y-038300X0177Y0000R000S-2119174445
317/I2C_SCL VIA MD0118PA00X+045600Y-046100X0177Y0000R000S-2119174445
317/I2C_SCL VIA MD0118PA00X+043225Y-043575X0177Y0000R000S-2119174445
317/I2C_SCL VIA MD0118PA00X+034680Y-046240X0177Y0000R000S-2119174445
317/I2C_SDA VIA MD0118PA00X+042900Y-044100X0177Y0000R000S-2119174445
317/I2C_SDA VIA MD0118PA00X+042900Y-038800X0177Y0000R000S-2119174445
317/I2C_SDA VIA MD0118PA00X+044812Y-045842X0177Y0000R000S-2119174445
317/I2C_SDA VIA MD0118PA00X+033300Y-046050X0177Y0000R000S-2119174445
317/DAT2 VIA MD0118PA00X+046673Y-037264X0177Y0000R000S-2119174445
317/DAT1 VIA MD0118PA00X+049705Y-037264X0177Y0000R000S-2119174445
317NET-(J1-PIN_1) VIA MD0118PA00X+047100Y-049800X0177Y0000R000S-2119174445
317NET-(J1-PIN_1) VIA MD0118PA00X+047100Y-050000X0177Y0000R000S-2119174445
317NET-(J1-PIN_1) VIA MD0118PA00X+047100Y-050200X0177Y0000R000S-2119174445
317ET-(U3-~{RST}) VIA MD0118PA00X+033850Y-038500X0177Y0000R000S-2119174445
317ET-(U3-~{RST}) VIA MD0118PA00X+037750Y-039800X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+042450Y-045000X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+045800Y-049600X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+042250Y-045000X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+042250Y-044750X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+042450Y-044450X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+046050Y-049350X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+042250Y-044450X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+045850Y-049400X0177Y0000R000S-2119174445
317NET-(JP1-A) VIA MD0118PA00X+042450Y-044750X0177Y0000R000S-2119174445
317GND VIA MD0118PA00X+035433Y-045241X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+047750Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+046550Y-043700X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+046950Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+044450Y-033050X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043600Y-040300X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050950Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038800Y-048850X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+034150Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+040550Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+044550Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+040120Y-047880X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048850Y-037100X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+049500Y-036550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+036500Y-038600X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+042300Y-042500X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+040900Y-042500X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048750Y-038400X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+041150Y-049250X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038800Y-049100X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+034850Y-040600X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+051100Y-045600X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+047150Y-036350X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048100Y-050150X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+034950Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+033051Y-044472X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043600Y-041800X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+044650Y-036950X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+046340Y-045450X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038450Y-048600X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+047750Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+049600Y-045500X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050787Y-045776X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+034050Y-039300X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+049350Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038950Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+042850Y-047400X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038600Y-041300X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050500Y-045950X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048100Y-049850X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050950Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+037200Y-048900X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+035750Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038600Y-041800X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+033200Y-049950X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+049950Y-038400X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050150Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+033350Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+044550Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043600Y-039800X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043600Y-041300X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+042950Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+047250Y-039150X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+046340Y-044709X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+041350Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+041200Y-038450X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048400Y-049850X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+037350Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+037350Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050750Y-036850X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+032550Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+037550Y-046600X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+045450Y-036150X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+046950Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048420Y-050150X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038950Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043850Y-042500X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038150Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+045350Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+046150Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038100Y-048600X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+046650Y-039000X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043750Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048550Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+045350Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043750Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050150Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+051100Y-045950X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+046150Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038800Y-048600X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048050Y-039000X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043500Y-045350X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048550Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+040550Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+036550Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+049350Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050650Y-033050X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+041350Y-044650X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+042150Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038600Y-040300X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+043600Y-040800X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038100Y-048850X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038150Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+041350Y-044300X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+047250Y-046950X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+042950Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+041526Y-041339X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+050500Y-045600X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048850Y-037400X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+039750Y-031750X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+037645Y-038800X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+039750Y-050550X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038450Y-049100X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038600Y-040800X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+041640Y-047740X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038450Y-048850X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+048228Y-046885X0177Y0000R000S1628436659
317GND VIA MD0118PA00X+038100Y-049100X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+050480Y-044960X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038820Y-050080X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+050480Y-044600X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038460Y-050080X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+051040Y-044620X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+047360Y-044720X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038160Y-049860X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+037200Y-049500X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038820Y-049640X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+034451Y-049801X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+051040Y-044980X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038820Y-049860X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+047150Y-043700X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038460Y-049640X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038460Y-049860X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+049600Y-044900X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+050780Y-044780X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038160Y-050080X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+038160Y-049640X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+041050Y-047400X0177Y0000R000S1628436659
317+5V VIA MD0118PA00X+032874Y-036304X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+049606Y-040320X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038255Y-038800X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+040400Y-037150X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+047244Y-047638X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+035595Y-038976X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+048000Y-037450X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+046457Y-040320X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+043350Y-048650X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+040450Y-048550X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038800Y-037150X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+046350Y-048650X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+048150Y-044050X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+041300Y-047400X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+048031Y-040320X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038450Y-047600X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+048000Y-037100X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+041300Y-047150X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+046600Y-048650X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038800Y-047850X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+041050Y-047150X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+048100Y-038400X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+050394Y-040354X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+043650Y-048650X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038450Y-048100X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038450Y-047850X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038800Y-048100X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+045669Y-040320X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+040350Y-038800X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+032750Y-043500X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+035595Y-038189X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+048819Y-040320X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+047244Y-040320X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038800Y-047600X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038100Y-048100X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+048228Y-047603X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038100Y-047850X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+036100Y-050350X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+038100Y-047600X0177Y0000R000S1628436659
317+3.3V VIA MD0118PA00X+048150Y-045550X0177Y0000R000S1628436659
317NET-(R3-PAD1) VIA MD0118PA00X+035433Y-042948X0177Y0000R000S1628436659
317/SD_DECT VIA MD0118PA00X+050138Y-037264X0177Y0000R000S1628436659
317/SD_DECT VIA MD0118PA00X+044800Y-049900X0177Y0000R000S1628436659
317/MISO VIA MD0118PA00X+038450Y-045800X0177Y0000R000S1628436659
317/MISO VIA MD0118PA00X+049272Y-037264X0177Y0000R000S1628436659
317/SPI_SCL VIA MD0118PA00X+048400Y-037264X0177Y0000R000S1628436659
317/SPI_SCL VIA MD0118PA00X+038450Y-044900X0177Y0000R000S1628436659
317/SPI_SCL VIA MD0118PA00X+048031Y-039601X0177Y0000R000S1628436659
317/MOSI VIA MD0118PA00X+037500Y-044550X0177Y0000R000S1628436659
317/MOSI VIA MD0118PA00X+047539Y-037264X0177Y0000R000S1628436659
317/SPI_CS VIA MD0118PA00X+046250Y-046000X0177Y0000R000S1628436659
317/SPI_CS VIA MD0118PA00X+047106Y-037264X0177Y0000R000S1628436659
317/BUZZER VIA MD0118PA00X+044450Y-046650X0177Y0000R000S1628436659
317-GPIO16_D6_TX) VIA MD0118PA00X+036480Y-047320X0177Y0000R000S1628436659
317-GPIO16_D6_TX) VIA MD0118PA00X+045700Y-046650X0177Y0000R000S1628436659
317/I2C_SCL VIA MD0118PA00X+043000Y-038300X0177Y0000R000S1628436659
317/I2C_SCL VIA MD0118PA00X+045600Y-046100X0177Y0000R000S1628436659
317/I2C_SCL VIA MD0118PA00X+043225Y-043575X0177Y0000R000S1628436659
317/I2C_SCL VIA MD0118PA00X+034680Y-046240X0177Y0000R000S1628436659
317/I2C_SDA VIA MD0118PA00X+042900Y-044100X0177Y0000R000S1628436659
317/I2C_SDA VIA MD0118PA00X+042900Y-038800X0177Y0000R000S1628436659
317/I2C_SDA VIA MD0118PA00X+044812Y-045842X0177Y0000R000S1628436659
317/I2C_SDA VIA MD0118PA00X+033300Y-046050X0177Y0000R000S1628436659
317/DAT2 VIA MD0118PA00X+046673Y-037264X0177Y0000R000S1628436659
317/DAT1 VIA MD0118PA00X+049705Y-037264X0177Y0000R000S1628436659
317NET-(J1-PIN_1) VIA MD0118PA00X+047100Y-049800X0177Y0000R000S1628436659
317NET-(J1-PIN_1) VIA MD0118PA00X+047100Y-050000X0177Y0000R000S1628436659
317NET-(J1-PIN_1) VIA MD0118PA00X+047100Y-050200X0177Y0000R000S1628436659
317ET-(U3-~{RST}) VIA MD0118PA00X+033850Y-038500X0177Y0000R000S1628436659
317ET-(U3-~{RST}) VIA MD0118PA00X+037750Y-039800X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+042450Y-045000X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+045800Y-049600X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+042250Y-045000X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+042250Y-044750X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+042450Y-044450X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+046050Y-049350X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+042250Y-044450X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+045850Y-049400X0177Y0000R000S1628436659
317NET-(JP1-A) VIA MD0118PA00X+042450Y-044750X0177Y0000R000S1628436659
327GND C5 -1 A01X+048730Y-038386X0354Y0374R180S2
327+3.3V C5 -2 A01X+048120Y-038386X0354Y0374R180S2
327/LED_DIN J3 -1 A01X+050700Y-043800X0984Y0669R000S2
@@ -239,7 +239,7 @@ P arrayDim N
327+3.3V U1 -24 A01X+041178Y-047266X0669Y0000R180S2
327GND TP3 -1 A01X+032677Y-050197X0591Y0000R000S2
327NET-(D2-K) D2 -1 A01X+032874Y-036924X0344Y0374R270S2
327+3.3V D2 -2 A01X+032874Y-036304X0344Y0374R270S2
327+5V D2 -2 A01X+032874Y-036304X0344Y0374R270S2
327+3.3V R14 -1 A01X+047244Y-040320X0384Y0374R270S2
327/MOSI R14 -2 A01X+047244Y-039601X0384Y0374R270S2
327+3.3V R13 -1 A01X+048031Y-040320X0384Y0374R270S2

View File

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

0
src/drivers/buzzer.rs Normal file
View File

9
src/drivers/fram.rs Normal file
View File

@@ -0,0 +1,9 @@
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

44
src/drivers/mock.rs Normal file
View File

@@ -0,0 +1,44 @@
use anyhow::Result;
use log::debug;
use std::time::Duration;
use tokio::time::sleep;
use crate::hardware::{Buzzer, Hotspot, StatusLed};
pub struct MockBuzzer {}
impl Buzzer for MockBuzzer {
async fn modulated_tone(&mut self, frequency_hz: f64, duration: Duration) -> Result<()> {
debug!("MockBuzzer: modulte tone: {frequency_hz} Hz");
sleep(duration).await;
Ok(())
}
}
pub struct MockLed {}
impl StatusLed for MockLed {
fn turn_off(&mut self) -> Result<()> {
debug!("Turn mock LED off");
Ok(())
}
fn turn_on(&mut self, color: rgb::RGB8) -> Result<()> {
debug!("Turn mock LED on to: {color}");
Ok(())
}
}
pub struct MockHotspot {}
impl Hotspot for MockHotspot {
async fn enable_hotspot(&self) -> Result<()> {
debug!("Mockhotspot: Enable hotspot");
Ok(())
}
async fn disable_hotspot(&self) -> Result<()> {
debug!("Mockhotspot: Disable hotspot");
Ok(())
}
}

View File

@@ -1,6 +1,6 @@
use embassy_time::{Duration, Timer};
use esp_hal::{Async, uart::Uart};
use log::{debug, info, warn};
use log::{debug, info};
use crate::TallyPublisher;
@@ -17,15 +17,7 @@ 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}");
match extract_id(&uart_buffer) {
Some(read) => {
chan.publish(read.try_into().unwrap()).await;
}
None => {
warn!("Invalid read from the RFID reader");
}
};
chan.publish([1, 0, 2, 5, 0, 8, 12, 15]).await;
}
Err(e) => {
log::error!("Error reading from UART: {e}");
@@ -34,35 +26,3 @@ 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,6 +1,6 @@
use chrono::{TimeZone, Utc};
use ds3231::{
Config, DS3231, InterruptControl, Oscillator, SquareWaveFrequency, TimeRepresentation,
Config, DS3231, DS3231Error, InterruptControl, Oscillator, SquareWaveFrequency,
TimeRepresentation,
};
use esp_hal::{
Async,
@@ -9,6 +9,7 @@ use esp_hal::{
use log::{debug, error, info};
use crate::{FEEDBACK_STATE, drivers, feedback};
use chrono::{TimeZone, Utc};
include!(concat!(env!("OUT_DIR"), "/build_time.rs"));
@@ -29,7 +30,10 @@ impl RTCClock {
pub async fn get_time(&mut self) -> u64 {
match self.dev.datetime().await {
Ok(datetime) => datetime.and_utc().timestamp() as u64,
Ok(datetime) => {
let utc_time = datetime.and_utc().timestamp() as u64;
utc_time
}
Err(e) => {
FEEDBACK_STATE.signal(feedback::FeedbackState::Error);
error!("Failed to read RTC datetime: {:?}", e);
@@ -52,25 +56,22 @@ pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>>
square_wave_frequency: SquareWaveFrequency::Hz1,
interrupt_control: InterruptControl::Interrupt, // Enable interrupt mode
battery_backed_square_wave: false,
oscillator_enable: Oscillator::Enabled,
oscillator_enable: Oscillator::Disabled,
};
match rtc.configure(&rtc_config).await {
Ok(_) => info!("DS3231 configured successfully"),
Err(e) => {
error!("Failed to configure DS3231: {:?}", e);
error!("DS3231 configuration failed");
FEEDBACK_STATE.signal(feedback::FeedbackState::Error);
info!("Failed to configure DS3231: {:?}", e);
panic!("DS3231 configuration failed");
}
}
if rtc.datetime().await.unwrap() < naive_dt {
rtc.set_datetime(&naive_dt).await.unwrap_or_else(|e| {
FEEDBACK_STATE.signal(feedback::FeedbackState::Error);
error!("Failed to set RTC datetime: {:?}", e);
});
info!("RTC datetime set to: {}", naive_dt);
}
rtc.set_datetime(&naive_dt).await.unwrap_or_else(|e| {
FEEDBACK_STATE.signal(feedback::FeedbackState::Error);
error!("Failed to set RTC datetime: {:?}", e);
});
info!("RTC datetime set to: {}", naive_dt);
match rtc.status().await {
Ok(mut status) => {
@@ -84,7 +85,12 @@ pub async fn rtc_config(i2c: I2c<'static, Async>) -> DS3231<I2c<'static, Async>>
Err(e) => info!("Failed to read status: {:?}", e),
}
info!("RTC time is: {:?}", rtc.datetime().await.unwrap());
rtc
}
pub async fn read_rtc_time<'a>(
rtc: &'a mut DS3231<I2c<'static, Async>>,
) -> Result<u64, DS3231Error<esp_hal::i2c::master::Error>> {
let timestamp_result = rtc.datetime().await?;
Ok(timestamp_result.and_utc().timestamp() as u64)
}

View File

@@ -1,12 +1,12 @@
use embassy_time::{Duration, Timer};
use esp_hal::gpio::Output;
use embassy_time::{Delay, Duration, Timer};
use esp_hal::{delay, gpio::Output, peripherals, rmt::ConstChannelAccess};
use esp_hal_smartled::SmartLedsAdapterAsync;
use log::debug;
use init::hardware;
use log::{debug, error, info};
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,10 +25,14 @@ const LED_LEVEL: u8 = 255;
#[embassy_executor::task]
pub async fn feedback_task(
mut led: SmartLedsAdapterAsync<'static, { hardware::LED_BUFFER_SIZE }>,
mut buzzer: Output<'static>,
mut led: SmartLedsAdapterAsync<
ConstChannelAccess<esp_hal::rmt::Tx, 0>,
{ init::hardware::LED_BUFFER_SIZE },
>,
buzzer: peripherals::GPIO21<'static>,
) {
debug!("Starting feedback task");
let mut buzzer = init::hardware::setup_buzzer(buzzer);
loop {
let feedback_state = FEEDBACK_STATE.wait().await;
match feedback_state {
@@ -43,12 +47,6 @@ 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(
@@ -104,8 +102,6 @@ pub async fn feedback_task(
buzzer.set_high();
Timer::after(Duration::from_millis(100)).await;
buzzer.set_low();
Timer::after(Duration::from_secs(2)).await;
led.write(brightness(
[BLACK; init::hardware::NUM_LEDS].into_iter(),
LED_LEVEL,
@@ -123,7 +119,7 @@ pub async fn feedback_task(
}
FeedbackState::Idle => {
led.write(brightness(
[BLACK; init::hardware::NUM_LEDS].into_iter(),
[GREEN; init::hardware::NUM_LEDS].into_iter(),
LED_LEVEL,
))
.await
@@ -133,3 +129,106 @@ 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,16 +1,22 @@
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::Blocking;
use esp_hal::delay::Delay;
use esp_hal::gpio::AnyPin;
use esp_hal::gpio::{Input, InputConfig};
use esp_hal::i2c::master::Config;
use esp_hal::peripherals::{
GPIO1, GPIO16, GPIO17, GPIO18, GPIO19, GPIO20, GPIO21, GPIO22, GPIO23, I2C0, RMT, SPI2, UART1,
GPIO0, GPIO1, GPIO16, GPIO17, GPIO18, GPIO19, GPIO20, GPIO21, GPIO22, GPIO23, I2C0, RMT, SPI2,
UART1,
};
use esp_hal::rmt::Rmt;
use esp_hal::rmt::{ConstChannelAccess, Rmt};
use esp_hal::spi::master::{Config as Spi_config, Spi};
use esp_hal::system::software_reset;
use esp_hal::Blocking;
use esp_hal::time::Rate;
use esp_hal::timer::timg::TimerGroup;
use esp_hal::{
@@ -18,19 +24,20 @@ use esp_hal::{
clock::CpuClock,
gpio::{Output, OutputConfig},
i2c::master::I2c,
timer::systimer::SystemTimer,
uart::Uart,
};
use esp_hal_smartled::{SmartLedsAdapterAsync, buffer_size_async};
use esp_println::dbg;
use esp_println::logger::init_logger;
use log::{debug, error};
use smart_leds::SmartLedsWriteAsync;
use smart_leds::brightness;
use smart_leds::colors::RED;
use thiserror::Error;
use log::{debug, error, info};
use crate::FEEDBACK_STATE;
use crate::init::network;
use crate::init::sd_card::{SDCardPersistence, setup_sdcard};
use crate::init::sd_card::setup_sdcard;
use crate::init::wifi;
use crate::store::AttendanceDay;
use crate::store::persistence::Persistence;
/*************************************************
* GPIO Pinout Xiao Esp32c6
@@ -49,137 +56,120 @@ use crate::init::wifi;
*
*************************************************/
pub const NUM_LEDS: usize = 1;
pub const LED_BUFFER_SIZE: usize = buffer_size_async(NUM_LEDS);
pub const NUM_LEDS: usize = 66;
pub const LED_BUFFER_SIZE: usize = NUM_LEDS * 25;
static SD_DET: Mutex<RefCell<Option<Input>>> = Mutex::new(RefCell::new(None));
#[panic_handler]
fn panic(info: &core::panic::PanicInfo) -> ! {
let delay = Delay::new();
error!("PANIC: {info}");
delay.delay(esp_hal::time::Duration::from_secs(30));
software_reset()
loop {
error!("PANIC: {info}");
}
}
esp_bootloader_esp_idf::esp_app_desc!();
#[derive(Error, Debug)]
pub enum HardwareInitError {
#[error("Failed to etup UART")]
Uart(#[from] esp_hal::uart::ConfigError),
pub async fn hardware_init(
spawner: &mut Spawner,
) -> (
Uart<'static, Async>,
Stack<'static>,
I2c<'static, Async>,
SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE>,
GPIO21<'static>,
GPIO0<'static>,
) {
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
#[error("Failed to setup I2C")]
I2C(#[from] esp_hal::i2c::master::ConfigError),
esp_alloc::heap_allocator!(size: 72 * 1024);
#[error("Failed to setup SPI")]
Spi(#[from] esp_hal::spi::master::ConfigError),
let timer0 = SystemTimer::new(peripherals.SYSTIMER);
esp_hal_embassy::init(timer0.alarm0);
#[error("Failed to setuo LED")]
Led(#[from] esp_hal::rmt::Error),
init_logger(log::LevelFilter::Debug);
#[error("Failed to setup wifi")]
Wifi(#[from] wifi::WifiError),
}
let timer1 = TimerGroup::new(peripherals.TIMG0);
let mut rng = esp_hal::rng::Rng::new(peripherals.RNG);
let network_seed = (rng.random() as u64) << 32 | rng.random() as u64;
pub struct AppHardware {
pub uart: Uart<'static, Async>,
pub network_stack: Stack<'static>,
pub i2c: I2c<'static, Async>,
pub buzzer: Output<'static>,
pub sd_present: AnyPin<'static>,
pub led: SmartLedsAdapterAsync<'static, LED_BUFFER_SIZE>,
pub sdcard: SDCardPersistence,
}
wifi::set_antenna_mode(peripherals.GPIO3, peripherals.GPIO14).await;
let interfaces = wifi::setup_wifi(timer1.timer0, rng, peripherals.WIFI, spawner);
let stack = network::setup_network(network_seed, interfaces.ap, spawner);
impl AppHardware {
pub async fn init(spawner: Spawner) -> Result<Self, HardwareInitError> {
let config = esp_hal::Config::default().with_cpu_clock(CpuClock::max());
let peripherals = esp_hal::init(config);
Timer::after(Duration::from_millis(1)).await;
esp_alloc::heap_allocator!(#[unsafe(link_section = ".dram2_uninit")] size: 65536);
let uart_device = setup_uart(peripherals.UART1, peripherals.GPIO16, peripherals.GPIO17);
let timg0 = TimerGroup::new(peripherals.TIMG0);
let sw_interrupt =
esp_hal::interrupt::software::SoftwareInterruptControl::new(peripherals.SW_INTERRUPT);
esp_rtos::start(timg0.timer0, sw_interrupt.software_interrupt0);
let i2c_device = setup_i2c(peripherals.I2C0, peripherals.GPIO22, peripherals.GPIO23);
init_logger(log::LevelFilter::Debug);
let mut sd_det_gpio = peripherals.GPIO0;
let mut led = setup_led(peripherals.RMT, peripherals.GPIO1)?;
let _ = led.write(brightness(
[RED; NUM_LEDS].into_iter(),
255,
))
.await;
let spi_bus = setup_spi(
peripherals.SPI2,
peripherals.GPIO19,
peripherals.GPIO20,
peripherals.GPIO18,
);
let rng = esp_hal::rng::Rng::new();
let network_seed = (rng.random() as u64) << 32 | rng.random() as u64;
let sd_cs_pin = Output::new(
peripherals.GPIO2,
esp_hal::gpio::Level::High,
OutputConfig::default(),
);
wifi::set_antenna_mode(peripherals.GPIO3, peripherals.GPIO14).await;
let interfaces = wifi::setup_wifi(peripherals.WIFI, spawner)?;
let network_stack = network::setup_network(network_seed, interfaces.ap, spawner);
let mut vol_mgr = setup_sdcard(spi_bus, sd_cs_pin);
Timer::after(Duration::from_millis(1)).await;
let buzzer_gpio = peripherals.GPIO21;
let uart_device = setup_uart(peripherals.UART1, peripherals.GPIO16, peripherals.GPIO17)?;
Timer::after(Duration::from_millis(500)).await;
let i2c_device = setup_i2c(peripherals.I2C0, peripherals.GPIO22, peripherals.GPIO23)?;
let led = setup_led(peripherals.RMT, peripherals.GPIO1);
let sd_det_gpio = peripherals.GPIO0;
debug!("hardware init done");
let spi_bus = setup_spi(
peripherals.SPI2,
peripherals.GPIO19,
peripherals.GPIO20,
peripherals.GPIO18,
)?;
let sd_cs_pin = Output::new(
peripherals.GPIO2,
esp_hal::gpio::Level::High,
OutputConfig::default(),
);
let vol_mgr = setup_sdcard(spi_bus, sd_cs_pin);
let buzzer_gpio = peripherals.GPIO21;
let buzzer = setup_buzzer(buzzer_gpio);
Timer::after(Duration::from_millis(500)).await;
debug!("hardware init done");
Ok(Self {
uart: uart_device,
network_stack,
i2c: i2c_device,
buzzer,
sd_present: sd_det_gpio.into(),
led,
sdcard: vol_mgr,
})
}
(
uart_device,
stack,
i2c_device,
led,
buzzer_gpio,
sd_det_gpio,
)
}
fn setup_uart(
uart1: UART1<'static>,
uart_tx: GPIO16<'static>,
uart_rx: GPIO17<'static>,
) -> Result<Uart<'static, Async>, esp_hal::uart::ConfigError> {
let uart_device = Uart::new(uart1, esp_hal::uart::Config::default().with_baudrate(9600))?;
Ok(uart_device.with_rx(uart_rx).with_tx(uart_tx).into_async())
) -> Uart<'static, Async> {
let uard_device = Uart::new(uart1, esp_hal::uart::Config::default().with_baudrate(9600));
match uard_device {
Ok(block) => block.with_rx(uart_rx).with_tx(uart_tx).into_async(),
Err(e) => {
error!("Failed to initialize UART: {e}");
panic!(); //TODO panic!
}
}
}
fn setup_i2c(
i2c0: I2C0<'static>,
sda: GPIO22<'static>,
scl: GPIO23<'static>,
) -> Result<I2c<'static, Async>, esp_hal::i2c::master::ConfigError> {
) -> I2c<'static, Async> {
debug!("init I2C");
let config = Config::default().with_frequency(Rate::from_khz(400));
let i2c = I2c::new(i2c0, config)?;
Ok(i2c.with_sda(sda).with_scl(scl).into_async())
let i2c = match I2c::new(i2c0, config) {
Ok(i2c) => i2c.with_sda(sda).with_scl(scl).into_async(),
Err(e) => {
error!("Failed to initialize I2C: {:?}", e);
panic!(); //TODO panic!
}
};
i2c
}
fn setup_spi(
@@ -187,34 +177,39 @@ fn setup_spi(
sck: GPIO19<'static>,
miso: GPIO20<'static>,
mosi: GPIO18<'static>,
) -> Result<Spi<'static, Blocking>, esp_hal::spi::master::ConfigError> {
let spi = Spi::new(spi2, Spi_config::default())?;
Ok(spi.with_sck(sck).with_miso(miso).with_mosi(mosi))
) -> Spi<'static, Blocking> {
let spi = match Spi::new(spi2, Spi_config::default()) {
Ok(spi) => spi.with_sck(sck).with_miso(miso).with_mosi(mosi),
Err(e) => panic!("Failed to initialize SPI: {:?}", e),
};
spi
}
pub fn setup_buzzer(buzzer_gpio: GPIO21<'static>) -> Output<'static> {
let config = esp_hal::gpio::OutputConfig::default()
.with_drive_strength(esp_hal::gpio::DriveStrength::_40mA);
let buzzer = Output::new(buzzer_gpio, esp_hal::gpio::Level::Low, config);
Output::new(buzzer_gpio, esp_hal::gpio::Level::Low, config)
buzzer
}
fn setup_led<'a>(
rmt: RMT<'a>,
led_gpio: GPIO1<'a>,
) -> Result<esp_hal_smartled::SmartLedsAdapterAsync<'a, LED_BUFFER_SIZE>, esp_hal::rmt::Error> {
fn setup_led(
rmt: RMT<'static>,
led_gpio: GPIO1<'static>,
) -> SmartLedsAdapterAsync<ConstChannelAccess<esp_hal::rmt::Tx, 0>, LED_BUFFER_SIZE> {
debug!("setup led");
let rmt: Rmt<'_, esp_hal::Async> = {
let frequency: Rate = Rate::from_mhz(80);
Rmt::new(rmt, frequency)
}?
}
.expect("Failed to initialize RMT")
.into_async();
let rmt_channel = rmt.channel0;
let rmt_buffer = [esp_hal::rmt::PulseCode::default(); LED_BUFFER_SIZE];
let rmt_buffer = [0_u32; buffer_size_async(NUM_LEDS)];
Ok(SmartLedsAdapterAsync::new(
rmt_channel,
led_gpio,
rmt_buffer,
))
let led: SmartLedsAdapterAsync<_, LED_BUFFER_SIZE> =
SmartLedsAdapterAsync::new(rmt_channel, led_gpio, rmt_buffer);
led
}

View File

@@ -1,41 +1,43 @@
use core::net::{Ipv4Addr, SocketAddrV4};
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
server::{Server, ServerOptions},
};
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
use core::{net::Ipv4Addr, str::FromStr};
use embassy_executor::Spawner;
use embassy_net::{Ipv4Cidr, Runner, Stack, StackResources, StaticConfigV4};
use embassy_time::{Duration, Timer};
use esp_radio::wifi::WifiDevice;
use static_cell::StaticCell;
use esp_wifi::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
pub const GW_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 2, 1);
pub fn setup_network<'a>(seed: u64, wifi: WifiDevice<'static>, spawner: Spawner) -> Stack<'a> {
pub fn setup_network<'a>(seed: u64, wifi: WifiDevice<'static>, spawner: &mut Spawner) -> Stack<'a> {
let gw_ip_addr_str = "192.168.2.1";
let gw_ip_addr = Ipv4Addr::from_str(gw_ip_addr_str).expect("failed to parse gateway ip");
let config = embassy_net::Config::ipv4_static(StaticConfigV4 {
address: Ipv4Cidr::new(GW_IP, 24),
gateway: Some(GW_IP),
address: Ipv4Cidr::new(gw_ip_addr, 24),
gateway: Some(gw_ip_addr),
dns_servers: Default::default(),
});
static NETWORK_STACK: StaticCell<StackResources<NETWORK_STACK_SIZE>> = StaticCell::new();
let nw_stack = NETWORK_STACK.init(StackResources::new());
let (stack, runner) = embassy_net::new(wifi, config, nw_stack, seed);
let (stack, runner) =
embassy_net::new(wifi, config, make_static!(StackResources::<3>::new()), seed);
spawner.must_spawn(net_task(runner));
spawner.must_spawn(run_dhcp(stack));
spawner.must_spawn(run_dhcp(stack, gw_ip_addr_str));
stack
}
#[embassy_executor::task]
async fn run_dhcp(stack: Stack<'static>) {
async fn run_dhcp(stack: Stack<'static>, gw_ip_addr: &'static str) {
use core::net::{Ipv4Addr, SocketAddrV4};
use edge_dhcp::{
io::{self, DEFAULT_SERVER_PORT},
server::{Server, ServerOptions},
};
use edge_nal::UdpBind;
use edge_nal_embassy::{Udp, UdpBuffers};
let ip = Ipv4Addr::from_str(gw_ip_addr).expect("dhcp task failed to parse gw ip");
let mut buf = [0u8; 1500];
let mut gw_buf = [Ipv4Addr::UNSPECIFIED];
@@ -48,12 +50,12 @@ async fn run_dhcp(stack: Stack<'static>) {
DEFAULT_SERVER_PORT,
)))
.await
.expect("Failed to bind socket for DHCP server");
.unwrap();
loop {
_ = io::server::run(
&mut Server::<_, 64>::new_with_et(GW_IP),
&ServerOptions::new(GW_IP, Some(&mut gw_buf)),
&mut Server::<_, 64>::new_with_et(ip),
&ServerOptions::new(ip, Some(&mut gw_buf)),
&mut bound_socket,
&mut buf,
)
@@ -67,3 +69,4 @@ async fn run_dhcp(stack: Stack<'static>) {
async fn net_task(mut runner: Runner<'static, WifiDevice<'static>>) {
runner.run().await;
}

View File

@@ -1,16 +1,10 @@
use alloc::{string::ToString, vec::Vec};
use core::str::from_utf8;
use alloc::vec::Vec;
use embassy_time::Delay;
use embedded_hal_bus::spi::ExclusiveDevice;
use embedded_sdmmc::{
SdCard, SdCardError, ShortFileName, TimeSource, Timestamp, VolumeIdx, VolumeManager,
};
use embedded_sdmmc::{SdCard, TimeSource, Timestamp, VolumeIdx, VolumeManager};
use esp_hal::{Blocking, gpio::Output, spi::master::Spi};
use thiserror::Error;
use crate::store::{
AttendanceDay, day::Day, mapping_loader::Name, persistence::Persistence, tally_id::TallyID,
};
use crate::store::{AttendanceDay, Date, persistence::Persistence};
pub struct DummyTimesource;
@@ -44,232 +38,55 @@ pub struct SDCardPersistence {
vol_mgr: VolMgr,
}
impl SDCardPersistence {
const MAPPING_DIRNAME: &'static str = "MAPPINGS";
fn generate_filename_for_day(day: Day) -> Result<ShortFileName, PersistenceError> {
let basename = day.to_string();
let mut filename: heapless::String<11> = heapless::String::new();
filename
.push_str(&basename)
.map_err(|_| PersistenceError::DayFilename)?;
filename
.push_str(".JS")
.map_err(|_| PersistenceError::DayFilename)?;
ShortFileName::create_from_str(&filename).map_err(|_| PersistenceError::DayFilename)
}
fn generate_path_for_id(
id: TallyID,
) -> Result<(ShortFileName, ShortFileName), PersistenceError> {
let basename: heapless::String<12> = id.into();
let (dir, file) = basename.split_at(6);
let mut filename: heapless::String<11> = heapless::String::new();
filename
.push_str(file)
.map_err(|_| PersistenceError::IDFilename)?;
filename
.push_str(".JS")
.map_err(|_| PersistenceError::IDFilename)?;
let mut dirname: heapless::String<11> = heapless::String::new();
dirname
.push_str(dir)
.map_err(|_| PersistenceError::IDFilename)?;
Ok((
ShortFileName::create_from_str(&dirname).map_err(|_| PersistenceError::IDFilename)?,
ShortFileName::create_from_str(&filename).map_err(|_| PersistenceError::IDFilename)?,
))
}
fn get_tallyid_from_path(
dirname: &ShortFileName,
filename: &ShortFileName,
) -> Result<TallyID, PersistenceError> {
let mut id_str: heapless::String<12> = heapless::String::new();
id_str
.push_str(&dirname.to_string())
.map_err(|_| PersistenceError::IDFilename)?;
id_str
.push_str(from_utf8(filename.base_name()).map_err(|_| PersistenceError::IDFilename)?)
.map_err(|_| PersistenceError::IDFilename)?;
let id: TallyID = id_str
.try_into()
.map_err(|_| PersistenceError::IDFilename)?;
Ok(id)
}
}
#[derive(Error, Debug)]
pub enum PersistenceError {
#[error("Failed to interact with SD card")]
SdCard(embedded_sdmmc::Error<SdCardError>),
#[error("Failed to parse data")]
Parseing(#[from] serde_json::Error),
#[error("Failed to parse Day and Filename")]
DayFilename,
#[error("Failed to parse TallyID for file path")]
IDFilename,
#[error("Item not found")]
NotFound,
}
impl From<embedded_sdmmc::Error<SdCardError>> for PersistenceError {
fn from(err: embedded_sdmmc::Error<SdCardError>) -> Self {
PersistenceError::SdCard(err)
}
}
impl Persistence for SDCardPersistence {
type Error = PersistenceError;
async fn load_day(&mut self, day: Day) -> Result<AttendanceDay, Self::Error> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
let mut root_dir = vol_0.open_root_dir()?;
let filename = Self::generate_filename_for_day(day)?;
let file = root_dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadOnly);
if file.is_err() {
return Err(PersistenceError::NotFound);
}
let mut open_file = file?;
async fn load_day(&mut self, day: crate::store::Date) -> Option<AttendanceDay> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap();
let mut root_dir = vol_0.open_root_dir().unwrap();
let mut file = root_dir
.open_file_in_dir("day.jsn", embedded_sdmmc::Mode::ReadOnly)
.unwrap();
let mut read_buffer: [u8; 1024] = [0; 1024];
let read = open_file.read(&mut read_buffer)?;
open_file.close()?;
let read = file.read(&mut read_buffer).unwrap();
file.close().unwrap();
let day: AttendanceDay = serde_json::from_slice(&read_buffer[..read])?;
let day: AttendanceDay = serde_json::from_slice(&read_buffer[..read]).unwrap();
Ok(day)
Some(day)
}
async fn save_day(&mut self, day: Day, data: &AttendanceDay) -> Result<(), Self::Error> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
let mut root_dir = vol_0.open_root_dir()?;
async fn save_day(&mut self, day: Date, data: &AttendanceDay) {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap();
let mut root_dir = vol_0.open_root_dir().unwrap();
let filename = Self::generate_filename_for_day(day)?;
let mut file =
root_dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)?;
file.write(&serde_json::to_vec(data)?)?;
file.flush()?;
file.close()?;
Ok(())
let mut file = root_dir
.open_file_in_dir("day.jsn", embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)
.unwrap();
file.write(&serde_json::to_vec(data).unwrap()).unwrap();
file.flush();
file.close();
}
async fn list_days(&mut self) -> Result<Vec<Day>, Self::Error> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
let mut root_dir = vol_0.open_root_dir()?;
let mut days_dir = root_dir.open_dir(".")?;
let mut days: Vec<Day> = Vec::new();
days_dir.iterate_dir(|e| {
let filename = e.name.clone();
if let Ok(day) = filename.try_into() {
days.push(day);
}
})?;
Ok(days)
async fn load_mapping(&mut self) -> Option<crate::store::IDMapping> {
todo!()
}
async fn load_mapping_for_id(
&mut self,
id: crate::store::tally_id::TallyID,
) -> Result<crate::store::mapping_loader::Name, Self::Error> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
let mut root_dir = vol_0.open_root_dir()?;
let mut mapping_dir = root_dir.open_dir(Self::MAPPING_DIRNAME)?;
let (dirname, filename) = Self::generate_path_for_id(id)?;
let mut dir = mapping_dir.open_dir(dirname)?;
let mut file = dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadOnly)?;
let mut read_buffer: [u8; 1024] = [0; 1024];
let read_bytes = file.read(&mut read_buffer)?;
file.close()?;
let mapping: Name = serde_json::from_slice(&read_buffer[..read_bytes])?;
Ok(mapping)
async fn save_mapping(&mut self, data: &crate::store::IDMapping) {
todo!()
}
async fn save_mapping_for_id(
&mut self,
id: crate::store::tally_id::TallyID,
name: crate::store::mapping_loader::Name,
) -> Result<(), Self::Error> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
let mut root_dir = vol_0.open_root_dir()?;
let mut mapping_dir = root_dir.open_dir(Self::MAPPING_DIRNAME)?;
async fn list_days(&mut self) -> Vec<Date> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0)).unwrap();
let mut root_dir = vol_0.open_root_dir().unwrap();
let mut days_dir = root_dir.open_dir("days").unwrap();
let (dirname, filename) = Self::generate_path_for_id(id)?;
let mut days = Vec::new();
days_dir
.iterate_dir(|e| {
days.push(1);
})
.unwrap();
let mut dir = if let Ok(dir) = mapping_dir.open_dir(&dirname) {
dir
} else {
mapping_dir.make_dir_in_dir(&dirname)?;
mapping_dir.open_dir(&dirname)?
};
let mut file =
dir.open_file_in_dir(filename, embedded_sdmmc::Mode::ReadWriteCreateOrTruncate)?;
file.write(&serde_json::to_vec(&name)?)?;
Ok(())
}
async fn list_mappings(&mut self) -> Result<Vec<TallyID>, Self::Error> {
let mut vol_0 = self.vol_mgr.open_volume(VolumeIdx(0))?;
let mut root_dir = vol_0.open_root_dir()?;
let mut mapping_dir = root_dir.open_dir(Self::MAPPING_DIRNAME)?;
let mut ids: Vec<TallyID> = Vec::new();
let mut dir_names = Vec::new();
mapping_dir.iterate_dir(|entry| {
if entry.attributes.is_directory()
&& entry.name.to_string() != "."
&& entry.name.to_string() != ".."
{
dir_names.push(entry.name.clone());
}
})?;
for dirname in dir_names {
if let Ok(mut subdir) = mapping_dir.open_dir(&dirname) {
let mut file_names = Vec::new();
subdir.iterate_dir(|file_entry| {
if !file_entry.attributes.is_directory() {
file_names.push(file_entry.name.clone());
}
})?;
for filename in file_names {
let id = Self::get_tallyid_from_path(&dirname, &filename)?;
ids.push(id);
}
}
}
Ok(ids)
days
}
}

View File

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

View File

@@ -2,123 +2,92 @@
#![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;
use embassy_net::Stack;
use embassy_sync::{
blocking_mutex::raw::{CriticalSectionRawMutex, NoopRawMutex},
mutex::Mutex,
channel::Channel,
pubsub::{
PubSubChannel, Publisher, Subscriber,
PubSubChannel, Publisher,
WaitResult::{Lagged, Message},
},
signal::Signal,
};
use embassy_time::{Duration, Timer};
use esp_hal::gpio::InputConfig;
use esp_hal::gpio::{AnyPin, Input};
use esp_hal::gpio::Input;
use esp_hal::{gpio::InputConfig, peripherals};
use log::{debug, info};
use static_cell::StaticCell;
use static_cell::make_static;
use crate::store::TallyID;
extern crate alloc;
use crate::{
init::{hardware::AppHardware, sd_card::SDCardPersistence},
store::{IDStore, day::Day, mapping_loader::MappingLoader, tally_id::TallyID},
webserver::start_webserver,
};
mod drivers;
mod feedback;
mod init;
mod store;
mod webserver;
//mod webserver;
static FEEDBACK_STATE: Signal<CriticalSectionRawMutex, feedback::FeedbackState> = Signal::new();
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>;
static CHAN: StaticCell<TallyChannel> = StaticCell::new();
#[esp_hal_embassy::main]
async fn main(mut spawner: Spawner) {
let (uart_device, stack, _i2c, _led, buzzer_gpio, sd_det_gpio) =
init::hardware::hardware_init(&mut spawner).await;
#[esp_rtos::main]
async fn main(spawner: Spawner) -> ! {
let app_hardware = AppHardware::init(spawner).await.unwrap();
wait_for_stack_up(stack).await;
info!("Starting up...");
let mut rtc = drivers::rtc::RTCClock::new(app_hardware.i2c).await;
let chan: &'static mut TallyChannel = make_static!(PubSubChannel::new());
let current_day: Day = rtc.get_time().await.into();
//start_webserver(&mut spawner, stack);
let shared_sdcard: Rc<Mutex<CriticalSectionRawMutex, SDCardPersistence>> =
Rc::new(Mutex::new(app_hardware.sdcard));
let publisher = chan.publisher().unwrap();
let store: UsedStore = IDStore::new_from_storage(shared_sdcard.clone(), current_day).await;
let shared_store = Rc::new(Mutex::new(store));
let mapping_loader = MappingLoader::new(shared_sdcard.clone());
let chan: &'static mut TallyChannel = CHAN.init(PubSubChannel::new());
let publisher: TallyPublisher = chan.publisher().unwrap();
let mut sub: TallySubscriber = chan.subscriber().unwrap();
wait_for_stack_up(app_hardware.network_stack).await;
start_webserver(
spawner,
app_hardware.network_stack,
shared_store.clone(),
chan,
mapping_loader,
);
let mut rtc = drivers::rtc::RTCClock::new(_i2c).await;
/****************************** Spawning tasks ***********************************/
debug!("spawing NFC reader task...");
spawner.must_spawn(drivers::nfc_reader::rfid_reader_task(
app_hardware.uart,
uart_device,
publisher,
));
debug!("spawing feedback task..");
spawner.must_spawn(feedback::feedback_task(
app_hardware.led,
app_hardware.buzzer,
));
spawner.must_spawn(feedback::feedback_task(_led, buzzer_gpio));
debug!("spawn sd detect task");
spawner.must_spawn(sd_detect_task(app_hardware.sd_present));
spawner.must_spawn(sd_detect_task(sd_det_gpio));
/******************************************************************************/
let mut sub = chan.subscriber().unwrap();
debug!("everything spawned");
FEEDBACK_STATE.signal(feedback::FeedbackState::Startup);
loop {
let wait_result = sub.next_message().await;
match wait_result {
Lagged(_) => debug!("Lagged"),
Message(msg) => {
debug!("Got message: {msg:?}");
rtc.get_time().await;
info!("Current RTC time: {}", rtc.get_time().await);
Timer::after(Duration::from_millis(1000)).await;
let day: Day = rtc.get_time().await.into();
let added = shared_store.lock().await.add_id(msg, day).await;
if added {
FEEDBACK_STATE.signal(feedback::FeedbackState::Ack);
}
}
}
// let wait_result = sub.next_message().await;
// match wait_result {
// Lagged(_) => debug!("Lagged"),
// Message(msg) => debug!("Got message: {msg:?}"),
// }
}
}
#[embassy_executor::task]
async fn sd_detect_task(sd_det_gpio: AnyPin<'static>) {
async fn sd_detect_task(sd_det_gpio: peripherals::GPIO0<'static>) {
let mut sd_det = Input::new(sd_det_gpio, InputConfig::default());
sd_det.wait_for(esp_hal::gpio::Event::AnyEdge).await;
sd_det.wait_for(esp_hal::gpio::Event::AnyEdge);
loop {
sd_det.wait_for_any_edge().await;

View File

@@ -1,63 +0,0 @@
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(|_| ())
}
}

31
src/store/id_mapping.rs Normal file
View File

@@ -0,0 +1,31 @@
use super::TallyID;
use alloc::collections::BTreeMap;
use alloc::string::String;
use serde::Serialize;
#[derive(Clone, Serialize)]
pub struct Name {
pub first: String,
pub last: String,
}
#[derive(Clone, Serialize)]
pub struct IDMapping {
id_map: BTreeMap<TallyID, Name>,
}
impl IDMapping {
pub fn new() -> Self {
IDMapping {
id_map: BTreeMap::new(),
}
}
pub fn map(&self, id: &TallyID) -> Option<&Name> {
self.id_map.get(id)
}
pub fn add_mapping(&mut self, id: TallyID, name: Name) {
self.id_map.insert(id, name);
}
}

View File

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

View File

@@ -1,31 +0,0 @@
use alloc::{rc::Rc, string::String, vec::Vec};
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex};
use serde::{Deserialize, Serialize};
use crate::store::{persistence::Persistence, tally_id::TallyID};
#[derive(Clone, Serialize, Deserialize)]
pub struct Name {
pub first: String,
pub last: String,
}
pub struct MappingLoader<T: Persistence>(Rc<Mutex<CriticalSectionRawMutex, T>>);
impl<T: Persistence> MappingLoader<T> {
pub fn new(persistence_layer: Rc<Mutex<CriticalSectionRawMutex, T>>) -> Self {
Self(persistence_layer)
}
pub async fn get_mapping(&self, id: TallyID) -> Result<Name, T::Error> {
self.0.lock().await.load_mapping_for_id(id).await
}
pub async fn set_mapping(&self, id: TallyID, name: Name) -> Result<(), T::Error> {
self.0.lock().await.save_mapping_for_id(id, name).await
}
pub async fn list_mappings(&self) -> Result<Vec<TallyID>,T::Error> {
self.0.lock().await.list_mappings().await
}
}

View File

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

View File

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

View File

@@ -1,109 +0,0 @@
use alloc::string::String;
use core::{fmt::Display, str::FromStr};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct TallyID([u8; 6]);
impl FromStr for TallyID {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.as_bytes().try_into()
}
}
impl TryFrom<heapless::String<12>> for TallyID {
type Error = ();
fn try_from(value: heapless::String<12>) -> Result<Self, Self::Error> {
let bytes = value.as_bytes();
let mut out: [u8; 6] = [0; 6];
for i in 0..6 {
let hi = hex_val(bytes[2 * i])?;
let lo = hex_val(bytes[2 * i + 1])?;
out[i] = (hi << 4) | lo;
}
Ok(TallyID(out))
}
}
fn hex_val(b: u8) -> Result<u8, ()> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(()),
}
}
impl From<TallyID> for heapless::String<12> {
fn from(value: TallyID) -> Self {
const HEX_CHARS: &[u8; 16] = b"0123456789ABCDEF";
let mut s: Self = Self::new();
for &b in &value.0 {
// Should be safe to unwrap since the string is already long enough
s.push(HEX_CHARS[(b >> 4) as usize] as char).unwrap();
s.push(HEX_CHARS[(b & 0x0F) as usize] as char).unwrap();
}
s
}
}
/// From a array of hex chars
impl TryFrom<&[u8]> for TallyID {
type Error = ();
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
if value.len() != 12 {
return Err(());
}
let mut out: [u8; 6] = [0; 6];
for i in 0..6 {
let hi = hex_val(value[2 * i])?;
let lo = hex_val(value[2 * i + 1])?;
out[i] = (hi << 4) | lo;
}
Ok(TallyID(out))
}
}
impl TryFrom<[u8; 12]> for TallyID {
type Error = ();
fn try_from(value: [u8; 12]) -> Result<Self, Self::Error> {
Self::try_from(&value as &[u8])
}
}
impl Display for TallyID {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let s: heapless::String<12> = (*self).into();
write!(f, "{}", s)
}
}
impl Serialize for TallyID {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s: heapless::String<12> = (*self).into();
serializer.serialize_str(&s)
}
}
impl<'de> Deserialize<'de> for TallyID {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = <String>::deserialize(deserializer)?;
TallyID::from_str(&s).map_err(|_| de::Error::custom("Failed to parse Tally ID"))
}
}

View File

@@ -1,136 +0,0 @@
use log::error;
use picoserve::{
extract::{Json, Query, State},
response::{self, IntoResponse},
};
use serde::Deserialize;
use crate::{
store::{day::Day, mapping_loader::Name, tally_id::TallyID},
webserver::{app::AppState, sse::IDEvents},
};
#[derive(Deserialize)]
pub struct NewMapping {
id: TallyID,
name: Name,
}
#[derive(Deserialize)]
pub struct QueryTimespan {
from: u64,
to: u64,
}
#[derive(Deserialize)]
pub struct QueryDay {
timestamp: Option<u64>,
day: Option<u32>,
}
#[derive(Deserialize)]
pub struct QueryMapping {
id: TallyID,
}
// GET /api/mappings
pub async fn get_mappings(
State(state): State<AppState>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let loader = state.mapping_loader.lock().await;
match loader.list_mappings().await {
Ok(ids) => Ok(response::Json(ids)),
Err(_) => Err((
response::StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_SERVER_ERROR",
)),
}
}
// GET /api/mapping
pub async fn get_mapping(
State(state): State<AppState>,
Query(QueryMapping { id }): Query<QueryMapping>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let loader = state.mapping_loader.lock().await;
match loader.get_mapping(id).await {
Ok(name) => Ok(response::Json(name)),
Err(_) => Err((
response::StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_SERVER_ERROR",
)),
}
}
// POST /api/mapping
pub async fn add_mapping(
State(state): State<AppState>,
Json(data): Json<NewMapping>,
) -> impl IntoResponse {
let loader = state.mapping_loader.lock().await;
match loader.set_mapping(data.id, data.name).await {
Ok(_) => (response::StatusCode::CREATED, ""),
Err(_) => (
response::StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_SERVER_ERROR",
),
}
}
// SSE /api/idevent
pub async fn get_idevent(
State(state): State<AppState>,
) -> Result<impl IntoResponse, impl IntoResponse> {
match state.chan.subscriber() {
Ok(chan) => Ok(response::EventStream(IDEvents(chan))),
Err(e) => {
error!("Failed to create SSE: {:?}", e);
Err((
response::StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error",
))
}
}
}
// GET /api/days
pub async fn get_days(
State(state): State<AppState>,
Query(QueryTimespan { from, to }): Query<QueryTimespan>,
) -> impl IntoResponse {
let from_day = Day::new_from_timestamp(from);
let to_day = Day::new_from_timestamp(to);
let mut store = state.store.lock().await;
match store.list_days_in_timespan(from_day, to_day).await {
Ok(days) => Ok(response::Json(days)),
Err(_) => Err((
response::StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error",
)),
}
}
// GET /api/day
pub async fn get_day(
State(state): State<AppState>,
Query(QueryDay { timestamp, day }): Query<QueryDay>,
) -> Result<impl IntoResponse, impl IntoResponse> {
let parsed_day = timestamp
.map(Day::new_from_timestamp)
.or_else(|| day.map(Day::new))
.ok_or((response::StatusCode::NOT_FOUND, "Not found"))?;
let mut store = state.store.lock().await;
match store.load_day(parsed_day).await {
Ok(att_day) => Ok(response::Json(att_day)),
Err(_) => Err((
response::StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error",
)),
}
}

View File

@@ -1,36 +0,0 @@
use alloc::rc::Rc;
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex};
use picoserve::{AppWithStateBuilder, routing::get};
use crate::{
TallyChannel, UsedStore,
init::sd_card::SDCardPersistence,
store::mapping_loader::MappingLoader,
webserver::{
api::{add_mapping, get_day, get_days, get_idevent, get_mapping, get_mappings},
assets::Assets,
},
};
#[derive(Clone)]
pub struct AppState {
pub store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
pub chan: &'static TallyChannel,
pub mapping_loader: Rc<Mutex<CriticalSectionRawMutex, MappingLoader<SDCardPersistence>>>,
}
pub struct AppProps;
impl AppWithStateBuilder for AppProps {
type State = AppState;
type PathRouter = impl picoserve::routing::PathRouter<AppState>;
fn build_app(self) -> picoserve::Router<Self::PathRouter, AppState> {
picoserve::Router::from_service(Assets)
.route("/api/mapping", get(get_mapping).post(add_mapping))
.route("/api/mappings", get(get_mappings))
.route("/api/idevent", get(get_idevent))
.route("/api/days", get(get_days))
.route("/api/day", get(get_day))
}
}

View File

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

View File

@@ -1,67 +1,54 @@
use alloc::rc::Rc;
use embassy_executor::Spawner;
use embassy_net::Stack;
use embassy_sync::{blocking_mutex::raw::CriticalSectionRawMutex, mutex::Mutex};
use embassy_time::Duration;
use picoserve::{AppRouter, AppWithStateBuilder};
use picoserve::{AppBuilder, AppRouter, routing::get};
use static_cell::make_static;
use crate::{
TallyChannel, UsedStore,
init::sd_card::SDCardPersistence,
store::mapping_loader::{self, MappingLoader},
webserver::app::{AppProps, AppState},
};
mod api;
mod app;
mod assets;
mod sse;
pub const WEB_TAKS_SIZE: usize = 5; // Up this number if request start fail with Timeouts.
pub fn start_webserver(
spawner: Spawner,
stack: Stack<'static>,
store: Rc<Mutex<CriticalSectionRawMutex, UsedStore>>,
chan: &'static TallyChannel,
mapping_loader: MappingLoader<SDCardPersistence>,
) {
pub fn start_webserver(spawner: &mut Spawner, stack: Stack<'static>) {
let app = make_static!(AppProps.build_app());
let shared_mapping_loader = Rc::new(Mutex::new(mapping_loader));
let state = make_static!(AppState {
store,
chan,
mapping_loader: shared_mapping_loader
});
let config = make_static!(picoserve::Config::new(picoserve::Timeouts {
start_read_request: Some(Duration::from_secs(5)),
persistent_start_read_request: Some(Duration::from_secs(5)),
read_request: Some(Duration::from_secs(5)),
write: Some(Duration::from_secs(5)),
persistent_start_read_request: Some(Duration::from_secs(1)),
read_request: Some(Duration::from_secs(1)),
write: Some(Duration::from_secs(1)),
}));
for task_id in 0..WEB_TAKS_SIZE {
spawner.must_spawn(webserver_task(task_id, stack, app, config, state));
let _ = spawner.spawn(webserver_task(0, stack, app, config));
}
struct AppProps;
impl AppBuilder for AppProps {
type PathRouter = impl picoserve::routing::PathRouter;
fn build_app(self) -> picoserve::Router<Self::PathRouter> {
picoserve::Router::from_service(assets::Assets).route("/api/a", get(async move || "Hello"))
}
}
#[embassy_executor::task(pool_size = WEB_TAKS_SIZE)]
#[embassy_executor::task]
async fn webserver_task(
task_id: usize,
id: usize,
stack: embassy_net::Stack<'static>,
app: &'static AppRouter<AppProps>,
config: &'static picoserve::Config<Duration>,
state: &'static AppState,
) -> ! {
let mut tcp_rx_buffer = [0u8; 1024];
let mut tcp_tx_buffer = [0u8; 1024];
let mut http_buffer = [0u8; 2048];
picoserve::Server::new(&app.shared().with_state(state), config, &mut http_buffer)
.listen_and_serve(task_id, stack, 80, &mut tcp_rx_buffer, &mut tcp_tx_buffer)
.await
.into_never()
picoserve::listen_and_serve(
id,
app,
config,
stack,
80,
&mut tcp_rx_buffer,
&mut tcp_tx_buffer,
&mut http_buffer,
)
.await
}

View File

@@ -1,32 +0,0 @@
use embassy_time::{Duration, Timer};
use log::warn;
use picoserve::response;
use crate::TallySubscriber;
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>,
) -> Result<(), W::Error> {
loop {
let timeout = Timer::after(Duration::from_secs(15));
let sel = embassy_futures::select::select(self.0.next_message(), timeout);
match sel.await {
embassy_futures::select::Either::First(msg) => match msg {
embassy_sync::pubsub::WaitResult::Message(id) => {
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");
}
},
embassy_futures::select::Either::Second(_) => writer.write_keepalive().await?,
}
}
}
}

View File

@@ -1,108 +0,0 @@
{
"mapping": [
[
"123456789ABC",
{
"first": "Feuerwehrman",
"last": "Sam"
}
],
[
"A1B2C3D4E5F6",
{
"first": "Luna",
"last": "Starforge"
}
],
[
"0F1E2D3C4B5A",
{
"first": "Gareth",
"last": "Ironwill"
}
],
[
"ABCDEF123456",
{
"first": "Nina",
"last": "Skylark"
}
],
[
"654321FEDCBA",
{
"first": "Tobias",
"last": "Marrow"
}
],
[
"DEADBEEFCAFE",
{
"first": "Astra",
"last": "Vale"
}
],
[
"BADA55C0FFEE",
{
"first": "Rowan",
"last": "Tempest"
}
],
[
"C001D00D1337",
{
"first": "Juniper",
"last": "Voss"
}
],
[
"A0B1DB0D133B",
{
"first": "Öäü",
"last": "ßẞ"
}
]
],
"days": [
{
"date": 20372,
"ids": [
"123456789ABC",
"A1B2C3D4E5F6",
"A0B1DB0D133B"
]
},
{
"date": 20373,
"ids": [
"0F1E2D3C4B5A",
"ABCDEF123456"
]
},
{
"date": 20374,
"ids": [
"654321FEDCBA",
"DEADBEEFCAFE",
"BADA55C0FFEE",
"A0B1DB0D133B"
]
},
{
"date": 20375,
"ids": [
"C001D00D1337",
"A1B2C3D4E5F6",
"123456789ABC"
]
},
{
"date": 20376,
"ids": [
"N0T3X1ST1D0",
"654321FEDCBA"
]
}
]
}

View File

@@ -1,111 +0,0 @@
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}`);
});

885
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -3,33 +3,18 @@
import IDTable from "./lib/IDTable.svelte";
import LastId from "./lib/LastID.svelte";
import AddIDModal from "./lib/AddIDModal.svelte";
import ExportModal from "./lib/ExportModal.svelte";
import { generateCSVFile } from "./lib/exporting";
import {
cacheMappingInLocalstore,
fetchMapping,
loadCachedMappingFromLocalstore,
type IDMap,
} from "./lib/IDMapping";
import { downloadBlob } from "./lib/downloadBlob";
let lastID: string = $state("");
let mapping: IDMap | null = $state(null);
let addModal: AddIDModal;
let exportModal: ExportModal;
onMount(async () => {
mapping = loadCachedMappingFromLocalstore();
let fetchedMapping = await fetchMapping();
mapping = fetchedMapping;
cacheMappingInLocalstore(fetchedMapping);
let idTable: IDTable;
onMount(() => {
let sse = new EventSource("/api/idevent");
sse.addEventListener("msg", function (e) {
sse.onmessage = (e) => {
lastID = e.data;
});
};
});
</script>
@@ -40,14 +25,13 @@
<h1 class="text-3xl sm:text-4xl font-bold text-gray-800">Anwesenheit</h1>
</div>
<button
<a
class="px-6 py-3 text-lg font-semibold text-white bg-indigo-600 rounded-2xl shadow-md hover:bg-indigo-700 transition"
onclick={() => {
exportModal.open();
}}
href="/api/csv"
download="anwesenheit.csv"
>
Export CSV
</button>
Download CSV
</a>
<div class="pt-3 pb-2">
<LastId
@@ -58,42 +42,15 @@
/>
</div>
<div>
{#if mapping}
<IDTable
data={mapping}
onEdit={(id, firstName, lastName) => {
addModal.open(id, firstName, lastName);
}}
/>
<span>Gesammmte einträge: {Object.keys(mapping).length}</span>
{:else}
Lade ...
{/if}
<IDTable bind:this={idTable} onEdit={(id,firstName,lastName)=>{
addModal.open(id,firstName,lastName);
}}/>
</div>
<AddIDModal
bind:this={addModal}
onSubmitted={async (id, firstName, lastname) => {
if (mapping == null) {
return;
}
mapping[id] = {
first: firstName,
last: lastname,
};
}}
/>
<ExportModal
bind:this={exportModal}
onSubmitted={async (from, to) => {
if (!mapping) {
return;
}
let csvFile = await generateCSVFile(from, to, mapping);
downloadBlob("export.csv", csvFile, "text/csv");
onSubmitted={() => {
idTable.reloadData();
}}
/>
</main>

View File

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

View File

@@ -1,31 +0,0 @@
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

@@ -1,142 +0,0 @@
<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,3 +1,7 @@
export interface IDMapping {
id_map: IDMap
}
export interface IDMap {
[name: string]: Name
}
@@ -7,57 +11,6 @@ export interface Name {
last: string,
}
async function fetchIDList(): Promise<string[]> {
let res = await fetch("/api/mappings");
let data = await res.json();
return data;
}
async function fetchID(id: string): Promise<Name> {
let res = await fetch("/api/mapping?id=" + id);
let data = await res.json();
return data;
}
export async function fetchMapping(): Promise<IDMap> {
let ids = await fetchIDList();
let map: IDMap = {};
for (const id of ids) {
let id_name = await fetchID(id);
map[id] = id_name;
}
return map;
}
const CACHE_KEY = "idmap";
export function cacheMappingInLocalstore(mapping: IDMap) {
if (!localStorage) {
console.error("localStorage is not available");
return;
}
localStorage.setItem(CACHE_KEY, JSON.stringify(mapping));
}
export function loadCachedMappingFromLocalstore(): IDMap | null {
if (!localStorage) {
console.error("localStorage is not available");
return null;
}
const data = localStorage.getItem(CACHE_KEY);
if (!data) {
return null;
}
const mapping = JSON.parse(data);
return mapping;
}
export async function addMapping(id: string, firstName: string, lastName: string) {
let req = await fetch("/api/mapping", {
method: "POST",

View File

@@ -1,17 +1,19 @@
<script lang="ts">
import { type IDMap } from "./IDMapping";
import { onMount } from "svelte";
import type { IDMapping } from "./IDMapping";
let data: IDMapping | undefined = $state();
let {
onEdit,
data,
}: {
onEdit?: (id: string, firstName: string, lastName: string) => void;
data: IDMap;
} = $props();
let { onEdit }: { onEdit?: (string,string,string) => void } = $props();
export async function reloadData() {
let res = await fetch("/api/mapping");
data = await res.json();
}
let rows = $derived(
data
? Object.entries(data).map(([id, value]) => ({
? Object.entries(data.id_map).map(([id, value]) => ({
id,
...value,
}))
@@ -41,64 +43,70 @@
if (sortKey !== key) return "";
return sortDirection === "asc" ? "▲" : "▼";
}
onMount(async () => {
await reloadData();
});
</script>
<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>
</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
{#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
<span class="indicator">{indicator("first")}</span>
</th>
<th>
</th>
</tr>
{/each}
</tbody>
</table>
</div>
</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}
<style lang="css" scoped>
@reference "../app.css";
.indicator {
@apply ml-1 w-4 inline-block;
}

View File

@@ -1,103 +0,0 @@
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

@@ -1,8 +0,0 @@
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);
}

View File

@@ -1,53 +0,0 @@
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:3000",
target: "http://localhost:8080",
},
},
},