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:
mangavalkandClaude Opus 5.5 committed 2026-10-16 15:20:26 +02:00
1 parent 58a7cb03c2
commit 090c2bf8ae
45 files changed
+1265 -157

No files matched your search

+5 -2
View File
@@ -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);
+4
View File
@@ -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
View File
@@ -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};
+3 -1
View File
@@ -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)
+2 -1
View File
@@ -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;
};
+120
View File
@@ -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;
}
+58
View File
@@ -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;
};
+54
View File
@@ -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;
};
+2
View File
@@ -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)) ||
+13
View File
@@ -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)
};
+5
View File
@@ -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);
+43
View File
@@ -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;
}
+28
View File
@@ -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 -1
View File
@@ -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;
+8 -3
View File
@@ -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')}
+26
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+6 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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();
+15
View File
@@ -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);
}
+4
View File
@@ -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);