Public Access
Fix shifted pictures: non-blocking Nano drawing, frame check, refresh settings
The Nano's periodic full redraw blocked for ~0.8 s. A frame that arrived meanwhile overflowed its 64-byte serial buffer; lost bytes in a bitmap shift the rest of the image (picture moved up), and diff frames never repaired it. - Nano: all drawing (frames, effects, the dot refresh) runs from update() in ~1 ms slices; the refresh pauses while a frame is received or shown - New commands: C (reply with the image checksum) and I (refresh minutes, EEPROM) - ESP32: NanoMonitor asks for the checksum after frames and sends the whole image again on a mismatch, missing answer or Nano restart; counts ERR replies. Periodic whole-image resync. - Settings: "Dot refresh" and "Image resync" intervals (settings v5, v4 records are migrated) - System tab: loop timing, free memory, frame check counts - test_driver: the real driver against a simulated panel Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
58a7cb03c2
commit
090c2bf8ae
45 files changed
+1265
-157
No files matched your search
+5
-2
@@ -25,6 +25,8 @@ BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH);
|
||||
FlipdotFrames frames(display, pageResources);
|
||||
StatusScreen status(display, nano);
|
||||
DisplayController controller(nano, frames);
|
||||
LoopStats loopStats;
|
||||
NanoMonitor nanoMonitor(nano, display);
|
||||
|
||||
static Settings settings;
|
||||
static bool restart_pending = false;
|
||||
@@ -87,7 +89,7 @@ static void apply()
|
||||
{
|
||||
controller.setSettings(settings.schedule, settings.clock);
|
||||
controller.setEffectTime(effectTime(settings.drive));
|
||||
status.setDrive(settings.flip_time, settings.drive);
|
||||
status.setDrive(settings.flip_time, settings.drive, settings.refresh.dots);
|
||||
frames.setDrive(settings.flip_time, settings.drive);
|
||||
haApiSetSettings(settings.home_assistant);
|
||||
applyOffPage();
|
||||
@@ -137,7 +139,8 @@ const char *appSaveSettings(const Settings &new_settings, bool &restart_needed)
|
||||
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;
|
||||
a.effect_small_only != b.effect_small_only ||
|
||||
settings.refresh.dots != new_settings.refresh.dots;
|
||||
settings = new_settings;
|
||||
saveSettings(settings);
|
||||
selector.setChosen(settings.page);
|
||||
|
||||
@@ -8,16 +8,20 @@
|
||||
#include "display/status_screen.h"
|
||||
#include "logic/display_controller.h"
|
||||
#include "logic/local_time.h"
|
||||
#include "logic/loop_stats.h"
|
||||
#include "logic/page.h"
|
||||
#include "logic/page_schedule.h"
|
||||
#include "logic/settings.h"
|
||||
#include "nano_link/nano_link.h"
|
||||
#include "nano_link/nano_monitor.h"
|
||||
|
||||
extern NanoLink nano;
|
||||
extern BROSE9323 display;
|
||||
extern FlipdotFrames frames;
|
||||
extern StatusScreen status;
|
||||
extern DisplayController controller;
|
||||
extern LoopStats loopStats;
|
||||
extern NanoMonitor nanoMonitor;
|
||||
|
||||
// Settings: loaded at boot, changed in the web UI
|
||||
const Settings &appSettings();
|
||||
|
||||
+8
-1
@@ -55,6 +55,13 @@ const uint32_t HA_ERROR_INTERVAL = 60000;
|
||||
// this one (characters per ms at NANO_BAUD) and flipped its changed dots, plus a margin
|
||||
const uint32_t NANO_CHARS_PER_MS = NANO_BAUD / 10000;
|
||||
const uint32_t FRAME_MARGIN = 20;
|
||||
// Frame check (logic/frame_check.h): after a frame the Nano is asked for the checksum of
|
||||
// its image (C), at most this often (moving pages send many frames)
|
||||
const uint32_t FRAME_CHECK_INTERVAL = 3000;
|
||||
// No answer this long: counted as lost (the C or the frame before it got garbled)
|
||||
const uint32_t FRAME_CHECK_TIMEOUT = 2000;
|
||||
// A wrong image is repaired by sending the whole image again, at most this often
|
||||
const uint32_t FRAME_REPAIR_MIN_GAP = 10000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flashing the Nano over WiFi (see README.md, pio run -e nano_ota -t upload)
|
||||
@@ -105,7 +112,7 @@ const uint32_t SETUP_AP_DELAY = 20000; // open the AP when WiFi fails thi
|
||||
const uint32_t WIFI_CONNECT_TIMEOUT = 30000;
|
||||
const uint32_t WIFI_RESTART_TIME = 10UL * 60 * 1000;
|
||||
|
||||
#define FIRMWARE_VERSION "2.7.2"
|
||||
#define FIRMWARE_VERSION "2.8.0"
|
||||
|
||||
// Home Assistant device id (do not change: Home Assistant's entities are linked to it)
|
||||
const uint8_t HA_DEVICE_ID[] = {0x34, 0x86, 0x5d, 0x3a, 0x08, 0x28};
|
||||
|
||||
@@ -9,10 +9,11 @@ StatusScreen::StatusScreen(BROSE9323 &display, NanoLink &nano) : _display(displa
|
||||
{
|
||||
}
|
||||
|
||||
void StatusScreen::setDrive(uint16_t flip_time, const DriveSettings &drive)
|
||||
void StatusScreen::setDrive(uint16_t flip_time, const DriveSettings &drive, uint16_t dot_refresh)
|
||||
{
|
||||
_flip_time = flip_time;
|
||||
_drive = drive;
|
||||
_dot_refresh = dot_refresh;
|
||||
}
|
||||
|
||||
void StatusScreen::sendDrive()
|
||||
@@ -30,6 +31,7 @@ void StatusScreen::loop()
|
||||
_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);
|
||||
_display.setRefreshInterval(_dot_refresh);
|
||||
}
|
||||
|
||||
void StatusScreen::show(const char *line1, const char *line2, bool keep_on)
|
||||
|
||||
@@ -17,7 +17,7 @@ public:
|
||||
|
||||
// How the Nano drives the dots, sent with the first message (default: FLIP_TIME and
|
||||
// the DriveSettings defaults, so the recovery app always shows its messages instantly)
|
||||
void setDrive(uint16_t flip_time, const DriveSettings &drive);
|
||||
void setDrive(uint16_t flip_time, const DriveSettings &drive, uint16_t dot_refresh = RefreshSettings().dots);
|
||||
// Send them again (they changed), from loop()
|
||||
void sendDrive();
|
||||
// Sends changed drive settings once the display is on. Not while the Nano is flashed.
|
||||
@@ -30,4 +30,5 @@ private:
|
||||
bool _drive_pending = false;
|
||||
uint16_t _flip_time;
|
||||
DriveSettings _drive;
|
||||
uint16_t _dot_refresh = RefreshSettings().dots;
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
#include "logic/frame_check.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#include "config.h"
|
||||
|
||||
static int hexDigit(char c)
|
||||
{
|
||||
if (c >= '0' && c <= '9')
|
||||
return c - '0';
|
||||
if (c >= 'A' && c <= 'F')
|
||||
return c - 'A' + 10;
|
||||
if (c >= 'a' && c <= 'f')
|
||||
return c - 'a' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
void FrameCheck::reset(uint32_t now)
|
||||
{
|
||||
_pending = false;
|
||||
_checked_any = false;
|
||||
_replied = false;
|
||||
_problem = false;
|
||||
// Switching on sends the whole image anyway
|
||||
_last_resync = now;
|
||||
}
|
||||
|
||||
bool FrameCheck::due(uint32_t frames_sent, uint32_t now) const
|
||||
{
|
||||
// A repair is coming anyway: no need to count the same problem again
|
||||
if (_pending || _problem)
|
||||
return false;
|
||||
if (!_checked_any)
|
||||
return true;
|
||||
return frames_sent != _checked_frames && now - _last_check >= FRAME_CHECK_INTERVAL;
|
||||
}
|
||||
|
||||
void FrameCheck::sent(uint16_t expected, uint32_t frames_sent, uint32_t now)
|
||||
{
|
||||
_pending = true;
|
||||
_expected = expected;
|
||||
_sent_at = now;
|
||||
_checked_any = true;
|
||||
_checked_frames = frames_sent;
|
||||
_last_check = now;
|
||||
}
|
||||
|
||||
void FrameCheck::line(const char *text, uint32_t now)
|
||||
{
|
||||
(void)now;
|
||||
size_t len = strlen(text);
|
||||
while (len && (text[len - 1] == '\r' || text[len - 1] == ' '))
|
||||
len--;
|
||||
|
||||
if (len == 6 && text[0] == 'C' && text[1] == ' ')
|
||||
{
|
||||
int value = 0;
|
||||
for (int i = 2; i < 6; i++)
|
||||
{
|
||||
int d = hexDigit(text[i]);
|
||||
if (d < 0)
|
||||
return;
|
||||
value = value << 4 | d;
|
||||
}
|
||||
_replied = true;
|
||||
if (!_pending)
|
||||
return; // late answer to a check that already timed out
|
||||
_pending = false;
|
||||
if (value == _expected)
|
||||
_stats.checks++;
|
||||
else
|
||||
{
|
||||
_stats.mismatches++;
|
||||
_problem = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (strncmp(text, "ERR", 3) == 0)
|
||||
{
|
||||
_replied = true;
|
||||
_stats.errors++;
|
||||
size_t n = len < sizeof(_stats.last_error) - 1 ? len : sizeof(_stats.last_error) - 1;
|
||||
memcpy(_stats.last_error, text, n);
|
||||
_stats.last_error[n] = '\0';
|
||||
return;
|
||||
}
|
||||
if (len >= 5 && strncmp(text, "READY", 5) == 0)
|
||||
{
|
||||
// The Nano restarted while it was on: its image is gone
|
||||
_replied = true;
|
||||
_stats.restarts++;
|
||||
_pending = false;
|
||||
_problem = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool FrameCheck::resyncNow(uint32_t now, uint32_t resync_ms)
|
||||
{
|
||||
if (_pending && now - _sent_at >= FRAME_CHECK_TIMEOUT)
|
||||
{
|
||||
_pending = false;
|
||||
_stats.no_reply++;
|
||||
// Only when answers can arrive at all (old Nano firmware, TX not wired: never)
|
||||
if (_replied)
|
||||
_problem = true;
|
||||
}
|
||||
if (_problem && now - _last_resync >= FRAME_REPAIR_MIN_GAP)
|
||||
{
|
||||
_problem = false;
|
||||
_stats.repairs++;
|
||||
_last_resync = now;
|
||||
return true;
|
||||
}
|
||||
if (resync_ms && now - _last_resync >= resync_ms)
|
||||
{
|
||||
_last_resync = now;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// Checks that the Nano shows what was sent. The Nano has no acknowledgement and drops
|
||||
// bytes when its serial buffer overflows; a lost byte in a bitmap shifts the rest of the
|
||||
// image (the dots after it land too early). After a frame the ESP32 asks for the checksum
|
||||
// of the Nano's image (C command, bufferCrc() in lib/BROSE9323) and compares it with its
|
||||
// own copy. A wrong checksum, a missing answer or a restarted Nano (READY) makes it send
|
||||
// the whole image again (repair). Besides that the whole image is sent again every
|
||||
// resync interval, which also fixes what the check can't see.
|
||||
//
|
||||
// Pure logic (tested in test/test_esp32); nano_link/nano_monitor does the serial part.
|
||||
|
||||
struct FrameCheckStats
|
||||
{
|
||||
uint32_t checks = 0; // answers that matched
|
||||
uint32_t mismatches = 0; // answers that didn't
|
||||
uint32_t no_reply = 0; // checks without an answer in FRAME_CHECK_TIMEOUT
|
||||
uint32_t errors = 0; // ERR: lines from the Nano
|
||||
uint32_t restarts = 0; // READY while it was on: the Nano restarted (power dip?)
|
||||
uint32_t repairs = 0; // whole image sent again because of one of the above
|
||||
char last_error[48] = "";
|
||||
};
|
||||
|
||||
class FrameCheck
|
||||
{
|
||||
public:
|
||||
// The Nano was switched on or off: forget the check in progress
|
||||
void reset(uint32_t now);
|
||||
|
||||
// A check (C) should be sent now: a frame was sent since the last one (frames_sent
|
||||
// changed), none is waiting for its answer and the last one is FRAME_CHECK_INTERVAL ago
|
||||
bool due(uint32_t frames_sent, uint32_t now) const;
|
||||
// The C was sent; expected: the checksum of the ESP32's copy
|
||||
void sent(uint16_t expected, uint32_t frames_sent, uint32_t now);
|
||||
|
||||
// One line from the Nano (without the '\n')
|
||||
void line(const char *text, uint32_t now);
|
||||
|
||||
// Call each loop(). True: send the whole image now (repair, or every resync_ms; 0 = no
|
||||
// periodic resync)
|
||||
bool resyncNow(uint32_t now, uint32_t resync_ms);
|
||||
|
||||
const FrameCheckStats &stats() const { return _stats; }
|
||||
|
||||
private:
|
||||
FrameCheckStats _stats;
|
||||
bool _pending = false;
|
||||
uint16_t _expected = 0;
|
||||
uint32_t _sent_at = 0;
|
||||
bool _checked_any = false; // since reset()
|
||||
uint32_t _checked_frames = 0;
|
||||
uint32_t _last_check = 0;
|
||||
bool _replied = false; // the Nano answered since reset(): its TX is wired
|
||||
bool _problem = false; // repair at the next chance
|
||||
uint32_t _last_resync = 0;
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
// How busy loop() is. It never sleeps, so a CPU percentage would always be 100 %; what
|
||||
// matters is how long one pass takes (a slow pass delays OTA, the web UI and the frames).
|
||||
// tick() is called at the start of every pass with micros(); the numbers cover the last
|
||||
// complete window of LOOP_STATS_WINDOW_US.
|
||||
class LoopStats
|
||||
{
|
||||
public:
|
||||
static const uint32_t WINDOW_US = 10000000UL; // 10 s
|
||||
|
||||
void tick(uint32_t now_us)
|
||||
{
|
||||
if (!_started)
|
||||
{
|
||||
_started = true;
|
||||
_last = now_us;
|
||||
_window_start = now_us;
|
||||
return;
|
||||
}
|
||||
uint32_t pass = now_us - _last;
|
||||
_last = now_us;
|
||||
_count++;
|
||||
if (pass > _max)
|
||||
_max = pass;
|
||||
if (pass > _max_ever)
|
||||
_max_ever = pass;
|
||||
|
||||
uint32_t window = now_us - _window_start;
|
||||
if (window >= WINDOW_US)
|
||||
{
|
||||
_loops_per_s = (uint32_t)((uint64_t)_count * 1000000UL / window);
|
||||
_avg_us = window / _count;
|
||||
_window_max_us = _max;
|
||||
_count = 0;
|
||||
_max = 0;
|
||||
_window_start = now_us;
|
||||
}
|
||||
}
|
||||
|
||||
// Passes per second, average and longest pass in us (last window), longest since boot
|
||||
uint32_t loopsPerSecond() const { return _loops_per_s; }
|
||||
uint32_t averageUs() const { return _avg_us; }
|
||||
uint32_t maxUs() const { return _window_max_us; }
|
||||
uint32_t maxEverUs() const { return _max_ever; }
|
||||
|
||||
private:
|
||||
bool _started = false;
|
||||
uint32_t _last = 0, _window_start = 0;
|
||||
uint32_t _count = 0, _max = 0, _max_ever = 0;
|
||||
uint32_t _loops_per_s = 0, _avg_us = 0, _window_max_us = 0;
|
||||
};
|
||||
@@ -35,6 +35,8 @@ const char *validateSettings(const Settings &s)
|
||||
return "Unknown refresh effect";
|
||||
if (d.effect_time < 100 || d.effect_time > 10000)
|
||||
return "The effect time must be between 100 and 10000 ms";
|
||||
if (s.refresh.dots > REFRESH_MINUTES_MAX || s.refresh.image > REFRESH_MINUTES_MAX)
|
||||
return "Refresh intervals must be between 0 and 1440 minutes";
|
||||
|
||||
const HomeAssistantSettings &ha = s.home_assistant;
|
||||
if (!terminated(ha.host, sizeof(ha.host)) || !terminated(ha.user, sizeof(ha.user)) ||
|
||||
|
||||
@@ -54,6 +54,18 @@ struct DriveSettings
|
||||
bool effect_small_only = false; // big changes (a new page) appear instantly
|
||||
};
|
||||
|
||||
// Full redraws that fix the picture, in minutes (0 = never)
|
||||
const uint16_t REFRESH_MINUTES_MAX = 1440;
|
||||
struct RefreshSettings
|
||||
{
|
||||
// The Nano flips every dot once more (fixes dots that didn't flip). Sent to the Nano,
|
||||
// which keeps it in its EEPROM (I command, default there: 2).
|
||||
uint16_t dots = 2;
|
||||
// The ESP32 sends the whole image again (fixes a wrong image on the Nano, e.g. after
|
||||
// lost bytes). Only the dots that differ flip, so a correct image doesn't change.
|
||||
uint16_t image = 10;
|
||||
};
|
||||
|
||||
// What is shown during the day: the name of a dynamic page (logic/page.h) or a bitmap.
|
||||
// Both share one namespace. When it is gone, the built-in Clock page is shown.
|
||||
#define DEFAULT_PAGE BUILTIN_PAGE_CLOCK
|
||||
@@ -70,6 +82,7 @@ struct Settings
|
||||
bool status_led = true;
|
||||
char off_page[PAGE_NAME_SIZE] = OFF_PAGE_BLANK;
|
||||
DriveSettings drive;
|
||||
RefreshSettings refresh;
|
||||
// New fields go here, at the end (see system/settings_store.cpp)
|
||||
};
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ void loop()
|
||||
// OTA and the firmware guard come first, so they keep working whatever happens below
|
||||
otaLoop();
|
||||
firmwareGuardLoop(wifiConnected());
|
||||
loopStats.tick(micros());
|
||||
wifiWatchdog();
|
||||
|
||||
homeAssistantSetFirmwareStatus(firmwareGuardStatus());
|
||||
@@ -92,6 +93,10 @@ void loop()
|
||||
bool time_valid = appLocalTime(now);
|
||||
appPageLoop(time_valid, now);
|
||||
controller.update(millis(), time_valid, now, wifiConnected());
|
||||
// Checks the frames just sent; a wrong image on the Nano (and every resync interval):
|
||||
// the whole image again with the next update
|
||||
if (nanoMonitor.loop(millis(), appSettings().refresh.image * 60000UL))
|
||||
controller.pageChanged();
|
||||
|
||||
bool led = appSettings().status_led && statusLedLevel(millis(), wifiConnected(), nano.isOn());
|
||||
digitalWrite(LED_PIN, led);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#include "nano_link/nano_monitor.h"
|
||||
|
||||
NanoMonitor::NanoMonitor(NanoLink &nano, BROSE9323 &display) : _nano(nano), _display(display)
|
||||
{
|
||||
}
|
||||
|
||||
bool NanoMonitor::loop(uint32_t now, uint32_t resync_ms)
|
||||
{
|
||||
bool on = _nano.isOn();
|
||||
if (on != _was_on)
|
||||
{
|
||||
// Switched on (the Nano booted, NanoLink::on() took its READY) or off
|
||||
_was_on = on;
|
||||
_check.reset(now);
|
||||
_len = 0;
|
||||
}
|
||||
if (!on)
|
||||
return false;
|
||||
|
||||
// First the answers that arrived (also while loop() was blocked), then the timeouts
|
||||
Stream &serial = _nano.stream();
|
||||
while (serial.available())
|
||||
{
|
||||
char c = serial.read();
|
||||
if (c == '\n')
|
||||
{
|
||||
_line[_len] = '\0';
|
||||
_check.line(_line, now);
|
||||
_len = 0;
|
||||
}
|
||||
else if (_len < sizeof(_line) - 1)
|
||||
_line[_len++] = c;
|
||||
}
|
||||
bool resync = _check.resyncNow(now, resync_ms);
|
||||
|
||||
uint32_t frames = _display.framesSent();
|
||||
if (_check.due(frames, now))
|
||||
{
|
||||
serial.print("C\n");
|
||||
_check.sent(_display.crc(), frames, now);
|
||||
}
|
||||
return resync;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
#include "BROSE9323.h"
|
||||
#include "logic/frame_check.h"
|
||||
#include "nano_link/nano_link.h"
|
||||
|
||||
// Reads what the Nano sends back while it is on (C answers, ERR:, READY) and asks for
|
||||
// the checksum after frames (logic/frame_check.h). Not while the Nano is being flashed:
|
||||
// then the flash bridge owns the serial port.
|
||||
class NanoMonitor
|
||||
{
|
||||
public:
|
||||
NanoMonitor(NanoLink &nano, BROSE9323 &display);
|
||||
|
||||
// From loop(), after the frames of this pass were sent. True: send the whole image
|
||||
// again (repair, or the periodic resync every resync_ms; 0 = never).
|
||||
bool loop(uint32_t now, uint32_t resync_ms);
|
||||
|
||||
const FrameCheckStats &stats() const { return _check.stats(); }
|
||||
|
||||
private:
|
||||
NanoLink &_nano;
|
||||
BROSE9323 &_display;
|
||||
FrameCheck _check;
|
||||
bool _was_on = false;
|
||||
char _line[64];
|
||||
uint8_t _len = 0;
|
||||
};
|
||||
@@ -9,7 +9,7 @@ static const char *KEY = "settings";
|
||||
// Increase when the Settings struct changes; old records are then ignored (defaults),
|
||||
// unless the code below says how to read them. When fields are only added at the end,
|
||||
// an old record is the start of the new layout: add an OldVersion for it like v3 does.
|
||||
static const uint16_t VERSION = 4;
|
||||
static const uint16_t VERSION = 5;
|
||||
|
||||
struct OldVersion
|
||||
{
|
||||
@@ -78,6 +78,9 @@ static void convert(const Settings &old, ::Settings &s)
|
||||
|
||||
static_assert(sizeof(v3::Settings) <= sizeof(Settings), "the old record must fit the buffer");
|
||||
|
||||
// Version 4: the current layout without refresh (added at the end, so it is a prefix)
|
||||
static const OldVersion V4 = {4, offsetof(Settings, refresh)};
|
||||
|
||||
struct StoredSettings
|
||||
{
|
||||
uint16_t version;
|
||||
@@ -108,6 +111,11 @@ void loadSettings(Settings &settings)
|
||||
Settings loaded;
|
||||
if (read == sizeof(stored) && stored.version == VERSION && stored.size == sizeof(Settings))
|
||||
loaded = stored.settings;
|
||||
else if (stored.version == V4.version)
|
||||
{
|
||||
if (!readOld(stored, read, V4.fields, loaded))
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
bool ok = false;
|
||||
|
||||
@@ -145,8 +145,10 @@ details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:13px;m
|
||||
<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>Dot refresh</label><label>every <input type="number" id="h_rdots" min="0" max="1440" style="width:5em"> min (0 = off): every dot flipped again</label>
|
||||
<label>Image resync</label><label>every <input type="number" id="h_rimg" min="0" max="1440" style="width:5em"> min (0 = off): the whole image sent again</label>
|
||||
<label>Status LED</label><label><input type="checkbox" id="h_led"> blink while the display is on</label>
|
||||
</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><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. The dot refresh (and the first frame after power on) uses one pulse per dot; it runs between frames. The image resync repairs a picture the Nano got wrong (e.g. shifted); only dots that differ flip, so normally nothing changes. A wrong picture is also found and repaired by the frame check (see System).</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>
|
||||
@@ -212,6 +214,9 @@ async function pollStatus(){try{status=await api('/api/status');
|
||||
function sysinfo(){if(!status)return;const r=[['Firmware',status.firmware],['Last restart',status.reset_reason],['Uptime',Math.floor(status.uptime/3600)+' h '+Math.floor(status.uptime%3600/60)+' min'],
|
||||
['WiFi',status.wifi.ssid+' ('+status.wifi.ip+', '+status.wifi.rssi+' dBm)'],['Home Assistant',status.ha.enabled?(status.ha.connected?'connected':'not connected'):'disabled'],
|
||||
['HA power switch',status.power_enabled?'on':'off'],
|
||||
['Loop',status.load?(status.load.loops_per_s?status.load.loops_per_s+'/s, avg '+(status.load.avg_us/1000).toFixed(1)+' ms, max '+status.load.max_ms+' ms (since boot '+status.load.max_ever_ms+' ms)':'measuring...'):''],
|
||||
['Frame check',status.nano_link?(()=>{const n=status.nano_link;return n.checks+' ok, '+n.mismatches+' wrong, '+n.no_reply+' no answer, '+n.repairs+' repaired'+(n.restarts?', Nano restarted '+n.restarts+'x':'')+(n.errors?', '+n.errors+' errors (last: '+n.last_error+')':'')})():''],
|
||||
['Memory',status.load?Math.round(status.load.heap_free/1024)+' KB free (lowest '+Math.round(status.load.heap_min/1024)+' KB)':''],
|
||||
['Storage',status.storage.available?Math.round(status.storage.used/1024)+' of '+Math.round(status.storage.total/1024)+' KB used':'not available']];
|
||||
$('sysinfo').innerHTML='';r.forEach(([k,v])=>{const a=document.createElement('label');a.textContent=k;const b=document.createElement('span');b.textContent=v;$('sysinfo').append(a,b)})}
|
||||
// pages, bitmaps and fonts
|
||||
@@ -536,7 +541,7 @@ async function stopTest(){clearInterval(testTimer);testTimer=null;await stopLive
|
||||
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_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;
|
||||
$('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;$('h_rdots').value=s.refresh.dots;$('h_rimg').value=s.refresh.image;
|
||||
$('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_url').value=s.home_assistant.api_url;
|
||||
$('ha_token').value='';$('ha_token').placeholder=s.home_assistant.token_set?'unchanged':'none'}
|
||||
@@ -544,7 +549,7 @@ 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()});
|
||||
s.clock={no_wifi_screen:$('c_nw').checked};
|
||||
s.flip_time=+$('h_flip').value;s.status_led=$('h_led').checked;
|
||||
s.drive={flip_gap:+$('h_gap').value,pulses:+$('h_pulses').value,effect:+$('h_effect').value,effect_time:+$('h_efft').value,effect_small_only:$('h_small').checked};
|
||||
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};s.refresh={dots:+$('h_rdots').value,image:+$('h_rimg').value};
|
||||
Object.assign(s.home_assistant,{enabled:$('ha_en').checked,host:$('ha_host').value.trim(),port:+$('ha_port').value,user:$('ha_user').value.trim(),api_url:$('ha_url').value.trim()});
|
||||
if($('ha_pass').value)s.home_assistant.password=$('ha_pass').value;if($('ha_token').value)s.home_assistant.token=$('ha_token').value.trim();return s}
|
||||
async function saveSettings(){const r=await post('/api/settings',collect());settings=r.settings;fill();msg(r.restart?'Saved, restarting...':'Settings saved')}
|
||||
|
||||
@@ -170,6 +170,24 @@ static void handleStatus()
|
||||
sw["running"] = stopwatch.running();
|
||||
sw["ms"] = stopwatch.elapsed(millis());
|
||||
|
||||
JsonObject load = doc["load"].to<JsonObject>();
|
||||
load["loops_per_s"] = loopStats.loopsPerSecond();
|
||||
load["avg_us"] = loopStats.averageUs();
|
||||
load["max_ms"] = loopStats.maxUs() / 1000;
|
||||
load["max_ever_ms"] = loopStats.maxEverUs() / 1000;
|
||||
load["heap_free"] = ESP.getFreeHeap();
|
||||
load["heap_min"] = ESP.getMinFreeHeap();
|
||||
|
||||
const FrameCheckStats &fc = nanoMonitor.stats();
|
||||
JsonObject link = doc["nano_link"].to<JsonObject>();
|
||||
link["checks"] = fc.checks;
|
||||
link["mismatches"] = fc.mismatches;
|
||||
link["no_reply"] = fc.no_reply;
|
||||
link["errors"] = fc.errors;
|
||||
link["last_error"] = fc.last_error;
|
||||
link["restarts"] = fc.restarts;
|
||||
link["repairs"] = fc.repairs;
|
||||
|
||||
JsonObject ha = doc["ha"].to<JsonObject>();
|
||||
ha["enabled"] = appSettings().home_assistant.enabled;
|
||||
ha["connected"] = homeAssistantConnected();
|
||||
@@ -570,6 +588,9 @@ static void settingsToJson(const Settings &s, JsonObject o)
|
||||
drive["effect"] = s.drive.effect;
|
||||
drive["effect_time"] = s.drive.effect_time;
|
||||
drive["effect_small_only"] = s.drive.effect_small_only;
|
||||
JsonObject refresh = o["refresh"].to<JsonObject>();
|
||||
refresh["dots"] = s.refresh.dots;
|
||||
refresh["image"] = s.refresh.image;
|
||||
|
||||
JsonObject schedule = o["schedule"].to<JsonObject>();
|
||||
schedule["enabled"] = s.schedule.enabled;
|
||||
@@ -633,6 +654,11 @@ static void handleSettingsPost()
|
||||
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 refresh = body["refresh"];
|
||||
if (refresh["dots"].is<int>())
|
||||
s.refresh.dots = constrain(refresh["dots"].as<int>(), 0, 65535);
|
||||
if (refresh["image"].is<int>())
|
||||
s.refresh.image = constrain(refresh["image"].as<int>(), 0, 65535);
|
||||
|
||||
JsonVariantConst schedule = body["schedule"];
|
||||
copyBool(schedule["enabled"], s.schedule.enabled);
|
||||
|
||||
+15
-8
@@ -46,11 +46,13 @@ Arduino core, EEPROM and the display driver are replaced by fakes in
|
||||
being read; while a new frame is being drawn into the buffer the effect pauses, and the
|
||||
next `D` starts it again. The orders come from `DotSequence` in
|
||||
[lib/BROSE9323/dot_sequence.h](../../lib/BROSE9323/dot_sequence.h).
|
||||
- **Every 2 minutes** (`REFRESH_TIME`) it does a full redraw to fix dots that got stuck.
|
||||
A full redraw blocks for about half a second, which is long enough to overflow the
|
||||
64-byte serial buffer. So it only runs right after a `D` (the end of a frame, when the
|
||||
ESP is quiet) or when no serial data has arrived for 2 seconds, and never while an
|
||||
effect runs. It is always instant, with one pulse per dot.
|
||||
- **Nothing blocks.** Every frame (also instant ones, `D1`) is flipped from `update()` in
|
||||
slices of about 1 ms, so `loop()` keeps reading the serial port. A blocking redraw used
|
||||
to overflow the 64-byte receive buffer; the lost bytes shifted the rest of a bitmap
|
||||
(the picture moved up and left) and nothing repaired it.
|
||||
- **Every 2 minutes** (`I` command, stored in EEPROM, 0 = never) it flips every dot once
|
||||
more, with one pulse, to fix dots that got stuck. This also runs in slices, only while
|
||||
no frame is being received or shown, and continues where it was after a frame.
|
||||
- **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. `G`, `N` and `E` are stored the
|
||||
same way (addresses in `settings.cpp`; values that were never written fall back to the
|
||||
@@ -78,19 +80,24 @@ skipped and the next line starts clean. Coordinates are 2-digit **hex**. Color i
|
||||
| `s` | `sXXYYc` | Set pixel in the buffer |
|
||||
| `S` | `SXXYYc` | Set pixel and flip it right away (direct mode) |
|
||||
| `F` | `Fc` | Fill the whole buffer |
|
||||
| `D` | `D` or `D1` | Push the buffer to the display (changed dots only). `D1`: a frame of moving content (scrolling text, seconds), shown without the refresh effect; the periodic full redraw then waits until the serial line is idle. |
|
||||
| `D` | `D` or `D1` | Push the buffer to the display (changed dots only). `D1`: a frame of moving content (scrolling text, seconds), shown without the refresh effect. |
|
||||
| `P` | `PXXYYcsTEXT` | Print text at (XX,YY), color `c`, size `s`. A literal `\n` (backslash, n) in TEXT starts a new line. The whole line can be at most 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 |
|
||||
| `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 |
|
||||
| `I` | `Innn` | Minutes between full redraws (decimal, 0–1440, 0 = never), stored in EEPROM |
|
||||
| `C` | `C` | Reply `C xxxx`: checksum of the image received so far (CRC-16/CCITT of the buffer, only the dots, `lib/BROSE9323/buffer_crc.h`), 4 hex digits |
|
||||
| `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:
|
||||
|
||||
- `READY` after booting. The ESP waits for it before sending the first frame.
|
||||
- `ERR: ...` for invalid commands.
|
||||
- `READY` after booting. The ESP waits for it before sending the first frame. Later
|
||||
(while it is on) it means the Nano restarted, e.g. after a power dip.
|
||||
- `C xxxx` for `C`. The ESP sends `C` after frames (at most every 3 s) and compares the
|
||||
answer with its own copy; a wrong or missing answer makes it send the whole image again.
|
||||
- `ERR: ...` for invalid commands. The ESP counts them (web UI, System).
|
||||
|
||||
Example (what the ESP32 sends for one pixel and a refresh):
|
||||
|
||||
|
||||
+35
-15
@@ -71,7 +71,6 @@ void CommandParser::feed(char c)
|
||||
return;
|
||||
}
|
||||
_line[_line_len++] = c;
|
||||
_frame_ended = false;
|
||||
|
||||
// Bitmap header complete: draw the pixel data as it arrives
|
||||
if (_line_len == BITMAP_HEADER_LEN && isBitmapCommand(_line[0]))
|
||||
@@ -82,11 +81,6 @@ void CommandParser::feed(char c)
|
||||
}
|
||||
}
|
||||
|
||||
bool CommandParser::frameEnded() const
|
||||
{
|
||||
return _frame_ended;
|
||||
}
|
||||
|
||||
// Run one complete command line (without the '\n')
|
||||
void CommandParser::_runLine()
|
||||
{
|
||||
@@ -102,6 +96,10 @@ void CommandParser::_runLine()
|
||||
// Complete bitmap headers are handled in feed()
|
||||
_error(F("Incomplete bitmap header"));
|
||||
break;
|
||||
case 'c':
|
||||
case 'C':
|
||||
_checksum();
|
||||
break;
|
||||
case 'e':
|
||||
case 'E':
|
||||
_effect();
|
||||
@@ -110,21 +108,18 @@ void CommandParser::_runLine()
|
||||
case 'G':
|
||||
_gap();
|
||||
break;
|
||||
case 'i':
|
||||
case 'I':
|
||||
_refreshInterval();
|
||||
break;
|
||||
case 'n':
|
||||
case 'N':
|
||||
_pulses();
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
// D1: a frame of moving content, shown without the effect. More frames follow
|
||||
// soon, so the periodic full redraw waits until the serial line is idle.
|
||||
if (_line[1] == '1')
|
||||
_display.display(false, true);
|
||||
else
|
||||
{
|
||||
_display.display();
|
||||
_frame_ended = true;
|
||||
}
|
||||
// D1: a frame of moving content, shown without the effect
|
||||
_display.display(false, _line[1] == '1');
|
||||
break;
|
||||
case 'f':
|
||||
case 'F':
|
||||
@@ -320,3 +315,28 @@ void CommandParser::_effect()
|
||||
_display.setEffect(effect, t, flags);
|
||||
saveEffect(effect, t, flags);
|
||||
}
|
||||
|
||||
// C: replies "C xxxx", the bufferCrc() of the image received so far as 4 hex digits. The
|
||||
// ESP32 sends it after a frame and compares it with what it sent.
|
||||
void CommandParser::_checksum()
|
||||
{
|
||||
uint16_t crc = _display.crc();
|
||||
char text[7] = "C ";
|
||||
for (uint8_t i = 0; i < 4; i++)
|
||||
text[2 + i] = "0123456789ABCDEF"[(crc >> (12 - 4 * i)) & 0xF];
|
||||
text[6] = '\0';
|
||||
_reply.println(text);
|
||||
}
|
||||
|
||||
// Immm (mmm = decimal minutes between full redraws, 0 = never, up to 1440), stored in EEPROM
|
||||
void CommandParser::_refreshInterval()
|
||||
{
|
||||
long m = atol(_line + 1);
|
||||
if (_line_len < 2 || m < 0 || m > REFRESH_MINUTES_MAX)
|
||||
{
|
||||
_error(F("Refresh interval out of range (0-1440 min)"));
|
||||
return;
|
||||
}
|
||||
_refresh_minutes = m;
|
||||
saveRefreshMinutes(m);
|
||||
}
|
||||
@@ -16,8 +16,9 @@ public:
|
||||
// Handle one received byte
|
||||
void feed(char c);
|
||||
|
||||
// True when the last command was D (end of a frame) and no new command has started
|
||||
bool frameEnded() const;
|
||||
// Full redraw interval in minutes, 0 = never (I command; loaded from EEPROM at boot)
|
||||
uint16_t refreshMinutes() const { return _refresh_minutes; }
|
||||
void setRefreshMinutes(uint16_t minutes) { _refresh_minutes = minutes; }
|
||||
|
||||
private:
|
||||
BROSE9323 &_display;
|
||||
@@ -26,7 +27,7 @@ private:
|
||||
char _line[LINE_MAX + 1];
|
||||
uint8_t _line_len = 0;
|
||||
bool _discard_line = false; // skip everything until the next '\n'
|
||||
bool _frame_ended = false;
|
||||
uint16_t _refresh_minutes = REFRESH_MINUTES_DEFAULT;
|
||||
|
||||
// Bitmap (B) pixel data is drawn while it arrives instead of being buffered
|
||||
bool _bitmap_active = false;
|
||||
@@ -47,4 +48,6 @@ private:
|
||||
void _gap();
|
||||
void _pulses();
|
||||
void _effect();
|
||||
void _checksum();
|
||||
void _refreshInterval();
|
||||
};
|
||||
+4
-5
@@ -42,8 +42,10 @@ const uint16_t EFFECT_TIME_MIN = 100;
|
||||
const uint16_t EFFECT_TIME_MAX = 10000;
|
||||
const uint8_t EFFECT_FLAGS_MAX = 1; // EFFECT_FLAG_SMALL_ONLY
|
||||
|
||||
// Full redraw interval, fixes dots that did not flip
|
||||
const uint32_t REFRESH_TIME = 120000UL; // 2 min
|
||||
// Full redraw interval in minutes, fixes dots that did not flip (I command, EEPROM;
|
||||
// 0 = never). It runs from update() between frames and never blocks the serial port.
|
||||
const uint16_t REFRESH_MINUTES_DEFAULT = 2;
|
||||
const uint16_t REFRESH_MINUTES_MAX = 1440;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serial link to the ESP32
|
||||
@@ -51,8 +53,5 @@ const uint32_t REFRESH_TIME = 120000UL; // 2 min
|
||||
|
||||
const uint32_t SERIAL_BAUD = 115200;
|
||||
|
||||
// Serial line quiet for this long: safe to do a due full redraw without waiting for a D
|
||||
const uint32_t IDLE_TIME = 2000UL;
|
||||
|
||||
// Longest command line (bitmap data is streamed and does not count)
|
||||
const uint8_t LINE_MAX = 64;
|
||||
+11
-13
@@ -12,20 +12,17 @@ BROSE9323 display(DISPLAY_WIDTH, DISPLAY_HEIGHT, PANEL_WIDTH, FLIP_TIME_DEFAULT)
|
||||
CommandParser parser(display, Serial);
|
||||
|
||||
uint32_t last_refresh;
|
||||
uint32_t last_rx;
|
||||
|
||||
// Force a full redraw every REFRESH_TIME. The redraw blocks long enough to overflow the
|
||||
// 64 byte serial buffer, so it only runs when no data is expected: right after the ESP
|
||||
// finished a frame, or when the serial line is idle.
|
||||
// Flip every dot again each refresh interval. The driver does it from update(), between
|
||||
// frames and without blocking, so it can start at any time.
|
||||
void refreshIfDue()
|
||||
{
|
||||
// Not while an effect is drawing a frame (it flips every changed dot itself)
|
||||
if (display.busy())
|
||||
uint16_t minutes = parser.refreshMinutes();
|
||||
if (minutes == 0 || display.refreshing())
|
||||
return;
|
||||
bool quiet = parser.frameEnded() || millis() - last_rx >= IDLE_TIME;
|
||||
if (quiet && millis() - last_refresh >= REFRESH_TIME)
|
||||
if (millis() - last_refresh >= minutes * 60000UL)
|
||||
{
|
||||
display.display(true);
|
||||
display.startRefresh();
|
||||
last_refresh = millis();
|
||||
}
|
||||
}
|
||||
@@ -37,9 +34,13 @@ void measureRefresh()
|
||||
|
||||
display.fillScreen(1);
|
||||
display.display();
|
||||
while (display.update())
|
||||
;
|
||||
display.fillScreen(0);
|
||||
uint32_t t1 = millis();
|
||||
display.display();
|
||||
while (display.update())
|
||||
;
|
||||
uint32_t t2 = millis();
|
||||
Serial.println(t2 - t1);
|
||||
}
|
||||
@@ -56,6 +57,7 @@ void setup()
|
||||
uint16_t effect_time;
|
||||
loadEffect(effect, effect_time, effect_flags);
|
||||
display.setEffect(effect, effect_time, effect_flags);
|
||||
parser.setRefreshMinutes(loadRefreshMinutes());
|
||||
display.begin();
|
||||
|
||||
#ifdef MEASURE_REFRESH
|
||||
@@ -63,7 +65,6 @@ void setup()
|
||||
#endif
|
||||
|
||||
last_refresh = millis();
|
||||
last_rx = millis();
|
||||
|
||||
// Tells the ESP that frames can be sent (the relay powers the Nano, so this happens at every power on)
|
||||
Serial.println(F("READY"));
|
||||
@@ -72,10 +73,7 @@ void setup()
|
||||
void loop()
|
||||
{
|
||||
while (Serial.available())
|
||||
{
|
||||
last_rx = millis();
|
||||
parser.feed(Serial.read());
|
||||
}
|
||||
|
||||
display.update();
|
||||
refreshIfDue();
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 int EEPROM_REFRESH_ADDR = 11;
|
||||
const uint16_t EEPROM_MAGIC = 0xF11D;
|
||||
|
||||
static bool valid()
|
||||
@@ -93,3 +94,17 @@ void saveEffect(uint8_t effect, uint16_t time_ms, uint8_t flags)
|
||||
save(EEPROM_EFFECT_TIME_ADDR, time_ms);
|
||||
save(EEPROM_EFFECT_FLAGS_ADDR, flags);
|
||||
}
|
||||
|
||||
uint16_t loadRefreshMinutes()
|
||||
{
|
||||
uint16_t m;
|
||||
EEPROM.get(EEPROM_REFRESH_ADDR, m);
|
||||
if (!valid() || m > REFRESH_MINUTES_MAX)
|
||||
return REFRESH_MINUTES_DEFAULT;
|
||||
return m;
|
||||
}
|
||||
|
||||
void saveRefreshMinutes(uint16_t m)
|
||||
{
|
||||
save(EEPROM_REFRESH_ADDR, m);
|
||||
}
|
||||
@@ -16,3 +16,7 @@ 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);
|
||||
|
||||
// Full redraw interval in minutes, 0 = never
|
||||
uint16_t loadRefreshMinutes();
|
||||
void saveRefreshMinutes(uint16_t m);
|
||||
Reference in new issue
Block a user