diff --git a/.gitignore b/.gitignore index 6cdebd0..6fa9299 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ build managed_components *.swp +*.pyc +*.db .cache +.vscode diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..a89fedd --- /dev/null +++ b/server/README.md @@ -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) diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..fa60e77 --- /dev/null +++ b/server/alembic.ini @@ -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 /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 diff --git a/server/alembic/README b/server/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/server/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/server/alembic/env.py b/server/alembic/env.py new file mode 100644 index 0000000..dbb7b34 --- /dev/null +++ b/server/alembic/env.py @@ -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() diff --git a/server/alembic/script.py.mako b/server/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/server/alembic/script.py.mako @@ -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"} diff --git a/server/alembic/versions/7f92bfc47dfc_initial_tables.py b/server/alembic/versions/7f92bfc47dfc_initial_tables.py new file mode 100644 index 0000000..325592f --- /dev/null +++ b/server/alembic/versions/7f92bfc47dfc_initial_tables.py @@ -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 ### diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..ddd1164 --- /dev/null +++ b/server/app.py @@ -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//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//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//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//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') diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..f54cb28 --- /dev/null +++ b/server/models.py @@ -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)) diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..4996433 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +flask-sqlalchemy>=3.1 +alembic>=1.13 \ No newline at end of file diff --git a/server/seed.py b/server/seed.py new file mode 100644 index 0000000..cecd693 --- /dev/null +++ b/server/seed.py @@ -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() diff --git a/server/templates/docs.html b/server/templates/docs.html new file mode 100644 index 0000000..c972b92 --- /dev/null +++ b/server/templates/docs.html @@ -0,0 +1,131 @@ + + + + + + API Documentation - Sensor Readouts + + + +

Sensor Readouts — API Documentation

+ +

Base URL

+

http://localhost:5000/api

+ + +

GET /api/sensors

+
+ GET/api/sensors +

Return a list of all sensors ordered by name.

+ +
curl http://localhost:5000/api/sensors
+ +
[
+  { "id": 1, "name": "outdoor-station" },
+  { "id": 2, "name": "indoor-unit" }
+]
+
+ + +
+ POST/api/sensors +

Create a new sensor.

+ +
curl -X POST http://localhost:5000/api/sensors \
+     -H "Content-Type: application/json" \
+     -d '{"name": "new-sensor"}'
+ +
{ "id": 5, "name": "new-sensor" }
+

Errors:

+ + + + +
CodeCondition
400name is missing or empty
409Sensor with this name already exists
+
+ + +

GET /api/sensors/:id/types

+
+ GET/api/sensors/:id/types +

Return distinct readout types available for a given sensor.

+ +
curl http://localhost:5000/api/sensors/1/types
+ +
["humidity", "pressure", "temperature"]
+

Errors:

+ + + +
CodeCondition
404Sensor not found
+
+ + +

GET /api/sensors/:id/readouts

+
+ GET/api/sensors/:id/readouts +

Return the latest readouts for a sensor. Optional type query parameter filters by readout type.

+

Query parameters:

+ + + +
ParamTypeDescription
typestringFilter by type (e.g. temperature)
+ +
# all types
+curl http://localhost:5000/api/sensors/1/readouts
+
+# filter by type
+curl "http://localhost:5000/api/sensors/1/readouts?type=temperature"
+ +
[
+  { "value": 24.31, "timestamp": "2026-09-11T10:30:00" },
+  { "value": 24.15, "timestamp": "2026-09-11T10:29:30" }
+]
+
+ + +
+ POST/api/sensors/:id/readouts +

Add a new readout to an existing sensor. Timestamp is generated server-side.

+ +
curl -X POST http://localhost:5000/api/sensors/1/readouts \
+     -H "Content-Type: application/json" \
+     -d '{"type": "temperature", "value": 22.5}'
+ +
{
+  "id": 2702,
+  "sensor_id": 1,
+  "type": "temperature",
+  "value": 22.5,
+  "timestamp": "2026-09-11T17:00:00.123456"
+}
+

Errors:

+ + + + +
CodeCondition
400Missing type or value, or value is not a number
404Sensor not found
+
+ +
+ Note: All request and response bodies use application/json. +
+ + diff --git a/server/templates/sensors.html b/server/templates/sensors.html new file mode 100644 index 0000000..2776d27 --- /dev/null +++ b/server/templates/sensors.html @@ -0,0 +1,160 @@ + + + + + + Sensor Readouts + + + + +

Sensor Readouts

+ +
+ + +
+ + + + + + + + + + diff --git a/thermo_sensor/dependencies.lock b/thermo_sensor/dependencies.lock index 0dcb953..6de9ea7 100644 --- a/thermo_sensor/dependencies.lock +++ b/thermo_sensor/dependencies.lock @@ -65,13 +65,24 @@ dependencies: - esp32s2 - esp32s3 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: source: type: idf version: 6.1.0 direct_dependencies: - esp-idf-lib/bmp280 +- espressif/cjson - idf -manifest_hash: e9139083c387eac863d68b0a593ed1c2631dc643670305c76e9d8ac18ee8f285 +manifest_hash: 2b2fa2812480ea444900bab732b40d55618df67e034421f1a347f9de48f70eeb target: esp32 version: 3.0.0 diff --git a/thermo_sensor/main/CMakeLists.txt b/thermo_sensor/main/CMakeLists.txt index 73cfe06..a5d9113 100644 --- a/thermo_sensor/main/CMakeLists.txt +++ b/thermo_sensor/main/CMakeLists.txt @@ -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") diff --git a/thermo_sensor/main/idf_component.yml b/thermo_sensor/main/idf_component.yml index 5de1f28..063c0c1 100644 --- a/thermo_sensor/main/idf_component.yml +++ b/thermo_sensor/main/idf_component.yml @@ -15,3 +15,4 @@ dependencies: # # All dependencies of `main` are public by default. # public: true esp-idf-lib/bmp280: '*' + espressif/cjson: '*' diff --git a/thermo_sensor/main/thermo_sensor.c b/thermo_sensor/main/thermo_sensor.c index 697038c..c1e0d89 100644 --- a/thermo_sensor/main/thermo_sensor.c +++ b/thermo_sensor/main/thermo_sensor.c @@ -2,7 +2,6 @@ #include #include -#include "esp_log.h" #include "nvs_flash.h" #include "esp_http_server.h" @@ -11,6 +10,7 @@ #include "ygg_wifi.h" #include "ygg_http_server.h" #include "ygg_bmp280.h" +#include "ygg_http_req.h" static const char *TAG = "thermo_sensor"; @@ -21,8 +21,11 @@ bmp280_t dev_bmp_280; void main_loop(void *pvParameters) { while (1) { - vTaskDelay(pdMS_TO_TICKS(500)); + vTaskDelay(pdMS_TO_TICKS(10000)); ygg_bmp280_read_sensor(&dev_bmp_280); + ygg_send_sensor_redouts(last_temperature, + last_pressure, + last_humidity); } } diff --git a/thermo_sensor/main/ygg_http_req.c b/thermo_sensor/main/ygg_http_req.c new file mode 100644 index 0000000..06a2545 --- /dev/null +++ b/thermo_sensor/main/ygg_http_req.c @@ -0,0 +1,141 @@ +#include +#include +#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; +} diff --git a/thermo_sensor/main/ygg_http_req.h b/thermo_sensor/main/ygg_http_req.h new file mode 100644 index 0000000..03d876c --- /dev/null +++ b/thermo_sensor/main/ygg_http_req.h @@ -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 diff --git a/thermo_sensor/sdkconfig b/thermo_sensor/sdkconfig index ff223a3..ca50fae 100644 --- a/thermo_sensor/sdkconfig +++ b/thermo_sensor/sdkconfig @@ -3484,6 +3484,15 @@ CONFIG_I2CDEV_TIMEOUT=1000 # default: # CONFIG_I2CDEV_NOLOCK is not set # 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 # default: