feat(ui-new): Tools > Satellites -- sky plot and signal bars on the 128x64 OLED

Two views, swapped with left / right: a polar sky plot (filled = used in the
fix, hollow = tracked, a pixel = no signal) beside fix, used / in view, HDOP,
PDOP and time to first fix; and a signal bar per satellite (used first, a
dotted 30 dB-Hz line) under per-constellation counts. The GPS stays awake
while it's open. Data from GpsSky (-D GPS_SKYVIEW, now on the L1 solo build).
The simulator's made-up sky moves from the L2's GpsScreen into
helpers/sensors/GpsSkySim.h, so both sims show it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-28 16:33:41 +02:00
co-authored by Claude Opus 5.5
parent e82cedaf23
commit 95c4c093f0
8 changed files with 302 additions and 61 deletions
+2 -58
View File
@@ -44,62 +44,7 @@ static uint32_t snrColor(int snr) {
}
#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");
}
}
#include <helpers/sensors/GpsSkySim.h>
#endif
// The receiver's data, or nullptr on a board without the parser.
@@ -107,8 +52,7 @@ 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;
return gpsSkySim();
#else
return nullptr;
#endif
@@ -0,0 +1,212 @@
#pragma once
// Tools › Satellites (-D GPS_SKYVIEW): what the GPS receiver sees, in two
// views swapped with left / right (or Enter):
// Sky -- a polar plot (the rim is the horizon, the centre straight up,
// north at the top): a filled dot is a satellite the fix uses, a
// hollow one is tracked but unused, a single pixel is in view with
// no signal. Beside it: fix, used / in view, HDOP, PDOP and the
// time to first fix (or how long it's been looking).
// Signal -- a bar per satellite, its height the signal (C/N0, 50 dB-Hz =
// full), filled when used; a dotted line marks 30 dB-Hz (good).
// The top line counts used / in view per constellation.
//
// The data is helpers/sensors/GpsSky.h, fed the board's NMEA by
// MicroNMEALocationProvider (a made-up sky in the simulator: GpsSkySim.h).
// The GPS is kept awake while the screen is open (UITask's keep-awake list).
#include <math.h>
#include <helpers/sensors/GpsSky.h>
#ifdef SIM_PLATFORM
#include <helpers/sensors/GpsSkySim.h>
#else
#include <helpers/sensors/MicroNMEALocationProvider.h>
#endif
#include "icons.h" // miniIconScale()
class SatellitesScreen : public UIScreen {
UITask* _task;
bool _signal = false; // false: the sky view
// The board's location provider is a MicroNMEALocationProvider wherever
// GPS_SKYVIEW is set (the flag's contract, as on the L2).
static GpsSky* sky() {
#ifdef SIM_PLATFORM
return gpsSkySim();
#else
LocationProvider* p = sensors.getLocationProvider();
return p ? &((MicroNMEALocationProvider*)p)->sky() : nullptr;
#endif
}
static void dot(DisplayDriver& d, int x, int y) { d.fillRect(x, y, 1, 1); }
// Midpoint circle; `step` > 1 dots it (every step-th point of each octant).
static void circle(DisplayDriver& d, int cx, int cy, int r, int step = 1) {
int x = r, y = 0, err = 1 - r, i = 0;
while (x >= y) {
if (i++ % step == 0) {
dot(d, cx + x, cy + y); dot(d, cx - x, cy + y); dot(d, cx + x, cy - y); dot(d, cx - x, cy - y);
dot(d, cx + y, cy + x); dot(d, cx - y, cy + x); dot(d, cx + y, cy - x); dot(d, cx - y, cy - x);
}
y++;
if (err < 0) err += 2 * y + 1;
else { x--; err += 2 * (y - x) + 1; }
}
}
static int usedCount(const GpsSky& g) {
int n = 0;
for (int i = 0; i < g.count; i++) if (g.used(g.sats[i])) n++;
return n;
}
void renderSky(DisplayDriver& d, GpsSky& g, int top) {
const int lh = d.getLineHeight();
const int cw = d.getCharWidth();
const int s = miniIconScale(d);
const int r = (d.height() - 1 - top) / 2 - 1;
const int cx = r + 1, cy = top + r + 1;
circle(d, cx, cy, r); // the horizon
circle(d, cx, cy, r / 2, 2); // 45 degrees up, dotted
dot(d, cx, cy); // straight up
d.setColor(DisplayDriver::DARK); // north, on a gap in the rim
d.fillRect(cx - cw / 2 - 1, cy - r - lh / 2, cw + 2, lh);
d.setColor(DisplayDriver::LIGHT);
d.setCursor(cx - cw / 2, cy - r - lh / 2);
d.print("N");
for (int pass = 0; pass < 2; pass++) { // the used ones last, on top
for (int i = 0; i < g.count; i++) {
const GpsSky::Sat& sat = g.sats[i];
bool used = g.used(sat);
if (sat.elev < 0 || sat.azim < 0 || used != (pass == 1)) continue;
float rr = r * (90 - sat.elev) / 90.0f;
float a = sat.azim * (float)M_PI / 180.0f;
int x = cx + (int)lroundf(rr * sinf(a)), y = cy - (int)lroundf(rr * cosf(a));
if (used) {
d.fillRect(x - s, y - s, 2 * s + 1, 2 * s + 1);
} else if (sat.snr >= 0) {
d.setColor(DisplayDriver::DARK); // clear inside, over a ring
d.fillRect(x - s, y - s, 2 * s + 1, 2 * s + 1);
d.setColor(DisplayDriver::LIGHT);
d.drawRect(x - s, y - s, 2 * s + 1, 2 * s + 1);
} else {
dot(d, x, y);
}
}
}
// The numbers, right of the plot.
const int x0 = 2 * r + 6, step = lh + 1;
int y = top;
char buf[24];
d.setCursor(x0, y);
d.print(g.fix_mode == 3 ? "3D fix" : g.hasFix() ? "2D fix" : "No fix");
y += step;
snprintf(buf, sizeof(buf), "Sats %d/%d", usedCount(g), g.count);
d.setCursor(x0, y); d.print(buf);
y += step;
if (g.hdop > 0 && g.hdop < 99) snprintf(buf, sizeof(buf), "HDOP %.1f", g.hdop);
else snprintf(buf, sizeof(buf), "HDOP -");
d.setCursor(x0, y); d.print(buf);
y += step;
if (g.pdop > 0 && g.pdop < 99) snprintf(buf, sizeof(buf), "PDOP %.1f", g.pdop);
else snprintf(buf, sizeof(buf), "PDOP -");
d.setCursor(x0, y); d.print(buf);
y += step;
if (g.ttff_ms) snprintf(buf, sizeof(buf), "TTFF %lus", (unsigned long)(g.ttff_ms / 1000));
else if (g.start_ms) snprintf(buf, sizeof(buf), "Wait %lus", (unsigned long)((millis() - g.start_ms) / 1000));
else buf[0] = 0;
d.setCursor(x0, y); d.print(buf);
}
void renderSignal(DisplayDriver& d, GpsSky& g, int top) {
const int lh = d.getLineHeight();
const int W = d.width();
// Used / in view per constellation, the ones with any in view.
char line[40];
int o = 0;
for (uint8_t sys = 0; sys < GpsSky::SYS_COUNT && o < (int)sizeof(line) - 8; sys++) {
int seen = 0, used = 0;
for (int i = 0; i < g.count; i++) {
if (g.sats[i].sys != sys) continue;
seen++;
if (g.used(g.sats[i])) used++;
}
if (seen) o += snprintf(line + o, sizeof(line) - o, "%s%c%d/%d", o ? " " : "", GpsSky::sysLetter(sys), used, seen);
}
d.drawTextEllipsized(0, top, W, o ? line : "None in view");
// Used first, then the strongest.
int idx[GpsSky::MAX_SATS];
int n = g.count;
for (int i = 0; i < n; i++) idx[i] = i;
auto key = [&](int i) { return (g.used(g.sats[i]) ? 100 : 0) + g.sats[i].snr; };
for (int i = 1; i < n; i++) { // insertion sort, <= 64
int v = idx[i], j = i - 1;
while (j >= 0 && key(idx[j]) < key(v)) { idx[j + 1] = idx[j]; j--; }
idx[j + 1] = v;
}
int fit = (W + 1) / 3; // bars at least 2 px wide, 1 px apart
if (n > fit) n = fit;
if (!n) return;
int bw = (W + 1) / n - 1;
if (bw > 8) bw = 8;
const int y0 = top + lh + 3, y1 = d.height() - 1, H = y1 - y0;
for (int x = 0; x < W; x += 3) dot(d, x, y1 - H * 30 / 50); // 30 dB-Hz
for (int k = 0; k < n; k++) {
const GpsSky::Sat& sat = g.sats[idx[k]];
int x = k * (bw + 1);
int snr = sat.snr > 50 ? 50 : sat.snr;
int h = snr < 0 ? 1 : (H * snr / 50 > 2 ? H * snr / 50 : 2);
if (g.used(sat) || h <= 2) d.fillRect(x, y1 - h + 1, bw, h);
else {
d.setColor(DisplayDriver::DARK); // over the dotted line
d.fillRect(x, y1 - h + 1, bw, h);
d.setColor(DisplayDriver::LIGHT);
d.drawRect(x, y1 - h + 1, bw, h);
}
}
}
public:
SatellitesScreen(UITask* task) : _task(task) {}
void onShow() override {}
int render(DisplayDriver& display) override {
display.setTextSize(1);
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader(_signal ? "SIGNAL" : "SKY");
display.drawTextRightAlign(display.width() - 1, 0, _signal ? "2/2" : "1/2");
const int top = display.listStart();
const int cx = display.width() / 2, lh = display.getLineHeight();
GpsSky* g = sky();
if (!g) {
display.drawTextCentered(cx, top + lh, "No GPS data");
return 1000;
}
g->expire();
if (!g->sentences || millis() - g->last_ms > 5000) {
bool on = _task->getGPSState();
display.drawTextCentered(cx, top + lh, on ? "Waiting for GPS..." : "GPS is off");
if (!on) display.drawTextCentered(cx, top + 2 * lh + 2, "Home > GPS page");
return 1000;
}
if (_signal) renderSignal(display, *g, top);
else renderSky(display, *g, top);
return 1000;
}
bool handleInput(char c) override {
if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; }
if (c == KEY_LEFT || c == KEY_RIGHT || c == KEY_ENTER || c == KEY_NEXT || c == KEY_PREV) {
_signal = !_signal;
return true;
}
return true;
}
};
@@ -13,7 +13,7 @@ class ToolsScreen : public UIScreen {
UITask* _task;
enum Action {
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS, ACT_SATELLITES,
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
ACT_CLOCK, ACT_RINGTONE, ACT_DIAGNOSTICS
#if defined(PIN_GPIO1)
@@ -54,6 +54,7 @@ class ToolsScreen : public UIScreen {
case ACT_TRAIL: _task->gotoTrailScreen(); break;
case ACT_LOCATOR: _task->gotoLocatorScreen(); break;
case ACT_COMPASS: _task->gotoCompassScreen(); break;
case ACT_SATELLITES: _task->gotoSatellitesScreen(); break;
case ACT_BOT: _task->gotoBotScreen(); break;
case ACT_AUTOADVERT: _task->gotoAutoAdvertScreen(); break;
case ACT_REPEATER: _task->gotoRepeaterScreen(); break;
@@ -128,6 +129,9 @@ const ToolsScreen::Tool ToolsScreen::LOCATION_TOOLS[] = {
{ "Trail", &ICON_TRAIL, ACT_TRAIL },
{ "Locator", &ICON_MAP_WAYPOINT, ACT_LOCATOR },
{ "Compass", &ICON_MAP_NORTH, ACT_COMPASS },
#if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW)
{ "Satellites", &ICON_GPS, ACT_SATELLITES },
#endif
};
const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = {
{ "Remote Bot", &ICON_BOT, ACT_BOT },
@@ -144,7 +148,7 @@ const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
#endif
};
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 },
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, (uint8_t)(sizeof(LOCATION_TOOLS)/sizeof(LOCATION_TOOLS[0])) },
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 4 },
{ "System", &ICON_GEAR, SYSTEM_TOOLS, (uint8_t)(sizeof(SYSTEM_TOOLS)/sizeof(SYSTEM_TOOLS[0])) },
};
@@ -178,6 +178,9 @@ static const int QUICK_MSGS_MAX = 10;
#include "LocatorScreen.h"
#include "TrailScreen.h"
#include "CompassScreen.h"
#if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW)
#include "SatellitesScreen.h"
#endif
#include "DiagnosticsScreen.h"
#include "RepeaterScreen.h"
#if defined(PIN_GPIO1)
@@ -1623,6 +1626,9 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
locator_screen = new LocatorScreen(this, node_prefs);
trail_screen = new TrailScreen(this, &_core->trail.store());
compass_screen = new CompassScreen(this);
#if ENV_INCLUDE_GPS == 1 && defined(GPS_SKYVIEW)
satellites_screen = new SatellitesScreen(this);
#endif
diag_screen = new DiagnosticsScreen(this);
repeater_screen = new RepeaterScreen(this);
clock_tools = new ClockToolsScreen(this, node_prefs);
@@ -1654,6 +1660,7 @@ void UITask::openAdminFor(const ContactInfo& ci, bool from_picker) {
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
void UITask::gotoCompassScreen() { setCurrScreen(compass_screen); }
void UITask::gotoSatellitesScreen() { if (satellites_screen) setCurrScreen(satellites_screen); }
void UITask::gotoDiagnosticsScreen() { setCurrScreen(diag_screen); }
void UITask::gotoRepeaterScreen() { setCurrScreen(repeater_screen); }
void UITask::gotoClockTools() { setCurrScreen(clock_tools); }
@@ -2813,6 +2820,7 @@ void UITask::loop() {
|| (_node_prefs && _node_prefs->loc_share_enabled)
|| (_node_prefs && _node_prefs->locator_enabled && _node_prefs->locator_has_target)
|| curr == compass_screen
|| (satellites_screen && curr == satellites_screen)
|| (curr == nearby_screen && ((NearbyScreen*)nearby_screen)->isNavigating())
|| (curr == trail_screen && ((TrailScreen*)trail_screen)->wpNeedsLiveGps())
|| (curr == messages_screen && ((MessagesScreen*)messages_screen)->navActive())
+2
View File
@@ -112,6 +112,7 @@ class UITask : public UITaskBase, public UiCoreHost {
UIScreen* locator_screen = nullptr;
UIScreen* trail_screen = nullptr;
UIScreen* compass_screen = nullptr;
UIScreen* satellites_screen = nullptr; // Tools > Satellites (-D GPS_SKYVIEW)
UIScreen* diag_screen = nullptr;
UIScreen* repeater_screen = nullptr;
UIScreen* clock_tools = nullptr;
@@ -316,6 +317,7 @@ public:
void gotoTrailScreen();
void gotoMapScreen(); // opens the Trail screen directly in its Map view
void gotoCompassScreen();
void gotoSatellitesScreen();
void gotoDiagnosticsScreen();
void gotoRepeaterScreen();
void gotoGpioScreen(); // no-op on boards without user GPIO pins (see PIN_GPIO1)