From b1c80c912fb4a18a9435ee777246139ee27572d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 9 Oct 2026 23:14:20 +0200 Subject: [PATCH 1/6] SSH App: its messages were cleared the moment they were set The bottom-line message (a malformed address, not enough memory, a session still closing) is timed from millis() when a key sets it, and update() compared that with the pass's own time, read before the keys were handled. The unsigned difference wrapped, so the message was cleared before it was ever drawn: Enter on a new connection that was refused looked like it did nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- src/apps/ssh_app.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/apps/ssh_app.cpp b/src/apps/ssh_app.cpp index 349a767..01ed930 100644 --- a/src/apps/ssh_app.cpp +++ b/src/apps/ssh_app.cpp @@ -311,7 +311,9 @@ bool SshApp::onKey(const KeyEvent& e) { } void SshApp::update(uint32_t nowMs) { - if (!message_.empty() && nowMs - messageMs_ >= kMessageMs) { + // nowMs was read before this pass handled its keys: a message a key just set is "later" than it, + // and an unsigned difference would call that 49 days old, and clear it at once. + if (!message_.empty() && static_cast(nowMs - messageMs_) >= static_cast(kMessageMs)) { message_.clear(); requestRedraw(); } -- 2.53.0 From aadfd8cb374ee677c9b38998e3c3310411ee20f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 9 Oct 2026 23:14:37 +0200 Subject: [PATCH 2/6] UI: the website's colours, on every screen The theme takes the website's palette, each a colour the RGB332 frame buffer holds exactly: cyan for the accent, a selected row and a chosen button in cyan with dark blue text, a slate Status Bar (the grey it asked for was shown as a dull olive), the website's orange for warnings and its pink for messages. "Good" (a Fix, the quietest channel) keeps a green of its own, which the website doesn't have. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- docs/milestones/U1.md | 17 +++++++++++++++++ site/content/dev/milestones/u1.md | 17 +++++++++++++++++ src/apps/gemini_app.cpp | 2 +- src/apps/gnss_app.cpp | 14 +++++++------- src/apps/lora_scanner_app.cpp | 2 +- src/apps/ssh_app.cpp | 2 +- src/apps/system_app.cpp | 8 ++++---- src/apps/wifi_tools_app.cpp | 6 +++--- src/ui/theme.h | 19 ++++++++++++------- src/ui/widgets.cpp | 24 ++++++++++++------------ 10 files changed, 75 insertions(+), 36 deletions(-) diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index 2ee23af..e6638ca 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -59,3 +59,20 @@ A screenshot could only be taken by typing `screenshot` in the Shell, where "now **Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed. **Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card. + +## The website's colours, on every screen + +Every screen takes its colours from `src/ui/theme.h`, so the palette changed there, to the website's (`site/static/css/site.css`, dark side). Each is a colour the RGB332 frame buffer holds exactly. + +| | Was | Is | +|---|---|---| +| Accent | teal-blue | cyan `#00dbff` | +| A selected row, a dialog's chosen button | darker teal, white text | cyan, dark blue text (`#000055`) | +| Status Bar | `#202020`, which the frame buffer showed as a dull olive | slate `#494955`; what is idle on it in light grey `#b6b6aa` | +| Muted text | grey, as the frame buffer rounded it | the same grey, exact: `#9292aa` | +| Warning | orange | the website's orange `#ff9200` | +| Someone wrote (unread count, a mention, a message Toast) | green | pink `#ff92ff` | +| Good (a Fix, the quietest channel, a live task) | the same green | green `#49db55`, the one colour that isn't the website's: it has no green | +| Text on a Toast | black | dark blue | + +Looked at on the device, by screenshot: the Launcher, IRC, Wi-Fi Tools (menu, channel occupancy), GNSS (position, sky), Gemini, the LoRa Scanner (Sniffer, Sweep, presets), Storage, Notes, SSH, the Shell, System, Settings and the help panel. Not looked at: dialogs, a warning or a message Toast, the SSH terminal (which keeps its own 16 ANSI colours), the Setup screens. diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index 972878b..af94d64 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -67,3 +67,20 @@ A screenshot could only be taken by typing `screenshot` in the Shell, where "now **Checked on the device** (2026-10-07, with `key shot`): in the Launcher, a 33,383-byte PNG appears in `/screenshots` and is the Launcher; with the help panel open, the picture is of the panel (which now lists Fn p) and the panel stays open; on Settings > Debug Console, no file is written; back on the Settings list, one is. The test pictures were removed. **Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card. + +## The website's colours, on every screen + +Every screen takes its colours from `src/ui/theme.h`, so the palette changed there, to the website's (`site/static/css/site.css`, dark side). Each is a colour the RGB332 frame buffer holds exactly. + +| | Was | Is | +|---|---|---| +| Accent | teal-blue | cyan `#00dbff` | +| A selected row, a dialog's chosen button | darker teal, white text | cyan, dark blue text (`#000055`) | +| Status Bar | `#202020`, which the frame buffer showed as a dull olive | slate `#494955`; what is idle on it in light grey `#b6b6aa` | +| Muted text | grey, as the frame buffer rounded it | the same grey, exact: `#9292aa` | +| Warning | orange | the website's orange `#ff9200` | +| Someone wrote (unread count, a mention, a message Toast) | green | pink `#ff92ff` | +| Good (a Fix, the quietest channel, a live task) | the same green | green `#49db55`, the one colour that isn't the website's: it has no green | +| Text on a Toast | black | dark blue | + +Looked at on the device, by screenshot: the Launcher, IRC, Wi-Fi Tools (menu, channel occupancy), GNSS (position, sky), Gemini, the LoRa Scanner (Sniffer, Sweep, presets), Storage, Notes, SSH, the Shell, System, Settings and the help panel. Not looked at: dialogs, a warning or a message Toast, the SSH terminal (which keeps its own 16 ANSI colours), the Setup screens. diff --git a/src/apps/gemini_app.cpp b/src/apps/gemini_app.cpp index 553a580..ffd44fc 100644 --- a/src/apps/gemini_app.cpp +++ b/src/apps/gemini_app.cpp @@ -534,7 +534,7 @@ void GeminiApp::drawLine(Canvas& c, size_t i, int firstRow, int y, int rowsLeft) for (int r = firstRow; r < static_cast(rows.size()) && r - firstRow < rowsLeft; r++) { int ry = y + (r - firstRow) * theme::kLineHeight; if (isSelected) c.fillRect(0, ry, theme::kWidth - 3, theme::kLineHeight, theme::kSelection); - c.setTextColor(isSelected ? theme::kText : st.color); + c.setTextColor(isSelected ? theme::kOnAccent : st.color); std::string text = rows[r]; if (g.type == LineType::Preformatted) text = hscroll_ < static_cast(text.size()) ? text.substr(hscroll_) : ""; c.drawString(text.c_str(), kMargin, ry + 1); diff --git a/src/apps/gnss_app.cpp b/src/apps/gnss_app.cpp index 3d280fa..bea063e 100644 --- a/src/apps/gnss_app.cpp +++ b/src/apps/gnss_app.cpp @@ -22,7 +22,7 @@ uint16_t colorOf(Constellation c) { switch (c) { case Constellation::Gps: return theme::kAccent; case Constellation::Glonass: return 0xF8C8; // red - case Constellation::Galileo: return theme::kMessage; + case Constellation::Galileo: return theme::kGood; case Constellation::BeiDou: return 0xFEA0; // yellow case Constellation::Qzss: return 0xC99F; // violet default: return theme::kText; @@ -96,7 +96,7 @@ void GnssApp::drawPosition(Canvas& c) { char line[64]; c.setFont(&fonts::bold); - c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kMessage); + c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kGood); if (!gnss_.receiving(now)) std::snprintf(line, sizeof line, "No data from the receiver"); else if (s.fix == FixType::None) std::snprintf(line, sizeof line, "Searching: %u in view, %s", (unsigned)s.satellites.size(), @@ -155,10 +155,10 @@ void GnssApp::drawSky(Canvas& c) { // The sky from above: horizon, 30° and 60° elevation rings, north up. c.drawCircle(cx, cy, radius, theme::kMuted); - c.drawCircle(cx, cy, radius * 2 / 3, 0x4208); - c.drawCircle(cx, cy, radius / 3, 0x4208); - c.drawFastHLine(cx - radius, cy, 2 * radius, 0x4208); - c.drawFastVLine(cx, cy - radius, 2 * radius, 0x4208); + c.drawCircle(cx, cy, radius * 2 / 3, theme::kFaint); + c.drawCircle(cx, cy, radius / 3, theme::kFaint); + c.drawFastHLine(cx - radius, cy, 2 * radius, theme::kFaint); + c.drawFastVLine(cx, cy - radius, 2 * radius, theme::kFaint); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); c.setTextDatum(middle_center); @@ -201,7 +201,7 @@ void GnssApp::drawSky(Canvas& c) { c.drawString("No satellites", x, y); y += theme::kLineHeight; } - c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kMessage); + c.setTextColor(s.fix == FixType::None ? theme::kWarning : theme::kGood); c.drawString(fixLabel(s.fix), x, y + 4); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); diff --git a/src/apps/lora_scanner_app.cpp b/src/apps/lora_scanner_app.cpp index a622e63..2ba9405 100644 --- a/src/apps/lora_scanner_app.cpp +++ b/src/apps/lora_scanner_app.cpp @@ -327,7 +327,7 @@ void LoraScannerApp::drawSweep(Canvas& c) { uint32_t hz = radio_.config().frequencyHz; if (hz >= f.fromHz && hz <= f.fromHz + (f.steps - 1) * f.stepHz) { int x = x0 + static_cast((hz - f.fromHz) / f.stepHz) * cell + cell / 2; - for (int y = barTop; y < barBottom; y += 3) c.drawPixel(x, y, theme::kMessage); + for (int y = barTop; y < barBottom; y += 3) c.drawPixel(x, y, theme::kGood); } // The waterfall. diff --git a/src/apps/ssh_app.cpp b/src/apps/ssh_app.cpp index 01ed930..9e75494 100644 --- a/src/apps/ssh_app.cpp +++ b/src/apps/ssh_app.cpp @@ -424,7 +424,7 @@ void SshApp::drawTerminal(Canvas& c) { c.setFont(&fonts::small); c.setTextDatum(top_right); c.fillRect(area.x + area.w - 5 * static_cast(where.size()) - 3, area.y, 5 * static_cast(where.size()) + 3, 9, theme::kAccent); - c.setTextColor(0x0000); + c.setTextColor(theme::kOnAccent); c.drawString(where.c_str(), area.x + area.w - 1, area.y + 1); c.setTextDatum(top_left); } diff --git a/src/apps/system_app.cpp b/src/apps/system_app.cpp index 6918512..3828c7e 100644 --- a/src/apps/system_app.cpp +++ b/src/apps/system_app.cpp @@ -232,7 +232,7 @@ void SystemApp::drawOverview(Canvas& c) { const auto& h = loadHistory_[core]; for (size_t i = 1; i < h.size(); i++) { int x0 = gx + 1 + static_cast((i - 1) * gw / kHistory), x1 = gx + 1 + static_cast(i * gw / kHistory); - c.drawLine(x0, gy + gh - h.at(i - 1) * gh / 100, x1, gy + gh - h.at(i) * gh / 100, core ? theme::kMessage : theme::kAccent); + c.drawLine(x0, gy + gh - h.at(i - 1) * gh / 100, x1, gy + gh - h.at(i) * gh / 100, core ? theme::kGood : theme::kAccent); } } } @@ -290,7 +290,7 @@ void SystemApp::drawMemory(Canvas& c) { struct { size_t bytes; uint16_t color; - } floors[] = {{GeminiService::kStartFloor, theme::kMuted}, {GeminiService::kSteadyFloor, theme::kWarning}, {GeminiService::kTransientFloor, theme::kMessage}}; + } floors[] = {{GeminiService::kStartFloor, theme::kMuted}, {GeminiService::kSteadyFloor, theme::kWarning}, {GeminiService::kTransientFloor, theme::kGood}}; for (auto& f : floors) { int kb = static_cast(f.bytes / 1024), y = yOf(kb); for (int x = gx + 1; x < gx + gw - 1; x += 4) c.drawPixel(x, y, f.color); @@ -335,13 +335,13 @@ void SystemApp::drawNetwork(Canvas& c) { for (size_t i = 0; i < kUsers; i++) { bool active = rateIn_[i] || rateOut_[i]; c.setTextDatum(top_left); - c.setTextColor(active ? theme::kMessage : theme::kText); + c.setTextColor(active ? theme::kGood : theme::kText); c.drawString(net::userName(static_cast(i)), 4, y); c.setTextDatum(top_right); c.setTextColor(theme::kText); c.drawString(net::formatTraffic(traffic_[i].in).c_str(), 124, y); c.drawString(net::formatTraffic(traffic_[i].out).c_str(), 168, y); - c.setTextColor(active ? theme::kMessage : theme::kMuted); + c.setTextColor(active ? theme::kGood : theme::kMuted); c.drawString(active ? net::formatTraffic(rateIn_[i] + rateOut_[i]).c_str() : "-", area.w - 4, y); y += theme::kLineHeight; } diff --git a/src/apps/wifi_tools_app.cpp b/src/apps/wifi_tools_app.cpp index d9897f6..d86cdff 100644 --- a/src/apps/wifi_tools_app.cpp +++ b/src/apps/wifi_tools_app.cpp @@ -232,9 +232,9 @@ void WifiToolsApp::drawChannels(Canvas& c) { for (int ch = 1; ch <= 13; ch++) { int x = x0 + (ch - 1) * slot; int h = static_cast(load[ch] / maxLoad * barMaxH); - uint16_t color = ch == best ? theme::kMessage : (ch == 1 || ch == 6 || ch == 11) ? theme::kAccent : theme::kMuted; + uint16_t color = ch == best ? theme::kGood : (ch == 1 || ch == 6 || ch == 11) ? theme::kAccent : theme::kMuted; if (h > 0) c.fillRect(x, baseY - h, slot - 4, h, color); - c.setTextColor(ch == best ? theme::kMessage : theme::kText); + c.setTextColor(ch == best ? theme::kGood : theme::kText); c.setTextDatum(top_center); c.drawString(std::to_string(ch).c_str(), x + (slot - 4) / 2, baseY + 3); c.setTextDatum(top_left); @@ -265,7 +265,7 @@ void WifiToolsApp::drawTracker(Canvas& c) { int strength = lost ? 0 : std::min(100, std::max(0, (tracker_.latest() + 95) * 100 / 60)); int barW = area.w - 8; c.drawRect(4, area.y + 60, barW, 8, theme::kMuted); - c.fillRect(5, area.y + 61, (barW - 2) * strength / 100, 6, theme::kMessage); + c.fillRect(5, area.y + 61, (barW - 2) * strength / 100, 6, theme::kGood); const int gy = area.y + 74, gh = area.h - 76; const auto& h = tracker_.history(); diff --git a/src/ui/theme.h b/src/ui/theme.h index 52a28da..4f0ce7d 100644 --- a/src/ui/theme.h +++ b/src/ui/theme.h @@ -4,15 +4,20 @@ namespace roro::theme { -// RGB565 +// The website's palette (site/static/css/site.css, its dark side), as RGB565. Every one is a colour +// the 8-bit frame buffer (RGB332) holds exactly, so what is asked for is what is shown. constexpr uint16_t kBackground = 0x0000; constexpr uint16_t kText = 0xFFFF; -constexpr uint16_t kMuted = 0x8410; // grey -constexpr uint16_t kAccent = 0x2CBF; // teal-blue -constexpr uint16_t kSelection = 0x1A6F; // darker accent behind selected rows -constexpr uint16_t kBar = 0x2104; // Status Bar background -constexpr uint16_t kWarning = 0xFC60; // orange -constexpr uint16_t kMessage = 0x3666; // green +constexpr uint16_t kAccent = 0x06DF; // cyan, #00dbff: what is chosen, what is live +constexpr uint16_t kOnAccent = 0x000A; // dark blue, #000055: text and pictures on a cyan fill +constexpr uint16_t kSelection = kAccent; // behind a selected row, whose text is kOnAccent +constexpr uint16_t kMuted = 0x9495; // grey, #9292aa: between the website's two +constexpr uint16_t kFaint = 0x4A4A; // slate, #494955: grid lines, what stays behind +constexpr uint16_t kBar = kFaint; // Status Bar background +constexpr uint16_t kBarMuted = 0xB5B5; // light grey, #b6b6aa: what is idle, on the bar +constexpr uint16_t kWarning = 0xFC80; // orange, #ff9200 +constexpr uint16_t kMessage = 0xFC9F; // pink, #ff92ff: someone wrote +constexpr uint16_t kGood = 0x4ECA; // green, #49db55: a Fix, the best of a set (not on the website) constexpr int kWidth = 240; constexpr int kHeight = 135; diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp index e8ab98c..d268e4a 100644 --- a/src/ui/widgets.cpp +++ b/src/ui/widgets.cpp @@ -32,43 +32,43 @@ void statusBar(Canvas& c, const StatusInfo& info) { if (info.unread > 0) right("[" + std::to_string(info.unread) + "]", kMessage); if (info.batteryPercent >= 0) right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText); - if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted); + if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kBarMuted); switch (info.wifi) { case StatusInfo::Wifi::Connected: { // Three signal bars, then the label. for (int i = 0; i < 3; i++) { int h = 2 + 2 * i; - c.fillRect(x - 10 + 4 * i, 9 - h, 3, h, i < info.wifiBars ? kText : kMuted); + c.fillRect(x - 10 + 4 * i, 9 - h, 3, h, i < info.wifiBars ? kText : kBackground); } x -= 14; right("W", kText); break; } - case StatusInfo::Wifi::Searching: right("W?", kMuted); break; + case StatusInfo::Wifi::Searching: right("W?", kBarMuted); break; case StatusInfo::Wifi::Monitoring: right("MON", kAccent); break; case StatusInfo::Wifi::None: break; } switch (info.vpn) { // Q252: there while the tunnel is wanted, bright once the peer has answered - case StatusInfo::Vpn::Trying: right("VPN", kMuted); break; + case StatusInfo::Vpn::Trying: right("VPN", kBarMuted); break; case StatusInfo::Vpn::Up: right("VPN", kAccent); break; case StatusInfo::Vpn::Off: break; } if (info.ssh) right("SSH", kAccent); // Q260: a session goes on, whatever App is in front switch (info.debug) { // Q190: there while the console listens, bright with someone connected - case StatusInfo::Debug::On: right("DBG", kMuted); break; + case StatusInfo::Debug::On: right("DBG", kBarMuted); break; case StatusInfo::Debug::Client: right("DBG", kAccent); break; case StatusInfo::Debug::Off: break; } if (info.tracking) right("REC", kWarning); if (info.capturing) right("CAP", kWarning); switch (info.radio) { // Q101: muted while listening, bright for a moment on each packet - case StatusInfo::Radio::Listening: right("L", kMuted); break; + case StatusInfo::Radio::Listening: right("L", kBarMuted); break; case StatusInfo::Radio::Packet: right("L", kAccent); break; - case StatusInfo::Radio::Sweep: right("SW", kMuted); break; // the Sniffer is paused + case StatusInfo::Radio::Sweep: right("SW", kBarMuted); break; // the Sniffer is paused case StatusInfo::Radio::None: break; } switch (info.gnss) { // Q61: muted while searching, normal with a Fix, the count with a 3D Fix - case StatusInfo::Gnss::Searching: right("G", kMuted); break; + case StatusInfo::Gnss::Searching: right("G", kBarMuted); break; case StatusInfo::Gnss::TwoD: right("G", kText); break; case StatusInfo::Gnss::ThreeD: right("G" + std::to_string(info.gnssSatellites), kText); break; case StatusInfo::Gnss::None: break; @@ -88,10 +88,10 @@ void list(Canvas& c, const ListModel& model, Rect area, const std::function kWidth - 8) w = kWidth - 8; int x = (kWidth - w) / 2, y = kHeight - 22; c.fillRect(x, y, w, 18, color); - c.setTextColor(kBackground); + c.setTextColor(kOnAccent); c.setTextDatum(top_left); c.setClipRect(x + 4, y, w - 8, 18); c.drawString(t.text.c_str(), x + 8, y + 3); -- 2.53.0 From e2326e113b501f1cfe1cd8979075e32708529357 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 9 Oct 2026 23:14:37 +0200 Subject: [PATCH 3/6] Launcher: a grid of icons, with badges; the list stays as a Setting The Launcher shows the Apps as a 4 x 3 grid of 32 x 32 icons, the selected one's name under it (issue #9). The icons are the website's own, doubled, and two drawn the same way for SSH and the Shell; they are PNGs in assets/icons, turned into arrays by scripts/make_icons.py, which CI checks. The selected tile is the website's: dark blue on cyan, with notched corners. - GridModel (lib/ui, host-tested) holds the selection and the scrolling. - App::badge(): a dot on the tile for unread IRC messages, a Track recording, a Capture running, an SSH session open. - Settings > Launcher: Grid or List. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- .gitea/workflows/ci.yml | 3 + README.md | 2 + assets/icons/gemini.png | Bin 0 -> 108 bytes assets/icons/gnss.png | Bin 0 -> 121 bytes assets/icons/irc.png | Bin 0 -> 85 bytes assets/icons/lora.png | Bin 0 -> 99 bytes assets/icons/notes.png | Bin 0 -> 102 bytes assets/icons/settings.png | Bin 0 -> 117 bytes assets/icons/shell.png | Bin 0 -> 97 bytes assets/icons/ssh.png | Bin 0 -> 99 bytes assets/icons/storage.png | Bin 0 -> 104 bytes assets/icons/system.png | Bin 0 -> 96 bytes assets/icons/wifi-tools.png | Bin 0 -> 107 bytes docs/milestones/U1.md | 20 +- lib/apps_model/src/settings_menu.cpp | 4 +- lib/apps_model/src/settings_menu.h | 2 +- lib/core/src/app.h | 4 + lib/core/src/app_keys.h | 2 +- lib/core/src/app_manager.h | 3 + lib/services/src/settings.cpp | 1 + lib/services/src/settings.h | 1 + lib/ui/src/grid_model.cpp | 47 +++ lib/ui/src/grid_model.h | 33 ++ scripts/make_icons.py | 115 ++++++ site/content/dev/build/build-and-test.md | 2 + site/content/dev/milestones/u1.md | 20 +- site/content/guide/basics.md | 8 +- site/content/guide/settings.md | 1 + site/data/keys.toml | 2 +- src/apps/gnss_app.h | 1 + src/apps/irc_app.h | 1 + src/apps/launcher_app.cpp | 93 ++++- src/apps/launcher_app.h | 19 +- src/apps/lora_scanner_app.h | 1 + src/apps/ssh_app.h | 1 + src/main.cpp | 24 +- src/ui/icons.cpp | 391 ++++++++++++++++++ src/ui/icons.h | 23 ++ test/test_grid_model/test_grid_model.cpp | 97 +++++ .../test_settings_menu/test_settings_menu.cpp | 10 + 40 files changed, 907 insertions(+), 24 deletions(-) create mode 100644 assets/icons/gemini.png create mode 100644 assets/icons/gnss.png create mode 100644 assets/icons/irc.png create mode 100644 assets/icons/lora.png create mode 100644 assets/icons/notes.png create mode 100644 assets/icons/settings.png create mode 100644 assets/icons/shell.png create mode 100644 assets/icons/ssh.png create mode 100644 assets/icons/storage.png create mode 100644 assets/icons/system.png create mode 100644 assets/icons/wifi-tools.png create mode 100644 lib/ui/src/grid_model.cpp create mode 100644 lib/ui/src/grid_model.h create mode 100755 scripts/make_icons.py create mode 100644 src/ui/icons.cpp create mode 100644 src/ui/icons.h create mode 100644 test/test_grid_model/test_grid_model.cpp diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index f007221..4991b78 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -83,6 +83,9 @@ jobs: git checkout -q --detach "${{ github.sha }}" git describe --tags --always + - name: The icons are what their pictures give + run: python3 scripts/make_icons.py --check + - name: Host tests, and their coverage of lib/ if: github.event_name != 'workflow_dispatch' run: | diff --git a/README.md b/README.md index e950790..671e9e3 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ scripts/ci.sh This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`. +`scripts/make_icons.py` turns the Launcher's icons (`assets/icons/.png`, 32 × 32, one colour) into the arrays in `src/ui/icons.cpp`; run it after changing a picture. CI fails when the two don't match. + `scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure. The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md). diff --git a/assets/icons/gemini.png b/assets/icons/gemini.png new file mode 100644 index 0000000000000000000000000000000000000000..2734eaf6c2d21600c68906cf71498b65663f76c7 GIT binary patch literal 108 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNv8Rh;NX4z>gab@({{R2atk}aV zQK8Vn$lT*_Lg@$(pG<=w8=FB>JG-005rJ15X0)?1Fo@ohU%GRH(maqcp00i_>zopr E09jETd;kCd literal 0 HcmV?d00001 diff --git a/assets/icons/gnss.png b/assets/icons/gnss.png new file mode 100644 index 0000000000000000000000000000000000000000..91f6242cce84cd6487de6569829a6fb7ff86625a GIT binary patch literal 121 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNgQtsQNX4z>1P6xS%mGb58SJM# zNPJ-4Ai$=;*Ye;$gQ^5i#ex=IF($?C3ylV>Dy)h#_zmP61bDvHODtgFVPKeJBy4|K S(HqEOVDNPHb6Mw<&;$UVEgqi$ literal 0 HcmV?d00001 diff --git a/assets/icons/irc.png b/assets/icons/irc.png new file mode 100644 index 0000000000000000000000000000000000000000..a315b19a2a9cfa92ccd11dadabfbfc52f21cdd38 GIT binary patch literal 85 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNjHioZNX4z>gad3m6(0ot{QqCy hAn@br#D&bl3=9Uo0!xoHyD@{*d%F6$taD0e0stL47K#7> literal 0 HcmV?d00001 diff --git a/assets/icons/lora.png b/assets/icons/lora.png new file mode 100644 index 0000000000000000000000000000000000000000..e472eefe2ca4ae00552e10ba02af05a0ade315b8 GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNmZytjNX4z>1P2B=i$>p`c6Pr6 u;b!6V3cdp!#sN+2`icMl|Ci@zWMyEOI!(5@a0BxWkZw;`KbLh*2~7YE^ciIU literal 0 HcmV?d00001 diff --git a/assets/icons/notes.png b/assets/icons/notes.png new file mode 100644 index 0000000000000000000000000000000000000000..db11f3c7532d7b36cdfee09d96e4cebeb9fc8a68 GIT binary patch literal 102 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNuBVG*NX4z>1c!#hHWDoV|Noa) yU^$;C$E|NP1v9kLCA5k8JL*w`6<)GB;UzCS|&WPqoupUXO@geCyhnH>lK literal 0 HcmV?d00001 diff --git a/assets/icons/settings.png b/assets/icons/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..4a31161ab2798699f4cb4691f6e08e79172c7110 GIT binary patch literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNji-xaNX4z>1P6wH`~girdp=0lAvO+$mPST?eyhd_t}h&BG#N-HN*`h^0BTN>s@=bKy%oqj MPgg&ebxsLQ086MJmH+?% literal 0 HcmV?d00001 diff --git a/assets/icons/shell.png b/assets/icons/shell.png new file mode 100644 index 0000000000000000000000000000000000000000..d4c61cb1911d8dd208688c8250dc324198a4a29d GIT binary patch literal 97 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNhNp{TNX4z>gab@D4_NHyB-k-1 tOV}OY%;6GDDk$aHd*al;|BV6_j0`=dJb&NV?zjcg>*?y}vd$@?2>=yz8tMQ5 literal 0 HcmV?d00001 diff --git a/assets/icons/ssh.png b/assets/icons/ssh.png new file mode 100644 index 0000000000000000000000000000000000000000..ff210135f9d14ad2a366038d625ab7dd8497f809 GIT binary patch literal 99 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNmZytjNX4z>1O=fF^$mh-HU^Iv vjC(9#fklph!P{iTp>~0o{KxeLApI%{an^LB{Ts58qyf% literal 0 HcmV?d00001 diff --git a/assets/icons/storage.png b/assets/icons/storage.png new file mode 100644 index 0000000000000000000000000000000000000000..e2261f34f72ab731520fbe566a3bd24ce550274f GIT binary patch literal 104 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNzNd?0NX4z>ga+nk|K$@{nE87Q z(%OW1*gpvHL~#7JH#pS5KH1@fQpunH{HhNa7@8+2I3^Z-$OjqW>FVdQ&MBb@0KXp` ALjV8( literal 0 HcmV?d00001 diff --git a/assets/icons/system.png b/assets/icons/system.png new file mode 100644 index 0000000000000000000000000000000000000000..e6e391ce6262afec66eae5c75bfd228f7ae588c2 GIT binary patch literal 96 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNx~Gd{NX4z>gad3m6$YLE|1V_u s&-jdApy}bCbhac871@H|4YwLO8CFEgeH6V?xeuh()78&qol`;+06!iZmH+?% literal 0 HcmV?d00001 diff --git a/assets/icons/wifi-tools.png b/assets/icons/wifi-tools.png new file mode 100644 index 0000000000000000000000000000000000000000..784b46a8758a0bfe60b17b39207c7cd78295d54c GIT binary patch literal 107 zcmeAS@N?(olHy`uVBq!ia0vp^3Lwk~Bp9L@-6MgNk*AAeNX4z>gaiC<{yT6!V?M*K zz{1bm%-CYE;s0S~MS)Eo4gZ;Y3?4KJaxjE-yCpC&oUIWo?+lFn2QtOe)z4*}Q$iB} DK5-q2 literal 0 HcmV?d00001 diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index e6638ca..571ef5d 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -1,6 +1,6 @@ # U1 — Look and feel -**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10). +**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) is done, not released yet. Not started: screen recording (#17), themes (#10). **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. @@ -60,6 +60,24 @@ A screenshot could only be taken by typing `screenshot` in the Shell, where "now **Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card. +## The Launcher as a grid (issue #9) + +The Launcher was a list of names. It is now a grid of icons. + +| Question in the issue | Decided | +|---|---| +| Layout | **4 × 3** tiles of 60 × 36, so the 11 Apps are on one page. Only the selected App's name is written, on a line under the grid: a name under every tile doesn't fit 60 pixels ("LoRa Scanner"). More than 12 Apps: the rows scroll | +| Icons | **The website's own**: its nine 16 × 16 pictures (`site/static/img/icons-dark.svg`), doubled to 32 × 32, and two drawn the same way for SSH and the Shell. 1 bit a pixel (128 bytes each, in flash). The pictures are PNGs in `assets/icons/`, named after the App's id; `scripts/make_icons.py` writes `src/ui/icons.cpp` from them and CI checks the two match | +| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile dark blue (#000055) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is cyan too | +| Live state | **A dot** on the tile, from `App::badge()`: IRC (unread messages), GNSS (a Track recording), the LoRa Scanner (a Capture running), SSH (a session open). The Launcher asks every App on each pass and redraws when an answer changes | +| Order | As registered, as before | +| Shortcuts | None for now | +| The list | Kept: **Settings > Launcher**, Grid or List. Both keep the same selection | + +- `GridModel` (`lib/ui`) holds the selection and the scrolling, host-tested: left and right go through every item and wrap; up and down stay in the column, and land on the last item where the last row is short. +- An App registered without an icon shows its first letter in a frame. +- No animation: not asked for, and not measured. + ## The website's colours, on every screen Every screen takes its colours from `src/ui/theme.h`, so the palette changed there, to the website's (`site/static/css/site.css`, dark side). Each is a colour the RGB332 frame buffer holds exactly. diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index 26f7f9b..bec139c 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -19,7 +19,7 @@ const RowDef kRows[] = { {Row::LongName, Kind::Text, "Long name"}, {Row::ShortName, Kind::Text, "Short name"}, {Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"}, {Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"}, - {Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"}, + {Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Launcher, Kind::Toggle, "Launcher"}, {Row::Sound, Kind::Toggle, "Sound & LED"}, {Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}, {Row::Coordinates, Kind::Toggle, "Coordinates"}, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, @@ -80,6 +80,7 @@ std::string SettingsMenu::value(int i) const { case Row::Brightness: return std::to_string(settings_.getInt(Setting::Brightness)) + "%"; case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS)); case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS)); + case Row::Launcher: return settings_.getBool(Setting::LauncherList) ? "List" : "Grid"; case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off"; case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off"; case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off"; @@ -129,6 +130,7 @@ std::string SettingsMenu::choose(int i, int c) { } void SettingsMenu::toggle(int i) { + if (row(i) == Row::Launcher) settings_.setBool(Setting::LauncherList, !settings_.getBool(Setting::LauncherList)); if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound)); if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled)); if (row(i) == Row::GnssQuiet) settings_.setBool(Setting::GnssQuietForLora, !settings_.getBool(Setting::GnssQuietForLora)); diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 41bf052..661d1e5 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -11,7 +11,7 @@ namespace roro { // values, choice lists and validation messages. Rendering and navigation live in the App. class SettingsMenu { public: - enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About }; + enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About }; enum class Kind { Text, Choice, Toggle, Slider, Page }; explicit SettingsMenu(Settings& settings) : settings_(settings) {} diff --git a/lib/core/src/app.h b/lib/core/src/app.h index ca8bee1..a880588 100644 --- a/lib/core/src/app.h +++ b/lib/core/src/app.h @@ -50,6 +50,10 @@ class App { virtual bool retainsContent() const { return false; } virtual void contentLost() {} + // True while something goes on in the App that is worth a look, whether it is in front or + // not (unread messages, a recording running): the Launcher marks its tile (issue #9). + virtual bool badge() const { return false; } + void requestRedraw() { redraw_ = true; } bool consumeRedraw() { diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index 73067b7..935319b 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -48,7 +48,7 @@ inline constexpr KeyHelp kText[] = { // launcher: The Launcher inline constexpr KeyHelp kLauncher[] = { - {"; .", "up, down"}, + {"; . , /", "move"}, {"Enter", "open the App"}, }; diff --git a/lib/core/src/app_manager.h b/lib/core/src/app_manager.h index 6ab6467..c97fc14 100644 --- a/lib/core/src/app_manager.h +++ b/lib/core/src/app_manager.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include @@ -12,6 +14,7 @@ struct AppInfo { const char* title; bool hidden; // not listed in the Launcher, but can still be opened App* app; + const uint8_t* icon = nullptr; // the Launcher's picture of it (ui/icons.h); none: its first letter }; // Owns which App is in the foreground and routes keys. Home always returns to the Launcher; diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index 018c51e..d8fd9f5 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -53,6 +53,7 @@ const Definition kDefinitions[] = { {"ssh_key", Kind::String, 0, "", 0, 800}, {"ssh_public", Kind::String, 0, "", 0, 200}, {"ssh_font", Kind::Int, 1, nullptr, 0, 4}, + {"launcher_list", Kind::Bool, 0, nullptr, 0, 1}, // off: the grid }; static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast(Setting::Count), "every Setting needs a definition"); diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index 60ab16e..a857b04 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -41,6 +41,7 @@ enum class Setting : uint8_t { SshKey, // string: this device's own SSH private key, as OpenSSH writes one: never shown SshPublic, // string: its public half, the line to put in a server's authorized_keys SshFont, // int: the terminal's font, 0 (smallest) to 4 + LauncherList, // bool: the Launcher is a list of names instead of a grid of icons (issue #9) Count }; diff --git a/lib/ui/src/grid_model.cpp b/lib/ui/src/grid_model.cpp new file mode 100644 index 0000000..94082ae --- /dev/null +++ b/lib/ui/src/grid_model.cpp @@ -0,0 +1,47 @@ +#include "grid_model.h" + +namespace roro { + +void GridModel::setCount(int count) { + count_ = count < 0 ? 0 : count; + if (count_ == 0) { + selected_ = -1; + first_ = 0; + return; + } + select(selected_ < 0 ? 0 : selected_); +} + +void GridModel::select(int index) { + if (count_ == 0) return; + selected_ = index < 0 ? 0 : index >= count_ ? count_ - 1 : index; + int row = selected_ / cols_; + if (row < first_) first_ = row; + if (row >= first_ + rows_) first_ = row - rows_ + 1; + int maxFirst = rows() > rows_ ? rows() - rows_ : 0; + if (first_ > maxFirst) first_ = maxFirst; +} + +void GridModel::left() { + if (count_) select(selected_ == 0 ? count_ - 1 : selected_ - 1); +} + +void GridModel::right() { + if (count_) select(selected_ == count_ - 1 ? 0 : selected_ + 1); +} + +void GridModel::up() { + if (!count_) return; + int col = selected_ % cols_, row = selected_ / cols_; + row = row == 0 ? rows() - 1 : row - 1; + select(row * cols_ + col); // past the end of a short last row: the last item +} + +void GridModel::down() { + if (!count_) return; + int col = selected_ % cols_, row = selected_ / cols_; + row = row == rows() - 1 ? 0 : row + 1; + select(row * cols_ + col); +} + +} // namespace roro diff --git a/lib/ui/src/grid_model.h b/lib/ui/src/grid_model.h new file mode 100644 index 0000000..e235e25 --- /dev/null +++ b/lib/ui/src/grid_model.h @@ -0,0 +1,33 @@ +#pragma once + +namespace roro { + +// Selection and scrolling for a grid of `cols` columns showing `visibleRows` rows at a time, filled +// row by row. Left and Right go through every item and wrap around; Up and Down stay in the +// column and wrap around, landing on the last item where the last row has no such column. +class GridModel { + public: + GridModel(int cols, int visibleRows) : cols_(cols), rows_(visibleRows) {} + + void setCount(int count); + int count() const { return count_; } + int selected() const { return selected_; } // -1 when empty + int cols() const { return cols_; } + int rows() const { return count_ ? (count_ - 1) / cols_ + 1 : 0; } + int firstVisibleRow() const { return first_; } + int visibleRows() const { return rows_; } + + void select(int index); + void left(); + void right(); + void up(); + void down(); + + private: + int cols_, rows_; + int count_ = 0; + int selected_ = -1; + int first_ = 0; +}; + +} // namespace roro diff --git a/scripts/make_icons.py b/scripts/make_icons.py new file mode 100755 index 0000000..bb1e9d4 --- /dev/null +++ b/scripts/make_icons.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Turns the Launcher's icons (assets/icons/.png) into C arrays: src/ui/icons.h and icons.cpp. + +An icon is 32 x 32 and one colour: a pixel is drawn where the picture is light and opaque, and left +alone where it is dark or transparent. Any image editor's PNG will do (grey, RGB, palette, with or +without alpha, 1 or 8 bits a channel, not interlaced). Run this after changing a picture: + + python3 scripts/make_icons.py + +CI runs it with --check, which fails when the arrays are not what the pictures give. +""" +import os +import struct +import sys +import zlib + +ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") +SOURCE = os.path.join(ROOT, "assets", "icons") +SIZE = 32 + + +def read_png(path): + """The picture as rows of booleans: True where a pixel is drawn.""" + data = open(path, "rb").read() + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG") + at, idat, palette, alpha = 8, b"", None, None + while at < len(data): + (length,), kind = struct.unpack(">I", data[at:at + 4]), data[at + 4:at + 8] + body = data[at + 8:at + 8 + length] + at += 12 + length + if kind == b"IHDR": + width, height, depth, colour, _, _, interlace = struct.unpack(">IIBBBBB", body) + elif kind == b"PLTE": + palette = [body[i:i + 3] for i in range(0, len(body), 3)] + elif kind == b"tRNS": + alpha = body + elif kind == b"IDAT": + idat += body + if interlace or depth not in (1, 8) or (depth == 1 and colour not in (0, 3)): + raise ValueError("save it as a plain 8-bit or 1-bit PNG, not interlaced") + channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[colour] + step = channels if depth == 8 else 1 # bytes a pixel, for the filters + stride = (width * channels * depth + 7) // 8 + raw, rows, before = zlib.decompress(idat), [], bytes(stride) + for y in range(height): + kind, line = raw[y * (stride + 1)], bytearray(raw[y * (stride + 1) + 1:(y + 1) * (stride + 1)]) + for i in range(stride): + a = line[i - step] if i >= step else 0 + b, c = before[i], before[i - step] if i >= step else 0 + if kind == 1: + line[i] = (line[i] + a) & 255 + elif kind == 2: + line[i] = (line[i] + b) & 255 + elif kind == 3: + line[i] = (line[i] + (a + b) // 2) & 255 + elif kind == 4: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + line[i] = (line[i] + (a if pa <= pb and pa <= pc else b if pb <= pc else c)) & 255 + before = bytes(line) + row = [] + for x in range(width): + if depth == 1: + v = (line[x // 8] >> (7 - x % 8)) & 1 + px = [v * 255] if colour == 0 else [v] + else: + px = line[x * channels:(x + 1) * channels] + if colour == 3: + opaque = alpha is None or px[0] >= len(alpha) or alpha[px[0]] > 127 + light = sum(palette[px[0]]) > 381 + elif colour in (0, 4): + opaque, light = colour == 0 or px[1] > 127, px[0] > 127 + else: + opaque, light = colour == 2 or px[3] > 127, sum(px[:3]) > 381 + row.append(opaque and light) + rows.append(row) + return rows + + +def main(): + names = sorted(f[:-4] for f in os.listdir(SOURCE) if f.endswith(".png")) + header = ["// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.", "#pragma once", "", + "#include ", "", "namespace roro::icons {", "", + "// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes.", + f"constexpr int kSize = {SIZE};", ""] + body = ["// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file.", '#include "icons.h"', "", + "namespace roro::icons {", ""] + for name in names: + rows = read_png(os.path.join(SOURCE, name + ".png")) + if len(rows) != SIZE or len(rows[0]) != SIZE: + sys.exit(f"{name}.png is {len(rows[0])} x {len(rows)}: an icon is {SIZE} x {SIZE}") + ident = name.replace("-", "_") + header.append(f"extern const uint8_t {ident}[{SIZE * SIZE // 8}]; // {name}.png") + body.append(f"const uint8_t {ident}[{SIZE * SIZE // 8}] = {{") + for row in rows: + packed = [sum(0x80 >> i for i in range(8) if row[b * 8 + i]) for b in range(SIZE // 8)] + body.append(" " + ", ".join(f"0x{v:02x}" for v in packed) + ", // " + "".join("#" if v else "." for v in row)) + body += ["};", ""] + header += ["", "} // namespace roro::icons", ""] + body += ["} // namespace roro::icons", ""] + out = {os.path.join(ROOT, "src", "ui", "icons.h"): "\n".join(header), os.path.join(ROOT, "src", "ui", "icons.cpp"): "\n".join(body)} + if "--check" in sys.argv: + stale = [os.path.relpath(p, ROOT) for p, text in out.items() if not os.path.exists(p) or open(p).read() != text] + if stale: + sys.exit("out of date: " + ", ".join(stale) + " (run scripts/make_icons.py)") + print(f"{len(names)} icons, up to date") + return + for path, text in out.items(): + open(path, "w").write(text) + print(f"{len(names)} icons written") + + +if __name__ == "__main__": + main() diff --git a/site/content/dev/build/build-and-test.md b/site/content/dev/build/build-and-test.md index 58c0252..6ef5f98 100644 --- a/site/content/dev/build/build-and-test.md +++ b/site/content/dev/build/build-and-test.md @@ -20,6 +20,8 @@ scripts/ci.sh This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`. +`scripts/make_icons.py` turns the Launcher's icons (`assets/icons/.png`, 32 × 32, one colour) into the arrays in `src/ui/icons.cpp`; run it after changing a picture. CI fails when the two don't match. + `scripts/coverage.sh` runs the same tests with coverage counters and writes a line-by-line report to `.pio/coverage/index.html`. The badge above is its figure for `main`: the share of the lines of `lib/` that the host tests run. `lib/` is the logic that compiles on a PC; `lib/SD` (the card's driver) and `src/` (the Apps, the Services, everything that needs the device) have no host tests and aren't in that figure. The framework is rebuilt with the TLS settings in `platformio.ini` (`custom_sdkconfig`, ADR 0006), so the first build after a fresh checkout takes about 6 minutes; later builds take about 15 seconds when little has changed. The platform knows the framework is already rebuilt by `sdkconfig.defaults` in the project folder, which it writes and git ignores: delete it and the next build rebuilds the framework. CI keeps that file, a build cache and ccache in its volume (docs/milestones/R1.md). diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index af94d64..a3c6397 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -8,7 +8,7 @@ docs = true source = "docs/milestones/U1.md" tag = "U1" +++ -**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). Not started: screen recording (#17), a Launcher of tiles (#9), themes (#10). +**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) is done, not released yet. Not started: screen recording (#17), themes (#10). **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. @@ -68,6 +68,24 @@ A screenshot could only be taken by typing `screenshot` in the Shell, where "now **Not checked:** the real Fn+p on the keyboard (the mapper is host-tested); the two Toasts that refuse, which weren't looked at (one of them is on the page that mustn't be photographed); a device with no card. +## The Launcher as a grid (issue #9) + +The Launcher was a list of names. It is now a grid of icons. + +| Question in the issue | Decided | +|---|---| +| Layout | **4 × 3** tiles of 60 × 36, so the 11 Apps are on one page. Only the selected App's name is written, on a line under the grid: a name under every tile doesn't fit 60 pixels ("LoRa Scanner"). More than 12 Apps: the rows scroll | +| Icons | **The website's own**: its nine 16 × 16 pictures (`site/static/img/icons-dark.svg`), doubled to 32 × 32, and two drawn the same way for SSH and the Shell. 1 bit a pixel (128 bytes each, in flash). The pictures are PNGs in `assets/icons/`, named after the App's id; `scripts/make_icons.py` writes `src/ui/icons.cpp` from them and CI checks the two match | +| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile dark blue (#000055) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is cyan too | +| Live state | **A dot** on the tile, from `App::badge()`: IRC (unread messages), GNSS (a Track recording), the LoRa Scanner (a Capture running), SSH (a session open). The Launcher asks every App on each pass and redraws when an answer changes | +| Order | As registered, as before | +| Shortcuts | None for now | +| The list | Kept: **Settings > Launcher**, Grid or List. Both keep the same selection | + +- `GridModel` (`lib/ui`) holds the selection and the scrolling, host-tested: left and right go through every item and wrap; up and down stay in the column, and land on the last item where the last row is short. +- An App registered without an icon shows its first letter in a frame. +- No animation: not asked for, and not measured. + ## The website's colours, on every screen Every screen takes its colours from `src/ui/theme.h`, so the palette changed there, to the website's (`site/static/css/site.css`, dark side). Each is a colour the RGB332 frame buffer holds exactly. diff --git a/site/content/guide/basics.md b/site/content/guide/basics.md index b175408..82cb41a 100644 --- a/site/content/guide/basics.md +++ b/site/content/guide/basics.md @@ -36,7 +36,13 @@ While you type text (a note, an IRC line, a setting), `;` `.` `,` `/` type their ## The Launcher -The home screen lists the Apps. Move with the arrows, open one with Enter. Back inside an App returns here. +The home screen shows the Apps as a grid of icons, four to a row, with the name of the selected one written under the grid. Move with the four arrows, open an App with Enter. Back inside an App returns here. + +Left and right go through every App and wrap around; up and down stay in the column. + +**An orange dot on an icon** says something is going on in that App: unread messages in IRC, a Track being recorded in GNSS, a Capture running in the LoRa Scanner, a session open in SSH. + +If you prefer the list of names the Launcher used to be, **Settings → Launcher** switches between **Grid** and **List**. In the list, a `*` on the right stands for the dot. ## The Status Bar diff --git a/site/content/guide/settings.md b/site/content/guide/settings.md index 88605ed..c7cf21a 100644 --- a/site/content/guide/settings.md +++ b/site/content/guide/settings.md @@ -15,6 +15,7 @@ Move with the arrows. On a toggle, a choice or a slider, left and right change t | **Timezone** | For the clock and the dates in file names | | **Brightness** | A slider | | **Dim after**, **Screen off after** | Screen timeouts. Dimming must come before the screen turns off | +| **Launcher** | The home screen as a **Grid** of icons or a **List** of names (see [The basics](/guide/basics/#the-launcher)) | | **Sound & LED** | The beep and the flash on a [Toast](/guide/basics/) | | **GNSS** | Switches the receiver on and off (see [GNSS](/guide/gnss/)) | | **Pause GNSS for LoRa** | Puts the receiver on standby while the radio listens (see [LoRa Scanner](/guide/lora-scanner/)) | diff --git a/site/data/keys.toml b/site/data/keys.toml index 97549d8..3c8084a 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -36,7 +36,7 @@ rows = [ id = "launcher" title = "The Launcher" rows = [ - ["; .", "up, down"], + ["; . , /", "move"], ["Enter", "open the App"], ] diff --git a/src/apps/gnss_app.h b/src/apps/gnss_app.h index c32ce6f..bc0aa19 100644 --- a/src/apps/gnss_app.h +++ b/src/apps/gnss_app.h @@ -18,6 +18,7 @@ class GnssApp : public App { void update(uint32_t nowMs) override; void draw(Canvas& c) override; void help(std::vector& out) const override; + bool badge() const override { return gnss_.tracking(); } private: void drawPosition(Canvas& c); diff --git a/src/apps/irc_app.h b/src/apps/irc_app.h index c7f3457..5f900c8 100644 --- a/src/apps/irc_app.h +++ b/src/apps/irc_app.h @@ -26,6 +26,7 @@ class IrcApp : public App { void draw(Canvas& c) override; void help(std::vector& out) const override; const char* helpTitle() const override; + bool badge() const override { return irc_.totalUnread() > 0; } private: enum class Page { Chat, Settings }; diff --git a/src/apps/launcher_app.cpp b/src/apps/launcher_app.cpp index 10af6da..a8d5ae7 100644 --- a/src/apps/launcher_app.cpp +++ b/src/apps/launcher_app.cpp @@ -1,27 +1,110 @@ #include "launcher_app.h" +#include "ui/fonts.h" +#include "ui/icons.h" #include "ui/widgets.h" namespace roro { -void LauncherApp::onEnter() { list_.setCount(static_cast(manager_->visibleApps().size())); } +void LauncherApp::onEnter() { + int count = static_cast(manager_->visibleApps().size()); + list_.setCount(count); + grid_.setCount(count); + badges_ = badges(); +} bool LauncherApp::onKey(const KeyEvent& e) { + bool list = asList(); switch (e.key) { - case Key::Up: list_.up(); break; - case Key::Down: list_.down(); break; + case Key::Up: list ? list_.up() : grid_.up(); break; + case Key::Down: list ? list_.down() : grid_.down(); break; + case Key::Left: + if (list) return false; + grid_.left(); + break; + case Key::Right: + if (list) return false; + grid_.right(); + break; case Key::Select: if (list_.selected() >= 0) manager_->open(manager_->visibleApps()[list_.selected()]->id); return true; default: return false; } + // The view that didn't move follows. + if (list) grid_.select(list_.selected()); + else list_.select(grid_.selected()); requestRedraw(); return true; } -void LauncherApp::draw(Canvas& c) { +uint32_t LauncherApp::badges() const { + uint32_t bits = 0; auto apps = manager_->visibleApps(); - widgets::list(c, list_, theme::kContent, [&](int i) { return std::string(apps[i]->title); }); + for (size_t i = 0; i < apps.size() && i < 32; ++i) + if (apps[i]->app->badge()) bits |= 1u << i; + return bits; +} + +void LauncherApp::update(uint32_t) { + uint32_t now = badges(); + if (now == badges_) return; + badges_ = now; + requestRedraw(); +} + +void LauncherApp::draw(Canvas& c) { + if (!asList()) return drawGrid(c); + auto apps = manager_->visibleApps(); + widgets::list(c, list_, theme::kContent, [&](int i) { return std::string(apps[i]->title); }, + [&](int i) { return std::string(badges_ & (1u << i) ? "*" : ""); }); +} + +void LauncherApp::drawGrid(Canvas& c) { + using namespace theme; + auto apps = manager_->visibleApps(); + int selected = grid_.selected(); + for (int row = 0; row < kRows; ++row) { + for (int col = 0; col < kCols; ++col) { + int i = (grid_.firstVisibleRow() + row) * kCols + col; + if (i >= static_cast(apps.size())) break; + int x = kContent.x + col * kTileW, y = kContent.y + row * kTileH; + int ix = x + (kTileW - icons::kSize) / 2, iy = y + (kTileH - icons::kSize) / 2; + // As on the website: cyan on black, and the chosen one dark on a cyan tile with notched corners. + uint16_t ink = i == selected ? kOnAccent : kAccent; + if (i == selected) { + int tx = ix - 6, tw = icons::kSize + 12; + c.fillRect(tx + 4, y, tw - 8, kTileH, kAccent); + c.fillRect(tx + 2, y + 2, tw - 4, kTileH - 4, kAccent); + c.fillRect(tx, y + 4, tw, kTileH - 8, kAccent); + } + if (apps[i]->icon) { + c.drawBitmap(ix, iy, apps[i]->icon, icons::kSize, icons::kSize, ink); + } else { // an App without a picture: its first letter, framed + c.drawRect(ix + 2, iy + 2, icons::kSize - 4, icons::kSize - 4, ink); + c.setFont(&fonts::large); + c.setTextColor(ink); + c.setTextDatum(middle_center); + c.drawString(std::string(1, apps[i]->title[0]).c_str(), ix + icons::kSize / 2, iy + icons::kSize / 2); + } + if (badges_ & (1u << i)) { // a square dot on the icon's corner, with a margin to stand out from it + c.fillRect(ix + icons::kSize - 6, iy - 1, 10, 10, i == selected ? kAccent : kBackground); + c.fillRect(ix + icons::kSize - 4, iy + 1, 6, 6, kWarning); + } + } + } + // More rows than fit: where these are among them. + if (grid_.rows() > kRows) { + int h = kRows * kTileH, barH = h * kRows / grid_.rows(); + c.fillRect(kContent.x + kContent.w - 2, kContent.y + (h - barH) * grid_.firstVisibleRow() / (grid_.rows() - kRows), 2, barH, kMuted); + } + if (selected >= 0) { + c.setFont(&fonts::bold); + c.setTextColor(kAccent); + c.setTextDatum(top_center); + c.drawString(apps[selected]->title, kContent.x + kContent.w / 2, kContent.y + kRows * kTileH + 1); + } + c.setTextDatum(top_left); } } // namespace roro diff --git a/src/apps/launcher_app.h b/src/apps/launcher_app.h index 2c05b70..21cd77a 100644 --- a/src/apps/launcher_app.h +++ b/src/apps/launcher_app.h @@ -3,23 +3,40 @@ #include "app.h" #include "app_keys.h" #include "app_manager.h" +#include "grid_model.h" #include "list_model.h" +#include "settings.h" #include "ui/theme.h" namespace roro { -// Home screen: lists the visible Apps; Select opens one. +// Home screen: the visible Apps as a grid of icons, with the selected one's name under it, or as +// a list of names (Settings > Launcher). Select opens one. An App with something going on has a +// dot on its tile (App::badge, issue #9). class LauncherApp : public App { public: void setManager(AppManager& manager) { manager_ = &manager; } + void setSettings(const Settings& settings) { settings_ = &settings; } void onEnter() override; bool onKey(const KeyEvent& e) override; + void update(uint32_t nowMs) override; void draw(Canvas& c) override; void help(std::vector& out) const override { keys::add(out, keys::kLauncher); } private: + static constexpr int kCols = 4, kRows = 3; + static constexpr int kTileW = theme::kWidth / kCols, kTileH = 36; + + bool asList() const { return settings_ && settings_->getBool(Setting::LauncherList); } + uint32_t badges() const; // one bit a visible App + void drawGrid(Canvas& c); + AppManager* manager_ = nullptr; + const Settings* settings_ = nullptr; + // One selection, kept in both: the view can change in the Settings. ListModel list_{theme::kContent.h / theme::kLineHeight}; + GridModel grid_{kCols, kRows}; + uint32_t badges_ = 0; }; } // namespace roro diff --git a/src/apps/lora_scanner_app.h b/src/apps/lora_scanner_app.h index c42e3c3..cbc5ee1 100644 --- a/src/apps/lora_scanner_app.h +++ b/src/apps/lora_scanner_app.h @@ -28,6 +28,7 @@ class LoraScannerApp : public App { void draw(Canvas& c) override; void help(std::vector& out) const override; const char* helpTitle() const override; + bool badge() const override { return capture_.capturing(); } private: enum class View { Packets, Details, Presets, Sweep }; diff --git a/src/apps/ssh_app.h b/src/apps/ssh_app.h index d21271f..e76ecc6 100644 --- a/src/apps/ssh_app.h +++ b/src/apps/ssh_app.h @@ -28,6 +28,7 @@ class SshApp : public App { bool textEntryActive() const override { return view_ == View::Entry || (view_ == View::Session && !dialog_); } void help(std::vector& out) const override; const char* helpTitle() const override; + bool badge() const override { return ssh_.active(); } void update(uint32_t nowMs) override; void draw(Canvas& c) override; diff --git a/src/main.cpp b/src/main.cpp index f08fd80..63d9926 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -62,6 +62,7 @@ #include "safe_mode.h" #include "settings.h" #include "storage_paths.h" +#include "ui/icons.h" #include "ui/notifier.h" #include "ui/screen.h" #include "scp_args.h" @@ -230,19 +231,20 @@ void setup() { apps = new AppManager(launcher); launcher.setManager(*apps); - apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus)}); - apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService)}); - apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings)}); - apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService)}); - apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService)}); - apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi))}); - apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)}); + launcher.setSettings(settings); + apps->registerApp({"irc", "IRC", false, new IrcApp(*irc, *clockService, bus), icons::irc}); + apps->registerApp({"wifi-tools", "Wi-Fi Tools", false, new WifiToolsApp(*wifi, *storageService, *clockService), icons::wifi_tools}); + apps->registerApp({"gnss", "GNSS", false, new GnssApp(*gnssService, settings), icons::gnss}); + apps->registerApp({"gemini", "Gemini", false, new GeminiApp(*geminiService), icons::gemini}); + apps->registerApp({"lora", "LoRa Scanner", false, new LoraScannerApp(*radioService, *loraCapture, settings, *clockService), icons::lora}); + apps->registerApp({"storage", "Storage", false, new StorageApp(*fileOps, *storageService, *clockService, *update, *power, bus, *new WebShare(*storageService, *fileOps, *wifi)), icons::storage}); + apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power), icons::notes}); sshService = new SshService(settings); sshApp = new SshApp(*sshService, settings, *storageService); scpService = new ScpService(settings, *storageService, *fileOps); - apps->registerApp({"ssh", "SSH", false, sshApp}); + apps->registerApp({"ssh", "SSH", false, sshApp, icons::ssh}); shellApp = new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps); - apps->registerApp({"shell", "Shell", false, shellApp}); + apps->registerApp({"shell", "Shell", false, shellApp, icons::shell}); shellApp->asking = [] { return scpService->asking(); }; shellApp->answer = [](const std::string& password) { scpService->answer(password); }; shellApp->abort = [] { scpService->cancel(); }; @@ -252,9 +254,9 @@ void setup() { // A long note being rewritten (issue #47): the editor waits for the card, so it draws from there. NoteEditor::onProgress = [](const std::string& name, int percent) { screen.renderUpdate("Saving", name, percent); }; apps->registerApp({"system", "System", false, - new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs)}); + new SystemApp(*wifi, *battery, *storageService, *radioService, *gnssService, nvs), icons::system}); apps->registerApp({"settings", "Settings", false, - new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService})}); + new SettingsApp({settings, bus, *apps, *battery, *storageService, *clockService, *wifi, *savedNetworks, *update, *vpnService}), icons::settings}); apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)}); diff --git a/src/ui/icons.cpp b/src/ui/icons.cpp new file mode 100644 index 0000000..f34f0c5 --- /dev/null +++ b/src/ui/icons.cpp @@ -0,0 +1,391 @@ +// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file. +#include "icons.h" + +namespace roro::icons { + +const uint8_t gemini[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x33, 0x30, 0x00, 0x0c, // ..##..##..##................##.. + 0x33, 0x30, 0x00, 0x0c, // ..##..##..##................##.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##.. + 0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##.. + 0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##.. + 0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##.. + 0x30, 0x0c, 0x0c, 0x0c, // ..##........##......##......##.. + 0x30, 0x0c, 0x0c, 0x0c, // ..##........##......##......##.. + 0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##.. + 0x30, 0x30, 0x30, 0x0c, // ..##......##......##........##.. + 0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##.. + 0x30, 0xc0, 0xc0, 0x0c, // ..##....##......##..........##.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t gnss[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x3f, 0xc0, 0x00, // ..........########.............. + 0x00, 0x3f, 0xc0, 0x00, // ..........########.............. + 0x03, 0xc0, 0x0f, 0x00, // ......####..........####........ + 0x03, 0xc0, 0x0f, 0x00, // ......####..........####........ + 0x0c, 0x00, 0x0f, 0x30, // ....##..............####..##.... + 0x0c, 0x00, 0x0f, 0x30, // ....##..............####..##.... + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##.. + 0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##.. + 0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##.. + 0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##.. + 0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##.. + 0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##.. + 0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##.. + 0x3c, 0x30, 0x0c, 0x0c, // ..####....##........##......##.. + 0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##.. + 0x00, 0x30, 0x0c, 0x0c, // ..........##........##......##.. + 0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##.. + 0x30, 0x0f, 0xf0, 0x0c, // ..##........########........##.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x30, 0x00, 0x00, 0x0c, // ..##........................##.. + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x03, 0xc0, 0x03, 0xc0, // ......####............####...... + 0x03, 0xc0, 0x03, 0xc0, // ......####............####...... + 0x00, 0x3f, 0xfc, 0x00, // ..........############.......... + 0x00, 0x3f, 0xfc, 0x00, // ..........############.......... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t irc[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x0f, 0xff, 0xff, 0xf0, // ....########################.... + 0x0f, 0xff, 0xff, 0xf0, // ....########################.... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x0f, 0xff, 0xff, 0xf0, // ....########################.... + 0x0f, 0xff, 0xff, 0xf0, // ....########################.... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t lora[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x0c, 0x00, 0x00, // ............##.................. + 0x00, 0x0c, 0x00, 0x00, // ............##.................. + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x0c, 0x00, 0x00, // ............##.................. + 0x00, 0x0c, 0x00, 0x00, // ............##.................. + 0x00, 0x0c, 0x0c, 0x00, // ............##......##.......... + 0x00, 0x0c, 0x0c, 0x00, // ............##......##.......... + 0x00, 0x0c, 0x00, 0x00, // ............##.................. + 0x00, 0x0c, 0x00, 0x00, // ............##.................. + 0x00, 0x0c, 0x0c, 0x00, // ............##......##.......... + 0x00, 0x0c, 0x0c, 0x00, // ............##......##.......... + 0x00, 0x0c, 0x0c, 0x00, // ............##......##.......... + 0x00, 0x0c, 0x0c, 0x00, // ............##......##.......... + 0x00, 0xcc, 0x0c, 0xc0, // ........##..##......##..##...... + 0x00, 0xcc, 0x0c, 0xc0, // ........##..##......##..##...... + 0x00, 0xcc, 0xcc, 0xc0, // ........##..##..##..##..##...... + 0x00, 0xcc, 0xcc, 0xc0, // ........##..##..##..##..##...... + 0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##...... + 0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##...... + 0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##...... + 0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##...... + 0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##...... + 0x0c, 0xcc, 0xcc, 0xc0, // ....##..##..##..##..##..##...... + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x3f, 0xff, 0xff, 0xfc, // ..############################.. + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t notes[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0xc3, 0x0c, 0x00, // ........##....##....##.......... + 0x00, 0xc3, 0x0c, 0x00, // ........##....##....##.......... + 0x00, 0xc3, 0x0c, 0x00, // ........##....##....##.......... + 0x00, 0xc3, 0x0c, 0x00, // ........##....##....##.......... + 0x0f, 0xff, 0xff, 0xf0, // ....########################.... + 0x0f, 0xff, 0xff, 0xf0, // ....########################.... + 0x0c, 0xc3, 0x0c, 0x30, // ....##..##....##....##....##.... + 0x0c, 0xc3, 0x0c, 0x30, // ....##..##....##....##....##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0xff, 0xff, 0x30, // ....##..################..##.... + 0x0c, 0xff, 0xff, 0x30, // ....##..################..##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0xff, 0xff, 0x30, // ....##..################..##.... + 0x0c, 0xff, 0xff, 0x30, // ....##..################..##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0xff, 0xc0, 0x30, // ....##..##########........##.... + 0x0c, 0xff, 0xc0, 0x30, // ....##..##########........##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0f, 0xff, 0xff, 0xf0, // ....########################.... + 0x0f, 0xff, 0xff, 0xf0, // ....########################.... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t settings[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x0f, 0xf0, 0x00, // ............########............ + 0x00, 0x0f, 0xf0, 0x00, // ............########............ + 0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####...... + 0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####...... + 0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##.... + 0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##...... + 0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##...... + 0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####.. + 0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####.. + 0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##.. + 0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##.. + 0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##.. + 0x30, 0x30, 0x0c, 0x0c, // ..##......##........##......##.. + 0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####.. + 0x3c, 0x3c, 0x3c, 0x3c, // ..####....####....####....####.. + 0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##...... + 0x03, 0x0f, 0xf0, 0xc0, // ......##....########....##...... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##.... + 0x0c, 0x30, 0x0c, 0x30, // ....##....##........##....##.... + 0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####...... + 0x03, 0xcc, 0x33, 0xc0, // ......####..##....##..####...... + 0x00, 0x0f, 0xf0, 0x00, // ............########............ + 0x00, 0x0f, 0xf0, 0x00, // ............########............ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t shell[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x3c, 0x00, 0x00, 0x00, // ..####.......................... + 0x3c, 0x00, 0x00, 0x00, // ..####.......................... + 0x0f, 0x00, 0x00, 0x00, // ....####........................ + 0x0f, 0x00, 0x00, 0x00, // ....####........................ + 0x03, 0xc0, 0x00, 0x00, // ......####...................... + 0x03, 0xc0, 0x00, 0x00, // ......####...................... + 0x00, 0xf0, 0x00, 0x00, // ........####.................... + 0x00, 0xf0, 0x00, 0x00, // ........####.................... + 0x00, 0x3c, 0x00, 0x00, // ..........####.................. + 0x00, 0x3c, 0x00, 0x00, // ..........####.................. + 0x00, 0x0f, 0x00, 0x00, // ............####................ + 0x00, 0x0f, 0x00, 0x00, // ............####................ + 0x00, 0x3c, 0x00, 0x00, // ..........####.................. + 0x00, 0x3c, 0x00, 0x00, // ..........####.................. + 0x00, 0xf0, 0x00, 0x00, // ........####.................... + 0x00, 0xf0, 0x00, 0x00, // ........####.................... + 0x03, 0xc0, 0x00, 0x00, // ......####...................... + 0x03, 0xc0, 0x00, 0x00, // ......####...................... + 0x0f, 0x00, 0x00, 0x00, // ....####........................ + 0x0f, 0x00, 0x00, 0x00, // ....####........................ + 0x3c, 0x00, 0xff, 0xfc, // ..####..........##############.. + 0x3c, 0x00, 0xff, 0xfc, // ..####..........##############.. + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t ssh[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x0f, 0xf0, 0x00, 0x00, // ....########.................... + 0x0f, 0xf0, 0x00, 0x00, // ....########.................... + 0x30, 0x0c, 0x00, 0x00, // ..##........##.................. + 0x30, 0x0c, 0x00, 0x00, // ..##........##.................. + 0xc0, 0x03, 0x00, 0x00, // ##............##................ + 0xc0, 0x03, 0x00, 0x00, // ##............##................ + 0xc3, 0xc3, 0xff, 0xff, // ##....####....################## + 0xc3, 0xc3, 0xff, 0xff, // ##....####....################## + 0xc3, 0xc3, 0x00, 0xcc, // ##....####....##........##..##.. + 0xc3, 0xc3, 0x00, 0xcc, // ##....####....##........##..##.. + 0xc0, 0x03, 0x00, 0xcc, // ##............##........##..##.. + 0xc0, 0x03, 0x00, 0xcc, // ##............##........##..##.. + 0x30, 0x0c, 0x00, 0x00, // ..##........##.................. + 0x30, 0x0c, 0x00, 0x00, // ..##........##.................. + 0x0f, 0xf0, 0x00, 0x00, // ....########.................... + 0x0f, 0xf0, 0x00, 0x00, // ....########.................... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t storage[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x03, 0xff, 0xf0, 0x00, // ......##############............ + 0x03, 0xff, 0xf0, 0x00, // ......##############............ + 0x03, 0x00, 0x0c, 0x00, // ......##............##.......... + 0x03, 0x00, 0x0c, 0x00, // ......##............##.......... + 0x03, 0x33, 0x03, 0x00, // ......##..##..##......##........ + 0x03, 0x33, 0x03, 0x00, // ......##..##..##......##........ + 0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##...... + 0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##...... + 0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##...... + 0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##...... + 0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##...... + 0x03, 0x33, 0x00, 0xc0, // ......##..##..##........##...... + 0x03, 0x00, 0x00, 0xc0, // ......##................##...... + 0x03, 0x00, 0x00, 0xc0, // ......##................##...... + 0x03, 0x00, 0x00, 0xc0, // ......##................##...... + 0x03, 0x00, 0x00, 0xc0, // ......##................##...... + 0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##...... + 0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##...... + 0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##...... + 0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##...... + 0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##...... + 0x03, 0x30, 0x0c, 0xc0, // ......##..##........##..##...... + 0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##...... + 0x03, 0x3f, 0xfc, 0xc0, // ......##..############..##...... + 0x03, 0x00, 0x00, 0xc0, // ......##................##...... + 0x03, 0x00, 0x00, 0xc0, // ......##................##...... + 0x03, 0xff, 0xff, 0xc0, // ......####################...... + 0x03, 0xff, 0xff, 0xc0, // ......####################...... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t system[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0xff, 0xff, 0x00, // ........################........ + 0x00, 0xff, 0xff, 0x00, // ........################........ + 0x0f, 0xc0, 0x03, 0xf0, // ....######............######.... + 0x0f, 0xc0, 0x03, 0xf0, // ....######............######.... + 0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........ + 0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........ + 0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........ + 0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........ + 0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........ + 0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........ + 0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........ + 0x00, 0xcf, 0xf3, 0x00, // ........##..########..##........ + 0x0f, 0xc0, 0x03, 0xf0, // ....######............######.... + 0x0f, 0xc0, 0x03, 0xf0, // ....######............######.... + 0x00, 0xff, 0xff, 0x00, // ........################........ + 0x00, 0xff, 0xff, 0x00, // ........################........ + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +const uint8_t wifi_tools[128] = { + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x3f, 0xfc, 0x00, // ..........############.......... + 0x00, 0x3f, 0xfc, 0x00, // ..........############.......... + 0x03, 0xc0, 0x03, 0xc0, // ......####............####...... + 0x03, 0xc0, 0x03, 0xc0, // ......####............####...... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x0c, 0x00, 0x00, 0x30, // ....##....................##.... + 0x30, 0x3f, 0xfc, 0x0c, // ..##......############......##.. + 0x30, 0x3f, 0xfc, 0x0c, // ..##......############......##.. + 0x03, 0xc0, 0x03, 0xc0, // ......####............####...... + 0x03, 0xc0, 0x03, 0xc0, // ......####............####...... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x0f, 0xf0, 0x00, // ............########............ + 0x00, 0x0f, 0xf0, 0x00, // ............########............ + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x30, 0x0c, 0x00, // ..........##........##.......... + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x03, 0xc0, 0x00, // ..............####.............. + 0x00, 0x03, 0xc0, 0x00, // ..............####.............. + 0x00, 0x03, 0xc0, 0x00, // ..............####.............. + 0x00, 0x03, 0xc0, 0x00, // ..............####.............. + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ + 0x00, 0x00, 0x00, 0x00, // ................................ +}; + +} // namespace roro::icons diff --git a/src/ui/icons.h b/src/ui/icons.h new file mode 100644 index 0000000..0e1e610 --- /dev/null +++ b/src/ui/icons.h @@ -0,0 +1,23 @@ +// Made by scripts/make_icons.py from assets/icons/*.png: change the pictures, not this file. +#pragma once + +#include + +namespace roro::icons { + +// One bit a pixel, the leftmost first, a row after another: what Canvas::drawBitmap takes. +constexpr int kSize = 32; + +extern const uint8_t gemini[128]; // gemini.png +extern const uint8_t gnss[128]; // gnss.png +extern const uint8_t irc[128]; // irc.png +extern const uint8_t lora[128]; // lora.png +extern const uint8_t notes[128]; // notes.png +extern const uint8_t settings[128]; // settings.png +extern const uint8_t shell[128]; // shell.png +extern const uint8_t ssh[128]; // ssh.png +extern const uint8_t storage[128]; // storage.png +extern const uint8_t system[128]; // system.png +extern const uint8_t wifi_tools[128]; // wifi-tools.png + +} // namespace roro::icons diff --git a/test/test_grid_model/test_grid_model.cpp b/test/test_grid_model/test_grid_model.cpp new file mode 100644 index 0000000..6e7d110 --- /dev/null +++ b/test/test_grid_model/test_grid_model.cpp @@ -0,0 +1,97 @@ +#include + +#include "grid_model.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_empty_grid_has_no_selection() { + GridModel g(4, 3); + TEST_ASSERT_EQUAL(-1, g.selected()); + g.right(); + g.down(); + TEST_ASSERT_EQUAL(-1, g.selected()); + TEST_ASSERT_EQUAL(0, g.rows()); +} + +void test_left_and_right_go_through_every_item() { + GridModel g(4, 3); + g.setCount(11); + TEST_ASSERT_EQUAL(0, g.selected()); + g.right(); + TEST_ASSERT_EQUAL(1, g.selected()); + g.select(3); + g.right(); // onto the next row + TEST_ASSERT_EQUAL(4, g.selected()); + g.left(); + TEST_ASSERT_EQUAL(3, g.selected()); + g.select(10); + g.right(); // around + TEST_ASSERT_EQUAL(0, g.selected()); + g.left(); + TEST_ASSERT_EQUAL(10, g.selected()); +} + +void test_up_and_down_stay_in_the_column() { + GridModel g(4, 3); + g.setCount(11); // rows of 4, 4 and 3 + g.select(1); + g.down(); + TEST_ASSERT_EQUAL(5, g.selected()); + g.down(); + TEST_ASSERT_EQUAL(9, g.selected()); + g.down(); // around + TEST_ASSERT_EQUAL(1, g.selected()); + g.up(); + TEST_ASSERT_EQUAL(9, g.selected()); +} + +void test_a_short_last_row_gives_its_last_item() { + GridModel g(4, 3); + g.setCount(11); + g.select(7); // the fourth column, which the last row doesn't have + g.down(); + TEST_ASSERT_EQUAL(10, g.selected()); + g.select(3); + g.up(); + TEST_ASSERT_EQUAL(10, g.selected()); +} + +void test_rows_scroll_to_keep_the_selection_in_view() { + GridModel g(4, 3); + g.setCount(18); // 5 rows + TEST_ASSERT_EQUAL(5, g.rows()); + TEST_ASSERT_EQUAL(0, g.firstVisibleRow()); + g.select(12); // the fourth row + TEST_ASSERT_EQUAL(1, g.firstVisibleRow()); + g.select(17); + TEST_ASSERT_EQUAL(2, g.firstVisibleRow()); + g.select(5); + TEST_ASSERT_EQUAL(1, g.firstVisibleRow()); + g.select(0); + TEST_ASSERT_EQUAL(0, g.firstVisibleRow()); +} + +void test_a_smaller_count_keeps_the_selection_inside() { + GridModel g(4, 3); + g.setCount(18); + g.select(17); + g.setCount(6); + TEST_ASSERT_EQUAL(5, g.selected()); + TEST_ASSERT_EQUAL(0, g.firstVisibleRow()); + g.setCount(0); + TEST_ASSERT_EQUAL(-1, g.selected()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_empty_grid_has_no_selection); + RUN_TEST(test_left_and_right_go_through_every_item); + RUN_TEST(test_up_and_down_stay_in_the_column); + RUN_TEST(test_a_short_last_row_gives_its_last_item); + RUN_TEST(test_rows_scroll_to_keep_the_selection_in_view); + RUN_TEST(test_a_smaller_count_keeps_the_selection_inside); + return UNITY_END(); +} diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp index acc1a26..293c36c 100644 --- a/test/test_settings_menu/test_settings_menu.cpp +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -46,6 +46,15 @@ void test_toggle_rows_flip_on_select() { TEST_ASSERT_FALSE(f.settings.getBool(Setting::Sound)); } +void test_the_launcher_is_a_grid_or_a_list() { + Fixture f; + int launcher = f.row(SettingsMenu::Row::Launcher); + TEST_ASSERT_EQUAL_STRING("Grid", f.menu.value(launcher).c_str()); + f.menu.toggle(launcher); + TEST_ASSERT_TRUE(f.settings.getBool(Setting::LauncherList)); + TEST_ASSERT_EQUAL_STRING("List", f.menu.value(launcher).c_str()); +} + void test_choice_rows_list_options_and_mark_the_current_one() { Fixture f; int tz = f.row(SettingsMenu::Row::Timezone); @@ -161,6 +170,7 @@ int main() { RUN_TEST(test_every_row_has_a_label); RUN_TEST(test_values_are_human_readable); RUN_TEST(test_toggle_rows_flip_on_select); + RUN_TEST(test_the_launcher_is_a_grid_or_a_list); RUN_TEST(test_choice_rows_list_options_and_mark_the_current_one); RUN_TEST(test_choosing_applies_the_setting); RUN_TEST(test_choice_that_breaks_a_rule_is_refused_with_a_reason); -- 2.53.0 From 10627e6e18edc7fac43bec7a6cfbcc47c5c4a5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Fri, 9 Oct 2026 23:23:20 +0200 Subject: [PATCH 4/6] Launcher: the selected icon in slate, the name in white The selected icon was dark blue on its cyan tile, which read as black: it is the Status Bar's slate now. The App's name under the grid is white instead of cyan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- docs/milestones/U1.md | 2 +- site/content/dev/milestones/u1.md | 2 +- src/apps/launcher_app.cpp | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index 571ef5d..e1dc112 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -68,7 +68,7 @@ The Launcher was a list of names. It is now a grid of icons. |---|---| | Layout | **4 × 3** tiles of 60 × 36, so the 11 Apps are on one page. Only the selected App's name is written, on a line under the grid: a name under every tile doesn't fit 60 pixels ("LoRa Scanner"). More than 12 Apps: the rows scroll | | Icons | **The website's own**: its nine 16 × 16 pictures (`site/static/img/icons-dark.svg`), doubled to 32 × 32, and two drawn the same way for SSH and the Shell. 1 bit a pixel (128 bytes each, in flash). The pictures are PNGs in `assets/icons/`, named after the App's id; `scripts/make_icons.py` writes `src/ui/icons.cpp` from them and CI checks the two match | -| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile dark blue (#000055) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is cyan too | +| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile slate (#494955, the Status Bar's) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is white | | Live state | **A dot** on the tile, from `App::badge()`: IRC (unread messages), GNSS (a Track recording), the LoRa Scanner (a Capture running), SSH (a session open). The Launcher asks every App on each pass and redraws when an answer changes | | Order | As registered, as before | | Shortcuts | None for now | diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index a3c6397..11bbb2c 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -76,7 +76,7 @@ The Launcher was a list of names. It is now a grid of icons. |---|---| | Layout | **4 × 3** tiles of 60 × 36, so the 11 Apps are on one page. Only the selected App's name is written, on a line under the grid: a name under every tile doesn't fit 60 pixels ("LoRa Scanner"). More than 12 Apps: the rows scroll | | Icons | **The website's own**: its nine 16 × 16 pictures (`site/static/img/icons-dark.svg`), doubled to 32 × 32, and two drawn the same way for SSH and the Shell. 1 bit a pixel (128 bytes each, in flash). The pictures are PNGs in `assets/icons/`, named after the App's id; `scripts/make_icons.py` writes `src/ui/icons.cpp` from them and CI checks the two match | -| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile dark blue (#000055) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is cyan too | +| Colours | **The website's**: cyan (#00dbff) on black, and the selected tile slate (#494955, the Status Bar's) on a cyan fill with notched corners. Both are RGB332 colours. The name under the grid is white | | Live state | **A dot** on the tile, from `App::badge()`: IRC (unread messages), GNSS (a Track recording), the LoRa Scanner (a Capture running), SSH (a session open). The Launcher asks every App on each pass and redraws when an answer changes | | Order | As registered, as before | | Shortcuts | None for now | diff --git a/src/apps/launcher_app.cpp b/src/apps/launcher_app.cpp index a8d5ae7..5101bdf 100644 --- a/src/apps/launcher_app.cpp +++ b/src/apps/launcher_app.cpp @@ -70,8 +70,9 @@ void LauncherApp::drawGrid(Canvas& c) { if (i >= static_cast(apps.size())) break; int x = kContent.x + col * kTileW, y = kContent.y + row * kTileH; int ix = x + (kTileW - icons::kSize) / 2, iy = y + (kTileH - icons::kSize) / 2; - // As on the website: cyan on black, and the chosen one dark on a cyan tile with notched corners. - uint16_t ink = i == selected ? kOnAccent : kAccent; + // As on the website: cyan on black, and the chosen one on a cyan tile with notched corners, + // in the Status Bar's slate. + uint16_t ink = i == selected ? kFaint : kAccent; if (i == selected) { int tx = ix - 6, tw = icons::kSize + 12; c.fillRect(tx + 4, y, tw - 8, kTileH, kAccent); @@ -100,7 +101,7 @@ void LauncherApp::drawGrid(Canvas& c) { } if (selected >= 0) { c.setFont(&fonts::bold); - c.setTextColor(kAccent); + c.setTextColor(kText); c.setTextDatum(top_center); c.drawString(apps[selected]->title, kContent.x + kContent.w / 2, kContent.y + kRows * kTileH + 1); } -- 2.53.0 From 37335b4829ad6e17762a2e834ac6c179301b90c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sat, 10 Oct 2026 00:11:52 +0200 Subject: [PATCH 5/6] UI: seven themes, each dark and light, chosen in the Settings Settings > Theme and Settings > Light or dark pick the colours of every screen (issue #10): roro9stack (the website's, the default), Catppuccin, Dracula, Nord, ANSI terminal, Gruvbox and Solarized. - A palette is 14 roles (lib/ui/src/palette.h). theme::kText and the others are now references into the one in use, so the Apps are unchanged; the main loop applies it when a Setting changes. - Every colour is one the RGB332 frame buffer holds exactly: the themes' own were rounded, and those that fell together or lost contrast were picked by hand. test_palette checks exactness and 18 contrast ratios for each palette. - The Sky view's constellation colours come from the theme. - `theme [0-6] [light|dark]` on the consoles. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- README.md | 1 + docs/milestones/U1.md | 27 ++++- lib/apps_model/src/settings_menu.cpp | 14 ++- lib/apps_model/src/settings_menu.h | 2 +- lib/services/src/settings.cpp | 2 + lib/services/src/settings.h | 2 + lib/ui/src/palette.cpp | 59 ++++++++++ lib/ui/src/palette.h | 29 +++++ site/content/dev/debug/commands.md | 3 +- site/content/dev/milestones/u1.md | 27 ++++- site/content/guide/settings.md | 10 ++ src/apps/gnss_app.cpp | 8 +- src/main.cpp | 20 +++- src/ui/screen.h | 2 + src/ui/theme.h | 37 ++++--- test/test_palette/test_palette.cpp | 101 ++++++++++++++++++ .../test_settings_menu/test_settings_menu.cpp | 16 +++ 17 files changed, 336 insertions(+), 24 deletions(-) create mode 100644 lib/ui/src/palette.cpp create mode 100644 lib/ui/src/palette.h create mode 100644 test/test_palette/test_palette.cpp diff --git a/README.md b/README.md index 671e9e3..4f2fb2f 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow | `burst` | Publishes 5 Notifications at once | | `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key ` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | +| `theme` / `theme <0-6> [light\|dark]` | Lists the colour themes, or picks one and its side | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi ip dhcp` / `wifi ip
/ [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try ` to go back to the previous setting unless `wifi ip keep` follows | diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index e1dc112..42f01ae 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -1,6 +1,6 @@ # U1 — Look and feel -**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) is done, not released yet. Not started: screen recording (#17), themes (#10). +**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) and themes (#10) are done, not released yet. Not started: screen recording (#17). **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. @@ -94,3 +94,28 @@ Every screen takes its colours from `src/ui/theme.h`, so the palette changed the | Text on a Toast | black | dark blue | Looked at on the device, by screenshot: the Launcher, IRC, Wi-Fi Tools (menu, channel occupancy), GNSS (position, sky), Gemini, the LoRa Scanner (Sniffer, Sweep, presets), Storage, Notes, SSH, the Shell, System, Settings and the help panel. Not looked at: dialogs, a warning or a message Toast, the SSH terminal (which keeps its own 16 ANSI colours), the Setup screens. + +## Themes (issue #10) + +Seven themes, each with a dark and a light side, chosen in **Settings > Theme** and **Settings > Light or dark**: roro9stack (the website's, the default), Catppuccin (Mocha and Latte), Dracula (and Alucard), Nord, ANSI terminal, Gruvbox, Solarized. + +| Question in the issue | Decided | +|---|---| +| Which themes | The seven above. Not the high-contrast and phosphor ones the issue suggested: ANSI terminal dark (grey and green on black) and light (black on white) come close | +| Fonts and spacing too | No: colours only | +| Themes from the SD card | No: built in | +| A preview while picking | The Setting applies at once, so the Settings screen is the preview | +| Constellation colours | From the theme (its accent, its "good", and three roles named red, yellow and violet), so they read on every background | +| Light by day, dark by night | No | + +- **A palette is 14 roles** (`lib/ui/src/palette.h`): background, text, muted, faint, the bar and its idle marks, accent, what is drawn on an accent, warning, message, good, and red, yellow and violet. +- **`theme::kText` and the others are now references** into the palette in use (`src/ui/theme.h`), so no App changed: they still read as constants. The main loop applies the palette when either Setting changes, and draws everything again. +- **RGB332.** Every colour in the table is one the frame buffer holds exactly. The themes' own colours were rounded to those; where two roles fell together or lost contrast, one was picked by hand (Nord's accent and muted text, Gruvbox's greys, Solarized's surfaces). `test_palette` checks that every colour is exact, and 18 contrast ratios for each of the 14 palettes (text on background at 4.5 or more, the rest at 2 to 3). +- **What it costs in fidelity:** blue has four levels, so Dracula and Nord share a dark background (#242455), which was Catppuccin's too: Catppuccin dark is on black instead (its "crust"), with that blue for its bar and its brighter accents (peach, pink, green, sky), to tell it from Dracula; Solarized light's cream becomes white, and Gruvbox's dark brown becomes a dark olive (#242400). +- `theme [0-6] [light|dark]` on the consoles lists them or picks one. + +**Looked at on the device**, by screenshot: the Launcher in all 14; and in Gruvbox light, Settings, GNSS (position and sky), the Sweep, Storage, the help panel, Gemini and Wi-Fi Tools' channel occupancy. Nothing was drawn for black only. + +**Kept as they are:** the SSH terminal's 16 ANSI colours and its black background, the Sweep's waterfall scale, pictures, and the screen shown while the device powers off. + +**Not looked at:** the other 12 palettes beyond the Launcher, dialogs and Toasts in any theme but the default, the Setup screens. In Gruvbox light, the Sky view's GPS, GLONASS and BeiDou colours are three close shades of brown and red. diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index bec139c..f71c707 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -1,6 +1,7 @@ #include "settings_menu.h" #include "choices.h" +#include "palette.h" namespace roro { @@ -19,7 +20,8 @@ const RowDef kRows[] = { {Row::LongName, Kind::Text, "Long name"}, {Row::ShortName, Kind::Text, "Short name"}, {Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"}, {Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"}, - {Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Launcher, Kind::Toggle, "Launcher"}, {Row::Sound, Kind::Toggle, "Sound & LED"}, + {Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Launcher, Kind::Toggle, "Launcher"}, + {Row::Theme, Kind::Choice, "Theme"}, {Row::ThemeLight, Kind::Toggle, "Light or dark"}, {Row::Sound, Kind::Toggle, "Sound & LED"}, {Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}, {Row::Coordinates, Kind::Toggle, "Coordinates"}, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, @@ -81,6 +83,8 @@ std::string SettingsMenu::value(int i) const { case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS)); case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS)); case Row::Launcher: return settings_.getBool(Setting::LauncherList) ? "List" : "Grid"; + case Row::Theme: return ui::themeName(settings_.getInt(Setting::Theme)); + case Row::ThemeLight: return settings_.getBool(Setting::ThemeLight) ? "Light" : "Dark"; case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off"; case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off"; case Row::GnssQuiet: return settings_.getBool(Setting::GnssQuietForLora) ? "On" : "Off"; @@ -98,6 +102,11 @@ std::vector SettingsMenu::choices(int i) const { switch (row(i)) { case Row::Region: return labels(kRegions); case Row::Timezone: return labels(kTimezones); + case Row::Theme: { + std::vector names; + for (int t = 0; t < ui::kThemeCount; ++t) names.push_back(ui::themeName(t)); + return names; + } case Row::DimTimeout: return durations(kDimSeconds); case Row::OffTimeout: return durations(kOffSeconds); default: return {}; @@ -108,6 +117,7 @@ int SettingsMenu::currentChoice(int i) const { switch (row(i)) { case Row::Region: return indexOf(kRegions, settings_.getString(Setting::Region)); case Row::Timezone: return indexOf(kTimezones, settings_.getString(Setting::Timezone)); + case Row::Theme: return settings_.getInt(Setting::Theme); case Row::DimTimeout: return indexOf(kDimSeconds, settings_.getInt(Setting::DimTimeoutS)); case Row::OffTimeout: return indexOf(kOffSeconds, settings_.getInt(Setting::OffTimeoutS)); default: return -1; @@ -121,6 +131,7 @@ std::string SettingsMenu::choose(int i, int c) { settings_.setBool(Setting::RegionConfirmed, true); return ""; case Row::Timezone: settings_.setString(Setting::Timezone, kTimezones[c].value); return ""; + case Row::Theme: settings_.setInt(Setting::Theme, c); return ""; case Row::DimTimeout: return settings_.setInt(Setting::DimTimeoutS, kDimSeconds[c]) ? "" : "Dimming must happen before screen off"; case Row::OffTimeout: @@ -130,6 +141,7 @@ std::string SettingsMenu::choose(int i, int c) { } void SettingsMenu::toggle(int i) { + if (row(i) == Row::ThemeLight) settings_.setBool(Setting::ThemeLight, !settings_.getBool(Setting::ThemeLight)); if (row(i) == Row::Launcher) settings_.setBool(Setting::LauncherList, !settings_.getBool(Setting::LauncherList)); if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound)); if (row(i) == Row::Gnss) settings_.setBool(Setting::GnssEnabled, !settings_.getBool(Setting::GnssEnabled)); diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 661d1e5..87cb46c 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -11,7 +11,7 @@ namespace roro { // values, choice lists and validation messages. Rendering and navigation live in the App. class SettingsMenu { public: - enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About }; + enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About }; enum class Kind { Text, Choice, Toggle, Slider, Page }; explicit SettingsMenu(Settings& settings) : settings_(settings) {} diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp index d8fd9f5..648a62c 100644 --- a/lib/services/src/settings.cpp +++ b/lib/services/src/settings.cpp @@ -54,6 +54,8 @@ const Definition kDefinitions[] = { {"ssh_public", Kind::String, 0, "", 0, 200}, {"ssh_font", Kind::Int, 1, nullptr, 0, 4}, {"launcher_list", Kind::Bool, 0, nullptr, 0, 1}, // off: the grid + {"theme", Kind::Int, 0, nullptr, 0, 6}, // roro9stack's own; ui::kThemeCount of them + {"theme_light", Kind::Bool, 0, nullptr, 0, 1}, }; static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast(Setting::Count), "every Setting needs a definition"); diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h index a857b04..4638da7 100644 --- a/lib/services/src/settings.h +++ b/lib/services/src/settings.h @@ -42,6 +42,8 @@ enum class Setting : uint8_t { SshPublic, // string: its public half, the line to put in a server's authorized_keys SshFont, // int: the terminal's font, 0 (smallest) to 4 LauncherList, // bool: the Launcher is a list of names instead of a grid of icons (issue #9) + Theme, // int: the colour theme, an index into ui::palette (issue #10) + ThemeLight, // bool: its light side instead of its dark one Count }; diff --git a/lib/ui/src/palette.cpp b/lib/ui/src/palette.cpp new file mode 100644 index 0000000..e03af7f --- /dev/null +++ b/lib/ui/src/palette.cpp @@ -0,0 +1,59 @@ +#include "palette.h" + +namespace roro::ui { + +namespace { +// 0xRRGGBB to RGB565. Every colour below is one the RGB332 frame buffer holds exactly: three bits +// of red and of green (00 24 49 6d 92 b6 db ff) and two of blue (00 55 aa ff). The themes' own +// colours were rounded to those, then the ones that fell together or lost their contrast were +// chosen by hand (test_palette checks both). +constexpr uint16_t c(uint32_t rgb) { + return static_cast(((rgb >> 19) & 0x1F) << 11 | ((rgb >> 10) & 0x3F) << 5 | ((rgb >> 3) & 0x1F)); +} + +const char* const kNames[kThemeCount] = {"roro9stack", "Catppuccin", "Dracula", "Nord", "ANSI terminal", "Gruvbox", "Solarized"}; + +// background, text, muted, faint, bar, barMuted, accent, onAccent, warning, message, good, red, yellow, violet +constexpr Palette kPalettes[kThemeCount][2] = { + { + {c(0x000000), c(0xffffff), c(0x9292aa), c(0x494955), c(0x494955), c(0xb6b6aa), c(0x00dbff), c(0x000055), c(0xff9200), c(0xff92ff), c(0x49db55), c(0xff2455), c(0xffdb00), c(0xb692ff)}, // roro9stack, dark + {c(0xffffff), c(0x000055), c(0x4949aa), c(0xb6dbff), c(0xb6dbff), c(0x4949aa), c(0x0092aa), c(0xffffff), c(0xb64900), c(0xb600aa), c(0x006d00), c(0xb60000), c(0x926d00), c(0x6d49ff)}, // roro9stack, light + }, + { + {c(0x000000), c(0xdbdbff), c(0x9292ff), c(0x494955), c(0x242455), c(0xb6b6ff), c(0xdbb6ff), c(0x000000), c(0xffb655), c(0xff92ff), c(0x92ffaa), c(0xff6daa), c(0xffdb55), c(0x6db6ff)}, // Catppuccin, dark + {c(0xffffff), c(0x242455), c(0x6d6daa), c(0xb6b6aa), c(0xb6b6aa), c(0x494955), c(0x9249ff), c(0xffffff), c(0xdb6d00), c(0xdb6daa), c(0x499255), c(0xdb0055), c(0xdb9200), c(0x246dff)}, // Catppuccin, light + }, + { + {c(0x242455), c(0xffffff), c(0x6d6daa), c(0x494955), c(0x494955), c(0xb6b6aa), c(0xb692ff), c(0x242455), c(0xffb655), c(0xff6daa), c(0x49ff55), c(0xff4955), c(0xffffaa), c(0x92dbff)}, // Dracula, dark + {c(0xffffff), c(0x242400), c(0x6d6d55), c(0xdbdbff), c(0xdbdbff), c(0x6d6d55), c(0x6d49aa), c(0xffffff), c(0x924900), c(0x922455), c(0x246d00), c(0xdb4900), c(0x926d00), c(0x006daa)}, // Dracula, light + }, + { + {c(0x242455), c(0xdbffff), c(0x9292aa), c(0x494955), c(0x494955), c(0xdbdbff), c(0x92dbff), c(0x242455), c(0xdb9255), c(0xb692aa), c(0x92b6aa), c(0xb66d55), c(0xdbdbaa), c(0x9292aa)}, // Nord, dark + {c(0xdbffff), c(0x242455), c(0x494955), c(0xdbdbff), c(0xdbdbff), c(0x494955), c(0x496daa), c(0xdbffff), c(0xb64900), c(0x9249aa), c(0x496d00), c(0xb66d55), c(0xb66d00), c(0x9292aa)}, // Nord, light + }, + { + {c(0x000000), c(0xb6b6aa), c(0x6d6d55), c(0x494955), c(0x0000aa), c(0xb6b6aa), c(0x49ff55), c(0x000000), c(0xffff55), c(0xff49ff), c(0x49ffff), c(0xff4955), c(0xffff55), c(0x4949ff)}, // ANSI terminal, dark + {c(0xffffff), c(0x000000), c(0x494955), c(0xb6b6aa), c(0xb6b6aa), c(0x494955), c(0x0000aa), c(0xffffff), c(0xb64900), c(0xb600aa), c(0x006d00), c(0xb60000), c(0xb64900), c(0x00b6aa)}, // ANSI terminal, light + }, + { + {c(0x242400), c(0xdbdbaa), c(0x929255), c(0x492400), c(0x492400), c(0xdbb6aa), c(0xffb655), c(0x242400), c(0xff9200), c(0xdb92aa), c(0xb6b600), c(0xff4955), c(0xffb655), c(0x92b6aa)}, // Gruvbox, dark + {c(0xffffaa), c(0x494955), c(0x6d6d55), c(0xdbdbaa), c(0xdbdbaa), c(0x494955), c(0xb64900), c(0xffffaa), c(0xb66d00), c(0x924955), c(0x6d6d00), c(0x920000), c(0xb66d00), c(0x006d55)}, // Gruvbox, light + }, + { + {c(0x002455), c(0xb6b6aa), c(0x6d92aa), c(0x004955), c(0x004955), c(0x9292aa), c(0x2492aa), c(0xffffff), c(0xdb4900), c(0xdb24aa), c(0x929200), c(0xdb2455), c(0xb69200), c(0x6d6daa)}, // Solarized, dark + {c(0xffffff), c(0x244955), c(0x9292aa), c(0xdbdbaa), c(0xdbdbaa), c(0x6d6daa), c(0x2492aa), c(0xffffff), c(0xdb4900), c(0xdb24aa), c(0x929200), c(0xdb2455), c(0xb69200), c(0x6d6daa)}, // Solarized, light + }, +}; +} // namespace + +const char* themeName(int theme) { return kNames[theme < 0 || theme >= kThemeCount ? 0 : theme]; } + +const Palette& palette(int theme, bool light) { return kPalettes[theme < 0 || theme >= kThemeCount ? 0 : theme][light ? 1 : 0]; } + +uint32_t rgb888(uint16_t c) { + // As the display shows an RGB332 pixel: the bits kept, spread over 8. + uint32_t r = ((c >> 13) & 7) * 0x49 >> 1, g = ((c >> 8) & 7) * 0x49 >> 1, b = ((c >> 3) & 3) * 0x55; + return r << 16 | g << 8 | b; +} + +} // namespace roro::ui diff --git a/lib/ui/src/palette.h b/lib/ui/src/palette.h new file mode 100644 index 0000000..a7cc30a --- /dev/null +++ b/lib/ui/src/palette.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +// The colour themes (issue #10): each a dark and a light set of the same roles, as RGB565. +namespace roro::ui { + +struct Palette { + uint16_t background, text; + uint16_t muted; // secondary text + uint16_t faint; // grid lines, what stays behind + uint16_t bar; // the Status Bar's background + uint16_t barMuted; // what is idle, on the bar + uint16_t accent; // what is chosen, what is live: also the fill behind a selected row + uint16_t onAccent; // text on an accent, warning or message fill + uint16_t warning; + uint16_t message; // someone wrote + uint16_t good; // a Fix, the best of a set + uint16_t red, yellow, violet; // to tell things apart (the constellations of the Sky view) +}; + +constexpr int kThemeCount = 7; +const char* themeName(int theme); // "roro9stack", "Catppuccin", "Dracula", ... +const Palette& palette(int theme, bool light); // out of range: the first + +// What the 8-bit frame buffer shows for a colour, as 0xRRGGBB. +uint32_t rgb888(uint16_t rgb565); + +} // namespace roro::ui diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index 62de628..17ad8b8 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -37,7 +37,7 @@ gemini get fetch a Gemini page and report header, size, certificate, heap irc start | irc stop | irc dump | irc say install Update from SD update check | update list | update status | update install the project's releases on Gitea -sd card | sd list | cat | log | burst | sound on|off | short | normal +sd card | sd list | cat | log | burst | sound on|off | theme [0-6] [light|dark] | short | normal Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command ping [count] [size] | nslookup [server] | port | traceroute | cancel is it there, does its name resolve, is its port open, which way; one at a time tls [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one @@ -71,6 +71,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | `burst` | Publishes 5 Notifications at once | | `key up\|down\|left\|right\|select\|back\|home\|del\|tab\|space\|help\|shot`, or `key ` | Injects a key press (`help` is Fn+h: the keys of the screen that is showing; `shot` is Fn+p: a screenshot). `ctrl-`, `alt-` and `shift-` before it hold that key: `key ctrl-down`, `key alt-up`, `key ctrl-b` | | `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | +| `theme` / `theme <0-6> [light\|dark]` | Lists the colour themes, or picks one and its side | | `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | | `wifi add ` | Adds a Saved Network (so credentials stay out of the repo) | | `wifi ip dhcp` / `wifi ip
/ [gateway]` | A Saved Network's IP setting: Automatic, or Fixed. Add `try ` to go back to the previous setting unless `wifi ip keep` follows | diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index 11bbb2c..f51253c 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -8,7 +8,7 @@ docs = true source = "docs/milestones/U1.md" tag = "U1" +++ -**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) is done, not released yet. Not started: screen recording (#17), themes (#10). +**Status:** in progress. Shipped: the help key (issue #69) and the key tables the website shares with it (#72), both in **v0.13.0**; the screenshot key (#83, **v0.17.0**). The Launcher as a grid of icons (#9) and themes (#10) are done, not released yet. Not started: screen recording (#17). **Goal:** the interface is consistent and uncrowded: the same thing is done the same way on every screen, and the 135 pixels of height go to content. @@ -102,3 +102,28 @@ Every screen takes its colours from `src/ui/theme.h`, so the palette changed the | Text on a Toast | black | dark blue | Looked at on the device, by screenshot: the Launcher, IRC, Wi-Fi Tools (menu, channel occupancy), GNSS (position, sky), Gemini, the LoRa Scanner (Sniffer, Sweep, presets), Storage, Notes, SSH, the Shell, System, Settings and the help panel. Not looked at: dialogs, a warning or a message Toast, the SSH terminal (which keeps its own 16 ANSI colours), the Setup screens. + +## Themes (issue #10) + +Seven themes, each with a dark and a light side, chosen in **Settings > Theme** and **Settings > Light or dark**: roro9stack (the website's, the default), Catppuccin (Mocha and Latte), Dracula (and Alucard), Nord, ANSI terminal, Gruvbox, Solarized. + +| Question in the issue | Decided | +|---|---| +| Which themes | The seven above. Not the high-contrast and phosphor ones the issue suggested: ANSI terminal dark (grey and green on black) and light (black on white) come close | +| Fonts and spacing too | No: colours only | +| Themes from the SD card | No: built in | +| A preview while picking | The Setting applies at once, so the Settings screen is the preview | +| Constellation colours | From the theme (its accent, its "good", and three roles named red, yellow and violet), so they read on every background | +| Light by day, dark by night | No | + +- **A palette is 14 roles** (`lib/ui/src/palette.h`): background, text, muted, faint, the bar and its idle marks, accent, what is drawn on an accent, warning, message, good, and red, yellow and violet. +- **`theme::kText` and the others are now references** into the palette in use (`src/ui/theme.h`), so no App changed: they still read as constants. The main loop applies the palette when either Setting changes, and draws everything again. +- **RGB332.** Every colour in the table is one the frame buffer holds exactly. The themes' own colours were rounded to those; where two roles fell together or lost contrast, one was picked by hand (Nord's accent and muted text, Gruvbox's greys, Solarized's surfaces). `test_palette` checks that every colour is exact, and 18 contrast ratios for each of the 14 palettes (text on background at 4.5 or more, the rest at 2 to 3). +- **What it costs in fidelity:** blue has four levels, so Dracula and Nord share a dark background (#242455), which was Catppuccin's too: Catppuccin dark is on black instead (its "crust"), with that blue for its bar and its brighter accents (peach, pink, green, sky), to tell it from Dracula; Solarized light's cream becomes white, and Gruvbox's dark brown becomes a dark olive (#242400). +- `theme [0-6] [light|dark]` on the consoles lists them or picks one. + +**Looked at on the device**, by screenshot: the Launcher in all 14; and in Gruvbox light, Settings, GNSS (position and sky), the Sweep, Storage, the help panel, Gemini and Wi-Fi Tools' channel occupancy. Nothing was drawn for black only. + +**Kept as they are:** the SSH terminal's 16 ANSI colours and its black background, the Sweep's waterfall scale, pictures, and the screen shown while the device powers off. + +**Not looked at:** the other 12 palettes beyond the Launcher, dialogs and Toasts in any theme but the default, the Setup screens. In Gruvbox light, the Sky view's GPS, GLONASS and BeiDou colours are three close shades of brown and red. diff --git a/site/content/guide/settings.md b/site/content/guide/settings.md index c7cf21a..f16effa 100644 --- a/site/content/guide/settings.md +++ b/site/content/guide/settings.md @@ -16,6 +16,8 @@ Move with the arrows. On a toggle, a choice or a slider, left and right change t | **Brightness** | A slider | | **Dim after**, **Screen off after** | Screen timeouts. Dimming must come before the screen turns off | | **Launcher** | The home screen as a **Grid** of icons or a **List** of names (see [The basics](/guide/basics/#the-launcher)) | +| **Theme** | The colours of every screen: **roro9stack** (the website's), **Catppuccin**, **Dracula**, **Nord**, **ANSI terminal**, **Gruvbox** or **Solarized** | +| **Light or dark** | Which side of the theme: every one has both | | **Sound & LED** | The beep and the flash on a [Toast](/guide/basics/) | | **GNSS** | Switches the receiver on and off (see [GNSS](/guide/gnss/)) | | **Pause GNSS for LoRa** | Puts the receiver on standby while the radio listens (see [LoRa Scanner](/guide/lora-scanner/)) | @@ -49,3 +51,11 @@ Two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on What Fn + h shows on these screens. These tables are generated from the firmware's own lists, so they are always the current ones. {{ keys(scopes=["settings", "settings-choice", "wifi", "wifi-servers", "wifi-network", "wifi-status", "wifi-scan", "wifi-name", "vpn", "debug-console"]) }} + +## Themes + +A theme changes the colours and nothing else: the fonts and the layout stay. It applies at once, to every App, and is kept across restarts. + +The screen shows **256 colours**, so a theme here is the nearest the screen can do to the palette it is named after, not an exact copy: pale backgrounds turn white or cream, and dark ones turn a deep blue or olive. Each theme is checked for contrast after that rounding. + +Three things keep their own colours whatever the theme: the SSH terminal (the 16 colours a remote program asks for), the Sweep's waterfall (its colours are a scale of signal strength), and pictures. diff --git a/src/apps/gnss_app.cpp b/src/apps/gnss_app.cpp index bea063e..05b1ca7 100644 --- a/src/apps/gnss_app.cpp +++ b/src/apps/gnss_app.cpp @@ -17,14 +17,14 @@ using namespace gnss; namespace { -// One colour per constellation, readable in the 8-bit frame buffer. +// One colour per constellation, from the theme: readable on its background. uint16_t colorOf(Constellation c) { switch (c) { case Constellation::Gps: return theme::kAccent; - case Constellation::Glonass: return 0xF8C8; // red + case Constellation::Glonass: return theme::kRed; case Constellation::Galileo: return theme::kGood; - case Constellation::BeiDou: return 0xFEA0; // yellow - case Constellation::Qzss: return 0xC99F; // violet + case Constellation::BeiDou: return theme::kYellow; + case Constellation::Qzss: return theme::kViolet; default: return theme::kText; } } diff --git a/src/main.cpp b/src/main.cpp index 63d9926..b25df39 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -193,6 +193,7 @@ void setup() { Serial.begin(115200); settings.load(); + theme::apply(ui::palette(settings.getInt(Setting::Theme), settings.getBool(Setting::ThemeLight))); if (safeMode) return setupSafeMode(crashes); battery = new BatteryService(bus); @@ -694,7 +695,7 @@ static const char* const kHelp = "irc start | irc stop | irc dump | irc say \n" "install Update from SD\n" "update check | update list | update status | update install the project's releases on Gitea\n" - "sd card | sd list | cat | log | burst | sound on|off | short | normal\n" + "sd card | sd list | cat | log | burst | sound on|off | theme [0-6] [light|dark] | short | normal\n" "Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings open that App: a capital letter is an App, not a command\n" "ping [count] [size] | nslookup [server] | port | traceroute | cancel is it there, does its name resolve, is its port open, which way; one at a time\n" "tls [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n" @@ -1328,6 +1329,14 @@ static void runCommand(String line, bool fromSerial = false) { settings.setBool(Setting::DnsAlways, line.endsWith("on")); wifi->serversChanged(); } + if (line == "theme" || line.startsWith("theme ")) { // theme [0-6] [light|dark] + std::string rest = line.length() > 6 ? line.substring(6).c_str() : ""; + if (!rest.empty() && isdigit(static_cast(rest[0]))) settings.setInt(Setting::Theme, rest[0] - '0'); + if (rest.find("light") != std::string::npos) settings.setBool(Setting::ThemeLight, true); + if (rest.find("dark") != std::string::npos) settings.setBool(Setting::ThemeLight, false); + for (int t = 0; t < ui::kThemeCount; ++t) + console.printf("theme: %d %s%s\n", t, ui::themeName(t), t == settings.getInt(Setting::Theme) ? (settings.getBool(Setting::ThemeLight) ? " (light)" : " (dark)") : ""); + } if (line == "sound off") settings.setBool(Setting::Sound, false); if (line == "sound on") settings.setBool(Setting::Sound, true); if (line == "short") { @@ -1567,6 +1576,15 @@ static void loopPass() { StatusInfo status = currentStatus(); const Toast* toast = notifier->toasts().current(now); bool toastChanged = notifier->toasts().takeChanged(); + // The theme, when the Settings (or the `theme` command) changed it: every colour is drawn again. + static int shownTheme = settings.getInt(Setting::Theme) * 2 + settings.getBool(Setting::ThemeLight); + int wantedTheme = settings.getInt(Setting::Theme) * 2 + settings.getBool(Setting::ThemeLight); + if (wantedTheme != shownTheme) { + shownTheme = wantedTheme; + theme::apply(ui::palette(wantedTheme / 2, wantedTheme % 2)); + screen.invalidate(); + redrawAfterUpdate = true; + } if (apps->takeRedraw() || toastChanged || status != lastStatus || redrawAfterUpdate) { lastStatus = status; redrawAfterUpdate = false; diff --git a/src/ui/screen.h b/src/ui/screen.h index 8e49676..bcf5b23 100644 --- a/src/ui/screen.h +++ b/src/ui/screen.h @@ -14,6 +14,8 @@ class Screen { // Takes over the screen while a Firmware Update is received or about to restart. void renderUpdate(const std::string& title, const std::string& detail, int percent); Canvas& canvas() { return canvas_; } + // The colours changed: an App that keeps what it drew has to draw it all again. + void invalidate() { lost_ = true; } private: Canvas canvas_; diff --git a/src/ui/theme.h b/src/ui/theme.h index 4f0ce7d..1c15ef3 100644 --- a/src/ui/theme.h +++ b/src/ui/theme.h @@ -2,22 +2,31 @@ #include +#include "palette.h" + namespace roro::theme { -// The website's palette (site/static/css/site.css, its dark side), as RGB565. Every one is a colour -// the 8-bit frame buffer (RGB332) holds exactly, so what is asked for is what is shown. -constexpr uint16_t kBackground = 0x0000; -constexpr uint16_t kText = 0xFFFF; -constexpr uint16_t kAccent = 0x06DF; // cyan, #00dbff: what is chosen, what is live -constexpr uint16_t kOnAccent = 0x000A; // dark blue, #000055: text and pictures on a cyan fill -constexpr uint16_t kSelection = kAccent; // behind a selected row, whose text is kOnAccent -constexpr uint16_t kMuted = 0x9495; // grey, #9292aa: between the website's two -constexpr uint16_t kFaint = 0x4A4A; // slate, #494955: grid lines, what stays behind -constexpr uint16_t kBar = kFaint; // Status Bar background -constexpr uint16_t kBarMuted = 0xB5B5; // light grey, #b6b6aa: what is idle, on the bar -constexpr uint16_t kWarning = 0xFC80; // orange, #ff9200 -constexpr uint16_t kMessage = 0xFC9F; // pink, #ff92ff: someone wrote -constexpr uint16_t kGood = 0x4ECA; // green, #49db55: a Fix, the best of a set (not on the website) +// The colours every screen draws with: the theme chosen in the Settings (issue #10, lib/ui's +// palette.h), set at boot and whenever it changes. They read as constants, and are references into +// the palette in use, so a draw after apply() is in the new colours. +inline ui::Palette current = ui::palette(0, false); +inline void apply(const ui::Palette& p) { current = p; } + +inline const uint16_t& kBackground = current.background; +inline const uint16_t& kText = current.text; +inline const uint16_t& kAccent = current.accent; // what is chosen, what is live +inline const uint16_t& kOnAccent = current.onAccent; // text and pictures on an accent fill +inline const uint16_t& kSelection = current.accent; // behind a selected row, whose text is kOnAccent +inline const uint16_t& kMuted = current.muted; +inline const uint16_t& kFaint = current.faint; // grid lines, what stays behind +inline const uint16_t& kBar = current.bar; // Status Bar background +inline const uint16_t& kBarMuted = current.barMuted; // what is idle, on the bar +inline const uint16_t& kWarning = current.warning; +inline const uint16_t& kMessage = current.message; // someone wrote +inline const uint16_t& kGood = current.good; // a Fix, the best of a set +inline const uint16_t& kRed = current.red; // these three: to tell things apart +inline const uint16_t& kYellow = current.yellow; +inline const uint16_t& kViolet = current.violet; constexpr int kWidth = 240; constexpr int kHeight = 135; diff --git a/test/test_palette/test_palette.cpp b/test/test_palette/test_palette.cpp new file mode 100644 index 0000000..2d1fd3e --- /dev/null +++ b/test/test_palette/test_palette.cpp @@ -0,0 +1,101 @@ +#include + +#include +#include +#include + +#include "palette.h" + +using namespace roro::ui; + +void setUp() {} +void tearDown() {} + +namespace { +double channel(uint32_t v) { + double c = v / 255.0; + return c <= 0.03928 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4); +} +double luminance(uint16_t c) { + uint32_t rgb = rgb888(c); + return 0.2126 * channel(rgb >> 16) + 0.7152 * channel((rgb >> 8) & 255) + 0.0722 * channel(rgb & 255); +} +// The WCAG contrast ratio of two colours as the frame buffer shows them: 1 (the same) to 21. +double contrast(uint16_t a, uint16_t b) { + double la = luminance(a), lb = luminance(b); + return (std::fmax(la, lb) + 0.05) / (std::fmin(la, lb) + 0.05); +} + +void atLeast(double wanted, uint16_t a, uint16_t b, const char* what, int theme, bool light) { + char msg[96]; + std::snprintf(msg, sizeof msg, "%s %s: %s is %.1f, under %.1f", themeName(theme), light ? "light" : "dark", what, contrast(a, b), wanted); + TEST_ASSERT_TRUE_MESSAGE(contrast(a, b) >= wanted, msg); +} +} // namespace + +void test_seven_themes_with_names() { + TEST_ASSERT_EQUAL(7, kThemeCount); + TEST_ASSERT_EQUAL_STRING("roro9stack", themeName(0)); + for (int t = 0; t < kThemeCount; ++t) TEST_ASSERT_TRUE(std::string(themeName(t)).size() > 0 && std::string(themeName(t)).size() <= 14); + // Out of range: the first, rather than whatever is next in memory. + TEST_ASSERT_EQUAL_STRING("roro9stack", themeName(7)); + TEST_ASSERT_EQUAL_STRING("roro9stack", themeName(-1)); + TEST_ASSERT_EQUAL_PTR(&palette(0, true), &palette(99, true)); +} + +// The frame buffer keeps 3 bits of red and green and 2 of blue: a colour it would round is a +// colour that isn't the one shown. +void test_every_colour_is_one_the_frame_buffer_holds() { + for (int t = 0; t < kThemeCount; ++t) + for (int light = 0; light < 2; ++light) { + const Palette& p = palette(t, light); + const uint16_t* c = &p.background; + for (size_t i = 0; i < sizeof(Palette) / sizeof(uint16_t); ++i) { + uint32_t rgb = rgb888(c[i]); + uint16_t back = static_cast(((rgb >> 19) & 0x1F) << 11 | ((rgb >> 10) & 0x3F) << 5 | ((rgb >> 3) & 0x1F)); + TEST_ASSERT_EQUAL_HEX16(back, c[i]); + } + } +} + +void test_every_theme_reads() { + for (int t = 0; t < kThemeCount; ++t) + for (int light = 0; light < 2; ++light) { + const Palette& p = palette(t, light); + atLeast(4.5, p.text, p.background, "text", t, light); + atLeast(3, p.muted, p.background, "muted text", t, light); + atLeast(1.3, p.muted, p.text, "muted against text", t, light); + atLeast(3, p.accent, p.background, "the accent", t, light); + atLeast(3, p.onAccent, p.accent, "text on the accent", t, light); + atLeast(4.5, p.text, p.bar, "text on the bar", t, light); + atLeast(2.5, p.barMuted, p.bar, "idle marks on the bar", t, light); + atLeast(2, p.accent, p.bar, "live marks on the bar", t, light); + atLeast(3, p.warning, p.background, "a warning", t, light); + atLeast(3, p.message, p.background, "a message", t, light); + atLeast(3, p.good, p.background, "good", t, light); + atLeast(3, p.onAccent, p.warning, "text on a warning Toast", t, light); + atLeast(3, p.onAccent, p.message, "text on a message Toast", t, light); + atLeast(2, p.faint, p.accent, "the Launcher's selected icon", t, light); + atLeast(1.15, p.faint, p.background, "grid lines", t, light); + atLeast(2.5, p.red, p.background, "red", t, light); + atLeast(2.5, p.yellow, p.background, "yellow", t, light); + atLeast(2.5, p.violet, p.background, "violet", t, light); + } +} + +void test_the_first_is_the_websites() { + const Palette& p = palette(0, false); + TEST_ASSERT_EQUAL_HEX32(0x000000, rgb888(p.background)); + TEST_ASSERT_EQUAL_HEX32(0x00dbff, rgb888(p.accent)); + TEST_ASSERT_EQUAL_HEX32(0x494955, rgb888(p.bar)); + TEST_ASSERT_EQUAL_HEX32(0xffffff, rgb888(palette(0, true).background)); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_seven_themes_with_names); + RUN_TEST(test_every_colour_is_one_the_frame_buffer_holds); + RUN_TEST(test_every_theme_reads); + RUN_TEST(test_the_first_is_the_websites); + return UNITY_END(); +} diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp index 293c36c..da83d53 100644 --- a/test/test_settings_menu/test_settings_menu.cpp +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -46,6 +46,21 @@ void test_toggle_rows_flip_on_select() { TEST_ASSERT_FALSE(f.settings.getBool(Setting::Sound)); } +void test_the_theme_and_its_side() { + Fixture f; + int theme = f.row(SettingsMenu::Row::Theme), side = f.row(SettingsMenu::Row::ThemeLight); + TEST_ASSERT_EQUAL_STRING("roro9stack", f.menu.value(theme).c_str()); + TEST_ASSERT_EQUAL(7, static_cast(f.menu.choices(theme).size())); + TEST_ASSERT_EQUAL(0, f.menu.currentChoice(theme)); + TEST_ASSERT_EQUAL_STRING("", f.menu.choose(theme, 2).c_str()); + TEST_ASSERT_EQUAL_INT32(2, f.settings.getInt(Setting::Theme)); + TEST_ASSERT_EQUAL_STRING("Dracula", f.menu.value(theme).c_str()); + TEST_ASSERT_EQUAL_STRING("Dark", f.menu.value(side).c_str()); + f.menu.toggle(side); + TEST_ASSERT_TRUE(f.settings.getBool(Setting::ThemeLight)); + TEST_ASSERT_EQUAL_STRING("Light", f.menu.value(side).c_str()); +} + void test_the_launcher_is_a_grid_or_a_list() { Fixture f; int launcher = f.row(SettingsMenu::Row::Launcher); @@ -171,6 +186,7 @@ int main() { RUN_TEST(test_values_are_human_readable); RUN_TEST(test_toggle_rows_flip_on_select); RUN_TEST(test_the_launcher_is_a_grid_or_a_list); + RUN_TEST(test_the_theme_and_its_side); RUN_TEST(test_choice_rows_list_options_and_mark_the_current_one); RUN_TEST(test_choosing_applies_the_setting); RUN_TEST(test_choice_that_breaks_a_rule_is_refused_with_a_reason); -- 2.53.0 From 0c52613b72a0fe2cb955587b2caade9bac2f845a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Martin?= Date: Sat, 10 Oct 2026 00:11:52 +0200 Subject: [PATCH 6/6] Settings: its rows in five groups Settings had grown to 21 rows in one list. It opens on five groups (This device, Display, GNSS and radio, Network, System); Enter opens one, Back returns to the groups, and Back from the groups leaves Settings. SettingsMenu holds the groups and which one is open (host-tested: every setting is in exactly one group). The guide, the how-tos, the README, the scripts' messages and the firmware's own name the new paths, such as "Settings > System > Firmware". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk --- README.md | 18 ++-- docs/milestones/U1.md | 16 ++++ lib/apps_model/src/settings_menu.cpp | 95 +++++++++++++++---- lib/apps_model/src/settings_menu.h | 20 +++- lib/core/src/app_keys.h | 3 +- lib/gnss/src/geo_format.h | 2 +- scripts/flash.sh | 2 +- scripts/ota_push.py | 2 +- scripts/rdbg.py | 14 +-- scripts/release_build.sh | 2 +- scripts/sd_put.py | 2 +- site/content/dev/build/flash.md | 8 +- .../dev/build/how-an-update-works/index.md | 4 +- .../dev/build/how-an-update-works/ways-in.svg | 2 +- site/content/dev/debug/commands.md | 6 +- site/content/dev/debug/switch-it-on.md | 2 +- site/content/dev/milestones/u1.md | 16 ++++ site/content/faq.md | 4 +- site/content/guide/basics.md | 4 +- site/content/guide/gnss.md | 4 +- site/content/guide/lora-scanner.md | 2 +- site/content/guide/settings.md | 33 ++++++- site/content/guide/updates.md | 4 +- site/content/guide/vpn.md | 4 +- site/content/howto/no-dhcp.md | 4 +- site/content/howto/record-track.md | 2 +- site/content/howto/screenshot.md | 2 +- site/content/howto/update-from-sd.md | 2 +- site/content/howto/vpn.md | 2 +- site/data/keys.toml | 3 +- site/templates/downloads.html | 2 +- src/apps/debug_console_page.h | 2 +- src/apps/firmware_page.h | 2 +- src/apps/gnss_app.cpp | 2 +- src/apps/launcher_app.h | 2 +- src/apps/settings_app.cpp | 31 +++++- src/apps/settings_app.h | 2 +- src/apps/vpn_page.h | 2 +- src/apps/wifi_settings_page.h | 2 +- src/main.cpp | 12 +-- src/services/gnss_service.h | 2 +- src/services/update_service.cpp | 2 +- .../test_settings_menu/test_settings_menu.cpp | 56 ++++++++++- 43 files changed, 305 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 4f2fb2f..68ed7c6 100644 --- a/README.md +++ b/README.md @@ -94,19 +94,19 @@ scripts/ota_keygen.sh # once: creates the signing key (see ADR scripts/flash.sh --ota 10.39.39.12 # build, sign and push; or set RORO_OTA_HOST ``` -The device shows the push address in **Settings → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so. +The device shows the push address in **Settings → System → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so. -To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh ` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs). +To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → System → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh ` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs). **The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. (CI signs releases with a copy kept as a repository secret, ADR 0008.) ### Updates from Gitea -With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → Firmware**: +With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**: - **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. - **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question. -- **Settings → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device. +- **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device. The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed. @@ -116,7 +116,7 @@ The connection is checked against the two ISRG roots Let's Encrypt chains end in ## Networks without DHCP -Each Saved Network gets its address automatically (DHCP) or has a Fixed one (docs/milestones/S1.md): in Settings > Wi-Fi, Enter on a network opens its page, where "IP address" switches between Automatic and Fixed, with an address, a prefix length (24 is 255.255.255.0) and an optional gateway. Switching to Fixed starts from what the network is giving the device at that moment. The setting is checked and applied when you leave the page. IPv4 only. +Each Saved Network gets its address automatically (DHCP) or has a Fixed one (docs/milestones/S1.md): in Settings > Network > Wi-Fi, Enter on a network opens its page, where "IP address" switches between Automatic and Fixed, with an address, a prefix length (24 is 255.255.255.0) and an optional gateway. Switching to Fixed starts from what the network is giving the device at that moment. The setting is checked and applied when you leave the page. IPv4 only. "DNS and NTP" on the same screen holds two DNS servers (9.9.9.9 and 1.1.1.1 by default), used on Fixed networks, or on every network with "Always use my DNS"; and two NTP servers (pool.ntp.org and time.cloudflare.com), used after any the network's DHCP offers. Enter on "Status" shows what's in use and where each value came from. @@ -140,7 +140,7 @@ On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non- ## LoRa Scanner -The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops; for MeshCore packets what they are (an advert with the node's name, a message with the first byte of each node's key, a message on the public channel read, with the name its sender gives; any other channel message with the channel's byte) and the hops in their path. Enter shows a packet's details: what is never encrypted (the Meshtastic header; MeshCore's header, path and, in an advert, the node's name, kind, key and position; and a public channel message's sender, time and text, decrypted with the channel's published key) and a hex dump. `p` picks **MeshCore** (the default; its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8) or one of the 7 Meshtastic presets allowed in EU868, `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing. +The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops; for MeshCore packets what they are (an advert with the node's name, a message with the first byte of each node's key, a message on the public channel read, with the name its sender gives; any other channel message with the channel's byte) and the hops in their path. Enter shows a packet's details: what is never encrypted (the Meshtastic header; MeshCore's header, path and, in an advert, the node's name, kind, key and position; and a public channel message's sender, time and text, decrypted with the channel's published key) and a hex dump. `p` picks **MeshCore** (the default; its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8) or one of the 7 Meshtastic presets allowed in EU868, `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > GNSS and radio > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing. ## Storage @@ -188,7 +188,7 @@ The terminal (`lib/term`, host-tested) understands what a shell, `less`, `top`, ## VPN -A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on: one peer, IPv4. Copy a client's `.conf` to the card as `/vpn/wg0.conf` and import it in Settings > VPN (or `vpn import`); the configuration, private key included, is then kept in the device and never shown, and Settings offers to delete the file. A switch brings the tunnel up until the next restart; "Start with Wi-Fi" does it every time. It waits for the clock, which WireGuard needs. `VPN` shows in the Status Bar, bright once the server has answered. +A WireGuard tunnel (docs/milestones/N1.md), over whatever Wi-Fi the device is on: one peer, IPv4. Copy a client's `.conf` to the card as `/vpn/wg0.conf` and import it in Settings > Network > VPN (or `vpn import`); the configuration, private key included, is then kept in the device and never shown, and Settings offers to delete the file. A switch brings the tunnel up until the next restart; "Start with Wi-Fi" does it every time. It waits for the clock, which WireGuard needs. `VPN` shows in the Status Bar, bright once the server has answered. **What goes through it is one of two things:** everything, when AllowedIPs has `0.0.0.0/0` (and then nothing leaves the device while the server is silent), or the one subnet the device's tunnel address is in. A home network behind the server needs the first: lwIP routes by an interface's subnet or by default, nothing finer, and the import says how many ranges it can't reach. With the tunnel up the Debug Console and the Update Service answer on the tunnel address too, behind their token and their signature. It costs 63 KB of flash and under 2 KB of memory while up. @@ -231,7 +231,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow | `ls [folder]` / `du ` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | | `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead | | `cp [-f] ` / `mv [-f] ` / `rm [-r] [-f] ` / `mkdir ` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | -| `install ` | Update from SD with that `.ota` file, as Settings → Firmware does | +| `install ` | Update from SD with that `.ota` file, as Settings → System → Firmware does | | `update check` / `update list` / `update status` / `update install ` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one | | `update pretend ` / `update probe ` / `update damage cut\|flip ` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | | `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor | @@ -266,7 +266,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow ### The Debug Console -Every firmware has the Debug Console (ADR 0010): the serial console over Wi-Fi, on TCP 2323. It is **off** until you switch it on in **Settings → Debug Console**, which also makes its token and shows it. With the device on USB, `scripts/flash.sh --debug` flashes it, switches the console on and gives it the token in `~/.config/roro9stack/debug-token`, so nothing has to be typed. Then, with `RORO_OTA_HOST` set to the device's IP: +Every firmware has the Debug Console (ADR 0010): the serial console over Wi-Fi, on TCP 2323. It is **off** until you switch it on in **Settings → System → Debug Console**, which also makes its token and shows it. With the device on USB, `scripts/flash.sh --debug` flashes it, switches the console on and gives it the token in `~/.config/roro9stack/debug-token`, so nothing has to be typed. Then, with `RORO_OTA_HOST` set to the device's IP: ```sh scripts/rdbg.py # interactive: the console backlog, live lines, and commands diff --git a/docs/milestones/U1.md b/docs/milestones/U1.md index 42f01ae..c92190a 100644 --- a/docs/milestones/U1.md +++ b/docs/milestones/U1.md @@ -119,3 +119,19 @@ Seven themes, each with a dark and a light side, chosen in **Settings > Theme** **Kept as they are:** the SSH terminal's 16 ANSI colours and its black background, the Sweep's waterfall scale, pictures, and the screen shown while the device powers off. **Not looked at:** the other 12 palettes beyond the Launcher, dialogs and Toasts in any theme but the default, the Setup screens. In Gruvbox light, the Sky view's GPS, GLONASS and BeiDou colours are three close shades of brown and red. + +## Settings in groups + +Settings had grown to 21 rows in one list. It now opens on five groups, each a short list of its own: + +| Group | Rows | +|---|---| +| This device | Long name, Short name, Region, Timezone, Sound & LED | +| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher | +| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs | +| Network | Wi-Fi, VPN | +| System | Check for updates, Firmware, Debug Console, About | + +- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows. +- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name. +- The guide, the how-tos, the README, the scripts' messages and the firmware's own (the Toast for a new release, the GNSS App's hint) name the new paths: "Settings > System > Firmware". The milestone notes and the devlog keep the paths of their day. diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp index f71c707..88623ca 100644 --- a/lib/apps_model/src/settings_menu.cpp +++ b/lib/apps_model/src/settings_menu.cpp @@ -16,19 +16,50 @@ struct RowDef { const char* label; }; -const RowDef kRows[] = { - {Row::LongName, Kind::Text, "Long name"}, {Row::ShortName, Kind::Text, "Short name"}, - {Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"}, - {Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"}, - {Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Launcher, Kind::Toggle, "Launcher"}, - {Row::Theme, Kind::Choice, "Theme"}, {Row::ThemeLight, Kind::Toggle, "Light or dark"}, {Row::Sound, Kind::Toggle, "Sound & LED"}, - {Row::Gnss, Kind::Toggle, "GNSS"}, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}, - {Row::Coordinates, Kind::Toggle, "Coordinates"}, - {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, - {Row::CheckUpdates, Kind::Toggle, "Check for updates"}, {Row::Firmware, Kind::Page, "Firmware"}, - {Row::Vpn, Kind::Page, "VPN"}, - {Row::DebugConsole, Kind::Page, "Debug Console"}, {Row::About, Kind::Page, "About"}, +const RowDef kGroups[] = { + {Row::GroupDevice, Kind::Group, "This device"}, {Row::GroupDisplay, Kind::Group, "Display"}, + {Row::GroupPosition, Kind::Group, "GNSS and radio"}, {Row::GroupNetwork, Kind::Group, "Network"}, + {Row::GroupSystem, Kind::Group, "System"}, }; +constexpr int kGroupCount = sizeof(kGroups) / sizeof(kGroups[0]); + +struct Member { + int group; // an index into kGroups + RowDef def; +}; + +// In the order each group lists them. +const Member kRows[] = { + {0, {Row::LongName, Kind::Text, "Long name"}}, + {0, {Row::ShortName, Kind::Text, "Short name"}}, + {0, {Row::Region, Kind::Choice, "Region"}}, + {0, {Row::Timezone, Kind::Choice, "Timezone"}}, + {0, {Row::Sound, Kind::Toggle, "Sound & LED"}}, + {1, {Row::Brightness, Kind::Slider, "Brightness"}}, + {1, {Row::DimTimeout, Kind::Choice, "Dim after"}}, + {1, {Row::OffTimeout, Kind::Choice, "Screen off after"}}, + {1, {Row::Theme, Kind::Choice, "Theme"}}, + {1, {Row::ThemeLight, Kind::Toggle, "Light or dark"}}, + {1, {Row::Launcher, Kind::Toggle, "Launcher"}}, + {2, {Row::Gnss, Kind::Toggle, "GNSS"}}, + {2, {Row::GnssQuiet, Kind::Toggle, "Pause GNSS for LoRa"}}, + {2, {Row::Coordinates, Kind::Toggle, "Coordinates"}}, + {2, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}}, + {3, {Row::Wifi, Kind::Page, "Wi-Fi"}}, + {3, {Row::Vpn, Kind::Page, "VPN"}}, + {4, {Row::CheckUpdates, Kind::Toggle, "Check for updates"}}, + {4, {Row::Firmware, Kind::Page, "Firmware"}}, + {4, {Row::DebugConsole, Kind::Page, "Debug Console"}}, + {4, {Row::About, Kind::Page, "About"}}, +}; + +// Row i of what is listed: a group, or the i-th row of the open one. +const RowDef& at(int group, int i) { + if (group < 0) return kGroups[i < 0 || i >= kGroupCount ? 0 : i]; + for (const Member& m : kRows) + if (m.group == group && i-- == 0) return m.def; + return kGroups[group]; // out of range: harmless +} const int kDimSeconds[] = {10, 15, 30, 60, 120, 300}; const int kOffSeconds[] = {30, 60, 120, 300, 600, 1800}; @@ -65,10 +96,42 @@ int indexOf(const int (&table)[N], int value) { } // namespace -int SettingsMenu::count() const { return sizeof(kRows) / sizeof(kRows[0]); } -SettingsMenu::Row SettingsMenu::row(int i) const { return kRows[i].row; } -SettingsMenu::Kind SettingsMenu::kind(int i) const { return kRows[i].kind; } -std::string SettingsMenu::label(int i) const { return kRows[i].label; } +bool SettingsMenu::open(int i) { + if (group_ >= 0 || i < 0 || i >= kGroupCount) return false; + group_ = lastGroup_ = i; + return true; +} + +bool SettingsMenu::close() { + if (group_ < 0) return false; + group_ = -1; + return true; +} + +std::string SettingsMenu::title() const { return group_ < 0 ? "" : kGroups[group_].label; } + +bool SettingsMenu::reveal(Row r, int& index) { + int seen[kGroupCount] = {}; + for (const Member& m : kRows) { + if (m.def.row == r) { + group_ = lastGroup_ = m.group; + index = seen[m.group]; + return true; + } + seen[m.group]++; + } + return false; +} + +int SettingsMenu::count() const { + if (group_ < 0) return kGroupCount; + int n = 0; + for (const Member& m : kRows) n += m.group == group_; + return n; +} +SettingsMenu::Row SettingsMenu::row(int i) const { return at(group_, i).row; } +SettingsMenu::Kind SettingsMenu::kind(int i) const { return at(group_, i).kind; } +std::string SettingsMenu::label(int i) const { return at(group_, i).label; } std::string SettingsMenu::value(int i) const { switch (row(i)) { diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h index 87cb46c..9350d92 100644 --- a/lib/apps_model/src/settings_menu.h +++ b/lib/apps_model/src/settings_menu.h @@ -7,15 +7,26 @@ namespace roro { -// What the Settings App lists: one row per user-facing setting (plus sub-pages), with readable -// values, choice lists and validation messages. Rendering and navigation live in the App. +// What the Settings App lists: the groups first, then, with one open, a row per user-facing setting +// of that group (plus sub-pages), with readable values, choice lists and validation messages. +// Rendering and navigation live in the App. Every index is into what is listed now. class SettingsMenu { public: - enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About }; - enum class Kind { Text, Choice, Toggle, Slider, Page }; + enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Launcher, Theme, ThemeLight, Sound, Gnss, GnssQuiet, Coordinates, ProbeMacs, Wifi, CheckUpdates, Firmware, Vpn, DebugConsole, About, + GroupDevice, GroupDisplay, GroupPosition, GroupNetwork, GroupSystem }; + enum class Kind { Text, Choice, Toggle, Slider, Page, Group }; explicit SettingsMenu(Settings& settings) : settings_(settings) {} + // The groups are listed until one is opened; close() lists them again. + bool open(int i); // false when row i isn't a group + bool close(); // false when the groups were listed already + bool inGroup() const { return group_ >= 0; } + std::string title() const; // the open group's name, or "" + int groupIndex() const { return lastGroup_; } // where the group last opened is among the groups + // Opens the group a row is in, and says where the row is in it. False for a row there isn't. + bool reveal(Row r, int& index); + int count() const; Row row(int i) const; Kind kind(int i) const; @@ -37,6 +48,7 @@ class SettingsMenu { private: Settings& settings_; + int group_ = -1, lastGroup_ = 0; }; } // namespace roro diff --git a/lib/core/src/app_keys.h b/lib/core/src/app_keys.h index 935319b..4c5a1a4 100644 --- a/lib/core/src/app_keys.h +++ b/lib/core/src/app_keys.h @@ -414,8 +414,9 @@ inline constexpr KeyHelp kSystemSystem[] = { // settings: Settings inline constexpr KeyHelp kSettings[] = { {"; .", "up, down"}, - {"Enter", "edit, or open the page"}, + {"Enter", "open the group or the page, or edit"}, {", /", "change a switch or a slider"}, + {"`", "back to the groups"}, }; // settings-choice: Settings, a choice diff --git a/lib/gnss/src/geo_format.h b/lib/gnss/src/geo_format.h index da8d2ce..e42a4e9 100644 --- a/lib/gnss/src/geo_format.h +++ b/lib/gnss/src/geo_format.h @@ -6,7 +6,7 @@ namespace roro::gnss { // "50.86920° N": five decimals, about a metre. std::string formatDecimal(double degrees, bool latitude); -// "50° 52' 09.1\" N" (Settings → Coordinates, Q64). +// "50° 52' 09.1\" N" (Settings → GNSS and radio → Coordinates, Q64). std::string formatDms(double degrees, bool latitude); // The 6-character Maidenhead locator ("JO20ef"), as radio amateurs give their square. std::string maidenhead(double latitude, double longitude); diff --git a/scripts/flash.sh b/scripts/flash.sh index ee2989c..7541e67 100755 --- a/scripts/flash.sh +++ b/scripts/flash.sh @@ -2,7 +2,7 @@ # Flash the firmware over USB, then open the serial monitor. # Usage: scripts/flash.sh [--debug] [port] USB (default: the first Espressif device found) # scripts/flash.sh --ota [host] Wi-Fi: build, sign and push a Firmware Update -# (host: the device's IP from Settings > Firmware, or $RORO_OTA_HOST) +# (host: the device's IP from Settings > System > Firmware, or $RORO_OTA_HOST) # --debug (USB only): once flashed, switches the Debug Console on and gives it the token in # ~/.config/roro9stack/debug-token, over the serial port (ADR 0010, Q192), so scripts/rdbg.py works # at once. The settings survive later updates: it is needed once for a device, not at each flash. diff --git a/scripts/ota_push.py b/scripts/ota_push.py index c2846dd..3c3a202 100755 --- a/scripts/ota_push.py +++ b/scripts/ota_push.py @@ -2,7 +2,7 @@ """Pushes a signed Update File to a Cardputer over Wi-Fi (TCP 3232) and reports the result. Usage: scripts/ota_push.py - is the device's IP (shown in Settings > Firmware). + is the device's IP (shown in Settings > System > Firmware). """ import socket import sys diff --git a/scripts/rdbg.py b/scripts/rdbg.py index f2e589b..ebcd64e 100755 --- a/scripts/rdbg.py +++ b/scripts/rdbg.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 -"""The Debug Console, over Wi-Fi (TCP 2323, see ADR 0010). Switch it on first: Settings > Debug Console. +"""The Debug Console, over Wi-Fi (TCP 2323, see ADR 0010). Switch it on first: Settings > System > Debug Console. Usage: scripts/rdbg.py [-H host] [-t token] [-b] [command ...] no command interactive: type commands, see every console line live; Ctrl-D or `quit` leaves command runs it and prints what follows, until the console has been quiet for a moment - -H host the device's IP (Settings > Debug Console), default $RORO_OTA_HOST - -t token the device's token (also --token), as Settings > Debug Console shows it: dashes and + -H host the device's IP (Settings > System > Debug Console), default $RORO_OTA_HOST + -t token the device's token (also --token), as Settings > System > Debug Console shows it: dashes and case don't matter. Otherwise $RORO_DEBUG_TOKEN, otherwise ~/.config/roro9stack/debug-token -b also print the backlog the device sends on connecting (boot messages and so on) @@ -55,7 +55,7 @@ def find_token(given): token = tidy_token(token or "") if not token: sys.exit("No token: pass -t , set RORO_DEBUG_TOKEN, or put it in " + TOKEN_FILE + - "\n(the device shows it in Settings > Debug Console)") + "\n(the device shows it in Settings > System > Debug Console)") return token @@ -63,7 +63,7 @@ def log_in(sock, token): """Answers the device's challenge; returns the banner line, or exits saying what went wrong.""" first = read_until(sock, b"\n", 10) if first is None: - sys.exit("device: nothing came back. Is the console switched on (Settings > Debug Console)?") + sys.exit("device: nothing came back. Is the console switched on (Settings > System > Debug Console)?") line = first.decode(errors="replace").strip() if line.startswith("locked"): sys.exit("device: closed for a minute after too many wrong tokens") @@ -75,7 +75,7 @@ def log_in(sock, token): if banner is None: sys.exit("device: no answer") if banner.startswith(b"denied"): - sys.exit("device: wrong token (Settings > Debug Console shows the right one)") + sys.exit("device: wrong token (Settings > System > Debug Console shows the right one)") return banner @@ -326,7 +326,7 @@ def main(): try: sock = socket.create_connection((host, PORT), timeout=10) except OSError as e: - sys.exit(f"device: can't connect to {host}:{PORT} ({e}). Is the console switched on (Settings > Debug Console)?") + sys.exit(f"device: can't connect to {host}:{PORT} ({e}). Is the console switched on (Settings > System > Debug Console)?") with sock: banner = log_in(sock, token) show = sys.stdout if backlog or not args else None diff --git a/scripts/release_build.sh b/scripts/release_build.sh index 974c768..e40cbca 100755 --- a/scripts/release_build.sh +++ b/scripts/release_build.sh @@ -47,7 +47,7 @@ PREVIOUS="$(git -C "$SRC" describe --tags --abbrev=0 "$VERSION^" 2>/dev/null || echo echo "## Files" echo - echo "- \`$NAME.ota\`: the signed Update File. Copy it to \`/updates\` on the SD card and install it from Settings > Firmware or the Storage App, or push it over Wi-Fi with \`scripts/ota_push.py\`." + echo "- \`$NAME.ota\`: the signed Update File. Copy it to \`/updates\` on the SD card and install it from Settings > System > Firmware or the Storage App, or push it over Wi-Fi with \`scripts/ota_push.py\`." echo "- \`$NAME-factory.bin\`: the whole flash image, for a first install over USB at offset 0." echo "- \`$NAME.elf.gz\`: the symbols, to decode a crash report from this build." echo "- \`SHA256SUMS\`: checksums of the three." diff --git a/scripts/sd_put.py b/scripts/sd_put.py index c979f42..e163f28 100755 --- a/scripts/sd_put.py +++ b/scripts/sd_put.py @@ -2,7 +2,7 @@ """Copies a file to the Cardputer's SD card over the USB serial console (the `sd put` command). Usage: scripts/sd_put.sh [card path] -The card path defaults to /updates/, where Settings > Firmware finds Update Files. +The card path defaults to /updates/, where Settings > System > Firmware finds Update Files. The device acknowledges each chunk once it's on the card, checks the SHA-256 of the whole file, and only then renames .part to . """ diff --git a/site/content/dev/build/flash.md b/site/content/dev/build/flash.md index 3593c3e..40e960e 100644 --- a/site/content/dev/build/flash.md +++ b/site/content/dev/build/flash.md @@ -36,19 +36,19 @@ scripts/ota_keygen.sh # once: creates the signing key (see ADR scripts/flash.sh --ota 10.39.39.12 # build, sign and push; or set RORO_OTA_HOST ``` -The device shows the push address in **Settings → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so. +The device shows the push address in **Settings → System → Firmware**. It installs a correctly signed update right away, restarts (waiting up to 60 s if you're typing), and runs the new firmware on **Probation**. If the new firmware crashes, or can't reconnect Wi-Fi within 3 minutes, it rolls back to the previous one and says so. -To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh ` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs). +To install from the SD card instead, copy the `.ota` file from `.pio/build/cardputer-adv/` into `/updates` on the card, then use **Settings → System → Firmware**. With the Cardputer on USB, the card can stay in: `scripts/sd_put.sh ` sends it over the serial console into `/updates` (about 30 s for 1.6 MB, checked with SHA-256 before it's renamed into place; `SD_PUT_DEBUG=1` shows the console while it runs). **The private key** lives in `~/.config/roro9stack/ota-key.pem` and must never be committed. If it's lost, generate a new pair and flash once over USB. (CI signs releases with a copy kept as a repository secret, ADR 0008.) ### Updates from Gitea -With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → Firmware**: +With no PC and no card, the device can install the project's releases itself (docs/milestones/R1.md). In **Settings → System → Firmware**: - **Latest release** checks the server (Enter, or `c`) and says `v0.11.0 (new)` or `(current)`. Enter again opens the release: its version, date, size and the tag's message, with **Install** when it's newer. The download goes straight into the inactive slot, so no card is needed; the signature is checked after the first 160 bytes, before anything is written, and the image's hash at the end. The new firmware then runs on Probation as for any update. - **Older releases** lists the last ten, newest first. Opening an older one offers to go back to it, with a different question. -- **Settings → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device. +- **Settings → System → Check for updates** (on by default): once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs nothing by itself, and doesn't announce a version that already failed and rolled back on this device. The connection is checked against the two ISRG roots Let's Encrypt chains end in (ADR 0009), not the usual bundle of about 130 authorities. Whatever the connection, the Update File's own signature is what decides what gets installed. diff --git a/site/content/dev/build/how-an-update-works/index.md b/site/content/dev/build/how-an-update-works/index.md index 84a56f6..698a7a7 100644 --- a/site/content/dev/build/how-an-update-works/index.md +++ b/site/content/dev/build/how-an-update-works/index.md @@ -34,8 +34,8 @@ scripts/flash.sh --ota 10.39.39.12 # builds, signs and pushe {{ diagram(src="ways-in.svg", min_width=580, caption="Every path ends at the same Update Service and the same signature check.") }} 1. **Push over Wi-Fi** to TCP 3232: `scripts/flash.sh --ota`. The device always listens while Wi-Fi is connected. -2. **From the SD card**: a `.ota` in `/updates`, installed from Settings → Firmware, from the Storage App, or with the `install ` command. Put it there by hand, with `scripts/rdbg.py put` over Wi-Fi, or with `scripts/sd_put.sh` over USB. -3. **From the project's releases** on Gitea, which the device downloads itself over TLS (Settings → Firmware, or `update check` / `update install `): see [Flash and update](/dev/build/flash/). +2. **From the SD card**: a `.ota` in `/updates`, installed from Settings → System → Firmware, from the Storage App, or with the `install ` command. Put it there by hand, with `scripts/rdbg.py put` over Wi-Fi, or with `scripts/sd_put.sh` over USB. +3. **From the project's releases** on Gitea, which the device downloads itself over TLS (Settings → System → Firmware, or `update check` / `update install `): see [Flash and update](/dev/build/flash/). All three feed the same parser: the **header and signature are checked first**, the image is written to the **other app slot** while it is hashed, and the slot becomes the next boot **only if the hash matches**. Anything wrong ends in a Toast and **nothing changes**. A downgrade is allowed, and shows "older than the installed version". diff --git a/site/content/dev/build/how-an-update-works/ways-in.svg b/site/content/dev/build/how-an-update-works/ways-in.svg index dac3916..d2eae25 100644 --- a/site/content/dev/build/how-an-update-works/ways-in.svg +++ b/site/content/dev/build/how-an-update-works/ways-in.svg @@ -21,7 +21,7 @@ SD card /updates/*.ota - Settings → Firmware, + Settings → System → Firmware, or install <path> diff --git a/site/content/dev/debug/commands.md b/site/content/dev/debug/commands.md index 17ad8b8..55e86c6 100644 --- a/site/content/dev/debug/commands.md +++ b/site/content/dev/debug/commands.md @@ -45,8 +45,8 @@ ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, uname the firmware, its version and the chip it is built for scp [-f] [-P port] user@host:path | scp [-f] [-P port] user@host:path one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here -vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself -debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back +vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > Network > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself +debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > System > Debug Console); with seconds, it comes back debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token crash abort|wdt crash on purpose (to test crash reports and Safe Mode) wifi ip ... try | wifi ip keep a trial IP setting: back to the previous one unless kept @@ -97,7 +97,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r | `ls [folder]` / `du ` | Lists a folder of the SD card with sizes and dates, or counts the files and bytes under a path | | `screenshot [seconds]` | The screen as a PNG in `/screenshots` on the card, now or after a pause to get to the screen you want (240 x 135, about 33 KB). Over the Debug Console a bare `screenshot` sends the screen to the PC instead | | `cp [-f] ` / `mv [-f] ` / `rm [-r] [-f] ` / `mkdir ` / `cancel` | What the Storage App does, with its rules: copy (folders too), move or rename, delete (`rm -r` for a folder and what's in it, as Unix has it), new folder. `-f` replaces a file that's in the way; `*` and `?` in the last part of a path (`ls`, `du`, `rm`, `cp`, `mv`) run the command for each name matched, 64 at most; a tab separates paths that hold spaces; `cancel` stops a copy or a delete | -| `install ` | Update from SD with that `.ota` file, as Settings → Firmware does | +| `install ` | Update from SD with that `.ota` file, as Settings → System → Firmware does | | `update check` / `update list` / `update status` / `update install ` | The project's releases on Gitea: look at the latest, list the last ten, say what's known, or download and install one | | `update pretend ` / `update probe ` / `update damage cut\|flip ` / `update daily` | Pretend to run another version (so a release counts as an update), see whether a server's certificate is accepted, cut or damage the next download, run the daily check again | | `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor | diff --git a/site/content/dev/debug/switch-it-on.md b/site/content/dev/debug/switch-it-on.md index b51c688..98602f7 100644 --- a/site/content/dev/debug/switch-it-on.md +++ b/site/content/dev/debug/switch-it-on.md @@ -16,7 +16,7 @@ Before version 0.12 this was a separate *Debug Build* with a token compiled in f ## On the device -**Settings → Debug Console:** +**Settings → System → Debug Console:** | Row | Does | |---|---| diff --git a/site/content/dev/milestones/u1.md b/site/content/dev/milestones/u1.md index f51253c..f806582 100644 --- a/site/content/dev/milestones/u1.md +++ b/site/content/dev/milestones/u1.md @@ -127,3 +127,19 @@ Seven themes, each with a dark and a light side, chosen in **Settings > Theme** **Kept as they are:** the SSH terminal's 16 ANSI colours and its black background, the Sweep's waterfall scale, pictures, and the screen shown while the device powers off. **Not looked at:** the other 12 palettes beyond the Launcher, dialogs and Toasts in any theme but the default, the Setup screens. In Gruvbox light, the Sky view's GPS, GLONASS and BeiDou colours are three close shades of brown and red. + +## Settings in groups + +Settings had grown to 21 rows in one list. It now opens on five groups, each a short list of its own: + +| Group | Rows | +|---|---| +| This device | Long name, Short name, Region, Timezone, Sound & LED | +| Display | Brightness, Dim after, Screen off after, Theme, Light or dark, Launcher | +| GNSS and radio | GNSS, Pause GNSS for LoRa, Coordinates, Probe MACs | +| Network | Wi-Fi, VPN | +| System | Check for updates, Firmware, Debug Console, About | + +- Enter opens a group, Back returns to the groups with the selection on the one just left, and Back from the groups leaves Settings. An open group has its name over its rows. +- `SettingsMenu` (`lib/apps_model`) holds the groups and which one is open; every index is into what is listed. Host-tested: every setting is in exactly one group, and no group has more rows than fit under its name. +- The guide, the how-tos, the README, the scripts' messages and the firmware's own (the Toast for a new release, the GNSS App's hint) name the new paths: "Settings > System > Firmware". The milestone notes and the devlog keep the paths of their day. diff --git a/site/content/faq.md b/site/content/faq.md index d0869d2..1750735 100644 --- a/site/content/faq.md +++ b/site/content/faq.md @@ -39,7 +39,7 @@ The browser flasher uses Web Serial, which Chrome and Edge have on desktop and F ## How do I update it? -Three ways, all with the same signed update file: from the project's releases on the device itself (**Settings → Firmware**), from the SD card, or pushed over Wi-Fi from a PC. See [Updates](/guide/updates/) and [Install an update from the SD card](/howto/update-from-sd/). +Three ways, all with the same signed update file: from the project's releases on the device itself (**Settings → System → Firmware**), from the SD card, or pushed over Wi-Fi from a PC. See [Updates](/guide/updates/) and [Install an update from the SD card](/howto/update-from-sd/). ## Is it safe to update? What if it goes wrong? @@ -51,7 +51,7 @@ Yes: it is [open source](https://git.twis.la/twisla/roro9stack) (GPL-3.0). The d ## Does it phone home? What about privacy? -**The firmware contacts the project's server once a day,** to see whether a new release exists: **Settings → Check for updates**, on by default, only when Wi-Fi is up and the clock is set. It installs nothing by itself, and you can switch it off. Besides that, the device talks to what you ask it to: the IRC server and Gemini capsules you open, DNS servers (9.9.9.9 and 1.1.1.1 by default) and time servers (pool.ntp.org and time.cloudflare.com by default), all of which you can change. There is no account, no analytics and no telemetry. +**The firmware contacts the project's server once a day,** to see whether a new release exists: **Settings → System → Check for updates**, on by default, only when Wi-Fi is up and the clock is set. It installs nothing by itself, and you can switch it off. Besides that, the device talks to what you ask it to: the IRC server and Gemini capsules you open, DNS servers (9.9.9.9 and 1.1.1.1 by default) and time servers (pool.ntp.org and time.cloudflare.com by default), all of which you can change. There is no account, no analytics and no telemetry. **It listens on the network only for what you switched on:** the port that receives signed firmware updates, always; the Debug Console, the card shared with a browser and the VPN, only while you have them on. The SSH App connects out to the server you name and listens for nothing. diff --git a/site/content/guide/basics.md b/site/content/guide/basics.md index 82cb41a..421c835 100644 --- a/site/content/guide/basics.md +++ b/site/content/guide/basics.md @@ -42,7 +42,7 @@ Left and right go through every App and wrap around; up and down stay in the col **An orange dot on an icon** says something is going on in that App: unread messages in IRC, a Track being recorded in GNSS, a Capture running in the LoRa Scanner, a session open in SSH. -If you prefer the list of names the Launcher used to be, **Settings → Launcher** switches between **Grid** and **List**. In the list, a `*` on the right stands for the dot. +If you prefer the list of names the Launcher used to be, **Settings → Display → Launcher** switches between **Grid** and **List**. In the list, a `*` on the right stands for the dot. ## The Status Bar @@ -57,7 +57,7 @@ A strip at the top of every screen: the name of the App on the left, and on the | bars and `W` | Wi-Fi connected, with its signal; `W?` is searching; `MON` is the Wi-Fi radio in its monitoring mode, which pauses IRC | | `SSH` | An [SSH session](/guide/ssh/) is open, whatever App is in front | | `VPN` | The [WireGuard tunnel](/guide/vpn/) is wanted; brighter once the server has answered | -| `DBG` | The Debug Console is switched on (Settings → Debug Console); brighter while a PC is connected to it | +| `DBG` | The Debug Console is switched on (Settings → System → Debug Console); brighter while a PC is connected to it | | `REC` | A GNSS Track is being recorded | | `CAP` | A LoRa capture is being recorded | | `L` | The radio is listening; it lights up for a moment on each packet. `SW` while a Sweep runs | diff --git a/site/content/guide/gnss.md b/site/content/guide/gnss.md index accb0a0..8169129 100644 --- a/site/content/guide/gnss.md +++ b/site/content/guide/gnss.md @@ -7,13 +7,13 @@ tag = "GNSS" screens = ["sky.png"] +++ -GNSS needs the **Cap LoRa-1262**, an antenna with a view of the sky, and **Settings → GNSS** switched **On**. Otherwise the App says `GNSS is off` (or `paused`, when "Pause GNSS for LoRa" has put the receiver on standby while the radio listens). A first fix outdoors can take a while: the App says how long it has been searching. +GNSS needs the **Cap LoRa-1262**, an antenna with a view of the sky, and **Settings → GNSS and radio → GNSS** switched **On**. Otherwise the App says `GNSS is off` (or `paused`, when "Pause GNSS for LoRa" has put the receiver on standby while the radio listens). A first fix outdoors can take a while: the App says how long it has been searching. ## Position The first view shows the fix (`No Fix`, `2D Fix` or `3D Fix`, and how many satellites it uses), then: -- **Lat** and **Lon**, in decimal degrees or degrees, minutes and seconds (**Settings → Coordinates**); +- **Lat** and **Lon**, in decimal degrees or degrees, minutes and seconds (**Settings → GNSS and radio → Coordinates**); - the **Locator**, the Maidenhead grid square; - **Altitude**, **Speed** and the direction you are moving; - **HDOP**, the horizontal precision: a smaller number is better; diff --git a/site/content/guide/lora-scanner.md b/site/content/guide/lora-scanner.md index 9f3b443..c61ad40 100644 --- a/site/content/guide/lora-scanner.md +++ b/site/content/guide/lora-scanner.md @@ -63,7 +63,7 @@ A capture records packets into a **pcap** file with LoRaTap headers in `/capture ## The GNSS receiver raises the noise -The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded. +The GNSS receiver on the same Cap makes the radio's noise floor about **8 dB** worse while it runs. **Settings → GNSS and radio → Pause GNSS for LoRa** (off by default) puts the receiver on standby while the radio listens, except while a Track is being recorded. ## The keys, as the device lists them diff --git a/site/content/guide/settings.md b/site/content/guide/settings.md index f16effa..bd37d8b 100644 --- a/site/content/guide/settings.md +++ b/site/content/guide/settings.md @@ -6,26 +6,51 @@ weight = 11 tag = "Settings" +++ -Move with the arrows. On a toggle, a choice or a slider, left and right change the value; Enter opens a text field or a list of choices, or a page marked `>`. Back leaves. +Settings opens on five **groups**. Enter opens a group, and Back returns to the groups; Back from the groups leaves Settings. + +Inside a group, move with the arrows. On a toggle, a choice or a slider, left and right change the value; Enter opens a text field or a list of choices, or a page marked `>`. + +### This device | Setting | What it is | |---|---| | **Long name**, **Short name** | The names you gave in the first-start Setup (up to 39 bytes, and up to 4 characters) | | **Region** | The regulatory region; EU868 | | **Timezone** | For the clock and the dates in file names | +| **Sound & LED** | The beep and the flash on a [Toast](/guide/basics/) | + +### Display + +| Setting | What it is | +|---|---| | **Brightness** | A slider | | **Dim after**, **Screen off after** | Screen timeouts. Dimming must come before the screen turns off | -| **Launcher** | The home screen as a **Grid** of icons or a **List** of names (see [The basics](/guide/basics/#the-launcher)) | | **Theme** | The colours of every screen: **roro9stack** (the website's), **Catppuccin**, **Dracula**, **Nord**, **ANSI terminal**, **Gruvbox** or **Solarized** | | **Light or dark** | Which side of the theme: every one has both | -| **Sound & LED** | The beep and the flash on a [Toast](/guide/basics/) | +| **Launcher** | The home screen as a **Grid** of icons or a **List** of names (see [The basics](/guide/basics/#the-launcher)) | + +### GNSS and radio + +| Setting | What it is | +|---|---| | **GNSS** | Switches the receiver on and off (see [GNSS](/guide/gnss/)) | | **Pause GNSS for LoRa** | Puts the receiver on standby while the radio listens (see [LoRa Scanner](/guide/lora-scanner/)) | | **Coordinates** | Decimal degrees, or degrees, minutes and seconds | +| **Probe MACs** | Whether the Wi-Fi Tools' logs of probe requests keep the addresses as they are (**Raw**) or **Pseudonymised** | + +### Network + +| Setting | What it is | +|---|---| | **Wi-Fi** | The page below | +| **VPN** | A WireGuard tunnel: its switch, "Start with Wi-Fi", and importing its configuration from the card. See [VPN](/guide/vpn/) | + +### System + +| Setting | What it is | +|---|---| | **Check for updates** | Once a day, see [Updates](/guide/updates/) | | **Firmware** | The page described in [Updates](/guide/updates/) | -| **VPN** | A WireGuard tunnel: its switch, "Start with Wi-Fi", and importing its configuration from the card. See [VPN](/guide/vpn/) | | **Debug Console** | Off unless you switch it on. It lets a PC on the same network read the device's console and drive it, with a token shown on this page: see [the developer docs](/dev/debug/switch-it-on/). Leave it off if that means nothing to you | | **About** | The firmware version, the node number, battery, memory, uptime, clock and licence | diff --git a/site/content/guide/updates.md b/site/content/guide/updates.md index c92dbe9..d244293 100644 --- a/site/content/guide/updates.md +++ b/site/content/guide/updates.md @@ -9,7 +9,7 @@ screens = ["update.png"] Every update is one **signed file** (`.ota`). The device installs only a file signed with the project's key, so it cannot be tricked into installing anything else, whichever way the file arrives. -## Settings → Firmware +## Settings → System → Firmware The page shows the **version** running, its **status**, the address to **push updates to** over Wi-Fi, and: @@ -21,7 +21,7 @@ An install needs Wi-Fi if it is a download. The new firmware is checked before a ## Check for updates -**Settings → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > Firmware`, once per version. It installs **nothing** by itself, and it does not announce a version that already failed and rolled back on this device. +**Settings → System → Check for updates** is on by default. Once a day, with Wi-Fi up and the clock set, the device looks at the latest release and says `v0.11.0 is out: see Settings > System > Firmware`, once per version. It installs **nothing** by itself, and it does not announce a version that already failed and rolled back on this device. ## Probation and Rollback diff --git a/site/content/guide/vpn.md b/site/content/guide/vpn.md index 28aad8a..16ada27 100644 --- a/site/content/guide/vpn.md +++ b/site/content/guide/vpn.md @@ -15,14 +15,14 @@ You need a WireGuard server, yours or a provider's, and the configuration file i 1. On the server, make a configuration for a new client, as you would for a phone. 2. Copy the file to the SD card as **`/vpn/wg0.conf`**. -3. On the device: **Settings → VPN → Import /vpn/wg0.conf**. +3. On the device: **Settings → Network → VPN → Import /vpn/wg0.conf**. 4. Say yes when it offers to **delete the file**: the configuration is now stored in the device, and the file on the card still holds the private key in clear. If the file can't be used, the page says which line and why. The key itself is never shown, anywhere, once imported. ## Using it -**Settings → VPN** has a switch. `VPN` appears in the [Status Bar](/guide/basics/#the-status-bar) while the tunnel is wanted, and turns bright once the server has answered. The page shows the state, the server, this device's address in the tunnel, what goes through it, and how long ago the server was last heard. +**Settings → Network → VPN** has a switch. `VPN` appears in the [Status Bar](/guide/basics/#the-status-bar) while the tunnel is wanted, and turns bright once the server has answered. The page shows the state, the server, this device's address in the tunnel, what goes through it, and how long ago the server was last heard. - **The switch is for now.** It doesn't survive a restart. - **Start with Wi-Fi**, off by default, starts the tunnel whenever Wi-Fi connects. diff --git a/site/content/howto/no-dhcp.md b/site/content/howto/no-dhcp.md index b599647..d4072c7 100644 --- a/site/content/howto/no-dhcp.md +++ b/site/content/howto/no-dhcp.md @@ -6,14 +6,14 @@ weight = 4 tag = "Wi-Fi" +++ -1. **Add the network first.** In **Settings → Wi-Fi**, choose **Add a network**, pick it and type the password. (A hidden network has its own entry, which asks for the name first.) +1. **Add the network first.** In **Settings → Network → Wi-Fi**, choose **Add a network**, pick it and type the password. (A hidden network has its own entry, which asks for the name first.) 2. **Open the network's page:** Enter on it in the list of saved networks. 3. **Set *IP address* to *Fixed*.** The address, the prefix and the gateway start from what the network is giving the device at that moment, so you only change what is wrong. - **Address:** four numbers, like `10.39.39.13`. - **Prefix:** 1 to 30. 24 is 255.255.255.0. - **Gateway:** optional; empty means none. 4. **Leave the page.** The setting is checked and applied then; a bad address is refused with the reason. -5. **Check it.** Back in **Settings → Wi-Fi**, Enter on **Status** shows the address, the mask, the gateway, the DNS and NTP servers, and where each came from. +5. **Check it.** Back in **Settings → Network → Wi-Fi**, Enter on **Status** shows the address, the mask, the gateway, the DNS and NTP servers, and where each came from. ## DNS and time diff --git a/site/content/howto/record-track.md b/site/content/howto/record-track.md index abdb060..1e25323 100644 --- a/site/content/howto/record-track.md +++ b/site/content/howto/record-track.md @@ -8,7 +8,7 @@ tag = "GNSS" You need the **Cap LoRa-1262**, an **SD card**, and a place with a view of the sky. -1. **Switch the receiver on:** **Settings → GNSS** to *On*. The Status Bar shows `G` once it is searching. +1. **Switch the receiver on:** **Settings → GNSS and radio → GNSS** to *On*. The Status Bar shows `G` once it is searching. 2. **Open the GNSS App** and wait for a fix. The first line says `Searching: n in view` and how long it has been, then `3D Fix, n of m satellites`. The first fix outdoors can take a while. 3. **The clock must be set.** A fix sets it, and so does Wi-Fi. A Track will not start without one: the App says `Waiting for the time`. 4. **Press r.** The bottom line says `REC`, with the number of points and how long it has been going, and the Status Bar shows `REC`. You can leave the App: the Track keeps recording. diff --git a/site/content/howto/screenshot.md b/site/content/howto/screenshot.md index 7dbc76a..5292dc4 100644 --- a/site/content/howto/screenshot.md +++ b/site/content/howto/screenshot.md @@ -14,5 +14,5 @@ tag = "Screen" - It needs an SD card. - The files are PNGs of 240 by 135 pixels, named by date and time. -- **It refuses on Settings → Debug Console**, the page that shows the console's token: a picture of it would be a copy of the token. +- **It refuses on Settings → System → Debug Console**, the page that shows the console's token: a picture of it would be a copy of the token. - From the [Shell](/guide/shell/), `screenshot 5` takes the picture five seconds later, for a screen you can't press keys on. diff --git a/site/content/howto/update-from-sd.md b/site/content/howto/update-from-sd.md index 699b64f..15ad54e 100644 --- a/site/content/howto/update-from-sd.md +++ b/site/content/howto/update-from-sd.md @@ -8,7 +8,7 @@ tag = "Updates" 1. **Get the update file.** On the [Downloads page](/downloads/), take the `.ota` file of the release you want: `roro9stack-.ota`. Every release has one. 2. **Copy it to `/updates`** on the SD card, from a computer. If the folder is not there, create it. (With the Cardputer on USB, a developer can also send it with `scripts/sd_put.sh`; see the [README](https://git.twis.la/twisla/roro9stack#firmware-updates-over-wi-fi-ota).) -3. **Put the card in the Cardputer.** Open **Settings → Firmware.** Under *On the SD card* the file is listed. If it says `No .ota files in /updates`, the name or the folder is wrong. +3. **Put the card in the Cardputer.** Open **Settings → System → Firmware.** Under *On the SD card* the file is listed. If it says `No .ota files in /updates`, the name or the folder is wrong. 4. **Press Enter on the file,** then **Install**. The device checks the signature and the contents before it writes anything, installs, and restarts. It waits up to 60 seconds if you are typing. 5. **After the restart** the new firmware is on **Probation**: if it crashes or cannot get Wi-Fi back within 3 minutes, the device goes back to the previous version by itself and says so. diff --git a/site/content/howto/vpn.md b/site/content/howto/vpn.md index 0b92e6d..d784e06 100644 --- a/site/content/howto/vpn.md +++ b/site/content/howto/vpn.md @@ -12,7 +12,7 @@ You need a WireGuard server, and a **client configuration** made on it for the C 2. On the Cardputer, open **Storage** and press w; open the page on the phone ([Move files with your phone](/howto/phone-files/)). 3. In the page, tap **New folder**, name it `vpn`, go into it, and **upload `wg0.conf`**. 4. On the Cardputer, press Back to stop sharing. -5. Open **Settings → VPN → Import /vpn/wg0.conf**. You should see "Imported", and a question: **delete the file**. Say yes: the configuration is now in the device, and the file still holds the private key in clear. +5. Open **Settings → Network → VPN → Import /vpn/wg0.conf**. You should see "Imported", and a question: **delete the file**. Say yes: the configuration is now in the device, and the file still holds the private key in clear. 6. Switch **VPN** to On. `VPN` appears in the Status Bar, and turns bright once the server has answered, usually within seconds. The page says "It is up". 7. To have it start by itself, switch on **Start with Wi-Fi**. diff --git a/site/data/keys.toml b/site/data/keys.toml index 3c8084a..96beb17 100644 --- a/site/data/keys.toml +++ b/site/data/keys.toml @@ -488,8 +488,9 @@ id = "settings" title = "Settings" rows = [ ["; .", "up, down"], - ["Enter", "edit, or open the page"], + ["Enter", "open the group or the page, or edit"], [", /", "change a switch or a slider"], + ["`", "back to the groups"], ] [[scope]] diff --git a/site/templates/downloads.html b/site/templates/downloads.html index f302e90..db37cd8 100644 --- a/site/templates/downloads.html +++ b/site/templates/downloads.html @@ -11,7 +11,7 @@
-

Each release has a signed update file (.ota: copy it to /updates on the SD card and install it from Settings → Firmware or the Storage App, or push it over Wi-Fi), a factory image (-factory.bin, the whole flash at offset 0 for a first install over USB), the symbols (.elf.gz, to decode a crash report) and SHA256SUMS.

+

Each release has a signed update file (.ota: copy it to /updates on the SD card and install it from Settings → System → Firmware or the Storage App, or push it over Wi-Fi), a factory image (-factory.bin, the whole flash at offset 0 for a first install over USB), the symbols (.elf.gz, to decode a crash report) and SHA256SUMS.

{% if releases %} diff --git a/src/apps/debug_console_page.h b/src/apps/debug_console_page.h index 3ba46f8..90de281 100644 --- a/src/apps/debug_console_page.h +++ b/src/apps/debug_console_page.h @@ -15,7 +15,7 @@ namespace roro { -// Settings → Debug Console (ADR 0010): the switch, where to connect, and the token, which is shown +// Settings → System → Debug Console (ADR 0010): the switch, where to connect, and the token, which is shown // here and nowhere else. Switching on makes a token if there's none; a new one, or one typed by // hand, ends the connection of whoever holds the old one. class DebugConsolePage { diff --git a/src/apps/firmware_page.h b/src/apps/firmware_page.h index bbab4d4..6d296b5 100644 --- a/src/apps/firmware_page.h +++ b/src/apps/firmware_page.h @@ -18,7 +18,7 @@ namespace roro { -// Settings → Firmware: the running version and its Probation, where to push Firmware Updates, the +// Settings → System → Firmware: the running version and its Probation, where to push Firmware Updates, the // project's releases on Gitea (the latest, and the ten before it), and the Update Files on the SD // card (in /updates) to install from. class FirmwarePage { diff --git a/src/apps/gnss_app.cpp b/src/apps/gnss_app.cpp index 05b1ca7..ff8a4e9 100644 --- a/src/apps/gnss_app.cpp +++ b/src/apps/gnss_app.cpp @@ -76,7 +76,7 @@ void GnssApp::draw(Canvas& c) { c.drawString(held ? "GNSS is paused" : "GNSS is off", 4, area.y + 4); c.setFont(&fonts::body); c.setTextColor(theme::kMuted); - c.drawString(held ? "The LoRa radio is listening, and" : "Settings > GNSS turns it on.", 4, area.y + 22); + c.drawString(held ? "The LoRa radio is listening, and" : "On: Settings > GNSS and radio > GNSS", 4, area.y + 22); if (held) c.drawString("Settings pauses GNSS for it.", 4, area.y + 22 + theme::kLineHeight); return; } diff --git a/src/apps/launcher_app.h b/src/apps/launcher_app.h index 21cd77a..eb4c310 100644 --- a/src/apps/launcher_app.h +++ b/src/apps/launcher_app.h @@ -11,7 +11,7 @@ namespace roro { // Home screen: the visible Apps as a grid of icons, with the selected one's name under it, or as -// a list of names (Settings > Launcher). Select opens one. An App with something going on has a +// a list of names (Settings > Display > Launcher). Select opens one. An App with something going on has a // dot on its tile (App::badge, issue #9). class LauncherApp : public App { public: diff --git a/src/apps/settings_app.cpp b/src/apps/settings_app.cpp index 7763cb6..2fe22f6 100644 --- a/src/apps/settings_app.cpp +++ b/src/apps/settings_app.cpp @@ -15,7 +15,9 @@ using Row = SettingsMenu::Row; void SettingsApp::onEnter() { page_ = Page::Menu; + menu_.close(); list_.setCount(menu_.count()); + list_.select(0); } void SettingsApp::warn(const std::string& text) { @@ -56,7 +58,12 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) { if (menu_.kind(i) == Kind::Toggle) menu_.toggle(i); return true; case Key::Select: break; - default: return false; // Back leaves Settings + case Key::Back: + if (!menu_.close()) return false; // from the groups, Back leaves Settings + list_.setCount(menu_.count()); + list_.select(menu_.groupIndex()); + return true; + default: return false; } editingRow_ = i; switch (menu_.kind(i)) { @@ -72,6 +79,11 @@ bool SettingsApp::onMenuKey(const KeyEvent& e) { break; case Kind::Toggle: menu_.toggle(i); break; case Kind::Slider: break; + case Kind::Group: + menu_.open(i); + list_.setCount(menu_.count()); + list_.select(0); + break; case Kind::Page: switch (menu_.row(i)) { case Row::Wifi: @@ -194,16 +206,27 @@ std::vector SettingsApp::aboutLines() const { void SettingsApp::draw(Canvas& c) { const auto& area = theme::kContent; switch (page_) { - case Page::Menu: + case Page::Menu: { + // An open group has its name on a line of its own, over its rows. + theme::Rect rows = area; + if (menu_.inGroup()) { + c.setFont(&fonts::bold); + c.setTextDatum(top_left); + c.setTextColor(theme::kMuted); + c.drawString(menu_.title().c_str(), 4, area.y + 1); + rows.y += theme::kLineHeight; + rows.h -= theme::kLineHeight; + } widgets::list( - c, list_, area, [this](int i) { return menu_.label(i); }, + c, list_, rows, [this](int i) { return menu_.label(i); }, [this](int i) { auto kind = menu_.kind(i); - if (kind == Kind::Page) return std::string(">"); + if (kind == Kind::Page || kind == Kind::Group) return std::string(">"); if (kind == Kind::Slider) return "< " + menu_.value(i) + " >"; return menu_.value(i); }); break; + } case Page::Text: c.setFont(&fonts::body); c.setTextColor(theme::kMuted); diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h index b6bb51a..f5f9d92 100644 --- a/src/apps/settings_app.h +++ b/src/apps/settings_app.h @@ -35,7 +35,7 @@ struct SettingsAppDeps { VpnService& vpn; }; -// Settings: every user-facing setting, plus the Wi-Fi, Firmware, Debug Console and About pages. +// Settings: every user-facing setting, in groups, plus the Wi-Fi, VPN, Firmware, Debug Console and About pages. class SettingsApp : public App { public: explicit SettingsApp(const SettingsAppDeps& deps) diff --git a/src/apps/vpn_page.h b/src/apps/vpn_page.h index 0c34759..5861cff 100644 --- a/src/apps/vpn_page.h +++ b/src/apps/vpn_page.h @@ -16,7 +16,7 @@ namespace roro { -// Settings > VPN (issue #8): the switch, "Start with Wi-Fi", importing a `.conf` from the card +// Settings > Network > VPN (issue #8): the switch, "Start with Wi-Fi", importing a `.conf` from the card // and forgetting it, and what the tunnel is doing. No key is ever on this page. class VpnPage { public: diff --git a/src/apps/wifi_settings_page.h b/src/apps/wifi_settings_page.h index e549545..1359bf0 100644 --- a/src/apps/wifi_settings_page.h +++ b/src/apps/wifi_settings_page.h @@ -17,7 +17,7 @@ namespace roro { -// Settings → Wi-Fi: the On/Off switch, the connection and its details, DNS and NTP servers, and +// Settings → Network → Wi-Fi: the On/Off switch, the connection and its details, DNS and NTP servers, and // the Saved Networks: adding one from a scan or by name (hidden), and each one's own page with its // IP setting, Automatic or Fixed (docs/milestones/S1.md, Q113). Owned by the Settings App. class WifiSettingsPage { diff --git a/src/main.cpp b/src/main.cpp index b25df39..55ea01b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -703,8 +703,8 @@ static const char* const kHelp = "uname the firmware, its version and the chip it is built for\n" "scp [-f] [-P port] user@host:path | scp [-f] [-P port] user@host:path one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it\n" "ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\n" - "vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n" - "debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n" + "vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > Network > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n" + "debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > System > Debug Console); with seconds, it comes back\n" "debug on | debug token <16 to 64 characters> | debug token new (USB serial only) switch it on, set its token\n" "crash abort|wdt crash on purpose (to test crash reports and Safe Mode)\n" "wifi ip ... try | wifi ip keep a trial IP setting: back to the previous one unless kept\n" @@ -897,13 +897,13 @@ static void debugCommand(const String& args, bool fromSerial) { debugResumes = true; console.printf("debug: off for %lu s\n", (unsigned long)seconds); } else if (!fromSerial) { - console.println("debug: over USB serial only (or Settings > Debug Console)"); + console.println("debug: over USB serial only (or Settings > System > Debug Console)"); } else if (args == "on") { DebugConsole::switchOn(settings); - console.println("debug: on (the token is in Settings > Debug Console)"); + console.println("debug: on (the token is in Settings > System > Debug Console)"); } else if (args == "token new") { settings.setString(Setting::DebugToken, DebugConsole::freshToken()); - console.println("debug: a new token (it is in Settings > Debug Console)"); + console.println("debug: a new token (it is in Settings > System > Debug Console)"); } else if (args.startsWith("token ")) { std::string token = debug::tidyToken(args.substring(6).c_str()); bool ok = debug::validToken(token) && settings.setString(Setting::DebugToken, token); @@ -1141,7 +1141,7 @@ static void runCommand(String line, bool fromSerial = false) { if (found) radioService->setPreset(p); console.printf("lora preset: %s\n", found ? p.name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod MeshCore)"); } - if (line == "gnss quiet on" || line == "gnss quiet off") { // Settings > Pause GNSS for LoRa (issue #20) + if (line == "gnss quiet on" || line == "gnss quiet off") { // Settings > GNSS and radio > Pause GNSS for LoRa (issue #20) settings.setBool(Setting::GnssQuietForLora, line.endsWith("on")); console.printf("gnss quiet: %s\n", line.endsWith("on") ? "GNSS pauses while the LoRa radio listens" : "GNSS stays on"); } diff --git a/src/services/gnss_service.h b/src/services/gnss_service.h index 60fdaed..c2bf90a 100644 --- a/src/services/gnss_service.h +++ b/src/services/gnss_service.h @@ -16,7 +16,7 @@ namespace roro { // The GNSS receiver on the Cap LoRa-1262 (see CONTEXT.md and docs/milestones/M2.md): reads its // NMEA from the main loop's tick (about 450 bytes/s, so a 512-byte UART buffer covers a second), -// holds the current Fix, and sets the clock from it. Settings → GNSS switches it on and off. +// holds the current Fix, and sets the clock from it. Settings → GNSS and radio → GNSS switches it on and off. class GnssService : public Service { public: static constexpr int kRxPin = 15, kTxPin = 13; // measured: M2 step 1 diff --git a/src/services/update_service.cpp b/src/services/update_service.cpp index 69ff6e4..bc34d85 100644 --- a/src/services/update_service.cpp +++ b/src/services/update_service.cpp @@ -284,7 +284,7 @@ void UpdateService::serve() { if (r == Request::BackgroundCheck && gitea_.latest(latest) && release::shouldAnnounce(latest, runningVersion(), failedVersion(), announced_)) { announced_ = latest.tag; - notify(latest.tag + " is out: see Settings > Firmware", NotificationLevel::Info); // 48 bytes at most + notify(latest.tag + " is out: Settings > System > Firmware", NotificationLevel::Info); // 48 bytes at most } break; } diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp index da83d53..d8bb710 100644 --- a/test/test_settings_menu/test_settings_menu.cpp +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -12,10 +12,10 @@ struct Fixture { Settings settings{store, bus}; SettingsMenu menu{settings}; Fixture() { settings.load(); } + // Opens the group the row is in: the index is into that group. int row(SettingsMenu::Row r) { - for (int i = 0; i < menu.count(); i++) - if (menu.row(i) == r) return i; - return -1; + int i = -1; + return menu.reveal(r, i) ? i : -1; } }; @@ -28,6 +28,53 @@ void test_every_row_has_a_label() { for (int i = 0; i < f.menu.count(); i++) TEST_ASSERT_FALSE(f.menu.label(i).empty()); } +void test_the_groups_are_listed_first() { + Fixture f; + TEST_ASSERT_FALSE(f.menu.inGroup()); + TEST_ASSERT_EQUAL(5, f.menu.count()); + for (int i = 0; i < f.menu.count(); i++) + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Group), static_cast(f.menu.kind(i))); + TEST_ASSERT_EQUAL_STRING("This device", f.menu.label(0).c_str()); + TEST_ASSERT_EQUAL_STRING("Display", f.menu.label(1).c_str()); + TEST_ASSERT_FALSE(f.menu.close()); // nothing to close: Back leaves Settings +} + +void test_a_group_opens_and_closes() { + Fixture f; + TEST_ASSERT_TRUE(f.menu.open(1)); + TEST_ASSERT_TRUE(f.menu.inGroup()); + TEST_ASSERT_EQUAL_STRING("Display", f.menu.title().c_str()); + TEST_ASSERT_EQUAL(6, f.menu.count()); + TEST_ASSERT_EQUAL_STRING("Brightness", f.menu.label(0).c_str()); + TEST_ASSERT_EQUAL_STRING("Launcher", f.menu.label(5).c_str()); + TEST_ASSERT_FALSE(f.menu.open(0)); // a row of a group isn't a group + TEST_ASSERT_TRUE(f.menu.close()); + TEST_ASSERT_EQUAL(1, f.menu.groupIndex()); // where the selection goes back to + TEST_ASSERT_EQUAL(5, f.menu.count()); + TEST_ASSERT_FALSE(f.menu.open(5)); +} + +void test_every_setting_is_in_one_group() { + Fixture f; + int total = 0; + for (int g = 0; g < 5; g++) { + TEST_ASSERT_TRUE(f.menu.open(g)); + TEST_ASSERT_TRUE(f.menu.count() > 0 && f.menu.count() <= 8); // a title and 8 rows fit the screen + for (int i = 0; i < f.menu.count(); i++) { + TEST_ASSERT_FALSE(f.menu.label(i).empty()); + TEST_ASSERT_NOT_EQUAL(static_cast(SettingsMenu::Kind::Group), static_cast(f.menu.kind(i))); + } + total += f.menu.count(); + f.menu.close(); + } + TEST_ASSERT_EQUAL(21, total); + int i = -1; + TEST_ASSERT_TRUE(f.menu.reveal(SettingsMenu::Row::Vpn, i)); + TEST_ASSERT_EQUAL_STRING("Network", f.menu.title().c_str()); + TEST_ASSERT_EQUAL_STRING("VPN", f.menu.label(i).c_str()); + TEST_ASSERT_FALSE(f.menu.reveal(SettingsMenu::Row::GroupDisplay, i)); +} + void test_values_are_human_readable() { Fixture f; TEST_ASSERT_EQUAL_STRING("60%", f.menu.value(f.row(SettingsMenu::Row::Brightness)).c_str()); @@ -183,6 +230,9 @@ void test_gnss_and_coordinate_rows_toggle() { int main() { UNITY_BEGIN(); RUN_TEST(test_every_row_has_a_label); + RUN_TEST(test_the_groups_are_listed_first); + RUN_TEST(test_a_group_opens_and_closes); + RUN_TEST(test_every_setting_is_in_one_group); RUN_TEST(test_values_are_human_readable); RUN_TEST(test_toggle_rows_flip_on_select); RUN_TEST(test_the_launcher_is_a_grid_or_a_list); -- 2.53.0