Initial TableWatch implementation
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal 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
10
.vscode/extensions.json
vendored
Normal 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
101
README.md
Normal file
@@ -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 збережений у прошивці у відкритому вигляді. Для продакшна бажано використовувати проксі/сервер або секрети поза прошивкою.
|
||||||
54
boards/esp32-s3-zero.json
Normal file
54
boards/esp32-s3-zero.json
Normal 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
37
include/README
Normal 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
46
lib/README
Normal 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
14
platformio.ini
Normal 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
13
src/app_state.cpp
Normal 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
16
src/app_state.h
Normal 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
48
src/config.h
Normal 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¤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;
|
||||||
335
src/display.cpp
Normal file
335
src/display.cpp
Normal 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
10
src/display.h
Normal 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
20
src/hardware.cpp
Normal 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
4
src/hardware.h
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
void initAnalog();
|
||||||
|
int readBatteryPercent();
|
||||||
76
src/main.cpp
Normal file
76
src/main.cpp
Normal 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
278
src/network.cpp
Normal 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
10
src/network.h
Normal 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
169
src/web.cpp
Normal 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
4
src/web.h
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
void startWebServer();
|
||||||
|
void webLoop();
|
||||||
11
test/README
Normal file
11
test/README
Normal 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
|
||||||
Reference in New Issue
Block a user