commit 15c84487ec044fdabf5afe90621a6cc0f7144c00 Author: Taras Syvash Date: Sun Jan 18 21:33:13 2026 +0200 Initial TableWatch implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..c10b377 --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# ElinkInformer / TableWatch + +E‑paper інформер на ESP32‑S3 (WeActStudio 2.9" tri‑color). Показує час великими цифрами, Wi‑Fi, батарею, погоду та дані з Notion. Є веб‑інтерфейс для керування інтервалами, а також оновлення прошивки через браузер. + +## Функції +- великий годинник у стилі flip‑card +- статус‑бар: Wi‑Fi, батарея, погода, час +- таблиця Notion (5 записів) з полями `Name` + `Data/Date` +- чергування екранів (час/Notion) з налаштовуваними інтервалами +- Wi‑Fi portal налаштування (SSID `ElinkInformer-Setup`) +- веб‑інтерфейс з Bootstrap‑темою (mDNS `tablewatch.local`) +- оновлення прошивки через web upload + +## Обладнання +- ESP32‑S3 (board: `esp32-s3-zero` у PlatformIO) +- WeActStudio 2.9" E‑Paper (GxEPD2_290_C90c) + +## Піни дисплея +У `src/config.h`: +- `PIN_BUSY = 6` +- `PIN_RST = 7` +- `PIN_DC = 8` +- `PIN_CS = 9` +- `PIN_SCK = 10` +- `PIN_MOSI = 11` + +## Батарея / ADC +Налаштування в `src/config.h`: +- `kBatteryAdcPin` (за замовчуванням `A0`) +- `kBatteryAdcRefMv` (3300 мВ) +- `kBatteryDividerNum`/`kBatteryDividerDen` (коефіцієнт подільника) +- `kBatteryMvMin`/`kBatteryMvMax` (межі 0–100%) + +Калібруйте `kBatteryMvMin`/`kBatteryMvMax` під вашу батарею. + +## Погода +- Джерело: Open‑Meteo (`kWeatherUrl`) +- Відображення: `Дніпро +3C дощ` (українські назви) + +## Notion +- Використовується Notion API для читання бази. +- Потрібні поля в базі: + - `Name` (title) + - `Data` або `Date` або `Дата` (date) +- ID бази береться з URL (параметр `v` не потрібен). +- Токен інтеграції збережений у `kNotionToken`. + +**Важливо:** інтеграцію потрібно додати до доступу бази (Share → додати інтеграцію). + +## Веб‑інтерфейс +Доступ за адресою `http://tablewatch.local/` (mDNS) або IP. + +Можна налаштувати: +- час показу екрана годинника +- час показу екрана Notion +- інтервал мережевої синхронізації + +Кнопки: +- Оновлення прошивки (завантаження `.bin`) +- Перезавантаження + +## Оновлення прошивки (Web) +Використовуйте файл `.bin` після збірки: +```bash +pio run +``` +Файл з’явиться у `.pio/build/esp32-s3-devkitm-1/firmware.bin`. + +## Збірка та прошивка (USB) +```bash +pio run +pio run -t upload +``` + +## Налаштування Wi‑Fi +Після старту пристрій піднімає portal: +- SSID: `ElinkInformer-Setup` +- Відкрити `192.168.4.1` + +## Налаштування інтервалів +Параметри зберігаються в RTC: +- `gClockScreenSec` — екран часу +- `gTableScreenSec` — екран Notion +- `gNetSyncSec` — мережевий синх + +Дефолти в `src/config.h`: +- `kDefaultClockScreenSec` +- `kDefaultTableScreenSec` +- `kDefaultNetSyncSec` + +## Структура проєкту +- `src/config.h` — всі константи/налаштування +- `src/app_state.*` — стан та RTC змінні +- `src/display.*` — рендеринг дисплея +- `src/network.*` — Wi‑Fi, NTP, погода, Notion +- `src/web.*` — веб‑інтерфейс та OTA‑upload через web +- `src/hardware.*` — ADC/батарея +- `src/main.cpp` — ініціалізація та головний цикл + +## Безпека +Токен Notion збережений у прошивці у відкритому вигляді. Для продакшна бажано використовувати проксі/сервер або секрети поза прошивкою. diff --git a/boards/esp32-s3-zero.json b/boards/esp32-s3-zero.json new file mode 100644 index 0000000..c72c236 --- /dev/null +++ b/boards/esp32-s3-zero.json @@ -0,0 +1,54 @@ +{ + "build": { + "arduino": { + "ldscript": "esp32s3_out.ld", + "partitions": "default.csv" + }, + "core": "esp32", + "extra_flags": [ + "-DARDUINO_ESP32S3_DEV", + "-DARDUINO_USB_MODE=1", + "-DARDUINO_RUNNING_CORE=1", + "-DARDUINO_EVENT_RUNNING_CORE=1" + ], + "f_cpu": "240000000L", + "f_flash": "80000000L", + "flash_mode": "qio", + "hwids": [ + [ + "0x303A", + "0x1001" + ] + ], + "mcu": "esp32s3", + "variant": "esp32s3" + }, + "connectivity": [ + "bluetooth", + "wifi" + ], + "debug": { + "default_tool": "esp-builtin", + "onboard_tools": [ + "esp-builtin" + ], + "openocd_target": "esp32s3.cfg" + }, + "frameworks": [ + "arduino", + "espidf" + ], + "name": "ESP32-S3-Zero (4MB)", + "url": "https://www.espressif.com/en/products/socs/esp32-s3", + "vendor": "Generic", + "hardware": { + "flash_size": "4MB" + }, + "upload": { + "flash_size": "4MB", + "maximum_ram_size": 327680, + "maximum_size": 4194304, + "require_upload_port": true, + "speed": 460800 + } +} diff --git a/include/README b/include/README new file mode 100644 index 0000000..49819c0 --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..9379397 --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..e274e71 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,14 @@ +[env:esp32-s3-devkitm-1] +platform = espressif32 +board = esp32-s3-zero +framework = arduino +board_build.flash_size = 4MB +board_build.partitions = default.csv +lib_deps = + ZinggJM/GxEPD2@^1.5.5 + tzapu/WiFiManager@^2.0.17 + bblanchon/ArduinoJson@^7.2.0 + olikraus/U8g2_for_Adafruit_GFX@^1.8.0 +build_flags = + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_USB_MODE=1 diff --git a/src/app_state.cpp b/src/app_state.cpp new file mode 100644 index 0000000..8cb3ea7 --- /dev/null +++ b/src/app_state.cpp @@ -0,0 +1,13 @@ +#include "app_state.h" + +RTC_DATA_ATTR time_t gLastNetSyncEpoch = 0; +RTC_DATA_ATTR char gWeatherText[64] = "Дніпро --C"; +RTC_DATA_ATTR int gNotionCount = 0; +RTC_DATA_ATTR char gNotionNames[kNotionMaxItems][kNotionNameLen]; +RTC_DATA_ATTR char gNotionDates[kNotionMaxItems][kNotionDateLen]; +RTC_DATA_ATTR uint32_t gClockScreenSec = kDefaultClockScreenSec; +RTC_DATA_ATTR uint32_t gTableScreenSec = kDefaultTableScreenSec; +RTC_DATA_ATTR uint32_t gNetSyncSec = kDefaultNetSyncSec; + +uint32_t gLastScreenSwitchMs = 0; +bool gShowTable = false; diff --git a/src/app_state.h b/src/app_state.h new file mode 100644 index 0000000..40cc053 --- /dev/null +++ b/src/app_state.h @@ -0,0 +1,16 @@ +#pragma once + +#include +#include "config.h" + +extern RTC_DATA_ATTR time_t gLastNetSyncEpoch; +extern RTC_DATA_ATTR char gWeatherText[64]; +extern RTC_DATA_ATTR int gNotionCount; +extern RTC_DATA_ATTR char gNotionNames[kNotionMaxItems][kNotionNameLen]; +extern RTC_DATA_ATTR char gNotionDates[kNotionMaxItems][kNotionDateLen]; +extern RTC_DATA_ATTR uint32_t gClockScreenSec; +extern RTC_DATA_ATTR uint32_t gTableScreenSec; +extern RTC_DATA_ATTR uint32_t gNetSyncSec; + +extern uint32_t gLastScreenSwitchMs; +extern bool gShowTable; diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..36bdb5e --- /dev/null +++ b/src/config.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +// WeActStudio 2.9" E-Paper wiring +constexpr int PIN_BUSY = 6; +constexpr int PIN_RST = 7; +constexpr int PIN_DC = 8; +constexpr int PIN_CS = 9; +constexpr int PIN_SCK = 10; +constexpr int PIN_MOSI = 11; + +constexpr char kPortalSsid[] = "ElinkInformer-Setup"; +constexpr uint32_t kPortalTimeoutSec = 180; +constexpr int kStatusBarHeight = 22; + +constexpr int kBatteryAdcPin = A0; +constexpr int kBatteryAdcRefMv = 3300; +constexpr int kBatteryAdcMax = 4095; +constexpr int kBatteryDividerNum = 2; +constexpr int kBatteryDividerDen = 1; +constexpr int kBatteryMvMin = 3200; +constexpr int kBatteryMvMax = 4200; + +constexpr int kWifiConnectAttempts = 5; +constexpr uint32_t kWifiConnectTimeoutMs = 5000; + +constexpr uint32_t kDefaultNetSyncSec = 10 * 60; +constexpr uint32_t kWakeIntervalSec = 120; +constexpr uint32_t kDefaultClockScreenSec = 180; +constexpr uint32_t kDefaultTableScreenSec = 180; + +constexpr bool kDeepSleepEnabled = false; +constexpr bool kWebEnabled = true; +constexpr char kMdnsHost[] = "tablewatch"; + +constexpr time_t kMinValidEpoch = 1700000000; +constexpr char kTimeZone[] = "EET-2EEST,M3.5.0/3,M10.5.0/4"; +constexpr char kWeatherUrl[] = + "https://api.open-meteo.com/v1/forecast?latitude=48.45&longitude=34.98¤t=temperature_2m,weather_code&timezone=Europe%2FKyiv"; + +constexpr char kNotionDatabaseId[] = "2eca519844538080a06bc10a2505aa6e"; +constexpr char kNotionToken[] = "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"; +constexpr int kNotionMaxItems = 5; +constexpr size_t kNotionNameLen = 48; +constexpr size_t kNotionDateLen = 16; diff --git a/src/display.cpp b/src/display.cpp new file mode 100644 index 0000000..a586c78 --- /dev/null +++ b/src/display.cpp @@ -0,0 +1,335 @@ +#include "display.h" + +#include +#include +#include +#include +#include + +#include "app_state.h" +#include "config.h" +#include "network.h" + +GxEPD2_3C display( + GxEPD2_290_C90c(PIN_CS, PIN_DC, PIN_RST, PIN_BUSY) +); +U8G2_FOR_ADAFRUIT_GFX u8g2Fonts; + +void initDisplay() { + SPI.begin(PIN_SCK, -1, PIN_MOSI, PIN_CS); + display.init(115200); + u8g2Fonts.begin(display); +} + +int utf8CharLen(char c) { + uint8_t uc = static_cast(c); + if ((uc & 0x80) == 0) { + return 1; + } + if ((uc & 0xE0) == 0xC0) { + return 2; + } + if ((uc & 0xF0) == 0xE0) { + return 3; + } + if ((uc & 0xF8) == 0xF0) { + return 4; + } + return 1; +} + +void truncateUtf8ToWidth(const char *src, int maxWidth, char *out, size_t outSize) { + if (!src || outSize == 0) { + return; + } + out[0] = '\0'; + size_t outLen = 0; + const char *p = src; + while (*p && (outLen + 1) < outSize) { + int len = utf8CharLen(*p); + if (outLen + len >= outSize) { + break; + } + memcpy(out + outLen, p, len); + outLen += len; + out[outLen] = '\0'; + if (u8g2Fonts.getUTF8Width(out) > maxWidth) { + out[outLen - len] = '\0'; + outLen -= len; + break; + } + p += len; + } + if (*p) { + const char *ellipsis = "..."; + if (u8g2Fonts.getUTF8Width(out) + u8g2Fonts.getUTF8Width(ellipsis) <= maxWidth) { + strlcat(out, ellipsis, outSize); + } else { + while (outLen > 0) { + size_t newLen = outLen; + if (newLen == 0) { + break; + } + do { + --newLen; + } while (newLen > 0 && (static_cast(out[newLen]) & 0xC0) == 0x80); + outLen = newLen; + out[outLen] = '\0'; + if (u8g2Fonts.getUTF8Width(out) + u8g2Fonts.getUTF8Width(ellipsis) <= maxWidth) { + strlcat(out, ellipsis, outSize); + break; + } + } + } + } +} + +void drawWifiIcon(int16_t x, int16_t y, int level) { + const int barW = 3; + const int barGap = 2; + const int barHeights[4] = {3, 5, 8, 11}; + const int baseY = y + 11; + + for (int i = 0; i < 4; ++i) { + int barH = barHeights[i]; + int barX = x + i * (barW + barGap); + int barY = baseY - barH; + if (i < level) { + display.fillRect(barX, barY, barW, barH, GxEPD_BLACK); + } else { + display.drawRect(barX, barY, barW, barH, GxEPD_BLACK); + } + } +} + +void drawBatteryIcon(int16_t x, int16_t y, int percent) { + const int bodyW = 22; + const int bodyH = 10; + const int capW = 2; + const int capH = 4; + + display.drawRect(x, y, bodyW, bodyH, GxEPD_BLACK); + display.fillRect(x + bodyW, y + (bodyH - capH) / 2, capW, capH, GxEPD_BLACK); + + int clamped = constrain(percent, 0, 100); + int fillW = (bodyW - 2) * clamped / 100; + if (fillW > 0) { + uint16_t fillColor = clamped <= 20 ? GxEPD_RED : GxEPD_BLACK; + display.fillRect(x + 1, y + 1, fillW, bodyH - 2, fillColor); + } +} + +void drawPortalIndicator(int16_t x, int16_t y) { + display.setFont(nullptr); + display.setTextColor(GxEPD_RED); + display.setCursor(x, y); + display.print("P"); +} + +void drawWeather(int16_t width, const char *weatherText) { + const int wifiX = 8; + const int wifiW = 4 * 3 + 3 * 2; + const int batteryX = width - 8 - 22 - 2; + const int startX = wifiX + wifiW + 8; + const int endX = batteryX - 6; + if (endX <= startX || !weatherText || weatherText[0] == '\0') { + return; + } + + u8g2Fonts.setFontMode(1); + u8g2Fonts.setFontDirection(0); + u8g2Fonts.setForegroundColor(GxEPD_BLACK); + u8g2Fonts.setBackgroundColor(GxEPD_WHITE); + u8g2Fonts.setFont(u8g2_font_unifont_t_cyrillic); + int16_t textW = u8g2Fonts.getUTF8Width(weatherText); + int16_t tx = startX + (endX - startX - textW) / 2; + int16_t ty = 16; + u8g2Fonts.setCursor(tx, ty); + u8g2Fonts.print(weatherText); +} + +void renderStatus(const String &line1, const String &line2, int wifiLevel, int batteryPercent, + bool portalActive, uint16_t line2Color) { + display.setRotation(3); + display.setTextColor(GxEPD_BLACK); + display.setFont(&FreeMonoBold9pt7b); + + display.firstPage(); + do { + display.fillScreen(GxEPD_WHITE); + int16_t width = display.width(); + display.drawRect(2, 2, width - 4, display.height() - 4, GxEPD_BLACK); + display.drawFastHLine(2, kStatusBarHeight, width - 4, GxEPD_RED); + drawWifiIcon(8, 5, wifiLevel); + if (portalActive) { + drawPortalIndicator(8 + 4 * (3 + 2) + 4, 14); + display.setFont(&FreeMonoBold9pt7b); + display.setTextColor(GxEPD_BLACK); + } + drawBatteryIcon(width - 8 - 22 - 2, 6, batteryPercent); + drawWeather(width, gWeatherText); + + display.setCursor(10, kStatusBarHeight + 22); + display.print(line1); + display.setTextColor(line2Color); + display.setCursor(10, kStatusBarHeight + 44); + display.print(line2); + } while (display.nextPage()); +} + +void drawFlipCard(int16_t x, int16_t y, int16_t w, int16_t h, const char *text) { + display.drawRect(x, y, w, h, GxEPD_BLACK); + int16_t midY = y + h / 2; + display.drawFastHLine(x, midY, w, GxEPD_BLACK); + + display.setFont(nullptr); + int16_t tbx, tby; + uint16_t tbw, tbh; + int textSize = 10; + for (; textSize > 1; --textSize) { + display.setTextSize(textSize); + display.getTextBounds(text, 0, 0, &tbx, &tby, &tbw, &tbh); + if (tbw <= (w - 8) && tbh <= (h - 8)) { + break; + } + } + display.getTextBounds(text, 0, 0, &tbx, &tby, &tbw, &tbh); + int16_t tx = x + (w - tbw) / 2 - tbx; + int16_t ty = y + (h - tbh) / 2 - tby; + display.setCursor(tx, ty); + display.setTextColor(GxEPD_BLACK); + display.print(text); + display.setTextSize(1); +} + +void renderClock(int wifiLevel, int batteryPercent) { + struct tm timeinfo; + bool timeOk = getLocalTimeSnapshot(&timeinfo); + + char hh[3] = "--"; + char mm[3] = "--"; + if (timeOk) { + snprintf(hh, sizeof(hh), "%02d", timeinfo.tm_hour); + snprintf(mm, sizeof(mm), "%02d", timeinfo.tm_min); + } + + display.setRotation(3); + display.setFont(&FreeMonoBold9pt7b); + + display.firstPage(); + do { + display.fillScreen(GxEPD_WHITE); + int16_t width = display.width(); + int16_t height = display.height(); + + display.drawRect(2, 2, width - 4, height - 4, GxEPD_BLACK); + display.drawFastHLine(2, kStatusBarHeight, width - 4, GxEPD_RED); + drawWifiIcon(8, 5, wifiLevel); + drawBatteryIcon(width - 8 - 22 - 2, 6, batteryPercent); + drawWeather(width, gWeatherText); + + int16_t margin = 2; + int16_t gap = 6; + int16_t topY = kStatusBarHeight + 2; + int16_t bottomY = height - 2; + int16_t cardH = bottomY - topY; + int16_t cardW = (width - 2 * margin - gap) / 2; + int16_t leftX = margin; + int16_t rightX = leftX + cardW + gap; + + drawFlipCard(leftX, topY, cardW, cardH, hh); + drawFlipCard(rightX, topY, cardW, cardH, mm); + + int16_t colonX = leftX + cardW + gap / 2; + int16_t colonY = topY + cardH / 2; + display.fillCircle(colonX, colonY - 10, 2, GxEPD_RED); + display.fillCircle(colonX, colonY + 10, 2, GxEPD_RED); + } while (display.nextPage()); +} + +void renderTable(int wifiLevel, int batteryPercent) { + struct tm timeinfo; + bool timeOk = getLocalTimeSnapshot(&timeinfo); + char timeText[6] = "--:--"; + if (timeOk) { + snprintf(timeText, sizeof(timeText), "%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min); + } + + display.setRotation(3); + display.setFont(&FreeMonoBold9pt7b); + + display.firstPage(); + do { + display.fillScreen(GxEPD_WHITE); + int16_t width = display.width(); + int16_t height = display.height(); + + display.drawRect(2, 2, width - 4, height - 4, GxEPD_BLACK); + display.drawFastHLine(2, kStatusBarHeight, width - 4, GxEPD_RED); + const int wifiX = 8; + const int wifiW = 4 * 3 + 3 * 2; + const int batteryX = width - 8 - 22 - 2; + drawWifiIcon(wifiX, 5, wifiLevel); + drawBatteryIcon(batteryX, 6, batteryPercent); + + display.setFont(&FreeMonoBold9pt7b); + display.setTextColor(GxEPD_BLACK); + int16_t tbx, tby; + uint16_t tbw, tbh; + display.getTextBounds(timeText, 0, 0, &tbx, &tby, &tbw, &tbh); + int16_t timeX = wifiX + wifiW + 6; + int16_t timeY = 16; + display.setCursor(timeX, timeY); + display.print(timeText); + + int16_t weatherEndX = batteryX - 6; + int16_t timeRightX = timeX + tbw + 6; + if (weatherEndX > timeRightX) { + int16_t weatherMaxW = weatherEndX - timeRightX; + char weatherBuf[64]; + u8g2Fonts.setFontMode(1); + u8g2Fonts.setFontDirection(0); + u8g2Fonts.setForegroundColor(GxEPD_BLACK); + u8g2Fonts.setBackgroundColor(GxEPD_WHITE); + u8g2Fonts.setFont(u8g2_font_unifont_t_cyrillic); + truncateUtf8ToWidth(gWeatherText, weatherMaxW, weatherBuf, sizeof(weatherBuf)); + int16_t weatherW = u8g2Fonts.getUTF8Width(weatherBuf); + int16_t weatherX = weatherEndX - weatherW; + u8g2Fonts.setCursor(weatherX, timeY); + u8g2Fonts.print(weatherBuf); + } + + int16_t topY = kStatusBarHeight + 4; + int16_t tableH = height - topY - 6; + int16_t rowH = tableH / (kNotionMaxItems + 1); + int16_t colSplit = width * 2 / 3; + + u8g2Fonts.setFontMode(1); + u8g2Fonts.setFontDirection(0); + u8g2Fonts.setForegroundColor(GxEPD_BLACK); + u8g2Fonts.setBackgroundColor(GxEPD_WHITE); + u8g2Fonts.setFont(u8g2_font_unifont_t_cyrillic); + + int16_t headerY = topY + rowH - 2; + u8g2Fonts.setCursor(8, headerY); + u8g2Fonts.print("Name"); + u8g2Fonts.setCursor(colSplit, headerY); + u8g2Fonts.print("Дата"); + + for (int i = 0; i < kNotionMaxItems; ++i) { + if (i >= gNotionCount) { + break; + } + int16_t rowTop = topY + rowH * (i + 1); + int16_t textY = rowTop + rowH - 4; + char nameBuf[kNotionNameLen]; + char dateBuf[kNotionDateLen]; + truncateUtf8ToWidth(gNotionNames[i], colSplit - 12, nameBuf, sizeof(nameBuf)); + truncateUtf8ToWidth(gNotionDates[i], width - colSplit - 8, dateBuf, sizeof(dateBuf)); + u8g2Fonts.setCursor(8, textY); + u8g2Fonts.print(nameBuf); + u8g2Fonts.setCursor(colSplit, textY); + u8g2Fonts.print(dateBuf); + } + } while (display.nextPage()); +} diff --git a/src/display.h b/src/display.h new file mode 100644 index 0000000..6098059 --- /dev/null +++ b/src/display.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +void initDisplay(); +void renderStatus(const String &line1, const String &line2, int wifiLevel, int batteryPercent, + bool portalActive = false, uint16_t line2Color = GxEPD_BLACK); +void renderClock(int wifiLevel, int batteryPercent); +void renderTable(int wifiLevel, int batteryPercent); diff --git a/src/hardware.cpp b/src/hardware.cpp new file mode 100644 index 0000000..11bc890 --- /dev/null +++ b/src/hardware.cpp @@ -0,0 +1,20 @@ +#include +#include "config.h" + +void initAnalog() { + analogReadResolution(12); + analogSetPinAttenuation(kBatteryAdcPin, ADC_11db); +} + +int readBatteryPercent() { + uint16_t raw = analogRead(kBatteryAdcPin); + int mv = static_cast(raw) * kBatteryAdcRefMv / kBatteryAdcMax; + mv = mv * kBatteryDividerNum / kBatteryDividerDen; + if (mv <= kBatteryMvMin) { + return 0; + } + if (mv >= kBatteryMvMax) { + return 100; + } + return (mv - kBatteryMvMin) * 100 / (kBatteryMvMax - kBatteryMvMin); +} diff --git a/src/hardware.h b/src/hardware.h new file mode 100644 index 0000000..90d613c --- /dev/null +++ b/src/hardware.h @@ -0,0 +1,4 @@ +#pragma once + +void initAnalog(); +int readBatteryPercent(); diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..c375d1f --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,76 @@ +#include +#include + +#include "app_state.h" +#include "config.h" +#include "display.h" +#include "hardware.h" +#include "network.h" +#include "web.h" + +void setup() { + Serial.begin(115200); + uint32_t serialWaitStart = millis(); + while (!Serial && (millis() - serialWaitStart) < 1500) { + delay(10); + } + Serial.println("Boot"); + delay(200); + + initDisplay(); + initAnalog(); + + int batteryPercent = readBatteryPercent(); + time_t now = time(nullptr); + bool timeValid = now >= kMinValidEpoch; + bool needNetSync = !timeValid || gLastNetSyncEpoch == 0 || + (timeValid && (now - gLastNetSyncEpoch) >= gNetSyncSec); + int wifiLevel = 0; + if (needNetSync) { + performNetSync(batteryPercent, &wifiLevel); + } + + gShowTable = false; + renderClock(wifiLevel, batteryPercent); + gLastScreenSwitchMs = millis(); + if (kDeepSleepEnabled) { + esp_sleep_enable_timer_wakeup(static_cast(kWakeIntervalSec) * 1000000ULL); + esp_deep_sleep_start(); + } +} + +void loop() { + delay(1000); + if (kDeepSleepEnabled) { + return; + } + webLoop(); + + uint32_t nowMs = millis(); + uint32_t currentScreenSec = gShowTable ? gTableScreenSec : gClockScreenSec; + if ((nowMs - gLastScreenSwitchMs) < (currentScreenSec * 1000UL)) { + return; + } + gLastScreenSwitchMs = nowMs; + + time_t now = time(nullptr); + bool timeValid = now >= kMinValidEpoch; + bool needNetSync = !timeValid || gLastNetSyncEpoch == 0 || + (timeValid && (now - gLastNetSyncEpoch) >= gNetSyncSec); + int batteryPercent = readBatteryPercent(); + int wifiLevel = 0; + if (needNetSync) { + performNetSync(batteryPercent, &wifiLevel); + } + + if (gNotionCount <= 0) { + gShowTable = false; + } else { + gShowTable = !gShowTable; + } + if (gShowTable) { + renderTable(wifiLevel, batteryPercent); + } else { + renderClock(wifiLevel, batteryPercent); + } +} diff --git a/src/network.cpp b/src/network.cpp new file mode 100644 index 0000000..6030142 --- /dev/null +++ b/src/network.cpp @@ -0,0 +1,278 @@ +#include "network.h" + +#include +#include +#include +#include +#include +#include + +#include "app_state.h" +#include "config.h" +#include "display.h" +#include "web.h" + +int wifiLevelFromRssi(int32_t rssi) { + if (rssi == 0) { + return 0; + } + if (rssi <= -100) { + return 0; + } + if (rssi >= -50) { + return 4; + } + return (rssi + 100) * 4 / 50; +} + +bool syncTime() { + configTzTime(kTimeZone, "pool.ntp.org", "time.nist.gov", "time.google.com"); + struct tm timeinfo; + for (int i = 0; i < 10; ++i) { + if (getLocalTime(&timeinfo, 1000)) { + return true; + } + } + return false; +} + +bool getLocalTimeSnapshot(struct tm *timeinfo) { + time_t now = time(nullptr); + if (now < kMinValidEpoch) { + return false; + } + localtime_r(&now, timeinfo); + return true; +} + +const char *weatherLabelFromCode(int code) { + if (code < 0) { + return "хмарно"; + } + if (code == 0) { + return "ясно"; + } + if (code == 1 || code == 2) { + return "мінливо"; + } + if (code == 3) { + return "хмарно"; + } + if (code == 45 || code == 48) { + return "туман"; + } + if (code == 95 || code == 96 || code == 99) { + return "гроза"; + } + if ((code >= 51 && code <= 57)) { + return "мряка"; + } + if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) { + return "дощ"; + } + if ((code >= 71 && code <= 77) || (code >= 85 && code <= 86)) { + return "сніг"; + } + return "хмарно"; +} + +bool updateWeather() { + if (WiFi.status() != WL_CONNECTED) { + return false; + } + + WiFiClientSecure client; + client.setInsecure(); + HTTPClient http; + if (!http.begin(client, kWeatherUrl)) { + return false; + } + int httpCode = http.GET(); + if (httpCode != HTTP_CODE_OK) { + http.end(); + return false; + } + + String payload = http.getString(); + http.end(); + + StaticJsonDocument<1024> doc; + DeserializationError err = deserializeJson(doc, payload); + if (err) { + return false; + } + + float temp = doc["current"]["temperature_2m"] | NAN; + int code = doc["current"]["weather_code"] | -1; + if (isnan(temp)) { + return false; + } + + const char *label = weatherLabelFromCode(code); + snprintf(gWeatherText, sizeof(gWeatherText), "Дніпро %+.0fC %s", temp, label); + return true; +} + +bool updateNotion() { + if (WiFi.status() != WL_CONNECTED) { + return false; + } + Serial.println("Notion: запрос"); + WiFiClientSecure client; + client.setInsecure(); + HTTPClient http; + String url = String("https://api.notion.com/v1/databases/") + kNotionDatabaseId + "/query"; + if (!http.begin(client, url)) { + Serial.println("Notion: http begin fail"); + return false; + } + http.addHeader("Content-Type", "application/json"); + http.addHeader("Authorization", String("Bearer ") + kNotionToken); + http.addHeader("Notion-Version", "2022-06-28"); + + StaticJsonDocument<128> req; + req["page_size"] = kNotionMaxItems; + String body; + serializeJson(req, body); + + int httpCode = http.POST(body); + if (httpCode != HTTP_CODE_OK) { + Serial.printf("Notion: http %d\n", httpCode); + http.end(); + return false; + } + String payload = http.getString(); + http.end(); + Serial.printf("Notion: payload %u bytes\n", payload.length()); + + StaticJsonDocument<8192> doc; + DeserializationError err = deserializeJson(doc, payload); + if (err) { + Serial.println("Notion: json error"); + return false; + } + + JsonArray results = doc["results"].as(); + int idx = 0; + for (JsonVariant v : results) { + if (idx >= kNotionMaxItems) { + break; + } + JsonObject props = v["properties"].as(); + const char *name = ""; + if (!props.isNull()) { + JsonVariant nameProp = props["Name"]; + if (!nameProp.isNull()) { + name = nameProp["title"][0]["plain_text"] | ""; + } + if (name[0] == '\0') { + for (JsonPair kv : props) { + const char *type = kv.value()["type"] | ""; + if (strcmp(type, "title") == 0) { + name = kv.value()["title"][0]["plain_text"] | ""; + break; + } + } + } + } + + const char *dateStart = ""; + if (!props.isNull()) { + JsonVariant dataProp = props["Data"]; + if (dataProp.isNull()) { + dataProp = props["Date"]; + } + if (dataProp.isNull()) { + dataProp = props["Дата"]; + } + if (!dataProp.isNull()) { + dateStart = dataProp["date"]["start"] | ""; + if (dateStart[0] == '\0') { + dateStart = dataProp["rich_text"][0]["plain_text"] | ""; + } + } + if (dateStart[0] == '\0') { + for (JsonPair kv : props) { + const char *type = kv.value()["type"] | ""; + if (strcmp(type, "date") == 0) { + dateStart = kv.value()["date"]["start"] | ""; + break; + } + } + } + } + strlcpy(gNotionNames[idx], name, sizeof(gNotionNames[idx])); + if (dateStart && dateStart[0] != '\0') { + char dateBuf[kNotionDateLen]; + strlcpy(dateBuf, dateStart, sizeof(dateBuf)); + dateBuf[10] = '\0'; + strlcpy(gNotionDates[idx], dateBuf, sizeof(gNotionDates[idx])); + } else { + gNotionDates[idx][0] = '\0'; + } + ++idx; + } + gNotionCount = idx; + Serial.printf("Notion: items %d\n", gNotionCount); + return true; +} + +bool waitForWifi(uint32_t timeoutMs) { + uint32_t startMs = millis(); + while (WiFi.status() != WL_CONNECTED && (millis() - startMs) < timeoutMs) { + delay(200); + } + return WiFi.status() == WL_CONNECTED; +} + +bool performNetSync(int batteryPercent, int *wifiLevelOut) { + int wifiLevel = 0; + renderStatus("WiFi", "Connecting...", wifiLevel, batteryPercent); + WiFi.mode(WIFI_STA); + bool connected = false; + static bool webSetup = false; + for (int attempt = 0; attempt < kWifiConnectAttempts; ++attempt) { + WiFi.begin(); + if (waitForWifi(kWifiConnectTimeoutMs)) { + connected = true; + break; + } + } + + if (!connected) { + WiFiManager wm; + wm.setConfigPortalTimeout(kPortalTimeoutSec); + renderStatus("AP: ElinkInformer-Setup", "Open 192.168.4.1", wifiLevel, batteryPercent, true); + connected = wm.autoConnect(kPortalSsid); + if (!connected) { + renderStatus("WiFi setup failed", "Rebooting...", wifiLevel, batteryPercent, true, GxEPD_RED); + delay(3000); + ESP.restart(); + } + } + + wifiLevel = wifiLevelFromRssi(WiFi.RSSI()); + if (kWebEnabled && !webSetup) { + startWebServer(); + webSetup = true; + } + bool timeOk = syncTime(); + bool weatherOk = updateWeather(); + bool notionOk = updateNotion(); + if (!timeOk) { + renderStatus("Time sync failed", "Check WiFi", wifiLevel, batteryPercent, false, GxEPD_RED); + } + if (timeOk || weatherOk || notionOk) { + gLastNetSyncEpoch = time(nullptr); + } + + if (!kWebEnabled) { + WiFi.disconnect(true); + WiFi.mode(WIFI_OFF); + } + + if (wifiLevelOut) { + *wifiLevelOut = wifiLevel; + } + return timeOk || weatherOk || notionOk; +} diff --git a/src/network.h b/src/network.h new file mode 100644 index 0000000..905a97e --- /dev/null +++ b/src/network.h @@ -0,0 +1,10 @@ +#pragma once + +#include + +int wifiLevelFromRssi(int32_t rssi); +bool syncTime(); +bool getLocalTimeSnapshot(struct tm *timeinfo); +bool updateWeather(); +bool updateNotion(); +bool performNetSync(int batteryPercent, int *wifiLevelOut); diff --git a/src/web.cpp b/src/web.cpp new file mode 100644 index 0000000..c0e86de --- /dev/null +++ b/src/web.cpp @@ -0,0 +1,169 @@ +#include "web.h" + +#include +#include +#include +#include +#include +#include + +#include "app_state.h" +#include "config.h" +#include "hardware.h" + +namespace { +WebServer gServer(80); +bool gWebStarted = false; + +uint32_t clampIntervalSec(uint32_t value, uint32_t minValue, uint32_t maxValue) { + if (value < minValue) { + return minValue; + } + if (value > maxValue) { + return maxValue; + } + return value; +} + +void handleWebRoot() { + String html; + html.reserve(2400); + html += ""; + html += ""; + html += ""; + html += "TableWatch"; + html += "
"; + html += "
"; + html += "

TableWatch

"; + html += "

mDNS: "; + html += kMdnsHost; + html += ".local

"; + html += "
"; + html += "
IP: "; + html += WiFi.localIP().toString(); + html += "
RSSI: "; + html += String(WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0); + html += " dBm
Battery: "; + html += String(readBatteryPercent()); + html += "%
Notion: "; + html += String(gNotionCount); + html += " items
Last sync: "; + html += (gLastNetSyncEpoch ? String(ctime(&gLastNetSyncEpoch)) : String("never")); + html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += "
"; + html += ""; + html += "
"; + html += "
"; + html += "Оновити прошивку"; + html += "Перезавантажити"; + html += "
"; + html += "
"; + gServer.send(200, "text/html; charset=utf-8", html); +} + +void handleWebSave() { + if (!gServer.hasArg("clock") || !gServer.hasArg("table") || !gServer.hasArg("sync")) { + gServer.send(400, "text/plain; charset=utf-8", "Missing parameters"); + return; + } + uint32_t clockSec = gServer.arg("clock").toInt(); + uint32_t tableSec = gServer.arg("table").toInt(); + uint32_t syncSec = gServer.arg("sync").toInt(); + gClockScreenSec = clampIntervalSec(clockSec, 30, 1800); + gTableScreenSec = clampIntervalSec(tableSec, 30, 1800); + gNetSyncSec = clampIntervalSec(syncSec, 60, 3600); + gServer.sendHeader("Location", "/"); + gServer.send(303, "text/plain; charset=utf-8", "Saved"); +} + +void handleWebUpdatePage() { + String html; + html.reserve(1200); + html += ""; + html += ""; + html += ""; + html += "Firmware Update"; + html += "
"; + html += "
"; + html += "

Оновлення прошивки

"; + html += "
"; + html += "
"; + html += ""; + html += "
"; + html += "Назад"; + html += "
"; + gServer.send(200, "text/html; charset=utf-8", html); +} + +void handleWebUpdate() { + HTTPUpload &upload = gServer.upload(); + if (upload.status == UPLOAD_FILE_START) { + if (!Update.begin(UPDATE_SIZE_UNKNOWN)) { + Update.printError(Serial); + } + } else if (upload.status == UPLOAD_FILE_WRITE) { + if (Update.write(upload.buf, upload.currentSize) != upload.currentSize) { + Update.printError(Serial); + } + } else if (upload.status == UPLOAD_FILE_END) { + if (Update.end(true)) { + gServer.send(200, "text/plain; charset=utf-8", "OK. Перезавантаження..."); + delay(500); + ESP.restart(); + } else { + Update.printError(Serial); + gServer.send(500, "text/plain; charset=utf-8", "Update failed"); + } + } else if (upload.status == UPLOAD_FILE_ABORTED) { + Update.end(); + } +} +} // namespace + +void startWebServer() { + if (gWebStarted) { + return; + } + gServer.on("/", HTTP_GET, handleWebRoot); + gServer.on("/save", HTTP_POST, handleWebSave); + gServer.on("/update", HTTP_GET, handleWebUpdatePage); + gServer.on( + "/update", + HTTP_POST, + []() {}, + handleWebUpdate + ); + gServer.on("/reboot", HTTP_GET, []() { + gServer.send(200, "text/plain; charset=utf-8", "Rebooting..."); + delay(500); + ESP.restart(); + }); + gServer.onNotFound([]() { + gServer.send(404, "text/plain; charset=utf-8", "Not found"); + }); + gServer.begin(); + if (MDNS.begin(kMdnsHost)) { + MDNS.addService("http", "tcp", 80); + } + gWebStarted = true; + Serial.println("Web: started"); +} + +void webLoop() { + if (kWebEnabled && gWebStarted && WiFi.status() == WL_CONNECTED) { + gServer.handleClient(); + } +} diff --git a/src/web.h b/src/web.h new file mode 100644 index 0000000..7a0bcfc --- /dev/null +++ b/src/web.h @@ -0,0 +1,4 @@ +#pragma once + +void startWebServer(); +void webLoop(); diff --git a/test/README b/test/README new file mode 100644 index 0000000..9b1e87b --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html