i2c fixes + server improvements
This commit is contained in:
+50
-20
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import click
|
||||
from flask import Flask, jsonify, redirect, render_template, request, url_for
|
||||
@@ -10,7 +11,6 @@ from flask_login import (
|
||||
login_user,
|
||||
logout_user,
|
||||
)
|
||||
from sqlalchemy import func
|
||||
|
||||
from models import Sensor, SensorReadout, User, db
|
||||
|
||||
@@ -137,37 +137,67 @@ def create_app():
|
||||
def api_sensor_readouts_hourly(sensor_id):
|
||||
type_ = request.args.get("type")
|
||||
range_ = request.args.get("range", "all")
|
||||
# Frontend wysyła strefę czasową przeglądarki (np. Europe/Warsaw),
|
||||
# żeby "today" znaczyło "od północy czasu lokalnego", a nie 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 = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
now_local = now_utc.astimezone(local_tz)
|
||||
if range_ == "today":
|
||||
cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
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 - timedelta(days=7)
|
||||
cutoff = (now_utc - timedelta(days=7)).replace(tzinfo=None)
|
||||
elif range_ == "month":
|
||||
cutoff = now - timedelta(days=30)
|
||||
cutoff = (now_utc - timedelta(days=30)).replace(tzinfo=None)
|
||||
elif range_ == "all":
|
||||
cutoff = None
|
||||
else:
|
||||
return jsonify({"error": "range must be one of: today, week, month, all"}), 400
|
||||
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)
|
||||
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.group_by("hour")
|
||||
.order_by("hour")
|
||||
.all()
|
||||
)
|
||||
return jsonify(
|
||||
[{"timestamp": r.hour, "value": round(r.avg_value, 2)} for r in rows]
|
||||
)
|
||||
rows = query.order_by(SensorReadout.timestamp).all()
|
||||
# Zakresy today i 24h: wszystkie pomiary jako punkty, bez uśredniania.
|
||||
if range_ in ("today", "24h"):
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"timestamp": ts.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"value": value,
|
||||
}
|
||||
for ts, value in rows
|
||||
]
|
||||
)
|
||||
# Pozostałe zakresy: średnia godzinowa liczona po pełnej godzinie
|
||||
# lokalnej (poprawne też przy zmianie DST), znacznik to początek
|
||||
# godziny w UTC (frontend dokleja "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)
|
||||
|
||||
# --- API: device writes (intentionally public — called by devices/sensors) ---
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<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>
|
||||
@@ -44,6 +45,7 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -74,8 +76,13 @@
|
||||
const refreshButton = document.getElementById('refresh-button');
|
||||
const readoutsSection = document.getElementById('readouts-section');
|
||||
const readoutsBody = document.getElementById('readouts-body');
|
||||
const chartInfo = document.getElementById('chart-info');
|
||||
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) {
|
||||
@@ -87,12 +94,38 @@
|
||||
|
||||
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) {
|
||||
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' });
|
||||
return d.toLocaleString('pl-PL', { timeZone: userTz, hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function chartInfoText(range) {
|
||||
if (range === 'today') return `Raw measurements since local midnight (${userTz}) — no averaging.`;
|
||||
if (range === '24h') return 'Raw measurements from the last 24 hours — no averaging.';
|
||||
const period = 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}).`;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -143,11 +176,14 @@
|
||||
|
||||
try {
|
||||
// fetch hourly data for chart
|
||||
const hourly = await fetchJSON(`/api/sensors/${sensorId}/readouts/hourly?type=${type}&range=${range}`);
|
||||
const hourly = await fetchJSON(`/api/sensors/${sensorId}/readouts/hourly?type=${encodeURIComponent(type)}&range=${encodeURIComponent(range)}&tz=${encodeURIComponent(userTz)}`);
|
||||
if (!hourly.length) throw new Error('no data for this range');
|
||||
|
||||
const labels = hourly.map(r => formatChartLabel(r.timestamp));
|
||||
const values = hourly.map(r => r.value);
|
||||
const values = hourly.map(r => displayValue(type, r.value));
|
||||
// Przy zakresach today/24h punktów jest dużo (~1/min),
|
||||
// więc mniejsze markery żeby wykres był czytelny.
|
||||
const dense = range === 'today' || range === '24h';
|
||||
|
||||
if (chart) chart.destroy();
|
||||
const ctx = document.getElementById('chart').getContext('2d');
|
||||
@@ -164,15 +200,15 @@
|
||||
pointBorderColor: '#55ffff',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 3,
|
||||
pointRadius: dense ? 1.5 : 3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { title: { display: true, text: 'Time (hourly avg)' } },
|
||||
y: { title: { display: true, text: type } }
|
||||
x: { title: { display: true, text: dense ? 'Time' : 'Time (hourly avg)' } },
|
||||
y: { title: { display: true, text: yAxisTitle(type) } }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -180,9 +216,11 @@
|
||||
// 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);
|
||||
} catch (err) {
|
||||
chartError.textContent = '! Chart error: ' + err.message;
|
||||
chartError.classList.remove('hidden');
|
||||
chartInfo.textContent = '';
|
||||
chartSection.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
@@ -199,7 +237,7 @@
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user