diff --git a/server/app.py b/server/app.py index 25cd5c9..c968e2f 100644 --- a/server/app.py +++ b/server/app.py @@ -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) --- diff --git a/server/templates/sensors.html b/server/templates/sensors.html index 0a239b6..0271a96 100644 --- a/server/templates/sensors.html +++ b/server/templates/sensors.html @@ -35,6 +35,7 @@ Range: Today + Last 24h Week Month All @@ -44,6 +45,7 @@ + @@ -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 = `${formatTimestamp(r.timestamp)}${r.value}`; + tr.innerHTML = `${formatTimestamp(r.timestamp)}${formatTableValue(type, r.value)}`; readoutsBody.appendChild(tr); }); readoutsSection.classList.remove('hidden'); diff --git a/thermo_sensor/main/CMakeLists.txt b/thermo_sensor/main/CMakeLists.txt index e661af0..c10f737 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" "ygg_http_req.c" "ygg_state.c" "ygg_sh1106.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") diff --git a/thermo_sensor/main/thermo_sensor.c b/thermo_sensor/main/thermo_sensor.c index 5a14e3d..3ed6c73 100644 --- a/thermo_sensor/main/thermo_sensor.c +++ b/thermo_sensor/main/thermo_sensor.c @@ -3,9 +3,11 @@ #include #include #include +#include #include "nvs_flash.h" #include "esp_http_server.h" +#include "i2cdev.h" #include "ygg_http_server.h" #include "ygg_http_routes.h" @@ -15,6 +17,7 @@ #include "ygg_http_req.h" #include "ygg_state.h" #include "ygg_sh1106.h" +#include "ygg_lock.h" #include "lvgl.h" @@ -25,7 +28,10 @@ bmp280_t dev_bmp_280; void lvgl_task(void *pvParameters) { while (1) { - lv_timer_handler(); + if (ygg_lvgl_lock_take(pdMS_TO_TICKS(100))) { + lv_timer_handler(); + ygg_lvgl_lock_give(); + } vTaskDelay(pdMS_TO_TICKS(10)); } } @@ -34,19 +40,31 @@ 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); + 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]; - 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); - vTaskDelay(pdMS_TO_TICKS(10000)); + 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)); } } @@ -77,6 +95,12 @@ 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); diff --git a/thermo_sensor/main/ygg_bmp280.c b/thermo_sensor/main/ygg_bmp280.c index fa37928..f37d062 100644 --- a/thermo_sensor/main/ygg_bmp280.c +++ b/thermo_sensor/main/ygg_bmp280.c @@ -1,29 +1,52 @@ #include #include #include +#include #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) { - ESP_ERROR_CHECK(i2cdev_init()); + // 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(¶ms); 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, ¶ms)); 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; @@ -32,4 +55,5 @@ void ygg_bmp280_read_sensor(bmp280_t *dev) { printf(", Humidity: %.2f\n", humidity); else printf("\n"); + return ESP_OK; } diff --git a/thermo_sensor/main/ygg_bmp280.h b/thermo_sensor/main/ygg_bmp280.h index 93fb73a..6b52087 100644 --- a/thermo_sensor/main/ygg_bmp280.h +++ b/thermo_sensor/main/ygg_bmp280.h @@ -2,11 +2,13 @@ #define YGG_BMP280_H #include +#include #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 diff --git a/thermo_sensor/main/ygg_lock.c b/thermo_sensor/main/ygg_lock.c new file mode 100644 index 0000000..7dd6d24 --- /dev/null +++ b/thermo_sensor/main/ygg_lock.c @@ -0,0 +1,40 @@ +#include "ygg_lock.h" +#include + +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); + } +} diff --git a/thermo_sensor/main/ygg_lock.h b/thermo_sensor/main/ygg_lock.h new file mode 100644 index 0000000..17244c7 --- /dev/null +++ b/thermo_sensor/main/ygg_lock.h @@ -0,0 +1,15 @@ +#ifndef YGG_LOCK_H +#define YGG_LOCK_H + +#include +#include + +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 diff --git a/thermo_sensor/main/ygg_sh1106.c b/thermo_sensor/main/ygg_sh1106.c index f5fcb30..17a32b2 100644 --- a/thermo_sensor/main/ygg_sh1106.c +++ b/thermo_sensor/main/ygg_sh1106.c @@ -6,14 +6,19 @@ #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 400000 +#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 19 -#define SH1106_I2C_SCL_GPIO 23 +#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]; @@ -51,8 +56,21 @@ static void example_lvgl_flush_cb(lv_display_t *disp, const lv_area_t *area, uin } } } - // pass the draw buffer to the driver - esp_lcd_panel_draw_bitmap(panel_handle, x1, y1, x2 + 1, y2 + 1, oled_buffer); + // 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); } @@ -71,7 +89,8 @@ void ygg_init_tick_cb() { } lv_display_t* ygg_sh1106_init() { - ESP_ERROR_CHECK(i2cdev_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)); @@ -102,6 +121,9 @@ lv_display_t* ygg_sh1106_init() { // 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)); @@ -149,9 +171,16 @@ lv_display_t* ygg_sh1106_init() { } 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(); }