Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df06a4129f | ||
|
|
0a9da4f9c5 | ||
|
|
cbf5f20c75 | ||
|
|
b77b348d54 | ||
|
|
e55af30644 | ||
|
|
7675acd81e | ||
|
|
dc9d5a750e | ||
|
|
85179c8a80 | ||
|
|
b7048b401d | ||
|
|
6a782c1e9b | ||
|
|
02f3df8c41 | ||
|
|
7094d05030 | ||
|
|
d603d8d574 | ||
|
|
b09dde607a | ||
|
|
f953deaec3 | ||
|
|
b607bd8d41 | ||
|
|
73f4e0d1c6 |
@@ -6,3 +6,5 @@ managed_components
|
|||||||
.cache
|
.cache
|
||||||
.vscode
|
.vscode
|
||||||
.clangd
|
.clangd
|
||||||
|
.secret_key
|
||||||
|
server/clipboard/
|
||||||
|
|||||||
+461
-53
@@ -1,13 +1,27 @@
|
|||||||
# Sensor Readouts
|
# Sensor Readouts
|
||||||
|
|
||||||
A Flask web application for managing and visualizing sensor data. It provides a REST API for creating sensors and storing readouts, along with a frontend dashboard that displays real-time charts and tabular data.
|
Two Flask services with separate responsibilities and separate databases:
|
||||||
|
|
||||||
|
- **main app** (`app.py`) — all user-facing views: login, home, notes,
|
||||||
|
notifications, clipboard, calendar, API docs and the `/sensors` dashboard. It
|
||||||
|
owns the **main app** database (`main_app.db`: users, notes, notifications and
|
||||||
|
clipboard file metadata) 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
|
## Tech Stack
|
||||||
|
|
||||||
- **Python 3.13+**
|
- **Python 3.13+**
|
||||||
- **Flask 3** — web framework
|
- **Flask 3** — web framework
|
||||||
- **SQLAlchemy** (via Flask-SQLAlchemy) — ORM
|
- **SQLAlchemy** (via Flask-SQLAlchemy) — ORM
|
||||||
- **SQLite** — database
|
- **SQLite** — databases
|
||||||
- **Alembic** — database migrations
|
- **Alembic** — database migrations
|
||||||
- **Chart.js** — frontend charting library
|
- **Chart.js** — frontend charting library
|
||||||
|
|
||||||
@@ -15,18 +29,36 @@ A Flask web application for managing and visualizing sensor data. It provides a
|
|||||||
|
|
||||||
```
|
```
|
||||||
server/
|
server/
|
||||||
├── app.py # Flask application factory and API routes
|
├── app.py # Main app: views, notes + API proxy
|
||||||
├── models.py # SQLAlchemy models (Sensor, SensorReadout)
|
├── sensors_app.py # Sensors app: API + sensors database
|
||||||
├── seed.py # Database seeder with sample data
|
├── sensors_client.py # HTTP client used by the main app
|
||||||
├── requirements.txt # Python dependencies
|
├── auth.py # Flask-Login setup
|
||||||
├── data.db # SQLite database (auto-created)
|
├── config.py # Config (databases, secret key, sensors URL)
|
||||||
├── alembic.ini # Alembic configuration
|
├── models.py # Sensor, SensorReadout (sensors DB)
|
||||||
├── alembic/ # Migration scripts
|
├── user_models.py # User, Note, NoteItem, Notification, ClipboardFile
|
||||||
│ ├── env.py
|
├── seed.py # Sample sensor data
|
||||||
│ └── versions/
|
├── 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)
|
||||||
|
├── clipboard/ # Uploaded clipboard files (runtime, not in git)
|
||||||
|
├── data.db # Legacy combined DB (not used by default)
|
||||||
|
├── requirements.txt
|
||||||
└── templates/
|
└── templates/
|
||||||
├── sensors.html # Dashboard with chart and table
|
├── home.html
|
||||||
└── docs.html # API documentation page
|
├── login.html
|
||||||
|
├── notes.html
|
||||||
|
├── notifications.html
|
||||||
|
├── clipboard.html
|
||||||
|
├── calendar.html
|
||||||
|
├── docs.html
|
||||||
|
└── sensors.html
|
||||||
```
|
```
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
@@ -39,36 +71,254 @@ source .venv/bin/activate
|
|||||||
# Install dependencies
|
# Install dependencies
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Run database migrations
|
# Sensors database
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
# (Optional) Seed the database with sample data
|
# Users database
|
||||||
|
alembic -c alembic_users.ini upgrade head
|
||||||
|
|
||||||
|
# (Optional) Sample sensor data
|
||||||
python seed.py
|
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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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, notes and notifications live only in `main_app.db` (main app).
|
||||||
|
There is no public registration; accounts are created by an administrator.
|
||||||
|
|
||||||
|
Main app pages (`/`, `/home`, `/notes`, `/notifications`, `/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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mateusz/prog/yggdrasil/server
|
||||||
|
sudo -u mateusz .venv/bin/flask --app app create-admin
|
||||||
|
```
|
||||||
|
|
||||||
|
To pass credentials without an interactive prompt:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Clipboard
|
||||||
|
|
||||||
|
Logged-in users can upload, download and delete files at `/clipboard`. Files
|
||||||
|
are stored on the server in the clipboard directory (random file name) and only
|
||||||
|
metadata (original name, content type, size, owner) is kept in `main_app.db`.
|
||||||
|
|
||||||
|
Limits:
|
||||||
|
|
||||||
|
- one file: **100 MB** (`CLIPBOARD_MAX_FILE_BYTES`)
|
||||||
|
- total storage per user: **1 GB** (`CLIPBOARD_MAX_TOTAL_BYTES`)
|
||||||
|
|
||||||
|
The per-file limit is enforced while uploading; the per-user total quota is
|
||||||
|
checked before a file is stored.
|
||||||
|
|
||||||
|
Apply the users database migration once (the production launcher does this
|
||||||
|
automatically):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic -c alembic_users.ini upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Calendar
|
||||||
|
|
||||||
|
The main app provides a `/calendar` page with two views for browsing sensor
|
||||||
|
readouts:
|
||||||
|
|
||||||
|
- **month** (`/calendar?view=month&year=YYYY&month=MM`) — calendar grid of the
|
||||||
|
given month, each day lists readouts (sensor, time, type, value)
|
||||||
|
- **week** (`/calendar?view=week&date=YYYY-MM-DD`) — list of the seven days of
|
||||||
|
the week containing the given date
|
||||||
|
|
||||||
|
Readouts are fetched from the sensors API through the
|
||||||
|
`GET /api/sensors/:id/readouts/range?from=...&to=...` endpoint.
|
||||||
|
|
||||||
|
## 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. Checklist item text can be edited at any
|
||||||
|
time.
|
||||||
|
|
||||||
|
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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic -c alembic_users.ini upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
Each user has their own short text notifications in `main_app.db`. A
|
||||||
|
notification is either **new** or **read**. The main navigation shows the number
|
||||||
|
of new notifications next to the `Notifications` link. Opening a notification
|
||||||
|
from the `/notifications` page marks it as read and moves it below the new ones.
|
||||||
|
A notification can be deleted from the `/notifications` page with the `[x]`
|
||||||
|
button (endpoint: `POST /notifications/<id>/delete`).
|
||||||
|
|
||||||
|
Create notifications with the CLI (run it as the same user as the service):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/mateusz/prog/yggdrasil/server
|
||||||
|
|
||||||
|
# For one user
|
||||||
|
sudo -u mateusz .venv/bin/flask --app app create-notification \
|
||||||
|
--username admin --message "Backup finished."
|
||||||
|
|
||||||
|
# For every user
|
||||||
|
sudo -u mateusz .venv/bin/flask --app app create-notification \
|
||||||
|
--all --message "Planned maintenance tonight."
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the users database migration once (the production launcher does this
|
||||||
|
automatically):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
alembic -c alembic_users.ini upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
## Running
|
## 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.
|
||||||
|
|
||||||
|
Do not run the main app on the sensors port (`8001`) while `SENSORS_API_URL` is
|
||||||
|
unset: it defaults to `http://localhost:8001`, so the main app would proxy API
|
||||||
|
requests to itself. Both launchers refuse to start in that configuration.
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python app.py
|
./run.sh # main app on 8000
|
||||||
|
./run.sh main 8000
|
||||||
|
./run.sh sensors 8001
|
||||||
```
|
```
|
||||||
|
|
||||||
The server starts on `http://localhost:5000`.
|
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies (includes gunicorn)
|
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
# Run with gunicorn
|
./run-prod.sh main 8000
|
||||||
gunicorn -c gunicorn.conf.py "app:create_app()"
|
./run-prod.sh sensors 8001
|
||||||
```
|
```
|
||||||
|
|
||||||
Gunicorn starts on `http://0.0.0.0:8000` with `cpu * 2 + 1` workers.
|
Both scripts use `.venv` when present. `run-prod.sh` runs the matching
|
||||||
|
migrations before starting each service.
|
||||||
|
|
||||||
#### systemd service
|
To expose the sensors API on the network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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 |
|
||||||
|
| `CLIPBOARD_DIR` | `<server>/clipboard` | Directory where uploaded files are stored |
|
||||||
|
| `CLIPBOARD_MAX_FILE_BYTES` | `104857600` (100 MB) | Maximum size of a single uploaded file |
|
||||||
|
| `CLIPBOARD_MAX_TOTAL_BYTES` | `1073741824` (1 GB) | Maximum total size of files per user |
|
||||||
|
| `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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo cp yggdrasil.service /etc/systemd/system/
|
sudo cp yggdrasil.service /etc/systemd/system/
|
||||||
@@ -76,14 +326,57 @@ sudo systemctl daemon-reload
|
|||||||
sudo systemctl enable --now yggdrasil
|
sudo systemctl enable --now yggdrasil
|
||||||
```
|
```
|
||||||
|
|
||||||
| Route | Description |
|
Main app and sensors API:
|
||||||
|---|---|
|
|
||||||
| `/sensors` | Dashboard with interactive chart and data table |
|
|
||||||
| `/api/docs` | API documentation with curl examples |
|
|
||||||
|
|
||||||
## Database
|
```bash
|
||||||
|
sudo cp yggdrasil.service yggdrasil-sensors.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now yggdrasil yggdrasil-sensors
|
||||||
|
```
|
||||||
|
|
||||||
Two tables are used:
|
Check whether the main service is active:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status yggdrasil
|
||||||
|
systemctl is-active yggdrasil # prints: active / inactive / failed
|
||||||
|
systemctl is-enabled yggdrasil # prints: enabled / disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
Quick HTTP check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -I http://localhost:8000/login
|
||||||
|
ss -ltnp | grep ':8000'
|
||||||
|
```
|
||||||
|
|
||||||
|
Logs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`, `/clipboard`, `/calendar`, `/notes`, `/notifications`, `/sensors`, `/api/docs`, `/api/*` (proxy) |
|
||||||
|
| sensors | 8001 | `/api/*` (sensors and readouts) |
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover -s tests -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Databases
|
||||||
|
|
||||||
|
### sensors.db
|
||||||
|
|
||||||
|
Owned by the sensors app. Contains only sensor data.
|
||||||
|
|
||||||
**sensor**
|
**sensor**
|
||||||
| Column | Type | Description |
|
| Column | Type | Description |
|
||||||
@@ -101,64 +394,179 @@ Two tables are used:
|
|||||||
| `value` | Float | Measured value |
|
| `value` | Float | Measured value |
|
||||||
| `timestamp` | DateTime | Readout timestamp (UTC) |
|
| `timestamp` | DateTime | Readout timestamp (UTC) |
|
||||||
|
|
||||||
|
### main_app.db (users, notes and notifications)
|
||||||
|
|
||||||
|
Owned by the main app. Contains accounts, notes and notifications.
|
||||||
|
|
||||||
|
**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) |
|
||||||
|
|
||||||
|
**notification**
|
||||||
|
| Column | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | Integer | Primary key |
|
||||||
|
| `user_id` | Integer | Foreign key to `user.id` (recipient) |
|
||||||
|
| `message` | String(500) | Short notification text |
|
||||||
|
| `is_read` | Boolean | `false` = new, `true` = read |
|
||||||
|
| `created_at` | DateTime | Creation timestamp (UTC) |
|
||||||
|
| `read_at` | DateTime | When the notification was read, or `NULL` |
|
||||||
|
|
||||||
|
### 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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
.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`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.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
|
### Migrations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create a new migration after model changes
|
# Sensors DB
|
||||||
|
alembic upgrade head
|
||||||
alembic revision --autogenerate -m "description"
|
alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
# Apply pending migrations
|
# Users DB
|
||||||
alembic upgrade head
|
alembic -c alembic_users.ini upgrade head
|
||||||
|
alembic -c alembic_users.ini revision --autogenerate -m "description"
|
||||||
# Roll back one step
|
|
||||||
alembic downgrade -1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
Base URL: `http://localhost:5000/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
|
### Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Auth | Description |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET` | `/api/sensors` | List all sensors |
|
| `GET` | `/api/sensors` | public | List all sensors |
|
||||||
| `POST` | `/api/sensors` | Create a new sensor |
|
| `POST` | `/api/sensors` | public | Create a new sensor |
|
||||||
| `GET` | `/api/sensors/:id/types` | List readout types for a sensor |
|
| `GET` | `/api/sensors/:id/types` | login | List readout types for a sensor |
|
||||||
| `GET` | `/api/sensors/:id/readouts` | Latest readouts (max 300, filterable by `?type=`) |
|
| `GET` | `/api/sensors/:id/readouts` | login | Latest readouts (max 300, filterable by `?type=`) |
|
||||||
| `GET` | `/api/sensors/:id/readouts/hourly` | Hourly averaged readouts (filterable by `?type=`) |
|
| `GET` | `/api/sensors/:id/readouts/hourly` | login | Hourly averaged readouts |
|
||||||
| `POST` | `/api/sensors/:id/readouts` | Add a new readout |
|
| `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
|
### Examples
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List sensors
|
# List sensors
|
||||||
curl http://localhost:5000/api/sensors
|
curl http://localhost:8000/api/sensors
|
||||||
|
|
||||||
# Create a sensor
|
# Create a sensor
|
||||||
curl -X POST http://localhost:5000/api/sensors \
|
curl -X POST http://localhost:8000/api/sensors \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name": "bmp280"}'
|
-d '{"name": "bmp280"}'
|
||||||
|
|
||||||
# Get temperature readouts for sensor 1
|
# Get temperature readouts for sensor 1 (requires a logged-in session)
|
||||||
curl "http://localhost:5000/api/sensors/1/readouts?type=temperature"
|
curl -b cookies.txt "http://localhost:8000/api/sensors/1/readouts?type=temperature"
|
||||||
|
|
||||||
# Get hourly averaged data
|
# Get hourly averaged data (requires a logged-in session)
|
||||||
curl "http://localhost:5000/api/sensors/1/readouts/hourly?type=temperature"
|
curl -b cookies.txt "http://localhost:8000/api/sensors/1/readouts/hourly?type=temperature"
|
||||||
|
|
||||||
# Add a readout
|
# Add a readout
|
||||||
curl -X POST http://localhost:5000/api/sensors/1/readouts \
|
curl -X POST http://localhost:8000/api/sensors/1/readouts \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"type": "temperature", "value": 22.5}'
|
-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`.
|
Full API documentation with request/response examples is available at `/api/docs`.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
The dashboard at `/sensors` provides:
|
The dashboard in the main app at `/sensors` provides:
|
||||||
|
|
||||||
- **Sensor dropdown** — select a sensor from the list
|
- **Sensor dropdown** — select a sensor from the list
|
||||||
- **Type dropdown** — select a readout type (populated based on the chosen sensor)
|
- **Type dropdown** — select a readout type (populated based on the chosen sensor)
|
||||||
- **Line chart** — hourly averaged values rendered with Chart.js
|
- **Line chart** — raw or hourly-averaged values for the selected range
|
||||||
- **Data table** — raw readouts with formatted timestamps (latest 300 entries)
|
- **Data table** — raw readouts with formatted timestamps (latest 300 entries)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy import engine_from_config, pool
|
|||||||
# Add the project root to sys.path so that models can be imported.
|
# Add the project root to sys.path so that models can be imported.
|
||||||
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..")))
|
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
from app import create_app
|
from sensors_app import create_app
|
||||||
from models import db
|
from models import db
|
||||||
|
|
||||||
# Create the Flask application to get the database URI.
|
# Create the Flask application to get the database URI.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""deprecated user table (users moved to the main app)
|
||||||
|
|
||||||
|
Revision ID: c59e689377c2
|
||||||
|
Revises: 7f92bfc47dfc
|
||||||
|
Create Date: 2026-10-03 19:06:44.095699
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c59e689377c2'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '7f92bfc47dfc'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""No-op: the sensors database no longer stores users.
|
||||||
|
|
||||||
|
The revision is kept so legacy databases that already point at it can
|
||||||
|
still be upgraded. New sensors databases create only sensor tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""No-op: the user table is no longer part of the sensors database."""
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = %(here)s/alembic_users
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from config import users_database_uri
|
||||||
|
from user_models import user_db
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", users_database_uri())
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = user_db.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""create user table
|
||||||
|
|
||||||
|
Revision ID: 1a7c3e5d9b21
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-11-01 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "1a7c3e5d9b21"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"user",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("username", sa.String(length=80), nullable=False),
|
||||||
|
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("is_admin", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("username"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("user")
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""create notes
|
||||||
|
|
||||||
|
Revision ID: b7d4f1a2c3e5
|
||||||
|
Revises: 1a7c3e5d9b21
|
||||||
|
Create Date: 2026-11-01 17:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b7d4f1a2c3e5"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "1a7c3e5d9b21"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"note",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_note_user_id"), "note", ["user_id"], unique=False)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"note_item",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("note_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("text", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("is_done", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["note_id"], ["note.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_note_item_note_id"), "note_item", ["note_id"], unique=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_note_item_note_id"), table_name="note_item")
|
||||||
|
op.drop_table("note_item")
|
||||||
|
op.drop_index(op.f("ix_note_user_id"), table_name="note")
|
||||||
|
op.drop_table("note")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""add note title
|
||||||
|
|
||||||
|
Revision ID: c9e5a1b7d3f2
|
||||||
|
Revises: b7d4f1a2c3e5
|
||||||
|
Create Date: 2026-11-01 17:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c9e5a1b7d3f2"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "b7d4f1a2c3e5"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"note",
|
||||||
|
sa.Column(
|
||||||
|
"title",
|
||||||
|
sa.String(length=200),
|
||||||
|
nullable=False,
|
||||||
|
server_default="Untitled",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("note") as batch_op:
|
||||||
|
batch_op.drop_column("title")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""create notifications
|
||||||
|
|
||||||
|
Revision ID: e2b6d8f0a4c1
|
||||||
|
Revises: c9e5a1b7d3f2
|
||||||
|
Create Date: 2026-11-01 18:55:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "e2b6d8f0a4c1"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "c9e5a1b7d3f2"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"notification",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("message", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("is_read", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.Column("read_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_notification_user_id"), "notification", ["user_id"], unique=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_notification_user_id"), table_name="notification")
|
||||||
|
op.drop_table("notification")
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""create clipboard files
|
||||||
|
|
||||||
|
Revision ID: f5c7d9a1b3e8
|
||||||
|
Revises: e2b6d8f0a4c1
|
||||||
|
Create Date: 2026-11-01 20:45:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f5c7d9a1b3e8"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "e2b6d8f0a4c1"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"clipboard_file",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("original_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("stored_name", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("size", sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column("content_type", sa.String(length=255), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["user.id"]),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("stored_name"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_clipboard_file_user_id"),
|
||||||
|
"clipboard_file",
|
||||||
|
["user_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_clipboard_file_user_id"), table_name="clipboard_file")
|
||||||
|
op.drop_table("clipboard_file")
|
||||||
+773
-103
@@ -1,136 +1,806 @@
|
|||||||
|
import fcntl
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from flask import Flask, jsonify, render_template, request
|
import click
|
||||||
|
from flask import (
|
||||||
|
Flask,
|
||||||
|
flash,
|
||||||
|
jsonify,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
send_file,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
from flask_login import current_user, login_required, login_user, logout_user
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
from models import Sensor, SensorReadout, db
|
from auth import init_login_manager
|
||||||
|
from config import (
|
||||||
|
clipboard_dir,
|
||||||
|
clipboard_max_file_bytes,
|
||||||
|
clipboard_max_total_bytes,
|
||||||
|
load_secret_key,
|
||||||
|
sensors_api_url,
|
||||||
|
users_database_uri,
|
||||||
|
)
|
||||||
|
from sensors_client import SensorsAPIError, SensorsClient
|
||||||
|
from user_models import (
|
||||||
|
ClipboardFile,
|
||||||
|
Note,
|
||||||
|
NoteItem,
|
||||||
|
Notification,
|
||||||
|
User,
|
||||||
|
user_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def format_bytes(value: int) -> str:
|
||||||
|
size = float(value)
|
||||||
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||||
|
if size < 1024 or unit == "TB":
|
||||||
|
if unit == "B":
|
||||||
|
return f"{int(size)} B"
|
||||||
|
return f"{size:.1f} {unit}"
|
||||||
|
size /= 1024
|
||||||
|
return f"{int(size)} B"
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_TZ_DEFAULT = "Europe/Warsaw"
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_tz(name):
|
||||||
|
try:
|
||||||
|
return ZoneInfo(name)
|
||||||
|
except Exception:
|
||||||
|
return ZoneInfo(LOCAL_TZ_DEFAULT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date_arg(raw):
|
||||||
|
value = (raw or "").strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _day_bounds_utc(day: date, tz):
|
||||||
|
start_local = datetime(day.year, day.month, day.day)
|
||||||
|
end_local = start_local + timedelta(days=1)
|
||||||
|
start_utc = start_local.replace(tzinfo=tz).astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
end_utc = end_local.replace(tzinfo=tz).astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return start_utc, end_utc
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
db_path = os.path.join(os.path.dirname(__file__), "data.db")
|
app.config["SECRET_KEY"] = load_secret_key()
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
|
app.config["SQLALCHEMY_DATABASE_URI"] = users_database_uri()
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
app.config["SENSORS_API_URL"] = sensors_api_url()
|
||||||
|
app.config["CLIPBOARD_DIR"] = str(clipboard_dir())
|
||||||
|
app.config["CLIPBOARD_MAX_FILE_BYTES"] = clipboard_max_file_bytes()
|
||||||
|
app.config["CLIPBOARD_MAX_TOTAL_BYTES"] = clipboard_max_total_bytes()
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = (
|
||||||
|
app.config["CLIPBOARD_MAX_FILE_BYTES"] + 1024 * 1024
|
||||||
|
)
|
||||||
|
Path(app.config["CLIPBOARD_DIR"]).mkdir(parents=True, exist_ok=True)
|
||||||
|
app.add_template_filter(format_bytes, "filesize")
|
||||||
|
|
||||||
db.init_app(app)
|
user_db.init_app(app)
|
||||||
|
sensors = SensorsClient(app.config["SENSORS_API_URL"])
|
||||||
|
|
||||||
|
def _login_redirect(req):
|
||||||
|
login_url = url_for("login")
|
||||||
|
if req.path and req.path != login_url:
|
||||||
|
return url_for("login", next=req.path)
|
||||||
|
return login_url
|
||||||
|
|
||||||
|
def _load_user(user_id):
|
||||||
|
try:
|
||||||
|
return user_db.session.get(User, int(user_id))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
init_login_manager(app, _login_redirect, _load_user)
|
||||||
|
|
||||||
|
def _is_safe_next(target: str | None) -> bool:
|
||||||
|
return bool(target) and target.startswith("/") and not target.startswith("//")
|
||||||
|
|
||||||
|
def _proxy(call):
|
||||||
|
try:
|
||||||
|
status, data = call()
|
||||||
|
except SensorsAPIError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 502
|
||||||
|
return jsonify(data), status
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def inject_globals():
|
||||||
|
unread_notifications = 0
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
unread_notifications = Notification.query.filter_by(
|
||||||
|
user_id=current_user.id, is_read=False
|
||||||
|
).count()
|
||||||
|
return {
|
||||||
|
"main_api_url": request.url_root.rstrip("/") + "/api",
|
||||||
|
"unread_notifications": unread_notifications,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Pages ---
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def index():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("home"))
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
@app.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for("home"))
|
||||||
|
error = None
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.form.get("username", "").strip()
|
||||||
|
password = request.form.get("password", "")
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if user and user.check_password(password):
|
||||||
|
login_user(user)
|
||||||
|
next_page = request.args.get("next") or request.form.get("next")
|
||||||
|
if not _is_safe_next(next_page):
|
||||||
|
next_page = url_for("home")
|
||||||
|
return redirect(next_page)
|
||||||
|
error = "Invalid username or password."
|
||||||
|
return render_template("login.html", error=error, next=request.args.get("next", ""))
|
||||||
|
|
||||||
|
@app.route("/logout")
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
@app.route("/home")
|
||||||
|
@login_required
|
||||||
|
def home():
|
||||||
|
return render_template("home.html", user=current_user)
|
||||||
|
|
||||||
@app.route("/sensors")
|
@app.route("/sensors")
|
||||||
|
@login_required
|
||||||
def sensors_page():
|
def sensors_page():
|
||||||
return render_template("sensors.html")
|
return render_template("sensors.html", user=current_user)
|
||||||
|
|
||||||
@app.route("/api/docs")
|
@app.route("/api/docs")
|
||||||
|
@login_required
|
||||||
def api_docs():
|
def api_docs():
|
||||||
return render_template("docs.html")
|
return render_template("docs.html", user=current_user)
|
||||||
|
|
||||||
|
# --- Clipboard ---
|
||||||
|
|
||||||
|
def _clipboard_path(name):
|
||||||
|
return Path(app.config["CLIPBOARD_DIR"]) / name
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _clipboard_lock():
|
||||||
|
lock_path = Path(app.config["CLIPBOARD_DIR"]) / ".clipboard.lock"
|
||||||
|
with open(lock_path, "a+") as lock_file:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
def _clipboard_file_or_404(file_id):
|
||||||
|
return ClipboardFile.query.filter_by(
|
||||||
|
id=file_id, user_id=current_user.id
|
||||||
|
).first_or_404()
|
||||||
|
|
||||||
|
@app.route("/clipboard")
|
||||||
|
@login_required
|
||||||
|
def clipboard_page():
|
||||||
|
files = (
|
||||||
|
ClipboardFile.query.filter_by(user_id=current_user.id)
|
||||||
|
.order_by(ClipboardFile.created_at.desc(), ClipboardFile.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
total_used = (
|
||||||
|
user_db.session.query(
|
||||||
|
func.coalesce(func.sum(ClipboardFile.size), 0)
|
||||||
|
)
|
||||||
|
.filter(ClipboardFile.user_id == current_user.id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
max_total = app.config["CLIPBOARD_MAX_TOTAL_BYTES"]
|
||||||
|
return render_template(
|
||||||
|
"clipboard.html",
|
||||||
|
user=current_user,
|
||||||
|
files=files,
|
||||||
|
total_used=total_used,
|
||||||
|
max_total=max_total,
|
||||||
|
remaining=max(0, max_total - total_used),
|
||||||
|
max_file=app.config["CLIPBOARD_MAX_FILE_BYTES"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def request_too_large(_error):
|
||||||
|
flash(
|
||||||
|
"Upload too large. Maximum file size is "
|
||||||
|
f"{format_bytes(app.config['CLIPBOARD_MAX_FILE_BYTES'])}.",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
@app.route("/clipboard/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def clipboard_upload():
|
||||||
|
upload = request.files.get("file")
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
flash("Choose a file to upload.", "error")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
original_name = (
|
||||||
|
os.path.basename(upload.filename.replace("\\", "/"))
|
||||||
|
.strip()
|
||||||
|
.replace("\x00", "")
|
||||||
|
)
|
||||||
|
if not original_name:
|
||||||
|
flash("The file name is invalid.", "error")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
original_name = original_name[:255]
|
||||||
|
|
||||||
|
directory = Path(app.config["CLIPBOARD_DIR"])
|
||||||
|
stored_name = uuid.uuid4().hex
|
||||||
|
temp_path = directory / f".{stored_name}.tmp"
|
||||||
|
final_path = directory / stored_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
upload.save(str(temp_path))
|
||||||
|
size = temp_path.stat().st_size
|
||||||
|
except OSError:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
flash("Could not save the uploaded file.", "error")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
if size <= 0:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
flash("The file is empty.", "error")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
max_file = app.config["CLIPBOARD_MAX_FILE_BYTES"]
|
||||||
|
if size > max_file:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
flash(
|
||||||
|
f"File is too large. Maximum size is {format_bytes(max_file)}.",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
with _clipboard_lock():
|
||||||
|
total_used = (
|
||||||
|
user_db.session.query(
|
||||||
|
func.coalesce(func.sum(ClipboardFile.size), 0)
|
||||||
|
)
|
||||||
|
.filter(ClipboardFile.user_id == current_user.id)
|
||||||
|
.scalar()
|
||||||
|
or 0
|
||||||
|
)
|
||||||
|
max_total = app.config["CLIPBOARD_MAX_TOTAL_BYTES"]
|
||||||
|
if total_used + size > max_total:
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
flash(
|
||||||
|
f"Clipboard storage is full "
|
||||||
|
f"({format_bytes(max_total)} limit).",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.replace(temp_path, final_path)
|
||||||
|
user_db.session.add(
|
||||||
|
ClipboardFile(
|
||||||
|
user_id=current_user.id,
|
||||||
|
original_name=original_name,
|
||||||
|
stored_name=stored_name,
|
||||||
|
size=size,
|
||||||
|
content_type=(
|
||||||
|
upload.mimetype or "application/octet-stream"
|
||||||
|
)[:255],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
user_db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
user_db.session.rollback()
|
||||||
|
final_path.unlink(missing_ok=True)
|
||||||
|
temp_path.unlink(missing_ok=True)
|
||||||
|
flash("Could not save the uploaded file.", "error")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
flash(f"Uploaded {original_name}.", "success")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
@app.route("/clipboard/<int:file_id>/download")
|
||||||
|
@login_required
|
||||||
|
def clipboard_download(file_id):
|
||||||
|
entry = _clipboard_file_or_404(file_id)
|
||||||
|
path = _clipboard_path(entry.stored_name)
|
||||||
|
if not path.is_file():
|
||||||
|
flash("The file is missing from the server.", "error")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
return send_file(
|
||||||
|
path,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=entry.original_name,
|
||||||
|
mimetype=entry.content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/clipboard/<int:file_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def clipboard_delete(file_id):
|
||||||
|
entry = _clipboard_file_or_404(file_id)
|
||||||
|
name = entry.original_name
|
||||||
|
path = _clipboard_path(entry.stored_name)
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
flash("Could not delete the file from disk.", "error")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
user_db.session.delete(entry)
|
||||||
|
user_db.session.commit()
|
||||||
|
flash(f"Deleted {name}.", "success")
|
||||||
|
return redirect(url_for("clipboard_page"))
|
||||||
|
|
||||||
|
@app.route("/api/sensors/<int:sensor_id>/readouts/range")
|
||||||
|
@login_required
|
||||||
|
def api_sensor_readouts_range(sensor_id):
|
||||||
|
return _proxy(
|
||||||
|
lambda: sensors.sensor_readouts_range(sensor_id, request.args.to_dict())
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/calendar")
|
||||||
|
@login_required
|
||||||
|
def calendar_page():
|
||||||
|
view = (request.args.get("view") or "month").strip().lower()
|
||||||
|
if view not in ("month", "week"):
|
||||||
|
view = "month"
|
||||||
|
tz = _calendar_tz(request.args.get("tz") or LOCAL_TZ_DEFAULT)
|
||||||
|
now_local = datetime.now(tz)
|
||||||
|
|
||||||
|
year = now_local.year
|
||||||
|
month = now_local.month
|
||||||
|
week_start = None
|
||||||
|
week_end = None
|
||||||
|
week_prev = None
|
||||||
|
week_next = None
|
||||||
|
days = []
|
||||||
|
month_weeks = None
|
||||||
|
|
||||||
|
if view == "week":
|
||||||
|
week_date = _parse_date_arg(request.args.get("date")) or now_local.date()
|
||||||
|
week_start = week_date - timedelta(days=week_date.weekday())
|
||||||
|
week_end = week_start + timedelta(days=6)
|
||||||
|
week_prev = week_start - timedelta(days=7)
|
||||||
|
week_next = week_start + timedelta(days=7)
|
||||||
|
day_list = [week_start + timedelta(days=i) for i in range(7)]
|
||||||
|
range_from = _day_bounds_utc(week_start, tz)[0]
|
||||||
|
range_to = _day_bounds_utc(week_end + timedelta(days=1), tz)[0]
|
||||||
|
else:
|
||||||
|
year = int(request.args.get("year") or now_local.year)
|
||||||
|
month = int(request.args.get("month") or now_local.month)
|
||||||
|
if not 1 <= month <= 12:
|
||||||
|
month = 1
|
||||||
|
first_day = date(year, month, 1)
|
||||||
|
next_year = year + 1 if month == 12 else year
|
||||||
|
next_month = 1 if month == 12 else month + 1
|
||||||
|
last_day = date(next_year, next_month, 1) - timedelta(days=1)
|
||||||
|
day_list = []
|
||||||
|
current = first_day
|
||||||
|
while current <= last_day:
|
||||||
|
day_list.append(current)
|
||||||
|
current += timedelta(days=1)
|
||||||
|
range_from, _ = _day_bounds_utc(first_day, tz)
|
||||||
|
range_to, _ = _day_bounds_utc(last_day, tz)
|
||||||
|
week_start = first_day - timedelta(days=first_day.weekday())
|
||||||
|
week_end = last_day
|
||||||
|
|
||||||
|
readouts_by_day = {day: [] for day in day_list}
|
||||||
|
sensor_names = {}
|
||||||
|
try:
|
||||||
|
listed = sensors.list_sensors()
|
||||||
|
for sensor in listed[1] or []:
|
||||||
|
sensor_id = int(sensor["id"])
|
||||||
|
sensor_names[sensor_id] = sensor["name"]
|
||||||
|
status, payload = sensors.sensor_readouts_range(
|
||||||
|
sensor_id,
|
||||||
|
{"from": range_from.isoformat(), "to": range_to.isoformat()},
|
||||||
|
)
|
||||||
|
if status != 200:
|
||||||
|
continue
|
||||||
|
for item in payload or []:
|
||||||
|
raw_ts = (item.get("timestamp") or "").rstrip("Z")
|
||||||
|
ts = datetime.fromisoformat(raw_ts) if raw_ts else None
|
||||||
|
if ts is None:
|
||||||
|
continue
|
||||||
|
local_day = ts.replace(tzinfo=timezone.utc).astimezone(tz).date()
|
||||||
|
if local_day in readouts_by_day:
|
||||||
|
readouts_by_day[local_day].append(
|
||||||
|
{
|
||||||
|
"id": item.get("id"),
|
||||||
|
"sensor_id": item.get("sensor_id") or sensor_id,
|
||||||
|
"type": item.get("type"),
|
||||||
|
"value": item.get("value"),
|
||||||
|
"timestamp": ts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except SensorsAPIError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
days = [
|
||||||
|
{"date": day, "readouts": readouts_by_day.get(day, [])}
|
||||||
|
for day in day_list
|
||||||
|
]
|
||||||
|
|
||||||
|
month_weeks = None
|
||||||
|
if view == "month":
|
||||||
|
first_monday = week_start
|
||||||
|
last_sunday = week_end + timedelta(days=6 - week_end.weekday())
|
||||||
|
end_exclusive = last_sunday + timedelta(days=1)
|
||||||
|
weeks = []
|
||||||
|
week = []
|
||||||
|
current = first_monday
|
||||||
|
while current < end_exclusive:
|
||||||
|
week.append(
|
||||||
|
{
|
||||||
|
"date": current,
|
||||||
|
"in_month": current.month == month,
|
||||||
|
"readouts": readouts_by_day.get(current, []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if current.weekday() == 6:
|
||||||
|
weeks.append(week)
|
||||||
|
week = []
|
||||||
|
current += timedelta(days=1)
|
||||||
|
if week:
|
||||||
|
weeks.append(week)
|
||||||
|
month_weeks = weeks
|
||||||
|
|
||||||
|
if month == 1:
|
||||||
|
prev_year, prev_month = year - 1, 12
|
||||||
|
else:
|
||||||
|
prev_year, prev_month = year, month - 1
|
||||||
|
if month == 12:
|
||||||
|
next_year, next_month = year + 1, 1
|
||||||
|
else:
|
||||||
|
next_year, next_month = year, month + 1
|
||||||
|
|
||||||
|
month_names = (
|
||||||
|
"January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December",
|
||||||
|
)
|
||||||
|
month_label = f"{month_names[month - 1]} {year}"
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"calendar.html",
|
||||||
|
user=current_user,
|
||||||
|
view=view,
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
week_start=week_start,
|
||||||
|
week_end=week_end,
|
||||||
|
days=days,
|
||||||
|
month_weeks=month_weeks,
|
||||||
|
sensor_names=sensor_names,
|
||||||
|
prev_year=prev_year,
|
||||||
|
prev_month=prev_month,
|
||||||
|
next_year=next_year,
|
||||||
|
next_month=next_month,
|
||||||
|
week_prev=week_prev,
|
||||||
|
week_next=week_next,
|
||||||
|
today=now_local.date(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Notifications (stored in main_app.db) ---
|
||||||
|
|
||||||
|
@app.route("/notifications")
|
||||||
|
@login_required
|
||||||
|
def notifications_page():
|
||||||
|
notifications = (
|
||||||
|
Notification.query.filter_by(user_id=current_user.id)
|
||||||
|
.order_by(
|
||||||
|
Notification.is_read.asc(),
|
||||||
|
Notification.created_at.desc(),
|
||||||
|
Notification.id.desc(),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return render_template(
|
||||||
|
"notifications.html", user=current_user, notifications=notifications
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/notifications/<int:notification_id>/read")
|
||||||
|
@login_required
|
||||||
|
def notification_read(notification_id):
|
||||||
|
notification = Notification.query.filter_by(
|
||||||
|
id=notification_id, user_id=current_user.id
|
||||||
|
).first_or_404()
|
||||||
|
if not notification.is_read:
|
||||||
|
notification.mark_read()
|
||||||
|
user_db.session.commit()
|
||||||
|
return redirect(
|
||||||
|
url_for(
|
||||||
|
"notifications_page",
|
||||||
|
_anchor=f"notification-{notification.id}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/notifications/<int:notification_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def notification_delete(notification_id):
|
||||||
|
notification = Notification.query.filter_by(
|
||||||
|
id=notification_id, user_id=current_user.id
|
||||||
|
).first_or_404()
|
||||||
|
user_db.session.delete(notification)
|
||||||
|
user_db.session.commit()
|
||||||
|
flash("Deleted notification.", "success")
|
||||||
|
return redirect(url_for("notifications_page"))
|
||||||
|
|
||||||
|
# --- Notes (stored in main_app.db) ---
|
||||||
|
|
||||||
|
def _note_or_404(note_id):
|
||||||
|
return Note.query.filter_by(
|
||||||
|
id=note_id, user_id=current_user.id
|
||||||
|
).first_or_404()
|
||||||
|
|
||||||
|
def _note_redirect(note_id):
|
||||||
|
return redirect(
|
||||||
|
url_for("notes_page", _anchor=f"note-{note_id}")
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/notes")
|
||||||
|
@login_required
|
||||||
|
def notes_page():
|
||||||
|
notes = (
|
||||||
|
Note.query.filter_by(user_id=current_user.id)
|
||||||
|
.order_by(Note.updated_at.desc(), Note.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return render_template("notes.html", user=current_user, notes=notes)
|
||||||
|
|
||||||
|
@app.route("/notes", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def notes_create():
|
||||||
|
title = request.form.get("title", "").strip()
|
||||||
|
content = request.form.get("content", "").strip()
|
||||||
|
if not title:
|
||||||
|
return redirect(url_for("notes_page"))
|
||||||
|
note = Note(
|
||||||
|
user_id=current_user.id,
|
||||||
|
title=title[:200],
|
||||||
|
content=content[:2000],
|
||||||
|
)
|
||||||
|
user_db.session.add(note)
|
||||||
|
user_db.session.commit()
|
||||||
|
return _note_redirect(note.id)
|
||||||
|
|
||||||
|
@app.route("/notes/autosave", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def notes_autosave():
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "json required"}), 400
|
||||||
|
note_id = data.get("id")
|
||||||
|
title = (data.get("title") or "").strip()[:200]
|
||||||
|
content = (data.get("content") or "").strip()[:2000]
|
||||||
|
if not title:
|
||||||
|
return jsonify({"error": "title required"}), 400
|
||||||
|
if note_id:
|
||||||
|
note = _note_or_404(int(note_id))
|
||||||
|
note.title = title
|
||||||
|
note.content = content
|
||||||
|
note.touch()
|
||||||
|
user_db.session.commit()
|
||||||
|
return jsonify({"id": note.id, "status": "saved"})
|
||||||
|
note = Note(
|
||||||
|
user_id=current_user.id,
|
||||||
|
title=title,
|
||||||
|
content=content,
|
||||||
|
)
|
||||||
|
user_db.session.add(note)
|
||||||
|
user_db.session.commit()
|
||||||
|
return jsonify({"id": note.id, "status": "created"})
|
||||||
|
|
||||||
|
@app.route("/notes/<int:note_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def notes_update(note_id):
|
||||||
|
note = _note_or_404(note_id)
|
||||||
|
title = request.form.get("title", "").strip()
|
||||||
|
content = request.form.get("content", "").strip()
|
||||||
|
if title:
|
||||||
|
note.title = title[:200]
|
||||||
|
note.content = content[:2000]
|
||||||
|
note.touch()
|
||||||
|
user_db.session.commit()
|
||||||
|
return _note_redirect(note.id)
|
||||||
|
|
||||||
|
@app.route("/notes/<int:note_id>/delete", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def notes_delete(note_id):
|
||||||
|
note = _note_or_404(note_id)
|
||||||
|
user_db.session.delete(note)
|
||||||
|
user_db.session.commit()
|
||||||
|
return redirect(url_for("notes_page"))
|
||||||
|
|
||||||
|
@app.route("/notes/<int:note_id>/items", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def notes_add_item(note_id):
|
||||||
|
note = _note_or_404(note_id)
|
||||||
|
text = request.form.get("text", "").strip()
|
||||||
|
if text:
|
||||||
|
item = NoteItem(note=note, text=text[:255])
|
||||||
|
note.touch()
|
||||||
|
user_db.session.add(item)
|
||||||
|
user_db.session.commit()
|
||||||
|
return _note_redirect(note.id)
|
||||||
|
|
||||||
|
@app.route(
|
||||||
|
"/notes/<int:note_id>/items/<int:item_id>", methods=["POST"]
|
||||||
|
)
|
||||||
|
@login_required
|
||||||
|
def notes_update_item(note_id, item_id):
|
||||||
|
note = _note_or_404(note_id)
|
||||||
|
item = NoteItem.query.filter_by(id=item_id, note_id=note.id).first_or_404()
|
||||||
|
if request.is_json:
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
text = (data.get("text") or "").strip()
|
||||||
|
else:
|
||||||
|
text = request.form.get("text", "").strip()
|
||||||
|
if text:
|
||||||
|
item.text = text[:255]
|
||||||
|
note.touch()
|
||||||
|
user_db.session.commit()
|
||||||
|
if request.is_json:
|
||||||
|
return jsonify({"id": item.id, "status": "saved"})
|
||||||
|
return _note_redirect(note.id)
|
||||||
|
|
||||||
|
@app.route(
|
||||||
|
"/notes/<int:note_id>/items/<int:item_id>/toggle", methods=["POST"]
|
||||||
|
)
|
||||||
|
@login_required
|
||||||
|
def notes_toggle_item(note_id, item_id):
|
||||||
|
note = _note_or_404(note_id)
|
||||||
|
item = NoteItem.query.filter_by(id=item_id, note_id=note.id).first_or_404()
|
||||||
|
item.is_done = request.form.get("is_done") in {"1", "true", "on", "yes"}
|
||||||
|
note.touch()
|
||||||
|
user_db.session.commit()
|
||||||
|
return _note_redirect(note.id)
|
||||||
|
|
||||||
|
@app.route(
|
||||||
|
"/notes/<int:note_id>/items/<int:item_id>/delete", methods=["POST"]
|
||||||
|
)
|
||||||
|
@login_required
|
||||||
|
def notes_delete_item(note_id, item_id):
|
||||||
|
note = _note_or_404(note_id)
|
||||||
|
item = NoteItem.query.filter_by(id=item_id, note_id=note.id).first_or_404()
|
||||||
|
user_db.session.delete(item)
|
||||||
|
note.touch()
|
||||||
|
user_db.session.commit()
|
||||||
|
return _note_redirect(note.id)
|
||||||
|
|
||||||
|
# --- API: proxied to the sensors service ---
|
||||||
|
# The browser only talks to this app; the sensors service owns the
|
||||||
|
# sensor database and the actual API implementation.
|
||||||
|
|
||||||
@app.route("/api/sensors")
|
@app.route("/api/sensors")
|
||||||
def api_sensors():
|
def api_sensors():
|
||||||
sensors = Sensor.query.order_by(Sensor.name).all()
|
return _proxy(sensors.list_sensors)
|
||||||
return jsonify([{"id": s.id, "name": s.name} for s in sensors])
|
|
||||||
|
|
||||||
@app.route("/api/sensors/<int:sensor_id>/types")
|
|
||||||
def api_sensor_types(sensor_id):
|
|
||||||
types = (
|
|
||||||
db.session.query(SensorReadout.type)
|
|
||||||
.filter_by(sensor_id=sensor_id)
|
|
||||||
.distinct()
|
|
||||||
.order_by(SensorReadout.type)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return jsonify([t[0] for t in types])
|
|
||||||
|
|
||||||
@app.route("/api/sensors/<int:sensor_id>/readouts")
|
|
||||||
def api_sensor_readouts(sensor_id):
|
|
||||||
type_ = request.args.get("type")
|
|
||||||
query = SensorReadout.query.filter_by(sensor_id=sensor_id)
|
|
||||||
if type_:
|
|
||||||
query = query.filter_by(type=type_)
|
|
||||||
readouts = query.order_by(SensorReadout.timestamp.desc()).limit(300).all()
|
|
||||||
return jsonify(
|
|
||||||
[
|
|
||||||
{"value": r.value, "timestamp": r.timestamp.isoformat() + "Z"}
|
|
||||||
for r in readouts
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
|
|
||||||
def api_sensor_readouts_hourly(sensor_id):
|
|
||||||
type_ = request.args.get("type")
|
|
||||||
query = (
|
|
||||||
db.session.query(
|
|
||||||
func.strftime("%Y-%m-%dT%H:00:00", SensorReadout.timestamp).label("hour"),
|
|
||||||
func.avg(SensorReadout.value).label("avg_value"),
|
|
||||||
)
|
|
||||||
.filter(SensorReadout.sensor_id == sensor_id)
|
|
||||||
)
|
|
||||||
if type_:
|
|
||||||
query = query.filter(SensorReadout.type == type_)
|
|
||||||
rows = (
|
|
||||||
query.group_by("hour")
|
|
||||||
.order_by("hour")
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
return jsonify(
|
|
||||||
[{"timestamp": r.hour, "value": round(r.avg_value, 2)} for r in rows]
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/sensors", methods=["POST"])
|
@app.route("/api/sensors", methods=["POST"])
|
||||||
def api_create_sensor():
|
def api_create_sensor():
|
||||||
data = request.get_json(silent=True)
|
return _proxy(lambda: sensors.create_sensor(request.get_json(silent=True)))
|
||||||
if not data or "name" not in data:
|
|
||||||
return jsonify({"error": "name is required"}), 400
|
|
||||||
|
|
||||||
name = data["name"].strip()
|
|
||||||
if not name:
|
|
||||||
return jsonify({"error": "name cannot be empty"}), 400
|
|
||||||
|
|
||||||
if Sensor.query.filter_by(name=name).first():
|
|
||||||
return jsonify({"error": "sensor already exists"}), 409
|
|
||||||
|
|
||||||
sensor = Sensor(name=name)
|
|
||||||
db.session.add(sensor)
|
|
||||||
db.session.commit()
|
|
||||||
return jsonify({"id": sensor.id, "name": sensor.name}), 201
|
|
||||||
|
|
||||||
@app.route("/api/sensors/<int:sensor_id>/readouts", methods=["POST"])
|
@app.route("/api/sensors/<int:sensor_id>/readouts", methods=["POST"])
|
||||||
def api_create_readout(sensor_id):
|
def api_create_readout(sensor_id):
|
||||||
sensor = db.session.get(Sensor, sensor_id)
|
return _proxy(
|
||||||
if not sensor:
|
lambda: sensors.create_readout(sensor_id, request.get_json(silent=True))
|
||||||
return jsonify({"error": "sensor not found"}), 404
|
|
||||||
|
|
||||||
data = request.get_json(silent=True)
|
|
||||||
if not data:
|
|
||||||
return jsonify({"error": "request body is required"}), 400
|
|
||||||
|
|
||||||
missing = [f for f in ("type", "value") if f not in data]
|
|
||||||
if missing:
|
|
||||||
return jsonify({"error": f"missing fields: {', '.join(missing)}"}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
value = float(data["value"])
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({"error": "value must be a number"}), 400
|
|
||||||
|
|
||||||
readout = SensorReadout(
|
|
||||||
sensor_id=sensor_id,
|
|
||||||
type=str(data["type"]).strip(),
|
|
||||||
value=value,
|
|
||||||
)
|
)
|
||||||
db.session.add(readout)
|
|
||||||
db.session.commit()
|
@app.route("/api/sensors/<int:sensor_id>/types")
|
||||||
return jsonify(
|
@login_required
|
||||||
{
|
def api_sensor_types(sensor_id):
|
||||||
"id": readout.id,
|
return _proxy(lambda: sensors.sensor_types(sensor_id))
|
||||||
"sensor_id": readout.sensor_id,
|
|
||||||
"type": readout.type,
|
@app.route("/api/sensors/<int:sensor_id>/readouts")
|
||||||
"value": readout.value,
|
@login_required
|
||||||
"timestamp": readout.timestamp.isoformat() + "Z",
|
def api_sensor_readouts(sensor_id):
|
||||||
}
|
return _proxy(
|
||||||
), 201
|
lambda: sensors.sensor_readouts(
|
||||||
|
sensor_id, {"type": request.args.get("type")}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
|
||||||
|
@login_required
|
||||||
|
def api_sensor_readouts_hourly(sensor_id):
|
||||||
|
return _proxy(
|
||||||
|
lambda: sensors.sensor_readouts_hourly(sensor_id, request.args.to_dict())
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Command for creating the first admin ---
|
||||||
|
# Usage:
|
||||||
|
# flask --app app create-admin --username admin --password 'secret'
|
||||||
|
# ADMIN_USERNAME=admin ADMIN_PASSWORD=secret flask --app app create-admin
|
||||||
|
|
||||||
|
@app.cli.command("create-admin")
|
||||||
|
@click.option("--username", default=None, help="Administrator username")
|
||||||
|
@click.option("--password", default=None, help="Administrator password")
|
||||||
|
def create_admin(username, password):
|
||||||
|
"""Create or update an administrator account (is_admin=True)."""
|
||||||
|
username = username or os.environ.get("ADMIN_USERNAME")
|
||||||
|
if not username:
|
||||||
|
username = click.prompt("Admin username")
|
||||||
|
username = username.strip()
|
||||||
|
if not username:
|
||||||
|
raise click.ClickException("Username cannot be empty.")
|
||||||
|
|
||||||
|
password = password or os.environ.get("ADMIN_PASSWORD")
|
||||||
|
if not password:
|
||||||
|
password = click.prompt("Admin password", hide_input=True, confirmation_prompt=True)
|
||||||
|
if not password:
|
||||||
|
raise click.ClickException("Password cannot be empty.")
|
||||||
|
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if user:
|
||||||
|
user.set_password(password)
|
||||||
|
user.is_admin = True
|
||||||
|
user_db.session.commit()
|
||||||
|
click.echo(f"Updated existing user '{username}' (granted is_admin=True).")
|
||||||
|
else:
|
||||||
|
user = User(username=username, is_admin=True)
|
||||||
|
user.set_password(password)
|
||||||
|
user_db.session.add(user)
|
||||||
|
user_db.session.commit()
|
||||||
|
click.echo(f"Created administrator '{username}'.")
|
||||||
|
|
||||||
|
@app.cli.command("create-notification")
|
||||||
|
@click.option("--username", default=None, help="Recipient username")
|
||||||
|
@click.option("--all", "all_users", is_flag=True, help="Send to all users")
|
||||||
|
@click.option("--message", default=None, help="Notification text")
|
||||||
|
def create_notification(username, all_users, message):
|
||||||
|
"""Create a notification for one user or for all users."""
|
||||||
|
if all_users and username:
|
||||||
|
raise click.ClickException("Use either --username or --all, not both.")
|
||||||
|
if not all_users and not username:
|
||||||
|
raise click.ClickException("Provide --username or --all.")
|
||||||
|
|
||||||
|
message = (message or "").strip()
|
||||||
|
if not message:
|
||||||
|
message = click.prompt("Message").strip()
|
||||||
|
if not message:
|
||||||
|
raise click.ClickException("Message cannot be empty.")
|
||||||
|
message = message[:500]
|
||||||
|
|
||||||
|
if all_users:
|
||||||
|
users = User.query.all()
|
||||||
|
else:
|
||||||
|
user = User.query.filter_by(username=username).first()
|
||||||
|
if not user:
|
||||||
|
raise click.ClickException(f"User '{username}' not found.")
|
||||||
|
users = [user]
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
user_db.session.add(Notification(user_id=user.id, message=message))
|
||||||
|
user_db.session.commit()
|
||||||
|
click.echo(f"Created {len(users)} notification(s).")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app = create_app()
|
app = create_app()
|
||||||
app.run(debug=True, host='0.0.0.0')
|
app.run(debug=True, host='0.0.0.0', port=8000)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import jsonify, redirect, request
|
||||||
|
from flask_login import LoginManager
|
||||||
|
|
||||||
|
|
||||||
|
def init_login_manager(app, login_url_builder, load_user, api_prefixes=("/api/",)):
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.user_loader(load_user)
|
||||||
|
|
||||||
|
@login_manager.unauthorized_handler
|
||||||
|
def unauthorized():
|
||||||
|
# API clients get JSON instead of a redirect to the login page.
|
||||||
|
if request.path.startswith(tuple(api_prefixes)):
|
||||||
|
return jsonify({"error": "authentication required"}), 401
|
||||||
|
return redirect(login_url_builder(request))
|
||||||
|
|
||||||
|
return login_manager
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
SECRET_FILE = BASE_DIR / ".secret_key"
|
||||||
|
|
||||||
|
DEFAULT_SENSORS_PORT = 8001
|
||||||
|
DEFAULT_CLIPBOARD_MAX_FILE_BYTES = 100 * 1024 * 1024
|
||||||
|
DEFAULT_CLIPBOARD_MAX_TOTAL_BYTES = 1024 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def load_secret_key() -> str:
|
||||||
|
"""Return a stable key used to sign the main app session cookies."""
|
||||||
|
key = os.environ.get("SECRET_KEY")
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
if SECRET_FILE.exists():
|
||||||
|
return SECRET_FILE.read_text().strip()
|
||||||
|
return "dev-secret-key-change-me"
|
||||||
|
|
||||||
|
|
||||||
|
def sensors_database_uri() -> str:
|
||||||
|
"""Database owned by the sensors API (sensors and readouts only)."""
|
||||||
|
configured = os.environ.get("SENSORS_DATABASE_URL") or os.environ.get("DATABASE_URL")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
db_path = os.environ.get(
|
||||||
|
"SENSORS_DB_PATH", os.environ.get("DB_PATH", str(BASE_DIR / "sensors.db"))
|
||||||
|
)
|
||||||
|
return f"sqlite:///{db_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def users_database_uri() -> str:
|
||||||
|
"""Database owned by the main app (user accounts only)."""
|
||||||
|
configured = os.environ.get("USERS_DATABASE_URL")
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
db_path = os.environ.get("USERS_DB_PATH", str(BASE_DIR / "main_app.db"))
|
||||||
|
return f"sqlite:///{db_path}"
|
||||||
|
|
||||||
|
|
||||||
|
def sensors_api_url() -> str:
|
||||||
|
"""Base URL used by the main app to call the sensors API (server-to-server)."""
|
||||||
|
configured = os.environ.get("SENSORS_API_URL")
|
||||||
|
if configured:
|
||||||
|
return configured.rstrip("/")
|
||||||
|
return f"http://localhost:{DEFAULT_SENSORS_PORT}"
|
||||||
|
|
||||||
|
|
||||||
|
def clipboard_dir() -> Path:
|
||||||
|
"""Directory where uploaded clipboard files are stored."""
|
||||||
|
configured = os.environ.get("CLIPBOARD_DIR")
|
||||||
|
if configured:
|
||||||
|
return Path(configured).expanduser().resolve()
|
||||||
|
return BASE_DIR / "clipboard"
|
||||||
|
|
||||||
|
|
||||||
|
def clipboard_max_file_bytes() -> int:
|
||||||
|
return int(
|
||||||
|
os.environ.get(
|
||||||
|
"CLIPBOARD_MAX_FILE_BYTES", DEFAULT_CLIPBOARD_MAX_FILE_BYTES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def clipboard_max_total_bytes() -> int:
|
||||||
|
return int(
|
||||||
|
os.environ.get(
|
||||||
|
"CLIPBOARD_MAX_TOTAL_BYTES", DEFAULT_CLIPBOARD_MAX_TOTAL_BYTES
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
bind = "0.0.0.0:8000"
|
bind = os.environ.get("BIND") or f"0.0.0.0:{os.environ.get('PORT', '8000')}"
|
||||||
workers = min(os.cpu_count() * 2 + 1, 9)
|
workers = min(os.cpu_count() * 2 + 1, 9)
|
||||||
timeout = 30
|
timeout = 30
|
||||||
keepalive = 5
|
keepalive = 5
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Copy data from a legacy combined data.db into the split databases.
|
||||||
|
|
||||||
|
The legacy database contained users, sensors and readouts in one file. Run the
|
||||||
|
new migrations first, then import the old rows:
|
||||||
|
|
||||||
|
alembic upgrade head
|
||||||
|
alembic -c alembic_users.ini upgrade head
|
||||||
|
python migrate_legacy_db.py
|
||||||
|
|
||||||
|
The legacy file is only read; nothing is deleted from it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import MetaData, Table, create_engine, inspect, select
|
||||||
|
from sqlalchemy.dialects.sqlite import insert
|
||||||
|
|
||||||
|
from models import Sensor, SensorReadout
|
||||||
|
from user_models import User
|
||||||
|
|
||||||
|
|
||||||
|
def _sqlite_engine(path: Path):
|
||||||
|
if not path.exists():
|
||||||
|
raise SystemExit(f"database not found: {path}")
|
||||||
|
return create_engine(f"sqlite:///{path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_table(source, destination, table) -> int:
|
||||||
|
if table.name not in inspect(source).get_table_names():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
source_table = Table(table.name, MetaData(), autoload_with=source)
|
||||||
|
with source.connect() as conn:
|
||||||
|
rows = [dict(row._mapping) for row in conn.execute(select(source_table))]
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with destination.begin() as conn:
|
||||||
|
conn.execute(insert(table).on_conflict_do_nothing(), rows)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--legacy", default="data.db", type=Path)
|
||||||
|
parser.add_argument("--sensors", default="sensors.db", type=Path)
|
||||||
|
parser.add_argument("--main-app", default="main_app.db", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
legacy = _sqlite_engine(args.legacy)
|
||||||
|
sensors = _sqlite_engine(args.sensors)
|
||||||
|
main_app = _sqlite_engine(args.main_app)
|
||||||
|
|
||||||
|
if not inspect(sensors).has_table(Sensor.__tablename__):
|
||||||
|
raise SystemExit(f"{args.sensors} has no schema; run alembic upgrade head first")
|
||||||
|
if not inspect(main_app).has_table(User.__tablename__):
|
||||||
|
raise SystemExit(
|
||||||
|
f"{args.main_app} has no schema; run alembic -c alembic_users.ini upgrade head first"
|
||||||
|
)
|
||||||
|
|
||||||
|
sensor_count = _copy_table(legacy, sensors, Sensor.__table__)
|
||||||
|
readout_count = _copy_table(legacy, sensors, SensorReadout.__table__)
|
||||||
|
user_count = _copy_table(legacy, main_app, User.__table__)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Copied {sensor_count} sensors, {readout_count} readouts and "
|
||||||
|
f"{user_count} users. The legacy database was not modified."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
flask>=3.0
|
flask>=3.0
|
||||||
flask-sqlalchemy>=3.1
|
flask-sqlalchemy>=3.1
|
||||||
|
flask-login>=0.6
|
||||||
alembic>=1.13
|
alembic>=1.13
|
||||||
gunicorn>=22.0
|
gunicorn>=22.0
|
||||||
Executable
+88
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Production startup (gunicorn). Pick the service and port:
|
||||||
|
#
|
||||||
|
# ./run-prod.sh # main app on 8000
|
||||||
|
# ./run-prod.sh main 8000 # main app on 8000
|
||||||
|
# ./run-prod.sh sensors 8001 # sensors app on 8001 (localhost only)
|
||||||
|
#
|
||||||
|
# SECRET_KEY resolution (main app sessions):
|
||||||
|
# 1. SECRET_KEY environment variable (takes precedence),
|
||||||
|
# 2. .secret_key file next to this script (created once on first start),
|
||||||
|
# 3. otherwise a random key is generated and saved to .secret_key.
|
||||||
|
#
|
||||||
|
# The sensors API is open to every user on the local machine. To expose it
|
||||||
|
# on the network explicitly set HOST=0.0.0.0 (or BIND).
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APP="${1:-${APP:-main}}"
|
||||||
|
PORT="${2:-${PORT:-}}"
|
||||||
|
|
||||||
|
case "$APP" in
|
||||||
|
main)
|
||||||
|
MODULE="app:create_app"
|
||||||
|
DEFAULT_PORT=8000
|
||||||
|
DEFAULT_HOST="0.0.0.0"
|
||||||
|
;;
|
||||||
|
sensors)
|
||||||
|
MODULE="sensors_app:create_app"
|
||||||
|
DEFAULT_PORT=8001
|
||||||
|
DEFAULT_HOST="127.0.0.1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [main|sensors] [port]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
PORT="${PORT:-$DEFAULT_PORT}"
|
||||||
|
HOST="${HOST:-$DEFAULT_HOST}"
|
||||||
|
|
||||||
|
# Prevent the main app from proxying to itself. Without an explicit
|
||||||
|
# SENSORS_API_URL the client targets localhost:8001, so main must not listen
|
||||||
|
# there as well.
|
||||||
|
if [ "$APP" = "main" ] && [ -z "${SENSORS_API_URL:-}" ] && [ "$PORT" = "8001" ]; then
|
||||||
|
echo "Refusing to start main on port 8001: SENSORS_API_URL would default to" >&2
|
||||||
|
echo "http://localhost:8001 and the app would call itself recursively." >&2
|
||||||
|
echo "Use another port (for example 8000) or set SENSORS_API_URL explicitly." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$APP" = "main" ]; then
|
||||||
|
SECRET_FILE=".secret_key"
|
||||||
|
if [ -z "${SECRET_KEY:-}" ]; then
|
||||||
|
if [ -f "$SECRET_FILE" ]; then
|
||||||
|
SECRET_KEY="$(cat "$SECRET_FILE")"
|
||||||
|
else
|
||||||
|
SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(32))')"
|
||||||
|
printf '%s' "$SECRET_KEY" > "$SECRET_FILE"
|
||||||
|
chmod 600 "$SECRET_FILE"
|
||||||
|
echo "Generated a new SECRET_KEY, saved to $SECRET_FILE" >&2
|
||||||
|
fi
|
||||||
|
export SECRET_KEY
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Each service runs migrations for its own database.
|
||||||
|
if [ -x ".venv/bin/alembic" ]; then
|
||||||
|
ALEMBIC=".venv/bin/alembic"
|
||||||
|
else
|
||||||
|
ALEMBIC="alembic"
|
||||||
|
fi
|
||||||
|
if [ "$APP" = "main" ]; then
|
||||||
|
"$ALEMBIC" -c alembic_users.ini upgrade head
|
||||||
|
else
|
||||||
|
"$ALEMBIC" -c alembic.ini upgrade head
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -x ".venv/bin/gunicorn" ]; then
|
||||||
|
GUNICORN=".venv/bin/gunicorn"
|
||||||
|
else
|
||||||
|
GUNICORN="gunicorn"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export BIND="${BIND:-$HOST:$PORT}"
|
||||||
|
echo "Starting $APP app on http://$BIND" >&2
|
||||||
|
# Gunicorn needs the factory call syntax ("module:create_app()"), otherwise it
|
||||||
|
# treats create_app itself as the WSGI callable.
|
||||||
|
exec "$GUNICORN" -c gunicorn.conf.py "$MODULE()"
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Development launcher. Pick the service and port:
|
||||||
|
#
|
||||||
|
# ./run.sh # main app on 8000
|
||||||
|
# ./run.sh main 8000 # main app on 8000
|
||||||
|
# ./run.sh sensors 8001 # sensors app on 8001 (localhost only)
|
||||||
|
#
|
||||||
|
# The main app calls the sensors API server-to-server. Override its address
|
||||||
|
# when the sensors app is not on localhost:8001:
|
||||||
|
#
|
||||||
|
# SENSORS_API_URL=http://192.168.0.50:9001 ./run.sh main 8000
|
||||||
|
#
|
||||||
|
# The sensors API is open to every user on the local machine. To expose it
|
||||||
|
# on the network explicitly set HOST=0.0.0.0.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
APP="${1:-${APP:-main}}"
|
||||||
|
PORT="${2:-${PORT:-}}"
|
||||||
|
|
||||||
|
case "$APP" in
|
||||||
|
main)
|
||||||
|
MODULE="app:create_app"
|
||||||
|
DEFAULT_PORT=8000
|
||||||
|
DEFAULT_HOST="0.0.0.0"
|
||||||
|
;;
|
||||||
|
sensors)
|
||||||
|
MODULE="sensors_app:create_app"
|
||||||
|
DEFAULT_PORT=8001
|
||||||
|
DEFAULT_HOST="127.0.0.1"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [main|sensors] [port]" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
PORT="${PORT:-$DEFAULT_PORT}"
|
||||||
|
HOST="${HOST:-$DEFAULT_HOST}"
|
||||||
|
|
||||||
|
# Prevent the main app from proxying to itself. Without an explicit
|
||||||
|
# SENSORS_API_URL the client targets localhost:8001, so main must not listen
|
||||||
|
# there as well.
|
||||||
|
if [ "$APP" = "main" ] && [ -z "${SENSORS_API_URL:-}" ] && [ "$PORT" = "8001" ]; then
|
||||||
|
echo "Refusing to start main on port 8001: SENSORS_API_URL would default to" >&2
|
||||||
|
echo "http://localhost:8001 and the app would call itself recursively." >&2
|
||||||
|
echo "Use another port (for example 8000) or set SENSORS_API_URL explicitly." >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -x ".venv/bin/python" ]; then
|
||||||
|
PYTHON=".venv/bin/python"
|
||||||
|
else
|
||||||
|
PYTHON="python3"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting $APP app on http://$HOST:$PORT" >&2
|
||||||
|
exec "$PYTHON" -m flask --app "$MODULE" run --host "$HOST" --port "$PORT" --debug
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from app import create_app
|
from sensors_app import create_app
|
||||||
from models import Sensor, SensorReadout, db
|
from models import Sensor, SensorReadout, db
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, request
|
||||||
|
|
||||||
|
from config import sensors_database_uri
|
||||||
|
from models import Sensor, SensorReadout, db
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_readout_ts(raw: str):
|
||||||
|
value = (raw or "").strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
if value.endswith("Z"):
|
||||||
|
value = value[:-1]
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is not None:
|
||||||
|
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
app.config["SQLALCHEMY_DATABASE_URI"] = sensors_database_uri()
|
||||||
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
|
||||||
|
db.init_app(app)
|
||||||
|
|
||||||
|
# --- API: device endpoints (intentionally public) ---
|
||||||
|
# The sensor firmware has no browser session, but it needs the sensor
|
||||||
|
# list to resolve a name to an id before pushing readouts.
|
||||||
|
|
||||||
|
@app.route("/api/sensors")
|
||||||
|
def api_sensors():
|
||||||
|
sensors = Sensor.query.order_by(Sensor.name).all()
|
||||||
|
return jsonify([{"id": s.id, "name": s.name} for s in sensors])
|
||||||
|
|
||||||
|
@app.route("/api/sensors", methods=["POST"])
|
||||||
|
def api_create_sensor():
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data or "name" not in data:
|
||||||
|
return jsonify({"error": "name is required"}), 400
|
||||||
|
|
||||||
|
name = data["name"].strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({"error": "name cannot be empty"}), 400
|
||||||
|
|
||||||
|
if Sensor.query.filter_by(name=name).first():
|
||||||
|
return jsonify({"error": "sensor already exists"}), 409
|
||||||
|
|
||||||
|
sensor = Sensor(name=name)
|
||||||
|
db.session.add(sensor)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify({"id": sensor.id, "name": sensor.name}), 201
|
||||||
|
|
||||||
|
@app.route("/api/sensors/<int:sensor_id>/readouts", methods=["POST"])
|
||||||
|
def api_create_readout(sensor_id):
|
||||||
|
sensor = db.session.get(Sensor, sensor_id)
|
||||||
|
if not sensor:
|
||||||
|
return jsonify({"error": "sensor not found"}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "request body is required"}), 400
|
||||||
|
|
||||||
|
missing = [f for f in ("type", "value") if f not in data]
|
||||||
|
if missing:
|
||||||
|
return jsonify({"error": f"missing fields: {', '.join(missing)}"}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = float(data["value"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({"error": "value must be a number"}), 400
|
||||||
|
|
||||||
|
readout = SensorReadout(
|
||||||
|
sensor_id=sensor_id,
|
||||||
|
type=str(data["type"]).strip(),
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
db.session.add(readout)
|
||||||
|
db.session.commit()
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"id": readout.id,
|
||||||
|
"sensor_id": readout.sensor_id,
|
||||||
|
"type": readout.type,
|
||||||
|
"value": readout.value,
|
||||||
|
"timestamp": readout.timestamp.isoformat() + "Z",
|
||||||
|
}
|
||||||
|
), 201
|
||||||
|
|
||||||
|
@app.route("/api/sensors/<int:sensor_id>/readouts/range")
|
||||||
|
def api_sensor_readouts_range(sensor_id):
|
||||||
|
sensor = db.session.get(Sensor, sensor_id)
|
||||||
|
if not sensor:
|
||||||
|
return jsonify({"error": "sensor not found"}), 404
|
||||||
|
|
||||||
|
from_raw = request.args.get("from")
|
||||||
|
to_raw = request.args.get("to")
|
||||||
|
from_ts = _parse_readout_ts(from_raw) if from_raw else None
|
||||||
|
to_ts = _parse_readout_ts(to_raw) if to_raw else None
|
||||||
|
if from_ts is None or to_ts is None or to_ts < from_ts:
|
||||||
|
return jsonify({"error": "from and to are required and valid"}), 400
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.session.query(
|
||||||
|
SensorReadout.id,
|
||||||
|
SensorReadout.sensor_id,
|
||||||
|
SensorReadout.type,
|
||||||
|
SensorReadout.value,
|
||||||
|
SensorReadout.timestamp,
|
||||||
|
)
|
||||||
|
.filter(SensorReadout.sensor_id == sensor_id)
|
||||||
|
.filter(SensorReadout.timestamp >= from_ts, SensorReadout.timestamp < to_ts)
|
||||||
|
.order_by(SensorReadout.timestamp)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": row[0],
|
||||||
|
"sensor_id": row[1],
|
||||||
|
"type": row[2],
|
||||||
|
"value": row[3],
|
||||||
|
"timestamp": row[4].isoformat() + "Z",
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- API: dashboard reads (public on the local machine) ---
|
||||||
|
|
||||||
|
@app.route("/api/sensors/<int:sensor_id>/types")
|
||||||
|
def api_sensor_types(sensor_id):
|
||||||
|
types = (
|
||||||
|
db.session.query(SensorReadout.type)
|
||||||
|
.filter_by(sensor_id=sensor_id)
|
||||||
|
.distinct()
|
||||||
|
.order_by(SensorReadout.type)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return jsonify([t[0] for t in types])
|
||||||
|
|
||||||
|
@app.route("/api/sensors/<int:sensor_id>/readouts")
|
||||||
|
def api_sensor_readouts(sensor_id):
|
||||||
|
type_ = request.args.get("type")
|
||||||
|
query = SensorReadout.query.filter_by(sensor_id=sensor_id)
|
||||||
|
if type_:
|
||||||
|
query = query.filter_by(type=type_)
|
||||||
|
readouts = query.order_by(SensorReadout.timestamp.desc()).limit(300).all()
|
||||||
|
return jsonify(
|
||||||
|
[
|
||||||
|
{"value": r.value, "timestamp": r.timestamp.isoformat() + "Z"}
|
||||||
|
for r in readouts
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
|
||||||
|
def api_sensor_readouts_hourly(sensor_id):
|
||||||
|
type_ = request.args.get("type")
|
||||||
|
range_ = request.args.get("range", "all")
|
||||||
|
# The frontend sends the browser timezone (e.g. Europe/Warsaw), so
|
||||||
|
# "today" means "since midnight local time", not UTC.
|
||||||
|
tz_name = request.args.get("tz", "Europe/Warsaw") or "Europe/Warsaw"
|
||||||
|
try:
|
||||||
|
local_tz = ZoneInfo(tz_name)
|
||||||
|
except (ZoneInfoNotFoundError, ValueError):
|
||||||
|
return jsonify({"error": f"unknown timezone: {tz_name}"}), 400
|
||||||
|
# Timestamps are stored as naive UTC, so the cutoff must be naive UTC too.
|
||||||
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
now_local = now_utc.astimezone(local_tz)
|
||||||
|
if range_ == "today":
|
||||||
|
midnight_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
cutoff = midnight_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
elif range_ == "24h":
|
||||||
|
cutoff = (now_utc - timedelta(hours=24)).replace(tzinfo=None)
|
||||||
|
elif range_ == "week":
|
||||||
|
cutoff = (now_utc - timedelta(days=7)).replace(tzinfo=None)
|
||||||
|
elif range_ == "month":
|
||||||
|
cutoff = (now_utc - timedelta(days=30)).replace(tzinfo=None)
|
||||||
|
elif range_ == "all":
|
||||||
|
cutoff = None
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "range must be one of: today, 24h, week, month, all"}), 400
|
||||||
|
query = db.session.query(SensorReadout.timestamp, SensorReadout.value).filter(
|
||||||
|
SensorReadout.sensor_id == sensor_id
|
||||||
|
)
|
||||||
|
if cutoff is not None:
|
||||||
|
query = query.filter(SensorReadout.timestamp >= cutoff)
|
||||||
|
if type_:
|
||||||
|
query = query.filter(SensorReadout.type == type_)
|
||||||
|
rows = query.order_by(SensorReadout.timestamp).all()
|
||||||
|
# today/24h return every measurement by default; the "hourly average"
|
||||||
|
# checkbox forces hourly averages.
|
||||||
|
averaged = range_ not in ("today", "24h") or request.args.get(
|
||||||
|
"avg", "0"
|
||||||
|
).lower() in ("1", "true", "yes")
|
||||||
|
if not averaged:
|
||||||
|
return jsonify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"timestamp": ts.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
|
"value": value,
|
||||||
|
}
|
||||||
|
for ts, value in rows
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Other ranges: hourly average over the full local hour (correct
|
||||||
|
# across DST changes), timestamp is the start of the hour in UTC
|
||||||
|
# (the frontend appends "Z").
|
||||||
|
buckets: dict[datetime, list[float]] = {}
|
||||||
|
for ts, value in rows:
|
||||||
|
ts_utc = ts.replace(tzinfo=timezone.utc)
|
||||||
|
hour_local = ts_utc.astimezone(local_tz).replace(
|
||||||
|
minute=0, second=0, microsecond=0
|
||||||
|
)
|
||||||
|
hour_utc = hour_local.astimezone(timezone.utc).replace(tzinfo=None)
|
||||||
|
buckets.setdefault(hour_utc, []).append(value)
|
||||||
|
result = [
|
||||||
|
{
|
||||||
|
"timestamp": hour.strftime("%Y-%m-%dT%H:00:00"),
|
||||||
|
"value": round(sum(values) / len(values), 2),
|
||||||
|
}
|
||||||
|
for hour, values in sorted(buckets.items())
|
||||||
|
]
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = create_app()
|
||||||
|
app.run(debug=True, host='127.0.0.1', port=8001)
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import json
|
||||||
|
from urllib import error, parse, request
|
||||||
|
|
||||||
|
|
||||||
|
class SensorsAPIError(Exception):
|
||||||
|
"""Raised when the sensors API is unreachable or returns invalid data."""
|
||||||
|
|
||||||
|
|
||||||
|
class SensorsClient:
|
||||||
|
def __init__(self, base_url: str, timeout: float = 10.0):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def _request(self, method, path, params=None, payload=None):
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
if params:
|
||||||
|
query = parse.urlencode({k: v for k, v in params.items() if v is not None})
|
||||||
|
if query:
|
||||||
|
url += f"?{query}"
|
||||||
|
|
||||||
|
data = None
|
||||||
|
headers = {"Accept": "application/json"}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
req = request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with request.urlopen(req, timeout=self.timeout) as response:
|
||||||
|
body = response.read()
|
||||||
|
return response.status, self._decode(body)
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
return exc.code, self._decode(exc.read())
|
||||||
|
except (error.URLError, TimeoutError, OSError) as exc:
|
||||||
|
raise SensorsAPIError(f"sensors API unavailable: {exc}") from exc
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode(body: bytes):
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(body)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise SensorsAPIError("sensors API returned invalid JSON") from exc
|
||||||
|
|
||||||
|
# --- Sensor data ---
|
||||||
|
|
||||||
|
def list_sensors(self):
|
||||||
|
return self._request("GET", "/api/sensors")
|
||||||
|
|
||||||
|
def create_sensor(self, payload):
|
||||||
|
return self._request("POST", "/api/sensors", payload=payload)
|
||||||
|
|
||||||
|
def create_readout(self, sensor_id, payload):
|
||||||
|
return self._request(
|
||||||
|
"POST", f"/api/sensors/{sensor_id}/readouts", payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
def sensor_types(self, sensor_id):
|
||||||
|
return self._request("GET", f"/api/sensors/{sensor_id}/types")
|
||||||
|
|
||||||
|
def sensor_readouts(self, sensor_id, params=None):
|
||||||
|
return self._request("GET", f"/api/sensors/{sensor_id}/readouts", params=params)
|
||||||
|
|
||||||
|
def sensor_readouts_range(self, sensor_id, params=None):
|
||||||
|
return self._request(
|
||||||
|
"GET", f"/api/sensors/{sensor_id}/readouts/range", params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
def sensor_readouts_hourly(self, sensor_id, params=None):
|
||||||
|
return self._request(
|
||||||
|
"GET", f"/api/sensors/{sensor_id}/readouts/hourly", params=params
|
||||||
|
)
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
/* Yggdrasil retro theme — Midnight Commander vibes.
|
||||||
|
* Midnight-blue background, pixel frames, hard shadows, zero rounding.
|
||||||
|
* VT323 font (falls back to monospace if the CDN is unreachable).
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--mc-blue: #0000aa;
|
||||||
|
--mc-deep: #000066;
|
||||||
|
--mc-fg: #d7d7d7;
|
||||||
|
--mc-dim: #a8a8a8;
|
||||||
|
--mc-bright: #ffffff;
|
||||||
|
--mc-cyan: #55ffff;
|
||||||
|
--mc-cyan-dim: #00aaaa;
|
||||||
|
--mc-yellow: #ffff55;
|
||||||
|
--mc-red: #aa0000;
|
||||||
|
--mc-red-bright: #ff5555;
|
||||||
|
--mc-black: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; border-radius: 0 !important; }
|
||||||
|
|
||||||
|
html { background: var(--mc-black); }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--mc-blue);
|
||||||
|
color: var(--mc-fg);
|
||||||
|
font-family: "VT323", "Courier New", Courier, monospace;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.35;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1rem 4rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle CRT effect — scanlines over the whole page. */
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
rgba(0, 0, 0, 0.10) 0 1px,
|
||||||
|
transparent 1px 3px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection { background: var(--mc-cyan); color: var(--mc-black); }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* --- Headings --- */
|
||||||
|
h1 {
|
||||||
|
color: var(--mc-yellow);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
font-size: 2.2rem;
|
||||||
|
text-shadow: 3px 3px 0 var(--mc-black);
|
||||||
|
border-bottom: 3px double var(--mc-dim);
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 { color: var(--mc-bright); letter-spacing: 2px; }
|
||||||
|
|
||||||
|
/* Yggdrasil logo — pixel art, so keep it crisp when scaled down.
|
||||||
|
* On the home page it spans the full width of the blue band. */
|
||||||
|
.logo {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1rem auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
border: 3px double var(--mc-bright);
|
||||||
|
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small logo pinned to the right side of the black navbar. */
|
||||||
|
nav { display: flex; align-items: center; }
|
||||||
|
.nav-logo {
|
||||||
|
height: 68px;
|
||||||
|
width: auto;
|
||||||
|
margin-left: auto;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mcblink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* --- Menu bar (navigation) --- */
|
||||||
|
nav {
|
||||||
|
background: var(--mc-black);
|
||||||
|
border: 2px solid var(--mc-dim);
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
nav a {
|
||||||
|
color: var(--mc-cyan);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0 0.6em;
|
||||||
|
margin-right: 0.2em;
|
||||||
|
}
|
||||||
|
nav a:hover { background: var(--mc-cyan); color: var(--mc-black); }
|
||||||
|
.nav-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--mc-yellow);
|
||||||
|
color: var(--mc-black);
|
||||||
|
border: 1px solid var(--mc-black);
|
||||||
|
padding: 0 0.35em;
|
||||||
|
margin-left: 0.25em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Panels and dialogs --- */
|
||||||
|
.panel {
|
||||||
|
background: var(--mc-deep);
|
||||||
|
border: 3px double var(--mc-bright);
|
||||||
|
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.45);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
position: relative;
|
||||||
|
max-width: 460px;
|
||||||
|
margin: 10vh auto 0;
|
||||||
|
background: var(--mc-blue);
|
||||||
|
border: 3px double var(--mc-bright);
|
||||||
|
box-shadow: 10px 10px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
padding: 1.5rem 1.5rem 1.2rem;
|
||||||
|
}
|
||||||
|
.dialog-title {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.75em;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--mc-blue);
|
||||||
|
color: var(--mc-yellow);
|
||||||
|
padding: 0 1em;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Forms --- */
|
||||||
|
form { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||||
|
label { color: var(--mc-bright); letter-spacing: 1px; }
|
||||||
|
|
||||||
|
/* Terminal-style field wrapper for the block caret (see login.html).
|
||||||
|
* The overlay block is positioned by JS; without JS it stays hidden. */
|
||||||
|
.term-field { position: relative; }
|
||||||
|
.caret-block {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 1ch;
|
||||||
|
height: 1.15em;
|
||||||
|
background: var(--mc-yellow);
|
||||||
|
box-shadow: 0 0 6px rgba(255, 255, 85, 0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
animation: mcblink 1.06s steps(1) infinite;
|
||||||
|
}
|
||||||
|
.term-field.active .caret-block { display: block; }
|
||||||
|
/* Hide the native thin caret only when the JS block takes over. */
|
||||||
|
input.js-caret { caret-color: transparent; }
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font: inherit;
|
||||||
|
background: var(--mc-black);
|
||||||
|
color: var(--mc-bright);
|
||||||
|
border: 2px solid var(--mc-dim);
|
||||||
|
padding: 0.3em 0.5em;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--mc-yellow);
|
||||||
|
}
|
||||||
|
select { width: 100%; margin-top: 0.3rem; }
|
||||||
|
option { background: var(--mc-black); color: var(--mc-bright); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: var(--mc-dim);
|
||||||
|
color: var(--mc-black);
|
||||||
|
border: none;
|
||||||
|
box-shadow: 4px 4px 0 var(--mc-black);
|
||||||
|
padding: 0.4em 1.2em;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--mc-cyan); }
|
||||||
|
button:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 var(--mc-black); }
|
||||||
|
button:disabled { opacity: 0.5; cursor: default; }
|
||||||
|
|
||||||
|
#refresh-button { margin-top: 0.6rem; }
|
||||||
|
|
||||||
|
/* Sensors page: the "hourly average" checkbox must stay a small inline
|
||||||
|
* box next to its text — the global `input { width: 100% }` would
|
||||||
|
* otherwise stretch it full-width and break the row layout. */
|
||||||
|
#avg-label { display: flex; align-items: center; gap: 0.5em; margin-top: 0.6rem; }
|
||||||
|
#avg-label input { width: auto; margin: 0; }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: var(--mc-red);
|
||||||
|
color: var(--mc-bright);
|
||||||
|
border: 2px solid var(--mc-bright);
|
||||||
|
box-shadow: 4px 4px 0 var(--mc-black);
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tables --- */
|
||||||
|
table { width: 100%; margin-top: 0.5rem; border-collapse: collapse; }
|
||||||
|
th, td {
|
||||||
|
border: 1px solid var(--mc-dim);
|
||||||
|
padding: 0.3em 0.6em;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
th { background: var(--mc-cyan-dim); color: var(--mc-black); text-transform: uppercase; letter-spacing: 1px; }
|
||||||
|
tbody tr:hover { background: var(--mc-cyan); color: var(--mc-black); }
|
||||||
|
|
||||||
|
/* --- Links inside content --- */
|
||||||
|
a { color: var(--mc-cyan); }
|
||||||
|
a:hover { background: var(--mc-cyan); color: var(--mc-black); text-decoration: none; }
|
||||||
|
|
||||||
|
/* --- MC-style menu (home page) --- */
|
||||||
|
.mc-menu { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
.mc-menu li { margin: 0.2em 0; }
|
||||||
|
.mc-menu a {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.2em 0.6em;
|
||||||
|
}
|
||||||
|
.mc-menu a::before { content: "[*] "; color: var(--mc-yellow); }
|
||||||
|
.mc-menu a:hover::before { color: var(--mc-black); }
|
||||||
|
.mc-key {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--mc-black);
|
||||||
|
color: var(--mc-cyan);
|
||||||
|
border: 1px solid var(--mc-cyan-dim);
|
||||||
|
padding: 0 0.4em;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
a:hover .mc-key { background: var(--mc-black); color: var(--mc-black); }
|
||||||
|
|
||||||
|
.hint { color: var(--mc-dim); }
|
||||||
|
|
||||||
|
/* Yellow highlight for usernames, roles and other key words. */
|
||||||
|
.hl { color: var(--mc-yellow); }
|
||||||
|
|
||||||
|
/* --- API docs --- */
|
||||||
|
.endpoint {
|
||||||
|
background: var(--mc-deep);
|
||||||
|
border: 3px double var(--mc-bright);
|
||||||
|
box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.45);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
.method {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1em 0.6em;
|
||||||
|
margin-right: 0.5em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.get { background: var(--mc-cyan); color: var(--mc-black); }
|
||||||
|
.post { background: var(--mc-yellow); color: var(--mc-black); }
|
||||||
|
.path { font-size: 1.1rem; color: var(--mc-bright); }
|
||||||
|
.desc { margin: 0.5rem 0; }
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--mc-dim);
|
||||||
|
margin: 0.75rem 0 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: var(--mc-black);
|
||||||
|
color: var(--mc-cyan);
|
||||||
|
border: 2px solid var(--mc-dim);
|
||||||
|
padding: 0.7rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
code { font-family: inherit; }
|
||||||
|
.note {
|
||||||
|
background: var(--mc-black);
|
||||||
|
border-left: 6px solid var(--mc-yellow);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Bottom status bar --- */
|
||||||
|
.statusbar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--mc-black);
|
||||||
|
color: var(--mc-dim);
|
||||||
|
border-top: 2px solid var(--mc-dim);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.25em 1em;
|
||||||
|
font-size: 0.95em;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.statusbar b {
|
||||||
|
background: var(--mc-cyan-dim);
|
||||||
|
color: var(--mc-bright);
|
||||||
|
padding: 0 0.4em;
|
||||||
|
margin-right: 0.3em;
|
||||||
|
}
|
||||||
|
.statusbar a { text-decoration: none; }
|
||||||
|
|
||||||
|
/* --- Chart --- */
|
||||||
|
.chart-box { margin-top: 1rem; }
|
||||||
|
canvas {
|
||||||
|
background: var(--mc-black);
|
||||||
|
border: 3px double var(--mc-bright);
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Notes --- */
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 4.5em;
|
||||||
|
}
|
||||||
|
.note-card {
|
||||||
|
background: var(--mc-deep);
|
||||||
|
border: 3px double var(--mc-bright);
|
||||||
|
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.45);
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.note-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.note-meta { color: var(--mc-dim); font-size: 0.8em; }
|
||||||
|
.checklist { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
.checklist li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
border-bottom: 1px dashed var(--mc-dim);
|
||||||
|
padding: 0.15em 0;
|
||||||
|
}
|
||||||
|
.check-form {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.check-form input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--mc-yellow);
|
||||||
|
}
|
||||||
|
.item-edit-form {
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.item-edit-form input { flex: 1; min-width: 0; }
|
||||||
|
.item-edit-form button {
|
||||||
|
background: var(--mc-dim);
|
||||||
|
color: var(--mc-black);
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.item-edit-form button:hover { background: var(--mc-cyan); }
|
||||||
|
.checklist li.done .item-edit-form input {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--mc-dim);
|
||||||
|
}
|
||||||
|
.item-delete-form { margin: 0; }
|
||||||
|
.item-delete-form button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--mc-red-bright);
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.item-delete-form button:hover { background: var(--mc-red); color: var(--mc-bright); }
|
||||||
|
.add-item-form {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
.add-item-form input { flex: 1; }
|
||||||
|
.note-menu-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--mc-dim);
|
||||||
|
border: 2px solid var(--mc-dim);
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.note-menu-btn:hover { color: var(--mc-bright); border-color: var(--mc-bright); }
|
||||||
|
|
||||||
|
.note-title-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-save-status {
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--mc-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.note-save-status.saved { color: var(--mc-yellow); }
|
||||||
|
.note-save-status.unsaved { color: var(--mc-red); }
|
||||||
|
|
||||||
|
.note-menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 100%;
|
||||||
|
background: var(--mc-deep);
|
||||||
|
border: 3px double var(--mc-bright);
|
||||||
|
box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.45);
|
||||||
|
min-width: 140px;
|
||||||
|
z-index: 10;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
.note-menu-dropdown button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
color: var(--mc-bright);
|
||||||
|
font: inherit;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.note-menu-dropdown button:hover { background: var(--mc-cyan); color: var(--mc-black); }
|
||||||
|
|
||||||
|
|
||||||
|
/* --- Notifications --- */
|
||||||
|
.notification-list { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
.notification-item {
|
||||||
|
border: 2px solid var(--mc-dim);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
background: var(--mc-deep);
|
||||||
|
}
|
||||||
|
.notification-item.unread { border-left: 6px solid var(--mc-yellow); }
|
||||||
|
.notification-item.read { opacity: 0.78; }
|
||||||
|
.notification-item a {
|
||||||
|
display: block;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
color: var(--mc-fg);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.notification-item a:hover {
|
||||||
|
background: var(--mc-cyan);
|
||||||
|
color: var(--mc-black);
|
||||||
|
}
|
||||||
|
.notification-message { display: block; margin-bottom: 0.25rem; }
|
||||||
|
.notification-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: var(--mc-dim);
|
||||||
|
}
|
||||||
|
.notification-item a:hover .notification-meta { color: var(--mc-black); }
|
||||||
|
.notification-status {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 0 0.45em;
|
||||||
|
background: var(--mc-dim);
|
||||||
|
color: var(--mc-black);
|
||||||
|
}
|
||||||
|
.notification-item.unread .notification-status {
|
||||||
|
background: var(--mc-yellow);
|
||||||
|
color: var(--mc-black);
|
||||||
|
}
|
||||||
|
.notification-delete-form { margin: 0; }
|
||||||
|
.notification-delete-form button {
|
||||||
|
background: var(--mc-red);
|
||||||
|
color: var(--mc-bright);
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.notification-delete-form button:hover { background: var(--mc-red-bright); }
|
||||||
|
|
||||||
|
/* --- Flash messages --- */
|
||||||
|
.flash-messages { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
.flash {
|
||||||
|
border: 2px solid var(--mc-bright);
|
||||||
|
box-shadow: 4px 4px 0 var(--mc-black);
|
||||||
|
padding: 0.4em 0.8em;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
.flash-success { background: var(--mc-deep); border-left: 6px solid var(--mc-cyan); }
|
||||||
|
.flash-error { background: var(--mc-red); color: var(--mc-bright); border-left: 6px solid var(--mc-red-bright); }
|
||||||
|
|
||||||
|
/* --- Clipboard --- */
|
||||||
|
.clipboard-list { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
.clipboard-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6em;
|
||||||
|
border: 2px solid var(--mc-dim);
|
||||||
|
background: var(--mc-deep);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
}
|
||||||
|
.clipboard-main { min-width: 0; }
|
||||||
|
.clipboard-item a { color: var(--mc-cyan); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.clipboard-item a:hover { text-decoration: underline; }
|
||||||
|
.clipboard-meta { color: var(--mc-dim); font-size: 0.8em; margin-left: 0.6em; }
|
||||||
|
.clipboard-delete-form { margin: 0; flex: 0 0 auto; flex-direction: row; align-items: center; }
|
||||||
|
.clipboard-delete-form button {
|
||||||
|
background: var(--mc-red);
|
||||||
|
color: var(--mc-bright);
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 0.1em 0.4em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
.clipboard-delete-form button:hover { background: var(--mc-red-bright); }
|
||||||
|
|
||||||
|
/* --- Calendar --- */
|
||||||
|
.calendar-controls { display: flex; gap: 0.8em; align-items: center; margin-top: 0.5rem; }
|
||||||
|
.calendar-title-row { display: flex; align-items: center; justify-content: space-between; gap: 0.8em; margin-top: 0.5rem; }
|
||||||
|
.calendar-grid { display: flex; flex-direction: column; gap: 0.4em; margin-top: 1rem; }
|
||||||
|
.calendar-week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 0.4em; }
|
||||||
|
.calendar-day { border: 2px solid var(--mc-dim); background: var(--mc-deep); min-height: 6em; padding: 0.3em; width: 100%; box-sizing: border-box; }
|
||||||
|
.calendar-day-outside { opacity: 0.55; }
|
||||||
|
.calendar-day-header { color: var(--mc-yellow); text-transform: uppercase; letter-spacing: 1px; font-size: 0.8em; }
|
||||||
|
.calendar-readouts { list-style: none; padding: 0; margin: 0.3em 0 0; }
|
||||||
|
.calendar-event { border-bottom: 1px dashed var(--mc-dim); padding: 0.15em 0; font-size: 0.9em; }
|
||||||
|
.calendar-event-time { color: var(--mc-cyan); margin-right: 0.4em; }
|
||||||
|
.calendar-event-sensor { color: var(--mc-bright); }
|
||||||
|
.calendar-event-type { color: var(--mc-dim); }
|
||||||
|
.calendar-event-value { float: right; color: var(--mc-yellow); }
|
||||||
|
.calendar-list { list-style: none; padding: 0; margin: 1rem 0; }
|
||||||
|
.calendar-day-item { border: 2px solid var(--mc-dim); background: var(--mc-deep); margin: 0.5rem 0; padding: 0.4em 0.6em; min-height: 6em; width: 100%; box-sizing: border-box; }
|
||||||
|
.calendar-nav { display: flex; gap: 0.8em; margin-top: 0.8rem; }
|
||||||
|
.calendar-today {
|
||||||
|
display: inline-block;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: var(--mc-yellow);
|
||||||
|
color: var(--mc-black);
|
||||||
|
box-shadow: 4px 4px 0 var(--mc-black);
|
||||||
|
padding: 0.4em 1.2em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.calendar-today:hover { background: var(--mc-cyan); color: var(--mc-black); }
|
||||||
|
.calendar-day.calendar-today,
|
||||||
|
.calendar-day-item.calendar-today {
|
||||||
|
background: var(--mc-deep);
|
||||||
|
border: 3px solid var(--mc-yellow);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,96 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Calendar — Yggdrasil</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Home</a>
|
||||||
|
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||||
|
<a href="{{ url_for('notes_page') }}">Notes</a>
|
||||||
|
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
|
||||||
|
<a href="{{ url_for('calendar_page') }}">Calendar</a>
|
||||||
|
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
|
||||||
|
<a href="{{ url_for('api_docs') }}">API</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Log out</a>
|
||||||
|
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
|
||||||
|
</nav>
|
||||||
|
<h1>Calendar</h1>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Readouts</h2>
|
||||||
|
<div class="calendar-controls">
|
||||||
|
<a href="{{ url_for('calendar_page', view='month', year=year, month=month) }}"><span class="mc-key">M</span>Month</a>
|
||||||
|
<a href="{{ url_for('calendar_page', view='week', date=today.isoformat()) }}"><span class="mc-key">W</span>Week</a>
|
||||||
|
{% if view == 'month' %}
|
||||||
|
<a class="calendar-today" href="{{ url_for('calendar_page', view='month', year=today.year, month=today.month) }}"><span class="mc-key">T</span>Today</a>
|
||||||
|
<a href="{{ url_for('calendar_page', view='month', year=prev_year, month=prev_month) }}"><</a>
|
||||||
|
<a href="{{ url_for('calendar_page', view='month', year=next_year, month=next_month) }}">></a>
|
||||||
|
{% else %}
|
||||||
|
<a class="calendar-today" href="{{ url_for('calendar_page', view='week', date=today.isoformat()) }}"><span class="mc-key">T</span>Today</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if view == 'month' %}
|
||||||
|
<div class="calendar-title-row">
|
||||||
|
<h2 class="calendar-title">{{ month_label }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-grid">
|
||||||
|
{% for week in month_weeks %}
|
||||||
|
<div class="calendar-week">
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="calendar-day{% if not day.in_month %} calendar-day-outside{% endif %}{% if day.date == today %} calendar-today{% endif %}">
|
||||||
|
<div class="calendar-day-header">{{ day.date.strftime("%a %d %b") }}</div>
|
||||||
|
<ul class="calendar-readouts">
|
||||||
|
{% for r in day.readouts %}
|
||||||
|
<li class="calendar-event">
|
||||||
|
<span class="calendar-event-time">{{ r.timestamp.strftime("%H:%M") }}</span>
|
||||||
|
<span class="calendar-event-sensor">{{ sensor_names[r.sensor_id] }}</span>
|
||||||
|
<span class="calendar-event-type">{{ r.type }}</span>
|
||||||
|
<span class="calendar-event-value">{{ r.value|round(1) }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="calendar-title-row">
|
||||||
|
<h2 class="calendar-title">Tydzień {{ week_start }} – {{ week_end }}</h2>
|
||||||
|
<div class="calendar-nav">
|
||||||
|
<a href="{{ url_for('calendar_page', view='week', date=week_prev.isoformat()) }}"><</a>
|
||||||
|
<a href="{{ url_for('calendar_page', view='week', date=week_next.isoformat()) }}">></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="calendar-list">
|
||||||
|
{% for day in days %}
|
||||||
|
<li class="calendar-day-item{% if day.date == today %} calendar-today{% endif %}" id="calendar-{{ day.date }}">
|
||||||
|
<div class="calendar-day-header">{{ day.date.strftime("%Y-%m-%d (%a)") }}</div>
|
||||||
|
<ul class="calendar-readouts">
|
||||||
|
{% for r in day.readouts %}
|
||||||
|
<li class="calendar-event">
|
||||||
|
<span class="calendar-event-time">{{ r.timestamp.strftime("%H:%M") }}</span>
|
||||||
|
<span class="calendar-event-sensor">{{ sensor_names[r.sensor_id] }}</span>
|
||||||
|
<span class="calendar-event-type">{{ r.type }}</span>
|
||||||
|
<span class="calendar-event-value">{{ r.value|round(1) }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span>{{ user.username }}@yggdrasil:~/calendar$</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Clipboard — Yggdrasil</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Home</a>
|
||||||
|
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||||
|
<a href="{{ url_for('notes_page') }}">Notes</a>
|
||||||
|
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
|
||||||
|
<a href="{{ url_for('calendar_page') }}">Calendar</a>
|
||||||
|
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
|
||||||
|
<a href="{{ url_for('api_docs') }}">API</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Log out</a>
|
||||||
|
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
|
||||||
|
</nav>
|
||||||
|
<h1>Clipboard</h1>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Upload file</h2>
|
||||||
|
<p class="hint">
|
||||||
|
Max single file: {{ max_file | filesize }}. Total quota:
|
||||||
|
{{ max_total | filesize }} — used: {{ total_used | filesize }},
|
||||||
|
free: {{ remaining | filesize }}.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ url_for('clipboard_upload') }}" enctype="multipart/form-data">
|
||||||
|
<label for="file-upload">File:</label>
|
||||||
|
<input type="file" id="file-upload" name="file" required>
|
||||||
|
<button type="submit">[ Upload ]</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="flash-messages">
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<li class="flash flash-{{ category }}">{{ message }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% if not files %}
|
||||||
|
<p class="hint">No files yet. Upload your first file above.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="clipboard-list">
|
||||||
|
{% for file in files %}
|
||||||
|
<li class="clipboard-item" id="clipboard-{{ file.id }}">
|
||||||
|
<div class="clipboard-main">
|
||||||
|
<a href="{{ url_for('clipboard_download', file_id=file.id) }}"
|
||||||
|
download="{{ file.original_name }}"
|
||||||
|
title="{{ file.original_name }}">{{ file.original_name }}</a>
|
||||||
|
<span class="clipboard-meta">{{ file.size | filesize }} · {{ file.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('clipboard_delete', file_id=file.id) }}" class="clipboard-delete-form">
|
||||||
|
<button type="submit" title="Delete {{ file.original_name }}">[x]</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span>{{ user.username }}@yggdrasil:~/clipboard$</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+27
-26
@@ -3,31 +3,28 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>API Documentation - Sensor Readouts</title>
|
<title>API Documentation - Yggdrasil</title>
|
||||||
<style>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
body { font-family: sans-serif; max-width: 900px; margin: 2rem auto; line-height: 1.5; }
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||||
h2 { margin-top: 2rem; color: #333; }
|
|
||||||
.endpoint { background: #f8f8f8; border: 1px solid #ddd; border-radius: 6px; padding: 1rem 1.25rem; margin: 1rem 0; }
|
|
||||||
.method { display: inline-block; font-weight: bold; padding: 0.15rem 0.5rem; border-radius: 3px; color: #fff; margin-right: 0.5rem; font-size: 0.85rem; }
|
|
||||||
.get { background: #2563eb; }
|
|
||||||
.post { background: #16a34a; }
|
|
||||||
.path { font-family: monospace; font-size: 1rem; }
|
|
||||||
.desc { margin: 0.5rem 0; }
|
|
||||||
table { border-collapse: collapse; width: 100%; margin: 0.5rem 0; }
|
|
||||||
th, td { border: 1px solid #ccc; padding: 0.35rem 0.6rem; text-align: left; font-size: 0.9rem; }
|
|
||||||
th { background: #f0f0f0; }
|
|
||||||
pre { background: #1e1e1e; color: #d4d4d4; padding: 0.75rem; border-radius: 4px; overflow-x: auto; font-size: 0.85rem; }
|
|
||||||
code { font-family: monospace; }
|
|
||||||
.note { background: #fef9c3; border-left: 3px solid #eab308; padding: 0.5rem 0.75rem; margin: 1rem 0; font-size: 0.9rem; }
|
|
||||||
.section-label { font-size: 0.8rem; color: #888; margin: 0.75rem 0 0.25rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Home</a>
|
||||||
|
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||||
|
<a href="{{ url_for('notes_page') }}">Notes</a>
|
||||||
|
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
|
||||||
|
<a href="{{ url_for('calendar_page') }}">Calendar</a>
|
||||||
|
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
|
||||||
|
<a href="{{ url_for('api_docs') }}">API</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Log out</a>
|
||||||
|
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
|
||||||
|
</nav>
|
||||||
<h1>Sensor Readouts — API Documentation</h1>
|
<h1>Sensor Readouts — API Documentation</h1>
|
||||||
|
|
||||||
<h2>Base URL</h2>
|
<h2>Base URL</h2>
|
||||||
<p><code>http://localhost:5000/api</code></p>
|
<p><code>{{ main_api_url }}</code></p>
|
||||||
|
<p>The main app proxies all requests to the sensors API service.</p>
|
||||||
|
|
||||||
<!-- GET /api/sensors -->
|
<!-- GET /api/sensors -->
|
||||||
<h2>GET /api/sensors</h2>
|
<h2>GET /api/sensors</h2>
|
||||||
@@ -35,7 +32,7 @@
|
|||||||
<span class="method get">GET</span><span class="path">/api/sensors</span>
|
<span class="method get">GET</span><span class="path">/api/sensors</span>
|
||||||
<p class="desc">Return a list of all sensors ordered by name.</p>
|
<p class="desc">Return a list of all sensors ordered by name.</p>
|
||||||
<p class="section-label">curl</p>
|
<p class="section-label">curl</p>
|
||||||
<pre><code>curl http://localhost:5000/api/sensors</code></pre>
|
<pre><code>curl {{ main_api_url }}/sensors</code></pre>
|
||||||
<p class="section-label">Response 200</p>
|
<p class="section-label">Response 200</p>
|
||||||
<pre><code>[
|
<pre><code>[
|
||||||
{ "id": 1, "name": "outdoor-station" },
|
{ "id": 1, "name": "outdoor-station" },
|
||||||
@@ -48,7 +45,7 @@
|
|||||||
<span class="method post">POST</span><span class="path">/api/sensors</span>
|
<span class="method post">POST</span><span class="path">/api/sensors</span>
|
||||||
<p class="desc">Create a new sensor.</p>
|
<p class="desc">Create a new sensor.</p>
|
||||||
<p class="section-label">curl</p>
|
<p class="section-label">curl</p>
|
||||||
<pre><code>curl -X POST http://localhost:5000/api/sensors \
|
<pre><code>curl -X POST {{ main_api_url }}/sensors \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name": "new-sensor"}'</code></pre>
|
-d '{"name": "new-sensor"}'</code></pre>
|
||||||
<p class="section-label">Response 201</p>
|
<p class="section-label">Response 201</p>
|
||||||
@@ -67,7 +64,7 @@
|
|||||||
<span class="method get">GET</span><span class="path">/api/sensors/:id/types</span>
|
<span class="method get">GET</span><span class="path">/api/sensors/:id/types</span>
|
||||||
<p class="desc">Return distinct readout types available for a given sensor.</p>
|
<p class="desc">Return distinct readout types available for a given sensor.</p>
|
||||||
<p class="section-label">curl</p>
|
<p class="section-label">curl</p>
|
||||||
<pre><code>curl http://localhost:5000/api/sensors/1/types</code></pre>
|
<pre><code>curl {{ main_api_url }}/sensors/1/types</code></pre>
|
||||||
<p class="section-label">Response 200</p>
|
<p class="section-label">Response 200</p>
|
||||||
<pre><code>["humidity", "pressure", "temperature"]</code></pre>
|
<pre><code>["humidity", "pressure", "temperature"]</code></pre>
|
||||||
<p><strong>Errors:</strong></p>
|
<p><strong>Errors:</strong></p>
|
||||||
@@ -89,10 +86,10 @@
|
|||||||
</table>
|
</table>
|
||||||
<p class="section-label">curl</p>
|
<p class="section-label">curl</p>
|
||||||
<pre><code># all types
|
<pre><code># all types
|
||||||
curl http://localhost:5000/api/sensors/1/readouts
|
curl {{ main_api_url }}/sensors/1/readouts
|
||||||
|
|
||||||
# filter by type
|
# filter by type
|
||||||
curl "http://localhost:5000/api/sensors/1/readouts?type=temperature"</code></pre>
|
curl "{{ main_api_url }}/sensors/1/readouts?type=temperature"</code></pre>
|
||||||
<p class="section-label">Response 200</p>
|
<p class="section-label">Response 200</p>
|
||||||
<pre><code>[
|
<pre><code>[
|
||||||
{ "value": 24.31, "timestamp": "2026-09-11T10:30:00" },
|
{ "value": 24.31, "timestamp": "2026-09-11T10:30:00" },
|
||||||
@@ -105,7 +102,7 @@ curl "http://localhost:5000/api/sensors/1/readouts?type=temperature"</code></pre
|
|||||||
<span class="method post">POST</span><span class="path">/api/sensors/:id/readouts</span>
|
<span class="method post">POST</span><span class="path">/api/sensors/:id/readouts</span>
|
||||||
<p class="desc">Add a new readout to an existing sensor. Timestamp is generated server-side.</p>
|
<p class="desc">Add a new readout to an existing sensor. Timestamp is generated server-side.</p>
|
||||||
<p class="section-label">curl</p>
|
<p class="section-label">curl</p>
|
||||||
<pre><code>curl -X POST http://localhost:5000/api/sensors/1/readouts \
|
<pre><code>curl -X POST {{ main_api_url }}/sensors/1/readouts \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"type": "temperature", "value": 22.5}'</code></pre>
|
-d '{"type": "temperature", "value": 22.5}'</code></pre>
|
||||||
<p class="section-label">Response 201</p>
|
<p class="section-label">Response 201</p>
|
||||||
@@ -127,5 +124,9 @@ curl "http://localhost:5000/api/sensors/1/readouts?type=temperature"</code></pre
|
|||||||
<div class="note">
|
<div class="note">
|
||||||
<strong>Note:</strong> All request and response bodies use <code>application/json</code>.
|
<strong>Note:</strong> All request and response bodies use <code>application/json</code>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span>{{ user.username }}@yggdrasil:~/api$</span>
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Yggdrasil — Home</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Home</a>
|
||||||
|
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||||
|
<a href="{{ url_for('notes_page') }}">Notes</a>
|
||||||
|
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
|
||||||
|
<a href="{{ url_for('calendar_page') }}">Calendar</a>
|
||||||
|
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
|
||||||
|
<a href="{{ url_for('api_docs') }}">API</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Log out</a>
|
||||||
|
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
|
||||||
|
</nav>
|
||||||
|
<h1>Yggdrasil</h1>
|
||||||
|
<div class="panel">
|
||||||
|
<p>Welcome, <span class="hl">{{ user.username }}</span>! You are logged in{% if user.is_admin %} as <b class="hl">administrator</b>{% endif %}.</p>
|
||||||
|
<p>Select an item from the menu:</p>
|
||||||
|
<ul class="mc-menu">
|
||||||
|
<li><a href="{{ url_for('sensors_page') }}"><span class="mc-key">1</span>Sensors — charts and readouts</a></li>
|
||||||
|
<li><a href="{{ url_for('notes_page') }}"><span class="mc-key">2</span>Notes — title, text and checklists</a></li>
|
||||||
|
<li><a href="{{ url_for('notifications_page') }}"><span class="mc-key">3</span>Notifications{% if unread_notifications %} ({{ unread_notifications }} new){% endif %}</a></li>
|
||||||
|
<li><a href="{{ url_for('api_docs') }}"><span class="mc-key">4</span>API — documentation</a></li>
|
||||||
|
<li><a href="{{ url_for('clipboard_page') }}"><span class="mc-key">5</span>Clipboard — upload and share files</a></li>
|
||||||
|
<li><a href="{{ url_for('calendar_page') }}"><span class="mc-key">6</span>Calendar — sensor readings</a></li>
|
||||||
|
<li><a href="{{ url_for('logout') }}"><span class="mc-key">0</span>Log out</a></li>
|
||||||
|
</ul>
|
||||||
|
<p class="hint">Tip: press <b>1</b>, <b>2</b>, <b>3</b>, <b>4</b>, <b>5</b>, <b>6</b> or <b>0</b> to navigate.</p>
|
||||||
|
</div>
|
||||||
|
<img class="logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span>{{ user.username }}@yggdrasil:~$</span>
|
||||||
|
</footer>
|
||||||
|
<script>
|
||||||
|
// Numbered menu hotkeys, Midnight Commander style.
|
||||||
|
// Ignored while typing in a form field.
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
|
const tag = (document.activeElement && document.activeElement.tagName) || '';
|
||||||
|
if (/^(INPUT|TEXTAREA|SELECT)$/.test(tag)) return;
|
||||||
|
const targets = {
|
||||||
|
'1': "{{ url_for('sensors_page') }}",
|
||||||
|
'2': "{{ url_for('notes_page') }}",
|
||||||
|
'3': "{{ url_for('notifications_page') }}",
|
||||||
|
'4': "{{ url_for('api_docs') }}",
|
||||||
|
'5': "{{ url_for('clipboard_page') }}",
|
||||||
|
'6': "{{ url_for('calendar_page') }}",
|
||||||
|
'0': "{{ url_for('logout') }}"
|
||||||
|
};
|
||||||
|
if (targets[e.key]) window.location.href = targets[e.key];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login — Yggdrasil</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dialog">
|
||||||
|
<div class="dialog-title">[ Login ]</div>
|
||||||
|
<h1>Yggdrasil</h1>
|
||||||
|
{% if error %}
|
||||||
|
<p class="error">! {{ error }}</p>
|
||||||
|
{% endif %}
|
||||||
|
<form method="post" action="{{ url_for('login', next=next) if next else url_for('login') }}">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
<div>
|
||||||
|
<label for="username">Username:</label><br>
|
||||||
|
<div class="term-field">
|
||||||
|
<input type="text" id="username" name="username" required autocomplete="username" autofocus>
|
||||||
|
<span class="caret-block" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password">Password:</label><br>
|
||||||
|
<div class="term-field">
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
|
<span class="caret-block" aria-hidden="true"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit">[ OK ] Log in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
// Block caret, Midnight Commander style.
|
||||||
|
// The native caret is hidden (only when this script runs) and a
|
||||||
|
// blinking block is drawn exactly where the text cursor is.
|
||||||
|
// Measurement is exact because the font is monospace.
|
||||||
|
// Without JS the fields keep their normal native caret.
|
||||||
|
function attachBlockCaret(input) {
|
||||||
|
const field = input.closest('.term-field');
|
||||||
|
const block = field.querySelector('.caret-block');
|
||||||
|
input.classList.add('js-caret');
|
||||||
|
const ctx = document.createElement('canvas').getContext('2d');
|
||||||
|
|
||||||
|
function place() {
|
||||||
|
const cs = getComputedStyle(input);
|
||||||
|
ctx.font = cs.font;
|
||||||
|
const pos = (input.selectionStart !== null && input.selectionStart !== undefined)
|
||||||
|
? input.selectionStart
|
||||||
|
: input.value.length;
|
||||||
|
let text = input.value.slice(0, pos);
|
||||||
|
if (input.type === 'password') text = '\u2022'.repeat(text.length);
|
||||||
|
const padLeft = parseFloat(cs.paddingLeft) || 0;
|
||||||
|
const borderLeft = parseFloat(cs.borderLeftWidth) || 0;
|
||||||
|
const x = borderLeft + padLeft + ctx.measureText(text).width - (input.scrollLeft || 0);
|
||||||
|
block.style.left = Math.max(borderLeft + padLeft, x) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
function show() {
|
||||||
|
field.classList.add('active');
|
||||||
|
requestAnimationFrame(place);
|
||||||
|
}
|
||||||
|
function hide() { field.classList.remove('active'); }
|
||||||
|
|
||||||
|
['input', 'change', 'keyup', 'click', 'select', 'focus'].forEach((ev) => {
|
||||||
|
input.addEventListener(ev, show);
|
||||||
|
});
|
||||||
|
input.addEventListener('blur', hide);
|
||||||
|
window.addEventListener('load', () => setTimeout(place, 300)); // late autofill
|
||||||
|
if (document.fonts && document.fonts.ready) document.fonts.ready.then(place);
|
||||||
|
if (document.activeElement === input) show();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.term-field input').forEach(attachBlockCaret);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Notes — Yggdrasil</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Home</a>
|
||||||
|
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||||
|
<a href="{{ url_for('notes_page') }}">Notes</a>
|
||||||
|
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
|
||||||
|
<a href="{{ url_for('calendar_page') }}">Calendar</a>
|
||||||
|
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
|
||||||
|
<a href="{{ url_for('api_docs') }}">API</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Log out</a>
|
||||||
|
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
|
||||||
|
</nav>
|
||||||
|
<h1>Notes</h1>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>New note</h2>
|
||||||
|
<form method="post" action="{{ url_for('notes_create') }}" data-note-id="new">
|
||||||
|
<label for="new-note-title">Title</label>
|
||||||
|
<input type="text" id="new-note-title" name="title" maxlength="200"
|
||||||
|
placeholder="Note title" required>
|
||||||
|
<label for="new-note-content">Text</label>
|
||||||
|
<textarea id="new-note-content" name="content" rows="3" maxlength="2000"
|
||||||
|
placeholder="Write a short note..."></textarea>
|
||||||
|
<button type="submit">[ Add note ]</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not notes %}
|
||||||
|
<p class="hint">No notes yet. Add your first note above.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for note in notes %}
|
||||||
|
<section class="note-card" id="note-{{ note.id }}">
|
||||||
|
<form method="post" action="{{ url_for('notes_update', note_id=note.id) }}" data-note-id="{{ note.id }}">
|
||||||
|
<div class="note-title-row">
|
||||||
|
<label for="note-title-{{ note.id }}">Title</label>
|
||||||
|
<span class="note-save-status"></span>
|
||||||
|
<button class="note-menu-btn" type="button" aria-label="More options">⋮</button>
|
||||||
|
<div class="note-menu-dropdown hidden">
|
||||||
|
<button type="button" class="menu-delete" data-note-id="{{ note.id }}">Delete note</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="note-title-{{ note.id }}" name="title"
|
||||||
|
value="{{ note.title }}" maxlength="200" required>
|
||||||
|
<label for="note-content-{{ note.id }}">Text</label>
|
||||||
|
<textarea id="note-content-{{ note.id }}" name="content" rows="2"
|
||||||
|
maxlength="2000">{{ note.content }}</textarea>
|
||||||
|
<div class="note-actions">
|
||||||
|
<span class="note-meta">{{ note.updated_at.strftime("%Y-%m-%d %H:%M") }}</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul class="checklist">
|
||||||
|
{% for item in note.ordered_items %}
|
||||||
|
<li class="{{ 'done' if item.is_done else '' }}">
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('notes_toggle_item', note_id=note.id, item_id=item.id) }}"
|
||||||
|
class="check-form">
|
||||||
|
<input type="checkbox" name="is_done" value="1"
|
||||||
|
{% if item.is_done %}checked{% endif %}
|
||||||
|
onchange="this.form.submit()"
|
||||||
|
aria-label="Mark '{{ item.text }}' as done">
|
||||||
|
</form>
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('notes_update_item', note_id=note.id, item_id=item.id) }}"
|
||||||
|
class="item-edit-form">
|
||||||
|
<input type="text" name="text" value="{{ item.text }}"
|
||||||
|
maxlength="255" required aria-label="Checklist item text"
|
||||||
|
data-autosave-url="{{ url_for('notes_update_item', note_id=note.id, item_id=item.id) }}">
|
||||||
|
</form>
|
||||||
|
<form method="post"
|
||||||
|
action="{{ url_for('notes_delete_item', note_id=note.id, item_id=item.id) }}"
|
||||||
|
class="item-delete-form">
|
||||||
|
<button type="submit" title="Delete checklist item"
|
||||||
|
aria-label="Delete checklist item">[x]</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('notes_add_item', note_id=note.id) }}"
|
||||||
|
class="add-item-form">
|
||||||
|
<input type="text" name="text" maxlength="255" required
|
||||||
|
placeholder="New checklist item">
|
||||||
|
<button type="submit">[ Add item ]</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form id="delete-note-form-{{ note.id }}" method="post"
|
||||||
|
action="{{ url_for('notes_delete', note_id=note.id) }}"
|
||||||
|
class="hidden"></form>
|
||||||
|
</section>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span>{{ user.username }}@yggdrasil:~/notes$</span>
|
||||||
|
</footer>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var timers = {};
|
||||||
|
function autosave(form, url, payload) {
|
||||||
|
var id = form.getAttribute('data-note-id');
|
||||||
|
var key = id === 'new' ? 'new' : id;
|
||||||
|
clearTimeout(timers[key]);
|
||||||
|
timers[key] = setTimeout(function () {
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).then(function (r) { return r.json(); }).then(function (data) {
|
||||||
|
if (data && data.id && id === 'new') {
|
||||||
|
form.setAttribute('data-note-id', data.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
document.querySelectorAll('form[data-note-id]').forEach(function (form) {
|
||||||
|
var id = form.getAttribute('data-note-id');
|
||||||
|
var key = id === 'new' ? 'new' : id;
|
||||||
|
var statusEl = form.querySelector('.note-save-status');
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = 'note-save-status' + (cls ? ' ' + cls : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
form.querySelectorAll('input[name="title"], textarea[name="content"]').forEach(function (input) {
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
dirty = true;
|
||||||
|
setStatus('not saved', 'unsaved');
|
||||||
|
clearTimeout(timers[key]);
|
||||||
|
timers[key] = setTimeout(function () {
|
||||||
|
var title = form.querySelector('input[name="title"]').value;
|
||||||
|
var content = form.querySelector('textarea[name="content"]').value;
|
||||||
|
fetch('/notes/autosave', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: id === 'new' ? null : parseInt(id, 10),
|
||||||
|
title: title,
|
||||||
|
content: content
|
||||||
|
})
|
||||||
|
}).then(function (r) { return r.json(); }).then(function (data) {
|
||||||
|
dirty = false;
|
||||||
|
setStatus('saved', 'saved');
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!dirty) setStatus('', '');
|
||||||
|
}, 3000);
|
||||||
|
if (data && data.id && id === 'new') {
|
||||||
|
form.setAttribute('data-note-id', data.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.item-edit-form input[name="text"]').forEach(function (input) {
|
||||||
|
var form = input.closest('form');
|
||||||
|
var url = input.getAttribute('data-autosave-url');
|
||||||
|
var card = input.closest('.note-card');
|
||||||
|
var statusEl = card.querySelector('.note-save-status');
|
||||||
|
var key = form.getAttribute('action') + ':' + input.name;
|
||||||
|
var dirty = false;
|
||||||
|
|
||||||
|
function setStatus(text, cls) {
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = 'note-save-status' + (cls ? ' ' + cls : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
dirty = true;
|
||||||
|
setStatus('not saved', 'unsaved');
|
||||||
|
clearTimeout(timers[key]);
|
||||||
|
timers[key] = setTimeout(function () {
|
||||||
|
var text = input.value;
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ text: text })
|
||||||
|
}).then(function (r) { return r.json(); }).then(function () {
|
||||||
|
dirty = false;
|
||||||
|
setStatus('saved', 'saved');
|
||||||
|
setTimeout(function () {
|
||||||
|
if (!dirty) setStatus('', '');
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.note-menu-btn').forEach(function (btn) {
|
||||||
|
var card = btn.closest('.note-card');
|
||||||
|
var dropdown = card.querySelector('.note-menu-dropdown');
|
||||||
|
var deleteBtn = card.querySelector('.menu-delete');
|
||||||
|
var form = card.querySelector('form[id^="delete-note-form-"]');
|
||||||
|
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.querySelectorAll('.note-menu-dropdown').forEach(function (d) {
|
||||||
|
if (d !== dropdown) d.classList.add('hidden');
|
||||||
|
});
|
||||||
|
dropdown.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteBtn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (confirm('Are you sure you want to delete this note?')) {
|
||||||
|
form.submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function () {
|
||||||
|
document.querySelectorAll('.note-menu-dropdown').forEach(function (d) {
|
||||||
|
d.classList.add('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Notifications — Yggdrasil</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Home</a>
|
||||||
|
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||||
|
<a href="{{ url_for('notes_page') }}">Notes</a>
|
||||||
|
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
|
||||||
|
<a href="{{ url_for('calendar_page') }}">Calendar</a>
|
||||||
|
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
|
||||||
|
<a href="{{ url_for('api_docs') }}">API</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Log out</a>
|
||||||
|
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
|
||||||
|
</nav>
|
||||||
|
<h1>Notifications</h1>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
{% if unread_notifications %}
|
||||||
|
{{ unread_notifications }} new notification{{ "s" if unread_notifications != 1 else "" }}.
|
||||||
|
{% else %}
|
||||||
|
No new notifications.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if not notifications %}
|
||||||
|
<p class="hint">There are no notifications yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="notification-list">
|
||||||
|
{% for notification in notifications %}
|
||||||
|
<li id="notification-{{ notification.id }}"
|
||||||
|
class="notification-item {{ notification.status }}">
|
||||||
|
<a href="{{ url_for('notification_read', notification_id=notification.id) }}"
|
||||||
|
title="{{ 'Mark as read' if notification.status == 'new' else 'Read notification' }}">
|
||||||
|
<span class="notification-message">{{ notification.message }}</span>
|
||||||
|
<span class="notification-meta">
|
||||||
|
<span class="notification-status">{{ notification.status }}</span>
|
||||||
|
<span>{{ notification.created_at.strftime("%Y-%m-%d %H:%M") }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<form method="post" action="{{ url_for('notification_delete', notification_id=notification.id) }}" class="notification-delete-form">
|
||||||
|
<button type="submit" title="Delete notification">[x]</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span>{{ user.username }}@yggdrasil:~/notifications$</span>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+196
-50
@@ -3,21 +3,24 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Sensor Readouts</title>
|
<title>Sensor Readouts — Yggdrasil</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
<style>
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; }
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
|
||||||
select, table { width: 100%; margin-top: 0.5rem; }
|
<link rel="stylesheet" href="{{ url_for('static', filename='retro.css') }}">
|
||||||
select { padding: 0.4rem; }
|
|
||||||
table { border-collapse: collapse; margin-top: 1rem; }
|
|
||||||
th, td { border: 1px solid #ccc; padding: 0.4rem 0.6rem; text-align: left; }
|
|
||||||
th { background: #f5f5f5; }
|
|
||||||
label { font-weight: bold; }
|
|
||||||
.hidden { display: none; }
|
|
||||||
.chart-box { margin-top: 1rem; }
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('home') }}">Home</a>
|
||||||
|
<a href="{{ url_for('sensors_page') }}">Sensors</a>
|
||||||
|
<a href="{{ url_for('notes_page') }}">Notes</a>
|
||||||
|
<a href="{{ url_for('clipboard_page') }}">Clipboard</a>
|
||||||
|
<a href="{{ url_for('calendar_page') }}">Calendar</a>
|
||||||
|
<a href="{{ url_for('notifications_page') }}">Notifications{% if unread_notifications %} <span class="nav-badge">{{ unread_notifications }}</span>{% endif %}</a>
|
||||||
|
<a href="{{ url_for('api_docs') }}">API</a>
|
||||||
|
<a href="{{ url_for('logout') }}">Log out</a>
|
||||||
|
<img class="nav-logo" src="{{ url_for('static', filename='yggdrasil-logo.png') }}" alt="Yggdrasil logo">
|
||||||
|
</nav>
|
||||||
<h1>Sensor Readouts</h1>
|
<h1>Sensor Readouts</h1>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -34,8 +37,25 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="range-section" class="hidden">
|
||||||
|
<label for="range-select">Range:</label>
|
||||||
|
<select id="range-select">
|
||||||
|
<option value="today">Today</option>
|
||||||
|
<option value="24h">Last 24h</option>
|
||||||
|
<option value="week">Week</option>
|
||||||
|
<option value="month">Month</option>
|
||||||
|
<option value="all" selected>All</option>
|
||||||
|
</select>
|
||||||
|
<label id="avg-label" class="hidden">
|
||||||
|
<input type="checkbox" id="avg-checkbox"> hourly average
|
||||||
|
</label>
|
||||||
|
<button id="refresh-button" type="button">[ Refresh ]</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="chart-section" class="hidden chart-box">
|
<div id="chart-section" class="hidden chart-box">
|
||||||
<canvas id="chart"></canvas>
|
<canvas id="chart"></canvas>
|
||||||
|
<p id="chart-info" class="desc"></p>
|
||||||
|
<p id="chart-error" class="error hidden"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="readouts-section" class="hidden">
|
<div id="readouts-section" class="hidden">
|
||||||
@@ -50,23 +70,88 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer class="statusbar">
|
||||||
|
<span>{{ user.username }}@yggdrasil:~/sensors$</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const sensorSelect = document.getElementById('sensor-select');
|
const sensorSelect = document.getElementById('sensor-select');
|
||||||
const typeSelect = document.getElementById('type-select');
|
const typeSelect = document.getElementById('type-select');
|
||||||
const typeSection = document.getElementById('type-section');
|
const typeSection = document.getElementById('type-section');
|
||||||
|
const rangeSelect = document.getElementById('range-select');
|
||||||
|
const rangeSection = document.getElementById('range-section');
|
||||||
const chartSection = document.getElementById('chart-section');
|
const chartSection = document.getElementById('chart-section');
|
||||||
|
const chartError = document.getElementById('chart-error');
|
||||||
|
const refreshButton = document.getElementById('refresh-button');
|
||||||
const readoutsSection = document.getElementById('readouts-section');
|
const readoutsSection = document.getElementById('readouts-section');
|
||||||
const readoutsBody = document.getElementById('readouts-body');
|
const readoutsBody = document.getElementById('readouts-body');
|
||||||
|
const chartInfo = document.getElementById('chart-info');
|
||||||
|
const avgCheckbox = document.getElementById('avg-checkbox');
|
||||||
|
const avgLabel = document.getElementById('avg-label');
|
||||||
let chart = null;
|
let chart = null;
|
||||||
|
|
||||||
|
// Strefa czasowa przeglądarki — backend liczy "today" od północy
|
||||||
|
// w tej strefie (np. Europe/Warsaw = GMT+2 latem).
|
||||||
|
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Warsaw';
|
||||||
|
|
||||||
|
// The page must keep working (dropdown + table) even if the
|
||||||
|
// Chart.js CDN is unreachable — the chart is then simply skipped.
|
||||||
|
if (window.Chart) {
|
||||||
|
Chart.defaults.color = '#d7d7d7';
|
||||||
|
Chart.defaults.borderColor = 'rgba(215,215,215,0.18)';
|
||||||
|
Chart.defaults.font.family = "'VT323', 'Courier New', monospace";
|
||||||
|
Chart.defaults.font.size = 20;
|
||||||
|
}
|
||||||
|
|
||||||
function formatTimestamp(iso) {
|
function formatTimestamp(iso) {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleString('pl-PL', { timeZone: 'Europe/Warsaw' });
|
return d.toLocaleString('pl-PL', { timeZone: userTz });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatChartLabel(iso) {
|
function formatChartLabel(iso, timeOnly) {
|
||||||
const d = new Date(iso + 'Z');
|
const d = new Date(iso + 'Z');
|
||||||
return d.toLocaleString('pl-PL', { timeZone: 'Europe/Warsaw', hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit', year: 'numeric' });
|
if (timeOnly) {
|
||||||
|
return d.toLocaleString('pl-PL', { timeZone: userTz, hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
return d.toLocaleString('pl-PL', { timeZone: userTz, day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function chartInfoText(range, averaged) {
|
||||||
|
if (!averaged) {
|
||||||
|
if (range === 'today') return `Raw measurements since local midnight (${userTz}) — no averaging.`;
|
||||||
|
return 'Raw measurements from the last 24 hours — no averaging.';
|
||||||
|
}
|
||||||
|
const period = range === 'today' ? 'today (since local midnight)'
|
||||||
|
: range === '24h' ? 'the last 24 hours'
|
||||||
|
: range === 'week' ? 'the last 7 days'
|
||||||
|
: range === 'month' ? 'the last 30 days'
|
||||||
|
: 'all stored data';
|
||||||
|
return `Hourly averages over ${period} — each point is the mean of all measurements in that local hour (${userTz}).`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRawRange(range) {
|
||||||
|
return range === 'today' || range === '24h';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAvgVisibility() {
|
||||||
|
avgLabel.classList.toggle('hidden', !isRawRange(rangeSelect.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ciśnienie jest składowane w bazie w paskalach (tak wysyła firmware),
|
||||||
|
// a wyświetlane w hektopaskalach z jednym miejscem po przecinku.
|
||||||
|
function displayValue(type, value) {
|
||||||
|
if (type === 'pressure') return Math.round(value / 10) / 10;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTableValue(type, value) {
|
||||||
|
if (type === 'pressure') return (value / 100).toFixed(1) + ' hPa';
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yAxisTitle(type) {
|
||||||
|
if (type === 'pressure') return 'pressure (hPa)';
|
||||||
|
return type;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJSON(url) {
|
async function fetchJSON(url) {
|
||||||
@@ -87,6 +172,7 @@
|
|||||||
sensorSelect.addEventListener('change', async () => {
|
sensorSelect.addEventListener('change', async () => {
|
||||||
const sensorId = sensorSelect.value;
|
const sensorId = sensorSelect.value;
|
||||||
typeSection.classList.add('hidden');
|
typeSection.classList.add('hidden');
|
||||||
|
rangeSection.classList.add('hidden');
|
||||||
chartSection.classList.add('hidden');
|
chartSection.classList.add('hidden');
|
||||||
readoutsSection.classList.add('hidden');
|
readoutsSection.classList.add('hidden');
|
||||||
typeSelect.innerHTML = '<option value="">-- select type --</option>';
|
typeSelect.innerHTML = '<option value="">-- select type --</option>';
|
||||||
@@ -102,59 +188,119 @@
|
|||||||
typeSelect.appendChild(opt);
|
typeSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
typeSection.classList.remove('hidden');
|
typeSection.classList.remove('hidden');
|
||||||
|
rangeSection.classList.remove('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
typeSelect.addEventListener('change', async () => {
|
async function loadChart() {
|
||||||
const sensorId = sensorSelect.value;
|
const sensorId = sensorSelect.value;
|
||||||
const type = typeSelect.value;
|
const type = typeSelect.value;
|
||||||
|
const range = rangeSelect.value;
|
||||||
chartSection.classList.add('hidden');
|
chartSection.classList.add('hidden');
|
||||||
|
chartError.classList.add('hidden');
|
||||||
|
|
||||||
|
if (!sensorId || !type || !window.Chart) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// fetch hourly data for chart
|
||||||
|
const averaged = !isRawRange(range) || avgCheckbox.checked;
|
||||||
|
const hourly = await fetchJSON(`/api/sensors/${sensorId}/readouts/hourly?type=${encodeURIComponent(type)}&range=${encodeURIComponent(range)}&tz=${encodeURIComponent(userTz)}&avg=${averaged ? '1' : '0'}`);
|
||||||
|
if (!hourly.length) throw new Error('no data for this range');
|
||||||
|
|
||||||
|
// Surowe punkty today/24h (~1/min) — mniejsze markery,
|
||||||
|
// żeby wykres był czytelny. W obrębie doby na osi X
|
||||||
|
// wystarczy sama godzina (też po uśrednieniu).
|
||||||
|
const dense = !averaged;
|
||||||
|
const labels = hourly.map(r => formatChartLabel(r.timestamp, isRawRange(range)));
|
||||||
|
const values = hourly.map(r => displayValue(type, r.value));
|
||||||
|
|
||||||
|
if (chart) chart.destroy();
|
||||||
|
const ctx = document.getElementById('chart').getContext('2d');
|
||||||
|
chart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: type,
|
||||||
|
data: values,
|
||||||
|
borderColor: '#55ffff',
|
||||||
|
backgroundColor: 'rgba(85,255,255,0.12)',
|
||||||
|
pointBackgroundColor: '#ffff55',
|
||||||
|
pointBorderColor: '#55ffff',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: dense ? 1.5 : 3,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: {
|
||||||
|
x: { title: { display: true, text: dense ? 'Time' : 'Time (hourly avg)' } },
|
||||||
|
y: { title: { display: true, text: yAxisTitle(type) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chartSection.classList.remove('hidden');
|
||||||
|
// The chart was created while the container was hidden,
|
||||||
|
// so force a re-measure now that it is visible.
|
||||||
|
chart.resize();
|
||||||
|
chartInfo.textContent = '> ' + chartInfoText(range, averaged);
|
||||||
|
} catch (err) {
|
||||||
|
chartError.textContent = '! Chart error: ' + err.message;
|
||||||
|
chartError.classList.remove('hidden');
|
||||||
|
chartInfo.textContent = '';
|
||||||
|
chartSection.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTable() {
|
||||||
|
const sensorId = sensorSelect.value;
|
||||||
|
const type = typeSelect.value;
|
||||||
readoutsSection.classList.add('hidden');
|
readoutsSection.classList.add('hidden');
|
||||||
readoutsBody.innerHTML = '';
|
readoutsBody.innerHTML = '';
|
||||||
|
|
||||||
if (!type) return;
|
if (!sensorId || !type) return;
|
||||||
|
|
||||||
// fetch hourly data for chart
|
|
||||||
const hourly = await fetchJSON(`/api/sensors/${sensorId}/readouts/hourly?type=${type}`);
|
|
||||||
const labels = hourly.map(r => formatChartLabel(r.timestamp));
|
|
||||||
const values = hourly.map(r => r.value);
|
|
||||||
|
|
||||||
if (chart) chart.destroy();
|
|
||||||
const ctx = document.getElementById('chart').getContext('2d');
|
|
||||||
chart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: labels,
|
|
||||||
datasets: [{
|
|
||||||
label: type,
|
|
||||||
data: values,
|
|
||||||
borderColor: '#2563eb',
|
|
||||||
backgroundColor: 'rgba(37,99,235,0.1)',
|
|
||||||
fill: true,
|
|
||||||
tension: 0.3,
|
|
||||||
pointRadius: 2,
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
plugins: { legend: { display: false } },
|
|
||||||
scales: {
|
|
||||||
x: { title: { display: true, text: 'Time (hourly avg)' } },
|
|
||||||
y: { title: { display: true, text: type } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
chartSection.classList.remove('hidden');
|
|
||||||
|
|
||||||
// fetch raw readouts for table
|
// fetch raw readouts for table
|
||||||
const readouts = await fetchJSON(`/api/sensors/${sensorId}/readouts?type=${type}`);
|
const readouts = await fetchJSON(`/api/sensors/${sensorId}/readouts?type=${type}`);
|
||||||
readouts.forEach(r => {
|
readouts.forEach(r => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `<td>${formatTimestamp(r.timestamp)}</td><td>${r.value}</td>`;
|
tr.innerHTML = `<td>${formatTimestamp(r.timestamp)}</td><td>${formatTableValue(type, r.value)}</td>`;
|
||||||
readoutsBody.appendChild(tr);
|
readoutsBody.appendChild(tr);
|
||||||
});
|
});
|
||||||
readoutsSection.classList.remove('hidden');
|
readoutsSection.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSelect.addEventListener('change', async () => {
|
||||||
|
if (!typeSelect.value) {
|
||||||
|
chartSection.classList.add('hidden');
|
||||||
|
readoutsSection.classList.add('hidden');
|
||||||
|
readoutsBody.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadChart();
|
||||||
|
await loadTable();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
refreshButton.addEventListener('click', async () => {
|
||||||
|
if (!sensorSelect.value || !typeSelect.value || refreshButton.disabled) return;
|
||||||
|
refreshButton.disabled = true;
|
||||||
|
try {
|
||||||
|
await loadChart();
|
||||||
|
await loadTable();
|
||||||
|
} finally {
|
||||||
|
refreshButton.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rangeSelect.addEventListener('change', () => {
|
||||||
|
updateAvgVisibility();
|
||||||
|
loadChart();
|
||||||
|
});
|
||||||
|
|
||||||
|
avgCheckbox.addEventListener('change', loadChart);
|
||||||
|
|
||||||
|
updateAvgVisibility();
|
||||||
loadSensors();
|
loadSensors();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,457 @@
|
|||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from werkzeug.serving import make_server
|
||||||
|
|
||||||
|
ENV_KEYS = (
|
||||||
|
"SENSORS_DATABASE_URL",
|
||||||
|
"USERS_DATABASE_URL",
|
||||||
|
"SECRET_KEY",
|
||||||
|
"SENSORS_API_URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_env(keys):
|
||||||
|
return {key: os.environ.get(key) for key in keys}
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_env(saved):
|
||||||
|
for key, value in saved.items():
|
||||||
|
if value is None:
|
||||||
|
os.environ.pop(key, None)
|
||||||
|
else:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
class SensorApiTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
cls._env = _save_env(ENV_KEYS)
|
||||||
|
os.environ["SENSORS_DATABASE_URL"] = (
|
||||||
|
f"sqlite:///{Path(cls.tmpdir.name) / 'sensors.db'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
from models import db
|
||||||
|
from sensors_app import create_app
|
||||||
|
|
||||||
|
cls.app = create_app()
|
||||||
|
with cls.app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
cls.client = cls.app.test_client()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
from models import db
|
||||||
|
|
||||||
|
with cls.app.app_context():
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
cls.tmpdir.cleanup()
|
||||||
|
_restore_env(cls._env)
|
||||||
|
|
||||||
|
def test_sensors_db_has_no_user_table(self):
|
||||||
|
from models import db
|
||||||
|
|
||||||
|
with self.app.app_context():
|
||||||
|
tables = set(inspect(db.engine).get_table_names())
|
||||||
|
self.assertEqual(tables, {"sensor", "sensor_readout"})
|
||||||
|
|
||||||
|
def test_all_api_endpoints_are_public(self):
|
||||||
|
created = self.client.post("/api/sensors", json={"name": "api-test-sensor"})
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
sensor_id = created.get_json()["id"]
|
||||||
|
|
||||||
|
readout = self.client.post(
|
||||||
|
f"/api/sensors/{sensor_id}/readouts",
|
||||||
|
json={"type": "temperature", "value": 20.5},
|
||||||
|
)
|
||||||
|
self.assertEqual(readout.status_code, 201)
|
||||||
|
|
||||||
|
self.assertEqual(self.client.get("/api/sensors").status_code, 200)
|
||||||
|
types = self.client.get(f"/api/sensors/{sensor_id}/types")
|
||||||
|
self.assertEqual(types.status_code, 200)
|
||||||
|
self.assertEqual(types.get_json(), ["temperature"])
|
||||||
|
|
||||||
|
readouts = self.client.get(f"/api/sensors/{sensor_id}/readouts")
|
||||||
|
self.assertEqual(readouts.status_code, 200)
|
||||||
|
self.assertEqual(readouts.get_json()[0]["value"], 20.5)
|
||||||
|
|
||||||
|
hourly = self.client.get(
|
||||||
|
f"/api/sensors/{sensor_id}/readouts/hourly?type=temperature&range=all"
|
||||||
|
)
|
||||||
|
self.assertEqual(hourly.status_code, 200)
|
||||||
|
self.assertEqual(len(hourly.get_json()), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class MainAppTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.tmpdir = tempfile.TemporaryDirectory()
|
||||||
|
cls._env = _save_env(ENV_KEYS)
|
||||||
|
base = Path(cls.tmpdir.name)
|
||||||
|
os.environ["SENSORS_DATABASE_URL"] = f"sqlite:///{base / 'sensors.db'}"
|
||||||
|
os.environ["USERS_DATABASE_URL"] = f"sqlite:///{base / 'main_app.db'}"
|
||||||
|
os.environ["SECRET_KEY"] = "test-secret"
|
||||||
|
|
||||||
|
from models import db
|
||||||
|
from sensors_app import create_app as create_sensors_app
|
||||||
|
from user_models import User, user_db
|
||||||
|
|
||||||
|
cls.sensors_app = create_sensors_app()
|
||||||
|
with cls.sensors_app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
|
||||||
|
cls.server = make_server("127.0.0.1", 0, cls.sensors_app)
|
||||||
|
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||||
|
cls.thread.start()
|
||||||
|
os.environ["SENSORS_API_URL"] = f"http://127.0.0.1:{cls.server.server_port}"
|
||||||
|
|
||||||
|
from app import create_app as create_main_app
|
||||||
|
|
||||||
|
cls.main_app = create_main_app()
|
||||||
|
with cls.main_app.app_context():
|
||||||
|
user_db.create_all()
|
||||||
|
user = User(username="tester")
|
||||||
|
user.set_password("secret")
|
||||||
|
user_db.session.add(user)
|
||||||
|
user_db.session.commit()
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.client = self.main_app.test_client()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
from models import db
|
||||||
|
from user_models import user_db
|
||||||
|
|
||||||
|
cls.server.shutdown()
|
||||||
|
cls.thread.join()
|
||||||
|
with cls.sensors_app.app_context():
|
||||||
|
db.session.remove()
|
||||||
|
db.drop_all()
|
||||||
|
with cls.main_app.app_context():
|
||||||
|
user_db.session.remove()
|
||||||
|
user_db.drop_all()
|
||||||
|
cls.tmpdir.cleanup()
|
||||||
|
_restore_env(cls._env)
|
||||||
|
|
||||||
|
def login(self):
|
||||||
|
return self.client.post(
|
||||||
|
"/login", data={"username": "tester", "password": "secret"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_sensor_and_readout(self, name):
|
||||||
|
created = self.client.post("/api/sensors", json={"name": name})
|
||||||
|
self.assertEqual(created.status_code, 201)
|
||||||
|
sensor_id = created.get_json()["id"]
|
||||||
|
|
||||||
|
readout = self.client.post(
|
||||||
|
f"/api/sensors/{sensor_id}/readouts",
|
||||||
|
json={"type": "temperature", "value": 21.5},
|
||||||
|
)
|
||||||
|
self.assertEqual(readout.status_code, 201)
|
||||||
|
return sensor_id
|
||||||
|
|
||||||
|
def test_views_and_login_are_owned_by_main_app(self):
|
||||||
|
response = self.client.get("/sensors")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/login", response.headers["Location"])
|
||||||
|
|
||||||
|
self.assertEqual(self.client.get("/login").status_code, 200)
|
||||||
|
self.assertEqual(self.login().status_code, 302)
|
||||||
|
self.assertEqual(self.client.get("/home").status_code, 200)
|
||||||
|
self.assertEqual(self.client.get("/sensors").status_code, 200)
|
||||||
|
self.assertEqual(self.client.get("/notes").status_code, 200)
|
||||||
|
self.assertEqual(self.client.get("/notifications").status_code, 200)
|
||||||
|
self.assertEqual(self.client.get("/api/docs").status_code, 200)
|
||||||
|
|
||||||
|
def test_public_device_api_is_proxied_to_sensors(self):
|
||||||
|
sensor_id = self.create_sensor_and_readout("proxy-test")
|
||||||
|
|
||||||
|
listed = self.client.get("/api/sensors").get_json()
|
||||||
|
self.assertIn({"id": sensor_id, "name": "proxy-test"}, listed)
|
||||||
|
|
||||||
|
def test_dashboard_reads_require_login_and_are_proxied(self):
|
||||||
|
sensor_id = self.create_sensor_and_readout("dashboard-test")
|
||||||
|
|
||||||
|
self.assertEqual(self.client.get(f"/api/sensors/{sensor_id}/types").status_code, 401)
|
||||||
|
|
||||||
|
self.login()
|
||||||
|
types = self.client.get(f"/api/sensors/{sensor_id}/types")
|
||||||
|
self.assertEqual(types.status_code, 200)
|
||||||
|
self.assertEqual(types.get_json(), ["temperature"])
|
||||||
|
|
||||||
|
readouts = self.client.get(f"/api/sensors/{sensor_id}/readouts?type=temperature")
|
||||||
|
self.assertEqual(readouts.status_code, 200)
|
||||||
|
self.assertEqual(readouts.get_json()[0]["value"], 21.5)
|
||||||
|
|
||||||
|
hourly = self.client.get(
|
||||||
|
f"/api/sensors/{sensor_id}/readouts/hourly?type=temperature&range=all"
|
||||||
|
)
|
||||||
|
self.assertEqual(hourly.status_code, 200)
|
||||||
|
self.assertEqual(len(hourly.get_json()), 1)
|
||||||
|
|
||||||
|
def test_clipboard_requires_login(self):
|
||||||
|
response = self.client.get("/clipboard")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/login", response.headers["Location"])
|
||||||
|
|
||||||
|
def test_clipboard_page_shows_upload_form(self):
|
||||||
|
self.login()
|
||||||
|
response = self.client.get("/clipboard")
|
||||||
|
html = response.get_data(as_text=True)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn('action="/clipboard/upload"', html)
|
||||||
|
self.assertIn('name="file"', html)
|
||||||
|
|
||||||
|
def test_clipboard_upload_download_delete_flow(self):
|
||||||
|
import io
|
||||||
|
from sqlalchemy import func
|
||||||
|
from user_models import ClipboardFile, User, user_db
|
||||||
|
|
||||||
|
self.login()
|
||||||
|
upload = self.client.post(
|
||||||
|
"/clipboard/upload",
|
||||||
|
data={
|
||||||
|
"file": (io.BytesIO(b"clipboard payload"), "hello.txt", "text/plain")
|
||||||
|
},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
self.assertEqual(upload.status_code, 302)
|
||||||
|
|
||||||
|
with self.main_app.app_context():
|
||||||
|
user = User.query.filter_by(username="tester").one()
|
||||||
|
stored = (
|
||||||
|
ClipboardFile.query.filter_by(user_id=user.id)
|
||||||
|
.order_by(ClipboardFile.id.desc())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(stored)
|
||||||
|
file_id = stored.id
|
||||||
|
original_name = stored.original_name
|
||||||
|
size = stored.size
|
||||||
|
total = (
|
||||||
|
user_db.session.query(func.coalesce(func.sum(ClipboardFile.size), 0))
|
||||||
|
.filter(ClipboardFile.user_id == user.id)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(original_name, "hello.txt")
|
||||||
|
self.assertEqual(size, len(b"clipboard payload"))
|
||||||
|
self.assertEqual(total, len(b"clipboard payload"))
|
||||||
|
|
||||||
|
download = self.client.get(f"/clipboard/{file_id}/download")
|
||||||
|
self.assertEqual(download.status_code, 200)
|
||||||
|
self.assertEqual(download.data, b"clipboard payload")
|
||||||
|
|
||||||
|
delete = self.client.post(f"/clipboard/{file_id}/delete")
|
||||||
|
self.assertEqual(delete.status_code, 302)
|
||||||
|
|
||||||
|
with self.main_app.app_context():
|
||||||
|
user_db.session.expire_all()
|
||||||
|
self.assertEqual(ClipboardFile.query.filter_by(id=file_id).count(), 0)
|
||||||
|
|
||||||
|
def test_notifications_can_be_deleted(self):
|
||||||
|
from user_models import Notification, User, user_db
|
||||||
|
|
||||||
|
self.login()
|
||||||
|
with self.main_app.app_context():
|
||||||
|
user = User.query.filter_by(username="tester").one()
|
||||||
|
first = Notification(user_id=user.id, message="first notification")
|
||||||
|
second = Notification(user_id=user.id, message="second notification")
|
||||||
|
user_db.session.add_all([first, second])
|
||||||
|
user_db.session.commit()
|
||||||
|
first_id = first.id
|
||||||
|
second_id = second.id
|
||||||
|
|
||||||
|
delete = self.client.post(f"/notifications/{first_id}/delete")
|
||||||
|
self.assertEqual(delete.status_code, 302)
|
||||||
|
|
||||||
|
with self.main_app.app_context():
|
||||||
|
user_db.session.expire_all()
|
||||||
|
self.assertEqual(Notification.query.filter_by(id=first_id).count(), 0)
|
||||||
|
self.assertEqual(Notification.query.filter_by(id=second_id).count(), 1)
|
||||||
|
|
||||||
|
def test_calendar_requires_login(self):
|
||||||
|
response = self.client.get("/calendar")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/login", response.headers["Location"])
|
||||||
|
|
||||||
|
def test_calendar_month_and_week_views_render(self):
|
||||||
|
self.login()
|
||||||
|
month_response = self.client.get("/calendar?view=month")
|
||||||
|
self.assertEqual(month_response.status_code, 200)
|
||||||
|
self.assertIn("Calendar", month_response.get_data(as_text=True))
|
||||||
|
|
||||||
|
week_response = self.client.get("/calendar?view=week")
|
||||||
|
self.assertEqual(week_response.status_code, 200)
|
||||||
|
self.assertIn("Calendar", week_response.get_data(as_text=True))
|
||||||
|
|
||||||
|
def test_calendar_readouts_range_endpoint(self):
|
||||||
|
self.login()
|
||||||
|
sensor_id = self.create_sensor_and_readout("calendar-test")
|
||||||
|
|
||||||
|
response = self.client.get(
|
||||||
|
f"/api/sensors/{sensor_id}/readouts/range"
|
||||||
|
"?from=2000-01-01T00:00:00&to=2100-01-01T00:00:00"
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.get_json()
|
||||||
|
self.assertEqual(len(data), 1)
|
||||||
|
self.assertEqual(data[0]["sensor_id"], sensor_id)
|
||||||
|
|
||||||
|
def test_notes_require_login(self):
|
||||||
|
response = self.client.get("/notes")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/login", response.headers["Location"])
|
||||||
|
|
||||||
|
def test_notes_and_checklist_are_stored_in_main_db(self):
|
||||||
|
from user_models import Note, NoteItem, User, user_db
|
||||||
|
|
||||||
|
self.login()
|
||||||
|
created = self.client.post(
|
||||||
|
"/notes", data={"title": "Test title", "content": "My short note"}
|
||||||
|
)
|
||||||
|
self.assertEqual(created.status_code, 302)
|
||||||
|
|
||||||
|
with self.main_app.app_context():
|
||||||
|
user = User.query.filter_by(username="tester").one()
|
||||||
|
note = Note.query.filter_by(user_id=user.id).one()
|
||||||
|
note_id = note.id
|
||||||
|
self.assertEqual(note.title, "Test title")
|
||||||
|
self.assertEqual(note.content, "My short note")
|
||||||
|
|
||||||
|
tables = set(inspect(user_db.engine).get_table_names())
|
||||||
|
self.assertIn("note", tables)
|
||||||
|
self.assertIn("note_item", tables)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(
|
||||||
|
f"/notes/{note_id}/items", data={"text": "first item"}
|
||||||
|
).status_code,
|
||||||
|
302,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(
|
||||||
|
f"/notes/{note_id}/items", data={"text": "second item"}
|
||||||
|
).status_code,
|
||||||
|
302,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.main_app.app_context():
|
||||||
|
first, _second = (
|
||||||
|
NoteItem.query.filter_by(note_id=note_id).order_by(NoteItem.id).all()
|
||||||
|
)
|
||||||
|
first_id = first.id
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(
|
||||||
|
f"/notes/{note_id}/items/{first_id}",
|
||||||
|
data={"text": "renamed first item"},
|
||||||
|
).status_code,
|
||||||
|
302,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.post(
|
||||||
|
f"/notes/{note_id}/items/{first_id}/toggle",
|
||||||
|
data={"is_done": "1"},
|
||||||
|
).status_code,
|
||||||
|
302,
|
||||||
|
)
|
||||||
|
|
||||||
|
page = self.client.get("/notes")
|
||||||
|
html = page.get_data(as_text=True)
|
||||||
|
self.assertEqual(page.status_code, 200)
|
||||||
|
self.assertIn('class="done"', html)
|
||||||
|
self.assertIn('value="Test title"', html)
|
||||||
|
self.assertIn('value="renamed first item"', html)
|
||||||
|
self.assertLess(html.index("second item"), html.index("renamed first item"))
|
||||||
|
|
||||||
|
with self.main_app.app_context():
|
||||||
|
user_db.session.expire_all()
|
||||||
|
items = (
|
||||||
|
NoteItem.query.filter_by(note_id=note_id).order_by(NoteItem.id).all()
|
||||||
|
)
|
||||||
|
self.assertTrue(items[0].is_done)
|
||||||
|
self.assertFalse(items[1].is_done)
|
||||||
|
self.assertEqual(
|
||||||
|
[item.text for item in items[0].note.ordered_items],
|
||||||
|
["second item", "renamed first item"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_notifications_badge_and_mark_read(self):
|
||||||
|
from user_models import Notification, User, user_db
|
||||||
|
|
||||||
|
self.login()
|
||||||
|
with self.main_app.app_context():
|
||||||
|
user = User.query.filter_by(username="tester").one()
|
||||||
|
other = User(username="other-user")
|
||||||
|
other.set_password("secret")
|
||||||
|
user_db.session.add(other)
|
||||||
|
user_db.session.flush()
|
||||||
|
|
||||||
|
first = Notification(user_id=user.id, message="first notification")
|
||||||
|
second = Notification(user_id=user.id, message="second notification")
|
||||||
|
foreign = Notification(user_id=other.id, message="other user notification")
|
||||||
|
user_db.session.add_all([first, second, foreign])
|
||||||
|
user_db.session.commit()
|
||||||
|
first_id = first.id
|
||||||
|
foreign_id = foreign.id
|
||||||
|
|
||||||
|
home = self.client.get("/home").get_data(as_text=True)
|
||||||
|
self.assertIn('nav-badge">2</span>', home)
|
||||||
|
|
||||||
|
page = self.client.get("/notifications")
|
||||||
|
html = page.get_data(as_text=True)
|
||||||
|
self.assertEqual(page.status_code, 200)
|
||||||
|
self.assertIn("first notification", html)
|
||||||
|
self.assertIn("second notification", html)
|
||||||
|
self.assertNotIn("other user notification", html)
|
||||||
|
self.assertEqual(html.count('class="notification-item new"'), 2)
|
||||||
|
|
||||||
|
read = self.client.get(f"/notifications/{first_id}/read")
|
||||||
|
self.assertEqual(read.status_code, 302)
|
||||||
|
|
||||||
|
with self.main_app.app_context():
|
||||||
|
user_db.session.expire_all()
|
||||||
|
notification = user_db.session.get(Notification, first_id)
|
||||||
|
self.assertTrue(notification.is_read)
|
||||||
|
self.assertIsNotNone(notification.read_at)
|
||||||
|
|
||||||
|
page = self.client.get("/notifications")
|
||||||
|
html = page.get_data(as_text=True)
|
||||||
|
self.assertIn("1 new notification.", html)
|
||||||
|
self.assertIn('nav-badge">1</span>', html)
|
||||||
|
self.assertEqual(html.count('class="notification-item read"'), 1)
|
||||||
|
self.assertEqual(html.count('class="notification-item new"'), 1)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.client.get(f"/notifications/{foreign_id}/read").status_code, 404
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_navigation_links_are_present_on_all_pages(self):
|
||||||
|
self.login()
|
||||||
|
links = (
|
||||||
|
'href="/home"',
|
||||||
|
'href="/sensors"',
|
||||||
|
'href="/notes"',
|
||||||
|
'href="/clipboard"',
|
||||||
|
'href="/notifications"',
|
||||||
|
'href="/api/docs"',
|
||||||
|
'href="/logout"',
|
||||||
|
)
|
||||||
|
for path in ("/home", "/sensors", "/notes", "/clipboard", "/notifications", "/api/docs"):
|
||||||
|
html = self.client.get(path).get_data(as_text=True)
|
||||||
|
for link in links:
|
||||||
|
self.assertIn(link, html, f"{link} missing on {path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask_login import UserMixin
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
user_db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserMixin, user_db.Model):
|
||||||
|
__tablename__ = "user"
|
||||||
|
|
||||||
|
id = user_db.Column(user_db.Integer, primary_key=True)
|
||||||
|
username = user_db.Column(user_db.String(80), nullable=False, unique=True)
|
||||||
|
password_hash = user_db.Column(user_db.String(255), nullable=False)
|
||||||
|
is_admin = user_db.Column(user_db.Boolean, nullable=False, default=False)
|
||||||
|
created_at = user_db.Column(
|
||||||
|
user_db.DateTime, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_password(self, password: str) -> None:
|
||||||
|
self.password_hash = generate_password_hash(password)
|
||||||
|
|
||||||
|
def check_password(self, password: str) -> bool:
|
||||||
|
return check_password_hash(self.password_hash, password)
|
||||||
|
|
||||||
|
|
||||||
|
class Note(user_db.Model):
|
||||||
|
__tablename__ = "note"
|
||||||
|
|
||||||
|
id = user_db.Column(user_db.Integer, primary_key=True)
|
||||||
|
user_id = user_db.Column(
|
||||||
|
user_db.Integer, user_db.ForeignKey("user.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
title = user_db.Column(
|
||||||
|
user_db.String(200), nullable=False, server_default="Untitled"
|
||||||
|
)
|
||||||
|
content = user_db.Column(user_db.Text, nullable=False)
|
||||||
|
created_at = user_db.Column(
|
||||||
|
user_db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
updated_at = user_db.Column(
|
||||||
|
user_db.DateTime,
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(timezone.utc),
|
||||||
|
onupdate=lambda: datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
items = relationship(
|
||||||
|
"NoteItem",
|
||||||
|
back_populates="note",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ordered_items(self):
|
||||||
|
"""Unchecked items first, checked items last; stable within each group."""
|
||||||
|
return sorted(self.items, key=lambda item: (item.is_done, item.id or 0))
|
||||||
|
|
||||||
|
def touch(self) -> None:
|
||||||
|
self.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class NoteItem(user_db.Model):
|
||||||
|
__tablename__ = "note_item"
|
||||||
|
|
||||||
|
id = user_db.Column(user_db.Integer, primary_key=True)
|
||||||
|
note_id = user_db.Column(
|
||||||
|
user_db.Integer,
|
||||||
|
user_db.ForeignKey("note.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
text = user_db.Column(user_db.String(255), nullable=False)
|
||||||
|
is_done = user_db.Column(user_db.Boolean, nullable=False, default=False)
|
||||||
|
created_at = user_db.Column(
|
||||||
|
user_db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
|
||||||
|
note = relationship("Note", back_populates="items")
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(user_db.Model):
|
||||||
|
__tablename__ = "notification"
|
||||||
|
|
||||||
|
id = user_db.Column(user_db.Integer, primary_key=True)
|
||||||
|
user_id = user_db.Column(
|
||||||
|
user_db.Integer, user_db.ForeignKey("user.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
message = user_db.Column(user_db.String(500), nullable=False)
|
||||||
|
is_read = user_db.Column(user_db.Boolean, nullable=False, default=False)
|
||||||
|
created_at = user_db.Column(
|
||||||
|
user_db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
read_at = user_db.Column(user_db.DateTime, nullable=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return "read" if self.is_read else "new"
|
||||||
|
|
||||||
|
def mark_read(self) -> None:
|
||||||
|
if not self.is_read:
|
||||||
|
self.is_read = True
|
||||||
|
self.read_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
class ClipboardFile(user_db.Model):
|
||||||
|
__tablename__ = "clipboard_file"
|
||||||
|
|
||||||
|
id = user_db.Column(user_db.Integer, primary_key=True)
|
||||||
|
user_id = user_db.Column(
|
||||||
|
user_db.Integer, user_db.ForeignKey("user.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
original_name = user_db.Column(user_db.String(255), nullable=False)
|
||||||
|
stored_name = user_db.Column(user_db.String(64), nullable=False, unique=True)
|
||||||
|
size = user_db.Column(user_db.BigInteger, nullable=False)
|
||||||
|
content_type = user_db.Column(user_db.String(255), nullable=True)
|
||||||
|
created_at = user_db.Column(
|
||||||
|
user_db.DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
Executable
+1
@@ -0,0 +1 @@
|
|||||||
|
ssh -R 8001:localhost:8000 vps
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Yggdrasil Sensors App
|
||||||
|
After=network.target yggdrasil.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=mateusz
|
||||||
|
Group=mateusz
|
||||||
|
WorkingDirectory=/home/mateusz/prog/yggdrasil/server
|
||||||
|
# The sensors API is open to local users and listens on localhost only.
|
||||||
|
# Data lives in sensors.db; users live in the main app's main_app.db.
|
||||||
|
#Environment=SENSORS_DB_PATH=/home/mateusz/prog/yggdrasil/server/sensors.db
|
||||||
|
#Environment=HOST=0.0.0.0
|
||||||
|
ExecStart=/home/mateusz/prog/yggdrasil/server/run-prod.sh sensors 8001
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Yggdrasil Sensor API
|
Description=Yggdrasil Main App
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=mateusz
|
User=mateusz
|
||||||
Group=mateusz
|
Group=mateusz
|
||||||
WorkingDirectory=/home/mateusz/prog/yggdrasil/server
|
WorkingDirectory=/home/mateusz/prog/yggdrasil/server
|
||||||
ExecStart=/home/mateusz/prog/yggdrasil/server/.venv/bin/gunicorn -c gunicorn.conf.py "app:create_app()"
|
# Override when the sensors API is not on localhost:8001:
|
||||||
|
#Environment=SENSORS_API_URL=http://localhost:8001
|
||||||
|
#Environment=USERS_DB_PATH=/home/mateusz/prog/yggdrasil/server/main_app.db
|
||||||
|
ExecStart=/home/mateusz/prog/yggdrasil/server/run-prod.sh main 8000
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
|
|||||||
Submodule thermo_sensor/common_esp32 updated: f5e5ca63bd...30c2c8e196
@@ -79,10 +79,50 @@ dependencies:
|
|||||||
source:
|
source:
|
||||||
type: idf
|
type: idf
|
||||||
version: 6.1.0
|
version: 6.1.0
|
||||||
|
lvgl/lvgl:
|
||||||
|
component_hash: 7d82410747bbfb319531449749e940f440f8414f041f9eaba13f0d1dd7f58cdd
|
||||||
|
dependencies:
|
||||||
|
- name: espressif/freetype
|
||||||
|
registry_url: https://components.espressif.com
|
||||||
|
require: private
|
||||||
|
rules:
|
||||||
|
- if: idf_version >=5.5 && $CONFIG{LV_USE_FREETYPE} == True
|
||||||
|
version: ^2.14.3
|
||||||
|
- name: espressif/libjpeg-turbo
|
||||||
|
registry_url: https://components.espressif.com
|
||||||
|
require: private
|
||||||
|
rules:
|
||||||
|
- if: idf_version >=5.5 && $CONFIG{LV_USE_LIBJPEG_TURBO} == True
|
||||||
|
version: ^3.1.1
|
||||||
|
- name: espressif/libpng
|
||||||
|
registry_url: https://components.espressif.com
|
||||||
|
require: private
|
||||||
|
rules:
|
||||||
|
- if: idf_version >=5.5 && $CONFIG{LV_USE_LIBPNG} == True
|
||||||
|
version: ^1.6.58
|
||||||
|
- name: espressif/lz4
|
||||||
|
registry_url: https://components.espressif.com
|
||||||
|
require: private
|
||||||
|
rules:
|
||||||
|
- if: idf_version >=5.5 && $CONFIG{LV_USE_LZ4} == True && $CONFIG{LV_USE_LZ4_INTERNAL} == False
|
||||||
|
version: ^1.10.0
|
||||||
|
source:
|
||||||
|
registry_url: https://components.espressif.com/
|
||||||
|
type: service
|
||||||
|
version: 9.6.0~1
|
||||||
|
tny-robotics/sh1106-esp-idf:
|
||||||
|
component_hash: 036f5aad73224c563c3edb5db6f9833f3eb47c41b3d479740edddf49488ced69
|
||||||
|
dependencies: []
|
||||||
|
source:
|
||||||
|
registry_url: https://components.espressif.com/
|
||||||
|
type: service
|
||||||
|
version: 1.0.2
|
||||||
direct_dependencies:
|
direct_dependencies:
|
||||||
- esp-idf-lib/bmp280
|
- esp-idf-lib/bmp280
|
||||||
- espressif/cjson
|
- espressif/cjson
|
||||||
- idf
|
- idf
|
||||||
manifest_hash: 2b2fa2812480ea444900bab732b40d55618df67e034421f1a347f9de48f70eeb
|
- lvgl/lvgl
|
||||||
|
- tny-robotics/sh1106-esp-idf
|
||||||
|
manifest_hash: 35a13c57b5d80651f5e8a8efa2264804bbeec721529fc3fac1741cec178851f2
|
||||||
target: esp32
|
target: esp32
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
idf_component_register(SRCS "thermo_sensor.c" "ygg_http_routes.c" "ygg_bmp280.c" "ygg_http_req.c" "ygg_state.c" "../common_esp32/ygg_wifi.c" "../common_esp32/ygg_http_server.c"
|
idf_component_register(SRCS "thermo_sensor.c" "ygg_http_routes.c" "ygg_bmp280.c" "ygg_http_req.c" "ygg_state.c" "ygg_sh1106.c" "ygg_lock.c" "../common_esp32/ygg_wifi.c" "../common_esp32/ygg_http_server.c"
|
||||||
INCLUDE_DIRS "." "../common_esp32")
|
INCLUDE_DIRS "." "../common_esp32")
|
||||||
|
|||||||
@@ -16,3 +16,5 @@ dependencies:
|
|||||||
# public: true
|
# public: true
|
||||||
esp-idf-lib/bmp280: '*'
|
esp-idf-lib/bmp280: '*'
|
||||||
espressif/cjson: '*'
|
espressif/cjson: '*'
|
||||||
|
lvgl/lvgl: ^9
|
||||||
|
tny-robotics/sh1106-esp-idf: ^1.0.1
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
#include <stdio.h>
|
||||||
#include <freertos/FreeRTOS.h>
|
#include <freertos/FreeRTOS.h>
|
||||||
#include <freertos/task.h>
|
#include <freertos/task.h>
|
||||||
#include <esp_system.h>
|
#include <esp_system.h>
|
||||||
|
#include <esp_timer.h>
|
||||||
|
#include <esp_log.h>
|
||||||
|
|
||||||
#include "nvs_flash.h"
|
#include "nvs_flash.h"
|
||||||
#include "esp_http_server.h"
|
#include "esp_http_server.h"
|
||||||
|
#include "i2cdev.h"
|
||||||
|
|
||||||
#include "ygg_http_server.h"
|
#include "ygg_http_server.h"
|
||||||
#include "ygg_http_routes.h"
|
#include "ygg_http_routes.h"
|
||||||
@@ -12,20 +16,55 @@
|
|||||||
#include "ygg_bmp280.h"
|
#include "ygg_bmp280.h"
|
||||||
#include "ygg_http_req.h"
|
#include "ygg_http_req.h"
|
||||||
#include "ygg_state.h"
|
#include "ygg_state.h"
|
||||||
|
#include "ygg_sh1106.h"
|
||||||
|
#include "ygg_lock.h"
|
||||||
|
#include "lvgl.h"
|
||||||
|
|
||||||
|
|
||||||
static const char *TAG = "thermo_sensor";
|
static const char *TAG = "thermo_sensor";
|
||||||
|
static lv_display_t *sh_display = NULL;
|
||||||
|
static lv_obj_t *label = NULL;
|
||||||
bmp280_t dev_bmp_280;
|
bmp280_t dev_bmp_280;
|
||||||
|
|
||||||
void main_loop(void *pvParameters) {
|
void lvgl_task(void *pvParameters) {
|
||||||
|
while (1) {
|
||||||
|
if (ygg_lvgl_lock_take(pdMS_TO_TICKS(100))) {
|
||||||
|
lv_timer_handler();
|
||||||
|
ygg_lvgl_lock_give();
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void sensor_task(void *pvParameters) {
|
||||||
// Wait 1s, to avoid corrupted first sensor readout
|
// Wait 1s, to avoid corrupted first sensor readout
|
||||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||||
while (1) {
|
while (1) {
|
||||||
ygg_bmp280_read_sensor(&dev_bmp_280);
|
esp_err_t err = ygg_bmp280_read_sensor(&dev_bmp_280);
|
||||||
ygg_send_sensor_readouts(BASE_URL,
|
// Najpierw OLED, potem (blokujący) HTTP — żeby ekran
|
||||||
SENSOR_NAME,
|
// odświeżył się nawet gdy serwer nie odpowiada.
|
||||||
CURR_READOUT);
|
// ygg_bmp280_read_sensor() aktualizuje CURR_READOUT TYLKO przy
|
||||||
vTaskDelay(pdMS_TO_TICKS(10000));
|
// sukcesie, więc przy błędzie pokazujemy ostatnie dobre + ERR
|
||||||
|
// i NIE wysyłamy śmieci do bazy.
|
||||||
|
char buf[256];
|
||||||
|
if (err == ESP_OK) {
|
||||||
|
snprintf(buf, sizeof(buf), "Temp: %.1f C\nHumid: %.1f %%\nPress: %.1f hPa",
|
||||||
|
CURR_READOUT.temperature,
|
||||||
|
CURR_READOUT.humidity,
|
||||||
|
CURR_READOUT.pressure / 100.);
|
||||||
|
ygg_set_display_label(sh_display, label, buf);
|
||||||
|
ygg_send_sensor_readouts(BASE_URL,
|
||||||
|
SENSOR_NAME,
|
||||||
|
CURR_READOUT);
|
||||||
|
} else {
|
||||||
|
snprintf(buf, sizeof(buf), "SENSOR ERR\nTemp: %.1f C\nHumid: %.1f %%\nPress: %.1f hPa",
|
||||||
|
CURR_READOUT.temperature,
|
||||||
|
CURR_READOUT.humidity,
|
||||||
|
CURR_READOUT.pressure / 100.);
|
||||||
|
ygg_set_display_label(sh_display, label, buf);
|
||||||
|
ESP_LOGW(TAG, "skipping HTTP POST, keeping last good readout");
|
||||||
|
}
|
||||||
|
vTaskDelay(pdMS_TO_TICKS(60 * 1000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +95,21 @@ void app_main()
|
|||||||
httpd_register_uri_handler(server, &ygg_press_uri);
|
httpd_register_uri_handler(server, &ygg_press_uri);
|
||||||
httpd_register_uri_handler(server, &ygg_hum_uri);
|
httpd_register_uri_handler(server, &ygg_hum_uri);
|
||||||
|
|
||||||
|
// Jedna centralna inicjalizacja: mutexy + magistrala I2C.
|
||||||
|
// Wcześniej i2cdev_init() był wołany w obu ygg_*_init() + bus był
|
||||||
|
// tworzony przez dummy-device OLED, co mieszało.
|
||||||
|
ygg_lock_init();
|
||||||
ESP_ERROR_CHECK(i2cdev_init());
|
ESP_ERROR_CHECK(i2cdev_init());
|
||||||
|
|
||||||
|
sh_display = ygg_sh1106_init();
|
||||||
|
|
||||||
|
lv_obj_t *scr = lv_display_get_screen_active(sh_display);
|
||||||
|
label = lv_label_create(scr);
|
||||||
|
ygg_set_display_label(sh_display, label, "PLEASE WAIT...");
|
||||||
|
|
||||||
ygg_bmp280_init(&dev_bmp_280);
|
ygg_bmp280_init(&dev_bmp_280);
|
||||||
xTaskCreatePinnedToCore(main_loop, "bmp280_test", configMINIMAL_STACK_SIZE * 8, NULL, 5, NULL, APP_CPU_NUM);
|
|
||||||
|
xTaskCreatePinnedToCore(lvgl_task, "lvgl", 4096, NULL, 4, NULL, APP_CPU_NUM);
|
||||||
|
xTaskCreatePinnedToCore(sensor_task, "bmp280_test", 8192, NULL, 5, NULL, APP_CPU_NUM);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,52 @@
|
|||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <bmp280.h>
|
#include <bmp280.h>
|
||||||
|
#include <esp_log.h>
|
||||||
#include "ygg_bmp280.h"
|
#include "ygg_bmp280.h"
|
||||||
#include "ygg_state.h"
|
#include "ygg_state.h"
|
||||||
|
#include "ygg_lock.h"
|
||||||
|
|
||||||
|
static const char *TAG = "ygg_bmp280";
|
||||||
|
|
||||||
void ygg_bmp280_init(bmp280_t *dev) {
|
void ygg_bmp280_init(bmp280_t *dev) {
|
||||||
|
// UWAGA: i2cdev_init() + ygg_lock_init() robi app_main() raz przed
|
||||||
|
// wszystkimi initami. Tutaj już nie tworzymy magistrali.
|
||||||
bmp280_params_t params;
|
bmp280_params_t params;
|
||||||
bmp280_init_default_params(¶ms);
|
bmp280_init_default_params(¶ms);
|
||||||
memset(dev, 0, sizeof(bmp280_t));
|
memset(dev, 0, sizeof(bmp280_t));
|
||||||
|
|
||||||
ESP_ERROR_CHECK(bmp280_init_desc(dev, BMP280_I2C_ADDRESS_0, 0, I2C_MASTER_SDA, I2C_MASTER_SCL));
|
ESP_ERROR_CHECK(bmp280_init_desc(dev, BMP280_I2C_ADDRESS_0, 0, I2C_MASTER_SDA, I2C_MASTER_SCL));
|
||||||
|
// bmp280_init_desc() ustawia 1MHz - za szybko na pająka + dzieloną
|
||||||
|
// magistralę z OLED. Zjeżdżamy do 100kHz przed pierwszym użyciem
|
||||||
|
// (device jest dopinane do busa leniwie przy pierwszej transakcji).
|
||||||
|
dev->i2c_dev.cfg.master.clk_speed = YGG_I2C_FREQ_HZ;
|
||||||
ESP_ERROR_CHECK(bmp280_init(dev, ¶ms));
|
ESP_ERROR_CHECK(bmp280_init(dev, ¶ms));
|
||||||
|
|
||||||
bool bme280p = dev->id == BME280_CHIP_ID;
|
bool bme280p = dev->id == BME280_CHIP_ID;
|
||||||
printf("BMP280: found %s\n", bme280p ? "BME280" : "BMP280");
|
printf("BMP280: found %s\n", bme280p ? "BME280" : "BMP280");
|
||||||
}
|
}
|
||||||
|
|
||||||
void ygg_bmp280_read_sensor(bmp280_t *dev) {
|
esp_err_t ygg_bmp280_read_sensor(bmp280_t *dev) {
|
||||||
float pressure, temperature, humidity;
|
float pressure = 0, temperature = 0, humidity = 0;
|
||||||
bool bme280p = dev->id == BME280_CHIP_ID;
|
bool bme280p = dev->id == BME280_CHIP_ID;
|
||||||
|
|
||||||
if (bmp280_read_float(dev, &temperature, &pressure, &humidity) != ESP_OK)
|
// Cały odczyt pod wspólnym mutexem I2C - OLED (esp_lcd) używa
|
||||||
printf("Temperature/pressure reading failed\n");
|
// tego samego busa bez wiedzy o mutexach i2cdev.
|
||||||
|
// Bez tego flush LVGL potrafi wejść w środek odczytu -> timeout.
|
||||||
|
if (!ygg_i2c_lock_take(pdMS_TO_TICKS(1000))) {
|
||||||
|
ESP_LOGW(TAG, "I2C busy, skipping readout");
|
||||||
|
return ESP_ERR_TIMEOUT;
|
||||||
|
}
|
||||||
|
esp_err_t err = bmp280_read_float(dev, &temperature, &pressure, &humidity);
|
||||||
|
ygg_i2c_lock_give();
|
||||||
|
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "Temperature/pressure reading failed: %s - keeping last good readout",
|
||||||
|
esp_err_to_name(err));
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktualizuj globalny stan TYLKO przy sukcesie.
|
||||||
CURR_READOUT.temperature = temperature;
|
CURR_READOUT.temperature = temperature;
|
||||||
CURR_READOUT.pressure = pressure;
|
CURR_READOUT.pressure = pressure;
|
||||||
CURR_READOUT.humidity = humidity;
|
CURR_READOUT.humidity = humidity;
|
||||||
@@ -31,4 +55,5 @@ void ygg_bmp280_read_sensor(bmp280_t *dev) {
|
|||||||
printf(", Humidity: %.2f\n", humidity);
|
printf(", Humidity: %.2f\n", humidity);
|
||||||
else
|
else
|
||||||
printf("\n");
|
printf("\n");
|
||||||
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
#define YGG_BMP280_H
|
#define YGG_BMP280_H
|
||||||
|
|
||||||
#include <bmp280.h>
|
#include <bmp280.h>
|
||||||
|
#include <esp_err.h>
|
||||||
|
|
||||||
#define I2C_MASTER_SDA 19
|
#define I2C_MASTER_SDA 19
|
||||||
#define I2C_MASTER_SCL 23
|
#define I2C_MASTER_SCL 23
|
||||||
|
#define YGG_I2C_FREQ_HZ 100000
|
||||||
|
|
||||||
void ygg_bmp280_init(bmp280_t *dev);
|
void ygg_bmp280_init(bmp280_t *dev);
|
||||||
void ygg_bmp280_read_sensor(bmp280_t *dev);
|
esp_err_t ygg_bmp280_read_sensor(bmp280_t *dev);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ char* ygg_send_get(const char *url) {
|
|||||||
|
|
||||||
esp_http_client_config_t config = {
|
esp_http_client_config_t config = {
|
||||||
.url = url,
|
.url = url,
|
||||||
|
.timeout_ms = 3000,
|
||||||
};
|
};
|
||||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||||
esp_err_t err = esp_http_client_open(client, 0);
|
esp_err_t err = esp_http_client_open(client, 0);
|
||||||
@@ -61,6 +62,7 @@ char* ygg_send_post(const char *url, char *post_data) {
|
|||||||
|
|
||||||
esp_http_client_config_t config = {
|
esp_http_client_config_t config = {
|
||||||
.url = url,
|
.url = url,
|
||||||
|
.timeout_ms = 3000,
|
||||||
};
|
};
|
||||||
|
|
||||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#include "ygg_lock.h"
|
||||||
|
#include <freertos/semphr.h>
|
||||||
|
|
||||||
|
static SemaphoreHandle_t s_i2c_mutex = NULL;
|
||||||
|
static SemaphoreHandle_t s_lvgl_mutex = NULL;
|
||||||
|
|
||||||
|
void ygg_lock_init(void) {
|
||||||
|
if (s_i2c_mutex == NULL) {
|
||||||
|
s_i2c_mutex = xSemaphoreCreateMutex();
|
||||||
|
}
|
||||||
|
if (s_lvgl_mutex == NULL) {
|
||||||
|
s_lvgl_mutex = xSemaphoreCreateMutex();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ygg_i2c_lock_take(TickType_t timeout) {
|
||||||
|
if (s_i2c_mutex == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return xSemaphoreTake(s_i2c_mutex, timeout) == pdTRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ygg_i2c_lock_give(void) {
|
||||||
|
if (s_i2c_mutex != NULL) {
|
||||||
|
xSemaphoreGive(s_i2c_mutex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ygg_lvgl_lock_take(TickType_t timeout) {
|
||||||
|
if (s_lvgl_mutex == NULL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return xSemaphoreTake(s_lvgl_mutex, timeout) == pdTRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ygg_lvgl_lock_give(void) {
|
||||||
|
if (s_lvgl_mutex != NULL) {
|
||||||
|
xSemaphoreGive(s_lvgl_mutex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#ifndef YGG_LOCK_H
|
||||||
|
#define YGG_LOCK_H
|
||||||
|
|
||||||
|
#include <freertos/FreeRTOS.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
|
||||||
|
void ygg_lock_init(void);
|
||||||
|
|
||||||
|
bool ygg_i2c_lock_take(TickType_t timeout);
|
||||||
|
void ygg_i2c_lock_give(void);
|
||||||
|
|
||||||
|
bool ygg_lvgl_lock_take(TickType_t timeout);
|
||||||
|
void ygg_lvgl_lock_give(void);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
#include <string.h>
|
||||||
|
#include "ygg_sh1106.h"
|
||||||
|
#include "lvgl.h"
|
||||||
|
#include "i2cdev.h"
|
||||||
|
#include "esp_lcd_io_i2c.h"
|
||||||
|
#include "esp_lcd_panel_ops.h"
|
||||||
|
#include "esp_timer.h"
|
||||||
|
#include "esp_lcd_panel_sh1106.h"
|
||||||
|
#include "esp_log.h"
|
||||||
|
#include "ygg_bmp280.h"
|
||||||
|
#include "ygg_lock.h"
|
||||||
|
|
||||||
|
#define I2C_HOST I2C_NUM_0
|
||||||
|
#define EXAMPLE_I2C_MASTER_FREQ_HZ YGG_I2C_FREQ_HZ
|
||||||
|
#define EXAMPLE_LCD_V_RES 64
|
||||||
|
#define EXAMPLE_LCD_H_RES 128
|
||||||
|
#define EXAMPLE_LVGL_PALETTE_SIZE 8
|
||||||
|
#define SH1106_I2C_SDA_GPIO I2C_MASTER_SDA
|
||||||
|
#define SH1106_I2C_SCL_GPIO I2C_MASTER_SCL
|
||||||
|
|
||||||
|
static const char *TAG = "ygg_sh1106";
|
||||||
|
|
||||||
|
static uint8_t oled_buffer[EXAMPLE_LCD_H_RES * EXAMPLE_LCD_V_RES / 8];
|
||||||
|
|
||||||
|
static void example_lvgl_flush_cb(lv_display_t *disp, const lv_area_t *area, uint8_t *px_map)
|
||||||
|
{
|
||||||
|
esp_lcd_panel_handle_t panel_handle = lv_display_get_user_data(disp);
|
||||||
|
|
||||||
|
// This is necessary because LVGL reserves 2 x 4 bytes in the buffer, as these are assumed to be used as a palette. Skip the palette here
|
||||||
|
// More information about the monochrome, please refer to https://docs.lvgl.io/9.2/porting/display.html#monochrome-displays
|
||||||
|
px_map += EXAMPLE_LVGL_PALETTE_SIZE;
|
||||||
|
|
||||||
|
uint16_t hor_res = lv_display_get_physical_horizontal_resolution(disp);
|
||||||
|
int x1 = area->x1;
|
||||||
|
int x2 = area->x2;
|
||||||
|
int y1 = area->y1;
|
||||||
|
int y2 = area->y2;
|
||||||
|
|
||||||
|
for (int y = y1; y <= y2; y++) {
|
||||||
|
for (int x = x1; x <= x2; x++) {
|
||||||
|
/* The order of bits is MSB first
|
||||||
|
MSB LSB
|
||||||
|
bits 7 6 5 4 3 2 1 0
|
||||||
|
pixels 0 1 2 3 4 5 6 7
|
||||||
|
Left Right
|
||||||
|
*/
|
||||||
|
bool chroma_color = (px_map[(hor_res >> 3) * y + (x >> 3)] & 1 << (7 - x % 8));
|
||||||
|
|
||||||
|
/* Write to the buffer as required for the display.
|
||||||
|
* It writes only 1-bit for monochrome displays mapped vertically.*/
|
||||||
|
uint8_t *buf = oled_buffer + hor_res * (y >> 3) + (x);
|
||||||
|
if (chroma_color) {
|
||||||
|
(*buf) &= ~(1 << (y % 8));
|
||||||
|
} else {
|
||||||
|
(*buf) |= (1 << (y % 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// pass the draw buffer to the driver.
|
||||||
|
// Cały transfer pod wspólnym mutexem I2C - BMP280 (i2cdev) używa
|
||||||
|
// tego samego busa. Bez tego odczyt sensora potrafi wejść w środek
|
||||||
|
// rysowania -> I2C hardware timeout po obu stronach.
|
||||||
|
// UWAGA: nie bierzemy tu mutexa LVGL - flush jest wołany z lvgl_task
|
||||||
|
// już pod mutexem LVGL (z lv_timer_handler). Branie go tu = deadlock.
|
||||||
|
if (ygg_i2c_lock_take(pdMS_TO_TICKS(1000))) {
|
||||||
|
esp_err_t err = esp_lcd_panel_draw_bitmap(panel_handle, x1, y1, x2 + 1, y2 + 1, oled_buffer);
|
||||||
|
ygg_i2c_lock_give();
|
||||||
|
if (err != ESP_OK) {
|
||||||
|
ESP_LOGW(TAG, "draw_bitmap failed: %s", esp_err_to_name(err));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ESP_LOGW(TAG, "I2C busy, skipping flush");
|
||||||
|
}
|
||||||
|
lv_display_flush_ready(disp);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void lvgl_tick_cb(void *arg) {
|
||||||
|
lv_tick_inc(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ygg_init_tick_cb() {
|
||||||
|
const esp_timer_create_args_t lvgl_tick_args = {
|
||||||
|
.callback = lvgl_tick_cb,
|
||||||
|
.name = "lvgl_tick",
|
||||||
|
};
|
||||||
|
esp_timer_handle_t lvgl_tick_handle = NULL;
|
||||||
|
ESP_ERROR_CHECK(esp_timer_create(&lvgl_tick_args, &lvgl_tick_handle));
|
||||||
|
ESP_ERROR_CHECK(esp_timer_start_periodic(lvgl_tick_handle, 10 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
lv_display_t* ygg_sh1106_init() {
|
||||||
|
// UWAGA: i2cdev_init() + ygg_lock_init() robi app_main() raz przed
|
||||||
|
// wszystkimi initami. Tutaj już nie tworzymy magistrali.
|
||||||
|
|
||||||
|
static i2c_dev_t oled_i2c_dev;
|
||||||
|
memset(&oled_i2c_dev, 0, sizeof(oled_i2c_dev));
|
||||||
|
oled_i2c_dev.port = I2C_HOST; // numer portu I2C
|
||||||
|
oled_i2c_dev.addr = SH1106_I2C_ADDR; // adres OLED-a (0x3C)
|
||||||
|
oled_i2c_dev.cfg.sda_io_num = SH1106_I2C_SDA_GPIO; // SDA z "idf menuconfig"
|
||||||
|
oled_i2c_dev.cfg.scl_io_num = SH1106_I2C_SCL_GPIO; // SCL z "idf menuconfig"
|
||||||
|
oled_i2c_dev.cfg.master.clk_speed = EXAMPLE_I2C_MASTER_FREQ_HZ;
|
||||||
|
oled_i2c_dev.cfg.sda_pullup_en = true; // OLED zwykle nie ma pull-upów
|
||||||
|
oled_i2c_dev.cfg.scl_pullup_en = true;
|
||||||
|
|
||||||
|
// Rejestracja urządzenia + utworzenie mutexu.
|
||||||
|
// Przy pierwszym urządzeniu na danym porcie i2cdev tworzy magistralę
|
||||||
|
// (i2c_new_master_bus) z podanymi pinami SDA/SCL.
|
||||||
|
ESP_ERROR_CHECK(i2c_dev_create_mutex(&oled_i2c_dev));
|
||||||
|
|
||||||
|
// Opcjonalnie: sprawdź, czy OLED odpowiada na magistrali.
|
||||||
|
ESP_ERROR_CHECK(i2c_dev_check_present(&oled_i2c_dev));
|
||||||
|
|
||||||
|
// Pobierz współdzielony uchwyt magistrali zarządzanej przez i2cdev,
|
||||||
|
// żeby przekazać go do natywnego komponentu esp_lcd.
|
||||||
|
// Dzięki temu inne sensory na i2cdev i wyświetlacz na esp_lcd
|
||||||
|
// bezpiecznie dzielą tę samą magistralę I2C.
|
||||||
|
// UWAGA: uchwytem zarządza i2cdev — NIE wołaj na nim i2c_del_master_bus().
|
||||||
|
i2c_master_bus_handle_t i2c_bus_handle = NULL;
|
||||||
|
ESP_ERROR_CHECK(i2cdev_get_shared_handle(I2C_HOST, (void **)&i2c_bus_handle));
|
||||||
|
|
||||||
|
// Create the i2c io handle
|
||||||
|
esp_lcd_panel_io_handle_t io_handle = NULL;
|
||||||
|
esp_lcd_panel_io_i2c_config_t io_config = ESP_SH1106_DEFAULT_IO_CONFIG;
|
||||||
|
// Zejście z domyślnych 400kHz na 100kHz - wspólna magistrala z BMP280,
|
||||||
|
// pająk + słabe pull-upy. 400kHz/1MHz = timeouty.
|
||||||
|
io_config.scl_speed_hz = EXAMPLE_I2C_MASTER_FREQ_HZ;
|
||||||
|
ESP_ERROR_CHECK(esp_lcd_new_panel_io_i2c(i2c_bus_handle, &io_config, &io_handle));
|
||||||
|
|
||||||
|
|
||||||
|
/* SCREEN CONFIGURATION */
|
||||||
|
|
||||||
|
// sh1106 panel configuration (most of the values are not used, but must be set to avoid cpp warnings)
|
||||||
|
esp_lcd_panel_dev_config_t panel_config = {
|
||||||
|
.reset_gpio_num = -1, // sh1106 does not have a reset pin, so set to -1
|
||||||
|
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB, // not even used, but must be set to avoid cpp warnings
|
||||||
|
.data_endian = LCD_RGB_DATA_ENDIAN_LITTLE, // not even used, but must be set to avoid cpp warnings
|
||||||
|
.bits_per_pixel = SH1106_PIXELS_PER_BYTE / 8, // bpp = 1 (monochrome, that's important)
|
||||||
|
.flags = {
|
||||||
|
.reset_active_high = false, // not even used, but must be set to avoid cpp warnings
|
||||||
|
},
|
||||||
|
.vendor_config = NULL, // no need for custom vendor config, not implemented
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the panel handle from the sh1106 driver
|
||||||
|
esp_lcd_panel_handle_t panel_handle = NULL;
|
||||||
|
ESP_ERROR_CHECK(esp_lcd_new_panel_sh1106(io_handle, &panel_config, &panel_handle));
|
||||||
|
|
||||||
|
// Reset the screen (no reset pin, so it's a no-op here, optional)
|
||||||
|
ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
|
||||||
|
|
||||||
|
// Initialize the screen (this one isn't optional at all!)
|
||||||
|
ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));
|
||||||
|
|
||||||
|
// Turn on the screen (Easier to see something, right?)
|
||||||
|
ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true));
|
||||||
|
|
||||||
|
void *buf = NULL;
|
||||||
|
size_t draw_buffer_sz = EXAMPLE_LCD_H_RES * EXAMPLE_LCD_V_RES / 8 + EXAMPLE_LVGL_PALETTE_SIZE;
|
||||||
|
buf = heap_caps_calloc(1, draw_buffer_sz, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
|
||||||
|
|
||||||
|
lv_init();
|
||||||
|
lv_display_t *display = lv_display_create(EXAMPLE_LCD_H_RES, EXAMPLE_LCD_V_RES);
|
||||||
|
lv_display_set_user_data(display, panel_handle);
|
||||||
|
lv_display_set_color_format(display, LV_COLOR_FORMAT_I1);
|
||||||
|
lv_display_set_buffers(display, buf, NULL, draw_buffer_sz, LV_DISPLAY_RENDER_MODE_FULL);
|
||||||
|
lv_display_set_flush_cb(display, example_lvgl_flush_cb);
|
||||||
|
|
||||||
|
ygg_init_tick_cb();
|
||||||
|
|
||||||
|
return display;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ygg_set_display_label(lv_display_t *display, lv_obj_t *label, const char *str) {
|
||||||
|
// LVGL nie jest thread-safe. Ta funkcja jest wołana z sensor_task,
|
||||||
|
// a lv_timer_handler chodzi w lvgl_task - wszystko pod jednym mutexem.
|
||||||
|
if (!ygg_lvgl_lock_take(pdMS_TO_TICKS(500))) {
|
||||||
|
ESP_LOGW(TAG, "LVGL busy, skipping label update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lv_label_set_text(label, str);
|
||||||
|
/* Size of the screen (if you use rotation 90 or 270, please use lv_display_get_vertical_resolution) */
|
||||||
|
lv_obj_set_width(label, lv_display_get_horizontal_resolution(display));
|
||||||
|
lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
|
||||||
|
lv_obj_align(label, LV_ALIGN_TOP_MID, 0, 0);
|
||||||
|
ygg_lvgl_lock_give();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#ifndef YGG_SH1106
|
||||||
|
#define YGG_SH1106
|
||||||
|
|
||||||
|
#include "lvgl.h"
|
||||||
|
|
||||||
|
lv_display_t* ygg_sh1106_init();
|
||||||
|
void ygg_set_display_label(lv_display_t *display, lv_obj_t *label, const char *str);
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -9,6 +9,6 @@ typedef struct {
|
|||||||
|
|
||||||
extern ygg_sensor_readout CURR_READOUT;
|
extern ygg_sensor_readout CURR_READOUT;
|
||||||
static const char *SENSOR_NAME = "BMP_280";
|
static const char *SENSOR_NAME = "BMP_280";
|
||||||
static const char *BASE_URL = "http://192.168.0.236:5000";
|
static const char *BASE_URL = "http://192.168.0.236:8000";
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+979
-8
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user