epaper-display/LORAWEB_NEU/docs/superpowers/specs/2026-03-19-loraweb-design.md
Christian Mueller e8e2a7dd6d Address spec review findings: SQLite concurrency, TOTP flow, InfluxDB bootstrap
- 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>
2026-03-19 22:30:46 +01:00

14 KiB
Raw Permalink Blame History

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 in topics (approved=0) → write all object fields + RF metadata (rssi, snr, dr, f_cnt, f_port) to InfluxDB measurement sensor_data
  • Tags: dev_eui, device_name, application_name
  • Timestamp from ChirpStack time field
  • Routing: single POST / handler checks ?event= query parameter. Only event=up is processed; all other values return 200 OK with no further action
  • InfluxDB errors: log at warn level 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_hours if > 0, otherwise global threshold
  • Status logic:
    • last_seen is 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_status in 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" }
    • Step 2 (only if TOTP): { totp_token, totp_code } → verify TOTP code against stored secret
      • totp_token is a short-lived JWT (60s, contains user_id, purpose="totp")
      • If valid: return { token: "jwt..." }
  • 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_PASSWORD env 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

  1. LoginPage: Username/password form, conditional TOTP input step
  2. DashboardPage: 4 status tiles (active/warning/alarm/unknown counts) + active alarm list
  3. 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)
  4. 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
  5. AlarmsPage: Active alarms and warnings list
  6. UsersPage (admin only): User CRUD, 2FA enable/disable

6.2 Charts (Recharts)

  • Line chart: Time series, connectNulls enabled
  • 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-slimcargo fetchcargo 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-alpinenpm install --legacy-peer-depsnpm 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/loraweb
  • ExecStart=/usr/bin/docker compose up
  • ExecStop=/usr/bin/docker compose down
  • Restart=always

7.5 Secrets

All via .env file (not in git). .env.example as template with:

  • JWT_SECRET, ADMIN_PASSWORD
  • INFLUX_TOKEN, INFLUX_ORG, INFLUX_BUCKET

7.6 InfluxDB Bootstrap

InfluxDB 2.7 supports automatic setup via environment variables in Docker:

  • DOCKER_INFLUXDB_INIT_MODE=setup
  • DOCKER_INFLUXDB_INIT_USERNAME, DOCKER_INFLUXDB_INIT_PASSWORD
  • DOCKER_INFLUXDB_INIT_ORG=loraweb
  • DOCKER_INFLUXDB_INIT_BUCKET=sensors
  • DOCKER_INFLUXDB_INIT_ADMIN_TOKEN (same value as INFLUX_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)

  1. Database migrations (SQL file + programmatic init)
  2. Daemon (HTTP ingest + alarm monitor)
  3. REST API (auth, CRUD, InfluxDB proxy)
  4. Frontend (pages, charts, responsive layout)
  5. Docker & infrastructure (Dockerfiles, Compose, nginx, systemd)

Each layer is testable independently before the next is built.