feat(l2): Gallery app (screenshots as thumbnails, full view with swipe and delete, the saved trails), the clock from NTP while WiFi is up (the router's server from DHCP first), tabs switch on release, Home apps out of Settings

- Gallery: thumbnails 30 to a page, built only while the screen is still (a decode held the loop and a scroll read as a tap)
- segmented(): CLICK_TRIG, so a swipe starting on a screen's tabs doesn't switch them
- Settings > Device > Home apps removed (hold an app on Home instead)
- platformio.ini: a note that the L2 has no microphone on GPIO15 (verified on hardware)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-10-29 15:39:56 +01:00
co-authored by Claude Opus 5.5
parent cdc2ff25c9
commit e154a4e721
11 changed files with 493 additions and 18 deletions
+3 -4
View File
@@ -99,10 +99,9 @@ if your mesh agrees on it.
> **Wio Tracker L2:** one list of pages:
>
> - **Device**: Display (including the lock, **Tap to wake**, the **Theme**,
> dark or light, which restarts the device, and the accent colour), Home apps,
> Power (battery, GPS, and the **Battery curve** as a chart whose points you
> drag), Sound (with the melody editor), Keyboard, Messages
> & contacts.
> dark or light, which restarts the device, and the accent colour), Power
> (battery, GPS, and the **Battery curve** as a chart whose points you
> drag), Sound (with the melody editor), Keyboard, Messages & contacts.
> - **Connections**: Radio (with **Advanced** at the bottom), Privacy,
> Bluetooth, WiFi, GPS. Adding contacts is in Messages & contacts.
> - **Map & data**: Map (trail, live sharing and arrival alert options),
+14 -4
View File
@@ -8,9 +8,9 @@ home page.
> [!NOTE]
> **Wio Tracker L2:** the tools are apps on the home screen: Messages, Nodes,
> Settings, Compass, Clock, GPS, Bot, Repeater, Admin, Diagnostics, Flashlight. The trail,
> live sharing and the locator are on the map (its tools button) and in
> Settings › Map.
> Settings, Compass, Clock, GPS, Bot, Repeater, Admin, Diagnostics, Flashlight,
> Gallery. The trail, live sharing and the locator are on the map (its tools
> button) and in Settings › Map.
## Clock
@@ -18,7 +18,8 @@ The **Clock** home page shows the time with a bar under it that fills with
the seconds (Settings › Display › Clock seconds), the date, and up to three
fields you choose (hold Enter): battery, temperature, humidity, pressure, altitude
(barometric or GPS), light, CO₂, GPS position, satellites, contacts, unread
messages. The time comes from GPS or the phone app; the time zone and summer
messages. The time comes from GPS or the phone app (on the Wio Tracker L2
also from the internet whenever WiFi is connected); the time zone and summer
time are in Settings.
**Enter** on the clock opens the clock tools:
@@ -108,6 +109,15 @@ when you leave. Not on e-ink, which gives no light.
> **Wio Tracker L2:** the Flashlight app. A tap, the side button or the top
> button puts it away.
## Gallery
> [!NOTE]
> **Wio Tracker L2 only.** Two tabs. **Screenshots** shows the ones on the SD
> card (side + top button takes one), newest first, as thumbnails, 30 to a
> page. Tap one to see it on the whole screen: swipe for the next or the
> previous, tap for its name, **Delete** and close. **Trails** lists the saved
> trails; tap one to open it on the map with Load, GPX and Delete.
## Status
The **Status** home page sums up the radio, GPS, battery and mesh in four
@@ -0,0 +1,389 @@
#pragma once
// Home > Gallery. Two tabs:
// Screenshots -- the card's screenshots/ (takeScreenshot(): side + top
// button), newest first, as a grid of thumbnails PAGE at a
// time; built one per loop pass while nothing is touched,
// so the grid shows at once and fills in. A tap shows one on the whole screen: swipe
// for the next / previous, tap for its name, Delete and close.
// Trails -- the saved trails, the list Map tools > Load shows; a tap
// opens the map with that trail's Load / GPX / Delete.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after NavMap.h.
namespace gallery {
static const char* const SHOTS_DIR = "/sdcard/screenshots";
static const int SHOTS_MAX = 300; // names kept, newest first
static const int PAGE = 30; // thumbnails on the grid at a time
static const int TW = 96, TH = 72; // a thumbnail: 3 to a row
static const int SW = 320, SH = 240; // the full view (what takeScreenshot() writes)
static const int BMP_MAX_W = 640; // wider files aren't ours: not shown
enum : uint8_t { T_SHOTS, T_TRAILS };
enum : uint8_t { V_DELETE, V_CLOSE };
static PSRAM_BSS char s_names[SHOTS_MAX][16];
static int s_n = 0;
static int s_page = 0; // the grid's page, from the newest
static uint8_t s_tab = T_SHOTS;
// The grid's thumbnails: one buffer per grid, freed with it (a rebuild makes
// the new grid before the old one goes, so each frees only its own).
static uint16_t* s_thumbs = nullptr;
static lv_image_dsc_t s_tdsc[PAGE];
static lv_obj_t* s_cell[PAGE];
static int s_cells = 0, s_next = 0; // cells on the grid / the next to build
// The full view, over the whole screen.
static uint16_t* s_full = nullptr;
static lv_image_dsc_t s_fdsc;
static lv_obj_t* s_view = nullptr;
static lv_obj_t* s_view_img = nullptr;
static lv_obj_t* s_view_bar = nullptr;
static lv_obj_t* s_view_title = nullptr;
static lv_obj_t* s_del_lbl = nullptr;
static int s_cur = 0;
static bool s_changed = false; // a delete from the view: the grid is rebuilt on closing it
static lv_point_t s_press;
static const int SWIPE_PX = 40;
static void scan() {
s_n = 0;
DIR* d = opendir(SHOTS_DIR);
if (!d) return;
while (struct dirent* de = readdir(d)) {
size_t l = strlen(de->d_name);
if (de->d_name[0] == '.' || l < 5 || l >= sizeof(s_names[0]) || strcasecmp(de->d_name + l - 4, ".bmp") != 0) continue;
if (s_n < SHOTS_MAX) snprintf(s_names[s_n++], sizeof(s_names[0]), "%s", de->d_name);
}
closedir(d);
// Newest first: scr_NNNN numbers count up.
qsort(s_names, s_n, sizeof(s_names[0]), [](const void* a, const void* b) {
return -strcmp((const char*)a, (const char*)b);
});
}
static void path(int i, char* out, size_t n) { snprintf(out, n, "%s/%s", SHOTS_DIR, s_names[i]); }
// In the display's byte order, as the map tiles are (UI_FLUSH_DMA: big-endian).
static inline uint16_t px565(uint32_t r, uint32_t g, uint32_t b) {
uint16_t c = (uint16_t)(((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3));
#ifdef UI_FLUSH_DMA
return __builtin_bswap16(c);
#else
return c;
#endif
}
static void dscInit(lv_image_dsc_t& d, uint16_t* px, int w, int h) {
memset(&d, 0, sizeof(d));
d.header.magic = LV_IMAGE_HEADER_MAGIC;
#ifdef UI_FLUSH_DMA
d.header.cf = LV_COLOR_FORMAT_RGB565_SWAPPED;
#else
d.header.cf = LV_COLOR_FORMAT_RGB565;
#endif
d.header.w = w;
d.header.h = h;
d.header.stride = w * 2;
d.data_size = w * h * 2;
d.data = (const uint8_t*)px;
}
// An uncompressed 24-bit BMP, read a row at a time (top row = 0).
struct Bmp {
FILE* f = nullptr;
int32_t w = 0, h = 0;
uint32_t off = 0, stride = 0;
bool bottom_up = true;
bool open(const char* p) {
f = fopen(p, "rb");
uint8_t hdr[54];
if (!f || fread(hdr, 1, 54, f) != 54 || hdr[0] != 'B' || hdr[1] != 'M') return false;
auto get32 = [&](int at) { return (uint32_t)hdr[at] | (uint32_t)hdr[at + 1] << 8 | (uint32_t)hdr[at + 2] << 16 | (uint32_t)hdr[at + 3] << 24; };
off = get32(10);
w = (int32_t)get32(18);
h = (int32_t)get32(22);
bottom_up = h > 0;
if (h < 0) h = -h;
const uint16_t bpp = hdr[28] | hdr[29] << 8;
if (bpp != 24 || get32(30) != 0 || w <= 0 || w > BMP_MAX_W || h <= 0 || h > BMP_MAX_W) return false;
stride = ((uint32_t)w * 3 + 3) & ~3u;
return true;
}
bool row(int y, uint8_t* out) { // BGR
if (fseek(f, (long)(off + (uint32_t)(bottom_up ? h - 1 - y : y) * stride), SEEK_SET) != 0) return false;
return fread(out, 1, (size_t)w * 3, f) == (size_t)w * 3;
}
~Bmp() { if (f) fclose(f); }
};
static PSRAM_BSS uint8_t s_row[2][BMP_MAX_W * 3];
// File `i` scaled into w x h: each pixel the mean of the 2x2 under its
// spot (a thumbnail), or the one pixel there (the full view).
static bool decode(int i, uint16_t* out, int w, int h, bool smooth) {
char p[48];
path(i, p, sizeof(p));
Bmp b;
if (!b.open(p)) return false;
for (int ty = 0; ty < h; ty++) {
const int sy = ty * b.h / h, sy2 = smooth && sy + 1 < b.h ? sy + 1 : sy;
if (!b.row(sy, s_row[0]) || (sy2 != sy && !b.row(sy2, s_row[1]))) return false;
const uint8_t* r1 = s_row[0];
const uint8_t* r2 = sy2 != sy ? s_row[1] : s_row[0];
for (int tx = 0; tx < w; tx++) {
const int sx = tx * b.w / w, sx2 = smooth && sx + 1 < b.w ? sx + 1 : sx;
const uint8_t* a = r1 + sx * 3; const uint8_t* c = r1 + sx2 * 3;
const uint8_t* d = r2 + sx * 3; const uint8_t* e = r2 + sx2 * 3;
out[ty * w + tx] = px565((a[2] + c[2] + d[2] + e[2]) / 4, (a[1] + c[1] + d[1] + e[1]) / 4,
(a[0] + c[0] + d[0] + e[0]) / 4);
}
}
return true;
}
static void onGridDeleted(lv_event_t* e) {
uint16_t* px = (uint16_t*)lv_event_get_user_data(e);
if (px != s_thumbs) { free(px); return; } // an older grid's
for (int i = 0; i < s_cells; i++) lv_image_cache_drop(&s_tdsc[i]);
free(s_thumbs);
s_thumbs = nullptr;
s_cells = s_next = 0;
}
static void onViewDeleted(lv_event_t* e) {
(void)e;
s_view = s_view_img = s_view_bar = s_view_title = s_del_lbl = nullptr;
lv_image_cache_drop(&s_fdsc);
free(s_full);
s_full = nullptr;
}
} // namespace gallery
static void onOpenGallery(lv_event_t* e) { (void)e; s_ui->showGallery(); }
static void onGalleryTab(lv_event_t* e) {
s_ui->galleryTab((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onGalleryShot(lv_event_t* e) { s_ui->galleryOpen((int)(intptr_t)lv_event_get_user_data(e)); }
static void onGalleryPage(lv_event_t* e) { s_ui->galleryPage((int)(intptr_t)lv_event_get_user_data(e)); }
static void onGalleryTrail(lv_event_t* e) { s_ui->galleryTrail((int)(intptr_t)lv_event_get_user_data(e)); }
static void onGalleryViewBtn(lv_event_t* e) { s_ui->galleryViewAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
// The full view: a swipe steps, a tap shows / hides the bar.
static void onGalleryViewTouch(lv_event_t* e) {
using namespace gallery;
lv_point_t p;
lv_indev_get_point(lv_indev_active(), &p);
if (lv_event_get_code(e) == LV_EVENT_PRESSED) { s_press = p; return; }
const int dx = p.x - s_press.x, dy = p.y - s_press.y;
if (abs(dx) > SWIPE_PX && abs(dx) > abs(dy)) s_ui->galleryStep(dx < 0 ? 1 : -1);
else if (abs(dx) < 10 && abs(dy) < 10 && s_view_bar) {
if (lv_obj_has_flag(s_view_bar, LV_OBJ_FLAG_HIDDEN)) lv_obj_remove_flag(s_view_bar, LV_OBJ_FLAG_HIDDEN);
else lv_obj_add_flag(s_view_bar, LV_OBJ_FLAG_HIDDEN);
}
}
void UITask::showGallery() {
_screen = SCR_GALLERY;
gallery::s_page = 0;
buildGallery();
}
void UITask::galleryTab(int tab) {
if (tab != gallery::T_SHOTS && tab != gallery::T_TRAILS) return;
gallery::s_tab = (uint8_t)tab;
buildGallery();
}
void UITask::galleryPage(int page) {
gallery::s_page = page;
buildGallery();
}
void UITask::buildGallery() {
using namespace gallery;
lv_obj_t* body = newScreen("Gallery", true);
lv_obj_set_style_pad_row(body, 4, 0);
static const char* TABS[] = { "Screenshots", "Trails", "" };
lv_obj_t* tabs = segmented(body, TABS, s_tab, lv_pct(100), theme::TAB_H);
lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_add_event_cb(tabs, onGalleryTab, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* list = scrollList(body);
const bool card = lvport::mountStorage();
if (s_tab == T_TRAILS) {
if (card) {
navmap::scanTrails();
for (int i = 0; i < navmap::s_st_n; i++) {
char title[32], sub[48], p[64];
navmap::trailTitle(navmap::s_st_names[i], title, sizeof(title));
snprintf(p, sizeof(p), "%s/%s", navmap::TRAILS_DIR, navmap::s_st_names[i]);
struct stat st;
long pts = stat(p, &st) == 0 ? ((long)st.st_size - navmap::TRAIL_HDR) / (long)sizeof(TrailPoint) : 0;
snprintf(sub, sizeof(sub), "%ld points", pts > 0 ? pts : 0);
listRow(list, title, sub, onGalleryTrail, (void*)(intptr_t)i);
}
}
if (TrailEngine::savedExists())
listRow(list, "Saved on the device", "Without a card, or on low battery", onGalleryTrail, (void*)(intptr_t)-1);
if (lv_obj_get_child_count(list) == 0)
noteLabel(list, "No saved trails yet - Map tools > Save keeps the one on the map.", THEME_FONT_BODY);
return;
}
if (!card) { noteLabel(list, "No SD card - screenshots are saved on it.", THEME_FONT_BODY); return; }
scan();
if (s_n == 0) {
noteLabel(list, "No screenshots yet. Hold the side button and press the top one to take one.", THEME_FONT_BODY);
return;
}
if (_header) {
lv_obj_t* c = label(_header, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_text_fmt(c, "%d", s_n);
lv_obj_align(c, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
const int pages = (s_n + PAGE - 1) / PAGE;
if (s_page >= pages) s_page = pages - 1;
if (s_page < 0) s_page = 0;
const int first = s_page * PAGE;
const int cells = s_n - first < PAGE ? s_n - first : PAGE;
uint16_t* px = (uint16_t*)psramBuf<uint16_t>((size_t)PAGE * TW * TH);
if (!px) { noteLabel(list, "Out of memory", THEME_FONT_BODY); return; }
s_thumbs = px;
s_cells = cells;
s_next = 0;
lv_obj_t* grid = lv_obj_create(list);
lv_obj_remove_style_all(grid);
fillWidth(grid);
lv_obj_set_height(grid, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_row(grid, 6, 0);
lv_obj_add_event_cb(grid, gallery::onGridDeleted, LV_EVENT_DELETE, px);
for (int i = 0; i < cells; i++) {
lv_obj_t* c = lv_obj_create(grid);
styleSurface(c, theme::SURFACE);
lv_obj_remove_flag(c, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(c, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(c, TW, TH);
lv_obj_set_style_pad_all(c, 0, 0);
lv_obj_add_event_cb(c, onGalleryShot, LV_EVENT_CLICKED, (void*)(intptr_t)(first + i));
s_cell[i] = c;
}
if (pages > 1) {
lv_obj_t* bar = buttonBar(list);
lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
if (s_page > 0) barButton(bar, LV_SYMBOL_LEFT " Newer", onGalleryPage, s_page - 1);
lv_obj_t* n = label(bar, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_text_fmt(n, "%d-%d of %d", first + 1, first + cells, s_n);
if (s_page + 1 < pages) barButton(bar, "Older " LV_SYMBOL_RIGHT, onGalleryPage, s_page + 1);
}
}
// From loop() on the gallery: the next thumbnail, only with the screen let
// go and still. A decode holds the loop up, and LVGL, missing the finger's
// moves meanwhile, took a scroll for a tap on the picture under it.
void UITask::galleryTick() {
using namespace gallery;
if (!s_thumbs || s_next >= s_cells || s_view) return;
lv_indev_t* in = lv_indev_get_next(NULL);
if (in && lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED) return;
if (lv_display_get_inactive_time(NULL) < 300 || lv_anim_count_running() > 0) return; // a scroll coasting
const int i = s_next++;
uint16_t* px = s_thumbs + (size_t)i * TW * TH;
if (!decode(s_page * PAGE + i, px, TW, TH, true)) {
lv_obj_center(label(s_cell[i], "?", THEME_FONT_LARGE, theme::TEXT_MUTED));
return;
}
dscInit(s_tdsc[i], px, TW, TH);
lv_obj_t* img = lv_image_create(s_cell[i]);
lv_obj_remove_flag(img, LV_OBJ_FLAG_CLICKABLE);
lv_image_set_src(img, &s_tdsc[i]);
lv_obj_center(img);
}
void UITask::galleryTrail(int idx) {
openMap(true);
savedTrailPopup(idx);
}
// Screenshot `i` on the whole screen.
void UITask::galleryOpen(int i) {
using namespace gallery;
if (i < 0 || i >= s_n) return;
if (!s_full) s_full = (uint16_t*)psramBuf<uint16_t>((size_t)SW * SH);
if (!s_full) { showToast("Out of memory"); return; }
if (s_view_img) lv_image_cache_drop(&s_fdsc);
if (!decode(i, s_full, SW, SH, false)) { showToast("Can't read this file"); return; }
s_cur = i;
dscInit(s_fdsc, s_full, SW, SH);
if (!s_view) {
s_changed = false;
s_view = lv_obj_create(_scr);
lv_obj_remove_style_all(s_view);
lv_obj_add_flag(s_view, LV_OBJ_FLAG_IGNORE_LAYOUT);
lv_obj_set_pos(s_view, 0, 0);
lv_obj_set_size(s_view, SW, SH);
lv_obj_set_style_bg_color(s_view, lv_color_black(), 0);
lv_obj_set_style_bg_opa(s_view, LV_OPA_COVER, 0);
lv_obj_remove_flag(s_view, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(s_view, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(s_view, onGalleryViewTouch, LV_EVENT_PRESSED, NULL);
lv_obj_add_event_cb(s_view, onGalleryViewTouch, LV_EVENT_RELEASED, NULL);
lv_obj_add_event_cb(s_view, gallery::onViewDeleted, LV_EVENT_DELETE, NULL);
s_view_img = lv_image_create(s_view);
lv_obj_remove_flag(s_view_img, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_pos(s_view_img, 0, 0);
s_view_bar = lv_obj_create(s_view);
styleSurface(s_view_bar, theme::SURFACE);
lv_obj_set_style_bg_opa(s_view_bar, LV_OPA_90, 0);
lv_obj_remove_flag(s_view_bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(s_view_bar, SW, 40);
lv_obj_set_pos(s_view_bar, 0, 0);
lv_obj_set_style_pad_hor(s_view_bar, theme::PAD, 0);
lv_obj_set_flex_flow(s_view_bar, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(s_view_bar, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(s_view_bar, 6, 0);
s_view_title = label(s_view_bar, "", THEME_FONT_BODY, theme::TEXT);
lv_obj_set_flex_grow(s_view_title, 1);
lv_obj_t* del = pillButton(s_view_bar, LV_SYMBOL_TRASH " Delete", theme::SURFACE_2, onGalleryViewBtn, V_DELETE);
lv_obj_set_height(del, 32);
s_del_lbl = lv_obj_get_child(del, 0);
lv_obj_set_height(pillButton(s_view_bar, LV_SYMBOL_CLOSE, theme::SURFACE_2, onGalleryViewBtn, V_CLOSE), 32);
lv_obj_add_flag(s_view_bar, LV_OBJ_FLAG_HIDDEN); // a tap brings it
}
lv_image_set_src(s_view_img, &s_fdsc);
lv_obj_invalidate(s_view_img);
lv_label_set_text_fmt(s_view_title, "%.*s %d / %d", (int)(strlen(s_names[i]) - 4), s_names[i], i + 1, s_n);
}
void UITask::galleryStep(int d) {
const int i = gallery::s_cur + d;
if (i >= 0 && i < gallery::s_n) galleryOpen(i);
}
void UITask::galleryCloseView() {
using namespace gallery;
if (!s_view) return;
lv_obj_delete(s_view); // onViewDeleted frees the picture
// Back on the page the one looked at last is on.
if (s_changed || s_cur / PAGE != s_page) { s_page = s_cur / PAGE; buildGallery(); }
}
void UITask::galleryBack() {
if (gallery::s_view) galleryCloseView();
else showHome();
}
void UITask::galleryViewAction(uint8_t act) {
using namespace gallery;
if (act == V_CLOSE) { galleryCloseView(); return; }
if (!tapConfirmed(s_del_lbl, "Delete?")) return;
char p[48];
path(s_cur, p, sizeof(p));
if (remove(p) != 0) { showToast("Delete failed"); return; }
s_changed = true;
scan();
if (s_n == 0) { s_cur = 0; galleryCloseView(); return; }
galleryOpen(s_cur < s_n ? s_cur : s_n - 1);
}
@@ -8,7 +8,7 @@
// map -- a minimap round your position, framing anyone sharing
// theirs and the target; tap for the Navigation map
// apps -- the tools, 3x2 to a page, in the user's order with some
// maybe hidden (Settings > Home apps, or hold an app)
// maybe hidden (hold an app)
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after NavMap.h (the
// minimap draws with the map's tile cache).
@@ -34,6 +34,7 @@ static const App APP_LIST[] = {
{ 'A', UI_SYMBOL_KEY, "Admin", onOpenAdminPick, false },
{ 'D', UI_SYMBOL_CHART, "Diagnostics", onOpenDiag, false },
{ 'L', UI_SYMBOL_BULB, "Flashlight", onOpenFlashlight, false },
{ 'P', LV_SYMBOL_IMAGE, "Gallery", onOpenGallery, false },
};
static const int APP_COUNT = sizeof(APP_LIST) / sizeof(APP_LIST[0]);
static const int PER_PAGE = 6;
@@ -41,7 +42,7 @@ static const char ALWAYS = 'S'; // Settings can't be hidden: it's the way back
// ── The user's order ──
// The letters in order, a hidden app's in lower case. Arranged on Home itself
// (hold an app, or Settings > Home apps); saved on Done or on leaving Home,
// (hold an app); saved on Done or on leaving Home,
// not per change.
static char s_order[APP_COUNT + 1];
static bool s_order_loaded = false, s_order_dirty = false;
@@ -27,6 +27,7 @@
#include <USBMSC.h>
#endif
#include <mbedtls/platform.h>
#include <esp_sntp.h>
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
#include <emscripten/fetch.h>
#include <dirent.h>
@@ -573,8 +574,10 @@ static bool flashInfo(uint64_t& total, uint64_t& used) {
// straight away when it's the only one.
static bool s_net_scanning = false;
static char s_fb_ssid[33], s_fb_pass[65];
static void ntpAskDhcp();
static void netBegin(const char* ssid, const char* pass) {
WiFi.mode(WIFI_STA);
ntpAskDhcp();
snprintf(s_fb_ssid, sizeof(s_fb_ssid), "%s", ssid);
snprintf(s_fb_pass, sizeof(s_fb_pass), "%s", pass);
if (wifiSavedCount() <= 1) { WiFi.begin(ssid, pass); return; }
@@ -585,6 +588,7 @@ static void netBegin(const char* ssid, const char* pass) {
static void netJoin(const char* ssid, const char* pass) {
s_net_scanning = false;
WiFi.mode(WIFI_STA);
ntpAskDhcp();
WiFi.begin(ssid, pass);
}
// After NET_FAILED / a timeout: true when the network wasn't there at all
@@ -615,6 +619,29 @@ static int netState() {
default: return NET_CONNECTING;
}
}
// The clock over WiFi (SNTP), while connected: the router's own NTP server
// when its DHCP lease names one (option 42 -- asked for before joining, the
// lease is where it comes from), then public pools. SNTP sets the system time
// itself; ntpTake() hands the time on to rtc_clock (a hardware RTC, if any).
static volatile uint32_t s_ntp_secs = 0;
static char s_ntp_dhcp[16];
static void ntpAskDhcp() { esp_sntp_servermode_dhcp(true); }
static void ntpSynced(struct timeval* tv) { s_ntp_secs = (uint32_t)tv->tv_sec; } // the lwIP task
static void ntpStart() {
if (esp_sntp_enabled()) esp_sntp_stop();
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
const ip_addr_t* a = esp_sntp_getserver(0);
const bool router = a && !ip_addr_isany(a);
if (router) snprintf(s_ntp_dhcp, sizeof(s_ntp_dhcp), "%s", ipaddr_ntoa(a));
esp_sntp_setservername(0, router ? s_ntp_dhcp : "pool.ntp.org");
esp_sntp_setservername(1, router ? "pool.ntp.org" : "time.google.com");
esp_sntp_setservername(2, "time.cloudflare.com");
sntp_set_time_sync_notification_cb(ntpSynced);
esp_sntp_init();
}
static void ntpStop() { if (esp_sntp_enabled()) esp_sntp_stop(); }
static uint32_t ntpTake() { uint32_t t = s_ntp_secs; s_ntp_secs = 0; return t; }
static void fetchFreeBuffer();
static void netEnd() {
if (s_net_scanning) { s_net_scanning = false; WiFi.scanDelete(); }
@@ -912,6 +939,9 @@ static bool netNotFound() { return false; }
static int netState() { return NET_UP; }
static void netEnd() { s_net_on = false; }
static int netRadio() { return s_net_on ? NET_UP : NET_OFF; }
static void ntpStart() {} // the browser's clock is the computer's already
static void ntpStop() {}
static uint32_t ntpTake() { return 0; }
static void netSsid(char* out, size_t n) { const WifiNet* w = wifiSaved(0); snprintf(out, n, "%s", s_net_on && w ? w->ssid : ""); }
static uint32_t s_scan_at = 0;
static void scanStart() { s_scan_at = millis(); }
@@ -66,7 +66,6 @@ static void onOpenSchemaPage(lv_event_t* e) {
s_nav_section = -1; // from Settings: all of a page
s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e));
}
static void onHomeApps(lv_event_t* e) { (void)e; s_ui->homeEdit(true); }
static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); }
static void onOpenQuickMsgs(lv_event_t* e);
static void onPinSetup(lv_event_t* e); // DeviceScreen.h
@@ -574,8 +573,6 @@ void UITask::settingsGroup(int i) {
g = group(body, "DEVICE");
listRow(g, LV_SYMBOL_EYE_OPEN " Display", "Brightness, screen off, lock, colour",
onOpenSchemaPage, (void*)(uintptr_t)PG_DISPLAY);
snprintf(sub, sizeof(sub), "Order, hide - %d of %d shown", home::shownCount(), home::APP_COUNT);
listRow(g, LV_SYMBOL_HOME " Home apps", sub, onHomeApps, NULL);
snprintf(sub, sizeof(sub), "Shutdown %s - GPS: %s", settingText(*_prefs, SETTING(low_batt_mv), v1, sizeof(v1)),
settingText(*_prefs, SETTING(gps_interval), v2, sizeof(v2)));
listRow(g, LV_SYMBOL_BATTERY_FULL " Power", sub, onOpenSchemaPage, (void*)(uintptr_t)PG_POWER);
+7 -2
View File
@@ -324,6 +324,7 @@ void UITask::loop() {
#endif
checkLowBattery();
mapDownloadTick(); // a map download keeps running on any screen, and asleep
wifiClockTick();
otaTick(); // so does a firmware update
trailJournalTick(); // the live trail's copy on the card, once a minute
if ((int32_t)(millis() - _next_trackback_ms) >= 0) { // walking the trail back, on any screen
@@ -347,6 +348,7 @@ void UITask::loop() {
}
if (locked()) lockPoll();
else if (_screen == SCR_HOME) { homeSwipePoll(); homeMapLoop(); }
else if (_screen == SCR_GALLERY) galleryTick();
if ((_screen == SCR_NEARBY || _screen == SCR_NODE) && (int32_t)(millis() - _next_nearby_ms) >= 0) {
// Scan results trickle in over a few seconds: poll fast while scanning.
_next_nearby_ms = millis() + (_scanning ? 250 : 2000);
@@ -1229,7 +1231,7 @@ int UITask::screenDepth(Screen s) {
switch (s) {
case SCR_HOME: return 0;
case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS:
case SCR_COMPASS: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK:
case SCR_COMPASS: case SCR_GALLERY: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK:
case SCR_DIAG: case SCR_GPS: return 1;
case SCR_THREAD: case SCR_CONTACTS: case SCR_CHANNEL_EDIT: case SCR_NODE:
case SCR_SETTINGS_NAV: case SCR_ADMIN: return 2;
@@ -1255,6 +1257,7 @@ void UITask::back() {
case SCR_ROUTE_PICK: _screen = SCR_ROUTE; buildRoute(); break;
case SCR_OTA: otaLeave(); break;
case SCR_COMPASS: showHome(); break;
case SCR_GALLERY: galleryBack(); break;
case SCR_BATT: showSchemaSettings(PG_POWER); break;
case SCR_GPS: if (_gps_from_settings) showSettings(); else showHome(); break;
case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break;
@@ -1338,6 +1341,7 @@ static void onOpenNodesMap(lv_event_t* e) { (void)e; s_ui->openMap(false); }
static void onOpenClock(lv_event_t* e); // ClockScreen.h
static void onOpenBot(lv_event_t* e); // BotScreen.h
static void onOpenCompass(lv_event_t* e); // CompassScreen.h
static void onOpenGallery(lv_event_t* e); // GalleryScreen.h
static void onOpenFlashlight(lv_event_t* e); // Flashlight.h
static void onOpenDiag(lv_event_t* e); // DiagScreen.h
static void onOpenGps(lv_event_t* e); // GpsScreen.h
@@ -1360,6 +1364,7 @@ static void onFactoryReset(lv_event_t* e) { s_ui->factoryResetStep((int)(uintptr
#include "Chart.h"
#include "ProfileView.h"
#include "NavMap.h"
#include "GalleryScreen.h"
#include "MapAreas.h"
#include "MapRegions.h"
#include "HomeScreen.h"
@@ -1442,7 +1447,7 @@ extern "C" EMSCRIPTEN_KEEPALIVE void sim_open(const char* name) {
{ "nodes", &UITask::showNearby }, { "ota", &UITask::showOta }, { "wifi", &UITask::showWifi },
{ "clock", &UITask::showClock }, { "radio", &UITask::showRadio }, { "scopes", &UITask::showScopes },
{ "repeater", &UITask::showRepeater }, { "bot", &UITask::showBot }, { "storage", &UITask::showStorage },
{ "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "admin", &UITask::showAdminPick },
{ "diag", &UITask::showDiag }, { "compass", &UITask::showCompass }, { "gallery", &UITask::showGallery }, { "admin", &UITask::showAdminPick },
{ "quick", &UITask::showQuickMsgs }, { "contacts", &UITask::showContacts },
};
for (auto& s : SCREENS) if (!strcmp(s.n, name)) { (s_ui->*s.fn)(); return; }
+15 -2
View File
@@ -77,7 +77,7 @@ public:
// ── Navigation (called from LVGL event callbacks) ────────────────────────
void showHome();
void setHomePage(int page);
void homeEdit(bool on); // Home's apps: drag to move, tap to hide (hold an app, or Settings > Home apps)
void homeEdit(bool on); // Home's apps: drag to move, tap to hide (hold an app)
void homeAppDrop(int from, int to);
void homeAppToggle(int pos);
void goHome(); // the side button: Home's clock page
@@ -369,6 +369,13 @@ public:
void diagReset();
void diagNoiseRun();
void showCompass();
void showGallery();
void galleryTab(int tab);
void galleryPage(int page);
void galleryOpen(int i); // a screenshot on the whole screen
void galleryStep(int d);
void galleryViewAction(uint8_t act);
void galleryTrail(int idx); // -1 = the device's own slot
void showFlashlight(); // Flashlight.h
void flashlightClose();
bool flashlightOn() const;
@@ -394,7 +401,7 @@ public:
void statusBarSync(); // up or hidden, by the screen's title bar (UITask.cpp)
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK };
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS, SCR_BATT, SCR_ROUTE, SCR_ROUTE_PICK, SCR_GALLERY };
void buildStatusBar();
void refreshStatusBar();
@@ -487,6 +494,8 @@ private:
bool navCurrentTarget(int32_t& lat, int32_t& lon, char* name, int n, bool& person, bool& set);
bool exportTrailGpx(char* name_out, size_t n);
void mapDownloadTick();
void wifiClockTick(); // the clock from NTP while WiFi is up (LvglPort.h ntpStart())
bool _ntp_on = false;
void mapLiveBegin();
void refreshDownloadPopup();
void buildWifi();
@@ -538,6 +547,10 @@ private:
void sampleHistory(); // DiagScreen.h: History's readings, from loop()
void fillHistory();
void buildCompass();
void buildGallery();
void galleryTick();
void galleryCloseView();
void galleryBack(); // the full view closes, else Home
void refreshCompass();
void favPickPopup(int slot);
void adminValuePopup();
+4 -1
View File
@@ -957,11 +957,14 @@ static int32_t s_settings_y = 0; // Settings' scroll, kept wh
// A one-of-N choice: a row (or grid, with "\n" in the map) of checkable
// buttons, `sel` checked (-1: none). `item` is the unchecked button colour —
// SURFACE on a SURFACE_2 panel, SURFACE_2 on the page.
// Picked on release over the same button (CLICK_TRIG), not on press as
// lv_buttonmatrix does by default: a swipe starting on a screen's tabs (to
// scroll the list under them back up) switched the tab.
static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, int h,
uint32_t item = theme::SURFACE_2) {
lv_obj_t* m = lv_buttonmatrix_create(parent);
lv_buttonmatrix_set_map(m, map);
lv_buttonmatrix_set_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_CHECKABLE);
lv_buttonmatrix_set_button_ctrl_all(m, (lv_buttonmatrix_ctrl_t)(LV_BUTTONMATRIX_CTRL_CHECKABLE | LV_BUTTONMATRIX_CTRL_CLICK_TRIG));
lv_buttonmatrix_set_one_checked(m, true);
if (sel >= 0) lv_buttonmatrix_set_button_ctrl(m, sel, LV_BUTTONMATRIX_CTRL_CHECKED);
lv_obj_set_size(m, w, h);
@@ -319,3 +319,28 @@ void UITask::wifiSave() {
snprintf(msg, sizeof(msg), LV_SYMBOL_REFRESH " Connecting to %s...", s_wifi_test_ssid);
wifiTestShow(msg, nullptr, nullptr);
}
// From loop(), any screen, once a second: SNTP runs while WiFi is connected
// (Settings > WiFi, a map download, an update, the app over WiFi), and each
// time it answers the clock takes the time -- ahead or behind, as from GPS.
// The first answer of a connection says so when it moved the clock.
void UITask::wifiClockTick() {
static uint32_t next_ms = 0;
static bool first = false;
if ((int32_t)(millis() - next_ms) < 0) return;
next_ms = millis() + 1000;
const bool up = lvport::netRadio() == lvport::NET_UP;
if (up != _ntp_on) {
_ntp_on = up;
if (up) lvport::ntpStart(); else lvport::ntpStop();
first = up;
}
const uint32_t t = lvport::ntpTake();
if (t > 1700000000UL) {
const uint32_t now = rtc_clock.getCurrentTime();
const bool moved = t > now + 1 || now > t + 1;
if (moved) rtc_clock.setCurrentTime(t);
if (first && moved && !_asleep) showToast("Clock set from the network");
first = false;
}
}
+3
View File
@@ -43,6 +43,9 @@ build_flags = ${esp32_base.build_flags}
-D PIN_I2S_BCK=11
-D PIN_I2S_WS=12
-D PIN_I2S_DOUT=16
; No microphone: Seeed's pin list has CODEC_I2S0_SDOUT on GPIO15, but on a
; retail unit (2026-10) it read flat -1 with the codec's ADC on, and stayed
; so with the codec's output muted -- the line isn't the codec's.
-D BUZZER_CODEC_ES8311=0x18
-D ENV_SKIP_GPS_DETECT=1 ; GPS is always present, powered via IO expander
; disable ALL env sensor I2C probes: this bus carries six onboard chips