Initial TableWatch implementation

This commit is contained in:
2026-01-18 21:33:13 +02:00
commit 15c84487ec
20 changed files with 1261 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
View File

@@ -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"
]
}

101
README.md Normal file
View File

@@ -0,0 +1,101 @@
# ElinkInformer / TableWatch
Epaper інформер на ESP32S3 (WeActStudio 2.9" tricolor). Показує час великими цифрами, WiFi, батарею, погоду та дані з Notion. Є веб‑інтерфейс для керування інтервалами, а також оновлення прошивки через браузер.
## Функції
- великий годинник у стилі flipcard
- статус‑бар: WiFi, батарея, погода, час
- таблиця Notion (5 записів) з полями `Name` + `Data/Date`
- чергування екранів (час/Notion) з налаштовуваними інтервалами
- WiFi portal налаштування (SSID `ElinkInformer-Setup`)
- веб‑інтерфейс з Bootstrapтемою (mDNS `tablewatch.local`)
- оновлення прошивки через web upload
## Обладнання
- ESP32S3 (board: `esp32-s3-zero` у PlatformIO)
- WeActStudio 2.9" EPaper (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` (межі 0100%)
Калібруйте `kBatteryMvMin`/`kBatteryMvMax` під вашу батарею.
## Погода
- Джерело: OpenMeteo (`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
```
## Налаштування WiFi
Після старту пристрій піднімає 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.*` — WiFi, NTP, погода, Notion
- `src/web.*` — веб‑інтерфейс та OTAupload через web
- `src/hardware.*` — ADC/батарея
- `src/main.cpp` — ініціалізація та головний цикл
## Безпека
Токен Notion збережений у прошивці у відкритому вигляді. Для продакшна бажано використовувати проксі/сервер або секрети поза прошивкою.

54
boards/esp32-s3-zero.json Normal file
View File

@@ -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
}
}

37
include/README Normal file
View File

@@ -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

46
lib/README Normal file
View File

@@ -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 <Foo.h>
#include <Bar.h>
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

14
platformio.ini Normal file
View File

@@ -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

13
src/app_state.cpp Normal file
View File

@@ -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;

16
src/app_state.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
#include <Arduino.h>
#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;

48
src/config.h Normal file
View File

@@ -0,0 +1,48 @@
#pragma once
#include <Arduino.h>
#include <stdint.h>
#include <time.h>
// 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&current=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;

335
src/display.cpp Normal file
View File

@@ -0,0 +1,335 @@
#include "display.h"
#include <SPI.h>
#include <GxEPD2_3C.h>
#include <Fonts/FreeMonoBold9pt7b.h>
#include <Fonts/FreeMonoBold24pt7b.h>
#include <U8g2_for_Adafruit_GFX.h>
#include "app_state.h"
#include "config.h"
#include "network.h"
GxEPD2_3C<GxEPD2_290_C90c, GxEPD2_290_C90c::HEIGHT> 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<uint8_t>(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<uint8_t>(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());
}

10
src/display.h Normal file
View File

@@ -0,0 +1,10 @@
#pragma once
#include <Arduino.h>
#include <GxEPD2_3C.h>
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);

20
src/hardware.cpp Normal file
View File

@@ -0,0 +1,20 @@
#include <Arduino.h>
#include "config.h"
void initAnalog() {
analogReadResolution(12);
analogSetPinAttenuation(kBatteryAdcPin, ADC_11db);
}
int readBatteryPercent() {
uint16_t raw = analogRead(kBatteryAdcPin);
int mv = static_cast<int>(raw) * kBatteryAdcRefMv / kBatteryAdcMax;
mv = mv * kBatteryDividerNum / kBatteryDividerDen;
if (mv <= kBatteryMvMin) {
return 0;
}
if (mv >= kBatteryMvMax) {
return 100;
}
return (mv - kBatteryMvMin) * 100 / (kBatteryMvMax - kBatteryMvMin);
}

4
src/hardware.h Normal file
View File

@@ -0,0 +1,4 @@
#pragma once
void initAnalog();
int readBatteryPercent();

76
src/main.cpp Normal file
View File

@@ -0,0 +1,76 @@
#include <Arduino.h>
#include <esp_sleep.h>
#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<uint64_t>(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);
}
}

278
src/network.cpp Normal file
View File

@@ -0,0 +1,278 @@
#include "network.h"
#include <WiFi.h>
#include <WiFiManager.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#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<JsonArray>();
int idx = 0;
for (JsonVariant v : results) {
if (idx >= kNotionMaxItems) {
break;
}
JsonObject props = v["properties"].as<JsonObject>();
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;
}

10
src/network.h Normal file
View File

@@ -0,0 +1,10 @@
#pragma once
#include <Arduino.h>
int wifiLevelFromRssi(int32_t rssi);
bool syncTime();
bool getLocalTimeSnapshot(struct tm *timeinfo);
bool updateWeather();
bool updateNotion();
bool performNetSync(int batteryPercent, int *wifiLevelOut);

169
src/web.cpp Normal file
View File

@@ -0,0 +1,169 @@
#include "web.h"
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <Update.h>
#include <time.h>
#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 += "<!doctype html><html lang=\"uk\"><head>";
html += "<meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">";
html += "<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootswatch@5.3.2/dist/flatly/bootstrap.min.css\">";
html += "<title>TableWatch</title></head><body class=\"bg-light\">";
html += "<div class=\"container py-4\"><div class=\"row justify-content-center\"><div class=\"col-lg-6\">";
html += "<div class=\"card shadow-sm\"><div class=\"card-body\">";
html += "<h4 class=\"card-title mb-3\">TableWatch</h4>";
html += "<p class=\"text-muted\">mDNS: <strong>";
html += kMdnsHost;
html += ".local</strong></p>";
html += "<div class=\"mb-3\">";
html += "<div><strong>IP:</strong> ";
html += WiFi.localIP().toString();
html += "</div><div><strong>RSSI:</strong> ";
html += String(WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0);
html += " dBm</div><div><strong>Battery:</strong> ";
html += String(readBatteryPercent());
html += "%</div><div><strong>Notion:</strong> ";
html += String(gNotionCount);
html += " items</div><div><strong>Last sync:</strong> ";
html += (gLastNetSyncEpoch ? String(ctime(&gLastNetSyncEpoch)) : String("never"));
html += "</div>";
html += "</div>";
html += "<form method=\"POST\" action=\"/save\">";
html += "<div class=\"mb-3\"><label class=\"form-label\">Час екрану часу (сек)</label>";
html += "<input class=\"form-control\" type=\"number\" min=\"30\" max=\"1800\" name=\"clock\" value=\"";
html += gClockScreenSec;
html += "\"></div>";
html += "<div class=\"mb-3\"><label class=\"form-label\">Час екрану Notion (сек)</label>";
html += "<input class=\"form-control\" type=\"number\" min=\"30\" max=\"1800\" name=\"table\" value=\"";
html += gTableScreenSec;
html += "\"></div>";
html += "<div class=\"mb-3\"><label class=\"form-label\">Інтервал синхронізації (сек)</label>";
html += "<input class=\"form-control\" type=\"number\" min=\"60\" max=\"3600\" name=\"sync\" value=\"";
html += gNetSyncSec;
html += "\"></div>";
html += "<button class=\"btn btn-primary\" type=\"submit\">Зберегти</button>";
html += "</form>";
html += "<div class=\"d-flex gap-2 mt-3\">";
html += "<a class=\"btn btn-outline-secondary\" href=\"/update\">Оновити прошивку</a>";
html += "<a class=\"btn btn-outline-danger\" href=\"/reboot\">Перезавантажити</a>";
html += "</div>";
html += "</div></div></div></div></div></body></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 += "<!doctype html><html lang=\"uk\"><head>";
html += "<meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">";
html += "<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootswatch@5.3.2/dist/flatly/bootstrap.min.css\">";
html += "<title>Firmware Update</title></head><body class=\"bg-light\">";
html += "<div class=\"container py-4\"><div class=\"row justify-content-center\"><div class=\"col-lg-6\">";
html += "<div class=\"card shadow-sm\"><div class=\"card-body\">";
html += "<h4 class=\"card-title mb-3\">Оновлення прошивки</h4>";
html += "<form method=\"POST\" action=\"/update\" enctype=\"multipart/form-data\">";
html += "<div class=\"mb-3\"><input class=\"form-control\" type=\"file\" name=\"update\" required></div>";
html += "<button class=\"btn btn-primary\" type=\"submit\">Оновити</button>";
html += "</form>";
html += "<a class=\"btn btn-link mt-3\" href=\"/\">Назад</a>";
html += "</div></div></div></div></div></body></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();
}
}

4
src/web.h Normal file
View File

@@ -0,0 +1,4 @@
#pragma once
void startWebServer();
void webLoop();

11
test/README Normal file
View File

@@ -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