Add HA dropdowns, off image, flip fixes and refresh effects

Home Assistant: remove the Clock switch (its entity is deleted on connect),
add an IP sensor and Mode / Off image dropdowns whose options follow the
saved bitmaps. Remove the built-in "Nintendo border" bitmap.

Off image: a bitmap (or blank) left on the dots when the display switches off.

Nano: pause after each flip pulse and repeat pulses for changed dots, both
stored in EEPROM (G/N commands). Refresh effects (E command): instant,
sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then
reveal, push and random, optionally only for small changes. Effects run in
steps from loop() so serial input keeps flowing.

Web UI: flip settings, refresh effect and test patterns under Settings; the
System tab shows why the ESP32 last restarted. Settings records from
versions 1 and 2 are migrated. Firmware 2.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-15 01:29:01 +02:00
1 parent 4a5f6a8b5d
commit 1d3b084de1
42 files changed
+1631 -230

No files matched your search

+14 -9
View File
@@ -29,7 +29,7 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| `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/frames.*` | The screens: clock, "No WiFi", page bitmap, live image, logo, blank (`FlipdotFrames`) |
| `display/frames.*` | The screens: clock, "No WiFi", page bitmap, live image, off image (`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/text.*` | Centered text lines on the 84 x 19 display |
@@ -39,7 +39,7 @@ debug output. Wiring is described in the [main README](../../README.md#wiring-be
| `nano_link/nano_flash_bridge.*` | WiFi to serial bridge for flashing the Nano over WiFi (TCP port 2323) |
| **`net/`** | **Network** |
| `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 (power switch, mode and off image dropdowns, IP and firmware sensors, 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 |
@@ -90,20 +90,25 @@ Rules for changing the code:
[main README](../../README.md#recovery-app).
- **Frames only when something changes:** a new frame is sent when the minute changes, the
screen changes (clock, logo, blank) or the Nano was just powered on. The serial line is
screen changes (clock, page, off image) or the Nano was just powered on. The serial line is
quiet in between, which also gives the Nano time for its periodic full redraw.
- **OTA updates** through ArduinoOTA. Hostname: `FlipDotDisplay`.
- **Robust OTA:** rollback, watchdog, OTA window after a crash and safe mode, see
[Keeping OTA alive](#keeping-ota-alive).
- **NTP time** with timezone `CET-1CEST,M3.5.0,M10.5.0/3` (Europe/Amsterdam). Until the
time is synced, the display shows "Connecting...".
- **Home Assistant** through MQTT (`home-assistant-integration` library) with two switches.
Each switch reports its new state back, so Home Assistant always shows the real state.
- `FlipDot Display Clock`: on = clock; off = Nintendo logo while the display is on,
then power off after 30 s
- `FlipDot Display Power`: off = clear the display and power it off after 1 s
- **Home Assistant** through MQTT (`home-assistant-integration` library). The switch and
the dropdown report their new state back, so Home Assistant always shows the real state.
- `FlipDot Display Power` switch: off = clear the display and power it off after 1 s
- `FlipDot Display Mode` dropdown: the page shown during the day, `Clock` or a bitmap.
The options follow the bitmaps saved in the web UI (`net/ha_page_select.*`, because
ArduinoHA's `HASelect` can't change its options).
- `FlipDot Display Off image` dropdown: `Blank` or a bitmap, drawn instead of a blank
screen at night and when the Power switch turns the display off
- `FlipDot Display IP` sensor: the IP address
Plus the **FlipDot Display Firmware** sensor (see
The old `FlipDot Display Clock` switch is removed from Home Assistant on connect (empty
retained discovery config). Plus the **FlipDot Display Firmware** sensor (see
[Keeping OTA alive](#keeping-ota-alive)) and the **FlipDot Display Recovery** button,
which restarts into the recovery app for 10 minutes.
- **Relay on GPIO 13** switches the power for both the panels and the Nano. `LOW` turns it
+42 -4
View File
@@ -33,10 +33,26 @@ static void applyPage()
controller.setPage(Page::Clock);
}
// Load the off bitmap into the frames; blank when it is gone
static void applyOffPage()
{
uint8_t bitmap[BITMAP_BYTES];
if (settings.off_page[0] && loadBitmap(settings.off_page, bitmap))
frames.setOffBitmap(bitmap);
else
{
if (settings.off_page[0])
Serial.printf("Off bitmap \"%s\" not found, the display is cleared instead\n", settings.off_page);
frames.setOffBitmap(nullptr);
}
}
static void apply()
{
controller.setSettings(settings.schedule, settings.clock);
status.setFlipTime(settings.flip_time);
controller.setEffectTime(effectTime(settings.drive));
status.setDrive(settings.flip_time, settings.drive);
applyOffPage();
applyPage();
}
@@ -64,14 +80,19 @@ const char *appSaveSettings(const Settings &new_settings, bool &restart_needed)
return error;
if (strcmp(new_settings.page, PAGE_CLOCK) != 0 && !bitmapExists(new_settings.page))
return "Bitmap not found";
if (new_settings.off_page[0] && !bitmapExists(new_settings.off_page))
return "Off bitmap not found";
restart_needed = !sameHomeAssistant(settings.home_assistant, new_settings.home_assistant);
bool flip_time_changed = settings.flip_time != new_settings.flip_time;
const DriveSettings &a = settings.drive, &b = new_settings.drive;
bool drive_changed = settings.flip_time != new_settings.flip_time || a.flip_gap != b.flip_gap ||
a.pulses != b.pulses || a.effect != b.effect || a.effect_time != b.effect_time ||
a.effect_small_only != b.effect_small_only;
settings = new_settings;
saveSettings(settings);
apply();
if (flip_time_changed && nano.isOn())
display.setTiming(settings.flip_time);
if (drive_changed)
status.sendDrive();
return nullptr;
}
@@ -86,8 +107,25 @@ const char *appSelectPage(const char *name)
return nullptr;
}
const char *appSelectOffPage(const char *name)
{
if (name[0] && !bitmapExists(name))
return "Bitmap not found";
strncpy(settings.off_page, name, sizeof(settings.off_page) - 1);
settings.off_page[sizeof(settings.off_page) - 1] = '\0';
saveSettings(settings);
applyOffPage();
controller.pageChanged();
return nullptr;
}
void appBitmapChanged(const char *name)
{
if (strcmp(settings.off_page, name) == 0)
{
applyOffPage();
controller.pageChanged();
}
if (strcmp(settings.page, name) == 0)
{
applyPage();
+3 -1
View File
@@ -25,7 +25,9 @@ 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
// Left on the dots when the display switches off: OFF_PAGE_BLANK or a bitmap name
const char *appSelectOffPage(const char *name);
// A bitmap was saved or deleted: redraw when it is the current page or off image
void appBitmapChanged(const char *name);
// Live mode of the web UI
+2 -2
View File
@@ -43,7 +43,7 @@ const uint32_t STATUS_FLIP_TIME = 2000;
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)
const uint32_t POWER_OFF_DELAY = 30000;
// ---------------------------------------------------------------------------
@@ -95,7 +95,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
#define FIRMWARE_VERSION "2.1.0"
#define FIRMWARE_VERSION "2.3.0"
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
+11 -2
View File
@@ -15,10 +15,10 @@ struct BuiltinBitmap
static const BuiltinBitmap BUILTIN[] = {
{"Nintendo", nintendo_bitmap_Nintendo_new},
{"Nintendo border", nintendo_bitmap_nintendo_border},
};
static bool mounted = false;
static uint32_t revision = 0;
static const BuiltinBitmap *builtin(const char *name)
{
@@ -77,6 +77,11 @@ std::vector<BitmapInfo> listBitmaps()
return list;
}
uint32_t bitmapListRevision()
{
return revision;
}
bool bitmapExists(const char *name)
{
if (builtin(name))
@@ -118,6 +123,7 @@ const char *saveBitmap(const char *name, const uint8_t *bitmap)
return "Could not write the file";
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close();
revision++;
return ok ? nullptr : "Storage full";
}
@@ -127,5 +133,8 @@ const char *deleteBitmap(const char *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";
if (!LittleFS.remove(path(name)))
return "Could not delete the file";
revision++;
return nullptr;
}
+2
View File
@@ -24,6 +24,8 @@ bool bitmapStorageAvailable();
void bitmapStorageUsage(size_t &used, size_t &total);
std::vector<BitmapInfo> listBitmaps();
// Changes each time a bitmap is saved or deleted
uint32_t bitmapListRevision();
bool bitmapExists(const char *name);
bool loadBitmap(const char *name, uint8_t *bitmap);
-17
View File
@@ -17,23 +17,6 @@ const unsigned char nintendo_bitmap_Nintendo_new[] PROGMEM = {
0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc,
0x00};
// 'nintendo border', 84x19px
const unsigned char nintendo_bitmap_nintendo_border[] PROGMEM = {
0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc, 0x00, 0x0f, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
0x80, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x60, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x60, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x30, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x60,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x60, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0,
0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x80, 0x0f, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc,
0x00};
// 'panda', 17x18px
const unsigned char nintendo_bitmap_panda_inverted[] PROGMEM = {
0xfe, 0x7f, 0x80, 0xfc, 0x3c, 0x80, 0xfc, 0x38, 0x00, 0xdd, 0xfe, 0x00, 0xbb, 0xff, 0x80, 0x73,
+10 -11
View File
@@ -3,7 +3,6 @@
#include <stdio.h>
#include <string.h>
#include "display/bitmap_store.h"
#include "display/bitmaps.h"
#include "display/text.h"
@@ -51,17 +50,9 @@ void FlipdotFrames::live()
_drawFullScreen(_live);
}
void FlipdotFrames::logo()
void FlipdotFrames::off()
{
uint8_t logo[BITMAP_BYTES];
if (loadBitmap("Nintendo", logo))
_drawFullScreen(logo);
}
void FlipdotFrames::blank()
{
_display.fillScreen(0);
_display.display();
_drawFullScreen(_off);
}
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
@@ -74,6 +65,14 @@ void FlipdotFrames::setLiveBitmap(const uint8_t *bitmap)
memcpy(_live, bitmap, BITMAP_BYTES);
}
void FlipdotFrames::setOffBitmap(const uint8_t *bitmap)
{
if (bitmap)
memcpy(_off, bitmap, BITMAP_BYTES);
else
memset(_off, 0, BITMAP_BYTES);
}
void FlipdotFrames::_drawFullScreen(const uint8_t *bitmap)
{
_display.fillScreen(0);
+5 -3
View File
@@ -18,17 +18,19 @@ public:
void page() override;
// The image set with setLiveBitmap()
void live() override;
// Nintendo logo
void logo() override;
void blank() override;
// Blank, or the bitmap set with setOffBitmap()
void off() override;
void setPageBitmap(const uint8_t *bitmap);
void setLiveBitmap(const uint8_t *bitmap);
// nullptr: blank
void setOffBitmap(const uint8_t *bitmap);
private:
BROSE9323 &_display;
uint8_t _page[BITMAP_BYTES] = {};
uint8_t _live[BITMAP_BYTES] = {};
uint8_t _off[BITMAP_BYTES] = {}; // all zero: blank
void _drawFullScreen(const uint8_t *bitmap);
};
+23 -3
View File
@@ -9,9 +9,27 @@ StatusScreen::StatusScreen(BROSE9323 &display, NanoLink &nano) : _display(displa
{
}
void StatusScreen::setFlipTime(uint16_t flip_time)
void StatusScreen::setDrive(uint16_t flip_time, const DriveSettings &drive)
{
_flip_time = flip_time;
_drive = drive;
}
void StatusScreen::sendDrive()
{
// Not sent right away: the Nano could be in its bootloader (flashing)
_drive_pending = true;
}
void StatusScreen::loop()
{
if (!_drive_pending || !_started || !_nano.isOn())
return;
_drive_pending = false;
_display.setTiming(_flip_time);
_display.setGap(_drive.flip_gap);
_display.setPulses(_drive.pulses);
_display.setEffect(_drive.effect, _drive.effect_time, _drive.effect_small_only ? EFFECT_FLAG_SMALL_ONLY : 0);
}
void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
@@ -21,8 +39,9 @@ void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
if (!_started)
{
_display.begin(&_nano.stream());
_display.setTiming(_flip_time);
_started = true;
_drive_pending = true;
loop();
}
drawTextLines(_display, line1, line2);
_display.display();
@@ -30,7 +49,8 @@ void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
if (!keep_on)
{
// The first frame after power on flips every dot, give the Nano time for that
delay(STATUS_FLIP_TIME);
// (a push can take longer, but these messages are short: at worst it is cut off)
delay(STATUS_FLIP_TIME + (_drive.effect == DRIVE_EFFECT_INSTANT ? 0 : _drive.effect_time));
_nano.off();
}
}
+10 -2
View File
@@ -1,6 +1,7 @@
#pragma once
#include "BROSE9323.h"
#include "logic/settings.h"
#include "nano_link/nano_link.h"
// Short messages for situations outside the normal clock: booting, crashes, recovery app.
@@ -14,12 +15,19 @@ public:
// 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);
// Flip time sent to the Nano with the first message (default FLIP_TIME)
void setFlipTime(uint16_t flip_time);
// How the Nano drives the dots, sent with the first message (default: FLIP_TIME and
// the DriveSettings defaults, so the recovery app always shows its messages instantly)
void setDrive(uint16_t flip_time, const DriveSettings &drive);
// Send them again (they changed), from loop()
void sendDrive();
// Sends changed drive settings once the display is on. Not while the Nano is flashed.
void loop();
private:
BROSE9323 &_display;
NanoLink &_nano;
bool _started = false;
bool _drive_pending = false;
uint16_t _flip_time;
DriveSettings _drive;
};
+9 -28
View File
@@ -14,6 +14,11 @@ void DisplayController::setSettings(const ScheduleSettings &schedule, const Cloc
_content_revision++; // e.g. evening time changed: redraw
}
void DisplayController::setEffectTime(uint32_t ms)
{
_effect_time = ms;
}
void DisplayController::setPage(Page page)
{
_page = page;
@@ -43,21 +48,11 @@ bool DisplayController::liveActive() const
return _live;
}
void DisplayController::setClockEnabled(bool enabled)
{
_clock_enabled = enabled;
}
void DisplayController::setPowerEnabled(bool enabled)
{
_power_enabled = enabled;
}
bool DisplayController::clockEnabled() const
{
return _clock_enabled;
}
bool DisplayController::powerEnabled() const
{
return _power_enabled;
@@ -92,8 +87,8 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
{
if (_power.isOn())
{
_showOnce(Screen::Blank);
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME);
_showOnce(Screen::Off);
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME + _effect_time);
}
return;
}
@@ -111,23 +106,12 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
if (!time_valid)
return;
if (!_clock_enabled)
{
// The logo is only shown while the display is on; it never switches the power on
if (_power.isOn())
{
_showOnce(Screen::Logo);
_off_timer.request(now, POWER_OFF_DELAY);
}
return;
}
switch (scheduleFor(hour, minute, _schedule))
{
case ScheduleMode::Night:
if (_power.isOn())
{
_showOnce(Screen::Blank);
_showOnce(Screen::Off);
_off_timer.request(now, POWER_OFF_DELAY);
}
break;
@@ -186,11 +170,8 @@ void DisplayController::_showOnce(Screen screen)
case Screen::Live:
_frames.live();
break;
case Screen::Logo:
_frames.logo();
break;
default:
_frames.blank();
_frames.off();
break;
}
_screen = screen;
+9 -9
View File
@@ -26,8 +26,8 @@ public:
virtual void page() = 0;
// The image sent from the web UI's live mode
virtual void live() = 0;
virtual void logo() = 0;
virtual void blank() = 0;
// Left on the dots when the display switches off: blank or the off bitmap
virtual void off() = 0;
};
enum class Page : uint8_t
@@ -45,8 +45,12 @@ public:
DisplayController(DisplayPower &power, Frames &frames);
void setSettings(const ScheduleSettings &schedule, const ClockSettings &clock);
// How long the Nano takes to show a frame with the refresh effect (ms, 0 = instant).
// The power stays on that much longer after the last frame.
void setEffectTime(uint32_t ms);
// What is shown during the day. Call pageChanged() when the bitmap itself changed.
// What is shown during the day. Call pageChanged() when the page or off bitmap
// itself changed.
void setPage(Page page);
void pageChanged();
@@ -56,12 +60,9 @@ public:
void stopLive();
bool liveActive() const;
// Home Assistant "FlipDot Display Clock" switch: off shows the logo instead of the page
void setClockEnabled(bool enabled);
// Home Assistant "FlipDot Display Power" switch: off clears the display and powers it off
void setPowerEnabled(bool enabled);
bool clockEnabled() const;
bool powerEnabled() const;
// Call often (every loop). time_valid is false until the time is synced over NTP.
@@ -76,8 +77,7 @@ private:
NoWifi,
Page,
Live,
Logo,
Blank,
Off, // blank or the off bitmap
};
DisplayPower &_power;
@@ -87,8 +87,8 @@ private:
ScheduleSettings _schedule;
ClockSettings _clock;
Page _page = Page::Clock;
uint32_t _effect_time = 0;
bool _clock_enabled = true;
bool _power_enabled = true;
Screen _screen = Screen::Unknown;
+21
View File
@@ -15,6 +15,8 @@ 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";
if (!terminated(s.off_page, sizeof(s.off_page)) || (s.off_page[0] && !validBitmapName(s.off_page)))
return "Unknown off image";
const ScheduleSettings &t = s.schedule;
if (t.day_start >= MINUTES_PER_DAY || t.late_start > MINUTES_PER_DAY || t.night_start > MINUTES_PER_DAY)
@@ -28,6 +30,16 @@ const char *validateSettings(const Settings &s)
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 DriveSettings &d = s.drive;
if (d.flip_gap > 5000)
return "The flip gap must be between 0 and 5000 us";
if (d.pulses < 1 || d.pulses > 3)
return "Pulses must be 1, 2 or 3";
if (d.effect >= DRIVE_EFFECT_COUNT)
return "Unknown refresh effect";
if (d.effect_time < 100 || d.effect_time > 10000)
return "The effect time must be between 100 and 10000 ms";
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)))
@@ -38,6 +50,15 @@ const char *validateSettings(const Settings &s)
return nullptr;
}
uint32_t effectTime(const DriveSettings &drive)
{
if (drive.effect == DRIVE_EFFECT_INSTANT)
return 0;
if (drive.effect == DRIVE_EFFECT_PUSH || drive.effect == DRIVE_EFFECT_RANDOM)
return drive.effect_time + DRIVE_PUSH_EXTRA_TIME;
return drive.effect_time;
}
bool parseTime(const char *text, uint16_t &minutes)
{
if (text == nullptr)
+28
View File
@@ -32,9 +32,29 @@ struct HomeAssistantSettings
char password[64] = "";
};
// How the Nano drives the dots (besides the flip time). Sent to the Nano, which keeps
// them in its EEPROM. The defaults match the Nano's (src/nano/config.h).
const uint8_t DRIVE_EFFECT_INSTANT = 0; // numbers: RefreshEffect in lib/BROSE9323/dot_sequence.h
const uint8_t DRIVE_EFFECT_PUSH = 8;
const uint8_t DRIVE_EFFECT_RANDOM = 9;
const uint8_t DRIVE_EFFECT_COUNT = 10;
// Pushing flips many dots per column step, so it can take much longer than effect_time
const uint32_t DRIVE_PUSH_EXTRA_TIME = 20000;
struct DriveSettings
{
uint16_t flip_gap = 200; // pause after each pulse in us (0-5000)
uint8_t pulses = 2; // pulses for each changed dot (1-3)
uint8_t effect = DRIVE_EFFECT_INSTANT;
uint16_t effect_time = 1500; // ms (100-10000)
bool effect_small_only = false; // big changes (a new page) appear instantly
};
// What is shown during the day: "clock" or the name of a bitmap
const int PAGE_NAME_SIZE = 25;
#define PAGE_CLOCK "clock"
// What is left on the dots when the display switches off: empty = blank, or a bitmap name
#define OFF_PAGE_BLANK ""
struct Settings
{
@@ -44,8 +64,16 @@ struct Settings
HomeAssistantSettings home_assistant;
uint16_t flip_time = 225; // flip pulse length in us, sent to the Nano
bool status_led = true;
// Added in settings version 2: keep it last, the fields before it are read from
// version 1 records (system/settings_store.cpp)
char off_page[PAGE_NAME_SIZE] = OFF_PAGE_BLANK;
// Added in version 3
DriveSettings drive;
};
// How long a new frame can take to appear on the dots in ms (0 when instant)
uint32_t effectTime(const DriveSettings &drive);
const uint16_t MINUTES_PER_DAY = 24 * 60;
const uint16_t SETTINGS_FLIP_TIME_MIN = 50;
const uint16_t SETTINGS_FLIP_TIME_MAX = 2000;
+1
View File
@@ -83,6 +83,7 @@ void loop()
// While the Nano is being flashed, nothing else may talk to it
if (flashBridge.handle())
return;
status.loop(); // changed flip settings, once the display is on
struct tm now = {};
bool time_valid = getLocalTime(&now, 0);
+89
View File
@@ -0,0 +1,89 @@
#include "net/ha_page_select.h"
#include <string.h>
#include <utils/HADictionary.h>
#include <utils/HASerializer.h>
static const size_t MAX_OPTIONS = 255; // HASerializerArray counts with a uint8_t
HAPageSelect::HAPageSelect(const char *uniqueId) : HABaseDeviceType(AHATOFSTR(HAComponentSelect), uniqueId)
{
}
HAPageSelect::~HAPageSelect()
{
delete _array;
}
void HAPageSelect::setOptions(const std::vector<String> &options)
{
if (options == _options)
return;
_options = options;
if (_options.size() > MAX_OPTIONS)
_options.resize(MAX_OPTIONS);
if (mqtt()->isConnected())
{
publishConfig();
_publishState(); // Home Assistant forgets the state when the config changes
}
}
void HAPageSelect::setState(const char *option)
{
if (_state_sent && _state == option)
return;
_state = option;
_publishState();
}
void HAPageSelect::_publishState()
{
_state_sent = mqtt()->isConnected() && !_state.isEmpty() &&
publishOnDataTopic(AHATOFSTR(HAStateTopic), _state.c_str(), true);
}
// Called by publishConfig(), which deletes the serializer again afterwards
void HAPageSelect::buildSerializer()
{
if (_serializer || !uniqueId() || _options.empty())
return;
delete _array;
_array = new HASerializerArray(_options.size(), false);
for (const String &option : _options)
_array->add(option.c_str());
_serializer = new HASerializer(this, 11); // 8 used, room to spare (no overflow check)
_serializer->set(AHATOFSTR(HANameProperty), _name);
_serializer->set(HASerializer::WithUniqueId);
_serializer->set(AHATOFSTR(HAIconProperty), _icon);
_serializer->set(AHATOFSTR(HAOptionsProperty), _array, HASerializer::ArrayPropertyType);
_serializer->set(HASerializer::WithDevice);
_serializer->set(HASerializer::WithAvailability);
_serializer->topic(AHATOFSTR(HAStateTopic));
_serializer->topic(AHATOFSTR(HACommandTopic));
}
void HAPageSelect::onMqttConnected()
{
if (!uniqueId())
return;
publishConfig();
publishAvailability();
_publishState();
subscribeTopic(uniqueId(), AHATOFSTR(HACommandTopic));
}
void HAPageSelect::onMqttMessage(const char *topic, const uint8_t *payload, const uint16_t length)
{
if (!_callback || !HASerializer::compareDataTopics(topic, uniqueId(), AHATOFSTR(HACommandTopic)))
return;
// Exact match (HASelect also accepts a prefix of an option)
for (const String &option : _options)
if (option.length() == length && memcmp(option.c_str(), payload, length) == 0)
{
_callback(option.c_str());
return;
}
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <ArduinoHA.h>
#include <vector>
// A Home Assistant select (dropdown) whose options can change at runtime: ArduinoHA's
// HASelect only accepts the options once, and its index is limited to 127. When the
// options change, the discovery config is published again and Home Assistant updates
// the entity. Options and state are option names, at most 255 options.
class HAPageSelect : public HABaseDeviceType
{
public:
explicit HAPageSelect(const char *uniqueId);
~HAPageSelect();
inline void setIcon(const char *icon) { _icon = icon; }
// Called with the chosen option. Not optimistic: report it back with setState().
inline void onCommand(void (*callback)(const char *option)) { _callback = callback; }
void setOptions(const std::vector<String> &options);
// Publishes the state when it changed, and again after each reconnect
void setState(const char *option);
protected:
void buildSerializer() override;
void onMqttConnected() override;
void onMqttMessage(const char *topic, const uint8_t *payload, const uint16_t length) override;
private:
std::vector<String> _options;
String _state;
bool _state_sent = false;
HASerializerArray *_array = nullptr;
const char *_icon = nullptr;
void (*_callback)(const char *option) = nullptr;
void _publishState();
};
+170 -22
View File
@@ -2,10 +2,17 @@
#include <ArduinoHA.h>
#include <WiFi.h>
#include <algorithm>
#include <string.h>
#include <strings.h>
#include <utils/HADictionary.h>
#include <utils/HASerializer.h>
#include "app.h"
#include "config.h"
#include "credentials.h"
#include "display/bitmap_store.h"
#include "net/ha_page_select.h"
#include "system/boot_target.h"
static WiFiClient client;
@@ -13,30 +20,153 @@ static HADevice device(HA_DEVICE_ID, sizeof(HA_DEVICE_ID));
static HAMqtt mqtt(client, device);
// Unique ids: do not change, Home Assistant's entities are linked to them
static HASwitch clockSwitch("FlipDotDisplayClock");
static HASwitch powerSwitch("FlipDotDisplayPower");
static HAPageSelect modeSelect("FlipDotDisplayMode");
static HAPageSelect offSelect("FlipDotDisplayOffImage");
static HASensor firmwareSensor("FlipDotDisplayFirmware");
static HASensor ipSensor("FlipDotDisplayIP");
static HAButton recoveryButton("FlipDotDisplayRecovery");
static char firmware_status[64] = "";
static bool firmware_status_sent = false;
// Removed entities: their retained discovery config is cleared, so Home Assistant
// deletes them instead of showing them as unavailable
static const char *const REMOVED_SWITCHES[] = {"FlipDotDisplayClock"};
// A text sensor that is only sent when its value changes, and again after a reconnect
// (HASensor doesn't resend its value by itself)
struct TextSensor
{
HASensor &sensor;
char value[64];
bool sent;
void set(const char *new_value)
{
if (strcmp(new_value, value) == 0)
return;
strncpy(value, new_value, sizeof(value) - 1);
value[sizeof(value) - 1] = '\0';
sent = false;
}
void loop()
{
if (!sent && value[0] && mqtt.isConnected())
sent = sensor.setValue(value);
}
};
static TextSensor firmware = {firmwareSensor, "", false};
static TextSensor ip = {ipSensor, "", false};
static DisplayController *displayController = nullptr;
static bool enabled = false;
// The switches are not optimistic: the new state must be reported back to Home Assistant
static void onClockCommand(bool state, HASwitch *sender)
{
displayController->setClockEnabled(state);
sender->setState(state);
}
static void onPowerCommand(bool state, HASwitch *sender)
{
displayController->setPowerEnabled(state);
sender->setState(state);
}
// The bitmap names for the dropdowns: built-in ones first, then the uploaded ones
// alphabetically. Rebuilt when bitmaps were added or deleted.
static const std::vector<String> &bitmapNames()
{
static std::vector<String> names;
static bool loaded = false;
static uint32_t revision = 0;
if (loaded && revision == bitmapListRevision())
return names;
loaded = true;
revision = bitmapListRevision();
std::vector<String> uploaded;
names.clear();
for (const BitmapInfo &b : listBitmaps())
(b.builtin ? names : uploaded).push_back(b.name);
std::sort(uploaded.begin(), uploaded.end(),
[](const String &a, const String &b) { return strcasecmp(a.c_str(), b.c_str()) < 0; });
names.insert(names.end(), uploaded.begin(), uploaded.end());
return names;
}
// A dropdown with one fixed option (the clock, or blank) followed by the bitmaps, for a
// setting that holds either the fixed value or a bitmap name
struct BitmapChoice
{
HAPageSelect &select;
const char *fixed_option; // shown in Home Assistant
const char *fixed_value; // stored in the settings
const char *(*setting)();
const char *(*choose)(const char *value); // returns nullptr or an error
bool options_loaded;
uint32_t options_revision;
bool state_known;
char state_for[PAGE_NAME_SIZE]; // setting the state was published for
std::vector<String> options;
// The option for the setting; the fixed one when the bitmap is gone (the display
// falls back to the clock or blank then too)
const char *current()
{
const char *value = setting();
if (strcmp(value, fixed_value) != 0)
for (const String &option : options)
if (strcasecmp(option.c_str(), value) == 0)
return option.c_str();
return fixed_option;
}
// Rebuilds the options when bitmaps were added or deleted and reports the setting
void update()
{
if (!options_loaded || options_revision != bitmapListRevision())
{
const std::vector<String> &names = bitmapNames();
options_loaded = true;
options_revision = bitmapListRevision();
options.assign(1, fixed_option);
options.insert(options.end(), names.begin(), names.end());
select.setOptions(options);
state_known = false;
}
if (!state_known || strcmp(state_for, setting()) != 0)
{
state_known = true;
strncpy(state_for, setting(), sizeof(state_for) - 1);
state_for[sizeof(state_for) - 1] = '\0';
select.setState(current());
}
}
void command(const char *option)
{
const char *error = choose(strcmp(option, fixed_option) == 0 ? fixed_value : option);
if (error)
Serial.printf("Home Assistant \"%s\": %s\n", option, error);
update(); // report the (new) state back
}
};
// Bitmaps can't be called "clock", and the blank off image is stored as ""
static BitmapChoice mode = {modeSelect, "Clock", PAGE_CLOCK, []() { return appSettings().page; }, appSelectPage};
static BitmapChoice offImage = {offSelect, "Blank", OFF_PAGE_BLANK, []() { return appSettings().off_page; },
appSelectOffPage};
static void clearRemovedEntities()
{
for (const char *id : REMOVED_SWITCHES)
{
uint16_t length = HASerializer::calculateConfigTopicLength(AHATOFSTR(HAComponentSwitch), id);
if (length == 0)
continue;
char topic[length];
if (HASerializer::generateConfigTopic(topic, AHATOFSTR(HAComponentSwitch), id))
mqtt.publish(topic, "", true);
}
}
void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettings &settings)
{
displayController = &controller;
@@ -50,24 +180,36 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin
device.setName(HA_DEVICE_NAME);
device.setSoftwareVersion(HA_SOFTWARE_VERSION);
clockSwitch.setName("FlipDot Display Clock");
clockSwitch.setIcon("mdi:lightbulb");
clockSwitch.setCurrentState(controller.clockEnabled());
clockSwitch.onCommand(onClockCommand);
powerSwitch.setName("FlipDot Display Power");
powerSwitch.setIcon("mdi:lightbulb");
powerSwitch.setCurrentState(controller.powerEnabled());
powerSwitch.onCommand(onPowerCommand);
modeSelect.setName("FlipDot Display Mode");
modeSelect.setIcon("mdi:image-multiple");
modeSelect.onCommand([](const char *option) { mode.command(option); });
mode.update();
offSelect.setName("FlipDot Display Off image");
offSelect.setIcon("mdi:image-off-outline");
offSelect.onCommand([](const char *option) { offImage.command(option); });
offImage.update();
recoveryButton.setName("FlipDot Display Recovery");
recoveryButton.setIcon("mdi:lifebuoy");
recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); });
firmwareSensor.setName("FlipDot Display Firmware");
firmwareSensor.setIcon("mdi:chip");
// The sensor doesn't resend its value on reconnect by itself
mqtt.onConnected([]() { firmware_status_sent = false; });
ipSensor.setName("FlipDot Display IP");
ipSensor.setIcon("mdi:ip-network");
mqtt.onConnected([]() {
firmware.sent = false;
ip.sent = false;
clearRemovedEntities();
});
// settings must stay alive: the MQTT library keeps the pointers
if (settings.host[0])
@@ -87,14 +229,20 @@ void homeAssistantLoop()
if (!enabled)
return;
mqtt.loop();
if (!firmware_status_sent && firmware_status[0] && mqtt.isConnected())
firmware_status_sent = firmwareSensor.setValue(firmware_status);
static uint32_t last_ip = 0;
if (WiFi.isConnected() && (uint32_t)WiFi.localIP() != last_ip)
{
last_ip = WiFi.localIP();
ip.set(WiFi.localIP().toString().c_str());
}
ip.loop();
firmware.loop();
mode.update();
offImage.update();
}
void homeAssistantSetFirmwareStatus(const char *status)
{
if (strcmp(status, firmware_status) == 0)
return;
strncpy(firmware_status, status, sizeof(firmware_status) - 1);
firmware_status_sent = false;
firmware.set(status);
}
+5 -2
View File
@@ -4,8 +4,11 @@
#include "logic/settings.h"
// Home Assistant integration over MQTT (with discovery):
// 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 = draw the off image and switch the display off
// FlipDot Display Mode select: the page shown during the day ("Clock" or a bitmap)
// FlipDot Display Off image select: left on the dots when the display switches off
// ("Blank" or a bitmap)
// FlipDot Display IP sensor: the IP address
// 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)
//
+29
View File
@@ -168,6 +168,35 @@ void firmwareGuardLoop(bool wifi_connected)
}
}
const char *firmwareGuardResetReason()
{
switch (esp_reset_reason())
{
case ESP_RST_POWERON:
return "power on";
case ESP_RST_EXT:
return "reset pin";
case ESP_RST_SW:
return "restart";
case ESP_RST_PANIC:
return "crash";
case ESP_RST_INT_WDT:
return "watchdog (interrupt)";
case ESP_RST_TASK_WDT:
return "watchdog (task)";
case ESP_RST_WDT:
return "watchdog";
case ESP_RST_DEEPSLEEP:
return "deep sleep";
case ESP_RST_BROWNOUT:
return "brownout (supply voltage dropped)";
case ESP_RST_SDIO:
return "SDIO";
default:
return "unknown";
}
}
const char *firmwareGuardStatus()
{
return status;
+4
View File
@@ -33,3 +33,7 @@ void firmwareGuardLoop(bool wifi_connected);
// Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes, recovery ok"
const char *firmwareGuardStatus();
// Why the ESP32 restarted last, e.g. "power on", "crash", "brownout". After a rollback
// this is why the new firmware stopped.
const char *firmwareGuardResetReason();
+35 -5
View File
@@ -1,11 +1,26 @@
#include "system/settings_store.h"
#include <Preferences.h>
#include <stddef.h>
#include <string.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;
// Increase when the Settings struct changes; old records are then ignored (defaults),
// unless OLD_VERSIONS says how to read them
static const uint16_t VERSION = 3;
// Older versions: fields were only added at the end, so the start of an old record has
// the same layout. New fields keep their defaults.
struct OldVersion
{
uint16_t version;
size_t fields; // bytes of Settings that version had
};
static const OldVersion OLD_VERSIONS[] = {
{1, offsetof(Settings, off_page)},
{2, offsetof(Settings, drive)},
};
struct StoredSettings
{
@@ -18,13 +33,28 @@ void loadSettings(Settings &settings)
{
settings = Settings();
StoredSettings stored;
memset(&stored, 0, sizeof(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;
Settings loaded;
if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings))
loaded = stored.settings;
else
{
const OldVersion *old = nullptr;
for (const OldVersion &o : OLD_VERSIONS)
if (o.version == stored.version)
old = &o;
if (!old || stored.size < old->fields || stored.size > sizeof(Settings) ||
read != offsetof(StoredSettings, settings) + stored.size)
return;
memcpy((void *)&loaded, &stored.settings, old->fields);
}
if (validateSettings(loaded) == nullptr)
settings = loaded;
}
void saveSettings(const Settings &settings)
+36 -7
View File
@@ -46,7 +46,9 @@ button.d{color:var(--bad)}
<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>
<p class="hint">The schedule (Settings) decides when the display is on. The page can also be chosen with the Home Assistant "Mode" dropdown.</p></div>
<div class="card"><h2>Left on the display when it switches off</h2><div class="grid" id="offpages"></div>
<p class="hint">Drawn at night and when the Home Assistant Power switch turns the display off; the dots keep it without power. Also in Home Assistant: "Off image".</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>
@@ -78,8 +80,22 @@ button.d{color:var(--bad)}
</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"> &micro;s (50-2000)</label>
<label>Flip gap</label><label><input type="number" id="h_gap" min="0" max="5000" style="width:6em"> &micro;s pause after each pulse (0-5000)</label>
<label>Pulses</label><label><select id="h_pulses"><option>1</option><option>2</option><option>3</option></select> per changed dot</label>
<label>Refresh effect</label><select id="h_effect"><option value="0">Instant (left to right)</option><option value="1">Sparkle</option>
<option value="2">Wipe (left to right)</option><option value="3">Rain (top to bottom)</option><option value="4">From the middle out</option>
<option value="5">Diagonal</option><option value="6">Spiral (outside in)</option><option value="7">Flip all, then reveal</option>
<option value="8">Push (slides in from the right)</option><option value="9">Random</option></select>
<label>Effect for</label><label><input type="checkbox" id="h_small"> small changes only (the clock's minutes); a new page appears at once</label>
<label>Effect time</label><label><input type="number" id="h_efft" min="100" max="10000" style="width:6em"> ms (100-10000, not for Instant; Push needs more when many dots change)</label>
<label>Status LED</label><label><input type="checkbox" id="h_led"> blink while the display is on</label>
</div></div>
</div><p class="hint">Dots that don't flip: a longer gap gives the power supply time to recover between pulses, a second pulse catches dots that missed the first one. Full redraws (after power on, every 2 minutes) use one pulse per dot.</p></div>
<div class="card"><h2>Test patterns</h2>
<div class="row"><button class="s" onclick="pattern('yellow')">All yellow</button><button class="s" onclick="pattern('black')">All black</button>
<button class="s" onclick="pattern('checker')">Checkerboard</button><button class="s" onclick="pattern('checker2')">Checkerboard inverted</button>
<button class="s" onclick="pattern('rows')">Rows</button><button class="s" onclick="pattern('cols')">Columns</button><button class="s" onclick="pattern('border')">Border</button></div>
<div class="row"><button class="s" onclick="cycleTest()">Cycle checkerboards</button><button class="s" onclick="stopTest()">Back to normal</button></div>
<p class="hint">Shown in live mode, with the current flip settings (save them first). Cycle flips every dot back and forth every few seconds: look for dots that stay behind.</p></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">
@@ -88,7 +104,7 @@ button.d{color:var(--bad)}
<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>
</div><p class="hint">Saving restarts the ESP32. Entities: Power switch, Mode and Off image dropdowns, IP and Firmware sensors, 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>
@@ -120,18 +136,20 @@ async function pollScreen(){try{const s=await api('/api/screen');draw($('mirror'
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'],
function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Last restart',status.reset_reason],['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')],
['HA power switch',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='';
const pages=$('pages'),offs=$('offpages'),bms=$('bitmaps');pages.innerHTML='';offs.innerHTML='';bms.innerHTML='';
offs.appendChild(card('Blank','all dots off',new Uint8Array(W*H),()=>selectOff(''),settings&&!settings.off_page));
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));
offs.appendChild(card(bm.name,bm.builtin?'built-in':'',data,()=>selectOff(bm.name),settings&&settings.off_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)}}
@@ -141,7 +159,8 @@ function card(name,tag,bits,onclick,cur){const d=document.createElement('div');d
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 selectOff(p){const r=await post('/api/settings',{off_page:p});settings=r.settings;msg('Off image: '+(p||'blank'));loadBitmaps()}
async function delBitmap(n){if(!confirm('Delete '+n+'?'))return;await post('/api/bitmap/delete',{name:n});msg('Deleted');settings=await api('/api/settings');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
@@ -169,16 +188,26 @@ function convert(){if(!srcImg)return;const c=document.createElement('canvas');c.
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()}
// test patterns (live mode)
let testTimer=null;
function patternBits(p){const b=new Uint8Array(W*H);for(let y=0;y<H;y++)for(let x=0;x<W;x++)b[y*W+x]=
p=='yellow'?1:p=='black'?0:p=='checker'?(x+y)%2:p=='checker2'?(x+y+1)%2:p=='rows'?y%2:p=='cols'?x%2:(x==0||y==0||x==W-1||y==H-1)?1:0;return b}
async function pattern(p){clearInterval(testTimer);testTimer=null;await live(patternBits(p))}
async function cycleTest(){clearInterval(testTimer);let n=0;const step=()=>live(patternBits(n++%2?'checker2':'checker')).catch(()=>{clearInterval(testTimer);testTimer=null});
await step();testTimer=setInterval(step,Math.max(4000,(settings.drive.effect?settings.drive.effect_time:0)+2000))}
async function stopTest(){clearInterval(testTimer);testTimer=null;await stopLive()}
// 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;
$('h_gap').value=s.drive.flip_gap;$('h_pulses').value=s.drive.pulses;$('h_effect').value=s.drive.effect;$('h_efft').value=s.drive.effect_time;$('h_small').checked=s.drive.effect_small_only;
$('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;
s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').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')}
+25 -1
View File
@@ -84,6 +84,7 @@ static void handleStatus()
JsonDocument doc;
doc["version"] = FIRMWARE_VERSION;
doc["firmware"] = firmwareGuardStatus();
doc["reset_reason"] = firmwareGuardResetReason();
doc["uptime"] = millis() / 1000;
struct tm now = {};
@@ -102,7 +103,7 @@ static void handleStatus()
doc["display_on"] = nano.isOn();
doc["live"] = controller.liveActive();
doc["page"] = appSettings().page;
doc["clock_enabled"] = controller.clockEnabled();
doc["off_page"] = appSettings().off_page;
doc["power_enabled"] = controller.powerEnabled();
JsonObject ha = doc["ha"].to<JsonObject>();
@@ -218,6 +219,8 @@ static void handleBitmapDelete()
}
if (strcmp(appSettings().page, name) == 0)
appSelectPage(PAGE_CLOCK);
if (strcmp(appSettings().off_page, name) == 0)
appSelectOffPage(OFF_PAGE_BLANK);
sendOk();
}
@@ -271,9 +274,17 @@ static void settingsToJson(const Settings &s, JsonObject o)
{
char time[6];
o["page"] = s.page;
o["off_page"] = s.off_page;
o["flip_time"] = s.flip_time;
o["status_led"] = s.status_led;
JsonObject drive = o["drive"].to<JsonObject>();
drive["flip_gap"] = s.drive.flip_gap;
drive["pulses"] = s.drive.pulses;
drive["effect"] = s.drive.effect;
drive["effect_time"] = s.drive.effect_time;
drive["effect_small_only"] = s.drive.effect_small_only;
JsonObject schedule = o["schedule"].to<JsonObject>();
schedule["enabled"] = s.schedule.enabled;
formatTime(s.schedule.day_start, time);
@@ -314,10 +325,23 @@ static void handleSettingsPost()
Settings s = appSettings();
copyText(body["page"], s.page, sizeof(s.page));
copyText(body["off_page"], s.off_page, sizeof(s.off_page));
if (body["flip_time"].is<int>())
s.flip_time = body["flip_time"].as<int>();
copyBool(body["status_led"], s.status_led);
// Range checks are in validateSettings(); negative or huge numbers must not wrap
JsonVariantConst drive = body["drive"];
if (drive["flip_gap"].is<int>())
s.drive.flip_gap = constrain(drive["flip_gap"].as<int>(), 0, 65535);
if (drive["pulses"].is<int>())
s.drive.pulses = constrain(drive["pulses"].as<int>(), 0, 255);
if (drive["effect"].is<int>())
s.drive.effect = constrain(drive["effect"].as<int>(), 0, 255);
if (drive["effect_time"].is<int>())
s.drive.effect_time = constrain(drive["effect_time"].as<int>(), 0, 65535);
copyBool(drive["effect_small_only"], s.drive.effect_small_only);
JsonVariantConst schedule = body["schedule"];
copyBool(schedule["enabled"], s.schedule.enabled);
copyBool(schedule["quarter_hours"], s.schedule.quarter_hours);
+24 -6
View File
@@ -27,19 +27,34 @@ Arduino core, EEPROM and the display driver are replaced by fakes in
## Behaviour
- **Boot:** the Nano loads the flip time from EEPROM, sets up the pins and sends `READY`.
- **Boot:** the Nano loads the flip settings from EEPROM, sets up the pins and sends `READY`.
It doesn't flip any dots at boot. The relay powers the Nano, so it boots every time the
display is switched on.
- **First `display()` after boot** redraws every dot, because the Nano doesn't know which
state the dots were left in.
- **`display()`:** compares the new buffer with the last one that was shown and strobes
only the dots that changed.
only the dots that changed, each with `N` pulses (default 2: the second one catches
dots that missed the first). Every pulse is followed by a pause (`G`, default 200 µs)
so the coil supply can recover.
- **Refresh effect** (`E`): with `0` (default) a frame appears column by column as fast
as possible. The other effects (sparkle, wipe, rain, from the middle out, diagonal,
spiral, flip all then reveal, push, random) spread the flips over the effect time.
Push shifts the whole picture one column per step, so it flips many dots and can take
longer than the effect time. With flag 1 only small changes (at most 1/10 of the dots,
like the clock's minutes) get the effect. After boot the dot state is unknown, so push
falls back to a wipe. They run in small steps from `loop()`, so serial data keeps
being read; while a new frame is being drawn into the buffer the effect pauses, and the
next `D` starts it again. The orders come from `DotSequence` in
[lib/BROSE9323/dot_sequence.h](../../lib/BROSE9323/dot_sequence.h).
- **Every 2 minutes** (`REFRESH_TIME`) it does a full redraw to fix dots that got stuck.
A full redraw blocks for about half a second, which is long enough to overflow the
64-byte serial buffer. So it only runs right after a `D` (the end of a frame, when the
ESP is quiet) or when no serial data has arrived for 2 seconds.
ESP is quiet) or when no serial data has arrived for 2 seconds, and never while an
effect runs. It is always instant, with one pulse per dot.
- **Flip pulse length** (µs, 50–2000) is set with the `T` command and stored in EEPROM,
so it is kept after a reboot. The default is 200 µs.
so it is kept after a reboot. The default is 200 µs. `G`, `N` and `E` are stored the
same way (addresses in `settings.cpp`; values that were never written fall back to the
defaults in `config.h`).
### Debug options
@@ -55,8 +70,8 @@ Uncomment these in `config.h`:
Each command is one ASCII line that **ends with `\n`** (`\r` is ignored). The Nano
collects a whole line before it runs the command. If a line is broken or unknown, it is
skipped and the next line starts clean. Coordinates are 2-digit **hex**. Color is `0`
(black) or `1` (yellow). The ESP32 driver (`ESPSERIAL`) only uses `s`, `S`, `F`, `D` and
`T`.
(black) or `1` (yellow). The ESP32 driver (`ESPSERIAL`) only uses `s`, `S`, `F`, `D`, `T`,
`G`, `N` and `E`.
| Cmd | Format | Meaning |
|-----|--------|---------|
@@ -68,6 +83,9 @@ skipped and the next line starts clean. Coordinates are 2-digit **hex**. Color i
| `B` | `BXXYYWWHHcHEX...` | Draw a bitmap. WW must be a multiple of 4. Pixel data is one hex digit per 4 pixels, row by row. The data is drawn while it arrives, so it is not limited to 64 characters. |
| `R` | `R` | Print the buffer as ASCII art on the serial port |
| `T` | `Tnnn` | Set the flip pulse length in µs (decimal, 50–2000), stored in EEPROM |
| `G` | `Gnnn` | Pause after each pulse in µs (decimal, 0–5000), stored in EEPROM |
| `N` | `Nn` | Pulses for each changed dot (1–3), stored in EEPROM |
| `E` | `Ee,nnnn[,f]` | Refresh effect `e` (0 instant, 1 sparkle, 2 wipe, 3 rain, 4 from the middle out, 5 diagonal, 6 spiral, 7 flip all then reveal, 8 push, 9 random), its time in ms (100–10000) and flags `f` (1 = only small changes, default 0), stored in EEPROM |
Replies on the Nano's TX pin:
+57
View File
@@ -102,6 +102,18 @@ void CommandParser::_runLine()
// Complete bitmap headers are handled in feed()
_error(F("Incomplete bitmap header"));
break;
case 'e':
case 'E':
_effect();
break;
case 'g':
case 'G':
_gap();
break;
case 'n':
case 'N':
_pulses();
break;
case 'd':
case 'D':
_display.display();
@@ -256,3 +268,48 @@ void CommandParser::_timing()
_display.setTiming(t);
saveFlipTime(t);
}
// Gttt (ttt = decimal pause after each pulse in us, 0-5000), stored in EEPROM
void CommandParser::_gap()
{
long t = atol(_line + 1);
if (_line_len < 2 || t < 0 || t > FLIP_GAP_MAX)
{
_error(F("Flip gap out of range (0-5000 us)"));
return;
}
_display.setGap(t);
saveFlipGap(t);
}
// Nn (n = pulses for each changed dot, 1-3), stored in EEPROM
void CommandParser::_pulses()
{
long n = atol(_line + 1);
if (n < 1 || n > PULSES_MAX)
{
_error(F("Pulses out of range (1-3)"));
return;
}
_display.setPulses(n);
savePulses(n);
}
// Ee,ttt[,f] (e = effect number, ttt = decimal duration in ms, 100-10000, f = flags,
// EFFECT_FLAG_... in dot_sequence.h, default 0), stored in EEPROM
void CommandParser::_effect()
{
const char *comma = strchr(_line + 1, ',');
const char *comma2 = comma ? strchr(comma + 1, ',') : nullptr;
long effect = atol(_line + 1);
long t = comma ? atol(comma + 1) : 0;
long flags = comma2 ? atol(comma2 + 1) : 0;
if (_line_len < 2 || !comma || effect < 0 || effect >= EFFECT_COUNT || t < EFFECT_TIME_MIN ||
t > EFFECT_TIME_MAX || flags < 0 || flags > EFFECT_FLAGS_MAX)
{
_error(F("Invalid effect (Ee,ms,flags: e 0-9, 100-10000 ms, flags 0-1)"));
return;
}
_display.setEffect(effect, t, flags);
saveEffect(effect, t, flags);
}
+3
View File
@@ -44,4 +44,7 @@ private:
void _print();
void _pixel(bool direct);
void _timing();
void _gap();
void _pulses();
void _effect();
};
+16
View File
@@ -26,6 +26,22 @@ const uint16_t FLIP_TIME_DEFAULT = 200;
const uint16_t FLIP_TIME_MIN = 50;
const uint16_t FLIP_TIME_MAX = 2000;
// Pause after each pulse in us, lets the coil supply recover (G command, EEPROM)
const uint16_t FLIP_GAP_DEFAULT = 200;
const uint16_t FLIP_GAP_MAX = 5000;
// Pulses for each dot that changed (N command, EEPROM)
const uint8_t PULSES_DEFAULT = 2;
const uint8_t PULSES_MAX = 3;
// How a new frame appears (E command, EEPROM): RefreshEffect from dot_sequence.h and
// the time it takes in ms
const uint8_t EFFECT_DEFAULT = 0; // instant
const uint16_t EFFECT_TIME_DEFAULT = 1500;
const uint16_t EFFECT_TIME_MIN = 100;
const uint16_t EFFECT_TIME_MAX = 10000;
const uint8_t EFFECT_FLAGS_MAX = 1; // EFFECT_FLAG_SMALL_ONLY
// Full redraw interval, fixes dots that did not flip
const uint32_t REFRESH_TIME = 120000UL; // 2 min
+10
View File
@@ -19,6 +19,9 @@ uint32_t last_rx;
// finished a frame, or when the serial line is idle.
void refreshIfDue()
{
// Not while an effect is drawing a frame (it flips every changed dot itself)
if (display.busy())
return;
bool quiet = parser.frameEnded() || millis() - last_rx >= IDLE_TIME;
if (quiet && millis() - last_refresh >= REFRESH_TIME)
{
@@ -47,6 +50,12 @@ void setup()
Serial.begin(SERIAL_BAUD);
display.setTiming(loadFlipTime());
display.setGap(loadFlipGap());
display.setPulses(loadPulses());
uint8_t effect, effect_flags;
uint16_t effect_time;
loadEffect(effect, effect_time, effect_flags);
display.setEffect(effect, effect_time, effect_flags);
display.begin();
#ifdef MEASURE_REFRESH
@@ -68,5 +77,6 @@ void loop()
parser.feed(Serial.read());
}
display.update();
refreshIfDue();
}
+75 -7
View File
@@ -3,25 +3,93 @@
#include <EEPROM.h>
#include "config.h"
#include "dot_sequence.h"
// EEPROM layout: magic marker (marks the stored value as valid), then the flip time
// EEPROM layout: magic marker (marks the stored values as valid), then the values. Later
// additions are checked one by one: on older Nanos they still hold 0xFF (out of range).
const int EEPROM_MAGIC_ADDR = 0;
const int EEPROM_FLIP_TIME_ADDR = 2;
const int EEPROM_FLIP_GAP_ADDR = 4;
const int EEPROM_PULSES_ADDR = 6;
const int EEPROM_EFFECT_ADDR = 7;
const int EEPROM_EFFECT_TIME_ADDR = 8;
const int EEPROM_EFFECT_FLAGS_ADDR = 10;
const uint16_t EEPROM_MAGIC = 0xF11D;
static bool valid()
{
uint16_t magic;
EEPROM.get(EEPROM_MAGIC_ADDR, magic);
return magic == EEPROM_MAGIC;
}
// put() only writes bytes that changed
template <typename T>
static void save(int addr, T value)
{
EEPROM.put(addr, value);
EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC);
}
uint16_t loadFlipTime()
{
uint16_t magic, t;
EEPROM.get(EEPROM_MAGIC_ADDR, magic);
uint16_t t;
EEPROM.get(EEPROM_FLIP_TIME_ADDR, t);
if (magic != EEPROM_MAGIC || t < FLIP_TIME_MIN || t > FLIP_TIME_MAX)
if (!valid() || t < FLIP_TIME_MIN || t > FLIP_TIME_MAX)
return FLIP_TIME_DEFAULT;
return t;
}
void saveFlipTime(uint16_t t)
{
// put() only writes bytes that changed
EEPROM.put(EEPROM_FLIP_TIME_ADDR, t);
EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC);
save(EEPROM_FLIP_TIME_ADDR, t);
}
uint16_t loadFlipGap()
{
uint16_t t;
EEPROM.get(EEPROM_FLIP_GAP_ADDR, t);
if (!valid() || t > FLIP_GAP_MAX)
return FLIP_GAP_DEFAULT;
return t;
}
void saveFlipGap(uint16_t t)
{
save(EEPROM_FLIP_GAP_ADDR, t);
}
uint8_t loadPulses()
{
uint8_t n;
EEPROM.get(EEPROM_PULSES_ADDR, n);
if (!valid() || n < 1 || n > PULSES_MAX)
return PULSES_DEFAULT;
return n;
}
void savePulses(uint8_t n)
{
save(EEPROM_PULSES_ADDR, n);
}
void loadEffect(uint8_t &effect, uint16_t &time_ms, uint8_t &flags)
{
EEPROM.get(EEPROM_EFFECT_ADDR, effect);
EEPROM.get(EEPROM_EFFECT_TIME_ADDR, time_ms);
EEPROM.get(EEPROM_EFFECT_FLAGS_ADDR, flags);
if (!valid() || effect >= EFFECT_COUNT || time_ms < EFFECT_TIME_MIN || time_ms > EFFECT_TIME_MAX)
{
effect = EFFECT_DEFAULT;
time_ms = EFFECT_TIME_DEFAULT;
}
if (!valid() || flags > EFFECT_FLAGS_MAX)
flags = 0;
}
void saveEffect(uint8_t effect, uint16_t time_ms, uint8_t flags)
{
save(EEPROM_EFFECT_ADDR, effect);
save(EEPROM_EFFECT_TIME_ADDR, time_ms);
save(EEPROM_EFFECT_FLAGS_ADDR, flags);
}
+11 -3
View File
@@ -2,9 +2,17 @@
#include <Arduino.h>
// Settings that survive a power cycle (stored in EEPROM)
// Settings that survive a power cycle (stored in EEPROM). Each load function returns the
// default from config.h when nothing valid is stored.
// Stored flip time, or FLIP_TIME_DEFAULT when nothing valid is stored
uint16_t loadFlipTime();
void saveFlipTime(uint16_t t);
uint16_t loadFlipGap();
void saveFlipGap(uint16_t t);
uint8_t loadPulses();
void savePulses(uint8_t n);
void loadEffect(uint8_t &effect, uint16_t &time_ms, uint8_t &flags);
void saveEffect(uint8_t effect, uint16_t time_ms, uint8_t flags);