Public Access
Add a web UI to the clock firmware
- Web UI on port 80: live view of the display, choose the page (clock or a bitmap), bitmaps (list, show, live, edit, delete), draw / image upload with conversion in the browser, live mode, settings, Home Assistant - Live view from a shadow buffer in the BROSE9323 driver (shownPixel) - Settings in NVS with the original behaviour as defaults: schedule times, quarter hours, yawning Pikachu, No WiFi screen, flip time, status LED, Home Assistant broker (restart on change) - Bitmaps on LittleFS; new 320 KB partition, ota slots now 1.31 MB - DisplayController: pages, live mode, settings; app.cpp holds the objects - README for the recovery app; docs for the web UI - Tests: settings, time parsing, bitmap format and names, schedule with settings, pages and live mode (95 total) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c9e0800e36
commit
2b1ac12ed7
34 files changed
+1987
-115
No files matched your search
@@ -126,6 +126,21 @@ See README.md for the overview.
|
|||||||
- Arduino's `esp_app_desc_t` version and date come from the precompiled core, so the
|
- Arduino's `esp_app_desc_t` version and date come from the precompiled core, so the
|
||||||
clock firmware stores `FIRMWARE_VERSION` per slot in NVS for the recovery page.
|
clock firmware stores `FIRMWARE_VERSION` per slot in NVS for the recovery page.
|
||||||
|
|
||||||
|
## ESP32 web UI
|
||||||
|
|
||||||
|
- `app.cpp` owns the objects and the actions (settings, page, live mode, restart);
|
||||||
|
`web/web_ui.cpp` is the JSON API (endpoints listed in `web_ui.h`), `web/web_page.h` the
|
||||||
|
single HTML/JS page (raw string literal in PROGMEM). Keep image work in the browser; the
|
||||||
|
ESP32 only handles 418-character hex bitmaps (`logic/bitmap_format.h`, MSB = left dot).
|
||||||
|
- Settings: `logic/settings.h` struct with defaults = original behaviour, stored as an NVS
|
||||||
|
blob with a version (`system/settings_store.cpp`). When the struct changes, bump
|
||||||
|
`VERSION` there (old records then fall back to defaults).
|
||||||
|
- Bitmaps on LittleFS in the `spiffs` partition (`/bitmaps/<name>.bin`, 209 bytes). Names
|
||||||
|
are restricted (`validBitmapName`), which also makes them safe in paths and JSON.
|
||||||
|
- The display mirror comes from the driver's shadow buffer (`BROSE9323::shownPixel`).
|
||||||
|
- Check the page's JavaScript after changes: extract the `<script>` part and run
|
||||||
|
`node --check` on it (node is installed).
|
||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
- Remote: https://mangavalk.com/mangavalk/Flipdot-display.git, branch `main`.
|
- Remote: https://mangavalk.com/mangavalk/Flipdot-display.git, branch `main`.
|
||||||
@@ -23,7 +23,8 @@ too, and it boots again each time the relay switches on. Only the ESP32 runs all
|
|||||||
| Part | Folder | What it does |
|
| Part | Folder | What it does |
|
||||||
|------|--------|--------------|
|
|------|--------|--------------|
|
||||||
| **Arduino Nano** | [src/nano](src/nano/README.md) | Drives the panel's row and column electronics directly. Keeps a frame buffer and flips only the dots that changed. Takes simple text commands over serial. |
|
| **Arduino Nano** | [src/nano](src/nano/README.md) | Drives the panel's row and column electronics directly. Keeps a frame buffer and flips only the dots that changed. Takes simple text commands over serial. |
|
||||||
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows a clock with Pikachu bitmaps, exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
|
| **ESP32** | [src/esp32](src/esp32/README.md) | Connects to WiFi, gets the time over NTP, shows a clock with Pikachu bitmaps or your own bitmaps, has a [web UI](#web-ui), exposes switches to Home Assistant over MQTT, switches display power with a relay and supports OTA updates. |
|
||||||
|
| **ESP32 recovery app** | [src/recovery](src/recovery/README.md) | Small second app: WiFi setup page and OTA, so the ESP32 stays reachable when the clock firmware fails. |
|
||||||
| **BROSE9323 driver** | [lib/BROSE9323](lib/BROSE9323/README.md) | Shared `Adafruit_GFX` display driver. On the Nano it toggles GPIO pins. On the ESP32 (`-D ESPSERIAL`) it turns every drawing call into a serial command for the Nano. |
|
| **BROSE9323 driver** | [lib/BROSE9323](lib/BROSE9323/README.md) | Shared `Adafruit_GFX` display driver. On the Nano it toggles GPIO pins. On the ESP32 (`-D ESPSERIAL`) it turns every drawing call into a serial command for the Nano. |
|
||||||
|
|
||||||
Because the driver is an `Adafruit_GFX` subclass on both boards, the ESP32 can use the
|
Because the driver is an `Adafruit_GFX` subclass on both boards, the ESP32 can use the
|
||||||
@@ -38,6 +39,7 @@ lib/BROSE9323/ shared flipdot driver (used by both targets)
|
|||||||
src/nano/ Arduino Nano firmware
|
src/nano/ Arduino Nano firmware
|
||||||
src/esp32/ ESP32 clock firmware
|
src/esp32/ ESP32 clock firmware
|
||||||
secrets.example.h template for WiFi / MQTT credentials
|
secrets.example.h template for WiFi / MQTT credentials
|
||||||
|
web/ the web UI
|
||||||
src/recovery/ ESP32 recovery app: WiFi setup page + OTA
|
src/recovery/ ESP32 recovery app: WiFi setup page + OTA
|
||||||
partitions.csv ESP32 flash layout (recovery app + two clock firmware slots)
|
partitions.csv ESP32 flash layout (recovery app + two clock firmware slots)
|
||||||
test/test_nano/ unit tests for the Nano firmware (run on the PC)
|
test/test_nano/ unit tests for the Nano firmware (run on the PC)
|
||||||
@@ -85,6 +87,31 @@ In VS Code you can also pick the environment in the PlatformIO status bar.
|
|||||||
> also uses. Disconnect that wire before you flash the Nano over USB, or the upload may
|
> also uses. Disconnect that wire before you flash the Nano over USB, or the upload may
|
||||||
> fail. Flashing over WiFi (`nano_ota`) doesn't have this problem.
|
> fail. Flashing over WiFi (`nano_ota`) doesn't have this problem.
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
Open `http://<ESP32 IP>/` (e.g. <http://10.1.1.198/>) in a browser. At the top is a live
|
||||||
|
view of the display: it shows what the dots show right now, updated every 1.5 s.
|
||||||
|
|
||||||
|
| Tab | What you can do |
|
||||||
|
|-----|-----------------|
|
||||||
|
| **Show** | Choose what the display shows during the day: the clock (Pikachu with the time) or any bitmap. The schedule still decides when the display is on. |
|
||||||
|
| **Bitmaps** | All bitmaps with a preview: show one as the page, send it live, open it in the editor, delete it. Two built-in ones (Nintendo) can't be deleted. |
|
||||||
|
| **Draw / Live** | Draw on an 84 x 19 canvas (click or drag), or load an image: it is converted in the browser (threshold, invert, dithering, fit / fill / stretch). "Show on display now" sends it to the display right away (*live mode*); with "live while drawing" every change appears on the dots. Save it as a bitmap with a name. |
|
||||||
|
| **Settings** | Schedule on/off, when the display is on, the late-evening quarter-hour mode, when it switches off; yawning Pikachu and its time; the "No WiFi" screen; the flip time; the status LED. |
|
||||||
|
| **Home Assistant** | On/off, broker, port, user, password. Saving restarts the ESP32. An empty broker uses `secrets.h`. |
|
||||||
|
| **System** | Firmware status, uptime, WiFi, storage; restart; start the recovery app. |
|
||||||
|
|
||||||
|
**Live mode** shows the image at once, also at night or before the time is known. It
|
||||||
|
ends with "Back to normal", or 30 minutes after the last image. The Home Assistant Power
|
||||||
|
switch still wins: when it is off, nothing is shown.
|
||||||
|
|
||||||
|
**Bitmaps** are stored on the ESP32 (LittleFS, 320 KB, room for well over a thousand). They
|
||||||
|
need the new flash layout from the [one-time installation](#one-time-installation-usb);
|
||||||
|
before that, only the built-in ones are available.
|
||||||
|
|
||||||
|
Settings are stored in the ESP32's NVS. The defaults are the original behaviour. There is
|
||||||
|
no login: anyone on your WiFi can use the page, as with OTA.
|
||||||
|
|
||||||
## Flashing the Nano over WiFi
|
## Flashing the Nano over WiFi
|
||||||
|
|
||||||
The ESP32 can flash the Nano for you, so you don't need a USB cable at the display:
|
The ESP32 can flash the Nano for you, so you don't need a USB cable at the display:
|
||||||
@@ -167,7 +194,7 @@ is broken. It shows what is going on as a short message on the display, and the
|
|||||||
LED double-blinks.
|
LED double-blinks.
|
||||||
|
|
||||||
```
|
```
|
||||||
flash (4 MB): bootloader | nvs | otadata | factory: recovery app | ota_0: clock | ota_1: clock
|
flash (4 MB): bootloader | nvs | otadata | factory: recovery | ota_0: clock | ota_1: clock | bitmaps
|
||||||
```
|
```
|
||||||
|
|
||||||
**When it starts**
|
**When it starts**
|
||||||
@@ -282,6 +309,8 @@ The serial protocol is described in [src/nano/README.md](src/nano/README.md).
|
|||||||
[One-time installation](#one-time-installation-usb)). Until then the ESP32 runs the old
|
[One-time installation](#one-time-installation-usb)). Until then the ESP32 runs the old
|
||||||
flash layout: the clock firmware works, but without the recovery app; the Home
|
flash layout: the clock firmware works, but without the recovery app; the Home
|
||||||
Assistant sensor shows "no recovery app".
|
Assistant sensor shows "no recovery app".
|
||||||
|
- [ ] **Test the web UI on the real hardware**: live view, uploading and drawing a bitmap,
|
||||||
|
live mode, choosing a page, settings, Home Assistant settings (restart).
|
||||||
- [ ] **Test the recovery app on the real hardware**: the Home Assistant button, the setup
|
- [ ] **Test the recovery app on the real hardware**: the Home Assistant button, the setup
|
||||||
WiFi with a wrong WiFi password, 3 quick power cycles, and an OTA upload into it.
|
WiFi with a wrong WiFi password, 3 quick power cycles, and an OTA upload into it.
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ void BROSE9323::display(bool force)
|
|||||||
#ifdef ESPSERIAL
|
#ifdef ESPSERIAL
|
||||||
stream->print("D\n");
|
stream->print("D\n");
|
||||||
stream->flush();
|
stream->flush();
|
||||||
|
// Shadow copy of what the dots show now (for the web UI)
|
||||||
|
memcpy(_old_buffer, _new_buffer, _buffer_size);
|
||||||
#else
|
#else
|
||||||
if (_direct_mode)
|
if (_direct_mode)
|
||||||
return;
|
return;
|
||||||
@@ -114,6 +116,19 @@ void BROSE9323::drawPixel(int16_t x, int16_t y, uint16_t color)
|
|||||||
if (y >= height() || y < 0 || x >= width() || x < 0)
|
if (y >= height() || y < 0 || x >= width() || x < 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Shadow copy for the web UI; S (direct mode) is visible right away
|
||||||
|
if (color)
|
||||||
|
_new_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7);
|
||||||
|
else
|
||||||
|
_new_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7));
|
||||||
|
if (_direct_mode)
|
||||||
|
{
|
||||||
|
if (color)
|
||||||
|
_old_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7);
|
||||||
|
else
|
||||||
|
_old_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7));
|
||||||
|
}
|
||||||
|
|
||||||
char s[10];
|
char s[10];
|
||||||
sprintf(s, "%c%02x%02x%d\n", _direct_mode ? 'S' : 's', x, y, (bool)color);
|
sprintf(s, "%c%02x%02x%d\n", _direct_mode ? 'S' : 's', x, y, (bool)color);
|
||||||
stream->print(s);
|
stream->print(s);
|
||||||
@@ -162,6 +177,7 @@ void BROSE9323::drawPixel(int16_t x, int16_t y, uint16_t color)
|
|||||||
void BROSE9323::fillScreen(uint16_t color)
|
void BROSE9323::fillScreen(uint16_t color)
|
||||||
{
|
{
|
||||||
#ifdef ESPSERIAL
|
#ifdef ESPSERIAL
|
||||||
|
memset(_new_buffer, color ? 0xFF : 0x00, _buffer_size);
|
||||||
stream->print(color ? "F1\n" : "F0\n");
|
stream->print(color ? "F1\n" : "F0\n");
|
||||||
#else
|
#else
|
||||||
memset(_new_buffer, color ? 0xFF : 0x00, _buffer_size);
|
memset(_new_buffer, color ? 0xFF : 0x00, _buffer_size);
|
||||||
@@ -310,3 +326,13 @@ size_t BROSE9323::print(const char *ifsh)
|
|||||||
|
|
||||||
return Adafruit_GFX::print(ifsh);
|
return Adafruit_GFX::print(ifsh);
|
||||||
}
|
}
|
||||||
|
bool BROSE9323::shownPixel(int16_t x, int16_t y) const
|
||||||
|
{
|
||||||
|
if (y >= height() || y < 0 || x >= width() || x < 0)
|
||||||
|
return false;
|
||||||
|
#if defined(__AVR_ATmega168P__) || defined(__AVR_ATmega168PB__) || defined(__AVR_ATmega168__)
|
||||||
|
return _new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7));
|
||||||
|
#else
|
||||||
|
return _old_buffer[y * _buffer_width + x / 8] & (1 << (x & 7));
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -87,5 +87,7 @@ public:
|
|||||||
void printBuffer(void);
|
void printBuffer(void);
|
||||||
#endif
|
#endif
|
||||||
size_t print(const char *ifsh);
|
size_t print(const char *ifsh);
|
||||||
|
// State of a dot as of the last display() call (ESP32: shadow copy for the web UI)
|
||||||
|
bool shownPixel(int16_t x, int16_t y) const;
|
||||||
};
|
};
|
||||||
#endif //BROSE9323_H
|
#endif //BROSE9323_H
|
||||||
+4
-3
@@ -2,11 +2,12 @@
|
|||||||
#
|
#
|
||||||
# factory recovery app (src/recovery): WiFi setup portal + OTA, never changed by OTA
|
# factory recovery app (src/recovery): WiFi setup portal + OTA, never changed by OTA
|
||||||
# ota_0/1 clock firmware (src/esp32), updated over OTA in turns, with rollback
|
# ota_0/1 clock firmware (src/esp32), updated over OTA in turns, with rollback
|
||||||
|
# spiffs LittleFS for the bitmaps uploaded in the web UI
|
||||||
#
|
#
|
||||||
# Name, Type, SubType, Offset, Size
|
# Name, Type, SubType, Offset, Size
|
||||||
nvs, data, nvs, 0x9000, 0x5000
|
nvs, data, nvs, 0x9000, 0x5000
|
||||||
otadata, data, ota, 0xe000, 0x2000
|
otadata, data, ota, 0xe000, 0x2000
|
||||||
factory, app, factory, 0x10000, 0x100000
|
factory, app, factory, 0x10000, 0x100000
|
||||||
ota_0, app, ota_0, 0x110000, 0x170000
|
ota_0, app, ota_0, 0x110000, 0x150000
|
||||||
ota_1, app, ota_1, 0x280000, 0x170000
|
ota_1, app, ota_1, 0x260000, 0x150000
|
||||||
coredump, data, coredump, 0x3F0000, 0x10000
|
spiffs, data, spiffs, 0x3B0000, 0x50000
|
||||||
|
Can't render this file because it contains an unexpected character in line 1 and column 44.
|
@@ -72,6 +72,7 @@ build_flags = -D ESPSERIAL -I src/esp32
|
|||||||
lib_deps =
|
lib_deps =
|
||||||
${env.lib_deps}
|
${env.lib_deps}
|
||||||
dawidchyrzynski/home-assistant-integration@^2.1.0
|
dawidchyrzynski/home-assistant-integration@^2.1.0
|
||||||
|
bblanchon/ArduinoJson@^7.0.0
|
||||||
knolleary/PubSubClient@^2.8.0
|
knolleary/PubSubClient@^2.8.0
|
||||||
upload_protocol = espota
|
upload_protocol = espota
|
||||||
upload_port = 10.1.1.198
|
upload_port = 10.1.1.198
|
||||||
|
|||||||
+25
-9
@@ -11,12 +11,15 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
|
|||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| `main.cpp` | Creates the objects, `setup()` and `loop()` |
|
| `main.cpp` | `setup()` and `loop()`: the order of things at boot and in the loop |
|
||||||
| `config.h` | All settings: pins, timings, timezone, Home Assistant device id |
|
| `app.*` | The objects (display, Nano link, controller, ...) and the actions the web UI uses: settings, page, live mode |
|
||||||
|
| `config.h` | Fixed settings: pins, timings, timezone, Home Assistant device id |
|
||||||
| `credentials.h` | Includes `secrets.h` (WiFi and MQTT credentials, **gitignored**); template: `secrets.example.h` |
|
| `credentials.h` | Includes `secrets.h` (WiFi and MQTT credentials, **gitignored**); template: `secrets.example.h` |
|
||||||
| **`logic/`** | **Pure logic without Arduino code, tested on the PC** |
|
| **`logic/`** | **Pure logic without Arduino code, tested on the PC** |
|
||||||
| `logic/display_controller.*` | Decides what is shown and when the display is powered (`DisplayController`) |
|
| `logic/display_controller.*` | Decides what is shown and when the display is powered: page, live mode, schedule, Home Assistant switches (`DisplayController`) |
|
||||||
| `logic/schedule.*` | When the clock is shown (night, clock, standby) |
|
| `logic/settings.*` | The settings of the web UI with their defaults and checks |
|
||||||
|
| `logic/schedule.*` | When the display is on (night, clock, standby), from the settings |
|
||||||
|
| `logic/bitmap_format.*` | 84 x 19 bitmap format, hex encoding, bitmap names |
|
||||||
| `logic/power_timer.*` | Countdown to switching the display off |
|
| `logic/power_timer.*` | Countdown to switching the display off |
|
||||||
| `logic/status_led.h` | Blink pattern of the built-in LED |
|
| `logic/status_led.h` | Blink pattern of the built-in LED |
|
||||||
| `logic/boot_policy.*` | How to start after a reset: normal, OTA-only window, safe mode |
|
| `logic/boot_policy.*` | How to start after a reset: normal, OTA-only window, safe mode |
|
||||||
@@ -24,8 +27,10 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
|
|||||||
| `system/firmware_guard.*` | Rollback of bad OTA firmware, watchdog, crash counter, power-cycle trigger, status for Home Assistant |
|
| `system/firmware_guard.*` | Rollback of bad OTA firmware, watchdog, crash counter, power-cycle trigger, status for Home Assistant |
|
||||||
| `system/boot_target.*` | Switching between the clock firmware and the recovery app |
|
| `system/boot_target.*` | Switching between the clock firmware and the recovery app |
|
||||||
| `system/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count |
|
| `system/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count |
|
||||||
|
| `system/settings_store.*` | The web UI settings in NVS |
|
||||||
| **`display/`** | **What is drawn** |
|
| **`display/`** | **What is drawn** |
|
||||||
| `display/frames.*` | The screens of the clock: clock, "No WiFi", logo, blank (`FlipdotFrames`) |
|
| `display/frames.*` | The screens: clock, "No WiFi", page bitmap, live image, logo, blank (`FlipdotFrames`) |
|
||||||
|
| `display/bitmap_store.*` | Built-in bitmaps and the uploaded ones on LittleFS |
|
||||||
| `display/status_screen.*` | Two-line status messages at boot, after a crash and in the recovery app (`StatusScreen`) |
|
| `display/status_screen.*` | Two-line status messages at boot, after a crash and in the recovery app (`StatusScreen`) |
|
||||||
| `display/text.*` | Centered text lines on the 84 x 19 display |
|
| `display/text.*` | Centered text lines on the 84 x 19 display |
|
||||||
| `display/bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) |
|
| `display/bitmaps.h` | 1-bit bitmaps (Pikachu, thought bubble, Nintendo logo, ...) |
|
||||||
@@ -35,6 +40,9 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
|
|||||||
| **`net/`** | **Network** |
|
| **`net/`** | **Network** |
|
||||||
| `net/wifi_ota.*` | WiFi (with a restart when it stays down), ESP32 OTA updates, OTA-only mode |
|
| `net/wifi_ota.*` | WiFi (with a restart when it stays down), ESP32 OTA updates, OTA-only mode |
|
||||||
| `net/home_assistant.*` | MQTT and the Home Assistant entities (two switches, firmware sensor, recovery button) |
|
| `net/home_assistant.*` | MQTT and the Home Assistant entities (two switches, firmware sensor, recovery button) |
|
||||||
|
| **`web/`** | **Web UI** (see the [main README](../../README.md#web-ui)) |
|
||||||
|
| `web/web_ui.*` | Web server and JSON API (the endpoints are listed in `web_ui.h`) |
|
||||||
|
| `web/web_page.h` | The page: HTML and JavaScript; the browser converts images and draws the previews |
|
||||||
|
|
||||||
`DisplayController` only talks to two small interfaces, `DisplayPower` (implemented by
|
`DisplayController` only talks to two small interfaces, `DisplayPower` (implemented by
|
||||||
`NanoLink`) and `Frames` (implemented by `FlipdotFrames`). The tests replace both with
|
`NanoLink`) and `Frames` (implemented by `FlipdotFrames`). The tests replace both with
|
||||||
@@ -130,7 +138,15 @@ controller (power on/off, redraws, both Home Assistant switches).
|
|||||||
|
|
||||||
## Adding bitmaps
|
## Adding bitmaps
|
||||||
|
|
||||||
Convert an image with [image2cpp](https://javl.github.io/image2cpp/) to "Arduino code",
|
The easy way is the web UI (Draw / Live tab): load an image, adjust it, save it.
|
||||||
horizontal, 1 bit per pixel. Paste the array into `display/bitmaps.h` and draw it in
|
|
||||||
`display/frames.cpp` with `_display.drawBitmap(x, y, bitmap, w, h, 1)`. The display is
|
To build a bitmap into the firmware instead: convert it with
|
||||||
84 × 19 pixels.
|
[image2cpp](https://javl.github.io/image2cpp/) to "Arduino code", horizontal, 1 bit per
|
||||||
|
pixel, 84 x 19. Paste the array into `display/bitmaps.h` and add it to `BUILTIN` in
|
||||||
|
`display/bitmap_store.cpp`.
|
||||||
|
|
||||||
|
## Live view of the display
|
||||||
|
|
||||||
|
The BROSE9323 driver keeps a shadow copy of what was sent to the Nano (`shownPixel()`),
|
||||||
|
updated on every `display()`. `GET /api/screen` returns it, and the web UI polls it. It
|
||||||
|
also keeps the last image while the display is switched off, like the dots do.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#include "app.h"
|
||||||
|
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "config.h"
|
||||||
|
#include "display/bitmap_store.h"
|
||||||
|
#include "system/settings_store.h"
|
||||||
|
|
||||||
|
NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
|
||||||
|
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
|
||||||
|
FlipdotFrames frames(display);
|
||||||
|
StatusScreen status(display, nano);
|
||||||
|
DisplayController controller(nano, frames);
|
||||||
|
|
||||||
|
static Settings settings;
|
||||||
|
static bool restart_pending = false;
|
||||||
|
static uint32_t restart_at = 0;
|
||||||
|
|
||||||
|
// Load the page bitmap into the frames; falls back to the clock when it is gone
|
||||||
|
static void applyPage()
|
||||||
|
{
|
||||||
|
if (strcmp(settings.page, PAGE_CLOCK) != 0)
|
||||||
|
{
|
||||||
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
if (loadBitmap(settings.page, bitmap))
|
||||||
|
{
|
||||||
|
frames.setPageBitmap(bitmap);
|
||||||
|
controller.setPage(Page::Bitmap);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Serial.printf("Page bitmap \"%s\" not found, showing the clock\n", settings.page);
|
||||||
|
}
|
||||||
|
controller.setPage(Page::Clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void apply()
|
||||||
|
{
|
||||||
|
controller.setSettings(settings.schedule, settings.clock);
|
||||||
|
status.setFlipTime(settings.flip_time);
|
||||||
|
applyPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
const Settings &appSettings()
|
||||||
|
{
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
void appLoadSettings()
|
||||||
|
{
|
||||||
|
loadSettings(settings);
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool sameHomeAssistant(const HomeAssistantSettings &a, const HomeAssistantSettings &b)
|
||||||
|
{
|
||||||
|
return a.enabled == b.enabled && a.port == b.port && strcmp(a.host, b.host) == 0 &&
|
||||||
|
strcmp(a.user, b.user) == 0 && strcmp(a.password, b.password) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *appSaveSettings(const Settings &new_settings, bool &restart_needed)
|
||||||
|
{
|
||||||
|
const char *error = validateSettings(new_settings);
|
||||||
|
if (error)
|
||||||
|
return error;
|
||||||
|
if (strcmp(new_settings.page, PAGE_CLOCK) != 0 && !bitmapExists(new_settings.page))
|
||||||
|
return "Bitmap not found";
|
||||||
|
|
||||||
|
restart_needed = !sameHomeAssistant(settings.home_assistant, new_settings.home_assistant);
|
||||||
|
bool flip_time_changed = settings.flip_time != new_settings.flip_time;
|
||||||
|
settings = new_settings;
|
||||||
|
saveSettings(settings);
|
||||||
|
apply();
|
||||||
|
if (flip_time_changed && nano.isOn())
|
||||||
|
display.setTiming(settings.flip_time);
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *appSelectPage(const char *name)
|
||||||
|
{
|
||||||
|
if (strcmp(name, PAGE_CLOCK) != 0 && !bitmapExists(name))
|
||||||
|
return "Bitmap not found";
|
||||||
|
strncpy(settings.page, name, sizeof(settings.page) - 1);
|
||||||
|
settings.page[sizeof(settings.page) - 1] = '\0';
|
||||||
|
saveSettings(settings);
|
||||||
|
applyPage();
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void appBitmapChanged(const char *name)
|
||||||
|
{
|
||||||
|
if (strcmp(settings.page, name) == 0)
|
||||||
|
{
|
||||||
|
applyPage();
|
||||||
|
controller.pageChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void appShowLive(const uint8_t *bitmap)
|
||||||
|
{
|
||||||
|
frames.setLiveBitmap(bitmap);
|
||||||
|
controller.showLive(millis());
|
||||||
|
}
|
||||||
|
|
||||||
|
void appStopLive()
|
||||||
|
{
|
||||||
|
controller.stopLive();
|
||||||
|
}
|
||||||
|
|
||||||
|
void appRestartLater(uint32_t delay_ms)
|
||||||
|
{
|
||||||
|
restart_pending = true;
|
||||||
|
restart_at = millis() + delay_ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
void appLoop()
|
||||||
|
{
|
||||||
|
if (restart_pending && (int32_t)(millis() - restart_at) >= 0)
|
||||||
|
{
|
||||||
|
Serial.println("Restarting for the new settings");
|
||||||
|
ESP.restart();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// The objects of the clock firmware and the actions the web UI and Home Assistant use.
|
||||||
|
// main.cpp only decides the order of things at boot and in loop().
|
||||||
|
|
||||||
|
#include "BROSE9323.h"
|
||||||
|
#include "display/frames.h"
|
||||||
|
#include "display/status_screen.h"
|
||||||
|
#include "logic/display_controller.h"
|
||||||
|
#include "logic/settings.h"
|
||||||
|
#include "nano_link/nano_link.h"
|
||||||
|
|
||||||
|
extern NanoLink nano;
|
||||||
|
extern BROSE9323 display;
|
||||||
|
extern FlipdotFrames frames;
|
||||||
|
extern StatusScreen status;
|
||||||
|
extern DisplayController controller;
|
||||||
|
|
||||||
|
// Settings: loaded at boot, changed in the web UI
|
||||||
|
const Settings &appSettings();
|
||||||
|
void appLoadSettings();
|
||||||
|
// Validates, stores and applies. Returns nullptr or an error message. Sets
|
||||||
|
// restart_needed when the change only works after a restart (Home Assistant).
|
||||||
|
const char *appSaveSettings(const Settings &settings, bool &restart_needed);
|
||||||
|
|
||||||
|
// The page shown during the day: PAGE_CLOCK or a bitmap name (stored in the settings)
|
||||||
|
const char *appSelectPage(const char *name);
|
||||||
|
// A bitmap was saved or deleted: redraw when it is the current page
|
||||||
|
void appBitmapChanged(const char *name);
|
||||||
|
|
||||||
|
// Live mode of the web UI
|
||||||
|
void appShowLive(const uint8_t *bitmap);
|
||||||
|
void appStopLive();
|
||||||
|
|
||||||
|
// Restart the ESP32 after `delay_ms` (so a web response can still be sent)
|
||||||
|
void appRestartLater(uint32_t delay_ms);
|
||||||
|
void appLoop();
|
||||||
@@ -41,6 +41,8 @@ const uint32_t CLEAR_BEFORE_OFF_TIME = 1000;
|
|||||||
const uint32_t STATUS_FLIP_TIME = 2000;
|
const uint32_t STATUS_FLIP_TIME = 2000;
|
||||||
// WiFi down this long while the clock runs: show "No WiFi" with the time instead
|
// WiFi down this long while the clock runs: show "No WiFi" with the time instead
|
||||||
const uint32_t NO_WIFI_SHOW_TIME = 60000;
|
const uint32_t NO_WIFI_SHOW_TIME = 60000;
|
||||||
|
// Live mode of the web UI ends this long after the last image was sent
|
||||||
|
const uint32_t LIVE_TIMEOUT = 30UL * 60 * 1000;
|
||||||
// Power off this long after the display is no longer needed (night, standby, logo)
|
// Power off this long after the display is no longer needed (night, standby, logo)
|
||||||
const uint32_t POWER_OFF_DELAY = 30000;
|
const uint32_t POWER_OFF_DELAY = 30000;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
#include "display/bitmap_store.h"
|
||||||
|
|
||||||
|
#include <LittleFS.h>
|
||||||
|
#include <strings.h>
|
||||||
|
|
||||||
|
#include "display/bitmaps.h"
|
||||||
|
|
||||||
|
static const char *DIR = "/bitmaps";
|
||||||
|
|
||||||
|
struct BuiltinBitmap
|
||||||
|
{
|
||||||
|
const char *name;
|
||||||
|
const uint8_t *data; // PROGMEM, BITMAP_BYTES
|
||||||
|
};
|
||||||
|
|
||||||
|
static const BuiltinBitmap BUILTIN[] = {
|
||||||
|
{"Nintendo", nintendo_bitmap_Nintendo_new},
|
||||||
|
{"Nintendo border", nintendo_bitmap_nintendo_border},
|
||||||
|
};
|
||||||
|
|
||||||
|
static bool mounted = false;
|
||||||
|
|
||||||
|
static const BuiltinBitmap *builtin(const char *name)
|
||||||
|
{
|
||||||
|
for (const BuiltinBitmap &b : BUILTIN)
|
||||||
|
if (strcasecmp(b.name, name) == 0)
|
||||||
|
return &b;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String path(const char *name)
|
||||||
|
{
|
||||||
|
return String(DIR) + "/" + name + ".bin";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bitmapStoreBegin()
|
||||||
|
{
|
||||||
|
mounted = LittleFS.begin(true);
|
||||||
|
if (mounted && !LittleFS.exists(DIR))
|
||||||
|
LittleFS.mkdir(DIR);
|
||||||
|
Serial.printf("Bitmap storage %s\n", mounted ? "ready" : "not available (old flash layout?)");
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bitmapStorageAvailable()
|
||||||
|
{
|
||||||
|
return mounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
void bitmapStorageUsage(size_t &used, size_t &total)
|
||||||
|
{
|
||||||
|
used = mounted ? LittleFS.usedBytes() : 0;
|
||||||
|
total = mounted ? LittleFS.totalBytes() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<BitmapInfo> listBitmaps()
|
||||||
|
{
|
||||||
|
std::vector<BitmapInfo> list;
|
||||||
|
for (const BuiltinBitmap &b : BUILTIN)
|
||||||
|
list.push_back({b.name, true});
|
||||||
|
if (!mounted)
|
||||||
|
return list;
|
||||||
|
|
||||||
|
File dir = LittleFS.open(DIR);
|
||||||
|
for (File file = dir.openNextFile(); file; file = dir.openNextFile())
|
||||||
|
{
|
||||||
|
String name = file.name();
|
||||||
|
int slash = name.lastIndexOf('/');
|
||||||
|
if (slash >= 0)
|
||||||
|
name = name.substring(slash + 1);
|
||||||
|
if (!name.endsWith(".bin"))
|
||||||
|
continue;
|
||||||
|
name.remove(name.length() - 4);
|
||||||
|
if (validBitmapName(name.c_str()) && !builtin(name.c_str()))
|
||||||
|
list.push_back({name, false});
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bitmapExists(const char *name)
|
||||||
|
{
|
||||||
|
if (builtin(name))
|
||||||
|
return true;
|
||||||
|
return mounted && validBitmapName(name) && LittleFS.exists(path(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool loadBitmap(const char *name, uint8_t *bitmap)
|
||||||
|
{
|
||||||
|
const BuiltinBitmap *b = builtin(name);
|
||||||
|
if (b)
|
||||||
|
{
|
||||||
|
memcpy_P(bitmap, b->data, BITMAP_BYTES);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!mounted || !validBitmapName(name))
|
||||||
|
return false;
|
||||||
|
File file = LittleFS.open(path(name), "r");
|
||||||
|
if (!file)
|
||||||
|
return false;
|
||||||
|
bool ok = file.read(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
|
||||||
|
file.close();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *saveBitmap(const char *name, const uint8_t *bitmap)
|
||||||
|
{
|
||||||
|
if (!validBitmapName(name))
|
||||||
|
return "Invalid name: use 1-24 letters, digits, spaces, - or _";
|
||||||
|
if (builtin(name))
|
||||||
|
return "A built-in bitmap has this name";
|
||||||
|
if (!mounted)
|
||||||
|
return "No bitmap storage (install the new flash layout over USB, see README)";
|
||||||
|
if (!LittleFS.exists(path(name)) && listBitmaps().size() >= (size_t)MAX_BITMAPS + (sizeof(BUILTIN) / sizeof(BUILTIN[0])))
|
||||||
|
return "Too many bitmaps, delete some first";
|
||||||
|
|
||||||
|
File file = LittleFS.open(path(name), "w");
|
||||||
|
if (!file)
|
||||||
|
return "Could not write the file";
|
||||||
|
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
|
||||||
|
file.close();
|
||||||
|
return ok ? nullptr : "Storage full";
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *deleteBitmap(const char *name)
|
||||||
|
{
|
||||||
|
if (builtin(name))
|
||||||
|
return "Built-in bitmaps can't be deleted";
|
||||||
|
if (!mounted || !validBitmapName(name) || !LittleFS.exists(path(name)))
|
||||||
|
return "Bitmap not found";
|
||||||
|
return LittleFS.remove(path(name)) ? nullptr : "Could not delete the file";
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <Arduino.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include "logic/bitmap_format.h"
|
||||||
|
|
||||||
|
// Full screen bitmaps (84 x 19, see logic/bitmap_format.h): built-in ones from
|
||||||
|
// display/bitmaps.h and the ones uploaded in the web UI, stored on LittleFS
|
||||||
|
// (partition "spiffs" in partitions.csv) as /bitmaps/<name>.bin.
|
||||||
|
|
||||||
|
struct BitmapInfo
|
||||||
|
{
|
||||||
|
String name;
|
||||||
|
bool builtin;
|
||||||
|
};
|
||||||
|
|
||||||
|
const int MAX_BITMAPS = 200;
|
||||||
|
|
||||||
|
// Mounts LittleFS (formats it the first time). False without the partition (old flash
|
||||||
|
// layout): only the built-in bitmaps are available then.
|
||||||
|
bool bitmapStoreBegin();
|
||||||
|
bool bitmapStorageAvailable();
|
||||||
|
void bitmapStorageUsage(size_t &used, size_t &total);
|
||||||
|
|
||||||
|
std::vector<BitmapInfo> listBitmaps();
|
||||||
|
bool bitmapExists(const char *name);
|
||||||
|
bool loadBitmap(const char *name, uint8_t *bitmap);
|
||||||
|
|
||||||
|
// Return nullptr on success, otherwise a message for the user
|
||||||
|
const char *saveBitmap(const char *name, const uint8_t *bitmap);
|
||||||
|
const char *deleteBitmap(const char *name);
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
#include "display/frames.h"
|
#include "display/frames.h"
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "display/bitmap_store.h"
|
||||||
#include "display/bitmaps.h"
|
#include "display/bitmaps.h"
|
||||||
#include "display/text.h"
|
#include "display/text.h"
|
||||||
#include "logic/schedule.h"
|
|
||||||
|
|
||||||
// Horizontal offset that centers the 81 pixel wide clock on the 84 pixel display
|
// Horizontal offset that centers the 81 pixel wide clock on the 84 pixel display
|
||||||
static const int CLOCK_X = 3;
|
static const int CLOCK_X = 3;
|
||||||
@@ -13,13 +14,13 @@ FlipdotFrames::FlipdotFrames(BROSE9323 &display) : _display(display)
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
void FlipdotFrames::clock(int hour, int minute)
|
void FlipdotFrames::clock(int hour, int minute, bool evening)
|
||||||
{
|
{
|
||||||
char time[6];
|
char time[6];
|
||||||
snprintf(time, sizeof(time), "%02d:%02d", hour, minute);
|
snprintf(time, sizeof(time), "%02d:%02d", hour, minute);
|
||||||
|
|
||||||
_display.fillScreen(0);
|
_display.fillScreen(0);
|
||||||
if (isEvening(hour))
|
if (evening)
|
||||||
_display.drawBitmap(CLOCK_X, 0, yawning_pikachu_bitmap_yawning_pikachu_black_border, 35, 19, 1);
|
_display.drawBitmap(CLOCK_X, 0, yawning_pikachu_bitmap_yawning_pikachu_black_border, 35, 19, 1);
|
||||||
else
|
else
|
||||||
_display.drawBitmap(CLOCK_X, 0, pikachu_bitmap_pikachu_black_border, 35, 19, 1);
|
_display.drawBitmap(CLOCK_X, 0, pikachu_bitmap_pikachu_black_border, 35, 19, 1);
|
||||||
@@ -40,11 +41,21 @@ void FlipdotFrames::noWifi(int hour, int minute)
|
|||||||
_display.display();
|
_display.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FlipdotFrames::page()
|
||||||
|
{
|
||||||
|
_drawFullScreen(_page);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlipdotFrames::live()
|
||||||
|
{
|
||||||
|
_drawFullScreen(_live);
|
||||||
|
}
|
||||||
|
|
||||||
void FlipdotFrames::logo()
|
void FlipdotFrames::logo()
|
||||||
{
|
{
|
||||||
_display.fillScreen(0);
|
uint8_t logo[BITMAP_BYTES];
|
||||||
_display.drawBitmap(0, 0, nintendo_bitmap_Nintendo_new, 84, 19, 1);
|
if (loadBitmap("Nintendo", logo))
|
||||||
_display.display();
|
_drawFullScreen(logo);
|
||||||
}
|
}
|
||||||
|
|
||||||
void FlipdotFrames::blank()
|
void FlipdotFrames::blank()
|
||||||
@@ -52,3 +63,20 @@ void FlipdotFrames::blank()
|
|||||||
_display.fillScreen(0);
|
_display.fillScreen(0);
|
||||||
_display.display();
|
_display.display();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
|
||||||
|
{
|
||||||
|
memcpy(_page, bitmap, BITMAP_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlipdotFrames::setLiveBitmap(const uint8_t *bitmap)
|
||||||
|
{
|
||||||
|
memcpy(_live, bitmap, BITMAP_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
void FlipdotFrames::_drawFullScreen(const uint8_t *bitmap)
|
||||||
|
{
|
||||||
|
_display.fillScreen(0);
|
||||||
|
_display.drawBitmap(0, 0, bitmap, BITMAP_WIDTH, BITMAP_HEIGHT, 1);
|
||||||
|
_display.display();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "BROSE9323.h"
|
#include "BROSE9323.h"
|
||||||
|
#include "logic/bitmap_format.h"
|
||||||
#include "logic/display_controller.h"
|
#include "logic/display_controller.h"
|
||||||
|
|
||||||
// Draws the screens on the flipdot display. Each method sends one complete frame.
|
// Draws the screens on the flipdot display. Each method sends one complete frame.
|
||||||
@@ -10,13 +11,24 @@ public:
|
|||||||
explicit FlipdotFrames(BROSE9323 &display);
|
explicit FlipdotFrames(BROSE9323 &display);
|
||||||
|
|
||||||
// Pikachu with a thought bubble showing HH:MM (yawning in the evening)
|
// Pikachu with a thought bubble showing HH:MM (yawning in the evening)
|
||||||
void clock(int hour, int minute) override;
|
void clock(int hour, int minute, bool evening) override;
|
||||||
// "No WiFi" with the time below it
|
// "No WiFi" with the time below it
|
||||||
void noWifi(int hour, int minute) override;
|
void noWifi(int hour, int minute) override;
|
||||||
|
// The bitmap set with setPageBitmap()
|
||||||
|
void page() override;
|
||||||
|
// The image set with setLiveBitmap()
|
||||||
|
void live() override;
|
||||||
// Nintendo logo
|
// Nintendo logo
|
||||||
void logo() override;
|
void logo() override;
|
||||||
void blank() override;
|
void blank() override;
|
||||||
|
|
||||||
|
void setPageBitmap(const uint8_t *bitmap);
|
||||||
|
void setLiveBitmap(const uint8_t *bitmap);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
BROSE9323 &_display;
|
BROSE9323 &_display;
|
||||||
|
uint8_t _page[BITMAP_BYTES] = {};
|
||||||
|
uint8_t _live[BITMAP_BYTES] = {};
|
||||||
|
|
||||||
|
void _drawFullScreen(const uint8_t *bitmap);
|
||||||
};
|
};
|
||||||
@@ -5,10 +5,15 @@
|
|||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "display/text.h"
|
#include "display/text.h"
|
||||||
|
|
||||||
StatusScreen::StatusScreen(BROSE9323 &display, NanoLink &nano) : _display(display), _nano(nano)
|
StatusScreen::StatusScreen(BROSE9323 &display, NanoLink &nano) : _display(display), _nano(nano), _flip_time(FLIP_TIME)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void StatusScreen::setFlipTime(uint16_t flip_time)
|
||||||
|
{
|
||||||
|
_flip_time = flip_time;
|
||||||
|
}
|
||||||
|
|
||||||
void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
|
void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
|
||||||
{
|
{
|
||||||
Serial.printf("Display: %s / %s\n", line1, line2 ? line2 : "");
|
Serial.printf("Display: %s / %s\n", line1, line2 ? line2 : "");
|
||||||
@@ -16,7 +21,7 @@ void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
|
|||||||
if (!_started)
|
if (!_started)
|
||||||
{
|
{
|
||||||
_display.begin(&_nano.stream());
|
_display.begin(&_nano.stream());
|
||||||
_display.setTiming(FLIP_TIME);
|
_display.setTiming(_flip_time);
|
||||||
_started = true;
|
_started = true;
|
||||||
}
|
}
|
||||||
drawTextLines(_display, line1, line2);
|
drawTextLines(_display, line1, line2);
|
||||||
|
|||||||
@@ -14,8 +14,12 @@ public:
|
|||||||
// power off again once the dots have flipped: flipdots keep their image without power.
|
// power off again once the dots have flipped: flipdots keep their image without power.
|
||||||
void show(const char *line1, const char *line2, bool keep_on = false);
|
void show(const char *line1, const char *line2, bool keep_on = false);
|
||||||
|
|
||||||
|
// Flip time sent to the Nano with the first message (default FLIP_TIME)
|
||||||
|
void setFlipTime(uint16_t flip_time);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
BROSE9323 &_display;
|
BROSE9323 &_display;
|
||||||
NanoLink &_nano;
|
NanoLink &_nano;
|
||||||
bool _started = false;
|
bool _started = false;
|
||||||
|
uint16_t _flip_time;
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#include "logic/bitmap_format.h"
|
||||||
|
|
||||||
|
#include <ctype.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
bool bitmapPixel(const uint8_t *bitmap, int x, int y)
|
||||||
|
{
|
||||||
|
if (x < 0 || y < 0 || x >= BITMAP_WIDTH || y >= BITMAP_HEIGHT)
|
||||||
|
return false;
|
||||||
|
return bitmap[y * BITMAP_ROW_BYTES + x / 8] & (0x80 >> (x & 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
void setBitmapPixel(uint8_t *bitmap, int x, int y, bool on)
|
||||||
|
{
|
||||||
|
if (x < 0 || y < 0 || x >= BITMAP_WIDTH || y >= BITMAP_HEIGHT)
|
||||||
|
return;
|
||||||
|
uint8_t &byte = bitmap[y * BITMAP_ROW_BYTES + x / 8];
|
||||||
|
if (on)
|
||||||
|
byte |= 0x80 >> (x & 7);
|
||||||
|
else
|
||||||
|
byte &= ~(0x80 >> (x & 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
static int hexValue(char c)
|
||||||
|
{
|
||||||
|
if (c >= '0' && c <= '9')
|
||||||
|
return c - '0';
|
||||||
|
if (c >= 'a' && c <= 'f')
|
||||||
|
return c - 'a' + 10;
|
||||||
|
if (c >= 'A' && c <= 'F')
|
||||||
|
return c - 'A' + 10;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool bitmapFromHex(const char *hex, uint8_t *bitmap)
|
||||||
|
{
|
||||||
|
if (hex == nullptr || strlen(hex) != (size_t)BITMAP_HEX_LENGTH)
|
||||||
|
return false;
|
||||||
|
for (int i = 0; i < BITMAP_BYTES; i++)
|
||||||
|
{
|
||||||
|
int high = hexValue(hex[2 * i]);
|
||||||
|
int low = hexValue(hex[2 * i + 1]);
|
||||||
|
if (high < 0 || low < 0)
|
||||||
|
return false;
|
||||||
|
bitmap[i] = (high << 4) | low;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void bitmapToHex(const uint8_t *bitmap, char *out)
|
||||||
|
{
|
||||||
|
static const char digits[] = "0123456789abcdef";
|
||||||
|
for (int i = 0; i < BITMAP_BYTES; i++)
|
||||||
|
{
|
||||||
|
out[2 * i] = digits[bitmap[i] >> 4];
|
||||||
|
out[2 * i + 1] = digits[bitmap[i] & 0x0F];
|
||||||
|
}
|
||||||
|
out[BITMAP_HEX_LENGTH] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool validBitmapName(const char *name)
|
||||||
|
{
|
||||||
|
if (name == nullptr)
|
||||||
|
return false;
|
||||||
|
size_t len = strlen(name);
|
||||||
|
if (len == 0 || len > (size_t)BITMAP_NAME_MAX)
|
||||||
|
return false;
|
||||||
|
if (name[0] == ' ' || name[len - 1] == ' ')
|
||||||
|
return false;
|
||||||
|
for (size_t i = 0; i < len; i++)
|
||||||
|
{
|
||||||
|
char c = name[i];
|
||||||
|
if (!isalnum((unsigned char)c) && c != ' ' && c != '-' && c != '_')
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// "clock" is the clock page
|
||||||
|
char lower[BITMAP_NAME_MAX + 1];
|
||||||
|
for (size_t i = 0; i <= len; i++)
|
||||||
|
lower[i] = tolower((unsigned char)name[i]);
|
||||||
|
return strcmp(lower, "clock") != 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// Full screen bitmaps (84 x 19), stored and sent to the web UI in the format of
|
||||||
|
// Adafruit_GFX::drawBitmap: rows from top to bottom, 11 bytes per row, the most
|
||||||
|
// significant bit is the leftmost dot. Pure logic, tested in test/test_esp32.
|
||||||
|
|
||||||
|
const int BITMAP_WIDTH = 84;
|
||||||
|
const int BITMAP_HEIGHT = 19;
|
||||||
|
const int BITMAP_ROW_BYTES = (BITMAP_WIDTH + 7) / 8;
|
||||||
|
const int BITMAP_BYTES = BITMAP_ROW_BYTES * BITMAP_HEIGHT; // 209
|
||||||
|
const int BITMAP_HEX_LENGTH = BITMAP_BYTES * 2; // 418
|
||||||
|
|
||||||
|
bool bitmapPixel(const uint8_t *bitmap, int x, int y);
|
||||||
|
void setBitmapPixel(uint8_t *bitmap, int x, int y, bool on);
|
||||||
|
|
||||||
|
// Exactly BITMAP_HEX_LENGTH hex digits. Returns false on anything else.
|
||||||
|
bool bitmapFromHex(const char *hex, uint8_t *bitmap);
|
||||||
|
// out must hold BITMAP_HEX_LENGTH + 1 characters
|
||||||
|
void bitmapToHex(const uint8_t *bitmap, char *out);
|
||||||
|
|
||||||
|
// Bitmap names: 1-24 characters, letters, digits, space, '-' and '_', no space at the
|
||||||
|
// start or end, not "clock" (that is the clock page)
|
||||||
|
const int BITMAP_NAME_MAX = 24;
|
||||||
|
bool validBitmapName(const char *name);
|
||||||
@@ -7,6 +7,42 @@ DisplayController::DisplayController(DisplayPower &power, Frames &frames) : _pow
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DisplayController::setSettings(const ScheduleSettings &schedule, const ClockSettings &clock)
|
||||||
|
{
|
||||||
|
_schedule = schedule;
|
||||||
|
_clock = clock;
|
||||||
|
_content_revision++; // e.g. evening time changed: redraw
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayController::setPage(Page page)
|
||||||
|
{
|
||||||
|
_page = page;
|
||||||
|
_content_revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayController::pageChanged()
|
||||||
|
{
|
||||||
|
_content_revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayController::showLive(uint32_t now)
|
||||||
|
{
|
||||||
|
_live = true;
|
||||||
|
_live_since = now;
|
||||||
|
_content_revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DisplayController::stopLive()
|
||||||
|
{
|
||||||
|
_live = false;
|
||||||
|
_content_revision++;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool DisplayController::liveActive() const
|
||||||
|
{
|
||||||
|
return _live;
|
||||||
|
}
|
||||||
|
|
||||||
void DisplayController::setClockEnabled(bool enabled)
|
void DisplayController::setClockEnabled(bool enabled)
|
||||||
{
|
{
|
||||||
_clock_enabled = enabled;
|
_clock_enabled = enabled;
|
||||||
@@ -36,7 +72,10 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
|
|||||||
_wifi_lost = true;
|
_wifi_lost = true;
|
||||||
_wifi_lost_since = now;
|
_wifi_lost_since = now;
|
||||||
}
|
}
|
||||||
bool show_no_wifi = _wifi_lost && now - _wifi_lost_since >= NO_WIFI_SHOW_TIME;
|
bool show_no_wifi = _clock.no_wifi_screen && _wifi_lost && now - _wifi_lost_since >= NO_WIFI_SHOW_TIME;
|
||||||
|
|
||||||
|
if (_live && now - _live_since >= LIVE_TIMEOUT)
|
||||||
|
stopLive();
|
||||||
|
|
||||||
// Switched off elsewhere (power timer, Nano flashing): the Nano lost its frame
|
// Switched off elsewhere (power timer, Nano flashing): the Nano lost its frame
|
||||||
if (!_power.isOn())
|
if (!_power.isOn())
|
||||||
@@ -53,12 +92,21 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
|
|||||||
{
|
{
|
||||||
if (_power.isOn())
|
if (_power.isOn())
|
||||||
{
|
{
|
||||||
_showBlank();
|
_showOnce(Screen::Blank);
|
||||||
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME);
|
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live mode from the web UI: shown right away, whatever the time
|
||||||
|
if (_live)
|
||||||
|
{
|
||||||
|
_off_timer.cancel();
|
||||||
|
_ensureOn();
|
||||||
|
_showOnce(Screen::Live);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the boot screen until the time is known
|
// Keep the boot screen until the time is known
|
||||||
if (!time_valid)
|
if (!time_valid)
|
||||||
return;
|
return;
|
||||||
@@ -68,64 +116,83 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
|
|||||||
// The logo is only shown while the display is on; it never switches the power on
|
// The logo is only shown while the display is on; it never switches the power on
|
||||||
if (_power.isOn())
|
if (_power.isOn())
|
||||||
{
|
{
|
||||||
_showLogo();
|
_showOnce(Screen::Logo);
|
||||||
_off_timer.request(now, POWER_OFF_DELAY);
|
_off_timer.request(now, POWER_OFF_DELAY);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (scheduleFor(hour, minute))
|
switch (scheduleFor(hour, minute, _schedule))
|
||||||
{
|
{
|
||||||
case ScheduleMode::Night:
|
case ScheduleMode::Night:
|
||||||
if (_power.isOn())
|
if (_power.isOn())
|
||||||
{
|
{
|
||||||
_showBlank();
|
_showOnce(Screen::Blank);
|
||||||
_off_timer.request(now, POWER_OFF_DELAY);
|
_off_timer.request(now, POWER_OFF_DELAY);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ScheduleMode::Clock:
|
case ScheduleMode::Clock:
|
||||||
_off_timer.cancel();
|
_off_timer.cancel();
|
||||||
if (!_power.isOn())
|
_ensureOn();
|
||||||
{
|
if (_page == Page::Clock)
|
||||||
_power.on();
|
_showClock(hour, minute, show_no_wifi);
|
||||||
_screen = Screen::Unknown;
|
else
|
||||||
}
|
_showOnce(Screen::Page);
|
||||||
_showClock(hour, minute, show_no_wifi);
|
|
||||||
break;
|
break;
|
||||||
case ScheduleMode::Standby:
|
case ScheduleMode::Standby:
|
||||||
// Flipdots keep their image without power, so the last time stays visible
|
// Flipdots keep their image without power, so the last image stays visible
|
||||||
if (_power.isOn())
|
if (_power.isOn())
|
||||||
_off_timer.request(now, POWER_OFF_DELAY);
|
_off_timer.request(now, POWER_OFF_DELAY);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DisplayController::_ensureOn()
|
||||||
|
{
|
||||||
|
if (_power.isOn())
|
||||||
|
return;
|
||||||
|
_power.on();
|
||||||
|
_screen = Screen::Unknown;
|
||||||
|
}
|
||||||
|
|
||||||
void DisplayController::_showClock(int hour, int minute, bool no_wifi)
|
void DisplayController::_showClock(int hour, int minute, bool no_wifi)
|
||||||
{
|
{
|
||||||
Screen screen = no_wifi ? Screen::NoWifi : Screen::Clock;
|
Screen screen = no_wifi ? Screen::NoWifi : Screen::Clock;
|
||||||
if (_screen == screen && _shown_hour == hour && _shown_minute == minute)
|
bool evening = isEvening(hour, minute, _clock);
|
||||||
|
if (_screen == screen && _shown_hour == hour && _shown_minute == minute && _shown_evening == evening &&
|
||||||
|
_shown_revision == _content_revision)
|
||||||
return;
|
return;
|
||||||
if (no_wifi)
|
if (no_wifi)
|
||||||
_frames.noWifi(hour, minute);
|
_frames.noWifi(hour, minute);
|
||||||
else
|
else
|
||||||
_frames.clock(hour, minute);
|
_frames.clock(hour, minute, evening);
|
||||||
_screen = screen;
|
_screen = screen;
|
||||||
_shown_hour = hour;
|
_shown_hour = hour;
|
||||||
_shown_minute = minute;
|
_shown_minute = minute;
|
||||||
|
_shown_evening = evening;
|
||||||
|
_shown_revision = _content_revision;
|
||||||
}
|
}
|
||||||
|
|
||||||
void DisplayController::_showLogo()
|
// Screens without the time: only sent again when they or their content changed
|
||||||
|
void DisplayController::_showOnce(Screen screen)
|
||||||
{
|
{
|
||||||
if (_screen == Screen::Logo)
|
if (_screen == screen && _shown_revision == _content_revision)
|
||||||
return;
|
return;
|
||||||
_frames.logo();
|
switch (screen)
|
||||||
_screen = Screen::Logo;
|
{
|
||||||
}
|
case Screen::Page:
|
||||||
|
_frames.page();
|
||||||
void DisplayController::_showBlank()
|
break;
|
||||||
{
|
case Screen::Live:
|
||||||
if (_screen == Screen::Blank)
|
_frames.live();
|
||||||
return;
|
break;
|
||||||
_frames.blank();
|
case Screen::Logo:
|
||||||
_screen = Screen::Blank;
|
_frames.logo();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_frames.blank();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_screen = screen;
|
||||||
|
_shown_revision = _content_revision;
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
#include "logic/power_timer.h"
|
#include "logic/power_timer.h"
|
||||||
|
#include "logic/settings.h"
|
||||||
|
|
||||||
// Power for the panels and the Nano (implemented by NanoLink)
|
// Power for the panels and the Nano (implemented by NanoLink)
|
||||||
class DisplayPower
|
class DisplayPower
|
||||||
@@ -18,22 +19,44 @@ public:
|
|||||||
class Frames
|
class Frames
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
virtual void clock(int hour, int minute) = 0;
|
virtual void clock(int hour, int minute, bool evening) = 0;
|
||||||
// Shown instead of the clock while WiFi is down (the time keeps running without it)
|
// Shown instead of the clock while WiFi is down (the time keeps running without it)
|
||||||
virtual void noWifi(int hour, int minute) = 0;
|
virtual void noWifi(int hour, int minute) = 0;
|
||||||
|
// The bitmap selected as page
|
||||||
|
virtual void page() = 0;
|
||||||
|
// The image sent from the web UI's live mode
|
||||||
|
virtual void live() = 0;
|
||||||
virtual void logo() = 0;
|
virtual void logo() = 0;
|
||||||
virtual void blank() = 0;
|
virtual void blank() = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Decides what the display shows and when it is powered, based on the time and the two
|
enum class Page : uint8_t
|
||||||
// Home Assistant switches. A frame is only sent when it changes. Pure logic, tested in
|
{
|
||||||
// test/test_esp32.
|
Clock, // Pikachu with the time
|
||||||
|
Bitmap, // a bitmap (FlipdotFrames holds which one)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decides what the display shows and when it is powered, based on the time, the settings,
|
||||||
|
// the Home Assistant switches and the web UI. A frame is only sent when it changes. Pure
|
||||||
|
// logic, tested in test/test_esp32.
|
||||||
class DisplayController
|
class DisplayController
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
DisplayController(DisplayPower &power, Frames &frames);
|
DisplayController(DisplayPower &power, Frames &frames);
|
||||||
|
|
||||||
// Home Assistant "FlipDot Display Clock" switch: off shows the logo instead of the clock
|
void setSettings(const ScheduleSettings &schedule, const ClockSettings &clock);
|
||||||
|
|
||||||
|
// What is shown during the day. Call pageChanged() when the bitmap itself changed.
|
||||||
|
void setPage(Page page);
|
||||||
|
void pageChanged();
|
||||||
|
|
||||||
|
// Live mode: show Frames::live() now, whatever the time, until stopLive() or
|
||||||
|
// LIVE_TIMEOUT after the last showLive()
|
||||||
|
void showLive(uint32_t now);
|
||||||
|
void stopLive();
|
||||||
|
bool liveActive() const;
|
||||||
|
|
||||||
|
// Home Assistant "FlipDot Display Clock" switch: off shows the logo instead of the page
|
||||||
void setClockEnabled(bool enabled);
|
void setClockEnabled(bool enabled);
|
||||||
// Home Assistant "FlipDot Display Power" switch: off clears the display and powers it off
|
// Home Assistant "FlipDot Display Power" switch: off clears the display and powers it off
|
||||||
void setPowerEnabled(bool enabled);
|
void setPowerEnabled(bool enabled);
|
||||||
@@ -51,6 +74,8 @@ private:
|
|||||||
Unknown, // nothing sent since the last power on
|
Unknown, // nothing sent since the last power on
|
||||||
Clock,
|
Clock,
|
||||||
NoWifi,
|
NoWifi,
|
||||||
|
Page,
|
||||||
|
Live,
|
||||||
Logo,
|
Logo,
|
||||||
Blank,
|
Blank,
|
||||||
};
|
};
|
||||||
@@ -59,17 +84,27 @@ private:
|
|||||||
Frames &_frames;
|
Frames &_frames;
|
||||||
PowerTimer _off_timer;
|
PowerTimer _off_timer;
|
||||||
|
|
||||||
|
ScheduleSettings _schedule;
|
||||||
|
ClockSettings _clock;
|
||||||
|
Page _page = Page::Clock;
|
||||||
|
|
||||||
bool _clock_enabled = true;
|
bool _clock_enabled = true;
|
||||||
bool _power_enabled = true;
|
bool _power_enabled = true;
|
||||||
|
|
||||||
Screen _screen = Screen::Unknown;
|
Screen _screen = Screen::Unknown;
|
||||||
int _shown_hour = -1;
|
int _shown_hour = -1;
|
||||||
int _shown_minute = -1;
|
int _shown_minute = -1;
|
||||||
|
bool _shown_evening = false;
|
||||||
|
uint32_t _content_revision = 0; // changed page, bitmap or live image
|
||||||
|
uint32_t _shown_revision = 0;
|
||||||
|
|
||||||
|
bool _live = false;
|
||||||
|
uint32_t _live_since = 0;
|
||||||
|
|
||||||
bool _wifi_lost = false;
|
bool _wifi_lost = false;
|
||||||
uint32_t _wifi_lost_since = 0;
|
uint32_t _wifi_lost_since = 0;
|
||||||
|
|
||||||
|
void _ensureOn();
|
||||||
void _showClock(int hour, int minute, bool no_wifi);
|
void _showClock(int hour, int minute, bool no_wifi);
|
||||||
void _showLogo();
|
void _showOnce(Screen screen);
|
||||||
void _showBlank();
|
|
||||||
};
|
};
|
||||||
@@ -1,21 +1,19 @@
|
|||||||
#include "logic/schedule.h"
|
#include "logic/schedule.h"
|
||||||
|
|
||||||
static const int DAY_START_HOUR = 8;
|
ScheduleMode scheduleFor(int hour, int minute, const ScheduleSettings &s)
|
||||||
static const int LATE_START_HOUR = 22;
|
|
||||||
static const int NIGHT_START_HOUR = 23;
|
|
||||||
static const int NIGHT_START_MINUTE = 31;
|
|
||||||
static const int EVENING_START_HOUR = 17;
|
|
||||||
|
|
||||||
ScheduleMode scheduleFor(int hour, int minute)
|
|
||||||
{
|
{
|
||||||
if (hour < DAY_START_HOUR || (hour >= NIGHT_START_HOUR && minute >= NIGHT_START_MINUTE))
|
if (!s.enabled)
|
||||||
|
return ScheduleMode::Clock;
|
||||||
|
|
||||||
|
int now = hour * 60 + minute;
|
||||||
|
if (now < s.day_start || now >= s.night_start)
|
||||||
return ScheduleMode::Night;
|
return ScheduleMode::Night;
|
||||||
if (hour < LATE_START_HOUR || minute % 15 == 0)
|
if (now < s.late_start || !s.quarter_hours || minute % 15 == 0)
|
||||||
return ScheduleMode::Clock;
|
return ScheduleMode::Clock;
|
||||||
return ScheduleMode::Standby;
|
return ScheduleMode::Standby;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isEvening(int hour)
|
bool isEvening(int hour, int minute, const ClockSettings &s)
|
||||||
{
|
{
|
||||||
return hour >= EVENING_START_HOUR;
|
return s.evening_enabled && hour * 60 + minute >= s.evening_start;
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
// When the clock is shown. Pure logic, tested in test/test_esp32.
|
#include "logic/settings.h"
|
||||||
|
|
||||||
|
// When the display is on. Pure logic, tested in test/test_esp32. With the default
|
||||||
|
// settings:
|
||||||
//
|
//
|
||||||
// 00:00 - 07:59 Night screen cleared, power off
|
// 00:00 - 07:59 Night screen cleared, power off
|
||||||
// 08:00 - 21:59 Clock clock updated every minute
|
// 08:00 - 21:59 Clock page shown (the clock is updated every minute)
|
||||||
// 22:00 - 23:30 Clock only during the minutes :00, :15, :30 and :45
|
// 22:00 - 23:30 Clock only during the minutes :00, :15, :30 and :45
|
||||||
// Standby in between: power off, the last time stays on the dots
|
// Standby in between: power off, the last image stays on the dots
|
||||||
// 23:31 - 23:59 Night
|
// 23:31 - 23:59 Night
|
||||||
|
|
||||||
enum class ScheduleMode
|
enum class ScheduleMode
|
||||||
@@ -15,7 +18,7 @@ enum class ScheduleMode
|
|||||||
Standby,
|
Standby,
|
||||||
};
|
};
|
||||||
|
|
||||||
ScheduleMode scheduleFor(int hour, int minute);
|
ScheduleMode scheduleFor(int hour, int minute, const ScheduleSettings &settings);
|
||||||
|
|
||||||
// Evening: the yawning Pikachu is shown
|
// Evening: the yawning Pikachu is shown
|
||||||
bool isEvening(int hour);
|
bool isEvening(int hour, int minute, const ClockSettings &settings);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#include "logic/settings.h"
|
||||||
|
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "logic/bitmap_format.h"
|
||||||
|
|
||||||
|
static bool terminated(const char *text, int size)
|
||||||
|
{
|
||||||
|
return memchr(text, '\0', size) != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const char *validateSettings(const Settings &s)
|
||||||
|
{
|
||||||
|
if (!terminated(s.page, sizeof(s.page)) ||
|
||||||
|
(strcmp(s.page, PAGE_CLOCK) != 0 && !validBitmapName(s.page)))
|
||||||
|
return "Unknown page";
|
||||||
|
|
||||||
|
const ScheduleSettings &t = s.schedule;
|
||||||
|
if (t.day_start >= MINUTES_PER_DAY || t.late_start > MINUTES_PER_DAY || t.night_start > MINUTES_PER_DAY)
|
||||||
|
return "Times must be between 00:00 and 24:00";
|
||||||
|
if (t.day_start > t.late_start || t.late_start > t.night_start)
|
||||||
|
return "The times must be in order: day start, late evening, night";
|
||||||
|
|
||||||
|
if (s.clock.evening_start >= MINUTES_PER_DAY)
|
||||||
|
return "The evening must start before 24:00";
|
||||||
|
|
||||||
|
if (s.flip_time < SETTINGS_FLIP_TIME_MIN || s.flip_time > SETTINGS_FLIP_TIME_MAX)
|
||||||
|
return "The flip time must be between 50 and 2000 us";
|
||||||
|
|
||||||
|
const HomeAssistantSettings &ha = s.home_assistant;
|
||||||
|
if (!terminated(ha.host, sizeof(ha.host)) || !terminated(ha.user, sizeof(ha.user)) ||
|
||||||
|
!terminated(ha.password, sizeof(ha.password)))
|
||||||
|
return "Home Assistant text too long";
|
||||||
|
if (ha.port == 0)
|
||||||
|
return "Invalid MQTT port";
|
||||||
|
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool parseTime(const char *text, uint16_t &minutes)
|
||||||
|
{
|
||||||
|
if (text == nullptr)
|
||||||
|
return false;
|
||||||
|
int hour = 0, minute = 0;
|
||||||
|
char extra;
|
||||||
|
if (sscanf(text, "%d:%d%c", &hour, &minute, &extra) != 2)
|
||||||
|
return false;
|
||||||
|
if (hour < 0 || minute < 0 || minute > 59 || hour * 60 + minute > MINUTES_PER_DAY)
|
||||||
|
return false;
|
||||||
|
minutes = hour * 60 + minute;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void formatTime(uint16_t minutes, char *out)
|
||||||
|
{
|
||||||
|
snprintf(out, 6, "%02u:%02u", (unsigned)(minutes / 60) % 100, (unsigned)(minutes % 60));
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
// Everything that can be changed in the web UI. Stored in NVS (system/settings_store),
|
||||||
|
// the defaults are the original behaviour. Pure logic, tested in test/test_esp32.
|
||||||
|
//
|
||||||
|
// Times are minutes since midnight (8:00 = 480).
|
||||||
|
|
||||||
|
struct ScheduleSettings
|
||||||
|
{
|
||||||
|
bool enabled = true; // false: the display is always on
|
||||||
|
uint16_t day_start = 8 * 60; // display on from
|
||||||
|
uint16_t late_start = 22 * 60; // from here only at the quarter hours (see quarter_hours)
|
||||||
|
bool quarter_hours = true; // false: on all the time until night_start
|
||||||
|
uint16_t night_start = 23 * 60 + 31; // display off from (up to 24:00 = never)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ClockSettings
|
||||||
|
{
|
||||||
|
bool evening_enabled = true; // yawning Pikachu in the evening
|
||||||
|
uint16_t evening_start = 17 * 60;
|
||||||
|
bool no_wifi_screen = true; // "No WiFi" instead of the clock when WiFi is down
|
||||||
|
};
|
||||||
|
|
||||||
|
struct HomeAssistantSettings
|
||||||
|
{
|
||||||
|
bool enabled = true;
|
||||||
|
char host[64] = ""; // empty: BROKER_ADDR from secrets.h
|
||||||
|
uint16_t port = 1883;
|
||||||
|
char user[32] = "";
|
||||||
|
char password[64] = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
// What is shown during the day: "clock" or the name of a bitmap
|
||||||
|
const int PAGE_NAME_SIZE = 25;
|
||||||
|
#define PAGE_CLOCK "clock"
|
||||||
|
|
||||||
|
struct Settings
|
||||||
|
{
|
||||||
|
char page[PAGE_NAME_SIZE] = PAGE_CLOCK;
|
||||||
|
ScheduleSettings schedule;
|
||||||
|
ClockSettings clock;
|
||||||
|
HomeAssistantSettings home_assistant;
|
||||||
|
uint16_t flip_time = 225; // flip pulse length in us, sent to the Nano
|
||||||
|
bool status_led = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uint16_t MINUTES_PER_DAY = 24 * 60;
|
||||||
|
const uint16_t SETTINGS_FLIP_TIME_MIN = 50;
|
||||||
|
const uint16_t SETTINGS_FLIP_TIME_MAX = 2000;
|
||||||
|
|
||||||
|
// Checks the values. Returns nullptr when they are fine, otherwise a message for the user.
|
||||||
|
const char *validateSettings(const Settings &settings);
|
||||||
|
|
||||||
|
// "HH:MM" (00:00 - 24:00) <-> minutes since midnight
|
||||||
|
bool parseTime(const char *text, uint16_t &minutes);
|
||||||
|
// out must hold 6 characters
|
||||||
|
void formatTime(uint16_t minutes, char *out);
|
||||||
+15
-15
@@ -1,27 +1,20 @@
|
|||||||
// ESP32 firmware: decides what the flipdot display shows (clock, Home Assistant switches)
|
// ESP32 firmware: decides what the flipdot display shows (clock, bitmaps, web UI, Home
|
||||||
// and sends it to the Nano, which drives the panels. See README.md.
|
// Assistant) and sends it to the Nano, which drives the panels. See README.md.
|
||||||
|
// The objects and the actions on them are in app.cpp; this file only sets the order.
|
||||||
|
|
||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
|
||||||
#include "BROSE9323.h"
|
#include "app.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
#include "display/frames.h"
|
#include "display/bitmap_store.h"
|
||||||
#include "display/status_screen.h"
|
|
||||||
#include "logic/display_controller.h"
|
|
||||||
#include "logic/status_led.h"
|
#include "logic/status_led.h"
|
||||||
#include "nano_link/nano_flash_bridge.h"
|
#include "nano_link/nano_flash_bridge.h"
|
||||||
#include "nano_link/nano_link.h"
|
|
||||||
#include "net/home_assistant.h"
|
#include "net/home_assistant.h"
|
||||||
#include "net/wifi_ota.h"
|
#include "net/wifi_ota.h"
|
||||||
#include "system/boot_target.h"
|
#include "system/boot_target.h"
|
||||||
#include "system/firmware_guard.h"
|
#include "system/firmware_guard.h"
|
||||||
|
#include "web/web_ui.h"
|
||||||
NanoLink nano(Serial2, NANO_RX_PIN, NANO_TX_PIN, RELAY_PIN, NANO_RESET_PIN);
|
|
||||||
BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
|
|
||||||
FlipdotFrames frames(display);
|
|
||||||
StatusScreen status(display, nano);
|
|
||||||
DisplayController controller(nano, frames);
|
|
||||||
|
|
||||||
// Flashing the Nano over WiFi: restart it into its bootloader, switch it off afterwards.
|
// Flashing the Nano over WiFi: restart it into its bootloader, switch it off afterwards.
|
||||||
// The controller then powers it on again with the new firmware.
|
// The controller then powers it on again with the new firmware.
|
||||||
@@ -39,6 +32,9 @@ void setup()
|
|||||||
// Decided first, before anything that could crash
|
// Decided first, before anything that could crash
|
||||||
BootMode boot_mode = firmwareGuardBegin();
|
BootMode boot_mode = firmwareGuardBegin();
|
||||||
|
|
||||||
|
bitmapStoreBegin();
|
||||||
|
appLoadSettings();
|
||||||
|
|
||||||
if (boot_mode == BootMode::Normal)
|
if (boot_mode == BootMode::Normal)
|
||||||
status.show("Connecting", "to WiFi...", true);
|
status.show("Connecting", "to WiFi...", true);
|
||||||
wifiBegin(); // starts the recovery app when it fails
|
wifiBegin(); // starts the recovery app when it fails
|
||||||
@@ -66,7 +62,8 @@ void setup()
|
|||||||
status.show("Getting", "the time...", true);
|
status.show("Getting", "the time...", true);
|
||||||
|
|
||||||
configTzTime(TIMEZONE, NTP_SERVER);
|
configTzTime(TIMEZONE, NTP_SERVER);
|
||||||
homeAssistantBegin(controller);
|
homeAssistantBegin(controller, appSettings().home_assistant);
|
||||||
|
webUiBegin();
|
||||||
|
|
||||||
firmwareGuardStartWatchdog();
|
firmwareGuardStartWatchdog();
|
||||||
}
|
}
|
||||||
@@ -80,6 +77,8 @@ void loop()
|
|||||||
|
|
||||||
homeAssistantSetFirmwareStatus(firmwareGuardStatus());
|
homeAssistantSetFirmwareStatus(firmwareGuardStatus());
|
||||||
homeAssistantLoop();
|
homeAssistantLoop();
|
||||||
|
webUiLoop();
|
||||||
|
appLoop();
|
||||||
|
|
||||||
// While the Nano is being flashed, nothing else may talk to it
|
// While the Nano is being flashed, nothing else may talk to it
|
||||||
if (flashBridge.handle())
|
if (flashBridge.handle())
|
||||||
@@ -89,5 +88,6 @@ void loop()
|
|||||||
bool time_valid = getLocalTime(&now, 0);
|
bool time_valid = getLocalTime(&now, 0);
|
||||||
controller.update(millis(), time_valid, now.tm_hour, now.tm_min, wifiConnected());
|
controller.update(millis(), time_valid, now.tm_hour, now.tm_min, wifiConnected());
|
||||||
|
|
||||||
digitalWrite(LED_PIN, statusLedLevel(millis(), wifiConnected(), nano.isOn()));
|
bool led = appSettings().status_led && statusLedLevel(millis(), wifiConnected(), nano.isOn());
|
||||||
|
digitalWrite(LED_PIN, led);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ static char firmware_status[64] = "";
|
|||||||
static bool firmware_status_sent = false;
|
static bool firmware_status_sent = false;
|
||||||
|
|
||||||
static DisplayController *displayController = nullptr;
|
static DisplayController *displayController = nullptr;
|
||||||
|
static bool enabled = false;
|
||||||
|
|
||||||
// The switches are not optimistic: the new state must be reported back to Home Assistant
|
// The switches are not optimistic: the new state must be reported back to Home Assistant
|
||||||
static void onClockCommand(bool state, HASwitch *sender)
|
static void onClockCommand(bool state, HASwitch *sender)
|
||||||
@@ -36,9 +37,15 @@ static void onPowerCommand(bool state, HASwitch *sender)
|
|||||||
sender->setState(state);
|
sender->setState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
void homeAssistantBegin(DisplayController &controller)
|
void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettings &settings)
|
||||||
{
|
{
|
||||||
displayController = &controller;
|
displayController = &controller;
|
||||||
|
enabled = settings.enabled;
|
||||||
|
if (!enabled)
|
||||||
|
{
|
||||||
|
Serial.println("Home Assistant disabled in the settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
device.setName(HA_DEVICE_NAME);
|
device.setName(HA_DEVICE_NAME);
|
||||||
device.setSoftwareVersion(HA_SOFTWARE_VERSION);
|
device.setSoftwareVersion(HA_SOFTWARE_VERSION);
|
||||||
@@ -62,11 +69,23 @@ void homeAssistantBegin(DisplayController &controller)
|
|||||||
// The sensor doesn't resend its value on reconnect by itself
|
// The sensor doesn't resend its value on reconnect by itself
|
||||||
mqtt.onConnected([]() { firmware_status_sent = false; });
|
mqtt.onConnected([]() { firmware_status_sent = false; });
|
||||||
|
|
||||||
mqtt.begin(BROKER_ADDR, MQTT_USER, MQTT_PASSWORD);
|
// settings must stay alive: the MQTT library keeps the pointers
|
||||||
|
if (settings.host[0])
|
||||||
|
mqtt.begin(settings.host, settings.port, settings.user[0] ? settings.user : nullptr,
|
||||||
|
settings.password[0] ? settings.password : nullptr);
|
||||||
|
else
|
||||||
|
mqtt.begin(BROKER_ADDR, settings.port, MQTT_USER, MQTT_PASSWORD); // secrets.h
|
||||||
|
}
|
||||||
|
|
||||||
|
bool homeAssistantConnected()
|
||||||
|
{
|
||||||
|
return enabled && mqtt.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
void homeAssistantLoop()
|
void homeAssistantLoop()
|
||||||
{
|
{
|
||||||
|
if (!enabled)
|
||||||
|
return;
|
||||||
mqtt.loop();
|
mqtt.loop();
|
||||||
if (!firmware_status_sent && firmware_status[0] && mqtt.isConnected())
|
if (!firmware_status_sent && firmware_status[0] && mqtt.isConnected())
|
||||||
firmware_status_sent = firmwareSensor.setValue(firmware_status);
|
firmware_status_sent = firmwareSensor.setValue(firmware_status);
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include "logic/display_controller.h"
|
#include "logic/display_controller.h"
|
||||||
|
#include "logic/settings.h"
|
||||||
|
|
||||||
// Home Assistant integration over MQTT (with discovery):
|
// Home Assistant integration over MQTT (with discovery):
|
||||||
// FlipDot Display Clock switch: on = clock, off = Nintendo logo
|
// FlipDot Display Clock switch: on = clock, off = Nintendo logo
|
||||||
// FlipDot Display Power switch: off = clear the display and switch it off
|
// FlipDot Display Power switch: off = clear the display and switch it off
|
||||||
// FlipDot Display Firmware sensor: version and health, e.g. "2.1.0, ok, 0 crashes, ..."
|
// FlipDot Display Firmware sensor: version and health, e.g. "2.1.0, ok, 0 crashes, ..."
|
||||||
// FlipDot Display Recovery button: restart into the recovery app (for 10 minutes)
|
// FlipDot Display Recovery button: restart into the recovery app (for 10 minutes)
|
||||||
void homeAssistantBegin(DisplayController &controller);
|
//
|
||||||
|
// The broker comes from the settings (web UI); an empty host means BROKER_ADDR, MQTT_USER
|
||||||
|
// and MQTT_PASSWORD from secrets.h. Changes need a restart.
|
||||||
|
void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettings &settings);
|
||||||
void homeAssistantLoop();
|
void homeAssistantLoop();
|
||||||
|
bool homeAssistantConnected();
|
||||||
|
|
||||||
// Publish the firmware status (only sent when it changes, and again after a reconnect)
|
// Publish the firmware status (only sent when it changes, and again after a reconnect)
|
||||||
void homeAssistantSetFirmwareStatus(const char *status);
|
void homeAssistantSetFirmwareStatus(const char *status);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#include "system/settings_store.h"
|
||||||
|
|
||||||
|
#include <Preferences.h>
|
||||||
|
|
||||||
|
static const char *NAMESPACE = "flipdot";
|
||||||
|
static const char *KEY = "settings";
|
||||||
|
// Increase when the Settings struct changes; old records are then ignored (defaults)
|
||||||
|
static const uint16_t VERSION = 1;
|
||||||
|
|
||||||
|
struct StoredSettings
|
||||||
|
{
|
||||||
|
uint16_t version;
|
||||||
|
uint16_t size;
|
||||||
|
Settings settings;
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSettings(Settings &settings)
|
||||||
|
{
|
||||||
|
settings = Settings();
|
||||||
|
StoredSettings stored;
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin(NAMESPACE, true);
|
||||||
|
size_t read = prefs.getBytes(KEY, &stored, sizeof(stored));
|
||||||
|
prefs.end();
|
||||||
|
if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings) &&
|
||||||
|
validateSettings(stored.settings) == nullptr)
|
||||||
|
settings = stored.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
void saveSettings(const Settings &settings)
|
||||||
|
{
|
||||||
|
StoredSettings stored;
|
||||||
|
stored.version = VERSION;
|
||||||
|
stored.size = sizeof(Settings);
|
||||||
|
stored.settings = settings;
|
||||||
|
Preferences prefs;
|
||||||
|
prefs.begin(NAMESPACE, false);
|
||||||
|
prefs.putBytes(KEY, &stored, sizeof(stored));
|
||||||
|
prefs.end();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "logic/settings.h"
|
||||||
|
|
||||||
|
// The web UI settings in NVS. A missing or outdated record gives the defaults.
|
||||||
|
void loadSettings(Settings &settings);
|
||||||
|
void saveSettings(const Settings &settings);
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <pgmspace.h>
|
||||||
|
|
||||||
|
// The web UI: one page, the browser does the image work (conversion, editor, preview).
|
||||||
|
// API: see web/web_ui.cpp. Bitmaps are 84 x 19, as 418 hex digits in drawBitmap format.
|
||||||
|
static const char WEB_PAGE[] PROGMEM = R"rawliteral(<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>FlipDot Display</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#16181d;--card:#22252c;--line:#363a44;--text:#e8e8e8;--dim:#9aa0aa;--dot:#ffd21f;--off:#2c2f36;--accent:#ffd21f;--bad:#ff6b6b}
|
||||||
|
*{box-sizing:border-box}body{margin:0;font-family:system-ui,sans-serif;background:var(--bg);color:var(--text)}
|
||||||
|
header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;gap:12px;align-items:baseline;flex-wrap:wrap}
|
||||||
|
header h1{font-size:18px;margin:0}#statusline{color:var(--dim);font-size:13px}
|
||||||
|
main{max-width:900px;margin:0 auto;padding:12px 16px}
|
||||||
|
.mirror{background:#000;border-radius:10px;padding:10px}.mirror canvas{width:100%;display:block}
|
||||||
|
#mirrorinfo{color:var(--dim);font-size:13px;margin:6px 2px 12px}
|
||||||
|
nav{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:12px}
|
||||||
|
nav button{background:var(--card);border:1px solid var(--line);color:var(--text);padding:8px 12px;border-radius:8px;cursor:pointer}
|
||||||
|
nav button.on{border-color:var(--accent);color:var(--accent)}
|
||||||
|
section{display:none}section.on{display:block}
|
||||||
|
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:12px}
|
||||||
|
h2{font-size:15px;margin:0 0 10px}
|
||||||
|
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:10px}
|
||||||
|
.item{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:8px;cursor:pointer}
|
||||||
|
.item.cur{border-color:var(--accent)}.item canvas{width:100%;background:#000;border-radius:4px}
|
||||||
|
.item .name{font-size:13px;margin:6px 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.item .tag{color:var(--dim);font-size:11px}
|
||||||
|
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:6px 0}
|
||||||
|
label{font-size:14px}input,select{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px}
|
||||||
|
input[type=checkbox]{transform:scale(1.2)}input[type=range]{padding:0}
|
||||||
|
button.b{background:var(--accent);color:#000;border:0;border-radius:6px;padding:7px 12px;cursor:pointer;font-weight:600}
|
||||||
|
button.s{background:var(--bg);color:var(--text);border:1px solid var(--line);border-radius:6px;padding:6px 10px;cursor:pointer}
|
||||||
|
button.d{color:var(--bad)}
|
||||||
|
#editor{width:100%;background:#000;border-radius:8px;touch-action:none;cursor:crosshair}
|
||||||
|
.form{display:grid;grid-template-columns:max-content 1fr;gap:8px 12px;align-items:center}
|
||||||
|
.hint{color:var(--dim);font-size:12px}#msg{position:fixed;bottom:16px;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--line);padding:8px 14px;border-radius:8px;display:none}
|
||||||
|
</style></head><body>
|
||||||
|
<header><h1>FlipDot Display</h1><span id="statusline">connecting...</span></header>
|
||||||
|
<main>
|
||||||
|
<div class="mirror"><canvas id="mirror" width="840" height="190"></canvas></div>
|
||||||
|
<div id="mirrorinfo"></div>
|
||||||
|
<nav id="tabs"></nav>
|
||||||
|
|
||||||
|
<section id="t-show"><div class="card"><h2>What to show during the day</h2>
|
||||||
|
<div id="livebox" class="row" style="display:none"><b>Live mode is on.</b><button class="s" onclick="stopLive()">Back to normal</button></div>
|
||||||
|
<div class="grid" id="pages"></div>
|
||||||
|
<p class="hint">The schedule (Settings) decides when the display is on. The Home Assistant "Clock" switch shows the Nintendo logo when off.</p></div></section>
|
||||||
|
|
||||||
|
<section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
|
||||||
|
<p class="hint" id="storage"></p></div></section>
|
||||||
|
|
||||||
|
<section id="t-draw"><div class="card"><h2>Draw / upload</h2>
|
||||||
|
<canvas id="editor" width="840" height="190"></canvas>
|
||||||
|
<div class="row"><button class="s" onclick="edClear()">Clear</button><button class="s" onclick="edInvert()">Invert</button>
|
||||||
|
<label><input type="file" id="file" accept="image/*"> Image</label></div>
|
||||||
|
<div class="row" id="imgopts" style="display:none">
|
||||||
|
<label>Threshold <input type="range" id="thr" min="1" max="254" value="128"></label>
|
||||||
|
<label><input type="checkbox" id="inv"> Invert</label><label><input type="checkbox" id="dith"> Dither</label>
|
||||||
|
<label>Fit <select id="fit"><option value="contain">fit</option><option value="cover">fill</option><option value="stretch">stretch</option></select></label></div>
|
||||||
|
<div class="row"><button class="b" onclick="sendLive()">Show on display now</button>
|
||||||
|
<label><input type="checkbox" id="autolive"> live while drawing</label>
|
||||||
|
<button class="s" onclick="stopLive()">Back to normal</button></div>
|
||||||
|
<div class="row"><input id="savename" placeholder="name" maxlength="24"><button class="b" onclick="saveBitmap()">Save as bitmap</button></div>
|
||||||
|
<p class="hint">Click or drag to draw; starting on a yellow dot erases. Images: dark parts become yellow dots (use Invert for the opposite).</p></div></section>
|
||||||
|
|
||||||
|
<section id="t-settings"><div class="card"><h2>Schedule</h2><div class="form">
|
||||||
|
<label>Schedule</label><label><input type="checkbox" id="s_en"> on (off: display always on)</label>
|
||||||
|
<label>Display on from</label><input type="time" id="s_day">
|
||||||
|
<label>Late evening from</label><input type="time" id="s_late">
|
||||||
|
<label>Quarter hours</label><label><input type="checkbox" id="s_q"> late evening: only at :00 :15 :30 :45</label>
|
||||||
|
<label>Display off from</label><input type="text" id="s_night" placeholder="HH:MM, 24:00 = never" size="8">
|
||||||
|
</div></div>
|
||||||
|
<div class="card"><h2>Clock</h2><div class="form">
|
||||||
|
<label>Yawning Pikachu</label><label><input type="checkbox" id="c_ev"> from <input type="time" id="c_evt"></label>
|
||||||
|
<label>No WiFi screen</label><label><input type="checkbox" id="c_nw"> show "No WiFi" when WiFi is down</label>
|
||||||
|
</div></div>
|
||||||
|
<div class="card"><h2>Hardware</h2><div class="form">
|
||||||
|
<label>Flip time</label><label><input type="number" id="h_flip" min="50" max="2000" style="width:6em"> µs (50-2000)</label>
|
||||||
|
<label>Status LED</label><label><input type="checkbox" id="h_led"> blink while the display is on</label>
|
||||||
|
</div></div>
|
||||||
|
<button class="b" onclick="saveSettings()">Save settings</button></section>
|
||||||
|
|
||||||
|
<section id="t-ha"><div class="card"><h2>Home Assistant (MQTT)</h2><div class="form">
|
||||||
|
<label>Enabled</label><input type="checkbox" id="ha_en">
|
||||||
|
<label>Broker</label><input id="ha_host" placeholder="empty: from secrets.h">
|
||||||
|
<label>Port</label><input type="number" id="ha_port" min="1" max="65535" style="width:7em">
|
||||||
|
<label>User</label><input id="ha_user" autocomplete="off">
|
||||||
|
<label>Password</label><input type="password" id="ha_pass" placeholder="unchanged" autocomplete="new-password">
|
||||||
|
</div><p class="hint">Saving restarts the ESP32. Entities: Clock and Power switches, Firmware sensor, Recovery button.</p>
|
||||||
|
<button class="b" onclick="saveHa()">Save and restart</button></div></section>
|
||||||
|
|
||||||
|
<section id="t-system"><div class="card"><h2>System</h2><div class="form" id="sysinfo"></div>
|
||||||
|
<div class="row" style="margin-top:12px"><button class="s" onclick="post('/api/restart').then(()=>msg('Restarting...'))">Restart</button>
|
||||||
|
<button class="s d" onclick="if(confirm('Start the recovery app for 10 minutes?'))post('/api/recovery').then(()=>msg('Starting recovery...'))">Recovery app</button></div>
|
||||||
|
<p class="hint">Firmware updates: <code>pio run -e esp32 -t upload</code></p></div></section>
|
||||||
|
</main><div id="msg"></div>
|
||||||
|
<script>
|
||||||
|
const W=84,H=19,RB=11,$=id=>document.getElementById(id);
|
||||||
|
let settings=null,status=null,bitmapList=[];
|
||||||
|
const tabs=[['show','Show'],['bitmaps','Bitmaps'],['draw','Draw / Live'],['settings','Settings'],['ha','Home Assistant'],['system','System']];
|
||||||
|
tabs.forEach(([id,name],i)=>{const b=document.createElement('button');b.textContent=name;b.onclick=()=>tab(id);b.id='b-'+id;$('tabs').appendChild(b)});
|
||||||
|
function tab(id){tabs.forEach(([t])=>{$('t-'+t).classList.toggle('on',t==id);$('b-'+t).classList.toggle('on',t==id)});location.hash=id}
|
||||||
|
function msg(t,bad){const m=$('msg');m.textContent=t;m.style.color=bad?'var(--bad)':'';m.style.display='block';clearTimeout(m.t);m.t=setTimeout(()=>m.style.display='none',3000)}
|
||||||
|
async function api(p,body){const r=await fetch(p,body===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||||
|
const j=await r.json().catch(()=>({}));if(!r.ok){msg(j.error||('Error '+r.status),true);throw j}return j}
|
||||||
|
const post=(p,b)=>api(p,b||{});
|
||||||
|
// bitmaps: Uint8Array(W*H) of 0/1 <-> hex in drawBitmap format
|
||||||
|
function fromHex(h){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++){const v=parseInt(h.substr((y*RB+(x>>3))*2,2),16);b[y*W+x]=(v>>(7-(x&7)))&1}return b}
|
||||||
|
function toHex(b){let h='';for(let y=0;y<H;y++)for(let i=0;i<RB;i++){let v=0;for(let k=0;k<8;k++){const x=i*8+k;if(x<W&&b[y*W+x])v|=0x80>>k}h+=v.toString(16).padStart(2,'0')}return h}
|
||||||
|
function draw(c,b){const g=c.getContext('2d'),s=c.width/W;g.fillStyle='#000';g.fillRect(0,0,c.width,c.height);
|
||||||
|
for(let y=0;y<H;y++)for(let x=0;x<W;x++){g.fillStyle=b[y*W+x]?'#ffd21f':'#2c2f36';g.beginPath();g.arc(x*s+s/2,y*s+s/2,s*0.42,0,7);g.fill()}}
|
||||||
|
function mini(b){const c=document.createElement('canvas');c.width=336;c.height=76;draw(c,b);return c}
|
||||||
|
const hm=m=>String(Math.floor(m/60)).padStart(2,'0')+':'+String(m%60).padStart(2,'0');
|
||||||
|
// mirror of the display
|
||||||
|
async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'),fromHex(s.data));
|
||||||
|
$('mirrorinfo').textContent=(s.on?'Display on':'Display off (the dots keep the last image)')+(s.live?' - live mode':'')}catch(e){}
|
||||||
|
setTimeout(pollScreen,document.hidden?5000:1500)}
|
||||||
|
async function pollStatus(){try{status=await api('/api/status');
|
||||||
|
$('statusline').textContent=(status.time||'--:--')+' | WiFi '+(status.wifi.rssi||'')+' dBm | '+status.firmware;
|
||||||
|
$('livebox').style.display=status.live?'flex':'none';sysinfo()}catch(e){}setTimeout(pollStatus,5000)}
|
||||||
|
function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Uptime',Math.floor(status.uptime/3600)+' h '+Math.floor(status.uptime%3600/60)+' min'],
|
||||||
|
['WiFi',status.wifi.ssid+' ('+status.wifi.ip+', '+status.wifi.rssi+' dBm)'],['Home Assistant',status.ha.enabled?(status.ha.connected?'connected':'not connected'):'disabled'],
|
||||||
|
['HA switches','Clock '+(status.clock_enabled?'on':'off')+', Power '+(status.power_enabled?'on':'off')],
|
||||||
|
['Storage',status.storage.available?Math.round(status.storage.used/1024)+' of '+Math.round(status.storage.total/1024)+' KB used':'not available']];
|
||||||
|
$('sysinfo').innerHTML='';r.forEach(([k,v])=>{const a=document.createElement('label');a.textContent=k;const b=document.createElement('span');b.textContent=v;$('sysinfo').append(a,b)})}
|
||||||
|
// pages and bitmaps
|
||||||
|
async function loadBitmaps(){const l=await api('/api/bitmaps');bitmapList=l.bitmaps;
|
||||||
|
$('storage').textContent=l.storage?'':'Bitmap storage not available: install the new flash layout (see README).';
|
||||||
|
const pages=$('pages'),bms=$('bitmaps');pages.innerHTML='';bms.innerHTML='';
|
||||||
|
pages.appendChild(card('Clock','Pikachu with the time',null,()=>selectPage('clock'),settings&&settings.page=='clock'));
|
||||||
|
for(const bm of bitmapList){const data=fromHex(bm.data);bm.bits=data;
|
||||||
|
pages.appendChild(card(bm.name,bm.builtin?'built-in':'',data,()=>selectPage(bm.name),settings&&settings.page==bm.name));
|
||||||
|
const c=card(bm.name,bm.builtin?'built-in':'',data,null);const r=document.createElement('div');r.className='row';
|
||||||
|
r.append(btn('Show',()=>selectPage(bm.name)),btn('Live',()=>live(data)),btn('Edit',()=>{ed.set(data.slice());$('savename').value=bm.builtin?'':bm.name;tab('draw')}));
|
||||||
|
if(!bm.builtin)r.append(btn('Delete',()=>delBitmap(bm.name),'d'));c.appendChild(r);bms.appendChild(c)}}
|
||||||
|
function btn(t,f,cls){const b=document.createElement('button');b.className='s '+(cls||'');b.textContent=t;b.onclick=e=>{e.stopPropagation();f()};return b}
|
||||||
|
function card(name,tag,bits,onclick,cur){const d=document.createElement('div');d.className='item'+(cur?' cur':'');
|
||||||
|
if(bits)d.appendChild(mini(bits));else{const c=document.createElement('div');c.style.cssText='height:76px;display:flex;align-items:center;justify-content:center;font-size:28px;background:#000;border-radius:4px;color:#ffd21f';c.textContent='12:34';d.appendChild(c)}
|
||||||
|
const n=document.createElement('div');n.className='name';n.textContent=name;const t=document.createElement('div');t.className='tag';t.textContent=tag;d.append(n,t);
|
||||||
|
if(onclick)d.onclick=onclick;return d}
|
||||||
|
async function selectPage(p){await post('/api/page',{page:p});settings.page=p;msg('Page: '+p);loadBitmaps()}
|
||||||
|
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');loadBitmaps()}
|
||||||
|
async function live(bits){await post('/api/live',{data:toHex(bits)});msg('Shown on the display')}
|
||||||
|
async function stopLive(){await post('/api/live/stop');msg('Back to normal')}
|
||||||
|
// editor
|
||||||
|
const ed={bits:new Uint8Array(W*H),c:$('editor'),set(b){this.bits=b;draw(this.c,b)}};ed.set(ed.bits);
|
||||||
|
let painting=null,liveTimer=null,srcImg=null;
|
||||||
|
function edPos(e){const r=ed.c.getBoundingClientRect();return[Math.floor((e.clientX-r.left)/r.width*W),Math.floor((e.clientY-r.top)/r.height*H)]}
|
||||||
|
ed.c.onpointerdown=e=>{const[x,y]=edPos(e);if(x<0||x>=W||y<0||y>=H)return;painting=ed.bits[y*W+x]?0:1;ed.c.setPointerCapture(e.pointerId);paint(e)};
|
||||||
|
ed.c.onpointermove=e=>{if(painting!==null)paint(e)};ed.c.onpointerup=()=>{painting=null};
|
||||||
|
function paint(e){const[x,y]=edPos(e);if(x<0||x>=W||y<0||y>=H)return;if(ed.bits[y*W+x]!=painting){ed.bits[y*W+x]=painting;draw(ed.c,ed.bits);changed()}}
|
||||||
|
function changed(){if(!$('autolive').checked)return;clearTimeout(liveTimer);liveTimer=setTimeout(()=>live(ed.bits),300)}
|
||||||
|
function edClear(){ed.set(new Uint8Array(W*H));changed()}
|
||||||
|
function edInvert(){ed.set(ed.bits.map(v=>1-v));changed()}
|
||||||
|
async function sendLive(){await live(ed.bits)}
|
||||||
|
async function saveBitmap(){const n=$('savename').value.trim();if(!n){msg('Enter a name',true);return}
|
||||||
|
await post('/api/bitmap',{name:n,data:toHex(ed.bits)});msg('Saved '+n);loadBitmaps()}
|
||||||
|
// image conversion
|
||||||
|
$('file').onchange=e=>{const f=e.target.files[0];if(!f)return;const img=new Image();img.onload=()=>{srcImg=img;$('imgopts').style.display='flex';convert()};img.src=URL.createObjectURL(f)};
|
||||||
|
['thr','inv','dith','fit'].forEach(id=>$(id).oninput=convert);
|
||||||
|
function convert(){if(!srcImg)return;const c=document.createElement('canvas');c.width=W;c.height=H;const g=c.getContext('2d');
|
||||||
|
g.fillStyle='#fff';g.fillRect(0,0,W,H);const fit=$('fit').value,iw=srcImg.width,ih=srcImg.height;let dw=W,dh=H;
|
||||||
|
if(fit!='stretch'){const s=fit=='contain'?Math.min(W/iw,H/ih):Math.max(W/iw,H/ih);dw=iw*s;dh=ih*s}
|
||||||
|
g.drawImage(srcImg,(W-dw)/2,(H-dh)/2,dw,dh);const d=g.getImageData(0,0,W,H).data,l=new Float32Array(W*H);
|
||||||
|
for(let i=0;i<W*H;i++)l[i]=0.299*d[i*4]+0.587*d[i*4+1]+0.114*d[i*4+2];
|
||||||
|
const thr=+$('thr').value,b=new Uint8Array(W*H),dith=$('dith').checked,inv=$('inv').checked;
|
||||||
|
for(let y=0;y<H;y++)for(let x=0;x<W;x++){const i=y*W+x,o=l[i]<thr?0:255;b[i]=(o==0)!=inv?1:0;
|
||||||
|
if(dith){const e=l[i]-o;if(x+1<W)l[i+1]+=e*7/16;if(y+1<H){if(x>0)l[i+W-1]+=e*3/16;l[i+W]+=e*5/16;if(x+1<W)l[i+W+1]+=e/16}}}
|
||||||
|
ed.set(b);changed()}
|
||||||
|
// settings
|
||||||
|
function fill(){const s=settings;$('s_en').checked=s.schedule.enabled;$('s_day').value=s.schedule.day_start;$('s_late').value=s.schedule.late_start;
|
||||||
|
$('s_q').checked=s.schedule.quarter_hours;$('s_night').value=s.schedule.night_start;$('c_ev').checked=s.clock.evening_enabled;$('c_evt').value=s.clock.evening_start;
|
||||||
|
$('c_nw').checked=s.clock.no_wifi_screen;$('h_flip').value=s.flip_time;$('h_led').checked=s.status_led;
|
||||||
|
$('ha_en').checked=s.home_assistant.enabled;$('ha_host').value=s.home_assistant.host;$('ha_port').value=s.home_assistant.port;$('ha_user').value=s.home_assistant.user;
|
||||||
|
$('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none'}
|
||||||
|
function collect(){const s=JSON.parse(JSON.stringify(settings));
|
||||||
|
Object.assign(s.schedule,{enabled:$('s_en').checked,day_start:$('s_day').value,late_start:$('s_late').value,quarter_hours:$('s_q').checked,night_start:$('s_night').value.trim()});
|
||||||
|
Object.assign(s.clock,{evening_enabled:$('c_ev').checked,evening_start:$('c_evt').value,no_wifi_screen:$('c_nw').checked});
|
||||||
|
s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked;
|
||||||
|
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim()});
|
||||||
|
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;return s}
|
||||||
|
async function saveSettings(){const r=await post('/api/settings',collect());settings=r.settings;fill();msg(r.restart?'Saved, restarting...':'Settings saved')}
|
||||||
|
async function saveHa(){await saveSettings()}
|
||||||
|
async function start(){settings=await api('/api/settings');fill();tab((location.hash||'#show').slice(1));pollScreen();pollStatus();loadBitmaps()}
|
||||||
|
start();
|
||||||
|
</script></body></html>)rawliteral";
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
#include "web/web_ui.h"
|
||||||
|
|
||||||
|
#include <ArduinoJson.h>
|
||||||
|
#include <WebServer.h>
|
||||||
|
#include <WiFi.h>
|
||||||
|
#include <time.h>
|
||||||
|
|
||||||
|
#include "app.h"
|
||||||
|
#include "config.h"
|
||||||
|
#include "display/bitmap_store.h"
|
||||||
|
#include "logic/bitmap_format.h"
|
||||||
|
#include "net/home_assistant.h"
|
||||||
|
#include "system/boot_target.h"
|
||||||
|
#include "system/firmware_guard.h"
|
||||||
|
#include "web/web_page.h"
|
||||||
|
|
||||||
|
static WebServer server(80);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void sendJson(int code, const JsonDocument &doc)
|
||||||
|
{
|
||||||
|
String out;
|
||||||
|
serializeJson(doc, out);
|
||||||
|
server.send(code, "application/json", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sendError(int code, const char *message)
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["error"] = message;
|
||||||
|
sendJson(code, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void sendOk()
|
||||||
|
{
|
||||||
|
server.send(200, "application/json", "{\"ok\":true}");
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool readBody(JsonDocument &doc)
|
||||||
|
{
|
||||||
|
if (deserializeJson(doc, server.arg("plain")))
|
||||||
|
{
|
||||||
|
sendError(400, "Invalid JSON");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void copyText(JsonVariantConst value, char *out, size_t size)
|
||||||
|
{
|
||||||
|
if (!value.is<const char *>())
|
||||||
|
return;
|
||||||
|
strncpy(out, value.as<const char *>(), size - 1);
|
||||||
|
out[size - 1] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void copyBool(JsonVariantConst value, bool &out)
|
||||||
|
{
|
||||||
|
if (value.is<bool>())
|
||||||
|
out = value.as<bool>();
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool copyTime(JsonVariantConst value, uint16_t &out)
|
||||||
|
{
|
||||||
|
if (value.isNull())
|
||||||
|
return true;
|
||||||
|
return parseTime(value.as<const char *>(), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page and status
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void handleRoot()
|
||||||
|
{
|
||||||
|
server.send_P(200, "text/html", WEB_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleStatus()
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["version"] = FIRMWARE_VERSION;
|
||||||
|
doc["firmware"] = firmwareGuardStatus();
|
||||||
|
doc["uptime"] = millis() / 1000;
|
||||||
|
|
||||||
|
struct tm now = {};
|
||||||
|
if (getLocalTime(&now, 0))
|
||||||
|
{
|
||||||
|
char time[6];
|
||||||
|
formatTime(now.tm_hour * 60 + now.tm_min, time);
|
||||||
|
doc["time"] = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonObject wifi = doc["wifi"].to<JsonObject>();
|
||||||
|
wifi["ssid"] = WiFi.SSID();
|
||||||
|
wifi["ip"] = WiFi.localIP().toString();
|
||||||
|
wifi["rssi"] = WiFi.RSSI();
|
||||||
|
|
||||||
|
doc["display_on"] = nano.isOn();
|
||||||
|
doc["live"] = controller.liveActive();
|
||||||
|
doc["page"] = appSettings().page;
|
||||||
|
doc["clock_enabled"] = controller.clockEnabled();
|
||||||
|
doc["power_enabled"] = controller.powerEnabled();
|
||||||
|
|
||||||
|
JsonObject ha = doc["ha"].to<JsonObject>();
|
||||||
|
ha["enabled"] = appSettings().home_assistant.enabled;
|
||||||
|
ha["connected"] = homeAssistantConnected();
|
||||||
|
|
||||||
|
size_t used, total;
|
||||||
|
bitmapStorageUsage(used, total);
|
||||||
|
JsonObject storage = doc["storage"].to<JsonObject>();
|
||||||
|
storage["available"] = bitmapStorageAvailable();
|
||||||
|
storage["used"] = used;
|
||||||
|
storage["total"] = total;
|
||||||
|
|
||||||
|
sendJson(200, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the dots show now, from the driver's shadow copy
|
||||||
|
static void handleScreen()
|
||||||
|
{
|
||||||
|
uint8_t bitmap[BITMAP_BYTES] = {};
|
||||||
|
for (int y = 0; y < BITMAP_HEIGHT; y++)
|
||||||
|
for (int x = 0; x < BITMAP_WIDTH; x++)
|
||||||
|
setBitmapPixel(bitmap, x, y, display.shownPixel(x, y));
|
||||||
|
char hex[BITMAP_HEX_LENGTH + 1];
|
||||||
|
bitmapToHex(bitmap, hex);
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["on"] = nano.isOn();
|
||||||
|
doc["live"] = controller.liveActive();
|
||||||
|
doc["data"] = hex;
|
||||||
|
sendJson(200, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bitmaps
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Streamed: with many bitmaps the JSON is too big to build in memory at once
|
||||||
|
static void handleBitmaps()
|
||||||
|
{
|
||||||
|
server.setContentLength(CONTENT_LENGTH_UNKNOWN);
|
||||||
|
server.send(200, "application/json", "");
|
||||||
|
server.sendContent(bitmapStorageAvailable() ? "{\"storage\":true,\"bitmaps\":[" : "{\"storage\":false,\"bitmaps\":[");
|
||||||
|
|
||||||
|
bool first = true;
|
||||||
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
char hex[BITMAP_HEX_LENGTH + 1];
|
||||||
|
for (const BitmapInfo &info : listBitmaps())
|
||||||
|
{
|
||||||
|
if (!loadBitmap(info.name.c_str(), bitmap))
|
||||||
|
continue;
|
||||||
|
bitmapToHex(bitmap, hex);
|
||||||
|
// Names only contain letters, digits, space, '-' and '_': no JSON escaping needed
|
||||||
|
String item = String(first ? "" : ",") + "{\"name\":\"" + info.name + "\",\"builtin\":" +
|
||||||
|
(info.builtin ? "true" : "false") + ",\"data\":\"" + hex + "\"}";
|
||||||
|
server.sendContent(item);
|
||||||
|
first = false;
|
||||||
|
}
|
||||||
|
server.sendContent("]}");
|
||||||
|
server.sendContent("");
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleBitmapGet()
|
||||||
|
{
|
||||||
|
String name = server.arg("name");
|
||||||
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
if (!loadBitmap(name.c_str(), bitmap))
|
||||||
|
{
|
||||||
|
sendError(404, "Bitmap not found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
char hex[BITMAP_HEX_LENGTH + 1];
|
||||||
|
bitmapToHex(bitmap, hex);
|
||||||
|
JsonDocument doc;
|
||||||
|
doc["name"] = name;
|
||||||
|
doc["data"] = hex;
|
||||||
|
sendJson(200, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleBitmapSave()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
const char *name = body["name"] | "";
|
||||||
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
if (!bitmapFromHex(body["data"] | "", bitmap))
|
||||||
|
{
|
||||||
|
sendError(400, "Invalid bitmap data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const char *error = saveBitmap(name, bitmap);
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
sendError(400, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appBitmapChanged(name);
|
||||||
|
sendOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleBitmapDelete()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
const char *name = body["name"] | "";
|
||||||
|
const char *error = deleteBitmap(name);
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
sendError(400, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (strcmp(appSettings().page, name) == 0)
|
||||||
|
appSelectPage(PAGE_CLOCK);
|
||||||
|
sendOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page and live mode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void handlePage()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
const char *error = appSelectPage(body["page"] | "");
|
||||||
|
if (error)
|
||||||
|
sendError(400, error);
|
||||||
|
else
|
||||||
|
sendOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleLive()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
uint8_t bitmap[BITMAP_BYTES];
|
||||||
|
if (!bitmapFromHex(body["data"] | "", bitmap))
|
||||||
|
{
|
||||||
|
sendError(400, "Invalid bitmap data");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!controller.powerEnabled())
|
||||||
|
{
|
||||||
|
sendError(409, "The display is switched off in Home Assistant (Power switch)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
appShowLive(bitmap);
|
||||||
|
sendOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleLiveStop()
|
||||||
|
{
|
||||||
|
appStopLive();
|
||||||
|
sendOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void settingsToJson(const Settings &s, JsonObject o)
|
||||||
|
{
|
||||||
|
char time[6];
|
||||||
|
o["page"] = s.page;
|
||||||
|
o["flip_time"] = s.flip_time;
|
||||||
|
o["status_led"] = s.status_led;
|
||||||
|
|
||||||
|
JsonObject schedule = o["schedule"].to<JsonObject>();
|
||||||
|
schedule["enabled"] = s.schedule.enabled;
|
||||||
|
formatTime(s.schedule.day_start, time);
|
||||||
|
schedule["day_start"] = String(time);
|
||||||
|
formatTime(s.schedule.late_start, time);
|
||||||
|
schedule["late_start"] = String(time);
|
||||||
|
schedule["quarter_hours"] = s.schedule.quarter_hours;
|
||||||
|
formatTime(s.schedule.night_start, time);
|
||||||
|
schedule["night_start"] = String(time);
|
||||||
|
|
||||||
|
JsonObject clock = o["clock"].to<JsonObject>();
|
||||||
|
clock["evening_enabled"] = s.clock.evening_enabled;
|
||||||
|
formatTime(s.clock.evening_start, time);
|
||||||
|
clock["evening_start"] = String(time);
|
||||||
|
clock["no_wifi_screen"] = s.clock.no_wifi_screen;
|
||||||
|
|
||||||
|
JsonObject ha = o["home_assistant"].to<JsonObject>();
|
||||||
|
ha["enabled"] = s.home_assistant.enabled;
|
||||||
|
ha["host"] = s.home_assistant.host;
|
||||||
|
ha["port"] = s.home_assistant.port;
|
||||||
|
ha["user"] = s.home_assistant.user;
|
||||||
|
ha["password_set"] = s.home_assistant.password[0] != '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleSettingsGet()
|
||||||
|
{
|
||||||
|
JsonDocument doc;
|
||||||
|
settingsToJson(appSettings(), doc.to<JsonObject>());
|
||||||
|
sendJson(200, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the fields in the request change
|
||||||
|
static void handleSettingsPost()
|
||||||
|
{
|
||||||
|
JsonDocument body;
|
||||||
|
if (!readBody(body))
|
||||||
|
return;
|
||||||
|
Settings s = appSettings();
|
||||||
|
|
||||||
|
copyText(body["page"], s.page, sizeof(s.page));
|
||||||
|
if (body["flip_time"].is<int>())
|
||||||
|
s.flip_time = body["flip_time"].as<int>();
|
||||||
|
copyBool(body["status_led"], s.status_led);
|
||||||
|
|
||||||
|
JsonVariantConst schedule = body["schedule"];
|
||||||
|
copyBool(schedule["enabled"], s.schedule.enabled);
|
||||||
|
copyBool(schedule["quarter_hours"], s.schedule.quarter_hours);
|
||||||
|
JsonVariantConst clock = body["clock"];
|
||||||
|
copyBool(clock["evening_enabled"], s.clock.evening_enabled);
|
||||||
|
copyBool(clock["no_wifi_screen"], s.clock.no_wifi_screen);
|
||||||
|
if (!copyTime(schedule["day_start"], s.schedule.day_start) || !copyTime(schedule["late_start"], s.schedule.late_start) ||
|
||||||
|
!copyTime(schedule["night_start"], s.schedule.night_start) || !copyTime(clock["evening_start"], s.clock.evening_start))
|
||||||
|
{
|
||||||
|
sendError(400, "Times must look like 08:30 (00:00 - 24:00)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonVariantConst ha = body["home_assistant"];
|
||||||
|
copyBool(ha["enabled"], s.home_assistant.enabled);
|
||||||
|
copyText(ha["host"], s.home_assistant.host, sizeof(s.home_assistant.host));
|
||||||
|
if (ha["port"].is<int>())
|
||||||
|
s.home_assistant.port = ha["port"].as<int>();
|
||||||
|
copyText(ha["user"], s.home_assistant.user, sizeof(s.home_assistant.user));
|
||||||
|
copyText(ha["password"], s.home_assistant.password, sizeof(s.home_assistant.password));
|
||||||
|
|
||||||
|
bool restart = false;
|
||||||
|
const char *error = appSaveSettings(s, restart);
|
||||||
|
if (error)
|
||||||
|
{
|
||||||
|
sendError(400, error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonDocument doc;
|
||||||
|
settingsToJson(appSettings(), doc["settings"].to<JsonObject>());
|
||||||
|
doc["restart"] = restart;
|
||||||
|
sendJson(200, doc);
|
||||||
|
if (restart)
|
||||||
|
appRestartLater(1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// System
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static void handleRestart()
|
||||||
|
{
|
||||||
|
sendOk();
|
||||||
|
appRestartLater(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void handleRecovery()
|
||||||
|
{
|
||||||
|
if (!recoveryAvailable())
|
||||||
|
{
|
||||||
|
sendError(409, "No recovery app installed (see README, one-time USB installation)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendOk();
|
||||||
|
delay(300); // let the response reach the browser
|
||||||
|
rebootToRecovery(RecoveryReason::Manual);
|
||||||
|
}
|
||||||
|
|
||||||
|
void webUiBegin()
|
||||||
|
{
|
||||||
|
server.on("/", HTTP_GET, handleRoot);
|
||||||
|
server.on("/api/status", HTTP_GET, handleStatus);
|
||||||
|
server.on("/api/screen", HTTP_GET, handleScreen);
|
||||||
|
server.on("/api/bitmaps", HTTP_GET, handleBitmaps);
|
||||||
|
server.on("/api/bitmap", HTTP_GET, handleBitmapGet);
|
||||||
|
server.on("/api/bitmap", HTTP_POST, handleBitmapSave);
|
||||||
|
server.on("/api/bitmap/delete", HTTP_POST, handleBitmapDelete);
|
||||||
|
server.on("/api/page", HTTP_POST, handlePage);
|
||||||
|
server.on("/api/live", HTTP_POST, handleLive);
|
||||||
|
server.on("/api/live/stop", HTTP_POST, handleLiveStop);
|
||||||
|
server.on("/api/settings", HTTP_GET, handleSettingsGet);
|
||||||
|
server.on("/api/settings", HTTP_POST, handleSettingsPost);
|
||||||
|
server.on("/api/restart", HTTP_POST, handleRestart);
|
||||||
|
server.on("/api/recovery", HTTP_POST, handleRecovery);
|
||||||
|
server.onNotFound([]() { sendError(404, "Not found"); });
|
||||||
|
server.begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
void webUiLoop()
|
||||||
|
{
|
||||||
|
server.handleClient();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Web UI of the clock firmware on port 80 (http://<ESP32 IP>/): live view of the display,
|
||||||
|
// choosing the page, bitmaps (upload, draw, store), live mode, settings and Home
|
||||||
|
// Assistant. The page itself is in web/web_page.h.
|
||||||
|
//
|
||||||
|
// JSON API (bitmaps are 418 hex digits, see logic/bitmap_format.h):
|
||||||
|
// GET /api/status time, WiFi, firmware, Home Assistant, storage
|
||||||
|
// GET /api/screen what the dots show now {on, live, data}
|
||||||
|
// GET /api/bitmaps all bitmaps with data {storage, bitmaps:[{name, builtin, data}]}
|
||||||
|
// GET /api/bitmap?name= one bitmap {name, data}
|
||||||
|
// POST /api/bitmap store {name, data}
|
||||||
|
// POST /api/bitmap/delete {name}
|
||||||
|
// POST /api/page {page: "clock" or a bitmap name}
|
||||||
|
// POST /api/live show now {data}
|
||||||
|
// POST /api/live/stop
|
||||||
|
// GET /api/settings (the MQTT password is never sent, only password_set)
|
||||||
|
// POST /api/settings changed fields; {settings, restart}
|
||||||
|
// POST /api/restart
|
||||||
|
// POST /api/recovery
|
||||||
|
void webUiBegin();
|
||||||
|
void webUiLoop();
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# ESP32: recovery app
|
||||||
|
|
||||||
|
PlatformIO environment: `recovery` (USB only)
|
||||||
|
|
||||||
|
A small second app on the ESP32 that only does WiFi, a setup web page and OTA. It lives in
|
||||||
|
the `factory` partition (see [partitions.csv](../../partitions.csv)), so OTA updates of
|
||||||
|
the clock firmware never change it. Its job: the ESP32 stays reachable, whatever happens
|
||||||
|
to the clock firmware, so it never needs USB again.
|
||||||
|
|
||||||
|
Installing it and using it as a user is described in the
|
||||||
|
[main README](../../README.md#recovery-app). This file is about how it works.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Contents |
|
||||||
|
|------|----------|
|
||||||
|
| `main.cpp` | `setup()` / `loop()`: WiFi (station + setup access point), OTA, status on the display, when to start the clock firmware again |
|
||||||
|
| `web_portal.*` | Setup page on port 80 and the captive portal (DNS) of the setup WiFi |
|
||||||
|
| `recovery_state.h` | State shared by `main.cpp` and the web portal |
|
||||||
|
|
||||||
|
It also uses code from the clock firmware (see `build_src_filter` of the `recovery`
|
||||||
|
environment in `platformio.ini`):
|
||||||
|
|
||||||
|
| From `src/esp32/` | Used for |
|
||||||
|
|-------------------|----------|
|
||||||
|
| `logic/boot_policy.*` | Recovery reasons, when to start the clock firmware again (tested in `test/test_esp32`) |
|
||||||
|
| `system/system_store.*` | NVS: WiFi credentials, recovery reason, clock firmware slot and version |
|
||||||
|
| `system/boot_target.*` | Starting the clock firmware in `ota_0` / `ota_1` |
|
||||||
|
| `nano_link/nano_link.*` | Relay and serial link to the Nano, for the status messages |
|
||||||
|
| `display/status_screen.*`, `display/text.*` | Two-line status messages on the display |
|
||||||
|
|
||||||
|
## How it starts
|
||||||
|
|
||||||
|
The clock firmware calls `rebootToRecovery(reason)`: it stores the reason in NVS, selects
|
||||||
|
the `factory` partition (this erases otadata) and restarts. The bootloader also falls back
|
||||||
|
to the recovery app by itself when neither `ota_0` nor `ota_1` holds a working firmware.
|
||||||
|
|
||||||
|
| Reason | Set by | Back to the clock firmware |
|
||||||
|
|--------|--------|----------------------------|
|
||||||
|
| `Wifi` | clock firmware: no WiFi at boot (30 s) or for 10 min | when WiFi works, after at least 1 min |
|
||||||
|
| `Crash` | clock firmware: 3 crashes in a row | after 10 min |
|
||||||
|
| `Manual` | Home Assistant button, web UI "Recovery app" | after 10 min |
|
||||||
|
| `PowerCycle` | clock firmware: 3 quick power cycles | after 10 min |
|
||||||
|
| `None` | bootloader fallback, or a fresh install | after 10 min, if a clock firmware exists |
|
||||||
|
|
||||||
|
"Stay in recovery" on the setup page turns the automatic start off; "Start the clock
|
||||||
|
firmware now" starts it right away. The decision is `recoveryShouldStartMain()` in
|
||||||
|
`logic/boot_policy.cpp`.
|
||||||
|
|
||||||
|
`startMainFirmware()` selects the slot that ran last (stored in NVS by the clock firmware),
|
||||||
|
or the other one. `esp_ota_set_boot_partition()` checks the whole image first, so a
|
||||||
|
broken slot is skipped. The clock firmware then starts as `PENDING_VERIFY` and must pass
|
||||||
|
the 60 s check (see the [ESP32 README](../esp32/README.md#keeping-ota-alive)), or the
|
||||||
|
bootloader comes back here.
|
||||||
|
|
||||||
|
## WiFi
|
||||||
|
|
||||||
|
- **Station:** the WiFi from NVS (set on the setup page), or `WIFI_SSID` / `WIFI_PASSWORD`
|
||||||
|
from `src/esp32/secrets.h`. Retried every 30 s; auto reconnect is off so the retries
|
||||||
|
are under control.
|
||||||
|
- **Setup access point** `FlipDot-Setup` (password `SETUP_AP_PASSWORD`): opened when the
|
||||||
|
station hasn't connected for 20 s. While a phone is connected to it, the station
|
||||||
|
retries pause, because they make the access point unstable. Closed 2 minutes after
|
||||||
|
the station works again (if nobody is connected).
|
||||||
|
- **Captive portal:** while the access point is open, a DNS server answers every name
|
||||||
|
with 192.168.4.1, and unknown pages redirect to the setup page, so phones open it by
|
||||||
|
themselves.
|
||||||
|
|
||||||
|
## Setup page
|
||||||
|
|
||||||
|
`GET /` shows the reason, WiFi state, the clock firmware in both slots and when it starts
|
||||||
|
again. `POST /wifi` stores new WiFi credentials and connects, `POST /start` starts the clock
|
||||||
|
firmware, `POST /stay` turns the automatic start off. There is no login: on your own
|
||||||
|
WiFi it has the same trust as OTA, on the setup WiFi the WiFi password protects it.
|
||||||
|
|
||||||
|
## Display
|
||||||
|
|
||||||
|
`StatusScreen` shows `Recovery` + reason, `WiFi setup:` + the access point name, or
|
||||||
|
`Recovery` + the IP address, whichever applies. After each message the display is switched
|
||||||
|
off again; the dots keep the image. New messages at most every 30 s.
|
||||||
|
|
||||||
|
## OTA
|
||||||
|
|
||||||
|
ArduinoOTA works on both networks, like in the clock firmware:
|
||||||
|
`pio run -e esp32 -t upload` (add `--upload-port 192.168.4.1` on the setup WiFi). An upload
|
||||||
|
from here always goes to `ota_0` (the first OTA slot after `factory`) and starts right after.
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
- A task watchdog (60 s) restarts the app when `loop()` hangs. After that restart the
|
||||||
|
bootloader starts the recovery app again (otadata still points to `factory`).
|
||||||
|
- The watchdog is paused during an OTA upload.
|
||||||
|
- Keep this app small and simple: it is the last line of defence, and changing it needs
|
||||||
|
USB (`pio run -e recovery -t upload`). It must fit in the 1 MB `factory` partition
|
||||||
|
(about 840 KB now).
|
||||||
+328
-25
@@ -12,10 +12,12 @@
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "logic/bitmap_format.h"
|
||||||
#include "logic/boot_policy.h"
|
#include "logic/boot_policy.h"
|
||||||
#include "logic/display_controller.h"
|
#include "logic/display_controller.h"
|
||||||
#include "logic/power_timer.h"
|
#include "logic/power_timer.h"
|
||||||
#include "logic/schedule.h"
|
#include "logic/schedule.h"
|
||||||
|
#include "logic/settings.h"
|
||||||
#include "logic/status_led.h"
|
#include "logic/status_led.h"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -47,12 +49,14 @@ class FakeFrames : public Frames
|
|||||||
public:
|
public:
|
||||||
std::vector<std::string> shown;
|
std::vector<std::string> shown;
|
||||||
|
|
||||||
void clock(int hour, int minute) override
|
void clock(int hour, int minute, bool evening) override
|
||||||
{
|
{
|
||||||
char s[16];
|
char s[24];
|
||||||
snprintf(s, sizeof(s), "clock %02d:%02d", hour, minute);
|
snprintf(s, sizeof(s), "clock %02d:%02d%s", hour, minute, evening ? " evening" : "");
|
||||||
shown.push_back(s);
|
shown.push_back(s);
|
||||||
}
|
}
|
||||||
|
void page() override { shown.push_back("page"); }
|
||||||
|
void live() override { shown.push_back("live"); }
|
||||||
void noWifi(int hour, int minute) override
|
void noWifi(int hour, int minute) override
|
||||||
{
|
{
|
||||||
char s[16];
|
char s[16];
|
||||||
@@ -65,6 +69,8 @@ public:
|
|||||||
std::string last() const { return shown.empty() ? "" : shown.back(); }
|
std::string last() const { return shown.empty() ? "" : shown.back(); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static const Settings defaults;
|
||||||
|
|
||||||
static FakePower power;
|
static FakePower power;
|
||||||
static FakeFrames frames;
|
static FakeFrames frames;
|
||||||
static DisplayController *controller;
|
static DisplayController *controller;
|
||||||
@@ -100,42 +106,42 @@ static void run(int hour, int minute, uint32_t ms = 0)
|
|||||||
|
|
||||||
void test_schedule_night_until_8()
|
void test_schedule_night_until_8()
|
||||||
{
|
{
|
||||||
TEST_ASSERT_TRUE(scheduleFor(0, 0) == ScheduleMode::Night);
|
TEST_ASSERT_TRUE(scheduleFor(0, 0, defaults.schedule) == ScheduleMode::Night);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(7, 0) == ScheduleMode::Night);
|
TEST_ASSERT_TRUE(scheduleFor(7, 0, defaults.schedule) == ScheduleMode::Night);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(7, 59) == ScheduleMode::Night);
|
TEST_ASSERT_TRUE(scheduleFor(7, 59, defaults.schedule) == ScheduleMode::Night);
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_schedule_clock_from_8_to_22()
|
void test_schedule_clock_from_8_to_22()
|
||||||
{
|
{
|
||||||
TEST_ASSERT_TRUE(scheduleFor(8, 0) == ScheduleMode::Clock);
|
TEST_ASSERT_TRUE(scheduleFor(8, 0, defaults.schedule) == ScheduleMode::Clock);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(12, 34) == ScheduleMode::Clock);
|
TEST_ASSERT_TRUE(scheduleFor(12, 34, defaults.schedule) == ScheduleMode::Clock);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(21, 59) == ScheduleMode::Clock);
|
TEST_ASSERT_TRUE(scheduleFor(21, 59, defaults.schedule) == ScheduleMode::Clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_schedule_quarter_hours_until_23_30()
|
void test_schedule_quarter_hours_until_23_30()
|
||||||
{
|
{
|
||||||
TEST_ASSERT_TRUE(scheduleFor(22, 0) == ScheduleMode::Clock);
|
TEST_ASSERT_TRUE(scheduleFor(22, 0, defaults.schedule) == ScheduleMode::Clock);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(22, 1) == ScheduleMode::Standby);
|
TEST_ASSERT_TRUE(scheduleFor(22, 1, defaults.schedule) == ScheduleMode::Standby);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(22, 14) == ScheduleMode::Standby);
|
TEST_ASSERT_TRUE(scheduleFor(22, 14, defaults.schedule) == ScheduleMode::Standby);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(22, 15) == ScheduleMode::Clock);
|
TEST_ASSERT_TRUE(scheduleFor(22, 15, defaults.schedule) == ScheduleMode::Clock);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(22, 45) == ScheduleMode::Clock);
|
TEST_ASSERT_TRUE(scheduleFor(22, 45, defaults.schedule) == ScheduleMode::Clock);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(23, 0) == ScheduleMode::Clock);
|
TEST_ASSERT_TRUE(scheduleFor(23, 0, defaults.schedule) == ScheduleMode::Clock);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(23, 29) == ScheduleMode::Standby);
|
TEST_ASSERT_TRUE(scheduleFor(23, 29, defaults.schedule) == ScheduleMode::Standby);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(23, 30) == ScheduleMode::Clock);
|
TEST_ASSERT_TRUE(scheduleFor(23, 30, defaults.schedule) == ScheduleMode::Clock);
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_schedule_night_from_23_31()
|
void test_schedule_night_from_23_31()
|
||||||
{
|
{
|
||||||
TEST_ASSERT_TRUE(scheduleFor(23, 31) == ScheduleMode::Night);
|
TEST_ASSERT_TRUE(scheduleFor(23, 31, defaults.schedule) == ScheduleMode::Night);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(23, 45) == ScheduleMode::Night);
|
TEST_ASSERT_TRUE(scheduleFor(23, 45, defaults.schedule) == ScheduleMode::Night);
|
||||||
TEST_ASSERT_TRUE(scheduleFor(23, 59) == ScheduleMode::Night);
|
TEST_ASSERT_TRUE(scheduleFor(23, 59, defaults.schedule) == ScheduleMode::Night);
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_evening_from_17()
|
void test_evening_from_17()
|
||||||
{
|
{
|
||||||
TEST_ASSERT_FALSE(isEvening(16));
|
TEST_ASSERT_FALSE(isEvening(16, 59, defaults.clock));
|
||||||
TEST_ASSERT_TRUE(isEvening(17));
|
TEST_ASSERT_TRUE(isEvening(17, 0, defaults.clock));
|
||||||
TEST_ASSERT_TRUE(isEvening(23));
|
TEST_ASSERT_TRUE(isEvening(23, 0, defaults.clock));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -376,7 +382,7 @@ void test_standby_keeps_the_last_time_and_powers_off()
|
|||||||
run(22, 0);
|
run(22, 0);
|
||||||
run(22, 1, POWER_OFF_DELAY + 200);
|
run(22, 1, POWER_OFF_DELAY + 200);
|
||||||
TEST_ASSERT_FALSE(power.is_on);
|
TEST_ASSERT_FALSE(power.is_on);
|
||||||
TEST_ASSERT_EQUAL_STRING("clock 22:00", frames.last().c_str()); // no blank
|
TEST_ASSERT_EQUAL_STRING("clock 22:00 evening", frames.last().c_str()); // no blank
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_quarter_hour_powers_on_again_and_redraws()
|
void test_quarter_hour_powers_on_again_and_redraws()
|
||||||
@@ -386,7 +392,7 @@ void test_quarter_hour_powers_on_again_and_redraws()
|
|||||||
TEST_ASSERT_FALSE(power.is_on);
|
TEST_ASSERT_FALSE(power.is_on);
|
||||||
run(22, 15);
|
run(22, 15);
|
||||||
TEST_ASSERT_TRUE(power.is_on);
|
TEST_ASSERT_TRUE(power.is_on);
|
||||||
TEST_ASSERT_EQUAL_STRING("clock 22:15", frames.last().c_str());
|
TEST_ASSERT_EQUAL_STRING("clock 22:15 evening", frames.last().c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
void test_clock_cancels_pending_power_off()
|
void test_clock_cancels_pending_power_off()
|
||||||
@@ -515,6 +521,277 @@ void test_night_without_wifi_still_powers_off()
|
|||||||
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
|
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Settings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void test_default_settings_are_valid()
|
||||||
|
{
|
||||||
|
TEST_ASSERT_NULL(validateSettings(defaults));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_settings_times_must_be_in_order()
|
||||||
|
{
|
||||||
|
Settings s;
|
||||||
|
s.schedule.late_start = 7 * 60; // before day_start
|
||||||
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
||||||
|
s = Settings();
|
||||||
|
s.schedule.night_start = 21 * 60; // before late_start
|
||||||
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
||||||
|
s = Settings();
|
||||||
|
s.schedule.night_start = MINUTES_PER_DAY; // never night: fine
|
||||||
|
TEST_ASSERT_NULL(validateSettings(s));
|
||||||
|
s.schedule.night_start = MINUTES_PER_DAY + 1;
|
||||||
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_settings_flip_time_and_port_ranges()
|
||||||
|
{
|
||||||
|
Settings s;
|
||||||
|
s.flip_time = 49;
|
||||||
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
||||||
|
s.flip_time = 2001;
|
||||||
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
||||||
|
s = Settings();
|
||||||
|
s.home_assistant.port = 0;
|
||||||
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_settings_page_must_be_clock_or_bitmap_name()
|
||||||
|
{
|
||||||
|
Settings s;
|
||||||
|
strcpy(s.page, "My bitmap");
|
||||||
|
TEST_ASSERT_NULL(validateSettings(s));
|
||||||
|
strcpy(s.page, "../evil");
|
||||||
|
TEST_ASSERT_NOT_NULL(validateSettings(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_time_parse_and_format()
|
||||||
|
{
|
||||||
|
uint16_t m = 0;
|
||||||
|
TEST_ASSERT_TRUE(parseTime("08:30", m));
|
||||||
|
TEST_ASSERT_EQUAL(510, m);
|
||||||
|
TEST_ASSERT_TRUE(parseTime("24:00", m));
|
||||||
|
TEST_ASSERT_EQUAL(MINUTES_PER_DAY, m);
|
||||||
|
TEST_ASSERT_FALSE(parseTime("24:01", m));
|
||||||
|
TEST_ASSERT_FALSE(parseTime("12:60", m));
|
||||||
|
TEST_ASSERT_FALSE(parseTime("noon", m));
|
||||||
|
TEST_ASSERT_FALSE(parseTime("12:30x", m));
|
||||||
|
TEST_ASSERT_FALSE(parseTime(nullptr, m));
|
||||||
|
char out[6];
|
||||||
|
formatTime(23 * 60 + 31, out);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("23:31", out);
|
||||||
|
formatTime(MINUTES_PER_DAY, out);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("24:00", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Bitmap format
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void test_bitmap_is_84_by_19_in_drawbitmap_format()
|
||||||
|
{
|
||||||
|
TEST_ASSERT_EQUAL(11, BITMAP_ROW_BYTES);
|
||||||
|
TEST_ASSERT_EQUAL(209, BITMAP_BYTES);
|
||||||
|
uint8_t b[BITMAP_BYTES] = {};
|
||||||
|
setBitmapPixel(b, 0, 0, true);
|
||||||
|
TEST_ASSERT_EQUAL_HEX8(0x80, b[0]); // leftmost dot = most significant bit
|
||||||
|
setBitmapPixel(b, 83, 18, true);
|
||||||
|
TEST_ASSERT_EQUAL_HEX8(0x10, b[18 * 11 + 10]); // x = 83: bit 3 of byte 10
|
||||||
|
TEST_ASSERT_TRUE(bitmapPixel(b, 83, 18));
|
||||||
|
TEST_ASSERT_FALSE(bitmapPixel(b, 84, 0)); // outside
|
||||||
|
setBitmapPixel(b, 0, 0, false);
|
||||||
|
TEST_ASSERT_EQUAL_HEX8(0x00, b[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitmap_hex_round_trip()
|
||||||
|
{
|
||||||
|
uint8_t a[BITMAP_BYTES], b[BITMAP_BYTES];
|
||||||
|
for (int i = 0; i < BITMAP_BYTES; i++)
|
||||||
|
a[i] = (uint8_t)(i * 37);
|
||||||
|
char hex[BITMAP_HEX_LENGTH + 1];
|
||||||
|
bitmapToHex(a, hex);
|
||||||
|
TEST_ASSERT_EQUAL(BITMAP_HEX_LENGTH, (int)strlen(hex));
|
||||||
|
TEST_ASSERT_TRUE(bitmapFromHex(hex, b));
|
||||||
|
TEST_ASSERT_EQUAL_MEMORY(a, b, BITMAP_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitmap_hex_rejects_wrong_input()
|
||||||
|
{
|
||||||
|
uint8_t b[BITMAP_BYTES];
|
||||||
|
TEST_ASSERT_FALSE(bitmapFromHex("abcd", b));
|
||||||
|
TEST_ASSERT_FALSE(bitmapFromHex(nullptr, b));
|
||||||
|
std::string bad(BITMAP_HEX_LENGTH, 'f');
|
||||||
|
bad[100] = 'x';
|
||||||
|
TEST_ASSERT_FALSE(bitmapFromHex(bad.c_str(), b));
|
||||||
|
std::string upper(BITMAP_HEX_LENGTH, 'F');
|
||||||
|
TEST_ASSERT_TRUE(bitmapFromHex(upper.c_str(), b));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitmap_names()
|
||||||
|
{
|
||||||
|
TEST_ASSERT_TRUE(validBitmapName("Nintendo"));
|
||||||
|
TEST_ASSERT_TRUE(validBitmapName("Happy Birthday 2"));
|
||||||
|
TEST_ASSERT_TRUE(validBitmapName("a-b_c"));
|
||||||
|
TEST_ASSERT_FALSE(validBitmapName(""));
|
||||||
|
TEST_ASSERT_FALSE(validBitmapName(" lead"));
|
||||||
|
TEST_ASSERT_FALSE(validBitmapName("trail "));
|
||||||
|
TEST_ASSERT_FALSE(validBitmapName("../etc"));
|
||||||
|
TEST_ASSERT_FALSE(validBitmapName("a/b"));
|
||||||
|
TEST_ASSERT_FALSE(validBitmapName("Clock")); // the clock page
|
||||||
|
TEST_ASSERT_TRUE(validBitmapName("123456789012345678901234"));
|
||||||
|
TEST_ASSERT_FALSE(validBitmapName("1234567890123456789012345"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Schedule with settings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void test_schedule_disabled_is_always_on()
|
||||||
|
{
|
||||||
|
ScheduleSettings s;
|
||||||
|
s.enabled = false;
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(3, 0, s) == ScheduleMode::Clock);
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_schedule_custom_times()
|
||||||
|
{
|
||||||
|
ScheduleSettings s;
|
||||||
|
s.day_start = 6 * 60 + 30;
|
||||||
|
s.late_start = 20 * 60;
|
||||||
|
s.night_start = 21 * 60;
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(6, 29, s) == ScheduleMode::Night);
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(6, 30, s) == ScheduleMode::Clock);
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(20, 1, s) == ScheduleMode::Standby);
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(20, 30, s) == ScheduleMode::Clock);
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(21, 0, s) == ScheduleMode::Night);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_schedule_without_quarter_hours_stays_on_until_night()
|
||||||
|
{
|
||||||
|
ScheduleSettings s;
|
||||||
|
s.quarter_hours = false;
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(22, 7, s) == ScheduleMode::Clock);
|
||||||
|
TEST_ASSERT_TRUE(scheduleFor(23, 31, s) == ScheduleMode::Night);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_evening_can_be_disabled()
|
||||||
|
{
|
||||||
|
ClockSettings c;
|
||||||
|
c.evening_enabled = false;
|
||||||
|
TEST_ASSERT_FALSE(isEvening(20, 0, c));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pages and live mode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void test_bitmap_page_is_shown_once()
|
||||||
|
{
|
||||||
|
controller->setPage(Page::Bitmap);
|
||||||
|
run(12, 0);
|
||||||
|
run(12, 1, 5000);
|
||||||
|
TEST_ASSERT_EQUAL(1, (int)frames.shown.size());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("page", frames.last().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitmap_page_redrawn_when_changed()
|
||||||
|
{
|
||||||
|
controller->setPage(Page::Bitmap);
|
||||||
|
run(12, 0);
|
||||||
|
controller->pageChanged();
|
||||||
|
run(12, 0);
|
||||||
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_switching_back_to_the_clock_page()
|
||||||
|
{
|
||||||
|
controller->setPage(Page::Bitmap);
|
||||||
|
run(12, 0);
|
||||||
|
controller->setPage(Page::Clock);
|
||||||
|
run(12, 0);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_bitmap_page_follows_the_schedule()
|
||||||
|
{
|
||||||
|
controller->setPage(Page::Bitmap);
|
||||||
|
run(23, 30);
|
||||||
|
run(23, 31, POWER_OFF_DELAY + 200);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str());
|
||||||
|
TEST_ASSERT_FALSE(power.is_on);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_evening_clock_redrawn_when_the_evening_starts()
|
||||||
|
{
|
||||||
|
run(16, 59);
|
||||||
|
run(17, 0);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("clock 17:00 evening", frames.last().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_settings_change_redraws()
|
||||||
|
{
|
||||||
|
run(18, 0);
|
||||||
|
ClockSettings c;
|
||||||
|
c.evening_enabled = false;
|
||||||
|
controller->setSettings(defaults.schedule, c);
|
||||||
|
run(18, 0);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("clock 18:00", frames.last().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_live_is_shown_right_away_even_at_night()
|
||||||
|
{
|
||||||
|
run(3, 0);
|
||||||
|
controller->showLive(now);
|
||||||
|
run(3, 0);
|
||||||
|
TEST_ASSERT_TRUE(power.is_on);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_live_works_before_the_time_is_synced()
|
||||||
|
{
|
||||||
|
controller->showLive(now);
|
||||||
|
controller->update(now, false, 0, 0, true);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("live", frames.last().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_each_live_image_is_drawn()
|
||||||
|
{
|
||||||
|
controller->showLive(now);
|
||||||
|
run(12, 0);
|
||||||
|
controller->showLive(now);
|
||||||
|
run(12, 0);
|
||||||
|
TEST_ASSERT_EQUAL(2, (int)frames.shown.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_stop_live_returns_to_the_page()
|
||||||
|
{
|
||||||
|
controller->showLive(now);
|
||||||
|
run(12, 0);
|
||||||
|
controller->stopLive();
|
||||||
|
run(12, 0);
|
||||||
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
||||||
|
TEST_ASSERT_FALSE(controller->liveActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_live_ends_after_timeout()
|
||||||
|
{
|
||||||
|
controller->showLive(now);
|
||||||
|
run(12, 0, LIVE_TIMEOUT + 200);
|
||||||
|
TEST_ASSERT_FALSE(controller->liveActive());
|
||||||
|
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
void test_live_respects_the_power_switch()
|
||||||
|
{
|
||||||
|
controller->setPowerEnabled(false);
|
||||||
|
controller->showLive(now);
|
||||||
|
run(12, 0, 5000);
|
||||||
|
TEST_ASSERT_FALSE(power.is_on);
|
||||||
|
TEST_ASSERT_EQUAL(0, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("live")));
|
||||||
|
}
|
||||||
|
|
||||||
int main()
|
int main()
|
||||||
{
|
{
|
||||||
UNITY_BEGIN();
|
UNITY_BEGIN();
|
||||||
@@ -570,5 +847,31 @@ int main()
|
|||||||
RUN_TEST(test_short_wifi_drop_does_not_change_the_display);
|
RUN_TEST(test_short_wifi_drop_does_not_change_the_display);
|
||||||
RUN_TEST(test_night_without_wifi_still_powers_off);
|
RUN_TEST(test_night_without_wifi_still_powers_off);
|
||||||
|
|
||||||
|
RUN_TEST(test_default_settings_are_valid);
|
||||||
|
RUN_TEST(test_settings_times_must_be_in_order);
|
||||||
|
RUN_TEST(test_settings_flip_time_and_port_ranges);
|
||||||
|
RUN_TEST(test_settings_page_must_be_clock_or_bitmap_name);
|
||||||
|
RUN_TEST(test_time_parse_and_format);
|
||||||
|
RUN_TEST(test_bitmap_is_84_by_19_in_drawbitmap_format);
|
||||||
|
RUN_TEST(test_bitmap_hex_round_trip);
|
||||||
|
RUN_TEST(test_bitmap_hex_rejects_wrong_input);
|
||||||
|
RUN_TEST(test_bitmap_names);
|
||||||
|
RUN_TEST(test_schedule_disabled_is_always_on);
|
||||||
|
RUN_TEST(test_schedule_custom_times);
|
||||||
|
RUN_TEST(test_schedule_without_quarter_hours_stays_on_until_night);
|
||||||
|
RUN_TEST(test_evening_can_be_disabled);
|
||||||
|
RUN_TEST(test_bitmap_page_is_shown_once);
|
||||||
|
RUN_TEST(test_bitmap_page_redrawn_when_changed);
|
||||||
|
RUN_TEST(test_switching_back_to_the_clock_page);
|
||||||
|
RUN_TEST(test_bitmap_page_follows_the_schedule);
|
||||||
|
RUN_TEST(test_evening_clock_redrawn_when_the_evening_starts);
|
||||||
|
RUN_TEST(test_settings_change_redraws);
|
||||||
|
RUN_TEST(test_live_is_shown_right_away_even_at_night);
|
||||||
|
RUN_TEST(test_live_works_before_the_time_is_synced);
|
||||||
|
RUN_TEST(test_each_live_image_is_drawn);
|
||||||
|
RUN_TEST(test_stop_live_returns_to_the_page);
|
||||||
|
RUN_TEST(test_live_ends_after_timeout);
|
||||||
|
RUN_TEST(test_live_respects_the_power_switch);
|
||||||
|
|
||||||
return UNITY_END();
|
return UNITY_END();
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user