server + getting sensor id + json in esp32

This commit is contained in:
2026-09-13 18:14:19 +02:00
parent 98fb81702b
commit e481468a0a
20 changed files with 1133 additions and 4 deletions
+3
View File
@@ -1,4 +1,7 @@
build build
managed_components managed_components
*.swp *.swp
*.pyc
*.db
.cache .cache
.vscode
+142
View File
@@ -0,0 +1,142 @@
# 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.
## Tech Stack
- **Python 3.13+**
- **Flask 3** — web framework
- **SQLAlchemy** (via Flask-SQLAlchemy) — ORM
- **SQLite** — database
- **Alembic** — database migrations
- **Chart.js** — frontend charting library
## Project Structure
```
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/
└── templates/
├── sensors.html # Dashboard with chart and table
└── docs.html # API documentation page
```
## Setup
```bash
# Create and activate virtual environment
python3 -m venv .venv
source .venv/bin/activate
# Install dependencies
pip install -r requirements.txt
# Run database migrations
alembic upgrade head
# (Optional) Seed the database with sample data
python seed.py
```
## Running
```bash
python app.py
```
The server starts on `http://localhost:5000`.
| Route | Description |
|---|---|
| `/sensors` | Dashboard with interactive chart and data table |
| `/api/docs` | API documentation with curl examples |
## Database
Two tables are used:
**sensor**
| Column | Type | Description |
|---|---|---|
| `id` | Integer | Primary key |
| `name` | String(128) | Unique sensor name |
| `created_at` | DateTime | Creation timestamp (UTC) |
**sensor_readout**
| Column | Type | Description |
|---|---|---|
| `id` | Integer | Primary key |
| `sensor_id` | Integer | Foreign key to `sensor.id` |
| `type` | String(64) | Readout type (e.g. `temperature`, `pressure`, `humidity`) |
| `value` | Float | Measured value |
| `timestamp` | DateTime | Readout timestamp (UTC) |
### Migrations
```bash
# Create a new migration after model changes
alembic revision --autogenerate -m "description"
# Apply pending migrations
alembic upgrade head
# Roll back one step
alembic downgrade -1
```
## API
Base URL: `http://localhost:5000/api`
### 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 |
### Examples
```bash
# List sensors
curl http://localhost:5000/api/sensors
# Create a sensor
curl -X POST http://localhost:5000/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 hourly averaged data
curl "http://localhost:5000/api/sensors/1/readouts/hourly?type=temperature"
# Add a readout
curl -X POST http://localhost:5000/api/sensors/1/readouts \
-H "Content-Type: application/json" \
-d '{"type": "temperature", "value": 22.5}'
```
Full API documentation with request/response examples is available at `/api/docs`.
## Frontend
The dashboard 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
- **Data table** — raw readouts with formatted timestamps (latest 300 entries)
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
# sqlalchemy.url is set dynamically in env.py from the Flask app config
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+69
View File
@@ -0,0 +1,69 @@
import os
import sys
from logging.config import fileConfig
from alembic import context
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 models import db
# Create the Flask application to get the database URI.
app = create_app()
# Alembic Config object.
config = context.config
# Override the sqlalchemy.url with the value from the Flask config.
config.set_main_option("sqlalchemy.url", app.config["SQLALCHEMY_DATABASE_URI"])
# Set up Python logging from the alembic.ini [loggers] section.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Target metadata for 'autogenerate' support.
target_metadata = db.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
Configures the context with just a URL and not an Engine.
"""
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:
"""Run migrations in 'online' mode.
Creates an Engine and associates a connection with the context.
"""
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,48 @@
"""initial tables
Revision ID: 7f92bfc47dfc
Revises:
Create Date: 2026-09-11 17:08:42.232582
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7f92bfc47dfc'
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:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sensor',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('sensor_readout',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sensor_id', sa.Integer(), nullable=False),
sa.Column('type', sa.String(length=64), nullable=False),
sa.Column('value', sa.Float(), nullable=False),
sa.Column('timestamp', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['sensor_id'], ['sensor.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('sensor_readout')
op.drop_table('sensor')
# ### end Alembic commands ###
+136
View File
@@ -0,0 +1,136 @@
import os
from flask import Flask, jsonify, render_template, request
from sqlalchemy import func
from models import Sensor, SensorReadout, db
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["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
@app.route("/sensors")
def sensors_page():
return render_template("sensors.html")
@app.route("/api/docs")
def api_docs():
return render_template("docs.html")
@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()}
for r in readouts
]
)
@app.route("/api/sensors/<int:sensor_id>/readouts/hourly")
def api_sensor_readouts_hourly(sensor_id):
type_ = request.args.get("type")
query = (
db.session.query(
func.strftime("%Y-%m-%dT%H:00:00", SensorReadout.timestamp).label("hour"),
func.avg(SensorReadout.value).label("avg_value"),
)
.filter(SensorReadout.sensor_id == sensor_id)
)
if type_:
query = query.filter(SensorReadout.type == type_)
rows = (
query.group_by("hour")
.order_by("hour")
.all()
)
return jsonify(
[{"timestamp": r.hour, "value": round(r.avg_value, 2)} for r in rows]
)
@app.route("/api/sensors", methods=["POST"])
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(),
}
), 201
return app
if __name__ == "__main__":
app = create_app()
app.run(debug=True, host='0.0.0.0')
+25
View File
@@ -0,0 +1,25 @@
from datetime import datetime, timezone
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class Sensor(db.Model):
__tablename__ = "sensor"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False, unique=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
readouts = db.relationship("SensorReadout", backref="sensor", lazy=True)
class SensorReadout(db.Model):
__tablename__ = "sensor_readout"
id = db.Column(db.Integer, primary_key=True)
sensor_id = db.Column(db.Integer, db.ForeignKey("sensor.id"), nullable=False)
type = db.Column(db.String(64), nullable=False)
value = db.Column(db.Float, nullable=False)
timestamp = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
+3
View File
@@ -0,0 +1,3 @@
flask>=3.0
flask-sqlalchemy>=3.1
alembic>=1.13
+63
View File
@@ -0,0 +1,63 @@
"""Seed the database with sample sensor data."""
import random
from datetime import datetime, timedelta, timezone
from app import create_app
from models import Sensor, SensorReadout, db
def seed():
app = create_app()
with app.app_context():
sensors = [
Sensor(name="outdoor-station"),
Sensor(name="indoor-unit"),
Sensor(name="greenhouse-probe"),
]
db.session.add_all(sensors)
db.session.flush()
types = ["temperature", "pressure", "humidity"]
base_time = datetime.now(timezone.utc) - timedelta(seconds=30 * 300)
readouts = []
for sensor in sensors:
t = base_time
for i in range(300):
readouts.append(
SensorReadout(
sensor_id=sensor.id,
type="temperature",
value=round(random.uniform(18.0, 28.0), 2),
timestamp=t,
)
)
readouts.append(
SensorReadout(
sensor_id=sensor.id,
type="pressure",
value=round(random.uniform(1008.0, 1018.0), 2),
timestamp=t,
)
)
readouts.append(
SensorReadout(
sensor_id=sensor.id,
type="humidity",
value=round(random.uniform(35.0, 65.0), 2),
timestamp=t,
)
)
t += timedelta(seconds=30)
db.session.add_all(readouts)
db.session.commit()
sensor_count = Sensor.query.count()
readout_count = SensorReadout.query.count()
print(f"Seeded {sensor_count} sensors and {readout_count} readouts.")
if __name__ == "__main__":
seed()
+131
View File
@@ -0,0 +1,131 @@
<!DOCTYPE html>
<html lang="en">
<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>
</head>
<body>
<h1>Sensor Readouts &mdash; API Documentation</h1>
<h2>Base URL</h2>
<p><code>http://localhost:5000/api</code></p>
<!-- GET /api/sensors -->
<h2>GET /api/sensors</h2>
<div class="endpoint">
<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>
<p class="section-label">Response 200</p>
<pre><code>[
{ "id": 1, "name": "outdoor-station" },
{ "id": 2, "name": "indoor-unit" }
]</code></pre>
</div>
<!-- POST /api/sensors -->
<div class="endpoint">
<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 \
-H "Content-Type: application/json" \
-d '{"name": "new-sensor"}'</code></pre>
<p class="section-label">Response 201</p>
<pre><code>{ "id": 5, "name": "new-sensor" }</code></pre>
<p><strong>Errors:</strong></p>
<table>
<tr><th>Code</th><th>Condition</th></tr>
<tr><td>400</td><td><code>name</code> is missing or empty</td></tr>
<tr><td>409</td><td>Sensor with this name already exists</td></tr>
</table>
</div>
<!-- GET /api/sensors/:id/types -->
<h2>GET /api/sensors/:id/types</h2>
<div class="endpoint">
<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>
<p class="section-label">Response 200</p>
<pre><code>["humidity", "pressure", "temperature"]</code></pre>
<p><strong>Errors:</strong></p>
<table>
<tr><th>Code</th><th>Condition</th></tr>
<tr><td>404</td><td>Sensor not found</td></tr>
</table>
</div>
<!-- GET /api/sensors/:id/readouts -->
<h2>GET /api/sensors/:id/readouts</h2>
<div class="endpoint">
<span class="method get">GET</span><span class="path">/api/sensors/:id/readouts</span>
<p class="desc">Return the latest readouts for a sensor. Optional <code>type</code> query parameter filters by readout type.</p>
<p><strong>Query parameters:</strong></p>
<table>
<tr><th>Param</th><th>Type</th><th>Description</th></tr>
<tr><td>type</td><td>string</td><td>Filter by type (e.g. <code>temperature</code>)</td></tr>
</table>
<p class="section-label">curl</p>
<pre><code># all types
curl http://localhost:5000/api/sensors/1/readouts
# filter by type
curl "http://localhost:5000/api/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" },
{ "value": 24.15, "timestamp": "2026-09-11T10:29:30" }
]</code></pre>
</div>
<!-- POST /api/sensors/:id/readouts -->
<div class="endpoint">
<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 \
-H "Content-Type: application/json" \
-d '{"type": "temperature", "value": 22.5}'</code></pre>
<p class="section-label">Response 201</p>
<pre><code>{
"id": 2702,
"sensor_id": 1,
"type": "temperature",
"value": 22.5,
"timestamp": "2026-09-11T17:00:00.123456"
}</code></pre>
<p><strong>Errors:</strong></p>
<table>
<tr><th>Code</th><th>Condition</th></tr>
<tr><td>400</td><td>Missing <code>type</code> or <code>value</code>, or <code>value</code> is not a number</td></tr>
<tr><td>404</td><td>Sensor not found</td></tr>
</table>
</div>
<div class="note">
<strong>Note:</strong> All request and response bodies use <code>application/json</code>.
</div>
</body>
</html>
+160
View File
@@ -0,0 +1,160 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sensor Readouts</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>
</head>
<body>
<h1>Sensor Readouts</h1>
<div>
<label for="sensor-select">Sensor:</label>
<select id="sensor-select">
<option value="">-- select sensor --</option>
</select>
</div>
<div id="type-section" class="hidden">
<label for="type-select">Type:</label>
<select id="type-select">
<option value="">-- select type --</option>
</select>
</div>
<div id="chart-section" class="hidden chart-box">
<canvas id="chart"></canvas>
</div>
<div id="readouts-section" class="hidden">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Value</th>
</tr>
</thead>
<tbody id="readouts-body"></tbody>
</table>
</div>
<script>
const sensorSelect = document.getElementById('sensor-select');
const typeSelect = document.getElementById('type-select');
const typeSection = document.getElementById('type-section');
const chartSection = document.getElementById('chart-section');
const readoutsSection = document.getElementById('readouts-section');
const readoutsBody = document.getElementById('readouts-body');
let chart = null;
function formatTimestamp(iso) {
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const months = ['Jan','Feb','Mar','Apr','May','Jun',
'Jul','Aug','Sep','Oct','Nov','Dec'];
return `${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}, ` +
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
async function fetchJSON(url) {
const res = await fetch(url);
return res.json();
}
async function loadSensors() {
const sensors = await fetchJSON('/api/sensors');
sensors.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
sensorSelect.appendChild(opt);
});
}
sensorSelect.addEventListener('change', async () => {
const sensorId = sensorSelect.value;
typeSection.classList.add('hidden');
chartSection.classList.add('hidden');
readoutsSection.classList.add('hidden');
typeSelect.innerHTML = '<option value="">-- select type --</option>';
readoutsBody.innerHTML = '';
if (!sensorId) return;
const types = await fetchJSON(`/api/sensors/${sensorId}/types`);
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
typeSelect.appendChild(opt);
});
typeSection.classList.remove('hidden');
});
typeSelect.addEventListener('change', async () => {
const sensorId = sensorSelect.value;
const type = typeSelect.value;
chartSection.classList.add('hidden');
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 => r.timestamp.replace('T', ' '));
const values = hourly.map(r => r.value);
if (chart) chart.destroy();
const ctx = document.getElementById('chart').getContext('2d');
chart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: type,
data: values,
borderColor: '#2563eb',
backgroundColor: 'rgba(37,99,235,0.1)',
fill: true,
tension: 0.3,
pointRadius: 2,
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
x: { title: { display: true, text: 'Time (hourly avg)' } },
y: { title: { display: true, text: type } }
}
}
});
chartSection.classList.remove('hidden');
// fetch raw readouts for table
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>`;
readoutsBody.appendChild(tr);
});
readoutsSection.classList.remove('hidden');
});
loadSensors();
</script>
</body>
</html>
+12 -1
View File
@@ -65,13 +65,24 @@ dependencies:
- esp32s2 - esp32s2
- esp32s3 - esp32s3
version: 2.1.2 version: 2.1.2
espressif/cjson:
component_hash: e788323270d90738662d66fffa910bfe1fba019bba087f01557e70c40485b469
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.7.19~2
idf: idf:
source: source:
type: idf type: idf
version: 6.1.0 version: 6.1.0
direct_dependencies: direct_dependencies:
- esp-idf-lib/bmp280 - esp-idf-lib/bmp280
- espressif/cjson
- idf - idf
manifest_hash: e9139083c387eac863d68b0a593ed1c2631dc643670305c76e9d8ac18ee8f285 manifest_hash: 2b2fa2812480ea444900bab732b40d55618df67e034421f1a347f9de48f70eeb
target: esp32 target: esp32
version: 3.0.0 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" "../../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" "../../common/esp32/ygg_wifi.c" "../../common/esp32/ygg_http_server.c"
INCLUDE_DIRS "." "../../common/esp32") INCLUDE_DIRS "." "../../common/esp32")
+1
View File
@@ -15,3 +15,4 @@ dependencies:
# # All dependencies of `main` are public by default. # # All dependencies of `main` are public by default.
# public: true # public: true
esp-idf-lib/bmp280: '*' esp-idf-lib/bmp280: '*'
espressif/cjson: '*'
+5 -2
View File
@@ -2,7 +2,6 @@
#include <freertos/task.h> #include <freertos/task.h>
#include <esp_system.h> #include <esp_system.h>
#include "esp_log.h"
#include "nvs_flash.h" #include "nvs_flash.h"
#include "esp_http_server.h" #include "esp_http_server.h"
@@ -11,6 +10,7 @@
#include "ygg_wifi.h" #include "ygg_wifi.h"
#include "ygg_http_server.h" #include "ygg_http_server.h"
#include "ygg_bmp280.h" #include "ygg_bmp280.h"
#include "ygg_http_req.h"
static const char *TAG = "thermo_sensor"; static const char *TAG = "thermo_sensor";
@@ -21,8 +21,11 @@ bmp280_t dev_bmp_280;
void main_loop(void *pvParameters) { void main_loop(void *pvParameters) {
while (1) { while (1) {
vTaskDelay(pdMS_TO_TICKS(500)); vTaskDelay(pdMS_TO_TICKS(10000));
ygg_bmp280_read_sensor(&dev_bmp_280); ygg_bmp280_read_sensor(&dev_bmp_280);
ygg_send_sensor_redouts(last_temperature,
last_pressure,
last_humidity);
} }
} }
+141
View File
@@ -0,0 +1,141 @@
#include <stdio.h>
#include <string.h>
#include "cJSON.h"
#include "esp_log.h"
#include "esp_http_client.h"
#include "ygg_http_req.h"
#define GET_RESP_MAX_SIZE 4096
static const char *TAG = "YGG_HTTP_REQ";
static char GET_RESP[GET_RESP_MAX_SIZE];
esp_err_t _http_event_handler(esp_http_client_event_t *evt) {
return ESP_OK;
}
char* ygg_send_get(const char *url) {
size_t total = 0;
esp_http_client_config_t config = {
.url = url,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_err_t err = esp_http_client_open(client, 0);
if (err != ESP_OK) {
ESP_LOGE(TAG, "HTTP open error: %s", esp_err_to_name(err));
esp_http_client_cleanup(client);
return NULL;
}
esp_http_client_fetch_headers(client);
while (total < GET_RESP_MAX_SIZE - 1) {
int len = esp_http_client_read(
client,
GET_RESP + total,
GET_RESP_MAX_SIZE - 1 - total
);
if (len <= 0) {
break;
}
total += len;
}
GET_RESP[total] = '\0';
ESP_LOGI(TAG, "Status HTTP = %d",
esp_http_client_get_status_code(client));
ESP_LOGI(TAG, "Response: %s", GET_RESP);
esp_http_client_close(client);
esp_http_client_cleanup(client);
return GET_RESP;
}
void ygg_send_post(const char *url, char *post_data) {
esp_http_client_config_t config = {
.url = url,
.event_handler = _http_event_handler,
};
esp_http_client_handle_t client = esp_http_client_init(&config);
esp_http_client_set_method(client, HTTP_METHOD_POST);
esp_http_client_set_header(client, "Content-Type", "application/json");
esp_http_client_set_post_field(client, post_data, strlen(post_data));
esp_err_t err = esp_http_client_perform(client);
if (err == ESP_OK) {
ESP_LOGI(TAG, "Status HTTP = %d, Content-Length = %"PRId64,
esp_http_client_get_status_code(client),
esp_http_client_get_content_length(client));
} else {
ESP_LOGE(TAG, "HTTP connection error: %s", esp_err_to_name(err));
}
esp_http_client_cleanup(client);
}
int ygg_get_sensor_id(const char *sensor_name,
const char *url);
void ygg_send_sensor_redouts(float temp, float press, float hum) {
const char *url = "http://192.168.0.236:5000/api/sensors/1/readouts";
char post_data[64];
int sensor_id = ygg_get_sensor_id("bmp280", "http://192.168.0.236:5000");
snprintf(post_data, sizeof(post_data),
"{\"type\":\"temperature\", \"value\":%.2f}", temp);
ygg_send_post(url, post_data);
snprintf(post_data, sizeof(post_data),
"{\"type\":\"pressure\", \"value\":%.2f}", press);
ygg_send_post(url, post_data);
snprintf(post_data, sizeof(post_data),
"{\"type\":\"humidity\", \"value\":%.2f}", hum);
ygg_send_post(url, post_data);
}
char* ygg_get_sensors(const char *base_url) {
char url[128];
snprintf(url, sizeof(url),
"%s/api/sensors", base_url);
char *sensors = ygg_send_get(url);
return sensors;
}
int ygg_find_sensor(char *sensors, const char *sensor_name) {
cJSON *root = cJSON_Parse(sensors);
cJSON *item;
cJSON_ArrayForEach(item, root) {
cJSON *id = cJSON_GetObjectItem(item, "id");
cJSON *item_name = cJSON_GetObjectItem(item, "name");
if (cJSON_IsNumber(id) &&
cJSON_IsString(item_name) &&
strcmp(item_name->valuestring, sensor_name) == 0) {
int result = id->valueint;
cJSON_Delete(root);
return result;
}
}
cJSON_Delete(root);
return -1;
}
int ygg_get_sensor_id(const char *sensor_name,
const char *url) {
char *sensors = ygg_get_sensors(url);
int id = ygg_find_sensor(sensors, sensor_name);
ESP_LOGI(TAG, "Sensor ID: %d", id);
return 0;
//if (id == -1) {
// id = ygg_add_sensor(url, sensor_name);
//}
//return id;
}
+6
View File
@@ -0,0 +1,6 @@
#ifndef YGG_HTTP_REQ_H
#define YGG_HTTP_REQ_H
void ygg_send_sensor_redouts(float temp, float press, float hum);
#endif
+9
View File
@@ -3484,6 +3484,15 @@ CONFIG_I2CDEV_TIMEOUT=1000
# default: # default:
# CONFIG_I2CDEV_NOLOCK is not set # CONFIG_I2CDEV_NOLOCK is not set
# end of I2C Device Library # end of I2C Device Library
#
# cJSON
#
# default:
CONFIG_CJSON_NESTING_LIMIT=1000
# default:
CONFIG_CJSON_CIRCULAR_LIMIT=10000
# end of cJSON
# end of Component config # end of Component config
# default: # default: