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

+1 -1
View File
@@ -36,7 +36,7 @@ See README.md for the overview.
data in `PROGMEM`. data in `PROGMEM`.
- The ESP32 talks to the Nano on `Serial2` (owned by `NanoLink`, GPIO 17 TX / GPIO 16 RX). - The ESP32 talks to the Nano on `Serial2` (owned by `NanoLink`, GPIO 17 TX / GPIO 16 RX).
`Serial` (UART0/USB) is free for debug output. Never write debug text to `Serial2`: the `Serial` (UART0/USB) is free for debug output. Never write debug text to `Serial2`: the
Nano runs lines that start with s/S/F/D/P/B/R/T as commands. Nano runs lines that start with s/S/F/D/P/B/R/T/G/N/E as commands.
- Open hardware TODOs are listed in the README `TODO` section. Update it when one is done. - Open hardware TODOs are listed in the README `TODO` section. Update it when one is done.
- `src/esp32/secrets.h` holds the real credentials and is gitignored. Never commit it. - `src/esp32/secrets.h` holds the real credentials and is gitignored. Never commit it.
Add new secrets to `secrets.example.h` as placeholders. Add new secrets to `secrets.example.h` as placeholders.
+15 -6
View File
@@ -95,9 +95,9 @@ view of the display: it shows what the dots show right now, updated every 1.5 s.
| Tab | What you can do | | 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. | | **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. | | **Bitmaps** | All bitmaps with a preview: show one as the page, send it live, open it in the editor, delete it. The built-in Nintendo logo 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. | | **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. | | **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 pause after each pulse, pulses per dot and the refresh effect (instant, sparkle, wipe, rain, from the middle out, diagonal, spiral, flip all then reveal, push, random; optionally only for small changes like the clock's minutes); test patterns; the status LED. |
| **Home Assistant** | On/off, broker, port, user, password. Saving restarts the ESP32. An empty broker uses `secrets.h`. | | **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. | | **System** | Firmware status, uptime, WiFi, storage; restart; start the recovery app. |
@@ -293,10 +293,15 @@ The ESP32 talks to the Nano on **UART2** (`Serial2`, 115200 baud). The ESP32's o
shown from 17:00. shown from 17:00.
- **22:00–23:30:** the clock is only shown during the minutes :00, :15, :30 and :45. - **22:00–23:30:** the clock is only shown during the minutes :00, :15, :30 and :45.
30 seconds after each of those minutes, the relay turns the display off. 30 seconds after each of those minutes, the relay turns the display off.
- **23:31–08:00:** the screen is cleared and power is cut after 30 seconds. - **23:31–08:00:** the screen is cleared (or shows the off image chosen in the web UI)
3. **Home Assistant switches** (MQTT discovery): and power is cut after 30 seconds.
- `FlipDot Display Clock`: off shows a Nintendo logo instead of the clock. 3. **Home Assistant** (MQTT discovery):
- `FlipDot Display Power`: off clears the display and cuts power through the relay. - `FlipDot Display Power` switch: off clears the display and cuts power through the relay.
- `FlipDot Display Mode` dropdown: what is shown during the day, the clock or one of the
bitmaps (the same choice as in the web UI).
- `FlipDot Display Off image` dropdown: what is left on the dots when the display
switches off (at night or with the Power switch): blank or one of the bitmaps.
- `FlipDot Display IP` sensor: the ESP32's IP address.
4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer. 4. **Nano.** The Nano reads commands from the serial line and updates its frame buffer.
On `D` it flips only the dots that changed. Every 2 minutes it redraws the whole screen On `D` it flips only the dots that changed. Every 2 minutes it redraws the whole screen
to fix dots that did not flip. to fix dots that did not flip.
@@ -324,6 +329,10 @@ The serial protocol is described in [src/nano/README.md](src/nano/README.md).
- [X] **Test flashing over WiFi on the real hardware.** The uploader is tested against a - [X] **Test flashing over WiFi on the real hardware.** The uploader is tested against a
simulated bootloader, not yet against the real Nano. Check especially that the old simulated bootloader, not yet against the real Nano. Check especially that the old
bootloader starts after the relay power cycle. bootloader starts after the relay power cycle.
- [ ] **Tune the flip settings on the real hardware.** Flash both the Nano and the ESP32
(the old Nano ignores the new `G`/`N`/`E` commands). With the test patterns (Settings),
try flip gap 0/200/500 µs and 1 or 2 pulses, and check that the refresh effects look
right and that the off image is complete before the power is cut.
- [ ] **Check the boot timing.** Build the Nano with `MEASURE_REFRESH` to see how long a - [ ] **Check the boot timing.** Build the Nano with `MEASURE_REFRESH` to see how long a
full redraw takes. If the reply wire isn't fitted, check that 3 s full redraw takes. If the reply wire isn't fitted, check that 3 s
(`NANO_BOOT_TIMEOUT`) is enough for the Nano to boot. (`NANO_BOOT_TIMEOUT`) is enough for the Nano to boot.
+235 -41
View File
@@ -75,38 +75,17 @@ void BROSE9323::display(bool force)
#else #else
if (_direct_mode) if (_direct_mode)
return; return;
if (_force_next) bool all = force || _force_next;
_force_next = false;
_drawing = false;
// An explicit full redraw is always instant (the periodic refresh)
if (_effect == EFFECT_INSTANT || force)
{ {
force = true; _animating = false;
_force_next = false; _drawAll(all);
} }
for (uint8_t x = 0; x < width(); x++) else
{ _startEffect(all);
_selectPanel(x / _panel_width);
_selectColumn(x % _panel_width);
for (uint8_t y = 0; y < height(); y++)
{
bool b = _new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7));
#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__)
if (!force && (bool)(_old_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))) == b)
{
continue;
}
#endif
_selectRow(y);
_setData(_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7)));
_strobe();
}
}
#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__)
// Store currently displayed content in old buffer
memcpy(_old_buffer, _new_buffer, _buffer_size);
#endif
#endif #endif
} }
@@ -136,6 +115,8 @@ void BROSE9323::drawPixel(int16_t x, int16_t y, uint16_t color)
#else #else
if (y >= height() || y < 0 || x >= width() || x < 0) if (y >= height() || y < 0 || x >= width() || x < 0)
return; return;
if (!_direct_mode)
_drawing = true;
if (color) if (color)
{ {
if (_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7))) if (_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7)))
@@ -158,18 +139,8 @@ void BROSE9323::drawPixel(int16_t x, int16_t y, uint16_t color)
_selectColumn(x % _panel_width); _selectColumn(x % _panel_width);
_selectRow(y);
_setData(_new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7)));
_strobe();
#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__)
// The dot is flipped now, so the next display() does not have to flip it again // The dot is flipped now, so the next display() does not have to flip it again
if (color) _flipDot(x, y, _pulses);
_old_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7);
else
_old_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7));
#endif
} }
#endif #endif
} }
@@ -181,6 +152,8 @@ void BROSE9323::fillScreen(uint16_t color)
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);
if (!_direct_mode)
_drawing = true;
if (_direct_mode) if (_direct_mode)
{ {
_setData(color); _setData(color);
@@ -219,6 +192,45 @@ void BROSE9323::setTiming(uint16_t t)
#endif #endif
} }
void BROSE9323::setGap(uint16_t t)
{
#ifdef ESPSERIAL
stream->write('G');
stream->print(t);
stream->write('\n');
#else
_flip_gap = t;
#endif
}
void BROSE9323::setPulses(uint8_t n)
{
#ifdef ESPSERIAL
stream->write('N');
stream->print(n);
stream->write('\n');
#else
_pulses = n ? n : 1;
#endif
}
void BROSE9323::setEffect(uint8_t effect, uint16_t time_ms, uint8_t flags)
{
#ifdef ESPSERIAL
stream->write('E');
stream->print(effect);
stream->write(',');
stream->print(time_ms);
stream->write(',');
stream->print(flags);
stream->write('\n');
#else
_effect = effect < EFFECT_COUNT ? effect : EFFECT_INSTANT;
_effect_time = time_ms;
_effect_flags = flags;
#endif
}
void BROSE9323::setDirect(bool d) void BROSE9323::setDirect(bool d)
{ {
_direct_mode = d; _direct_mode = d;
@@ -297,6 +309,188 @@ void BROSE9323::_strobe(void)
digitalWrite(ENABLE, 0); digitalWrite(ENABLE, 0);
delayMicroseconds(_flip_time); delayMicroseconds(_flip_time);
digitalWrite(ENABLE, 1); digitalWrite(ENABLE, 1);
if (_flip_gap)
delayMicroseconds(_flip_gap);
}
// What the panel shows now (without an old buffer: unknown, so the opposite of the
// wanted state, which makes every dot count as changed)
bool BROSE9323::_shown(uint8_t x, uint8_t y) const
{
#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__)
return _old_buffer[y * _buffer_width + x / 8] & (1 << (x & 7));
#else
return !_wanted(x, y);
#endif
}
bool BROSE9323::_changed(uint8_t x, uint8_t y) const
{
return _shown(x, y) != _wanted(x, y);
}
bool BROSE9323::_wanted(uint8_t x, uint8_t y) const
{
return _new_buffer[y * _buffer_width + x / 8] & (1 << (x & 7));
}
// Flip one dot and remember that it is shown
void BROSE9323::_flipTo(uint8_t x, uint8_t y, bool on, uint8_t pulses)
{
_selectPanel(x / _panel_width);
_selectColumn(x % _panel_width);
_selectRow(y);
_setData(on);
for (uint8_t i = 0; i < pulses; i++)
_strobe();
#if !defined(__AVR_ATmega168P__) && !defined(__AVR_ATmega168PB__) && !defined(__AVR_ATmega168__)
if (on)
_old_buffer[y * _buffer_width + x / 8] |= 1 << (x & 7);
else
_old_buffer[y * _buffer_width + x / 8] &= ~(1 << (x & 7));
#endif
}
// Flip one dot to its state in the new buffer
void BROSE9323::_flipDot(uint8_t x, uint8_t y, uint8_t pulses)
{
_flipTo(x, y, _wanted(x, y), pulses);
}
// Column by column, as fast as possible. all: also the dots that look unchanged (one
// pulse each, they are only repeated in case they didn't flip before).
void BROSE9323::_drawAll(bool all)
{
for (uint8_t x = 0; x < width(); x++)
for (uint8_t y = 0; y < height(); y++)
{
bool changed = _changed(x, y);
if (changed || all)
_flipDot(x, y, changed ? _pulses : 1);
}
}
void BROSE9323::_startEffect(bool all)
{
uint16_t total = (uint16_t)width() * height();
uint16_t changed = 0;
for (uint8_t y = 0; y < height(); y++)
for (uint8_t x = 0; x < width(); x++)
changed += _changed(x, y);
if (changed == 0 && !all)
{
_animating = false;
return;
}
// E.g. only the clock's minutes get the effect, a new page appears at once
if ((_effect_flags & EFFECT_FLAG_SMALL_ONLY) && (all || changed > total / 10))
{
_animating = false;
_drawAll(all);
return;
}
uint8_t effect = _effect;
if (effect == EFFECT_RANDOM)
effect = 1 + micros() % (EFFECT_RANDOM - 1);
// Pushing needs to know what is shown; after boot that is unknown
if (all && effect == EFFECT_PUSH)
effect = EFFECT_WIPE;
_running_effect = effect;
_animate_all = all;
_phase = 0;
uint32_t steps;
if (effect == EFFECT_PUSH)
{
// One step per column shift; the dots of a shift are flipped as fast as possible
_push_shift = 1;
_push_pos = 0;
steps = width();
}
else
{
_sequence.begin(effect == EFFECT_FLAP ? EFFECT_WIPE : effect, width(), height(), (uint16_t)micros());
// Sparkle: only the changed dots take time, so a small change sparkles as long as a big one
steps = (effect == EFFECT_SPARKLE && !all) ? changed : total;
if (effect == EFFECT_FLAP)
steps *= 2;
}
_step_time = (uint32_t)_effect_time * 1000 / steps;
_next_step = micros();
_animating = true;
}
// One dot of the current push step. Shift s (1..width) shows old columns s.. on the left
// and new columns 0..s-1 on the right: each column takes over its right neighbour (not
// changed yet in this step, as the columns go left to right), the last one a new column.
// False when the last shift is done.
bool BROSE9323::_pushDot(void)
{
uint8_t w = width(), h = height();
uint8_t x = _push_pos / h, y = _push_pos % h;
bool target = x + 1 < w ? _shown(x + 1, y) : _wanted(_push_shift - 1, y);
if (_shown(x, y) != target)
_flipTo(x, y, target, _push_shift == w ? _pulses : 1);
if (++_push_pos < (uint16_t)w * h)
return true;
_push_pos = 0;
_next_step += _step_time;
return ++_push_shift <= w;
}
bool BROSE9323::busy(void) const
{
return _animating && !_drawing;
}
bool BROSE9323::update(void)
{
// Wait for the D that ends the frame being drawn, it starts the effect again
if (!_animating || _drawing)
return busy();
// At most about 1 ms per call, so loop() keeps reading the serial port
uint32_t start = micros();
while ((int32_t)(micros() - _next_step) >= 0 && micros() - start < 1000)
{
if (_running_effect == EFFECT_PUSH)
{
if (!_pushDot())
{
_animating = false;
break;
}
continue;
}
uint8_t x, y;
if (!_sequence.next(x, y))
{
if (_running_effect == EFFECT_FLAP && _phase == 0)
{
// Everything is yellow now: reveal the new image
_phase = 1;
_sequence.begin(EFFECT_WIPE, width(), height(), 0);
continue;
}
_animating = false;
break;
}
// Flap: first every dot to yellow (one pulse), then the new image
bool yellow_phase = _running_effect == EFFECT_FLAP && _phase == 0;
bool target = yellow_phase ? true : _wanted(x, y);
bool changed = _shown(x, y) != target;
if (!changed && !_animate_all)
{
if (_running_effect == EFFECT_SPARKLE)
continue; // unchanged dots take no time
}
else
_flipTo(x, y, target, changed && !yellow_phase ? _pulses : 1);
_next_step += _step_time;
}
return busy();
} }
#endif #endif
+42
View File
@@ -3,6 +3,8 @@
#include <Adafruit_GFX.h> #include <Adafruit_GFX.h>
#include "dot_sequence.h"
//#define FLIPDOT_PLCC_ADAPTER //#define FLIPDOT_PLCC_ADAPTER
// ESPSERIAL is set per target via build_flags in platformio.ini (-D ESPSERIAL for the ESP32) // ESPSERIAL is set per target via build_flags in platformio.ini (-D ESPSERIAL for the ESP32)
@@ -10,6 +12,11 @@ class BROSE9323 : public Adafruit_GFX
{ {
private: private:
uint16_t _flip_time; uint16_t _flip_time;
uint16_t _flip_gap = 0; // pause after each pulse in us, lets the coil supply recover
uint8_t _pulses = 1; // pulses for each dot that changed
uint8_t _effect = EFFECT_INSTANT;
uint16_t _effect_time = 1500; // ms, how long an effect takes
uint8_t _effect_flags = 0; // EFFECT_FLAG_...
const uint8_t _panel_width; const uint8_t _panel_width;
const uint8_t _buffer_width; const uint8_t _buffer_width;
const uint16_t _buffer_size; const uint16_t _buffer_size;
@@ -27,6 +34,18 @@ private:
// After boot the physical dot state is unknown, so the first display() redraws everything // After boot the physical dot state is unknown, so the first display() redraws everything
bool _force_next = true; bool _force_next = true;
// A running effect (display() with an effect other than EFFECT_INSTANT)
DotSequence _sequence;
bool _animating = false;
uint8_t _running_effect = EFFECT_INSTANT; // _effect, or the one chosen for EFFECT_RANDOM
uint8_t _phase = 0; // EFFECT_FLAP: 0 = to yellow, 1 = the new image
uint8_t _push_shift = 0; // EFFECT_PUSH: column shift 1..width
uint16_t _push_pos = 0; // EFFECT_PUSH: dot within the shift
bool _animate_all = false; // flip every dot, not only the changed ones
bool _drawing = false; // the buffer changed since the last display(): pause
uint32_t _step_time = 0; // us per step
uint32_t _next_step = 0; // micros() of the next step
#ifndef FLIPDOT_PLCC_ADAPTER #ifndef FLIPDOT_PLCC_ADAPTER
const uint8_t ENABLE = 7, const uint8_t ENABLE = 7,
ADDR_0 = 3, ADDR_0 = 3,
@@ -71,6 +90,14 @@ private:
void _selectRow(uint8_t); void _selectRow(uint8_t);
void _setData(bool); void _setData(bool);
void _strobe(void); void _strobe(void);
bool _shown(uint8_t x, uint8_t y) const;
bool _changed(uint8_t x, uint8_t y) const;
bool _wanted(uint8_t x, uint8_t y) const;
void _flipTo(uint8_t x, uint8_t y, bool on, uint8_t pulses);
void _flipDot(uint8_t x, uint8_t y, uint8_t pulses);
bool _pushDot(void);
void _drawAll(bool all);
void _startEffect(bool all);
#endif #endif
public: public:
BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t ft = 280); BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t ft = 280);
@@ -83,6 +110,21 @@ public:
void fillScreen(uint16_t); void fillScreen(uint16_t);
void setDirect(bool); void setDirect(bool);
void setTiming(uint16_t); void setTiming(uint16_t);
// Pause after each flip pulse in us
void setGap(uint16_t);
// Pulses for each changed dot (a second pulse catches dots that didn't flip). Full
// redraws use one pulse per dot.
void setPulses(uint8_t);
// How display() shows a new frame (RefreshEffect), how long it takes in ms (pushing
// can take longer: it flips many dots per step) and EFFECT_FLAG_... flags.
// Effects run in steps from update(), so call that often (every loop).
void setEffect(uint8_t effect, uint16_t time_ms, uint8_t flags = 0);
#ifndef ESPSERIAL
// Runs the next steps of an effect. Returns busy().
bool update(void);
// An effect is running (paused while a new frame is being drawn into the buffer)
bool busy(void) const;
#endif
#ifndef ESPSERIAL #ifndef ESPSERIAL
void printBuffer(void); void printBuffer(void);
#endif #endif
+7
View File
@@ -19,6 +19,13 @@ The flags are set per environment in `platformio.ini` through `build_flags`. Don
- In direct mode (`setDirect(true)`) every pixel is flipped right away, without a call to - In direct mode (`setDirect(true)`) every pixel is flipped right away, without a call to
`display()`. `display()`.
- `setGap(us)` adds a pause after each flip pulse, `setPulses(n)` pulses each changed
dot `n` times. Full redraws (`display(true)`, the first `display()` after boot) pulse
the dots that look unchanged only once.
- `setEffect(effect, ms)`: how `display()` shows a frame (`RefreshEffect` in
`dot_sequence.h`). Anything but `EFFECT_INSTANT` runs in steps from `update()`, which
must be called often; `busy()` says whether an effect is running. `display(true)` is
always instant. On the ESP32 these calls become the `G`, `N` and `E` commands.
- `print()` maps German umlauts (ÄäÖöÜüß) to the code page 437 glyphs used by the GFX - `print()` maps German umlauts (ÄäÖöÜüß) to the code page 437 glyphs used by the GFX
font. font.
- On an ATmega168 the old buffer is left out to save RAM. Every `display()` call then - On an ATmega168 the old buffer is left out to save RAM. Every `display()` call then
+207
View File
@@ -0,0 +1,207 @@
#pragma once
#include <stdint.h>
// The order in which display() flips the dots, and how it is spread over time.
// The numbers are part of the serial protocol (E command) and the ESP32 settings.
enum RefreshEffect : uint8_t
{
EFFECT_INSTANT = 0, // column by column, left to right, as fast as possible
EFFECT_SPARKLE = 1, // the changed dots one at a time, in a scrambled order
EFFECT_WIPE = 2, // column by column, left to right
EFFECT_RAIN = 3, // row by row, top to bottom
EFFECT_CENTER = 4, // column by column, from the middle outwards
EFFECT_DIAGONAL = 5, // from the top left to the bottom right corner
EFFECT_SPIRAL = 6, // from the outside in, clockwise
// The ones below are run by BROSE9323 itself (DotSequence uses column order for them)
EFFECT_FLAP = 7, // every dot to yellow (wipe), then the new image (wipe)
EFFECT_PUSH = 8, // the new image slides in from the right, pushing the old one out
EFFECT_RANDOM = 9, // one of the effects 1-8, chosen for each frame
EFFECT_COUNT = 10,
};
// Flags for BROSE9323::setEffect()
const uint8_t EFFECT_FLAG_SMALL_ONLY = 1; // big changes (> 1/10 of the dots) are instant
// Visits every dot of a w x h display exactly once, in the order of an effect. Needs no
// buffer: the sparkle order comes from a maximal length LFSR (all states except 0, out
// of range values skipped), scrambled with a multiplication modulo the dot count.
class DotSequence
{
public:
void begin(uint8_t effect, uint8_t w, uint8_t h, uint16_t seed)
{
_effect = effect < EFFECT_COUNT ? effect : EFFECT_INSTANT;
_w = w;
_h = h;
_total = (uint16_t)w * h;
_pos = 0;
_diagonal = 0;
_left = 0;
_top = 0;
_right = w - 1;
_bottom = h - 1;
_x = 0;
_y = 0;
_direction = 0;
if (_effect != EFFECT_SPARKLE || _total == 0)
return;
uint8_t bits = 2;
while (bits < 16 && ((1UL << bits) - 1) < _total)
bits++;
// Galois LFSR feedback masks with a maximal period, for 2..16 bits
static const uint16_t masks[15] = {0x3, 0x6, 0xC, 0x14, 0x30, 0x60, 0xB8, 0x110,
0x240, 0x500, 0xE08, 0x1C80, 0x3802, 0x6000, 0xD008};
_mask = masks[bits - 2];
_period = (uint16_t)((1UL << bits) - 1);
_lfsr = seed % _period + 1; // any state except 0
// A multiplier without common factors with the total keeps the mapping 1:1
_mult = 997 % _total;
while (_mult == 0 || gcd(_mult, _total) != 1)
_mult++;
}
// The next dot; false when every dot was visited
bool next(uint8_t &x, uint8_t &y)
{
if (_effect == EFFECT_SPARKLE)
{
while (_pos < _period)
{
_pos++;
uint16_t index = _lfsr - 1;
_lfsr = (_lfsr & 1) ? (_lfsr >> 1) ^ _mask : _lfsr >> 1;
if (index < _total)
{
uint16_t p = (uint32_t)index * _mult % _total;
x = p % _w;
y = p / _w;
return true;
}
}
return false;
}
if (_pos >= _total)
return false;
if (_effect == EFFECT_DIAGONAL)
{
// Diagonal d holds the dots with x + y = d, top to bottom
while (true)
{
if (_y > (int16_t)_diagonal || _y >= _h)
{
_diagonal++;
_y = 0;
continue;
}
uint16_t dx = _diagonal - _y;
uint8_t dy = _y++;
if (dx < _w)
{
x = dx;
y = dy;
_pos++;
return true;
}
}
}
if (_effect == EFFECT_SPIRAL)
{
x = _x;
y = _y;
_pos++;
// Walk along the edge of the remaining rectangle, then make it smaller
switch (_direction)
{
case 0: // right along the top
if (_x < _right)
_x++;
else
{
_top++;
_direction = 1;
_y++;
}
break;
case 1: // down the right side
if (_y < _bottom)
_y++;
else
{
_right--;
_direction = 2;
_x--;
}
break;
case 2: // left along the bottom
if (_x > _left)
_x--;
else
{
_bottom--;
_direction = 3;
_y--;
}
break;
default: // up the left side
if (_y > _top)
_y--;
else
{
_left++;
_direction = 0;
_x++;
}
break;
}
return true;
}
uint16_t p = _pos++;
if (_effect == EFFECT_RAIN)
{
x = p % _w;
y = p / _w;
return true;
}
uint8_t column = p / _h;
y = p % _h;
if (_effect == EFFECT_CENTER)
{
uint8_t middle = _w / 2;
x = (column & 1) ? middle - (column + 1) / 2 : middle + column / 2;
}
else
x = column;
return true;
}
// Number of next() calls that return true
uint16_t total() const { return _total; }
private:
static uint16_t gcd(uint16_t a, uint16_t b)
{
while (b)
{
uint16_t t = a % b;
a = b;
b = t;
}
return a;
}
uint8_t _effect = EFFECT_INSTANT;
uint8_t _w = 0, _h = 0;
uint16_t _total = 0, _pos = 0;
uint16_t _lfsr = 1, _mask = 0, _period = 0, _mult = 1;
uint16_t _diagonal = 0; // diagonal
int16_t _left = 0, _top = 0, _right = 0, _bottom = 0; // spiral
int16_t _x = 0, _y = 0; // spiral; _y also diagonal
uint8_t _direction = 0; // spiral
};
+2 -1
View File
@@ -120,7 +120,8 @@ test_build_src = yes
extends = native extends = native
test_filter = test_nano test_filter = test_nano
build_src_filter = +<nano/> build_src_filter = +<nano/>
build_flags = -std=gnu++17 -I test/mocks ; test/mocks first: its BROSE9323.h replaces the driver, dot_sequence.h is the real one
build_flags = -std=gnu++17 -I test/mocks -I lib/BROSE9323
; ESP32 logic (test/test_esp32): only the modules without Arduino dependencies ; ESP32 logic (test/test_esp32): only the modules without Arduino dependencies
[env:test_esp32] [env:test_esp32]
+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/system_store.*` | Settings in NVS: WiFi, recovery reason, firmware slot and version, power-on count |
| `system/settings_store.*` | The web UI settings in NVS | | `system/settings_store.*` | The web UI settings in NVS |
| **`display/`** | **What is drawn** | | **`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/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 |
@@ -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) | | `nano_link/nano_flash_bridge.*` | WiFi to serial bridge for flashing the Nano over WiFi (TCP port 2323) |
| **`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 (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** (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_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 | | `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). [main README](../../README.md#recovery-app).
- **Frames only when something changes:** a new frame is sent when the minute changes, the - **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. quiet in between, which also gives the Nano time for its periodic full redraw.
- **OTA updates** through ArduinoOTA. Hostname: `FlipDotDisplay`. - **OTA updates** through ArduinoOTA. Hostname: `FlipDotDisplay`.
- **Robust OTA:** rollback, watchdog, OTA window after a crash and safe mode, see - **Robust OTA:** rollback, watchdog, OTA window after a crash and safe mode, see
[Keeping OTA alive](#keeping-ota-alive). [Keeping OTA alive](#keeping-ota-alive).
- **NTP time** with timezone `CET-1CEST,M3.5.0,M10.5.0/3` (Europe/Amsterdam). Until the - **NTP time** with timezone `CET-1CEST,M3.5.0,M10.5.0/3` (Europe/Amsterdam). Until the
time is synced, the display shows "Connecting...". time is synced, the display shows "Connecting...".
- **Home Assistant** through MQTT (`home-assistant-integration` library) with two switches. - **Home Assistant** through MQTT (`home-assistant-integration` library). The switch and
Each switch reports its new state back, so Home Assistant always shows the real state. the dropdown report their new state back, so Home Assistant always shows the real state.
- `FlipDot Display Clock`: on = clock; off = Nintendo logo while the display is on, - `FlipDot Display Power` switch: off = clear the display and power it off after 1 s
then power off after 30 s - `FlipDot Display Mode` dropdown: the page shown during the day, `Clock` or a bitmap.
- `FlipDot Display Power`: off = clear the display and power it off after 1 s 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, [Keeping OTA alive](#keeping-ota-alive)) and the **FlipDot Display Recovery** button,
which restarts into the recovery app for 10 minutes. 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 - **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); 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() static void apply()
{ {
controller.setSettings(settings.schedule, settings.clock); 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(); applyPage();
} }
@@ -64,14 +80,19 @@ const char *appSaveSettings(const Settings &new_settings, bool &restart_needed)
return error; return error;
if (strcmp(new_settings.page, PAGE_CLOCK) != 0 && !bitmapExists(new_settings.page)) if (strcmp(new_settings.page, PAGE_CLOCK) != 0 && !bitmapExists(new_settings.page))
return "Bitmap not found"; 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); 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; settings = new_settings;
saveSettings(settings); saveSettings(settings);
apply(); apply();
if (flip_time_changed && nano.isOn()) if (drive_changed)
display.setTiming(settings.flip_time); status.sendDrive();
return nullptr; return nullptr;
} }
@@ -86,8 +107,25 @@ const char *appSelectPage(const char *name)
return nullptr; 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) void appBitmapChanged(const char *name)
{ {
if (strcmp(settings.off_page, name) == 0)
{
applyOffPage();
controller.pageChanged();
}
if (strcmp(settings.page, name) == 0) if (strcmp(settings.page, name) == 0)
{ {
applyPage(); 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) // The page shown during the day: PAGE_CLOCK or a bitmap name (stored in the settings)
const char *appSelectPage(const char *name); 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); void appBitmapChanged(const char *name);
// Live mode of the web UI // 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; const uint32_t NO_WIFI_SHOW_TIME = 60000;
// Live mode of the web UI ends this long after the last image was sent // Live mode of the web UI ends this long after the last image was sent
const uint32_t LIVE_TIMEOUT = 30UL * 60 * 1000; 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; 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_CONNECT_TIMEOUT = 30000;
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000; 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) // 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}; 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[] = { static const BuiltinBitmap BUILTIN[] = {
{"Nintendo", nintendo_bitmap_Nintendo_new}, {"Nintendo", nintendo_bitmap_Nintendo_new},
{"Nintendo border", nintendo_bitmap_nintendo_border},
}; };
static bool mounted = false; static bool mounted = false;
static uint32_t revision = 0;
static const BuiltinBitmap *builtin(const char *name) static const BuiltinBitmap *builtin(const char *name)
{ {
@@ -77,6 +77,11 @@ std::vector<BitmapInfo> listBitmaps()
return list; return list;
} }
uint32_t bitmapListRevision()
{
return revision;
}
bool bitmapExists(const char *name) bool bitmapExists(const char *name)
{ {
if (builtin(name)) if (builtin(name))
@@ -118,6 +123,7 @@ const char *saveBitmap(const char *name, const uint8_t *bitmap)
return "Could not write the file"; return "Could not write the file";
bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES; bool ok = file.write(bitmap, BITMAP_BYTES) == (size_t)BITMAP_BYTES;
file.close(); file.close();
revision++;
return ok ? nullptr : "Storage full"; return ok ? nullptr : "Storage full";
} }
@@ -127,5 +133,8 @@ const char *deleteBitmap(const char *name)
return "Built-in bitmaps can't be deleted"; return "Built-in bitmaps can't be deleted";
if (!mounted || !validBitmapName(name) || !LittleFS.exists(path(name))) if (!mounted || !validBitmapName(name) || !LittleFS.exists(path(name)))
return "Bitmap not found"; 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); void bitmapStorageUsage(size_t &used, size_t &total);
std::vector<BitmapInfo> listBitmaps(); std::vector<BitmapInfo> listBitmaps();
// Changes each time a bitmap is saved or deleted
uint32_t bitmapListRevision();
bool bitmapExists(const char *name); bool bitmapExists(const char *name);
bool loadBitmap(const char *name, uint8_t *bitmap); 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, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x03, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfc,
0x00}; 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 // 'panda', 17x18px
const unsigned char nintendo_bitmap_panda_inverted[] PROGMEM = { const unsigned char nintendo_bitmap_panda_inverted[] PROGMEM = {
0xfe, 0x7f, 0x80, 0xfc, 0x3c, 0x80, 0xfc, 0x38, 0x00, 0xdd, 0xfe, 0x00, 0xbb, 0xff, 0x80, 0x73, 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 <stdio.h>
#include <string.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"
@@ -51,17 +50,9 @@ void FlipdotFrames::live()
_drawFullScreen(_live); _drawFullScreen(_live);
} }
void FlipdotFrames::logo() void FlipdotFrames::off()
{ {
uint8_t logo[BITMAP_BYTES]; _drawFullScreen(_off);
if (loadBitmap("Nintendo", logo))
_drawFullScreen(logo);
}
void FlipdotFrames::blank()
{
_display.fillScreen(0);
_display.display();
} }
void FlipdotFrames::setPageBitmap(const uint8_t *bitmap) void FlipdotFrames::setPageBitmap(const uint8_t *bitmap)
@@ -74,6 +65,14 @@ void FlipdotFrames::setLiveBitmap(const uint8_t *bitmap)
memcpy(_live, bitmap, BITMAP_BYTES); 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) void FlipdotFrames::_drawFullScreen(const uint8_t *bitmap)
{ {
_display.fillScreen(0); _display.fillScreen(0);
+5 -3
View File
@@ -18,17 +18,19 @@ public:
void page() override; void page() override;
// The image set with setLiveBitmap() // The image set with setLiveBitmap()
void live() override; void live() override;
// Nintendo logo // Blank, or the bitmap set with setOffBitmap()
void logo() override; void off() override;
void blank() override;
void setPageBitmap(const uint8_t *bitmap); void setPageBitmap(const uint8_t *bitmap);
void setLiveBitmap(const uint8_t *bitmap); void setLiveBitmap(const uint8_t *bitmap);
// nullptr: blank
void setOffBitmap(const uint8_t *bitmap);
private: private:
BROSE9323 &_display; BROSE9323 &_display;
uint8_t _page[BITMAP_BYTES] = {}; uint8_t _page[BITMAP_BYTES] = {};
uint8_t _live[BITMAP_BYTES] = {}; uint8_t _live[BITMAP_BYTES] = {};
uint8_t _off[BITMAP_BYTES] = {}; // all zero: blank
void _drawFullScreen(const uint8_t *bitmap); 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; _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) 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) if (!_started)
{ {
_display.begin(&_nano.stream()); _display.begin(&_nano.stream());
_display.setTiming(_flip_time);
_started = true; _started = true;
_drive_pending = true;
loop();
} }
drawTextLines(_display, line1, line2); drawTextLines(_display, line1, line2);
_display.display(); _display.display();
@@ -30,7 +49,8 @@ void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
if (!keep_on) if (!keep_on)
{ {
// The first frame after power on flips every dot, give the Nano time for that // 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(); _nano.off();
} }
} }
+10 -2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include "BROSE9323.h" #include "BROSE9323.h"
#include "logic/settings.h"
#include "nano_link/nano_link.h" #include "nano_link/nano_link.h"
// Short messages for situations outside the normal clock: booting, crashes, recovery app. // 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. // 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) // How the Nano drives the dots, sent with the first message (default: FLIP_TIME and
void setFlipTime(uint16_t flip_time); // 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: private:
BROSE9323 &_display; BROSE9323 &_display;
NanoLink &_nano; NanoLink &_nano;
bool _started = false; bool _started = false;
bool _drive_pending = false;
uint16_t _flip_time; 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 _content_revision++; // e.g. evening time changed: redraw
} }
void DisplayController::setEffectTime(uint32_t ms)
{
_effect_time = ms;
}
void DisplayController::setPage(Page page) void DisplayController::setPage(Page page)
{ {
_page = page; _page = page;
@@ -43,21 +48,11 @@ bool DisplayController::liveActive() const
return _live; return _live;
} }
void DisplayController::setClockEnabled(bool enabled)
{
_clock_enabled = enabled;
}
void DisplayController::setPowerEnabled(bool enabled) void DisplayController::setPowerEnabled(bool enabled)
{ {
_power_enabled = enabled; _power_enabled = enabled;
} }
bool DisplayController::clockEnabled() const
{
return _clock_enabled;
}
bool DisplayController::powerEnabled() const bool DisplayController::powerEnabled() const
{ {
return _power_enabled; return _power_enabled;
@@ -92,8 +87,8 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
{ {
if (_power.isOn()) if (_power.isOn())
{ {
_showOnce(Screen::Blank); _showOnce(Screen::Off);
_off_timer.request(now, CLEAR_BEFORE_OFF_TIME); _off_timer.request(now, CLEAR_BEFORE_OFF_TIME + _effect_time);
} }
return; return;
} }
@@ -111,23 +106,12 @@ void DisplayController::update(uint32_t now, bool time_valid, int hour, int minu
if (!time_valid) if (!time_valid)
return; 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)) switch (scheduleFor(hour, minute, _schedule))
{ {
case ScheduleMode::Night: case ScheduleMode::Night:
if (_power.isOn()) if (_power.isOn())
{ {
_showOnce(Screen::Blank); _showOnce(Screen::Off);
_off_timer.request(now, POWER_OFF_DELAY); _off_timer.request(now, POWER_OFF_DELAY);
} }
break; break;
@@ -186,11 +170,8 @@ void DisplayController::_showOnce(Screen screen)
case Screen::Live: case Screen::Live:
_frames.live(); _frames.live();
break; break;
case Screen::Logo:
_frames.logo();
break;
default: default:
_frames.blank(); _frames.off();
break; break;
} }
_screen = screen; _screen = screen;
+9 -9
View File
@@ -26,8 +26,8 @@ public:
virtual void page() = 0; virtual void page() = 0;
// The image sent from the web UI's live mode // The image sent from the web UI's live mode
virtual void live() = 0; virtual void live() = 0;
virtual void logo() = 0; // Left on the dots when the display switches off: blank or the off bitmap
virtual void blank() = 0; virtual void off() = 0;
}; };
enum class Page : uint8_t enum class Page : uint8_t
@@ -45,8 +45,12 @@ public:
DisplayController(DisplayPower &power, Frames &frames); DisplayController(DisplayPower &power, Frames &frames);
void setSettings(const ScheduleSettings &schedule, const ClockSettings &clock); 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 setPage(Page page);
void pageChanged(); void pageChanged();
@@ -56,12 +60,9 @@ public:
void stopLive(); void stopLive();
bool liveActive() const; 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 // Home Assistant "FlipDot Display Power" switch: off clears the display and powers it off
void setPowerEnabled(bool enabled); void setPowerEnabled(bool enabled);
bool clockEnabled() const;
bool powerEnabled() const; bool powerEnabled() const;
// Call often (every loop). time_valid is false until the time is synced over NTP. // Call often (every loop). time_valid is false until the time is synced over NTP.
@@ -76,8 +77,7 @@ private:
NoWifi, NoWifi,
Page, Page,
Live, Live,
Logo, Off, // blank or the off bitmap
Blank,
}; };
DisplayPower &_power; DisplayPower &_power;
@@ -87,8 +87,8 @@ private:
ScheduleSettings _schedule; ScheduleSettings _schedule;
ClockSettings _clock; ClockSettings _clock;
Page _page = Page::Clock; Page _page = Page::Clock;
uint32_t _effect_time = 0;
bool _clock_enabled = true;
bool _power_enabled = true; bool _power_enabled = true;
Screen _screen = Screen::Unknown; Screen _screen = Screen::Unknown;
+21
View File
@@ -15,6 +15,8 @@ const char *validateSettings(const Settings &s)
if (!terminated(s.page, sizeof(s.page)) || if (!terminated(s.page, sizeof(s.page)) ||
(strcmp(s.page, PAGE_CLOCK) != 0 && !validBitmapName(s.page))) (strcmp(s.page, PAGE_CLOCK) != 0 && !validBitmapName(s.page)))
return "Unknown 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; const ScheduleSettings &t = s.schedule;
if (t.day_start >= MINUTES_PER_DAY || t.late_start > MINUTES_PER_DAY || t.night_start > MINUTES_PER_DAY) 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) 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"; 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; const HomeAssistantSettings &ha = s.home_assistant;
if (!terminated(ha.host, sizeof(ha.host)) || !terminated(ha.user, sizeof(ha.user)) || if (!terminated(ha.host, sizeof(ha.host)) || !terminated(ha.user, sizeof(ha.user)) ||
!terminated(ha.password, sizeof(ha.password))) !terminated(ha.password, sizeof(ha.password)))
@@ -38,6 +50,15 @@ const char *validateSettings(const Settings &s)
return nullptr; 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) bool parseTime(const char *text, uint16_t &minutes)
{ {
if (text == nullptr) if (text == nullptr)
+28
View File
@@ -32,9 +32,29 @@ struct HomeAssistantSettings
char password[64] = ""; 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 // What is shown during the day: "clock" or the name of a bitmap
const int PAGE_NAME_SIZE = 25; const int PAGE_NAME_SIZE = 25;
#define PAGE_CLOCK "clock" #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 struct Settings
{ {
@@ -44,8 +64,16 @@ struct Settings
HomeAssistantSettings home_assistant; HomeAssistantSettings home_assistant;
uint16_t flip_time = 225; // flip pulse length in us, sent to the Nano uint16_t flip_time = 225; // flip pulse length in us, sent to the Nano
bool status_led = true; 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 MINUTES_PER_DAY = 24 * 60;
const uint16_t SETTINGS_FLIP_TIME_MIN = 50; const uint16_t SETTINGS_FLIP_TIME_MIN = 50;
const uint16_t SETTINGS_FLIP_TIME_MAX = 2000; 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 // While the Nano is being flashed, nothing else may talk to it
if (flashBridge.handle()) if (flashBridge.handle())
return; return;
status.loop(); // changed flip settings, once the display is on
struct tm now = {}; struct tm now = {};
bool time_valid = getLocalTime(&now, 0); 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 <ArduinoHA.h>
#include <WiFi.h> #include <WiFi.h>
#include <algorithm>
#include <string.h> #include <string.h>
#include <strings.h>
#include <utils/HADictionary.h>
#include <utils/HASerializer.h>
#include "app.h"
#include "config.h" #include "config.h"
#include "credentials.h" #include "credentials.h"
#include "display/bitmap_store.h"
#include "net/ha_page_select.h"
#include "system/boot_target.h" #include "system/boot_target.h"
static WiFiClient client; static WiFiClient client;
@@ -13,30 +20,153 @@ static HADevice device(HA_DEVICE_ID, sizeof(HA_DEVICE_ID));
static HAMqtt mqtt(client, device); static HAMqtt mqtt(client, device);
// Unique ids: do not change, Home Assistant's entities are linked to them // Unique ids: do not change, Home Assistant's entities are linked to them
static HASwitch clockSwitch("FlipDotDisplayClock");
static HASwitch powerSwitch("FlipDotDisplayPower"); static HASwitch powerSwitch("FlipDotDisplayPower");
static HAPageSelect modeSelect("FlipDotDisplayMode");
static HAPageSelect offSelect("FlipDotDisplayOffImage");
static HASensor firmwareSensor("FlipDotDisplayFirmware"); static HASensor firmwareSensor("FlipDotDisplayFirmware");
static HASensor ipSensor("FlipDotDisplayIP");
static HAButton recoveryButton("FlipDotDisplayRecovery"); static HAButton recoveryButton("FlipDotDisplayRecovery");
static char firmware_status[64] = ""; // Removed entities: their retained discovery config is cleared, so Home Assistant
static bool firmware_status_sent = false; // 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 DisplayController *displayController = nullptr;
static bool enabled = false; 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)
{
displayController->setClockEnabled(state);
sender->setState(state);
}
static void onPowerCommand(bool state, HASwitch *sender) static void onPowerCommand(bool state, HASwitch *sender)
{ {
displayController->setPowerEnabled(state); displayController->setPowerEnabled(state);
sender->setState(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) void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettings &settings)
{ {
displayController = &controller; displayController = &controller;
@@ -50,24 +180,36 @@ void homeAssistantBegin(DisplayController &controller, const HomeAssistantSettin
device.setName(HA_DEVICE_NAME); device.setName(HA_DEVICE_NAME);
device.setSoftwareVersion(HA_SOFTWARE_VERSION); 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.setName("FlipDot Display Power");
powerSwitch.setIcon("mdi:lightbulb"); powerSwitch.setIcon("mdi:lightbulb");
powerSwitch.setCurrentState(controller.powerEnabled()); powerSwitch.setCurrentState(controller.powerEnabled());
powerSwitch.onCommand(onPowerCommand); 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.setName("FlipDot Display Recovery");
recoveryButton.setIcon("mdi:lifebuoy"); recoveryButton.setIcon("mdi:lifebuoy");
recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); }); recoveryButton.onCommand([](HAButton *) { rebootToRecovery(RecoveryReason::Manual); });
firmwareSensor.setName("FlipDot Display Firmware"); firmwareSensor.setName("FlipDot Display Firmware");
firmwareSensor.setIcon("mdi:chip"); 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 // settings must stay alive: the MQTT library keeps the pointers
if (settings.host[0]) if (settings.host[0])
@@ -87,14 +229,20 @@ void homeAssistantLoop()
if (!enabled) if (!enabled)
return; return;
mqtt.loop(); 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) void homeAssistantSetFirmwareStatus(const char *status)
{ {
if (strcmp(status, firmware_status) == 0) firmware.set(status);
return;
strncpy(firmware_status, status, sizeof(firmware_status) - 1);
firmware_status_sent = false;
} }
+5 -2
View File
@@ -4,8 +4,11 @@
#include "logic/settings.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 Power switch: off = draw the off image and switch the display off
// FlipDot Display Power switch: off = clear the display and switch it 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 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)
// //
+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() const char *firmwareGuardStatus()
{ {
return status; 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" // Short status for Home Assistant, e.g. "2.1.0, ok, 0 crashes, recovery ok"
const char *firmwareGuardStatus(); 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 "system/settings_store.h"
#include <Preferences.h> #include <Preferences.h>
#include <stddef.h>
#include <string.h>
static const char *NAMESPACE = "flipdot"; static const char *NAMESPACE = "flipdot";
static const char *KEY = "settings"; static const char *KEY = "settings";
// Increase when the Settings struct changes; old records are then ignored (defaults) // Increase when the Settings struct changes; old records are then ignored (defaults),
static const uint16_t VERSION = 1; // 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 struct StoredSettings
{ {
@@ -18,13 +33,28 @@ void loadSettings(Settings &settings)
{ {
settings = Settings(); settings = Settings();
StoredSettings stored; StoredSettings stored;
memset(&stored, 0, sizeof(stored));
Preferences prefs; Preferences prefs;
prefs.begin(NAMESPACE, true); prefs.begin(NAMESPACE, true);
size_t read = prefs.getBytes(KEY, &stored, sizeof(stored)); size_t read = prefs.getBytes(KEY, &stored, sizeof(stored));
prefs.end(); prefs.end();
if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings) &&
validateSettings(stored.settings) == nullptr) Settings loaded;
settings = stored.settings; 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) 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> <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 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> <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> <section id="t-bitmaps"><div class="card"><h2>Bitmaps</h2><div class="grid" id="bitmaps"></div>
<p class="hint" id="storage"></p></div></section> <p class="hint" id="storage"></p></div></section>
@@ -78,8 +80,22 @@ button.d{color:var(--bad)}
</div></div> </div></div>
<div class="card"><h2>Hardware</h2><div class="form"> <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 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> <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> <button class="b" onclick="saveSettings()">Save settings</button></section>
<section id="t-ha"><div class="card"><h2>Home Assistant (MQTT)</h2><div class="form"> <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>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>User</label><input id="ha_user" autocomplete="off">
<label>Password</label><input type="password" id="ha_pass" placeholder="unchanged" autocomplete="new-password"> <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> <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> <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'); async function pollStatus(){try{status=await api('/api/status');
$('statusline').textContent=(status.time||'--:--')+' | WiFi '+(status.wifi.rssi||'')+' dBm | '+status.firmware; $('statusline').textContent=(status.time||'--:--')+' | WiFi '+(status.wifi.rssi||'')+' dBm | '+status.firmware;
$('livebox').style.display=status.live?'flex':'none';sysinfo()}catch(e){}setTimeout(pollStatus,5000)} $('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'], ['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']]; ['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)})} $('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 // pages and bitmaps
async function loadBitmaps(){const l=await api('/api/bitmaps');bitmapList=l.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).'; $('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')); 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; 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)); 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'; 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')})); 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)}} 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); 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} 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 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 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')} async function stopLive(){await post('/api/live/stop');msg('Back to normal')}
// editor // 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; 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}}} 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()} 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 // 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; 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; $('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; $('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_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'} $('ha_pass').placeholder=s.home_assistant.password_set?'unchanged':'none'}
function collect(){const s=JSON.parse(JSON.stringify(settings)); 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.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}); 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.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()}); 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} 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 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; JsonDocument doc;
doc["version"] = FIRMWARE_VERSION; doc["version"] = FIRMWARE_VERSION;
doc["firmware"] = firmwareGuardStatus(); doc["firmware"] = firmwareGuardStatus();
doc["reset_reason"] = firmwareGuardResetReason();
doc["uptime"] = millis() / 1000; doc["uptime"] = millis() / 1000;
struct tm now = {}; struct tm now = {};
@@ -102,7 +103,7 @@ static void handleStatus()
doc["display_on"] = nano.isOn(); doc["display_on"] = nano.isOn();
doc["live"] = controller.liveActive(); doc["live"] = controller.liveActive();
doc["page"] = appSettings().page; doc["page"] = appSettings().page;
doc["clock_enabled"] = controller.clockEnabled(); doc["off_page"] = appSettings().off_page;
doc["power_enabled"] = controller.powerEnabled(); doc["power_enabled"] = controller.powerEnabled();
JsonObject ha = doc["ha"].to<JsonObject>(); JsonObject ha = doc["ha"].to<JsonObject>();
@@ -218,6 +219,8 @@ static void handleBitmapDelete()
} }
if (strcmp(appSettings().page, name) == 0) if (strcmp(appSettings().page, name) == 0)
appSelectPage(PAGE_CLOCK); appSelectPage(PAGE_CLOCK);
if (strcmp(appSettings().off_page, name) == 0)
appSelectOffPage(OFF_PAGE_BLANK);
sendOk(); sendOk();
} }
@@ -271,9 +274,17 @@ static void settingsToJson(const Settings &s, JsonObject o)
{ {
char time[6]; char time[6];
o["page"] = s.page; o["page"] = s.page;
o["off_page"] = s.off_page;
o["flip_time"] = s.flip_time; o["flip_time"] = s.flip_time;
o["status_led"] = s.status_led; 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>(); JsonObject schedule = o["schedule"].to<JsonObject>();
schedule["enabled"] = s.schedule.enabled; schedule["enabled"] = s.schedule.enabled;
formatTime(s.schedule.day_start, time); formatTime(s.schedule.day_start, time);
@@ -314,10 +325,23 @@ static void handleSettingsPost()
Settings s = appSettings(); Settings s = appSettings();
copyText(body["page"], s.page, sizeof(s.page)); 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>()) if (body["flip_time"].is<int>())
s.flip_time = body["flip_time"].as<int>(); s.flip_time = body["flip_time"].as<int>();
copyBool(body["status_led"], s.status_led); 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"]; JsonVariantConst schedule = body["schedule"];
copyBool(schedule["enabled"], s.schedule.enabled); copyBool(schedule["enabled"], s.schedule.enabled);
copyBool(schedule["quarter_hours"], s.schedule.quarter_hours); 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 ## 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 It doesn't flip any dots at boot. The relay powers the Nano, so it boots every time the
display is switched on. display is switched on.
- **First `display()` after boot** redraws every dot, because the Nano doesn't know which - **First `display()` after boot** redraws every dot, because the Nano doesn't know which
state the dots were left in. state the dots were left in.
- **`display()`:** compares the new buffer with the last one that was shown and strobes - **`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. - **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 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 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, - **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 ### 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 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 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` 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 (black) or `1` (yellow). The ESP32 driver (`ESPSERIAL`) only uses `s`, `S`, `F`, `D`, `T`,
`T`. `G`, `N` and `E`.
| Cmd | Format | Meaning | | 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. | | `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 | | `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 | | `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: Replies on the Nano's TX pin:
+57
View File
@@ -102,6 +102,18 @@ void CommandParser::_runLine()
// Complete bitmap headers are handled in feed() // Complete bitmap headers are handled in feed()
_error(F("Incomplete bitmap header")); _error(F("Incomplete bitmap header"));
break; break;
case 'e':
case 'E':
_effect();
break;
case 'g':
case 'G':
_gap();
break;
case 'n':
case 'N':
_pulses();
break;
case 'd': case 'd':
case 'D': case 'D':
_display.display(); _display.display();
@@ -256,3 +268,48 @@ void CommandParser::_timing()
_display.setTiming(t); _display.setTiming(t);
saveFlipTime(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 _print();
void _pixel(bool direct); void _pixel(bool direct);
void _timing(); 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_MIN = 50;
const uint16_t FLIP_TIME_MAX = 2000; 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 // Full redraw interval, fixes dots that did not flip
const uint32_t REFRESH_TIME = 120000UL; // 2 min 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. // finished a frame, or when the serial line is idle.
void refreshIfDue() 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; bool quiet = parser.frameEnded() || millis() - last_rx >= IDLE_TIME;
if (quiet && millis() - last_refresh >= REFRESH_TIME) if (quiet && millis() - last_refresh >= REFRESH_TIME)
{ {
@@ -47,6 +50,12 @@ void setup()
Serial.begin(SERIAL_BAUD); Serial.begin(SERIAL_BAUD);
display.setTiming(loadFlipTime()); 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(); display.begin();
#ifdef MEASURE_REFRESH #ifdef MEASURE_REFRESH
@@ -68,5 +77,6 @@ void loop()
parser.feed(Serial.read()); parser.feed(Serial.read());
} }
display.update();
refreshIfDue(); refreshIfDue();
} }
+75 -7
View File
@@ -3,25 +3,93 @@
#include <EEPROM.h> #include <EEPROM.h>
#include "config.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_MAGIC_ADDR = 0;
const int EEPROM_FLIP_TIME_ADDR = 2; 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; 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 loadFlipTime()
{ {
uint16_t magic, t; uint16_t t;
EEPROM.get(EEPROM_MAGIC_ADDR, magic);
EEPROM.get(EEPROM_FLIP_TIME_ADDR, 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 FLIP_TIME_DEFAULT;
return t; return t;
} }
void saveFlipTime(uint16_t t) void saveFlipTime(uint16_t t)
{ {
// put() only writes bytes that changed save(EEPROM_FLIP_TIME_ADDR, t);
EEPROM.put(EEPROM_FLIP_TIME_ADDR, t); }
EEPROM.put(EEPROM_MAGIC_ADDR, EEPROM_MAGIC);
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> #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(); uint16_t loadFlipTime();
void saveFlipTime(uint16_t t); 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);
+7
View File
@@ -5,6 +5,7 @@
#include <vector> #include <vector>
#include "Arduino.h" #include "Arduino.h"
#include "dot_sequence.h" // the real one, from lib/BROSE9323
struct DisplayCall struct DisplayCall
{ {
@@ -17,6 +18,7 @@ class BROSE9323
public: public:
std::vector<DisplayCall> calls; std::vector<DisplayCall> calls;
bool direct = false; bool direct = false;
bool effect_running = false; // what busy() returns
BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t) {} BROSE9323(uint8_t, uint8_t, uint8_t, uint16_t) {}
@@ -26,6 +28,11 @@ public:
void fillScreen(uint16_t color) { log("fill", color); } void fillScreen(uint16_t color) { log("fill", color); }
void setDirect(bool d) { direct = d; } void setDirect(bool d) { direct = d; }
void setTiming(uint16_t t) { log("timing", t); } void setTiming(uint16_t t) { log("timing", t); }
void setGap(uint16_t t) { log("gap", t); }
void setPulses(uint8_t n) { log("pulses", n); }
void setEffect(uint8_t effect, uint16_t time_ms, uint8_t flags = 0) { log("effect", effect, time_ms, flags); }
bool update() { return effect_running; }
bool busy() const { return effect_running; }
void printBuffer() { log("printBuffer"); } void printBuffer() { log("printBuffer"); }
void setCursor(int16_t x, int16_t y) { log("cursor", x, y); } void setCursor(int16_t x, int16_t y) { log("cursor", x, y); }
void setTextColor(uint16_t color, uint16_t background) { log("textColor", color, background); } void setTextColor(uint16_t color, uint16_t background) { log("textColor", color, background); }
+81 -37
View File
@@ -63,8 +63,7 @@ public:
snprintf(s, sizeof(s), "nowifi %02d:%02d", hour, minute); snprintf(s, sizeof(s), "nowifi %02d:%02d", hour, minute);
shown.push_back(s); shown.push_back(s);
} }
void logo() override { shown.push_back("logo"); } void off() override { shown.push_back("off"); }
void blank() override { shown.push_back("blank"); }
std::string last() const { return shown.empty() ? "" : shown.back(); } std::string last() const { return shown.empty() ? "" : shown.back(); }
}; };
@@ -363,7 +362,7 @@ void test_night_clears_then_powers_off_after_delay()
{ {
run(23, 30); run(23, 30);
run(23, 31, POWER_OFF_DELAY - 200); run(23, 31, POWER_OFF_DELAY - 200);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str()); TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on); TEST_ASSERT_TRUE(power.is_on);
run(23, 31, 300); run(23, 31, 300);
TEST_ASSERT_FALSE(power.is_on); TEST_ASSERT_FALSE(power.is_on);
@@ -382,7 +381,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 evening", frames.last().c_str()); // no blank TEST_ASSERT_EQUAL_STRING("clock 22:00 evening", frames.last().c_str()); // no off screen
} }
void test_quarter_hour_powers_on_again_and_redraws() void test_quarter_hour_powers_on_again_and_redraws()
@@ -411,7 +410,19 @@ void test_power_switch_off_clears_and_powers_off_quickly()
run(22, 14, 1000); // 30 s countdown already running run(22, 14, 1000); // 30 s countdown already running
controller->setPowerEnabled(false); controller->setPowerEnabled(false);
run(22, 14, CLEAR_BEFORE_OFF_TIME + 200); run(22, 14, CLEAR_BEFORE_OFF_TIME + 200);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str()); TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_FALSE(power.is_on);
}
void test_power_switch_off_waits_for_the_refresh_effect()
{
controller->setEffectTime(3000);
run(12, 0);
controller->setPowerEnabled(false);
run(12, 0, CLEAR_BEFORE_OFF_TIME + 2000);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_TRUE(power.is_on); // the effect is still flipping dots
run(12, 0, 1400);
TEST_ASSERT_FALSE(power.is_on); TEST_ASSERT_FALSE(power.is_on);
} }
@@ -433,33 +444,6 @@ void test_power_switch_on_again_shows_clock()
TEST_ASSERT_EQUAL_STRING("clock 12:01", frames.last().c_str()); TEST_ASSERT_EQUAL_STRING("clock 12:01", frames.last().c_str());
} }
void test_clock_switch_off_shows_logo_then_powers_off()
{
run(12, 0);
controller->setClockEnabled(false);
run(12, 0, POWER_OFF_DELAY + 200);
TEST_ASSERT_EQUAL_STRING("logo", frames.last().c_str());
TEST_ASSERT_EQUAL(1, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("logo")));
TEST_ASSERT_FALSE(power.is_on);
}
void test_clock_switch_off_never_powers_on()
{
controller->setClockEnabled(false);
run(12, 0, 60000);
TEST_ASSERT_EQUAL(0, power.on_count);
}
void test_clock_switch_on_again_shows_clock()
{
run(12, 0);
controller->setClockEnabled(false);
run(12, 0);
controller->setClockEnabled(true);
run(12, 0);
TEST_ASSERT_EQUAL_STRING("clock 12:00", frames.last().c_str());
}
void test_redraws_after_power_was_switched_off_elsewhere() void test_redraws_after_power_was_switched_off_elsewhere()
{ {
// e.g. after flashing the Nano: the Nano restarts without a frame // e.g. after flashing the Nano: the Nano restarts without a frame
@@ -518,7 +502,7 @@ void test_night_without_wifi_still_powers_off()
wifi = false; wifi = false;
run(23, 31, POWER_OFF_DELAY + 200); run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_FALSE(power.is_on); TEST_ASSERT_FALSE(power.is_on);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str()); TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -545,6 +529,64 @@ void test_settings_times_must_be_in_order()
TEST_ASSERT_NOT_NULL(validateSettings(s)); TEST_ASSERT_NOT_NULL(validateSettings(s));
} }
void test_settings_drive_ranges()
{
Settings s;
TEST_ASSERT_NULL(validateSettings(s));
s.drive.flip_gap = 5001;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.pulses = 0;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s.drive.pulses = 4;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.effect = DRIVE_EFFECT_COUNT;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s = Settings();
s.drive.effect_time = 99;
TEST_ASSERT_NOT_NULL(validateSettings(s));
s.drive.effect_time = 10001;
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_effect_time_is_zero_when_instant()
{
DriveSettings d;
TEST_ASSERT_EQUAL(0, (int)effectTime(d)); // default: instant
d.effect = 1;
d.effect_time = 2500;
TEST_ASSERT_EQUAL(2500, (int)effectTime(d));
d.effect = DRIVE_EFFECT_PUSH; // can take much longer
TEST_ASSERT_EQUAL(2500 + (int)DRIVE_PUSH_EXTRA_TIME, (int)effectTime(d));
d.effect = DRIVE_EFFECT_RANDOM; // can be a push
TEST_ASSERT_EQUAL(2500 + (int)DRIVE_PUSH_EXTRA_TIME, (int)effectTime(d));
}
void test_settings_off_page_blank_or_bitmap_name()
{
Settings s;
TEST_ASSERT_EQUAL_STRING("", s.off_page); // default: blank
TEST_ASSERT_NULL(validateSettings(s));
strcpy(s.off_page, "Nintendo");
TEST_ASSERT_NULL(validateSettings(s));
strcpy(s.off_page, "../x");
TEST_ASSERT_NOT_NULL(validateSettings(s));
memset(s.off_page, 'a', sizeof(s.off_page)); // not terminated
TEST_ASSERT_NOT_NULL(validateSettings(s));
}
void test_changed_off_image_is_redrawn_while_the_display_is_on()
{
run(23, 30);
run(23, 31, 1000);
TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
controller->pageChanged();
run(23, 31, 1000);
TEST_ASSERT_EQUAL(2, (int)std::count(frames.shown.begin(), frames.shown.end(), std::string("off")));
TEST_ASSERT_TRUE(power.is_on);
}
void test_settings_flip_time_and_port_ranges() void test_settings_flip_time_and_port_ranges()
{ {
Settings s; Settings s;
@@ -719,7 +761,7 @@ void test_bitmap_page_follows_the_schedule()
controller->setPage(Page::Bitmap); controller->setPage(Page::Bitmap);
run(23, 30); run(23, 30);
run(23, 31, POWER_OFF_DELAY + 200); run(23, 31, POWER_OFF_DELAY + 200);
TEST_ASSERT_EQUAL_STRING("blank", frames.last().c_str()); TEST_ASSERT_EQUAL_STRING("off", frames.last().c_str());
TEST_ASSERT_FALSE(power.is_on); TEST_ASSERT_FALSE(power.is_on);
} }
@@ -835,11 +877,9 @@ int main()
RUN_TEST(test_quarter_hour_powers_on_again_and_redraws); RUN_TEST(test_quarter_hour_powers_on_again_and_redraws);
RUN_TEST(test_clock_cancels_pending_power_off); RUN_TEST(test_clock_cancels_pending_power_off);
RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly); RUN_TEST(test_power_switch_off_clears_and_powers_off_quickly);
RUN_TEST(test_power_switch_off_waits_for_the_refresh_effect);
RUN_TEST(test_power_switch_off_keeps_display_off_during_the_day); RUN_TEST(test_power_switch_off_keeps_display_off_during_the_day);
RUN_TEST(test_power_switch_on_again_shows_clock); RUN_TEST(test_power_switch_on_again_shows_clock);
RUN_TEST(test_clock_switch_off_shows_logo_then_powers_off);
RUN_TEST(test_clock_switch_off_never_powers_on);
RUN_TEST(test_clock_switch_on_again_shows_clock);
RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere); RUN_TEST(test_redraws_after_power_was_switched_off_elsewhere);
RUN_TEST(test_no_wifi_is_shown_after_a_minute_without_wifi); RUN_TEST(test_no_wifi_is_shown_after_a_minute_without_wifi);
RUN_TEST(test_no_wifi_screen_keeps_the_time_running); RUN_TEST(test_no_wifi_screen_keeps_the_time_running);
@@ -849,6 +889,10 @@ int main()
RUN_TEST(test_default_settings_are_valid); RUN_TEST(test_default_settings_are_valid);
RUN_TEST(test_settings_times_must_be_in_order); RUN_TEST(test_settings_times_must_be_in_order);
RUN_TEST(test_settings_drive_ranges);
RUN_TEST(test_effect_time_is_zero_when_instant);
RUN_TEST(test_settings_off_page_blank_or_bitmap_name);
RUN_TEST(test_changed_off_image_is_redrawn_while_the_display_is_on);
RUN_TEST(test_settings_flip_time_and_port_ranges); RUN_TEST(test_settings_flip_time_and_port_ranges);
RUN_TEST(test_settings_page_must_be_clock_or_bitmap_name); RUN_TEST(test_settings_page_must_be_clock_or_bitmap_name);
RUN_TEST(test_time_parse_and_format); RUN_TEST(test_time_parse_and_format);
+217
View File
@@ -9,6 +9,9 @@
#include "BROSE9323.h" #include "BROSE9323.h"
#include "EEPROM.h" #include "EEPROM.h"
#include <cstdlib>
#include <vector>
// From src/nano/main.cpp // From src/nano/main.cpp
extern BROSE9323 display; extern BROSE9323 display;
extern uint32_t last_refresh; extern uint32_t last_refresh;
@@ -233,6 +236,207 @@ void test_timing_out_of_range_is_rejected()
TEST_ASSERT_TRUE(replied("ERR")); TEST_ASSERT_TRUE(replied("ERR"));
} }
// ---------------------------------------------------------------------------
// Flip gap, pulses and refresh effect
// ---------------------------------------------------------------------------
void test_defaults_with_empty_eeprom()
{
EEPROM.erase();
setup();
TEST_ASSERT_EQUAL(200, display.last("gap")->a);
TEST_ASSERT_EQUAL(2, display.last("pulses")->a);
TEST_ASSERT_EQUAL(EFFECT_INSTANT, display.last("effect")->a);
TEST_ASSERT_EQUAL(1500, display.last("effect")->b);
TEST_ASSERT_EQUAL(0, display.last("effect")->c);
}
void test_eeprom_from_before_the_new_settings_keeps_flip_time()
{
// Old firmware only stored the magic and the flip time; the rest is still 0xFF
EEPROM.erase();
send("T300\n");
EEPROM.put(4, (uint16_t)0xFFFF);
EEPROM.put(6, (uint8_t)0xFF);
EEPROM.put(7, (uint8_t)0xFF);
EEPROM.put(8, (uint16_t)0xFFFF);
EEPROM.put(10, (uint8_t)0xFF);
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(300, display.last("timing")->a);
TEST_ASSERT_EQUAL(200, display.last("gap")->a);
TEST_ASSERT_EQUAL(2, display.last("pulses")->a);
TEST_ASSERT_EQUAL(EFFECT_INSTANT, display.last("effect")->a);
}
void test_gap_is_applied_and_stored()
{
send("G350\n");
TEST_ASSERT_EQUAL(350, display.last("gap")->a);
send("G0\n");
TEST_ASSERT_EQUAL(0, display.last("gap")->a);
send("G1200\n");
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(1200, display.last("gap")->a);
}
void test_gap_out_of_range_is_rejected()
{
send("G5001\n");
send("G\n");
TEST_ASSERT_EQUAL(0, display.count("gap"));
TEST_ASSERT_TRUE(replied("ERR"));
}
void test_pulses_are_applied_and_stored()
{
send("N3\n");
TEST_ASSERT_EQUAL(3, display.last("pulses")->a);
send("N1\n");
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(1, display.last("pulses")->a);
}
void test_pulses_out_of_range_are_rejected()
{
send("N0\n");
send("N4\n");
TEST_ASSERT_EQUAL(0, display.count("pulses"));
TEST_ASSERT_TRUE(replied("ERR"));
}
void test_effect_is_applied_and_stored()
{
send("E1,2500\n");
TEST_ASSERT_EQUAL(EFFECT_SPARKLE, display.last("effect")->a);
TEST_ASSERT_EQUAL(2500, display.last("effect")->b);
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(EFFECT_SPARKLE, display.last("effect")->a);
TEST_ASSERT_EQUAL(2500, display.last("effect")->b);
TEST_ASSERT_EQUAL(0, display.last("effect")->c); // no flags given
send("E9,1000,1\n");
TEST_ASSERT_EQUAL(EFFECT_RANDOM, display.last("effect")->a);
TEST_ASSERT_EQUAL(EFFECT_FLAG_SMALL_ONLY, display.last("effect")->c);
display.calls.clear();
setup();
TEST_ASSERT_EQUAL(EFFECT_FLAG_SMALL_ONLY, display.last("effect")->c);
send("E0,1500,0\n");
}
void test_invalid_effect_is_rejected()
{
send("E10,1500\n"); // unknown effect
send("E1,1500,2\n"); // unknown flag
send("E1,50\n"); // too fast
send("E1\n"); // no duration
TEST_ASSERT_EQUAL(0, display.count("effect"));
TEST_ASSERT_TRUE(replied("ERR"));
}
void test_no_full_redraw_while_an_effect_runs()
{
last_refresh = fake_millis;
fake_millis += 120000;
display.effect_running = true;
send("D\n");
TEST_ASSERT_EQUAL(1, display.count("display")); // only the frame itself
display.effect_running = false;
loop();
TEST_ASSERT_EQUAL(2, display.count("display"));
TEST_ASSERT_EQUAL(1, display.last("display")->a);
}
// Every effect order visits each dot exactly once
static void checkSequence(uint8_t effect, uint8_t w, uint8_t h, uint16_t seed)
{
std::vector<int> seen(w * h, 0);
DotSequence sequence;
sequence.begin(effect, w, h, seed);
uint8_t x, y;
int n = 0;
while (sequence.next(x, y))
{
TEST_ASSERT_TRUE(x < w && y < h);
seen[y * w + x]++;
n++;
}
TEST_ASSERT_EQUAL(w * h, n);
TEST_ASSERT_EQUAL(w * h, sequence.total());
for (int count : seen)
TEST_ASSERT_EQUAL(1, count);
}
void test_every_effect_visits_each_dot_once()
{
for (uint8_t effect = 0; effect < EFFECT_COUNT; effect++)
{
checkSequence(effect, 84, 19, 0);
checkSequence(effect, 84, 19, 12345);
checkSequence(effect, 7, 5, 3);
checkSequence(effect, 5, 7, 3);
checkSequence(effect, 1, 1, 0);
checkSequence(effect, 1, 6, 0);
checkSequence(effect, 6, 1, 0);
checkSequence(effect, 2, 2, 0);
}
// Sparkle with every LFSR size up to 4095 dots
for (int n = 2; n < 4096; n = n * 2 + 1)
checkSequence(EFFECT_SPARKLE, n > 255 ? 255 : n, n > 255 ? (n + 254) / 255 : 1, 7);
}
void test_wipe_goes_left_to_right_and_center_starts_in_the_middle()
{
DotSequence sequence;
uint8_t x, y;
sequence.begin(EFFECT_WIPE, 84, 19, 0);
sequence.next(x, y);
TEST_ASSERT_EQUAL(0, x);
sequence.begin(EFFECT_CENTER, 84, 19, 0);
sequence.next(x, y);
TEST_ASSERT_EQUAL(42, x);
sequence.begin(EFFECT_DIAGONAL, 84, 19, 0);
sequence.next(x, y);
TEST_ASSERT_TRUE(x == 0 && y == 0);
sequence.next(x, y);
TEST_ASSERT_TRUE(x == 1 && y == 0);
sequence.next(x, y);
TEST_ASSERT_TRUE(x == 0 && y == 1);
// Spiral: the whole top row, then down the right side
sequence.begin(EFFECT_SPIRAL, 84, 19, 0);
for (int i = 0; i < 84; i++)
{
sequence.next(x, y);
TEST_ASSERT_TRUE(x == i && y == 0);
}
sequence.next(x, y);
TEST_ASSERT_TRUE(x == 83 && y == 1);
sequence.begin(EFFECT_RAIN, 84, 19, 0);
sequence.next(x, y);
sequence.next(x, y);
TEST_ASSERT_EQUAL(1, x);
TEST_ASSERT_EQUAL(0, y);
}
void test_sparkle_order_is_scrambled()
{
// Neighbouring dots should rarely follow each other
DotSequence sequence;
sequence.begin(EFFECT_SPARKLE, 84, 19, 99);
uint8_t x, y, px = 0, py = 0;
int neighbours = 0, n = 0;
while (sequence.next(x, y))
{
if (n++ && abs(x - px) <= 1 && abs(y - py) <= 1)
neighbours++;
px = x;
py = y;
}
TEST_ASSERT_LESS_THAN(n / 20, neighbours);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Periodic full redraw // Periodic full redraw
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -312,6 +516,19 @@ int main()
RUN_TEST(test_timing_above_255_is_stored_and_restored); RUN_TEST(test_timing_above_255_is_stored_and_restored);
RUN_TEST(test_timing_out_of_range_is_rejected); RUN_TEST(test_timing_out_of_range_is_rejected);
RUN_TEST(test_defaults_with_empty_eeprom);
RUN_TEST(test_eeprom_from_before_the_new_settings_keeps_flip_time);
RUN_TEST(test_gap_is_applied_and_stored);
RUN_TEST(test_gap_out_of_range_is_rejected);
RUN_TEST(test_pulses_are_applied_and_stored);
RUN_TEST(test_pulses_out_of_range_are_rejected);
RUN_TEST(test_effect_is_applied_and_stored);
RUN_TEST(test_invalid_effect_is_rejected);
RUN_TEST(test_no_full_redraw_while_an_effect_runs);
RUN_TEST(test_every_effect_visits_each_dot_once);
RUN_TEST(test_wipe_goes_left_to_right_and_center_starts_in_the_middle);
RUN_TEST(test_sparkle_order_is_scrambled);
RUN_TEST(test_refresh_waits_for_end_of_frame); RUN_TEST(test_refresh_waits_for_end_of_frame);
RUN_TEST(test_refresh_only_once_per_interval); RUN_TEST(test_refresh_only_once_per_interval);
RUN_TEST(test_refresh_when_serial_is_idle); RUN_TEST(test_refresh_when_serial_is_idle);