diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index a0135acc..9ac90eb0 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -201,7 +201,19 @@ this roadmap is done; the Cardputer follows whatever the spike shows. USB plugged in (0x40 reads 0x01). Needed for a charging indicator and for skipping the low-battery shutdown on the cable. Find the right chip / register (devices on the bus: 0x14, 0x18, 0x21, 0x22, 0x2c, - 0x34, 0x48, 0x5d). Found 2026-09-26. + 0x34, 0x48, 0x5d). Found 2026-09-26. Also wanted for a hint on the + GPS screen while on the cable (see below). +- [x] GPS screen (2026-09-26): Home > GPS and Settings > System > GPS + details -- sky plot, C/N0 bar per satellite, fix / TTFF / DOPs, + per-constellation counts (helpers/sensors/GpsSky.h, -D GPS_SKYVIEW; + the sim feeds it made-up NMEA). +- [x] GPS indoors (2026-09-26): diagnosed with the new GPS screen and raw + NMEA logs. On the USB cable (charging) the L76K tracks satellites at + 25-35 dB-Hz but never decodes navigation data: no UTC in 10 min, no + fix in over an hour. On battery, same spot by the window: UTC within + seconds, first fix after 377 s. Screen, WiFi, Bluetooth and LoRa TX + were ruled out. Cause: noise from USB power / the charger -- a board + property, not fixable in firmware. Test GPS on battery. Ideas to come back to (2026-09-26): diff --git a/examples/companion_radio/ui-lvgl/GpsScreen.h b/examples/companion_radio/ui-lvgl/GpsScreen.h new file mode 100644 index 00000000..f84c6b79 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/GpsScreen.h @@ -0,0 +1,409 @@ +#pragma once +// Home > GPS (also Settings > System > GPS details): what the receiver sees. +// A sky plot of the satellites in view (centre = overhead, rings at 30 and 60 +// degrees elevation, filled = used in the fix, colour = signal), the fix +// state with time to first fix and the dilutions of precision, and a bar per +// satellite with its C/N0 (dB-Hz) -- the number that tells a weak antenna or +// a noisy board from a sky that just isn't visible. Refreshes every second. +// +// The data is helpers/sensors/GpsSky.h (-D GPS_SKYVIEW), fed by the board's +// NMEA stream; the sim feeds it made-up NMEA so the parser runs there too. +// +// Single-TU fragment: included by ui-lvgl/UITask.cpp after CompassScreen.h. + +#include + +namespace gpsview { + +static lv_obj_t* s_sky = nullptr; // sky plot (drawn) +static lv_obj_t* s_bars = nullptr; // signal bars (drawn, as wide as the satellites need) +static lv_obj_t* s_status = nullptr; +static lv_obj_t* s_used = nullptr; +static lv_obj_t* s_info = nullptr; +static lv_obj_t* s_systems = nullptr; +static lv_obj_t* s_note = nullptr; +static lv_obj_t* s_on_btn = nullptr; +static const int SKY = 150; +static const int BAR_PITCH = 26, BAR_W = 16, BARS_H = 104; + +// Snapshot the screen draws from, sorted: used first, then by signal. +static GpsSky::Sat s_sats[GpsSky::MAX_SATS]; +static bool s_used_flag[GpsSky::MAX_SATS]; +static int s_n = 0; + +// Fixed signal colours (not the accent: the user may pick green or red). +static uint32_t snrColor(int snr) { + if (snr < 0) return theme::TEXT_MUTED; + if (snr < 20) return 0xE5534B; + if (snr < 30) return 0xE5C04B; + return 0x6FCF6F; +} + +#if defined(SIM_PLATFORM) && defined(GPS_SKYVIEW) +// The sim has no receiver: a made-up sky, sent through the real parser as +// NMEA once a second. The fix comes 20 s after the screen first asks. +static GpsSky s_sim_sky; +static void simSentence(const char* body) { + uint8_t cs = 0; + for (const char* p = body; *p; p++) cs ^= (uint8_t)*p; + char line[120]; + snprintf(line, sizeof(line), "$%s*%02X\r\n", body, cs); + for (const char* p = line; *p; p++) s_sim_sky.feed(*p); +} +static void simFeed() { + static uint32_t s_next = 0, s_t0 = 0; + uint32_t now = millis(); + if (!s_t0) s_t0 = now; + if ((int32_t)(now - s_next) < 0) return; + s_next = now + 1000; + struct SimSat { const char* talker; int prn, elev, az, snr; }; + static const SimSat SATS[] = { + { "GP", 2, 72, 40, 44 }, { "GP", 5, 48, 120, 41 }, { "GP", 12, 31, 205, 36 }, { "GP", 15, 18, 290, 27 }, + { "GP", 20, 63, 310, 43 }, { "GP", 25, 9, 75, 18 }, { "GP", 29, 22, 160, 31 }, { "GP", 44, 30, 190, 33 }, + { "BD", 7, 55, 250, 38 }, { "BD", 10, 40, 20, 35 }, { "BD", 23, 12, 140, -1 }, { "BD", 37, 67, 95, 42 }, + { "GL", 71, 35, 330, 29 }, { "GL", 72, 50, 260, 34 }, { "GL", 80, 6, 30, -1 }, + }; + const int N = sizeof(SATS) / sizeof(SATS[0]); + float t = (now - s_t0) / 1000.0f; + bool fix = t > 20; + char b[110]; + for (const char* talker : { "GP", "BD", "GL" }) { + const SimSat* mine[8]; + int k = 0; + for (int i = 0; i < N; i++) if (!strcmp(SATS[i].talker, talker)) mine[k++] = &SATS[i]; + int msgs = (k + 3) / 4; + for (int m = 0; m < msgs; m++) { + int o = snprintf(b, sizeof(b), "%sGSV,%d,%d,%02d", talker, msgs, m + 1, k); + for (int j = m * 4; j < k && j < m * 4 + 4; j++) { + const SimSat& s = *mine[j]; + int az = ((int)(s.az + t / 6) % 360); + int snr = s.snr < 0 ? -1 : s.snr - (fix ? 0 : 8) + (int)(3 * sinf(t / 3 + s.prn)); + if (snr >= 0) o += snprintf(b + o, sizeof(b) - o, ",%02d,%02d,%03d,%02d", s.prn, s.elev, az, snr); + else o += snprintf(b + o, sizeof(b) - o, ",%02d,%02d,%03d,", s.prn, s.elev, az); + } + simSentence(b); + } + } + if (fix) { + simSentence("GNGSA,A,3,02,05,12,15,20,29,,,,,,,1.6,0.9,1.3,1"); + simSentence("GNGSA,A,3,07,10,37,,,,,,,,,,1.6,0.9,1.3,4"); + simSentence("GNGSA,A,3,72,,,,,,,,,,,,1.6,0.9,1.3,2"); + simSentence("GNGGA,120000.00,5213.0000,N,02100.0000,E,1,10,0.9,112.4,M,34.5,M,,"); + simSentence("GNRMC,120000.00,A,5213.0000,N,02100.0000,E,0.4,87.0,260926,,,A"); + } else { + simSentence("GNGSA,A,1,,,,,,,,,,,,,99.9,99.9,99.9,1"); + simSentence("GNGGA,120000.00,,,,,0,00,99.9,,,,,,"); + simSentence("GNRMC,120000.00,V,,,,,,,260926,,,N"); + } +} +#endif + +// The receiver's data, or nullptr on a board without the parser. +static GpsSky* sky() { +#if defined(SEEED_WIO_TRACKER_L2) && defined(GPS_SKYVIEW) + return &gps.sky(); +#elif defined(SIM_PLATFORM) && defined(GPS_SKYVIEW) + simFeed(); + return &s_sim_sky; +#else + return nullptr; +#endif +} + +static void snapshot(GpsSky& g) { + g.expire(); + s_n = g.count; + int idx[GpsSky::MAX_SATS]; + for (int i = 0; i < s_n; i++) idx[i] = i; + // Insertion sort (<= 64): used first, then strongest; untracked last. + for (int i = 1; i < s_n; i++) { + int v = idx[i], j = i - 1; + auto key = [&](int k) { return (g.used(g.sats[k]) ? 1000 : 0) + g.sats[k].snr; }; + while (j >= 0 && key(idx[j]) < key(v)) { idx[j + 1] = idx[j]; j--; } + idx[j + 1] = v; + } + for (int i = 0; i < s_n; i++) { s_sats[i] = g.sats[idx[i]]; s_used_flag[i] = g.used(g.sats[idx[i]]); } +} + +static void dot(lv_layer_t* layer, int32_t x, int32_t y, int32_t r, uint32_t color, bool filled) { + lv_draw_rect_dsc_t d; + lv_draw_rect_dsc_init(&d); + d.radius = LV_RADIUS_CIRCLE; + d.bg_color = lv_color_hex(color); + d.bg_opa = filled ? LV_OPA_COVER : LV_OPA_TRANSP; + d.border_color = lv_color_hex(color); + d.border_width = 2; + d.border_opa = LV_OPA_COVER; + lv_area_t a = { x - r, y - r, x + r, y + r }; + lv_draw_rect(layer, &d, &a); +} + +} // namespace gpsview + +static void onOpenGps(lv_event_t* e) { (void)e; s_ui->showGps(false); } +static void onOpenGpsFromSettings(lv_event_t* e) { (void)e; s_ui->showGps(true); } +static void onGpsTurnOn(lv_event_t* e) { (void)e; s_ui->setGps(true); s_ui->refreshGps(); } + +// Sky plot: horizon ring, 30 / 60 degree rings, N-S / E-W lines, one dot per +// satellite at its azimuth (N up) and elevation (overhead in the centre). +static void onGpsSkyDraw(lv_event_t* e) { + using namespace gpsview; + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + int32_t cx = (a.x1 + a.x2) / 2, cy = (a.y1 + a.y2) / 2; + int32_t r = SKY / 2 - 10; + + lv_draw_arc_dsc_t arc; + lv_draw_arc_dsc_init(&arc); + arc.center.x = cx; arc.center.y = cy; + arc.start_angle = 0; arc.end_angle = 360; + arc.color = lv_color_hex(theme::SURFACE_2); + for (int el = 0; el < 90; el += 30) { + arc.radius = (uint16_t)(r * (90 - el) / 90); + arc.width = el == 0 ? 2 : 1; + lv_draw_arc(layer, &arc); + } + lv_draw_line_dsc_t ld; + lv_draw_line_dsc_init(&ld); + ld.color = lv_color_hex(theme::SURFACE_2); + ld.width = 1; + ld.p1.x = cx; ld.p1.y = cy - r; ld.p2.x = cx; ld.p2.y = cy + r; + lv_draw_line(layer, &ld); + ld.p1.x = cx - r; ld.p1.y = cy; ld.p2.x = cx + r; ld.p2.y = cy; + lv_draw_line(layer, &ld); + + lv_draw_label_dsc_t td; + lv_draw_label_dsc_init(&td); + td.font = THEME_FONT_SMALL; + td.align = LV_TEXT_ALIGN_CENTER; + static const char* CARD[4] = { "N", "E", "S", "W" }; + int32_t lh = lv_font_get_line_height(td.font); + for (int i = 0; i < 4; i++) { + float ang = i * (float)M_PI / 2; + int32_t px = cx + (int32_t)(sinf(ang) * (r + 1)), py = cy - (int32_t)(cosf(ang) * (r + 1)); + td.text = CARD[i]; + td.color = lv_color_hex(i == 0 ? theme::ACCENT : theme::TEXT_MUTED); + lv_area_t la = { px - 8, py - lh / 2, px + 8, py + lh / 2 }; + lv_draw_rect_dsc_t bg; // a patch of background so the ring doesn't cross the letter + lv_draw_rect_dsc_init(&bg); + bg.bg_color = lv_color_hex(theme::BG); + bg.radius = 4; + lv_area_t ba = { px - 6, py - lh / 2 + 1, px + 6, py + lh / 2 - 1 }; + lv_draw_rect(layer, &bg, &ba); + lv_draw_label(layer, &td, &la); + } + + // Unused first, so the used ones sit on top where they overlap. + for (int pass = 0; pass < 2; pass++) { + for (int i = 0; i < s_n; i++) { + const GpsSky::Sat& s = s_sats[i]; + if (s.elev < 0 || s.azim < 0 || s_used_flag[i] != (pass == 1)) continue; + float rr = r * (90 - (s.elev > 90 ? 90 : s.elev)) / 90.0f; + float ang = s.azim * (float)M_PI / 180.0f; + dot(layer, cx + (int32_t)(sinf(ang) * rr), cy - (int32_t)(cosf(ang) * rr), 5, snrColor(s.snr), s_used_flag[i]); + } + } +} + +// One bar per satellite: C/N0 on top, the bar (solid = used in the fix), the +// constellation letter and number under it. 50 dB-Hz fills the height. +static void onGpsBarsDraw(lv_event_t* e) { + using namespace gpsview; + lv_obj_t* o = (lv_obj_t*)lv_event_get_target(e); + lv_layer_t* layer = lv_event_get_layer(e); + lv_area_t a; + lv_obj_get_coords(o, &a); + lv_draw_label_dsc_t td; + lv_draw_label_dsc_init(&td); + td.font = THEME_FONT_SMALL; + td.align = LV_TEXT_ALIGN_CENTER; + int32_t lh = lv_font_get_line_height(td.font); + int32_t base = a.y2 - lh - 2, top = a.y1 + lh + 2, full = base - top; + + lv_draw_line_dsc_t ld; // the 30 dB-Hz line: above it, a good signal + lv_draw_line_dsc_init(&ld); + ld.color = lv_color_hex(theme::SURFACE_2); + ld.width = 1; + ld.dash_width = 3; ld.dash_gap = 3; + ld.p1.x = a.x1; ld.p2.x = a.x2; + ld.p1.y = ld.p2.y = base - full * 30 / 50; + lv_draw_line(layer, &ld); + + for (int i = 0; i < s_n; i++) { + const GpsSky::Sat& s = s_sats[i]; + int32_t x = a.x1 + i * BAR_PITCH + (BAR_PITCH - BAR_W) / 2; + int snr = s.snr < 0 ? 0 : (s.snr > 50 ? 50 : s.snr); + int32_t h = full * snr / 50; + if (h < 2) h = 2; + lv_draw_rect_dsc_t d; + lv_draw_rect_dsc_init(&d); + d.radius = 3; + d.bg_color = lv_color_hex(snrColor(s.snr)); + d.bg_opa = s_used_flag[i] ? LV_OPA_COVER : LV_OPA_30; + d.border_color = d.bg_color; + d.border_width = s_used_flag[i] ? 0 : 1; + lv_area_t ba = { x, base - h, x + BAR_W - 1, base }; + lv_draw_rect(layer, &d, &ba); + + char t[8]; + if (s.snr >= 0) { + snprintf(t, sizeof(t), "%d", s.snr); + td.text = t; + td.color = lv_color_hex(theme::TEXT); + lv_area_t va = { x - 6, base - h - lh - 1, x + BAR_W + 5, base - h - 1 }; + lv_draw_label(layer, &td, &va); + } + char p[8]; + snprintf(p, sizeof(p), "%c%d", GpsSky::sysLetter(s.sys), s.prn); + td.text = p; + td.color = lv_color_hex(s_used_flag[i] ? theme::TEXT : theme::TEXT_MUTED); + lv_area_t pa = { x - (BAR_PITCH - BAR_W) / 2, base + 2, x + BAR_W + (BAR_PITCH - BAR_W) / 2 - 1, base + 2 + lh }; + lv_draw_label(layer, &td, &pa); + } +} + +void UITask::showGps(bool from_settings) { + _gps_from_settings = from_settings; + _screen = SCR_GPS; + buildGps(); +} + +void UITask::buildGps() { + using namespace gpsview; + lv_obj_t* body = newScreen("GPS", true); + s_sky = s_bars = s_status = s_used = s_info = s_systems = s_note = s_on_btn = nullptr; + + // Sky plot left, the fix right. + lv_obj_t* top = lv_obj_create(body); + lv_obj_remove_style_all(top); + lv_obj_set_size(top, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(top, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(top, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_column(top, theme::GAP, 0); + s_sky = lv_obj_create(top); + styleSurface(s_sky, theme::BG); + lv_obj_remove_flag(s_sky, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_remove_flag(s_sky, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(s_sky, SKY, SKY); + lv_obj_add_event_cb(s_sky, onGpsSkyDraw, LV_EVENT_DRAW_MAIN_END, NULL); + + lv_obj_t* col = lv_obj_create(top); + lv_obj_remove_style_all(col); + lv_obj_set_flex_grow(col, 1); + lv_obj_set_height(col, LV_SIZE_CONTENT); + lv_obj_set_flex_flow(col, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(col, 2, 0); + lv_obj_set_style_pad_top(col, 4, 0); + s_status = label(col, "", THEME_FONT_LARGE, theme::TEXT); + s_used = label(col, "", THEME_FONT_BODY, theme::TEXT); + s_info = label(col, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_style_text_line_space(s_info, 2, 0); + for (lv_obj_t* l : { s_used, s_info }) { + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + } + s_on_btn = lv_button_create(col); + lv_obj_set_height(s_on_btn, 34); + lv_obj_set_style_radius(s_on_btn, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(s_on_btn, 0, 0); + stylePrimary(s_on_btn); + lv_obj_center(label(s_on_btn, LV_SYMBOL_GPS " Turn on", THEME_FONT_BODY, theme::TEXT)); + lv_obj_add_event_cb(s_on_btn, onGpsTurnOn, LV_EVENT_CLICKED, NULL); + lv_obj_add_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN); + + sectionTitle(body, "SIGNAL (dB-Hz)"); + lv_obj_t* sc = lv_obj_create(body); // scrolls sideways when the satellites don't fit + styleSurface(sc, theme::BG); + lv_obj_set_size(sc, LV_PCT(100), BARS_H + 8); + lv_obj_set_scroll_dir(sc, LV_DIR_HOR); + lv_obj_set_scrollbar_mode(sc, LV_SCROLLBAR_MODE_AUTO); + s_bars = lv_obj_create(sc); + lv_obj_remove_style_all(s_bars); + lv_obj_remove_flag(s_bars, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_flag(s_bars, LV_OBJ_FLAG_EVENT_BUBBLE); + lv_obj_set_size(s_bars, BAR_PITCH, BARS_H); + lv_obj_add_event_cb(s_bars, onGpsBarsDraw, LV_EVENT_DRAW_MAIN_END, NULL); + + s_systems = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(s_systems, LV_LABEL_LONG_WRAP); + lv_obj_set_width(s_systems, LV_PCT(100)); + s_note = label(body, "Solid = used in the fix. Green is a strong signal (30+ dB-Hz); " + "indoors, below 25 a fix is unlikely.", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(s_note, LV_LABEL_LONG_WRAP); + lv_obj_set_width(s_note, LV_PCT(100)); + refreshGps(); +} + +// From loop(), once a second. +void UITask::refreshGps() { + using namespace gpsview; + if (_screen != SCR_GPS || !s_status) return; + GpsSky* g = sky(); + bool off = _core->gpsAvailable() && !_core->gpsEnabled(); + bool data = g && g->last_ms && millis() - g->last_ms < 3000; + if (off || !data) s_n = 0; + else snapshot(*g); + if (off) lv_obj_remove_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN); + else lv_obj_add_flag(s_on_btn, LV_OBJ_FLAG_HIDDEN); + + char used[48] = "", info[200] = ""; + if (!g) { + lv_label_set_text(s_status, "Not available"); + lv_obj_set_style_text_color(s_status, lv_color_hex(theme::TEXT_MUTED), 0); + } else if (off) { + lv_label_set_text(s_status, "GPS is off"); + lv_obj_set_style_text_color(s_status, lv_color_hex(theme::TEXT_MUTED), 0); + } else if (!data) { + lv_label_set_text(s_status, "No data"); + lv_obj_set_style_text_color(s_status, lv_color_hex(theme::FAIL), 0); +#if defined(SEEED_WIO_TRACKER_L2) + snprintf(info, sizeof(info), "Nothing from the receiver\n(%lu bytes so far)", (unsigned long)gps.rxChars()); +#else + snprintf(info, sizeof(info), "Nothing from the receiver"); +#endif + } else { + int tracked = 0, used_n = 0; + for (int i = 0; i < s_n; i++) { if (s_sats[i].snr >= 0) tracked++; if (s_used_flag[i]) used_n++; } + if (g->hasFix()) { + lv_label_set_text(s_status, g->fix_mode == 2 ? "2D fix" : "3D fix"); + lv_obj_set_style_text_color(s_status, lv_color_hex(0x6FCF6F), 0); + } else { + lv_label_set_text(s_status, "Searching"); + lv_obj_set_style_text_color(s_status, lv_color_hex(theme::ACCENT), 0); + } + snprintf(used, sizeof(used), "%d of %d used", g->hasFix() ? (used_n ? used_n : g->sats_used) : 0, s_n); + int o = snprintf(info, sizeof(info), "%d with a signal\n", tracked); + uint32_t since = g->start_ms ? (millis() - g->start_ms) / 1000 : 0; + if (g->ttff_ms) o += snprintf(info + o, sizeof(info) - o, "First fix after %lu s\n", (unsigned long)(g->ttff_ms / 1000)); + else o += snprintf(info + o, sizeof(info) - o, "Searching for %lu:%02lu\n", (unsigned long)(since / 60), (unsigned long)(since % 60)); + if (g->hasFix() && g->hdop > 0) + o += snprintf(info + o, sizeof(info) - o, "DOP H %.1f V %.1f P %.1f\n", g->hdop, g->vdop, g->pdop); + int32_t lat, lon; + if (g->hasFix() && _core->course.currentLocation(lat, lon)) + o += snprintf(info + o, sizeof(info) - o, "%.5f, %.5f\n", lat / 1e6, lon / 1e6); + if (g->hasFix() && g->alt_valid) + o += snprintf(info + o, sizeof(info) - o, "%.0f m - %.1f km/h\n", g->alt_m, g->speed_kmh); + if (g->utc_valid) o += snprintf(info + o, sizeof(info) - o, "UTC %02u:%02u:%02u", g->utc_h, g->utc_m, g->utc_s); + } + lv_label_set_text(s_used, used); + lv_label_set_text(s_info, info); + + // Per constellation: used / in view. + char sys[160] = ""; + int o = 0; + for (uint8_t k = 0; k < GpsSky::SYS_COUNT; k++) { + int inview = 0, u = 0; + for (int i = 0; i < s_n; i++) if (s_sats[i].sys == k) { inview++; if (s_used_flag[i]) u++; } + if (inview) o += snprintf(sys + o, sizeof(sys) - o, "%s%c %s %d/%d", o ? " " : "", GpsSky::sysLetter(k), + GpsSky::sysName(k), u, inview); + } + lv_label_set_text(s_systems, sys); + + int32_t w = s_n * BAR_PITCH; + if (w < BAR_PITCH) w = BAR_PITCH; + lv_obj_set_width(s_bars, w); + lv_obj_invalidate(s_sky); + lv_obj_invalidate(s_bars); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 24683911..f184efe7 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -387,6 +387,7 @@ void UITask::loop() { if (_screen == SCR_HOME) refreshHome(); refreshDiag(); refreshCompass(); + refreshGps(); } if (locked()) lockPoll(); else if (_screen == SCR_HOME) homeSwipePoll(); @@ -770,7 +771,7 @@ int UITask::screenDepth(Screen s) { case SCR_HOME: return 0; case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS: case SCR_FAVS: case SCR_COMPASS: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK: - case SCR_DIAG: return 1; + 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; default: return 3; // pages under a settings page @@ -789,6 +790,7 @@ void UITask::back() { case SCR_ADMIN_PICK: showHome(); break; case SCR_OTA: otaLeave(); break; case SCR_COMPASS: showHome(); break; + case SCR_GPS: if (_gps_from_settings) showSettings(); else showHome(); break; case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break; case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SETTINGS_NAV: // the map's options go back to the map @@ -859,6 +861,8 @@ static void onOpenBot(lv_event_t* e); // BotScreen.h static void onOpenFavourites(lv_event_t* e); // DeviceScreen.h static void onOpenCompass(lv_event_t* e); // CompassScreen.h static void onOpenDiag(lv_event_t* e); // DiagScreen.h +static void onOpenGps(lv_event_t* e); // GpsScreen.h +static void onOpenGpsFromSettings(lv_event_t* e); static void onOpenRepeater(lv_event_t* e); // RepeaterScreen.h static void onOpenAdminPick(lv_event_t* e); // AdminScreen.h static void onNodeName(lv_event_t* e); @@ -883,6 +887,7 @@ static const App APPS[] = { { LV_SYMBOL_LOOP, "Repeater", onOpenRepeater, false }, { UI_SYMBOL_KEY, "Admin", onOpenAdminPick, false }, { UI_SYMBOL_CHART, "Diagnostics", onOpenDiag, false }, + { LV_SYMBOL_GPS, "GPS", onOpenGps, false }, }; static const int COUNT = sizeof(APPS) / sizeof(APPS[0]); static const int PER_PAGE = 4; @@ -2515,6 +2520,7 @@ void UITask::buildSettings() { lv_obj_t* sw = switchRow(body, "GPS", "For maps and sharing", nullptr); if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED); lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL); + listRow(body, LV_SYMBOL_GPS " GPS details", "Satellites, signal, sky view", onOpenGpsFromSettings, NULL); } listRow(body, LV_SYMBOL_SD_CARD " Storage", "SD card, message history", onOpenStorage, NULL); listRow(body, LV_SYMBOL_REFRESH " Reboot", NULL, onPowerRow, (void*)(uintptr_t)1); @@ -2559,6 +2565,7 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { #include "DeviceScreen.h" #include "DiagScreen.h" #include "CompassScreen.h" +#include "GpsScreen.h" #include "RadioExtras.h" #include "RepeaterScreen.h" #include "SoundScreen.h" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 755e9b5b..41d8e964 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -217,6 +217,9 @@ public: void navFrameTrail(); // Settings > Storage (StorageScreen.h) void showStorage(); + void showGps(bool from_settings); + void buildGps(); + void refreshGps(); void buildStorage(); void pollStorage(); void storageKeep(int idx); @@ -249,7 +252,7 @@ public: void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; } 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_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE }; + 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_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS }; void buildStatusBar(); void refreshStatusBar(); @@ -371,6 +374,7 @@ private: DisplayDriver* _display = nullptr; SensorManager* _sensors = nullptr; NodePrefs* _prefs = nullptr; + bool _gps_from_settings = false; // GPS screen: Back returns there (else Home) UiCore* _core = nullptr; Screen _screen = SCR_HOME; diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index 42d240d4..6c684390 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -69,6 +69,7 @@ build_flags = -D HIST_DM_MAX=128 ; all DMs together, ~27 KB -D NEARBY_MAX=64 -D HIST_ARCHIVE ; message history also on the SD card (ui-lvgl/HistoryStore.h) + -D GPS_SKYVIEW ; satellites / signal / DOPs for the GPS screen (helpers/sensors/GpsSky.h) build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + diff --git a/src/helpers/sensors/GpsSky.h b/src/helpers/sensors/GpsSky.h new file mode 100644 index 00000000..0e861b11 --- /dev/null +++ b/src/helpers/sensors/GpsSky.h @@ -0,0 +1,213 @@ +#pragma once +// What the GPS receiver sees, for a status screen: every satellite in view +// (constellation, elevation, azimuth, signal) and which ones the fix uses, +// the dilutions of precision, fix type, speed and course. Fed the same NMEA +// characters MicroNMEA gets (which only keeps the fix itself), parsing +// GSV / GSA / GGA / RMC from any talker (GP, GL, GA, GB/BD, GQ, GN). +// +// Opt-in per board (-D GPS_SKYVIEW, used by MicroNMEALocationProvider), so +// boards without a screen for it don't carry the ~1.5 KB table. + +#include +#include +#include +#include + +class GpsSky { +public: + enum : uint8_t { SYS_GPS, SYS_GLONASS, SYS_GALILEO, SYS_BEIDOU, SYS_QZSS, SYS_SBAS, SYS_OTHER, SYS_COUNT }; + struct Sat { + uint16_t prn; + uint8_t sys; + int8_t elev; // degrees, -1 = not reported + int16_t azim; // degrees, -1 = not reported + int8_t snr; // C/N0 in dB-Hz, -1 = not tracked + uint32_t seen_ms; // last GSV mentioning it + uint32_t used_ms; // last GSA listing it in the fix (0 = never) + }; + static const int MAX_SATS = 64; + static const uint32_t SAT_TIMEOUT_MS = 10000; // gone from GSV this long: out of view + static const uint32_t USED_TIMEOUT_MS = 4000; + + Sat sats[MAX_SATS]; + int count = 0; + uint8_t fix_quality = 0; // GGA: 0 none, 1 GPS, 2 DGPS, 4/5 RTK, 6 dead reckoning + uint8_t fix_mode = 0; // GSA: 0 unknown, 1 no fix, 2 2D, 3 3D + uint8_t sats_used = 0; // GGA + float pdop = 0, hdop = 0, vdop = 0; // 0 = unknown + float alt_m = 0; + bool alt_valid = false; + float speed_kmh = 0, course_deg = 0; + bool rmc_valid = false; // RMC status A + uint8_t utc_h = 0, utc_m = 0, utc_s = 0; + bool utc_valid = false; + uint32_t sentences = 0, bad_sentences = 0; + uint32_t last_ms = 0; // last good sentence + uint32_t start_ms = 0; // first sentence since reset(): time to first fix counts from here + uint32_t ttff_ms = 0; // 0 = no fix yet + + // The receiver was (re)started: forget everything, restart the TTFF clock. + void reset() { + count = 0; + fix_quality = fix_mode = sats_used = 0; + pdop = hdop = vdop = 0; + alt_valid = rmc_valid = utc_valid = false; + start_ms = ttff_ms = 0; + _len = 0; + } + + void feed(char c) { + if (c == '$') { _len = 0; _buf[_len++] = c; return; } + if (_len == 0) return; + if (c == '\r' || c == '\n') { _buf[_len] = '\0'; line(); _len = 0; return; } + if (_len < sizeof(_buf) - 1) _buf[_len++] = c; + else _len = 0; // overlong: drop it + } + + bool used(const Sat& s) const { return s.used_ms && millis() - s.used_ms < USED_TIMEOUT_MS; } + bool hasFix() const { return fix_quality > 0 || fix_mode >= 2; } + + // Drop satellites no longer reported (call before reading the table). + void expire() { + uint32_t now = millis(); + int k = 0; + for (int i = 0; i < count; i++) if (now - sats[i].seen_ms < SAT_TIMEOUT_MS) sats[k++] = sats[i]; + count = k; + } + + static char sysLetter(uint8_t sys) { + static const char L[SYS_COUNT] = { 'G', 'R', 'E', 'C', 'J', 'S', '?' }; + return sys < SYS_COUNT ? L[sys] : '?'; + } + static const char* sysName(uint8_t sys) { + static const char* const N[SYS_COUNT] = { "GPS", "GLONASS", "Galileo", "BeiDou", "QZSS", "SBAS", "Other" }; + return sys < SYS_COUNT ? N[sys] : "Other"; + } + +private: + char _buf[100]; + size_t _len = 0; + uint32_t _gsa_ms = 0; + + static int hexVal(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; + } + + static uint8_t talkerSys(const char* t) { + if (t[0] == 'G' && t[1] == 'P') return SYS_GPS; + if (t[0] == 'G' && t[1] == 'L') return SYS_GLONASS; + if (t[0] == 'G' && t[1] == 'A') return SYS_GALILEO; + if ((t[0] == 'G' && t[1] == 'B') || (t[0] == 'B' && t[1] == 'D')) return SYS_BEIDOU; + if ((t[0] == 'G' && t[1] == 'Q') || (t[0] == 'Q' && t[1] == 'Z')) return SYS_QZSS; + return SYS_OTHER; // GN: mixed, decided per PRN + } + // NMEA 4.1 system ID (GSA field 18). + static uint8_t idSys(int id) { + switch (id) { + case 1: return SYS_GPS; case 2: return SYS_GLONASS; case 3: return SYS_GALILEO; + case 4: return SYS_BEIDOU; case 5: return SYS_QZSS; default: return SYS_OTHER; + } + } + // The classic NMEA numbering, when the talker doesn't say. + static uint8_t prnSys(int prn) { + if (prn >= 1 && prn <= 32) return SYS_GPS; + if (prn >= 33 && prn <= 64) return SYS_SBAS; + if (prn >= 65 && prn <= 96) return SYS_GLONASS; + if (prn >= 193 && prn <= 200) return SYS_QZSS; + if (prn >= 201 && prn <= 263) return SYS_BEIDOU; + if (prn >= 301 && prn <= 336) return SYS_GALILEO; + return SYS_OTHER; + } + + Sat* find(uint8_t sys, int prn) { + for (int i = 0; i < count; i++) if (sats[i].prn == prn && sats[i].sys == sys) return &sats[i]; + return nullptr; + } + + void line() { + // "$TTSSS,...*CS": checksum over everything between '$' and '*'. + char* star = strrchr(_buf, '*'); + if (!star || star - _buf < 7) { bad_sentences++; return; } + uint8_t cs = 0; + for (char* p = _buf + 1; p < star; p++) cs ^= (uint8_t)*p; + int h = hexVal(star[1]), l = hexVal(star[2]); + if (h < 0 || l < 0 || cs != (uint8_t)(h << 4 | l)) { bad_sentences++; return; } + *star = '\0'; + + char* f[24]; + int nf = 0; + for (char* p = _buf + 1; nf < 24;) { + f[nf++] = p; + char* comma = strchr(p, ','); + if (!comma) break; + *comma = '\0'; + p = comma + 1; + } + if (strlen(f[0]) != 5) { bad_sentences++; return; } + sentences++; + uint32_t now = millis(); + last_ms = now; + if (!start_ms) start_ms = now ? now : 1; + const char* type = f[0] + 2; + uint8_t tsys = talkerSys(f[0]); + + if (!strcmp(type, "GSV")) { + // total, index, in view, then (prn, elevation, azimuth, snr) x up to 4 [, signal id] + for (int g = 4; g + 3 < nf; g += 4) { // whole groups only: a lone trailing field is the signal id + if (!f[g][0]) continue; + int prn = atoi(f[g]); + if (prn <= 0) continue; + uint8_t sys = tsys == SYS_OTHER ? prnSys(prn) : (tsys == SYS_GPS && prn >= 33 && prn <= 64 ? SYS_SBAS : tsys); + Sat* s = find(sys, prn); + if (!s) { + if (count >= MAX_SATS) { expire(); if (count >= MAX_SATS) continue; } + s = &sats[count++]; + memset(s, 0, sizeof(*s)); + s->prn = (uint16_t)prn; + s->sys = sys; + } + s->elev = f[g + 1][0] ? (int8_t)atoi(f[g + 1]) : -1; + s->azim = f[g + 2][0] ? (int16_t)atoi(f[g + 2]) : -1; + s->snr = f[g + 3][0] ? (int8_t)atoi(f[g + 3]) : -1; + s->seen_ms = now; + } + } else if (!strcmp(type, "GSA")) { + // mode, fix (1/2/3), 12 PRNs, PDOP, HDOP, VDOP [, system id] + _gsa_ms = now; + if (nf > 2) fix_mode = (uint8_t)atoi(f[2]); + if (nf > 15 && f[15][0]) pdop = atof(f[15]); + if (nf > 16 && f[16][0]) hdop = atof(f[16]); + if (nf > 17 && f[17][0]) vdop = atof(f[17]); + uint8_t sys = nf > 18 && f[18][0] ? idSys(atoi(f[18])) : tsys; + for (int i = 3; i <= 14 && i < nf; i++) { + if (!f[i][0]) continue; + int prn = atoi(f[i]); + Sat* s = find(sys == SYS_OTHER ? prnSys(prn) : sys, prn); + if (!s && sys == SYS_GPS) s = find(SYS_SBAS, prn); + if (s) s->used_ms = now; + } + } else if (!strcmp(type, "GGA")) { + // time, lat, N, lon, E, quality, used, HDOP, altitude, M, ... + if (nf > 6) fix_quality = (uint8_t)atoi(f[6]); + if (nf > 7) sats_used = (uint8_t)atoi(f[7]); + if (nf > 8 && f[8][0] && now - _gsa_ms > 3000) hdop = atof(f[8]); // GSA's is finer + alt_valid = nf > 9 && f[9][0] && fix_quality > 0; + if (alt_valid) alt_m = atof(f[9]); + if (fix_quality > 0 && !ttff_ms) ttff_ms = now - start_ms + 1; + } else if (!strcmp(type, "RMC")) { + // time, status, lat, N, lon, E, speed (knots), course, date, ... + if (nf > 1 && strlen(f[1]) >= 6) { + utc_h = (uint8_t)((f[1][0] - '0') * 10 + f[1][1] - '0'); + utc_m = (uint8_t)((f[1][2] - '0') * 10 + f[1][3] - '0'); + utc_s = (uint8_t)((f[1][4] - '0') * 10 + f[1][5] - '0'); + utc_valid = true; + } + rmc_valid = nf > 2 && f[2][0] == 'A'; + speed_kmh = nf > 7 && f[7][0] ? atof(f[7]) * 1.852f : 0; + course_deg = nf > 8 && f[8][0] ? atof(f[8]) : 0; + } + } +}; diff --git a/src/helpers/sensors/MicroNMEALocationProvider.h b/src/helpers/sensors/MicroNMEALocationProvider.h index e3855011..c7a4a7ee 100644 --- a/src/helpers/sensors/MicroNMEALocationProvider.h +++ b/src/helpers/sensors/MicroNMEALocationProvider.h @@ -4,6 +4,9 @@ #include #include #include +#ifdef GPS_SKYVIEW + #include "GpsSky.h" +#endif #ifndef GPS_EN #ifdef PIN_GPS_EN @@ -40,6 +43,9 @@ class MicroNMEALocationProvider : public LocationProvider { char _nmeaBuffer[100]; uint32_t _rx_chars = 0; +#ifdef GPS_SKYVIEW + GpsSky _sky; +#endif MicroNMEA nmea; mesh::RTCClock* _clock; Stream* _gps_serial; @@ -77,6 +83,9 @@ public : } void begin() override { +#ifdef GPS_SKYVIEW + _sky.reset(); // restarts the time-to-first-fix clock +#endif claim(); if (_pin_en != -1) { digitalWrite(_pin_en, GPS_EN_ACTIVE); @@ -126,6 +135,10 @@ public : // Bytes read from the receiver so far: still at 0 (or not moving) = no NMEA // arriving at all, as opposed to NMEA without a fix (a UI diagnostics row). uint32_t rxChars() const { return _rx_chars; } +#ifdef GPS_SKYVIEW + // Satellites in view, signal, DOPs (a GPS status screen). + GpsSky& sky() { return _sky; } +#endif long getHDOP() override { return nmea.getHDOP(); } bool isValid() override { return nmea.isValid(); } @@ -147,6 +160,9 @@ public : Serial.print(c); #endif nmea.process(c); +#ifdef GPS_SKYVIEW + _sky.feed(c); +#endif } if (!isValid()) time_valid = 0; diff --git a/variants/sim/build_wasm.sh b/variants/sim/build_wasm.sh index dee96276..dfe440b0 100755 --- a/variants/sim/build_wasm.sh +++ b/variants/sim/build_wasm.sh @@ -184,7 +184,7 @@ if [ "$SIM_UI" = "lvgl" ]; then done # The L2's PSRAM-sized limits (solo/wio-tracker-l2/platformio.ini). DEFINES=("${kept[@]}" -DDISPLAY_CLASS=SimLcdDisplay -DMAX_GROUP_CHANNELS=40 -DUI_ZOOM=1 -DLV_CONF_INCLUDE_SIMPLE - -DTRAIL_CAPACITY=4096 -DWAYPOINT_CAPACITY=64 -DHIST_CH_MAX=256 -DHIST_DM_MAX=128 -DNEARBY_MAX=64 -DHIST_ARCHIVE) + -DTRAIL_CAPACITY=4096 -DWAYPOINT_CAPACITY=64 -DHIST_CH_MAX=256 -DHIST_DM_MAX=128 -DNEARBY_MAX=64 -DHIST_ARCHIVE -DGPS_SKYVIEW) INCLUDES+=("-I$LVGL_DIR") LINK_EXTRA=(-sFETCH=1) # map downloads (LvglPort.h) go through emscripten_fetch fi