- Add WAL mode + busy timeout for SQLite concurrent access - Define two-step TOTP login protocol with temporary JWT - Specify JWT claims structure and error response format - Add InfluxDB bootstrap via Docker init env vars - Clarify CORS strategy (same-origin in prod, permissive in dev) - Add daemon-config.toml to deploy directory - Clarify event routing (single POST / handler) - Note sensor delete uses SQLite transaction Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
LoRaWAN Web Portal – Design Specification
Date: 2026-03-19 Based on: Pflichtenheft v2.2 (März 2026) Approach: Bottom-up implementation, pflichtenheft as goal with flexibility in implementation details
1. Overview
Web-based monitoring portal for LoRaWAN sensor infrastructure. Receives uplink data from IoT devices via ChirpStack HTTP Integration, stores measurements in InfluxDB, manages device/sensor configuration in SQLite, and presents everything through a secured React frontend.
Key Decisions (Deviations from Pflichtenheft)
| Area | Pflichtenheft | Implementation | Reason |
|---|---|---|---|
| SQLx | 0.7 | 0.8 | Current version, better SQLite support |
| Axum | 0.7 | 0.8 | Current version, improved API |
| Vite | 5 | 6 | Faster builds, no breaking changes for us |
| Tailwind | 3.4 | 4 | CSS-based engine, better performance |
| Rust Edition | 2021 | 2024 | Current standard |
| Deploy path | — | /opt/loraweb | As specified in pflichtenheft |
React 18, config crate, and core architecture remain unchanged.
2. Project Structure
LORAWEB_NEU/
├── Cargo.toml # Workspace root (members: backend/daemon, backend/api)
├── backend/
│ ├── daemon/
│ │ ├── Cargo.toml
│ │ ├── Dockerfile
│ │ └── src/
│ │ ├── main.rs # Tokio runtime, task spawning
│ │ ├── config.rs # AppConfig via config crate
│ │ ├── http.rs # Axum HTTP ingest listener
│ │ ├── influx.rs # InfluxDB writer
│ │ ├── db.rs # Topic registration (SQLite)
│ │ └── alarm.rs # Alarm monitor loop
│ └── api/
│ ├── Cargo.toml
│ ├── Dockerfile
│ └── src/
│ ├── main.rs # Router, DB init, AppState
│ ├── auth.rs # JWT, Argon2, TOTP
│ ├── sensors.rs # CRUD + data + display-config
│ ├── topics.rs # Topic management
│ ├── alarms.rs # Alarm endpoints
│ └── users.rs # User CRUD + 2FA
├── frontend/
│ ├── src/
│ │ ├── App.tsx # Router, protected routes
│ │ ├── api/client.ts # Axios instance + API functions
│ │ ├── store/authStore.ts # React Context auth state
│ │ ├── pages/
│ │ │ ├── LoginPage.tsx
│ │ │ ├── DashboardPage.tsx
│ │ │ ├── SensorsPage.tsx # DataModal, multi-chart, mobile cards
│ │ │ ├── UnknownTopicsPage.tsx
│ │ │ ├── AlarmsPage.tsx
│ │ │ └── UsersPage.tsx
│ │ └── components/
│ │ ├── Layout.tsx # Responsive sidebar + hamburger drawer
│ │ └── StatusBadge.tsx
│ ├── Dockerfile
│ └── nginx.conf
├── database/
│ └── migrations/001_initial.sql
├── deploy/
│ ├── docker-compose.yml # Project name: loraweb
│ ├── .env.example
│ ├── daemon-config.toml # Daemon config example (not in git at runtime)
│ └── loraweb.service # systemd unit
└── docs/
3. Database Schema (SQLite)
Three tables, created idempotently (CREATE TABLE IF NOT EXISTS) at startup by both daemon and API.
SQLite Concurrency
Both daemon and API access the same SQLite file. To handle concurrent access:
- WAL mode (
PRAGMA journal_mode=WAL) enabled at connection open — allows concurrent readers during writes - Busy timeout (
PRAGMA busy_timeout=5000) — retries on lock contention instead of failing immediately - Both services set these pragmas on every connection open
topics
Represents a physical LoRaWAN device identified by DevEUI.
| Column | Type | Description |
|---|---|---|
| id | TEXT (UUID v4) | Primary key |
| topic | TEXT UNIQUE | DevEUI |
| approved | INTEGER (0/1) | 0 = unknown, 1 = approved |
| last_seen | TEXT (RFC3339) | Last uplink timestamp |
| alarm_status | TEXT | active / warning / alarm / unknown |
| alarm_threshold_hours | REAL | Per-device threshold (0 = use global) |
| created_at | TEXT (RFC3339) | First registration |
sensors
Logical sensor entity assigned to a topic.
| Column | Type | Description |
|---|---|---|
| id | TEXT (UUID v4) | Primary key |
| name | TEXT | Display name |
| description | TEXT | Optional description |
| location | TEXT | Location string |
| sensor_type | TEXT | Type (e.g. "Temperatur") |
| active | INTEGER (0/1) | Active flag |
| topic_id | TEXT FK → topics.id | Assigned topic (nullable) |
| display_config | TEXT (JSON) | Per-sensor field config |
| created_at | TEXT (RFC3339) | Creation date |
users
| Column | Type | Description |
|---|---|---|
| id | INTEGER PK AUTOINCREMENT | Primary key |
| username | TEXT UNIQUE | Username |
| password_hash | TEXT | Argon2id hash |
| role | TEXT | "user" or "admin" |
| totp_secret | TEXT | Base32 TOTP secret (nullable) |
| totp_enabled | INTEGER (0/1) | 2FA active |
| created_at | TEXT (RFC3339) | Creation date |
4. Daemon Design
Single Rust binary running two async tasks via Tokio:
4.1 HTTP Ingest (Axum, Port 8080)
POST /?event=up: Parse ChirpStack JSON → register unknown DevEUI intopics(approved=0) → write allobjectfields + RF metadata (rssi, snr, dr, f_cnt, f_port) to InfluxDB measurementsensor_data- Tags:
dev_eui,device_name,application_name - Timestamp from ChirpStack
timefield - Routing: single
POST /handler checks?event=query parameter. Onlyevent=upis processed; all other values return 200 OK with no further action - InfluxDB errors: log at
warnlevel and continue, no crash
4.2 Alarm Monitor (Tokio interval loop)
- Runs every
check_interval_secs(default 60) - Queries all topics with
approved=1 - Per topic: use
alarm_threshold_hoursif > 0, otherwise global threshold - Status logic:
last_seenis NULL →unknown- hours since last seen < warning threshold →
active - hours since last seen < alarm threshold →
warning - hours since last seen >= alarm threshold →
alarm
- Updates
alarm_statusin SQLite
4.3 Configuration
Via config crate: loads daemon-config.toml + environment variables (prefix LORAWEB__).
| Key | Default | Description |
|---|---|---|
| http.bind | 0.0.0.0 | Bind address |
| http.port | 8080 | HTTP port |
| influx.url | http://localhost:8086 | InfluxDB URL |
| influx.token | — | API token |
| influx.org | loraweb | Organization |
| influx.bucket | sensors | Bucket |
| database.path | loraweb.db | SQLite path |
| alarm.warning_threshold_hours | 2.0 | Warning threshold |
| alarm.alarm_threshold_hours | 6.0 | Alarm threshold |
| alarm.check_interval_secs | 60 | Check interval |
4.4 Startup
Log banner showing ingest URL and ChirpStack configuration hint.
5. REST API Design
Axum on port 3001, shared SQLite DB with daemon.
5.1 Authentication
POST /api/auth/login: Single endpoint, two-step flow:- Step 1:
{ username, password }→ verify Argon2id hash- If TOTP not enabled: return
{ token: "jwt..." } - If TOTP enabled: return
{ totp_required: true, totp_token: "temporary-token" }
- If TOTP not enabled: return
- Step 2 (only if TOTP):
{ totp_token, totp_code }→ verify TOTP code against stored secrettotp_tokenis a short-lived JWT (60s, contains user_id, purpose="totp")- If valid: return
{ token: "jwt..." }
- Step 1:
- JWT claims:
{ sub: user_id, role: "user"|"admin", exp: timestamp }(HS256, 24h) - Auth middleware extracts
Authorization: Bearer <token>, injects user info into request extensions - Default admin created at first startup (password from
ADMIN_PASSWORDenv var)
Error Response Format
All API errors use a consistent envelope:
{ "error": "Human-readable error message" }
With appropriate HTTP status codes: 400 (validation), 401 (unauthorized), 403 (forbidden), 404 (not found), 500 (internal).
5.2 Endpoints
| Route | Method | Auth | Description |
|---|---|---|---|
| /api/auth/login | POST | No | Login (+ optional TOTP) |
| /api/sensors | GET | Yes | List all sensors |
| /api/sensors | POST | Yes | Create sensor |
| /api/sensors/:id | GET | Yes | Sensor detail |
| /api/sensors/:id | PUT | Yes | Update sensor |
| /api/sensors/:id | DELETE | Yes | Delete sensor (resets topic to unapproved, in SQLite transaction) |
| /api/sensors/:id/data | GET | Yes | InfluxDB proxy, ?hours=N |
| /api/sensors/:id/display-config | GET | Yes | Get field config |
| /api/sensors/:id/display-config | PUT | Yes | Save field config |
| /api/topics | GET | Yes | List all topics |
| /api/topics/:id | PUT | Yes | Approve, assign, update |
| /api/topics/:id/threshold | PUT | Yes | Set alarm threshold |
| /api/alarms | GET | Yes | Alarm summary |
| /api/users | GET | Admin | List users |
| /api/users | POST | Admin | Create user |
| /api/users/:id | PUT | Admin | Update user |
| /api/users/:id | DELETE | Admin | Delete user |
| /api/users/:id/totp | POST | Admin | Enable 2FA (returns secret + QR URI) |
| /api/users/:id/totp | DELETE | Admin | Disable 2FA |
5.3 InfluxDB Proxy
- Sends Flux query to InfluxDB via reqwest
- Receives annotated CSV (after pivot)
- Parses CSV to JSON array server-side
- Returns JSON to frontend — no InfluxDB credentials exposed
6. Frontend Design
React 18 + TypeScript, Vite 6, Tailwind 4, Recharts.
6.1 Pages
- LoginPage: Username/password form, conditional TOTP input step
- DashboardPage: 4 status tiles (active/warning/alarm/unknown counts) + active alarm list
- SensorsPage: Table (desktop) / cards (mobile), CRUD modals, DataModal with two tabs:
- "Diagramme": Charts per configured panels (D1-D8), time selector, RF metadata toggle
- "Einrichten": Per-field config (label, unit, chart type, panel assignment)
- UnknownTopicsPage: List of unapproved topics, dropdown to assign to free sensors
- Sensor edit form shows alarm threshold in minutes (only when topic assigned); conversion to hours happens in the API
- AlarmsPage: Active alarms and warnings list
- UsersPage (admin only): User CRUD, 2FA enable/disable
6.2 Charts (Recharts)
- Line chart: Time series,
connectNullsenabled - Bar chart (daily): Frontend aggregates raw data by date
- Bar chart (weekly): Frontend aggregates by calendar week
- Pie chart: Percentage distribution
- Fields assigned to panels D1-D8; same panel number = same chart
- RF metadata (RSSI, SNR, DR) toggleable in line charts
- Time ranges: 1h, 6h, 24h, 7d, 30d, 90d
- Data table below charts: last 20 values with configured labels/units
6.3 Responsive Layout
- Desktop (
lg:breakpoint, 1024px+): Static sidebar navigation - Mobile: Top bar with hamburger icon → animated slide-in drawer
- Tables switch to card lists on mobile (
hidden sm:block/sm:hidden) - DataModal: Centered overlay on desktop, bottom-sheet on mobile
6.4 Auth & State
- React Context API for auth state
- localStorage:
loraweb_token,loraweb_user - Axios interceptor: 401 response → automatic logout + redirect to login
- All pages auto-refresh every 15 seconds
7. Infrastructure
7.1 Docker Compose
Project name: loraweb. Four services:
| Service | Image | Ports (host) | Volumes |
|---|---|---|---|
| daemon | Custom (Rust) | 8080 | loraweb-data:/data |
| api | Custom (Rust) | — (internal) | loraweb-data:/data |
| frontend | Custom (nginx) | 80 | — |
| influxdb | influxdb:2.7 | — (internal) | loraweb-influx:/var/lib/influxdb2 |
All containers run as user loraweb (UID 1001).
7.2 Dockerfiles
Rust (daemon + api): Multi-stage build
- Stage 1:
rust:1.88-slim→cargo fetch→cargo build --release - Stage 2:
debian:bookworm-slim→ copy binary, create non-root user - Build context: project root (Cargo workspace)
Frontend: Multi-stage build
- Stage 1:
node:20-alpine→npm install --legacy-peer-deps→npm run build - Stage 2:
nginx:alpine→ copy dist + nginx.conf
7.3 nginx.conf
location /api/→proxy_pass http://api:3001/api/location /→ serve static files,try_files $uri /index.html(SPA fallback)
7.4 systemd
deploy/loraweb.service:
WorkingDirectory=/opt/lorawebExecStart=/usr/bin/docker compose upExecStop=/usr/bin/docker compose downRestart=always
7.5 Secrets
All via .env file (not in git). .env.example as template with:
JWT_SECRET,ADMIN_PASSWORDINFLUX_TOKEN,INFLUX_ORG,INFLUX_BUCKET
7.6 InfluxDB Bootstrap
InfluxDB 2.7 supports automatic setup via environment variables in Docker:
DOCKER_INFLUXDB_INIT_MODE=setupDOCKER_INFLUXDB_INIT_USERNAME,DOCKER_INFLUXDB_INIT_PASSWORDDOCKER_INFLUXDB_INIT_ORG=lorawebDOCKER_INFLUXDB_INIT_BUCKET=sensorsDOCKER_INFLUXDB_INIT_ADMIN_TOKEN(same value asINFLUX_TOKEN)
This creates org, bucket, and admin token on first start. Subsequent starts skip setup if data directory exists.
7.7 CORS
In production, nginx proxies all /api/ requests (same origin) — no CORS needed. For local development (Vite dev server on different port), the API includes permissive CORS headers (tower-http CorsLayer with Any origin). This matches the Pflichtenheft's known limitation E1.
8. Implementation Order (Bottom-Up)
- Database migrations (SQL file + programmatic init)
- Daemon (HTTP ingest + alarm monitor)
- REST API (auth, CRUD, InfluxDB proxy)
- Frontend (pages, charts, responsive layout)
- Docker & infrastructure (Dockerfiles, Compose, nginx, systemd)
Each layer is testable independently before the next is built.