diff --git a/docs/solo/settings.md b/docs/solo/settings.md index 64664ac4..ddfbf6ea 100644 --- a/docs/solo/settings.md +++ b/docs/solo/settings.md @@ -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), diff --git a/docs/solo/tools.md b/docs/solo/tools.md index 0cd5f951..30b17dc4 100644 --- a/docs/solo/tools.md +++ b/docs/solo/tools.md @@ -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 diff --git a/examples/companion_radio/ui-lvgl/GalleryScreen.h b/examples/companion_radio/ui-lvgl/GalleryScreen.h new file mode 100644 index 00000000..5526c000 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/GalleryScreen.h @@ -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((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((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); +} diff --git a/examples/companion_radio/ui-lvgl/HomeScreen.h b/examples/companion_radio/ui-lvgl/HomeScreen.h index f0cc4a99..ea6e6cc4 100644 --- a/examples/companion_radio/ui-lvgl/HomeScreen.h +++ b/examples/companion_radio/ui-lvgl/HomeScreen.h @@ -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; diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h index a3d8fc64..bbb77a3b 100644 --- a/examples/companion_radio/ui-lvgl/LvglPort.h +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -27,6 +27,7 @@ #include #endif #include + #include #elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__) #include #include @@ -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(); } diff --git a/examples/companion_radio/ui-lvgl/SettingsPages.h b/examples/companion_radio/ui-lvgl/SettingsPages.h index d0192ed2..8031e414 100644 --- a/examples/companion_radio/ui-lvgl/SettingsPages.h +++ b/examples/companion_radio/ui-lvgl/SettingsPages.h @@ -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); diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 530de000..af16216f 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -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; } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index e6346df8..a38d63b6 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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(); diff --git a/examples/companion_radio/ui-lvgl/Widgets.h b/examples/companion_radio/ui-lvgl/Widgets.h index 66e00cdc..025695ec 100644 --- a/examples/companion_radio/ui-lvgl/Widgets.h +++ b/examples/companion_radio/ui-lvgl/Widgets.h @@ -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); diff --git a/examples/companion_radio/ui-lvgl/WifiScreen.h b/examples/companion_radio/ui-lvgl/WifiScreen.h index fea221ba..5ee443b1 100644 --- a/examples/companion_radio/ui-lvgl/WifiScreen.h +++ b/examples/companion_radio/ui-lvgl/WifiScreen.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; + } +} diff --git a/variants/wio-tracker-l2/platformio.ini b/variants/wio-tracker-l2/platformio.ini index 142f279d..2f16b552 100644 --- a/variants/wio-tracker-l2/platformio.ini +++ b/variants/wio-tracker-l2/platformio.ini @@ -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