Compare commits

17 Commits
Author SHA1 Message Date
mateusz df06a4129f auto save 2026-11-07 16:54:40 +01:00
mateusz 0a9da4f9c5 calendar 2026-11-03 08:44:11 +01:00
mateusz cbf5f20c75 clipboard 2026-11-01 20:47:27 +01:00
mateusz b77b348d54 notyfikacje 2026-11-01 20:27:10 +01:00
mateusz e55af30644 modify checkbox text in notes 2026-11-01 20:04:33 +01:00
mateusz 7675acd81e notatki 2026-11-01 17:17:58 +01:00
mateusz dc9d5a750e readme update 2026-11-01 16:58:32 +01:00
mateusz 85179c8a80 sensors is separate API with td 2026-11-01 12:07:04 +01:00
mateusz b7048b401d subrepo update 2026-10-23 11:38:37 +02:00
mateusz 6a782c1e9b retro css 2026-10-23 11:37:55 +02:00
mateusz 02f3df8c41 i2c fixes + server improvements 2026-10-05 19:28:37 +02:00
mateusz 7094d05030 css 2026-10-03 20:24:37 +02:00
mateusz d603d8d574 system użytkowników 2026-10-03 19:31:38 +02:00
mateusz b09dde607a refactor 2026-09-30 15:46:21 +02:00
mateusz f953deaec3 humidity and pressure 2026-09-25 16:46:30 +02:00
mateusz b607bd8d41 oled shows temp correctly 2026-09-25 16:33:06 +02:00
mateusz 73f4e0d1c6 oled v1 2026-09-25 16:21:37 +02:00
53 changed files with 10060 additions and 261 deletions
+2
View File
@@ -6,3 +6,5 @@ managed_components
.cache
.vscode
.clangd
.secret_key
server/clipboard/
+461 -53
View File
@@ -1,13 +1,27 @@
# 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
- **Python 3.13+**
- **Flask 3** — web framework
- **SQLAlchemy** (via Flask-SQLAlchemy) — ORM
- **SQLite** — database
- **SQLite** — databases
- **Alembic** — database migrations
- **Chart.js** — frontend charting library
@@ -15,18 +29,36 @@ A Flask web application for managing and visualizing sensor data. It provides a
```
server/
├── app.py # Flask application factory and API routes
├── models.py # SQLAlchemy models (Sensor, SensorReadout)
├── seed.py # Database seeder with sample data
├── requirements.txt # Python dependencies
├── data.db # SQLite database (auto-created)
├── alembic.ini # Alembic configuration
├── alembic/ # Migration scripts
│ ├── env.py
│ └── versions/
├── app.py # Main app: views, notes + API proxy
├── sensors_app.py # Sensors app: API + sensors database
├── sensors_client.py # HTTP client used by the main app
├── auth.py # Flask-Login setup
├── config.py # Config (databases, secret key, sensors URL)
├── models.py # Sensor, SensorReadout (sensors DB)
├── user_models.py # User, Note, NoteItem, Notification, ClipboardFile
├── seed.py # Sample sensor data
├── migrate_legacy_db.py # Import old combined data.db into split DBs
├── run.sh # Development launcher (main/sensors + port)
├── run-prod.sh # Production launcher (gunicorn)
├── gunicorn.conf.py # Gunicorn config (bind from BIND/PORT)
├── alembic.ini # Sensors DB migrations
├── alembic/ # Sensors DB migration scripts
├── alembic_users.ini # Users DB migrations
├── alembic_users/ # Users DB migration scripts
├── sensors.db # Sensors database (sensors app)
├── main_app.db # Users database (main app)
├── clipboard/ # Uploaded clipboard files (runtime, not in git)
├── data.db # Legacy combined DB (not used by default)
├── requirements.txt
└── templates/
├── sensors.html # Dashboard with chart and table
└── docs.html # API documentation page
├── home.html
├── login.html
├── notes.html
├── notifications.html
├── clipboard.html
├── calendar.html
├── docs.html
└── sensors.html
```
## Setup
@@ -39,36 +71,254 @@ source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Run database migrations
# Sensors database
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
```
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
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
```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
```bash
# Install dependencies (includes gunicorn)
pip install -r requirements.txt
# Run with gunicorn
gunicorn -c gunicorn.conf.py "app:create_app()"
./run-prod.sh main 8000
./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
sudo cp yggdrasil.service /etc/systemd/system/
@@ -76,14 +326,57 @@ sudo systemctl daemon-reload
sudo systemctl enable --now yggdrasil
```
| Route | Description |
|---|---|
| `/sensors` | Dashboard with interactive chart and data table |
| `/api/docs` | API documentation with curl examples |
Main app and sensors API:
## 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**
| Column | Type | Description |
@@ -101,64 +394,179 @@ Two tables are used:
| `value` | Float | Measured value |
| `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
```bash
# Create a new migration after model changes
# Sensors DB
alembic upgrade head
alembic revision --autogenerate -m "description"
# Apply pending migrations
alembic upgrade head
# Roll back one step
alembic downgrade -1
# Users DB
alembic -c alembic_users.ini upgrade head
alembic -c alembic_users.ini revision --autogenerate -m "description"
```
## 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
| Method | Path | Description |
|---|---|---|
| `GET` | `/api/sensors` | List all sensors |
| `POST` | `/api/sensors` | Create a new sensor |
| `GET` | `/api/sensors/:id/types` | List readout types for a sensor |
| `GET` | `/api/sensors/:id/readouts` | Latest readouts (max 300, filterable by `?type=`) |
| `GET` | `/api/sensors/:id/readouts/hourly` | Hourly averaged readouts (filterable by `?type=`) |
| `POST` | `/api/sensors/:id/readouts` | Add a new readout |
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/sensors` | public | List all sensors |
| `POST` | `/api/sensors` | public | Create a new sensor |
| `GET` | `/api/sensors/:id/types` | login | List readout types for a sensor |
| `GET` | `/api/sensors/:id/readouts` | login | Latest readouts (max 300, filterable by `?type=`) |
| `GET` | `/api/sensors/:id/readouts/hourly` | login | Hourly averaged readouts |
| `POST` | `/api/sensors/:id/readouts` | public | Add a new readout |
Calling the sensors API directly on `:8001` skips login; it is open to every
local user by design.
### Examples
```bash
# List sensors
curl http://localhost:5000/api/sensors
curl http://localhost:8000/api/sensors
# Create a sensor
curl -X POST http://localhost:5000/api/sensors \
curl -X POST http://localhost:8000/api/sensors \
-H "Content-Type: application/json" \
-d '{"name": "bmp280"}'
# Get temperature readouts for sensor 1
curl "http://localhost:5000/api/sensors/1/readouts?type=temperature"
# Get temperature readouts for sensor 1 (requires a logged-in session)
curl -b cookies.txt "http://localhost:8000/api/sensors/1/readouts?type=temperature"
# Get hourly averaged data
curl "http://localhost:5000/api/sensors/1/readouts/hourly?type=temperature"
# Get hourly averaged data (requires a logged-in session)
curl -b cookies.txt "http://localhost:8000/api/sensors/1/readouts/hourly?type=temperature"
# Add a readout
curl -X POST http://localhost:5000/api/sensors/1/readouts \
curl -X POST http://localhost:8000/api/sensors/1/readouts \
-H "Content-Type: application/json" \
-d '{"type": "temperature", "value": 22.5}'
# Direct local access with no login
curl http://localhost:8001/api/sensors/1/readouts
```
Full API documentation with request/response examples is available at `/api/docs`.
## Frontend
The dashboard at `/sensors` provides:
The dashboard in the main app at `/sensors` provides:
- **Sensor dropdown** — select a sensor from the list
- **Type dropdown** — select a readout type (populated based on the chosen sensor)
- **Line chart** — 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)
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy import engine_from_config, pool
# 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__), "..")))
from app import create_app
from sensors_app import create_app
from models import db
# 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."""
+37
View File
@@ -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
+52
View File
@@ -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()
+28
View File
@@ -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
View File
@@ -1,136 +1,806 @@
import fcntl
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 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():
app = Flask(__name__)
db_path = os.path.join(os.path.dirname(__file__), "data.db")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
app.config["SECRET_KEY"] = load_secret_key()
app.config["SQLALCHEMY_DATABASE_URI"] = users_database_uri()
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")
@login_required
def sensors_page():
return render_template("sensors.html")
return render_template("sensors.html", user=current_user)
@app.route("/api/docs")
@login_required
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")
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/<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]
)
return _proxy(sensors.list_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
return _proxy(lambda: sensors.create_sensor(request.get_json(silent=True)))
@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,
return _proxy(
lambda: sensors.create_readout(sensor_id, request.get_json(silent=True))
)
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>/types")
@login_required
def api_sensor_types(sensor_id):
return _proxy(lambda: sensors.sensor_types(sensor_id))
@app.route("/api/sensors/<int:sensor_id>/readouts")
@login_required
def api_sensor_readouts(sensor_id):
return _proxy(
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
if __name__ == "__main__":
app = create_app()
app.run(debug=True, host='0.0.0.0')
app.run(debug=True, host='0.0.0.0', port=8000)
+17
View File
@@ -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
+71
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
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)
timeout = 30
keepalive = 5
+73
View File
@@ -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
View File
@@ -1,4 +1,5 @@
flask>=3.0
flask-sqlalchemy>=3.1
flask-login>=0.6
alembic>=1.13
gunicorn>=22.0
+88
View File
@@ -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
View File
@@ -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
View File
@@ -3,7 +3,7 @@
import random
from datetime import datetime, timedelta, timezone
from app import create_app
from sensors_app import create_app
from models import Sensor, SensorReadout, db
+236
View File
@@ -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)
+73
View File
@@ -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
)
+579
View File
@@ -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

+96
View File
@@ -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) }}">&lt;</a>
<a href="{{ url_for('calendar_page', view='month', year=next_year, month=next_month) }}">&gt;</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()) }}">&lt;</a>
<a href="{{ url_for('calendar_page', view='week', date=week_next.isoformat()) }}">&gt;</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>
+73
View File
@@ -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 }} &middot; {{ 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
View File
@@ -3,31 +3,28 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Documentation - Sensor Readouts</title>
<style>
body { font-family: sans-serif; max-width: 900px; margin: 2rem auto; line-height: 1.5; }
h1 { border-bottom: 2px solid #333; padding-bottom: 0.5rem; }
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>
<title>API Documentation - 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>Sensor Readouts &mdash; API Documentation</h1>
<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 -->
<h2>GET /api/sensors</h2>
@@ -35,7 +32,7 @@
<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="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>
<pre><code>[
{ "id": 1, "name": "outdoor-station" },
@@ -48,7 +45,7 @@
<span class="method post">POST</span><span class="path">/api/sensors</span>
<p class="desc">Create a new sensor.</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" \
-d '{"name": "new-sensor"}'</code></pre>
<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>
<p class="desc">Return distinct readout types available for a given sensor.</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>
<pre><code>["humidity", "pressure", "temperature"]</code></pre>
<p><strong>Errors:</strong></p>
@@ -89,10 +86,10 @@
</table>
<p class="section-label">curl</p>
<pre><code># all types
curl http://localhost:5000/api/sensors/1/readouts
curl {{ main_api_url }}/sensors/1/readouts
# 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>
<pre><code>[
{ "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>
<p class="desc">Add a new readout to an existing sensor. Timestamp is generated server-side.</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" \
-d '{"type": "temperature", "value": 22.5}'</code></pre>
<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">
<strong>Note:</strong> All request and response bodies use <code>application/json</code>.
</div>
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~/api$</span>
</footer>
</body>
</html>
+62
View File
@@ -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>
+81
View File
@@ -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>
+233
View File
@@ -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>
+60
View File
@@ -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
View File
@@ -3,21 +3,24 @@
<head>
<meta charset="UTF-8">
<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>
<style>
body { font-family: sans-serif; max-width: 800px; margin: 2rem auto; }
select, table { width: 100%; margin-top: 0.5rem; }
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>
<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>Sensor Readouts</h1>
<div>
@@ -34,8 +37,25 @@
</select>
</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">
<canvas id="chart"></canvas>
<p id="chart-info" class="desc"></p>
<p id="chart-error" class="error hidden"></p>
</div>
<div id="readouts-section" class="hidden">
@@ -50,23 +70,88 @@
</table>
</div>
<footer class="statusbar">
<span>{{ user.username }}@yggdrasil:~/sensors$</span>
</footer>
<script>
const sensorSelect = document.getElementById('sensor-select');
const typeSelect = document.getElementById('type-select');
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 chartError = document.getElementById('chart-error');
const refreshButton = document.getElementById('refresh-button');
const readoutsSection = document.getElementById('readouts-section');
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;
// 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) {
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');
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) {
@@ -87,6 +172,7 @@
sensorSelect.addEventListener('change', async () => {
const sensorId = sensorSelect.value;
typeSection.classList.add('hidden');
rangeSection.classList.add('hidden');
chartSection.classList.add('hidden');
readoutsSection.classList.add('hidden');
typeSelect.innerHTML = '<option value="">-- select type --</option>';
@@ -102,59 +188,119 @@
typeSelect.appendChild(opt);
});
typeSection.classList.remove('hidden');
rangeSection.classList.remove('hidden');
});
typeSelect.addEventListener('change', async () => {
async function loadChart() {
const sensorId = sensorSelect.value;
const type = typeSelect.value;
const range = rangeSelect.value;
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');
readoutsBody.innerHTML = '';
if (!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');
if (!sensorId || !type) return;
// fetch raw readouts for table
const readouts = await fetchJSON(`/api/sensors/${sensorId}/readouts?type=${type}`);
readouts.forEach(r => {
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);
});
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();
</script>
</body>
+457
View File
@@ -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()
+121
View File
@@ -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)
)
+1
View File
@@ -0,0 +1 @@
ssh -R 8001:localhost:8000 vps
+18
View File
@@ -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
+5 -2
View File
@@ -1,12 +1,15 @@
[Unit]
Description=Yggdrasil Sensor API
Description=Yggdrasil Main App
After=network.target
[Service]
User=mateusz
Group=mateusz
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
RestartSec=5
+41 -1
View File
@@ -79,10 +79,50 @@ dependencies:
source:
type: idf
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:
- esp-idf-lib/bmp280
- espressif/cjson
- idf
manifest_hash: 2b2fa2812480ea444900bab732b40d55618df67e034421f1a347f9de48f70eeb
- lvgl/lvgl
- tny-robotics/sh1106-esp-idf
manifest_hash: 35a13c57b5d80651f5e8a8efa2264804bbeec721529fc3fac1741cec178851f2
target: esp32
version: 3.0.0
+1 -1
View File
@@ -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")
+2
View File
@@ -16,3 +16,5 @@ dependencies:
# public: true
esp-idf-lib/bmp280: '*'
espressif/cjson: '*'
lvgl/lvgl: ^9
tny-robotics/sh1106-esp-idf: ^1.0.1
+59 -7
View File
@@ -1,9 +1,13 @@
#include <stdio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <esp_system.h>
#include <esp_timer.h>
#include <esp_log.h>
#include "nvs_flash.h"
#include "esp_http_server.h"
#include "i2cdev.h"
#include "ygg_http_server.h"
#include "ygg_http_routes.h"
@@ -12,20 +16,55 @@
#include "ygg_bmp280.h"
#include "ygg_http_req.h"
#include "ygg_state.h"
#include "ygg_sh1106.h"
#include "ygg_lock.h"
#include "lvgl.h"
static const char *TAG = "thermo_sensor";
static lv_display_t *sh_display = NULL;
static lv_obj_t *label = NULL;
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
vTaskDelay(pdMS_TO_TICKS(1000));
while (1) {
ygg_bmp280_read_sensor(&dev_bmp_280);
ygg_send_sensor_readouts(BASE_URL,
SENSOR_NAME,
CURR_READOUT);
vTaskDelay(pdMS_TO_TICKS(10000));
esp_err_t err = ygg_bmp280_read_sensor(&dev_bmp_280);
// Najpierw OLED, potem (blokujący) HTTP — żeby ekran
// odświeżył się nawet gdy serwer nie odpowiada.
// ygg_bmp280_read_sensor() aktualizuje CURR_READOUT TYLKO przy
// 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_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());
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);
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);
}
+29 -4
View File
@@ -1,28 +1,52 @@
#include <stdio.h>
#include <string.h>
#include <bmp280.h>
#include <esp_log.h>
#include "ygg_bmp280.h"
#include "ygg_state.h"
#include "ygg_lock.h"
static const char *TAG = "ygg_bmp280";
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_init_default_params(&params);
memset(dev, 0, sizeof(bmp280_t));
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, &params));
bool bme280p = dev->id == BME280_CHIP_ID;
printf("BMP280: found %s\n", bme280p ? "BME280" : "BMP280");
}
void ygg_bmp280_read_sensor(bmp280_t *dev) {
float pressure, temperature, humidity;
esp_err_t ygg_bmp280_read_sensor(bmp280_t *dev) {
float pressure = 0, temperature = 0, humidity = 0;
bool bme280p = dev->id == BME280_CHIP_ID;
if (bmp280_read_float(dev, &temperature, &pressure, &humidity) != ESP_OK)
printf("Temperature/pressure reading failed\n");
// Cały odczyt pod wspólnym mutexem I2C - OLED (esp_lcd) używa
// 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.pressure = pressure;
CURR_READOUT.humidity = humidity;
@@ -31,4 +55,5 @@ void ygg_bmp280_read_sensor(bmp280_t *dev) {
printf(", Humidity: %.2f\n", humidity);
else
printf("\n");
return ESP_OK;
}
+3 -1
View File
@@ -2,11 +2,13 @@
#define YGG_BMP280_H
#include <bmp280.h>
#include <esp_err.h>
#define I2C_MASTER_SDA 19
#define I2C_MASTER_SCL 23
#define YGG_I2C_FREQ_HZ 100000
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
+2
View File
@@ -21,6 +21,7 @@ char* ygg_send_get(const char *url) {
esp_http_client_config_t config = {
.url = url,
.timeout_ms = 3000,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
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 = {
.url = url,
.timeout_ms = 3000,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
+40
View File
@@ -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);
}
}
+15
View File
@@ -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
+186
View File
@@ -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();
}
+9
View File
@@ -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
+1 -1
View File
@@ -9,6 +9,6 @@ typedef struct {
extern ygg_sensor_readout CURR_READOUT;
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
+979 -8
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff