Skocz do zawartości

Pomocna odpowiedź

Napisano

Na forum już wielokrotnie przewijały się tematy stacji pogodowych, ale większość z nich opierała się głównie na analizie warunków panujących na zewnątrz. Postanowiłem nieco wyrwać się z szeregu i moja wersja służy do mierzenia warunków wewnątrz pomieszczenia (szczególnie, że w lato potrafię mieć blisko 40 stopni w domu... nie polecam okien od wschodu bez naturalnych barier). Projekt i artykuł powstały we współpracy z firmą Botland, celem tej współpracy było pokazanie praktycznych przykładów wykorzystania modułów M5Stack.

IMG_1445.thumb.JPG.64a9adc7e27026d68c97a93552062fcb.JPG
Stacja warunków wewnętrznych

Założenia i elektronika

Zakładałem, że stacja powinna być dość niedużych rozmiarów oraz mieć możliwość mierzenia podstawowych parametrów - temperatury, wilgotności i jakości powietrza. Pierwotną koncepcją było użycie modułu PM1.0/2.5/10, aczkolwiek jest on raczej zbędny w przypadku analizy warunków wewnętrznych (raczej nikt nie mierzy parametrów przy otwartym oknie mieszkając w Krakowie).

Ostatecznie padło na M5Stack ENV Pro Unit, który idealnie wpasował się w zapotrzebowanie - wszystko jest w jednym miejscu, włącznie z czujnikiem jakości powietrza (VOC), a biblioteki do układu włożonego do modułu (BME688) posiadają wbudowane funkcje obliczeniowe. Niestety takich funkcji nie posiadał UIFlow, więc musiałem z niego zrezygnować na rzecz kodu.

Jako, że w kartonie leżał wolny moduł M5 Atom Echo postanowiłem go wykorzystać. W ten sposób istnieje potencjalna opcja rozbudowy urządzenia o sygnały dźwiękowe, gdy warunki są niezbyt optymalne. Jako, że sam moduł nie posiada ekranu trzeba było jakiś dołożyć. Padło na moduł OLED od M5Stack, gdyż kolory są zbędne, a jednak kąty widzenia są dość przydatne. Dodatkowo moduł Echo posiada tylko jedno wyprowadzenie GROVE, więc potrzebny był HUB ze złączami. 

Zasada działania

Urządzenie po uruchomieniu inicjuje połączenie z WiFi, połączenie z serwerem NTP oraz moduł wyświetlacza i czujnika. Gdy coś pójdzie nie tak urządzenie wchodzi w tryb błędu krytycznego (z wyjątkiem WiFi). Po inicjacji urządzenie przechodzi w tryb działania, w którym dostępne są 4 ekrany, które zmieniają się co 3 sekundy (lub po naciśnięciu głównego przycisku na module Echo). 

Pierwszy ekran to ekran czasu - wyświetla aktualną godzinę (w trybie online) lub --:-- w trybie offline. To dodatkowa funkcjonalność, którą było łatwo zaimplementować, a zawsze może się przydać. Dodatkowo czas jest synchronizowany z NTP i dzięki poprzedniej wpadce i radzie kolegi @jand w pełni obsługuje automatyczną zmianę czasu.

IMG_1442.thumb.JPG.ba3d1abd78aed3c4268b0e481d4c6fd2.JPG
Ekran czasu

Drugim ekranem jest ekran temperatury. Dzięki bibliotece od producenta uwzględnia on również odchylenia termiczne spowodowane nagrzewaniem się samego układu pomiarowego i płytki, na której jest zamontowany. Temperatura jest wyświetlana w racjonalnej jednostce (tj. stopniach Celsjusza). Problem pojawił się w momencie, gdy na ekranie chciałem umieścić znak stopnia, którego nie ma w czcionkach dostępnych w bibliotece obsługującej wyświetlacz (Adafruit GFX), aczkolwiek był symbol, który jest dość zbliżony (znak 0xF7).

IMG_1443.thumb.JPG.778c8e31a0b16d84c7dc519a9c59caf7.JPG
Ekran temperatury

Trzeci ekran to ekran wilgotności względnej. Jako, że zarówno wilgotność jak i jakość powietrza są wyświetlane w procentach wystąpiłby problem z rozpoznawaniem, która wartość jest aktualnie wyświetlana. W tym celu w prawym górnym rogu ekranu wyświetlana jest informacja o aktualnym czynniku. Dodatkowo taka implementacja pozwoliła dodać znacznik PREP, który jest wyświetlany w momencie dopóki czujnik nie ustabilizuje dokładności pomiaru jakości powietrza (5-30 minut od uruchomienia).

Oczywiście czwarty ekran to ekran jakości powietrza 🙂 

IMG_1451.thumb.JPG.46b449e91f68476d3056cbc4729b22d9.JPG
Ekran wilgotności

Obudowa

Początkową koncepcją było umieszczenie wszystkich elementów w klasycznym "pudełku", aczkolwiek po chwili namysłu stwierdziłem, iż ekran powinien być odchylony w górę, co by mocno skomplikowało projekt. Dodatkowo takie urządzenie zajmowałoby znacznie więcej miejsca niż jest to niezbędne.

Padło więc na konstrukcję złożoną z podstawy i obrotowego ramienia, na którym zamocowany jest wyświetlacz. Dla uproszczenia z przodu i pod spodem obudowy powstało wycięcie, którym poprowadzony jest przewód Grove, niestety posiadane przeze mnie egzemplarze były albo za długie albo za krótkie, więc został on od spodu przymocowany taśmą izolacyjną (by był stabilnie zamocowany i nie odpychał urządzenia w górę). Z przodu (jako, że Atomy mają port USB oraz gniazdo Grove z tej samej strony) zasłoniłem go kawałkiem czarnej taśmy izolacyjnej, by nie szpecił wyglądu.

Moduł atmosferyczny został zamocowany pod wyświetlaczem, gdyż nie warto było tworzyć dla niego osobnego miejsca). Przewody od wyświetlacza i modułu atmosferycznego zostały zwinięte i usztywnione opaską zaciskową do ramienia.

IMG_1446.thumb.JPG.e6a267b03a5179b897b3250f4fd012fd.JPG
Obudowa widziana z boku

Obudowa została zaprojektowana w Fusion360 i wykonana na drukarce 3D. Do złożenia potrzebne były jeszcze 2 śruby M4x12-16 oraz 2 śruby M4x20-25 i jedna śruba M5x35 z podkładkami i nakrętką. Pliki do druku są dostępne tutaj: Stacja Warunków Wewnętrznych.zip

Kod programu

Program został standardowo stworzony z wykorzystaniem PlatformIO, które znacząco upraszcza całą konfigurację warstwy sprzętowej.

Platformio.ini:

[env:m5stack-atom]
platform  = espressif32
board     = m5stack-atom
framework = arduino
monitor_speed = 115200
lib_deps =
    adafruit/Adafruit SH110X @ ^2.1.10
    adafruit/Adafruit GFX Library @ ^1.11.9
    boschsensortec/BME68x Sensor library @ 1.3.40408
    boschsensortec/bsec2 @ 1.10.2610

Plik konfiguracyjny (config.h):

#pragma once

/*
 * ============================================================
 *  Stacja jakości powietrza – plik konfiguracyjny
 *  Sprzęt: M5Stack Atom Echo + OLED Unit 1.3" + ENV Pro (BME688)
 * ============================================================
 *
 *  Wymagane biblioteki (platformio.ini → lib_deps):
 *    adafruit/Adafruit SH110X @ ^2.1.10
 *    adafruit/Adafruit GFX Library @ ^1.11.9
 *    boschsensortec/BME68x Sensor library @ 1.3.40408
 *    boschsensortec/bsec2 @ 1.10.2610
 *
 * ============================================================
 */

// ------------------------------------------------------------
//  Konfiguracja WiFi
// ------------------------------------------------------------
#define WIFI_SSID     "SSID"
#define WIFI_PASSWORD "PASSWD"
#define WIFI_TIMEOUT_MS     10000UL             // Maksymalny czas oczekiwania na połączenie [ms]

// ------------------------------------------------------------
//  Konfiguracja czasu (NTP)
// ------------------------------------------------------------
// Reguła strefy czasowej POSIX dla Warszawy:
//   CET (UTC+1) zimą, CEST (UTC+2) latem
//   Zmiana na czas letni: ostatnia niedziela marca o 2:00
//   Zmiana na czas zimowy: ostatnia niedziela października o 3:00
#define TIMEZONE_RULE       "CET-1CEST,M3.5.0,M10.5.0/3"
#define NTP_SERVER_1        "pool.ntp.org"
#define NTP_SERVER_2        "time.google.com"
#define NTP_SYNC_TIMEOUT_MS 5000UL              // Timeout oczekiwania na synchronizację NTP [ms]

// ------------------------------------------------------------
//  Piny I2C – złącze Grove w Atom Echo (HY2.0-4P)
// ------------------------------------------------------------
#define PIN_SDA             26                  // Linia danych I2C
#define PIN_SCL             32                  // Linia zegarowa I2C
#define I2C_FREQ_HZ         400000UL            // Tryb szybki I2C (Fast Mode, 400 kHz)

// ------------------------------------------------------------
//  Wyświetlacz OLED – M5Stack OLED Unit 1.3" (SH1107, 128×64)
// ------------------------------------------------------------
// Kontroler SH1107 natywnie obsługuje 128 kolumn × 128 wierszy,
// ale moduł M5Stack używa go w konfiguracji 64×128 (portret)
#define OLED_I2C_ADDR       0x3C                // Adres I2C wyświetlacza
#define OLED_NATIVE_W       64                  // Natywna szerokość przed obrotem [px]
#define OLED_NATIVE_H       128                 // Natywna wysokość przed obrotem [px]
#define OLED_ROTATION       1                   // Obrót 90° – tryb poziomy 128×64
#define SCREEN_W            128                 // Szerokość ekranu po obróceniu [px]
#define SCREEN_H            64                  // Wysokość ekranu po obróceniu [px]

// ------------------------------------------------------------
//  Czujnik środowiskowy – M5Stack ENV Pro Unit (BME688)
// ------------------------------------------------------------
// ENV Pro używa pinu SDO podłączonego do VCC → adres wysokiego bitu = 0x77
#define BME688_I2C_ADDR     BME68X_I2C_ADDR_HIGH   // 0x77

// ------------------------------------------------------------
//  Parametry wyświetlania ekranów
// ------------------------------------------------------------
// Czas wyświetlania każdego ekranu [ms]
#define SCREEN_DURATION_MS  3000UL
// Liczba ekranów w rotacji (czas → temperatura → wilgotność → jakość powietrza)
#define NUM_SCREENS         4

// ------------------------------------------------------------
//  Rozmiary czcionek (domyślna czcionka Adafruit GFX)
//  Rozmiary w pikselach: szerokość znaku = 6 × size, wysokość = 8 × size
// ------------------------------------------------------------
#define FONT_VALUE          3   // Duże cyfry wartości pomiarowych (18×24 px)
#define FONT_UNIT           2   // Jednostka przy wartości (12×16 px)
#define FONT_INDICATOR      1   // Małe wskaźniki w rogach (6×8 px)

// ------------------------------------------------------------
//  Układ stref na ekranie
// ------------------------------------------------------------
// Górna strefa zarezerwowana dla wskaźników tekstowych [px]
#define INDICATOR_ZONE_H    10
// Odstęp między wierszami wskaźników (przy FONT_INDICATOR = 1, 8px + 1px)
#define INDICATOR_ROW_H     9

// ------------------------------------------------------------
//  Próg gotowości czujnika IAQ (BSEC2 accuracy: 0–3)
// ------------------------------------------------------------
//  0 = trwa rozgrzewka (PREP)
//  1 = słaba dokładność, ale pierwsze wartości dostępne
//  2 = średnia dokładność
//  3 = pełna kalibracja (stabilny odczyt)
//
// Wskaźnik PREP jest wyświetlany gdy accuracy < IAQ_READY_ACCURACY
#define IAQ_READY_ACCURACY  1

Oraz sam program:

/*
 * ============================================================
 *  Stacja jakości powietrza – plik główny
 *
 *  Wyświetla w rotacji (co 3 s):
 *    [0] Aktualny czas (strefa warszawska, NTP)
 *    [1] Temperatura [°C]
 *    [2] Wilgotność względna [%]  ← wskaźnik H2O
 *    [3] Jakość powietrza [%]     ← wskaźnik jakości powietrza (+PREP gdy nieskalibrowany)
 *
 *  Układ ekranu (128×64 px):
 *    ┌─────────────────────────────┐
 *    │                     [WSKA] │  ← górna strefa wskaźników (10 px)
 *    │                            │
 *    │          WARTOŚĆ      JED. │  ← wyśrodkowana duża wartość + jednostka
 *    │                            │    (jednostka w prawym dolnym rogu wartości)
 *    └─────────────────────────────┘
 * ============================================================
 */

#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <time.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH110X.h>
#include "bsec2.h"
#include "config.h"

// Makro do obliczania długości tablicy (może być zdefiniowane w bsec2.h)
#ifndef ARRAY_LEN
  #define ARRAY_LEN(arr) (sizeof(arr) / sizeof((arr)[0]))
#endif

// ============================================================
//  Obiekty sprzętowe
// ============================================================

// Wyświetlacz OLED – SH1107 inicjalizowany w trybie 64×128 (portret),
// następnie obracany o 90° do trybu poziomego 128×64
Adafruit_SH1107 display(OLED_NATIVE_W, OLED_NATIVE_H, &Wire);

// Czujnik środowiskowy BME688 obsługiwany przez bibliotekę BSEC2
Bsec2 envSensor;

// ============================================================
//  Przycisk główny Atom Echo
// ============================================================

#define PIN_BUTTON          39   // GPIO39 – przycisk przedni Atom Echo (aktywny LOW)
#define DEBOUNCE_MS         50UL // Czas debouncingu przycisku [ms]

// ============================================================
//  Zmienne globalne – dane z czujnika
// ============================================================

float   g_temperature  = 0.0f;   // Temperatura skompensowana przez BSEC2 [°C]
float   g_humidity     = 0.0f;   // Wilgotność skompensowana przez BSEC2 [%]
float   g_iaq          = 500.0f; // Indeks jakości powietrza IAQ (0 = doskonała, 500 = krytyczna)
uint8_t g_iaqAccuracy  = 0;      // Dokładność IAQ: 0=rozgrzewka, 1=słaba, 2=średnia, 3=pełna
bool    g_sensorReady  = false;  // Flaga: czujnik zwrócił pierwsze dane

// ============================================================
//  Zmienne globalne – stan przycisku
// ============================================================

bool     g_lastButtonState = HIGH;  // Poprzedni stan pinu przycisku
uint32_t g_lastDebounceMs  = 0;     // Znacznik czasu ostatniej zmiany stanu [ms]

// ============================================================
//  Zarządzanie ekranami
// ============================================================

uint8_t  g_currentScreen = 0;    // Indeks aktualnie wyświetlanego ekranu (0–NUM_SCREENS-1)
uint32_t g_lastScreenMs  = 0;    // Znacznik czasu ostatniej zmiany ekranu [ms]

// ============================================================
//  Callback BSEC2
// ============================================================

/**
 * Wywoływany przez bibliotekę BSEC2 za każdym razem, gdy dostępne są nowe dane.
 * Aktualizuje globalne zmienne wartościami z czujnika.
 *
 * @param data     Surowe dane z rejestru BME68x
 * @param outputs  Przetworzone wyjścia BSEC2 (temperatura, wilgotność, IAQ itd.)
 * @param bsec     Referencja do obiektu Bsec2 (nieużywana tutaj)
 */
void onBsecData(const bme68xData data, const bsecOutputs outputs, Bsec2 bsec) {
    if (!outputs.nOutputs) return;  // Brak danych – nic nie rób

    for (uint8_t i = 0; i < outputs.nOutputs; i++) {
        const bsecData& out = outputs.output[i];

        switch (out.sensor_id) {
            // Temperatura skompensowana (uwzględnia nagrzewanie płytki)
            case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE:
                g_temperature = out.signal;
                break;

            // Wilgotność skompensowana
            case BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY:
                g_humidity = out.signal;
                break;

            // Wskaźnik jakości powietrza (0 = doskonała, 500 = krytyczna)
            case BSEC_OUTPUT_IAQ:
                g_iaq         = out.signal;
                g_iaqAccuracy = out.accuracy;
                break;

            default:
                break;
        }
    }

    g_sensorReady = true;  // Oznacz że czujnik zwrócił pierwsze dane
}

// ============================================================
//  Funkcje pomocnicze wyświetlacza
// ============================================================

/**
 * Czyści bufor wyświetlacza i ustawia domyślny kolor tekstu.
 * Musi być wywoływana na początku każdej funkcji ekranu.
 */
void clearScreen() {
    display.clearDisplay();
    display.setTextColor(SH110X_WHITE);
    display.setTextWrap(false);
}

/**
 * Rysuje krótki tekst wskaźnika wyrównany do prawej strony ekranu.
 * Wskaźniki umieszczane są w górnej strefie ekranu (INDICATOR_ZONE_H px).
 *
 * @param text  Tekst wskaźnika (np. "H2O", "AIR", "PREP")
 * @param row   Numer wiersza (0 = najwyższy), odstęp INDICATOR_ROW_H px/wiersz
 */
void drawRightIndicator(const char* text, uint8_t row = 0) {
    display.setTextSize(FONT_INDICATOR);

    // Oblicz szerokość tekstu dla wyrównania do prawej
    // Domyślna czcionka GFX: każdy znak ma 6 px szerokości przy size=1
    int16_t textW = (int16_t)(strlen(text) * 6 * FONT_INDICATOR);
    int16_t posX  = SCREEN_W - textW - 1;             // 1 px marginesu od prawej
    int16_t posY  = (int16_t)(row * INDICATOR_ROW_H); // Wiersz w strefie wskaźników

    display.setCursor(posX, posY);
    display.print(text);
}

/**
 * Wyświetla dużą wartość wyśrodkowaną poziomo w strefie poniżej wskaźników.
 * Obok wartości, wyrównana do jej prawego dolnego rogu, pojawia się mniejsza jednostka.
 *
 * Schemat pozycjonowania:
 *
 *   y=INDICATOR_ZONE_H ──────────────────────────────
 *                            23.5 ← wartość (FONT_VALUE)
 *                                °C ← jednostka (FONT_UNIT), dół wartości
 *   y=SCREEN_H ──────────────────────────────────────
 *
 * @param value      Tekst wartości (np. "23.5", "65", "12:34")
 * @param unit       Tekst jednostki (np. "\xB0C", "%"); pusty = bez jednostki
 * @param valueSize  Rozmiar czcionki wartości (domyślnie FONT_VALUE)
 * @param unitSize   Rozmiar czcionki jednostki (domyślnie FONT_UNIT)
 */
void drawCenteredValue(const char* value,
                       const char* unit      = "",
                       uint8_t     valueSize = FONT_VALUE,
                       uint8_t     unitSize  = FONT_UNIT) {

    // Rozmiary znaków domyślnej czcionki Adafruit GFX:
    //   szerokość znaku = 6 × size  (5 px glif + 1 px odstęp)
    //   wysokość znaku  = 8 × size  (7 px glif + 1 px odstęp)
    const int valCharW = 6 * (int)valueSize;
    const int valCharH = 8 * (int)valueSize;
    const int unitCharH = 8 * (int)unitSize;

    const int valLen  = (int)strlen(value);
    const int unitLen = (unit && unit[0]) ? (int)strlen(unit) : 0;

    // Łączna szerokość wartości w pikselach
    const int valW = valLen * valCharW;

    // Wyśrodkowanie wartości na pełnej wysokości ekranu (identycznie jak ekran czasu).
    // Wskaźniki zajmują tylko 10 px u góry i nie nachodzą na tekst startujący od ~y=20.
    const int valueX = (SCREEN_W - valW) / 2;
    const int valueY = (SCREEN_H - valCharH) / 2;

    // Rysuj wartość
    display.setTextSize(valueSize);
    display.setCursor(valueX, valueY);
    display.print(value);

    // Rysuj jednostkę w prawym dolnym rogu wartości (mniejsza czcionka)
    if (unitLen > 0) {
        const int unitW = unitLen * 6 * (int)unitSize;

        int unitX = valueX + valW;  // Zaraz za prawą krawędzią wartości
        int unitY = valueY + valCharH - unitCharH;  // Wyrównanie do dolnej krawędzi wartości

        // Zabezpieczenie – jeśli jednostka wychodzi poza ekran, cofnij w lewo
        if (unitX + unitW > SCREEN_W) {
            unitX = SCREEN_W - unitW;
        }

        display.setTextSize(unitSize);
        display.setCursor(unitX, unitY);
        display.print(unit);
    }
}

// ============================================================
//  Ekrany
// ============================================================

/**
 * Ekran 0: Aktualny czas w strefie warszawskiej.
 * Format "HH:MM", wyśrodkowany na pełnej wysokości ekranu (brak wskaźników).
 * W przypadku braku synchronizacji NTP wyświetla "--:--".
 */
void showTimeScreen() {
    clearScreen();

    struct tm timeinfo;
    char timeStr[6] = "--:--";  // Wartość domyślna gdy brak czasu NTP

    // getLocalTime z timeout 50 ms – nie blokuje pętli głównej
    if (getLocalTime(&timeinfo, 50)) {
        strftime(timeStr, sizeof(timeStr), "%H:%M", &timeinfo);
    }

    // Czas nie ma wskaźników – wyśrodkowanie w pełnej wysokości 64 px
    const int charW  = 6 * FONT_VALUE;
    const int charH  = 8 * FONT_VALUE;
    const int textW  = (int)strlen(timeStr) * charW;

    display.setTextSize(FONT_VALUE);
    display.setCursor((SCREEN_W - textW) / 2, (SCREEN_H - charH) / 2);
    display.print(timeStr);

    display.display();
}

/**
 * Ekran 1: Temperatura powietrza w stopniach Celsjusza.
 * Jednostka "°C" wyświetlana mniejszą czcionką w prawym dolnym rogu wartości.
 */
void showTemperatureScreen() {
    clearScreen();

    char valStr[8];
    snprintf(valStr, sizeof(valStr), "%.1f", g_temperature);  // Jeden decimal

    // Znak stopnia: 0xB0 w kodowaniu ISO 8859-1
    // 0xF7 dla domyślnej czcionki w Adafruit GFX jest zbliżony
    drawCenteredValue(valStr, "\xF7""C");

    display.display();
}

/**
 * Ekran 2: Wilgotność względna powietrza.
 * Wskaźnik "H2O" w prawym górnym rogu informuje o typie pomiaru (para wodna).
 * Jednostka "%" wyświetlana mniejszą czcionką w prawym dolnym rogu wartości.
 */
void showHumidityScreen() {
    clearScreen();

    // Wskaźnik rodzaju pomiaru – wilgotność = para wodna (H₂O)
    drawRightIndicator("H2O", 0);

    char valStr[5];
    snprintf(valStr, sizeof(valStr), "%.0f", g_humidity);  // Zaokrąglenie do pełnych %

    drawCenteredValue(valStr, "%");

    display.display();
}

/**
 * Ekran 3: Jakość powietrza jako procent (100% = doskonała, 0% = krytyczna).
 *
 * Wskaźnik "AIR" (Volatile Organic Compounds) w prawym górnym rogu informuje
 * o typie pomiaru (lotne związki organiczne).
 *
 * Wskaźnik "PREP" jest wyświetlany nad "AIR" dopóki biblioteka BSEC2 nie osiągnie
 * wymaganej dokładności kalibracji (g_iaqAccuracy < IAQ_READY_ACCURACY).
 * Typowy czas stabilizacji: 5–30 minut od pierwszego uruchomienia.
 *
 * Konwersja IAQ → procent jakości:
 *   IAQ 0   = doskonała jakość → 100%
 *   IAQ 500 = krytyczna jakość → 0%
 */
void showAirQualityScreen() {
    clearScreen();

    if (g_iaqAccuracy < IAQ_READY_ACCURACY) {
        // Czujnik w fazie rozgrzewki – pokaż PREP nad AIR
        drawRightIndicator("PREP", 0);
        drawRightIndicator("AIR",  1);
    } else {
        // Czujnik skalibrowany – tylko AIR
        drawRightIndicator("AIR", 0);
    }

    // Przelicz IAQ (0–500, niższy = lepszy) na procent jakości (wyższy = lepszy)
    int aqPct = constrain((int)(100.0f - (g_iaq / 500.0f * 100.0f)), 0, 100);

    char valStr[4];
    snprintf(valStr, sizeof(valStr), "%d", aqPct);

    drawCenteredValue(valStr, "%");

    display.display();
}

// ============================================================
//  Ekran startowy / komunikaty
// ============================================================

/**
 * Wyświetla komunikat tekstowy podczas inicjalizacji, wyśrodkowany na ekranie.
 *
 * Każda linia oddzielona '\n' jest wyśrodkowana poziomo osobno.
 * Cały blok linii jest wyśrodkowany pionowo na ekranie.
 *
 * @param msg       Tekst komunikatu (może zawierać '\n')
 * @param textSize  Rozmiar czcionki GFX (domyślnie 1 → 6×8 px / znak)
 */
void showBootMessage(const char* msg, uint8_t textSize = 1) {
    // Wymiary pojedynczego znaku dla wybranego rozmiaru czcionki
    const int charW  = 6 * (int)textSize;   // Szerokość znaku [px]
    const int charH  = 8 * (int)textSize;   // Wysokość znaku [px]
    const int lineH  = charH + 2;           // Wysokość wiersza (glif + 2 px odstępu)

    // Rozdziel wiadomość na linie (maksymalnie 8)
    const int MAX_LINES = 8;
    char      buf[128];
    strncpy(buf, msg, sizeof(buf) - 1);
    buf[sizeof(buf) - 1] = '\0';

    const char* lines[MAX_LINES];
    int         numLines = 0;

    char* token = strtok(buf, "\n");
    while (token && numLines < MAX_LINES) {
        lines[numLines++] = token;
        token = strtok(nullptr, "\n");
    }

    // Wyznacz pionowy punkt startowy całego bloku tekstu
    const int totalH = numLines * lineH - 2;              // -2: brak dolnego odstępu po ostatnim wierszu
    int       startY = (SCREEN_H - totalH) / 2;
    if (startY < 0) startY = 0;

    display.clearDisplay();
    display.setTextColor(SH110X_WHITE);
    display.setTextWrap(false);
    display.setTextSize(textSize);

    for (int i = 0; i < numLines; i++) {
        // Wyśrodkuj każdą linię poziomo
        int lineW = (int)strlen(lines[i]) * charW;
        int posX  = (SCREEN_W - lineW) / 2;
        if (posX < 0) posX = 0;

        display.setCursor(posX, startY + i * lineH);
        display.print(lines[i]);
    }

    display.display();
}

// ============================================================
//  Inicjalizacja WiFi i synchronizacja NTP
// ============================================================

/**
 * Nawiązuje połączenie z siecią WiFi i synchronizuje zegar systemowy
 * z serwerami NTP. W razie braku WiFi urządzenie pracuje bez czasu
 * (wyświetla "--:--" na ekranie czasu).
 */
void initWiFiAndNTP() {
    showBootMessage("Laczenie z WiFi...");
    Serial.println("[WiFi] Laczenie z siecia: " WIFI_SSID);

    WiFi.mode(WIFI_STA);
    WiFi.begin(WIFI_SSID, WIFI_PASSWORD);

    uint32_t startMs = millis();
    while (WiFi.status() != WL_CONNECTED && millis() - startMs < WIFI_TIMEOUT_MS) {
        delay(250);
    }

    if (WiFi.status() == WL_CONNECTED) {
        Serial.println("[WiFi] Polaczono. IP: " + WiFi.localIP().toString());
        showBootMessage("WiFi OK!\nSync NTP...");

        // Ustaw strefę czasową i serwery NTP
        configTzTime(TIMEZONE_RULE, NTP_SERVER_1, NTP_SERVER_2);

        // Czekaj na pierwsze udane pobranie czasu (maks. NTP_SYNC_TIMEOUT_MS)
        struct tm timeinfo;
        startMs = millis();
        while (!getLocalTime(&timeinfo, 100) && millis() - startMs < NTP_SYNC_TIMEOUT_MS) {
            delay(200);
        }

        if (getLocalTime(&timeinfo, 100)) {
            char buf[32];
            strftime(buf, sizeof(buf), "%H:%M:%S", &timeinfo);
            Serial.print("[NTP] Czas zsynchronizowany: ");
            Serial.println(buf);
            showBootMessage("NTP OK!");
        } else {
            Serial.println("[NTP] Timeout synchronizacji!");
            showBootMessage("NTP: brak czasu\n(offline)");
        }

    } else {
        Serial.println("[WiFi] Brak polaczenia – praca offline.");
        showBootMessage("Brak WiFi!\nPraca offline.");
    }

    delay(800);
}

// ============================================================
//  Inicjalizacja czujnika BME688 z biblioteką BSEC2
// ============================================================

/**
 * Inicjalizuje czujnik BME688 przez bibliotekę BSEC2.
 * Subskrybuje trzy wyjścia algorytmu:
 *   – skompensowaną temperaturę (z korekcją nagrzewania płytki)
 *   – skompensowaną wilgotność
 *   – wskaźnik IAQ z informacją o dokładności
 *
 * Tryb próbkowania: LP (Low Power) = co ~3 sekundy.
 * W razie błędu inicjalizacji program zatrzymuje się w pętli nieskończonej.
 */
void initBsec() {
    showBootMessage("Init BME688...");
    Serial.println("[BSEC] Inicjalizacja czujnika BME688...");

    // Inicjalizuj sensor pod adresem I2C 0x77 (ENV Pro używa pinu SDO=VCC)
    if (!envSensor.begin(BME688_I2C_ADDR, Wire)) {
        Serial.printf("[BSEC] BLAD begin(): status=%d, sensor=%d\n",
                      envSensor.status, envSensor.sensor.status);
        showBootMessage("BLAD!\nBME688 nie\nznaleziony.");
        while (true) { delay(1000); }  // Zatrzymanie – błąd krytyczny
    }

    // Lista wyjść BSEC2, które chcemy odbierać
    bsecSensor sensorOutputs[] = {
        BSEC_OUTPUT_IAQ,
        BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_TEMPERATURE,
        BSEC_OUTPUT_SENSOR_HEAT_COMPENSATED_HUMIDITY,
    };

    // Włącz subskrypcję w trybie LP (Low Power, ~3 s / pomiar).
    // BSEC_OUTPUT_IAQ dostępny jest wyłącznie w trybie LP/ULP – nie w SCAN.
    // Tryb SCAN służy do skanowania wielopunktowego grzałki i klasyfikacji gazów
    // (BSEC_OUTPUT_GAS_ESTIMATE_1..4), a nie do pomiaru IAQ.
    //
    // Status > 0 to ostrzeżenie (np. +14 = BSEC_W_SU_SAMPLINTVLINTEGERMULT) –
    // operacja zakończyła się sukcesem, można kontynuować.
    // Status < 0 to błąd krytyczny – należy przerwać działanie.
    envSensor.updateSubscription(sensorOutputs, ARRAY_LEN(sensorOutputs),
                                 BSEC_SAMPLE_RATE_LP);

    if (envSensor.status < BSEC_OK) {
        Serial.printf("[BSEC] BLAD updateSubscription(): status=%d\n", envSensor.status);
        showBootMessage("BLAD!\nBSEC subskrypcja.");
        while (true) { delay(1000); }  // Zatrzymanie – błąd krytyczny
    }
    if (envSensor.status > BSEC_OK) {
        Serial.printf("[BSEC] Ostrzezenie updateSubscription(): status=%d\n", envSensor.status);
    }

    // Zarejestruj callback wywoływany przy nowych danych
    envSensor.attachCallback(onBsecData);

    Serial.println("[BSEC] Inicjalizacja OK. Czujnik w fazie rozgrzewki.");
    showBootMessage("BME688 OK!\nRozgrzewka...");
    delay(800);
}

// ============================================================
//  Setup
// ============================================================

void setup() {
    Serial.begin(115200);
    Serial.println("\n[SYS] Start stacji jakosci powietrza.");

    // --- Inicjalizacja przycisku ---
    pinMode(PIN_BUTTON, INPUT_PULLUP);  // GPIO39 – wejście z podciąganiem do VCC

    // --- Inicjalizacja magistrali I2C ---
    Wire.begin(PIN_SDA, PIN_SCL);
    Wire.setClock(I2C_FREQ_HZ);

    // --- Inicjalizacja wyświetlacza OLED ---
    // Parametr reset=true: biblioteka wykona reset jeśli pin RST jest podłączony,
    // w przeciwnym razie pomija ten krok – bezpieczne gdy RST nie jest podłączony.
    if (!display.begin(OLED_I2C_ADDR, true)) {
        Serial.println("[OLED] BLAD: Nie znaleziono wyswietlacza! Sprawdz I2C.");
        while (true) { delay(1000); }  // Zatrzymanie – błąd krytyczny
    }

    display.setRotation(OLED_ROTATION);  // Obrót 90° → tryb poziomy 128×64
    display.setTextWrap(false);
    display.clearDisplay();
    display.display();

    Serial.println("[OLED] Wyswietlacz zainicjalizowany (128x64, SH1107).");

    // Ekran powitalny
    showBootMessage("Stacja\nJakosci\nPowietrza", 2);
    delay(1200);

    // --- WiFi + NTP ---
    initWiFiAndNTP();

    // --- Czujnik BME688 (BSEC2) ---
    initBsec();

    // --- Gotowy do pracy ---
    g_lastScreenMs = millis();
    Serial.println("[SYS] Inicjalizacja zakonczona. Uruchamianie petli.");
    showBootMessage("Gotowy!");
    delay(400);
}

// ============================================================
//  Loop
// ============================================================

void loop() {
    // --- Obsługa przycisku – zmiana ekranu na żądanie ---
    // Wykrywanie opadającego zbocza sygnału (HIGH→LOW) z debouncingiem.
    // Krótkie naciśnięcie przycisku natychmiast przełącza na następny ekran
    // i resetuje timer automatycznej rotacji.
    bool buttonState = digitalRead(PIN_BUTTON);
    if (buttonState == LOW && g_lastButtonState == HIGH) {
        uint32_t nowDebounce = millis();
        if (nowDebounce - g_lastDebounceMs > DEBOUNCE_MS) {
            g_lastDebounceMs = nowDebounce;
            g_currentScreen  = (g_currentScreen + 1) % NUM_SCREENS;
            g_lastScreenMs   = nowDebounce;  // Resetuj timer automatycznej rotacji
        }
    }
    g_lastButtonState = buttonState;

    // --- Obsługa BSEC2 ---
    // envSensor.run() sprawdza wewnętrzny harmonogram i gdy nadejdzie czas pomiaru:
    //   1. Konfiguruje grzałkę BME688
    //   2. Uruchamia pomiar
    //   3. Przetwarza dane algorytmem BSEC
    //   4. Wywołuje zarejestrowany callback (onBsecData)
    if (!envSensor.run()) {
        // Loguj błędy do konsoli (nie przerywaj działania)
        if (envSensor.status < BSEC_OK) {
            Serial.printf("[BSEC] Blad biblioteki: %d\n", envSensor.status);
        }
        if (envSensor.sensor.status < BME68X_OK) {
            Serial.printf("[BSEC] Blad czujnika: %d\n", envSensor.sensor.status);
        }
    }

    // --- Zmiana aktywnego ekranu co SCREEN_DURATION_MS ---
    uint32_t nowMs = millis();
    if (nowMs - g_lastScreenMs >= SCREEN_DURATION_MS) {
        g_lastScreenMs = nowMs;
        g_currentScreen = (g_currentScreen + 1) % NUM_SCREENS;
    }

    // --- Wyświetl aktualny ekran ---
    switch (g_currentScreen) {
        case 0: showTimeScreen();        break;  // Czas
        case 1: showTemperatureScreen(); break;  // Temperatura
        case 2: showHumidityScreen();    break;  // Wilgotność
        case 3: showAirQualityScreen();  break;  // Jakość powietrza
        default: break;
    }

    // Krótkie opóźnienie – zapobiega zalewaniu I2C i CPU przy braku pracy
    delay(50);
}

Czy powinienem rozdzielić plik na mniejsze? Zdecydowanie tak... Ale kto mi zabroni upchać wszystko w jednym miejscu? Szczególnie, że jest to nieco bardziej przyjazne dla początkujących. I tak, kod był wygenerowany przez Claude Sonnet Extended 🙂 Niestety tym razem nie obyło się bez kilkunastu poprawek.

Podsumowanie

Czy stacja działa? Tak. Czy pokazuje prawidłowe parametry? Jak na projekt hobbystyczny jest raczej prawidłowo (to nie jest profesjonalny sprzęt pomiarowy). Czy wygląda dobrze? To już pozostawiam do indywidualnej oceny. Tymczasem możecie zbudować podobny projekt, a ja szukam rozwiązania na saharę (37% wilgotności to dość nisko) w moim pomieszczeniu roboczym 🙂 Chociaż w sumie w ten sposób nie muszę zbyt mocno przejmować się wilgotnością filamentu do drukarki 3D.

  • Lubię! 1

Podoba Ci się ten projekt? Zostaw pozytywny komentarz i daj znać autorowi, że zbudował coś fajnego!

Masz uwagi? Napisz kulturalnie co warto zmienić. Doceń pracę autora nad konstrukcją oraz opisem.

Bądź aktywny - zaloguj się lub utwórz konto!

Tylko zarejestrowani użytkownicy mogą komentować zawartość tej strony

Utwórz konto w ~20 sekund!

Zarejestruj nowe konto, to proste!

Zarejestruj się »

Zaloguj się

Posiadasz własne konto? Użyj go!

Zaloguj się »
×
×
  • Utwórz nowe...