1 Commits

Author SHA1 Message Date
pablorevilla-meshtastic a676db2921 Initial Jinja ESP32 collector 2026-03-18 14:28:04 -07:00
5 changed files with 517 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
# Basic Jinja Project
Small starter project that renders HTML with Jinja using Python's built-in HTTP server.
## Run
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python3 collector.py
```
Open `http://127.0.0.1:8000/`.
## Files
- `collector.py` starts the server and renders templates.
- `templates/base.html` is the shared layout.
- `templates/index.html` is the page template.
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
import argparse
import json
import sqlite3
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from jinja2 import Environment, FileSystemLoader, select_autoescape
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
DEFAULT_DB_PATH = "readings.db"
DEFAULT_READINGS_LIMIT = 100
BASE_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = BASE_DIR / "templates"
templates = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=select_autoescape(["html", "xml"]),
)
def utc_now_iso():
return datetime.now(timezone.utc).isoformat()
def init_db(db_path):
conn = sqlite3.connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
sensor_type TEXT NOT NULL,
temperature_c REAL NOT NULL,
humidity REAL NOT NULL,
received_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_readings_device_received_at
ON readings (device_id, received_at DESC)
"""
)
conn.commit()
finally:
conn.close()
def insert_reading(db_path, reading):
conn = sqlite3.connect(db_path)
try:
cursor = conn.execute(
"""
INSERT INTO readings (
device_id,
sensor_type,
temperature_c,
humidity,
received_at
) VALUES (?, ?, ?, ?, ?)
""",
(
reading["device_id"],
reading["sensor_type"],
reading["temperature_c"],
reading["humidity"],
utc_now_iso(),
),
)
conn.commit()
return cursor.lastrowid
finally:
conn.close()
def fetch_recent_readings(db_path, limit):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"""
SELECT
id,
device_id,
sensor_type,
temperature_c,
humidity,
received_at
FROM readings
ORDER BY received_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
readings = [dict(row) for row in rows]
readings.reverse()
return readings
finally:
conn.close()
def validate_payload(payload):
required_fields = (
"device_id",
"sensor_type",
"temperature_c",
"humidity",
)
for field in required_fields:
if field not in payload:
raise ValueError("Missing field: {}".format(field))
validated = {
"device_id": str(payload["device_id"]).strip(),
"sensor_type": str(payload["sensor_type"]).strip(),
"temperature_c": float(payload["temperature_c"]),
"humidity": float(payload["humidity"]),
}
if not validated["device_id"]:
raise ValueError("device_id must not be empty")
if not validated["sensor_type"]:
raise ValueError("sensor_type must not be empty")
return validated
def render_template(template_name, **context):
template = templates.get_template(template_name)
return template.render(**context)
def build_dashboard_context(db_path):
readings = fetch_recent_readings(db_path, DEFAULT_READINGS_LIMIT)
latest = readings[-1] if readings else None
return {
"title": "ESP32 Collector",
"readings": readings,
"reading_count": len(readings),
"latest": latest,
}
class AppHandler(BaseHTTPRequestHandler):
server_version = "ESP32Collector/1.0"
def send_json(self, payload, status=HTTPStatus.OK):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_html(self, html, status=HTTPStatus.OK):
body = html.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
route = urlparse(self.path).path
if route == "/":
self.send_html(render_template("dashboard.html", **build_dashboard_context(self.server.db_path)))
return
if route == "/chart":
self.send_html(render_template("dashboard.html", **build_dashboard_context(self.server.db_path)))
return
if route == "/health":
self.send_json({"status": "ok"})
return
if route == "/readings":
readings = fetch_recent_readings(self.server.db_path, DEFAULT_READINGS_LIMIT)
self.send_json({"readings": readings})
return
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
def do_POST(self):
route = urlparse(self.path).path
if route != "/metrics":
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
return
content_length = self.headers.get("Content-Length")
if not content_length:
self.send_json({"error": "missing content length"}, status=HTTPStatus.LENGTH_REQUIRED)
return
try:
raw_body = self.rfile.read(int(content_length))
payload = json.loads(raw_body)
reading = validate_payload(payload)
row_id = insert_reading(self.server.db_path, reading)
except ValueError as exc:
self.send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
return
except json.JSONDecodeError:
self.send_json({"error": "invalid json"}, status=HTTPStatus.BAD_REQUEST)
return
except Exception as exc:
self.send_json(
{"error": "server error", "detail": str(exc)},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
return
print(
"[{}] stored reading id={} device={} temp_c={} humidity={}".format(
utc_now_iso(),
row_id,
reading["device_id"],
reading["temperature_c"],
reading["humidity"],
)
)
self.send_json({"status": "stored", "id": row_id}, status=HTTPStatus.CREATED)
def log_message(self, format_string, *args):
print(
"[{}] {} {}".format(
utc_now_iso(),
self.address_string(),
format_string % args,
)
)
def parse_args():
parser = argparse.ArgumentParser(
description="Collect ESP32 sensor data and render a Jinja dashboard."
)
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--db", default=DEFAULT_DB_PATH)
return parser.parse_args()
def main():
args = parse_args()
db_path = Path(args.db).resolve()
init_db(db_path)
server = ThreadingHTTPServer((args.host, args.port), AppHandler)
server.db_path = str(db_path)
print("Metrics endpoint: http://{}:{}/metrics".format(args.host, args.port))
print("Dashboard: http://{}:{}/chart".format(args.host, args.port))
print("SQLite database:", db_path)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down")
finally:
server.server_close()
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
Jinja2>=3.1,<4
+123
View File
@@ -0,0 +1,123 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root {
--bg: #f8f5ef;
--panel: #fffdf8;
--text: #1f2937;
--accent: #0f766e;
--border: #d6d3d1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Georgia, serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.10), transparent 25%),
linear-gradient(180deg, #fdfcf9 0%, var(--bg) 100%);
}
main {
width: min(760px, calc(100% - 32px));
margin: 48px auto;
}
.card {
padding: 32px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: 0 20px 50px rgba(31, 41, 55, 0.08);
}
h1 {
margin: 0 0 12px;
font-size: clamp(2rem, 5vw, 3rem);
}
p {
line-height: 1.6;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin: 24px 0;
}
.stat {
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
}
.stat strong,
.stat span {
display: block;
}
.stat strong {
font-size: 1.2rem;
}
.stat span {
color: #57534e;
margin-top: 4px;
}
.chart-wrap {
position: relative;
height: 360px;
margin: 24px 0;
}
.empty {
margin-top: 24px;
padding: 18px;
border: 1px dashed var(--border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 24px;
font-size: 0.95rem;
}
th,
td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
code {
font-family: "Courier New", monospace;
background: rgba(15, 118, 110, 0.08);
padding: 2px 6px;
border-radius: 6px;
}
</style>
</head>
<body>
<main>
<section class="card">
{% block content %}{% endblock %}
</section>
</main>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block content %}
<h1>ESP32 Collector</h1>
<p>Recent sensor uploads rendered with Jinja.</p>
<div class="stats">
<div class="stat">
<strong>{{ reading_count }}</strong>
<span>stored readings</span>
</div>
<div class="stat">
<strong>{{ latest.device_id if latest else "n/a" }}</strong>
<span>latest device</span>
</div>
<div class="stat">
<strong>{{ "%.1f"|format(latest.temperature_c) if latest else "n/a" }}</strong>
<span>latest temp C</span>
</div>
<div class="stat">
<strong>{{ "%.1f"|format(latest.humidity) if latest else "n/a" }}</strong>
<span>latest humidity %</span>
</div>
</div>
{% if readings %}
<div class="chart-wrap">
<canvas id="readingChart"></canvas>
</div>
<table>
<thead>
<tr>
<th>Time</th>
<th>Device</th>
<th>Sensor</th>
<th>Temp C</th>
<th>Humidity %</th>
</tr>
</thead>
<tbody>
{% for reading in readings|reverse %}
<tr>
<td>{{ reading.received_at }}</td>
<td>{{ reading.device_id }}</td>
<td>{{ reading.sensor_type }}</td>
<td>{{ "%.1f"|format(reading.temperature_c) }}</td>
<td>{{ "%.1f"|format(reading.humidity) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const readings = {{ readings | tojson }};
const labels = readings.map((reading) => new Date(reading.received_at).toLocaleString());
const temperatures = readings.map((reading) => reading.temperature_c);
const humidity = readings.map((reading) => reading.humidity);
new Chart(document.getElementById("readingChart"), {
type: "line",
data: {
labels,
datasets: [
{
label: "Temperature (C)",
data: temperatures,
borderColor: "#c2410c",
backgroundColor: "rgba(194, 65, 12, 0.14)",
borderWidth: 3,
fill: true,
tension: 0.25,
pointRadius: 2
},
{
label: "Humidity (%)",
data: humidity,
borderColor: "#0f766e",
backgroundColor: "rgba(15, 118, 110, 0.12)",
borderWidth: 3,
fill: true,
tension: 0.25,
pointRadius: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false
}
});
</script>
{% else %}
<div class="empty">
No readings yet. Your ESP32 should post JSON to <code>/metrics</code>.
</div>
{% endif %}
{% endblock %}