Files
yggdrasil/server/README.md
T
2026-11-01 17:17:58 +01:00

14 KiB

Sensor Readouts

Two Flask services with separate responsibilities and separate databases:

  • main app (app.py) — all user-facing views: login, home, notes, API docs and the /sensors dashboard. It owns the main app database (main_app.db: users and notes) and proxies data requests to the sensors API.
  • sensors app (sensors_app.py) — API only. It owns the sensors database (sensors.db, tables sensor and sensor_readout) and has no knowledge of users. Its API is open to every user on the local machine.
browser ──> main app :8000 ──HTTP──> sensors API :8001
                │                          │
           main_app.db                sensors.db

Tech Stack

  • Python 3.13+
  • Flask 3 — web framework
  • SQLAlchemy (via Flask-SQLAlchemy) — ORM
  • SQLite — databases
  • Alembic — database migrations
  • Chart.js — frontend charting library

Project Structure

server/
├── app.py                    # Main app: views, notes + API proxy
├── sensors_app.py            # Sensors app: API + sensors database
├── sensors_client.py         # HTTP client used by the main app
├── auth.py                   # Flask-Login setup
├── config.py                 # Config (databases, secret key, sensors URL)
├── models.py                 # Sensor, SensorReadout (sensors DB)
├── user_models.py            # User, Note, NoteItem (main_app.db)
├── seed.py                   # Sample sensor data
├── migrate_legacy_db.py      # Import old combined data.db into split DBs
├── run.sh                    # Development launcher (main/sensors + port)
├── run-prod.sh               # Production launcher (gunicorn)
├── gunicorn.conf.py          # Gunicorn config (bind from BIND/PORT)
├── alembic.ini               # Sensors DB migrations
├── alembic/                  # Sensors DB migration scripts
├── alembic_users.ini         # Users DB migrations
├── alembic_users/            # Users DB migration scripts
├── sensors.db                # Sensors database (sensors app)
├── main_app.db               # Users database (main app)
├── data.db                   # Legacy combined DB (not used by default)
├── requirements.txt
└── templates/
    ├── home.html
    ├── login.html
    ├── notes.html
    ├── docs.html
    └── sensors.html

Setup

# Create and activate virtual environment
python3 -m venv .venv
source .venv/bin/activate

# Install dependencies
pip install -r requirements.txt

# Sensors database
alembic upgrade head

# Users database
alembic -c alembic_users.ini upgrade head

# (Optional) Sample sensor data
python seed.py

The sensors.db and main_app.db files are created by the migrations and are ignored by git (*.db).

Migrating the old combined database

Previous versions stored users, sensors and readouts in data.db. The new layout uses sensors.db and main_app.db. To copy the old rows (the legacy file is only read, never modified):

alembic upgrade head
alembic -c alembic_users.ini upgrade head
python migrate_legacy_db.py

For custom paths use --legacy, --sensors and --main-app, for example:

python migrate_legacy_db.py --legacy old.db --sensors /data/sensors.db \
    --main-app /data/main_app.db

After verifying the new databases you can archive or remove data.db.

Users

User accounts and notes live only in main_app.db (main app). There is no public registration; accounts are created by an administrator.

Main app pages (/, /home, /notes, /api/docs, /sensors) and dashboard read endpoints (GET /api/sensors/:id/types, /readouts, /readouts/hourly) require login. Device endpoints (GET/POST /api/sensors, POST /api/sensors/:id/readouts) stay open so devices can push data without a browser session.

Set a secret key for sessions (required in production):

export SECRET_KEY='long-random-value'

If SECRET_KEY is unset, the main app falls back to .secret_key in this directory (created automatically by run-prod.sh).

First admin

# Option 1: flags
flask --app app create-admin --username admin --password 'secret'

# Option 2: environment variables (e.g. on first deploy)
ADMIN_USERNAME=admin ADMIN_PASSWORD=secret flask --app app create-admin

# Option 3: interactive (asks for username/password)
flask --app app create-admin

Running create-admin for an existing username resets its password and grants is_admin=True. Log in at /login; you land on /home.

When the main app runs as a systemd service, run the command as the same user and from the same directory as the service (see yggdrasil.service):

cd /home/mateusz/prog/yggdrasil/server
sudo -u mateusz .venv/bin/flask --app app create-admin

To pass credentials without an interactive prompt:

sudo -u mateusz env ADMIN_USERNAME=admin ADMIN_PASSWORD='secret' \
    .venv/bin/flask --app app create-admin

If the unit sets USERS_DB_PATH or USERS_DATABASE_URL, add the same variable to the command, for example:

sudo -u mateusz env USERS_DB_PATH=/data/main_app.db \
    .venv/bin/flask --app app create-admin

The service does not need to be restarted; the new account is used on the next login.

Notes

Logged-in users manage their own private short notes at /notes. Each note has a title, a text body and an optional checklist. Unchecked items are shown first; when an item is checked it is saved as done, moves to the bottom of the checklist and is displayed with a line through it.

Notes are stored in main_app.db in the note and note_item tables. Apply the users database migration once (the production launcher does this automatically):

alembic -c alembic_users.ini upgrade head

Running

The first argument selects the app (main or sensors), the second the port. The main app (:8000) listens on all interfaces; the sensors API (:8001) listens on 127.0.0.1 by default and is open to every local user without authentication.

Development

./run.sh                 # main app on 8000
./run.sh main 8000
./run.sh sensors 8001

Production

pip install -r requirements.txt

./run-prod.sh main 8000
./run-prod.sh sensors 8001

Both scripts use .venv when present. run-prod.sh runs the matching migrations before starting each service.

To expose the sensors API on the network:

HOST=0.0.0.0 ./run.sh sensors 8001
# or
BIND=0.0.0.0:8001 ./run-prod.sh sensors 8001

Configuration

Variable Default Purpose
SENSORS_API_URL http://localhost:8001 Sensors API address used by the main app
SENSORS_DATABASE_URL sqlite:///<server>/sensors.db Sensors database URL
SENSORS_DB_PATH <server>/sensors.db SQLite path when URL is unset
USERS_DATABASE_URL sqlite:///<server>/main_app.db Users database URL
USERS_DB_PATH <server>/main_app.db SQLite path when URL is unset
SECRET_KEY .secret_key file Main app session signing key
HOST main 0.0.0.0, sensors 127.0.0.1 Listen address
BIND <HOST>:<port> Gunicorn bind override

DATABASE_URL/DB_PATH are accepted as legacy aliases for the sensors DB.

When the services run on different machines, point SENSORS_API_URL at the address reachable from the main app process. For example, with the sensors API on your laptop (port 8001) and the main app on a VPS:

# On the laptop: expose the local sensors API as VPS port 5432
ssh -R 5432:localhost:8001 vps

# On the VPS
SENSORS_API_URL=http://localhost:5432 ./run-prod.sh main 8000

systemd services

Main app only:

sudo cp yggdrasil.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now yggdrasil

Main app and sensors API:

sudo cp yggdrasil.service yggdrasil-sensors.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now yggdrasil yggdrasil-sensors

Check whether the main service is active:

systemctl status yggdrasil
systemctl is-active yggdrasil      # prints: active / inactive / failed
systemctl is-enabled yggdrasil     # prints: enabled / disabled

Quick HTTP check:

curl -I http://localhost:8000/login
ss -ltnp | grep ':8000'

Logs:

journalctl -u yggdrasil -f
journalctl -u yggdrasil -n 50 --no-pager
journalctl -u yggdrasil-sensors -f   # if the sensors service is installed

The unit files currently use User=mateusz and WorkingDirectory=/home/mateusz/prog/yggdrasil/server; adjust them if the deployment path or user is different.

Service Port Routes
main 8000 /, /login, /home, /notes, /sensors, /api/docs, /api/* (proxy)
sensors 8001 /api/* (sensors and readouts)

Tests

python -m unittest discover -s tests -v

Databases

sensors.db

Owned by the sensors app. Contains only sensor data.

sensor

Column Type Description
id Integer Primary key
name String(128) Unique sensor name
created_at DateTime Creation timestamp (UTC)

sensor_readout

Column Type Description
id Integer Primary key
sensor_id Integer Foreign key to sensor.id
type String(64) Readout type (e.g. temperature, pressure, humidity)
value Float Measured value
timestamp DateTime Readout timestamp (UTC)

main_app.db (users and notes)

Owned by the main app. Contains accounts and notes.

user

Column Type Description
id Integer Primary key
username String(80) Unique username
password_hash String(255) Password hash
is_admin Boolean Administrator flag
created_at DateTime Creation timestamp (UTC)

note

Column Type Description
id Integer Primary key
user_id Integer Foreign key to user.id (note owner)
title String(200) Note title (existing notes get Untitled)
content Text Note body
created_at DateTime Creation timestamp (UTC)
updated_at DateTime Last modification timestamp (UTC)

note_item

Column Type Description
id Integer Primary key
note_id Integer Foreign key to note.id
text String(255) Checklist item text
is_done Boolean Checked state (checked items render last, struck through)
created_at DateTime Creation timestamp (UTC)

Inspecting the databases on Linux

The databases are ordinary SQLite files. Use the sqlite3 CLI (install it with sudo apt install sqlite3 on Debian/Ubuntu):

# List tables
sqlite3 sensors.db ".tables"
sqlite3 main_app.db ".tables"

# Show the schema (all tables or one table)
sqlite3 sensors.db ".schema"
sqlite3 sensors.db ".schema sensor_readout"

# Show columns of one table
sqlite3 sensors.db "PRAGMA table_info(sensor);"

# Inspect rows (readable column output)
sqlite3 -header -column sensors.db \
    "SELECT id, name, created_at FROM sensor LIMIT 10;"
sqlite3 -header -column sensors.db \
    "SELECT id, sensor_id, type, value, timestamp FROM sensor_readout
     ORDER BY timestamp DESC LIMIT 10;"

# Users (do not print password_hash unnecessarily)
sqlite3 -header -column main_app.db \
    "SELECT id, username, is_admin, created_at FROM user;"

# Counts and migration version
sqlite3 sensors.db "SELECT COUNT(*) FROM sensor_readout;"
sqlite3 sensors.db "SELECT * FROM alembic_version;"
sqlite3 main_app.db "SELECT * FROM alembic_version;"

Inside an interactive sqlite3 session you can also use:

.tables                 -- list tables
.headers on             -- show column names
.mode column            -- align columns
.schema sensor          -- show CREATE TABLE
SELECT * FROM sensor;   -- run a query
.quit                   -- exit

For read-only inspection add -readonly:

sqlite3 -readonly -header -column sensors.db \
    "SELECT * FROM sensor_readout LIMIT 5;"

If sqlite3 is not available, the same tables can be listed with Python:

.venv/bin/python - <<'PY'
import sqlite3
con = sqlite3.connect("sensors.db")
print([row[0] for row in con.execute(
    "SELECT name FROM sqlite_master WHERE type='table'"
)])
PY

Migrations

# Sensors DB
alembic upgrade head
alembic revision --autogenerate -m "description"

# Users DB
alembic -c alembic_users.ini upgrade head
alembic -c alembic_users.ini revision --autogenerate -m "description"

API

Public API (main app): http://localhost:8000/api Sensors API (local machine): http://localhost:8001/api

The main app proxies every /api/sensors* request to the sensors API, so clients and devices can keep using port 8000.

Endpoints

Method Path Auth Description
GET /api/sensors public List all sensors
POST /api/sensors public Create a new sensor
GET /api/sensors/:id/types login List readout types for a sensor
GET /api/sensors/:id/readouts login Latest readouts (max 300, filterable by ?type=)
GET /api/sensors/:id/readouts/hourly login Hourly averaged readouts
POST /api/sensors/:id/readouts public Add a new readout

Calling the sensors API directly on :8001 skips login; it is open to every local user by design.

Examples

# List sensors
curl http://localhost:8000/api/sensors

# Create a sensor
curl -X POST http://localhost:8000/api/sensors \
     -H "Content-Type: application/json" \
     -d '{"name": "bmp280"}'

# Get temperature readouts for sensor 1 (requires a logged-in session)
curl -b cookies.txt "http://localhost:8000/api/sensors/1/readouts?type=temperature"

# Get hourly averaged data (requires a logged-in session)
curl -b cookies.txt "http://localhost:8000/api/sensors/1/readouts/hourly?type=temperature"

# Add a readout
curl -X POST http://localhost:8000/api/sensors/1/readouts \
     -H "Content-Type: application/json" \
     -d '{"type": "temperature", "value": 22.5}'

# Direct local access with no login
curl http://localhost:8001/api/sensors/1/readouts

Full API documentation with request/response examples is available at /api/docs.

Frontend

The dashboard in the main app at /sensors provides:

  • Sensor dropdown — select a sensor from the list
  • Type dropdown — select a readout type (populated based on the chosen sensor)
  • Line chart — raw or hourly-averaged values for the selected range
  • Data table — raw readouts with formatted timestamps (latest 300 entries)