M2 step 3: GNSS Service, with standby and the clock from a Fix

The GNSS Service reads the receiver (UART 15/13, 115200, 1 KB buffer)
from its 50 ms tick and feeds the NMEA parser. With a Fix it sets the
clock (GNSS is the most trusted TimeSource) and refreshes it every 10
min. Settings gets GNSS On/Off and the coordinate format (Q64).

Off puts the receiver in standby with $PCAS12,65535, renewed hourly; On
wakes it with a hot start, $PCAS10,0. Both measured on the device: the
output stops within a second, and a command wakes it within a second.

Commands: gnss status (Fix, satellites per constellation, bytes and
sentences), gnss nmea on|off, gnss send <sentence>, gnss restart. The
probe is gone; never drive GPIO 15 (the receiver's output): the first
probe's swapped-pin attempt silenced it until a power cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
This commit is contained in:
2026-10-04 22:26:53 +02:00
co-authored by Claude Opus 5.5
parent c9b1ecb772
commit 988253ef02
13 changed files with 257 additions and 32 deletions
+4 -2
View File
@@ -8,11 +8,11 @@
| # | Decision | | # | Decision |
|---|---| |---|---|
| Q58 | Settings has a GNSS On/Off switch, **On by default**. Off puts the receiver in standby if it accepts a command for it (measured in step 1); otherwise the firmware stops listening. | | Q58 | Settings has a GNSS On/Off switch, **On by default**. Off puts the receiver in standby. *Measured:* `$PCAS12,<seconds>` (CASIC) stops its output within a second, for up to at least 65535 s, and any command wakes it within a second; Off sends `PCAS12,65535` (renewed hourly), On sends a hot start, `PCAS10,0`. |
| Q59 | The **GNSS App** has two views, switched with Tab. *Position*: latitude, longitude, altitude, speed, course, Fix (none / 2D / 3D), satellites used and in view, HDOP, UTC time. *Sky*: the satellites placed by azimuth and elevation, coloured by constellation, filled when used in the Fix. | | Q59 | The **GNSS App** has two views, switched with Tab. *Position*: latitude, longitude, altitude, speed, course, Fix (none / 2D / 3D), satellites used and in view, HDOP, UTC time. *Sky*: the satellites placed by azimuth and elevation, coloured by constellation, filled when used in the Fix. |
| Q60 | "Radar" in M2 means the Sky view. A radar of other Nodes by distance and bearing needs the mesh: M4. | | Q60 | "Radar" in M2 means the Sky view. A radar of other Nodes by distance and bearing needs the mesh: M4. |
| Q61 | The **Status Bar** shows a GNSS mark: absent when off, muted while searching, normal with a 2D Fix, with the satellite count with a 3D Fix. | | Q61 | The **Status Bar** shows a GNSS mark: absent when off, muted while searching, normal with a 2D Fix, with the satellite count with a 3D Fix. |
| Q62 | GNSS time **sets the clock when NTP hasn't this boot**. NTP stays preferred when online. | | Q62 | GNSS time **sets the clock once there's a Fix**, and refreshes it every 10 minutes. *Revised in step 3:* the clock's trust order from M0 (Mesh < NTP < GNSS) already ranks GNSS above NTP, which is right: GNSS time is at least as accurate. So GNSS also corrects a clock NTP set, not only an unset one. |
| Q63 | A **Track** is started and stopped in the GNSS App. It's written as GPX to `/gnss/tracks/<YYYYMMDD-HHMMSS>.gpx`, a point every 5 s when the position moved more than 5 m. It keeps recording with the App closed, with a Toast on start and stop and a Status Bar mark, and gets its own Storage Clean-up category. | | Q63 | A **Track** is started and stopped in the GNSS App. It's written as GPX to `/gnss/tracks/<YYYYMMDD-HHMMSS>.gpx`, a point every 5 s when the position moved more than 5 m. It keeps recording with the App closed, with a Toast on start and stop and a Status Bar mark, and gets its own Storage Clean-up category. |
| Q64 | Coordinates in **decimal degrees plus the Maidenhead locator**; a Settings switch for degrees, minutes and seconds. Metric units only. | | Q64 | Coordinates in **decimal degrees plus the Maidenhead locator**; a Settings switch for degrees, minutes and seconds. Metric units only. |
| Q65 | **The position never leaves the device in M2.** Sharing it over the mesh, and at what precision, is decided in M4. | | Q65 | **The position never leaves the device in M2.** Sharing it over the mesh, and at what precision, is decided in M4. |
@@ -20,6 +20,8 @@
| Q67 | The receiver keeps its **defaults** (all constellations, 1 Hz). No receiver settings. Time to first fix is measured and recorded here. | | Q67 | The receiver keeps its **defaults** (all constellations, 1 Hz). No receiver settings. Time to first fix is measured and recorded here. |
| Q68 | Debug aids: `gnss status`, and `gnss nmea on/off` to stream the raw sentences to the console (USB serial and Debug Console). Raw NMEA is never written to the card. | | Q68 | Debug aids: `gnss status`, and `gnss nmea on/off` to stream the raw sentences to the console (USB serial and Debug Console). Raw NMEA is never written to the card. |
**Lesson (step 3):** the first probe also tried the pins swapped, driving the receiver's output line from the ESP32 for about a second. The receiver then went silent until a full power cycle (an ESP32 restart doesn't cut the Cap's power). Never drive GPIO 15.
## Done when ## Done when
- The GNSS Service reads NMEA in the background whatever App is on screen, and a 3D Fix appears outdoors. - The GNSS Service reads NMEA in the background whatever App is on screen, and a 3D Fix appears outdoors.
+5
View File
@@ -20,6 +20,7 @@ const RowDef kRows[] = {
{Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"}, {Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"},
{Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"}, {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::Sound, Kind::Toggle, "Sound & LED"},
{Row::Gnss, Kind::Toggle, "GNSS"}, {Row::Coordinates, Kind::Toggle, "Coordinates"},
{Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"}, {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Wifi, Kind::Page, "Wi-Fi"},
{Row::Storage, Kind::Page, "Storage"}, {Row::Storage, Kind::Page, "Storage"},
{Row::Firmware, Kind::Page, "Firmware"}, {Row::Firmware, Kind::Page, "Firmware"},
@@ -79,6 +80,8 @@ std::string SettingsMenu::value(int i) const {
case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS)); case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS));
case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS)); case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS));
case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off"; case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off";
case Row::Gnss: return settings_.getBool(Setting::GnssEnabled) ? "On" : "Off";
case Row::Coordinates: return settings_.getBool(Setting::CoordinatesDms) ? "Deg min sec" : "Decimal";
case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised";
case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off"; case Row::Wifi: return settings_.getBool(Setting::WifiEnabled) ? "On" : "Off";
default: return ""; default: return "";
@@ -122,6 +125,8 @@ std::string SettingsMenu::choose(int i, int c) {
void SettingsMenu::toggle(int i) { void SettingsMenu::toggle(int i) {
if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound)); 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::Coordinates) settings_.setBool(Setting::CoordinatesDms, !settings_.getBool(Setting::CoordinatesDms));
if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw)); if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw));
} }
+1 -1
View File
@@ -11,7 +11,7 @@ namespace roro {
// values, choice lists and validation messages. Rendering and navigation live in the App. // values, choice lists and validation messages. Rendering and navigation live in the App.
class SettingsMenu { class SettingsMenu {
public: public:
enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Wifi, Storage, Firmware, About }; enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, Gnss, Coordinates, ProbeMacs, Wifi, Storage, Firmware, About };
enum class Kind { Text, Choice, Toggle, Slider, Page }; enum class Kind { Text, Choice, Toggle, Slider, Page };
explicit SettingsMenu(Settings& settings) : settings_(settings) {} explicit SettingsMenu(Settings& settings) : settings_(settings) {}
+4 -1
View File
@@ -92,7 +92,10 @@ void NmeaParser::feed(const char* data, size_t len) {
for (size_t i = 0; i < len; i++) { for (size_t i = 0; i < len; i++) {
char c = data[i]; char c = data[i];
if (c == '\n') { if (c == '\n') {
if (!overflow_ && !line_.empty()) sentence(line_); if (!overflow_ && !line_.empty()) {
if (onLine) onLine(line_);
sentence(line_);
}
line_.clear(); line_.clear();
overflow_ = false; overflow_ = false;
} else if (c != '\r') { } else if (c != '\r') {
+4
View File
@@ -2,6 +2,7 @@
#include <cstddef> #include <cstddef>
#include <cstdint> #include <cstdint>
#include <functional>
#include <map> #include <map>
#include <set> #include <set>
#include <string> #include <string>
@@ -53,6 +54,9 @@ class NmeaParser {
// One sentence without its line end. False if malformed or its checksum is wrong. // One sentence without its line end. False if malformed or its checksum is wrong.
bool sentence(const std::string& line); bool sentence(const std::string& line);
// Called with every complete line fed in, valid or not (`gnss nmea on` echoes them).
std::function<void(const std::string&)> onLine;
const GnssState& state() const { return state_; } const GnssState& state() const { return state_; }
uint32_t goodSentences() const { return good_; } uint32_t goodSentences() const { return good_; }
uint32_t badSentences() const { return bad_; } uint32_t badSentences() const { return bad_; }
+2
View File
@@ -29,6 +29,8 @@ const Definition kDefinitions[] = {
{"sound", Kind::Bool, 1, nullptr, 0, 1}, {"sound", Kind::Bool, 1, nullptr, 0, 1},
{"probe_mac_raw", Kind::Bool, 1, nullptr, 0, 1}, {"probe_mac_raw", Kind::Bool, 1, nullptr, 0, 1},
{"wifi_on", Kind::Bool, 1, nullptr, 0, 1}, {"wifi_on", Kind::Bool, 1, nullptr, 0, 1},
{"gnss_on", Kind::Bool, 1, nullptr, 0, 1},
{"coord_dms", Kind::Bool, 0, nullptr, 0, 1},
}; };
static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count), static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast<size_t>(Setting::Count),
"every Setting needs a definition"); "every Setting needs a definition");
+2
View File
@@ -21,6 +21,8 @@ enum class Setting : uint8_t {
Sound, // bool Sound, // bool
ProbeMacRaw, // bool: probe-request Logs keep raw MAC addresses ProbeMacRaw, // bool: probe-request Logs keep raw MAC addresses
WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range
GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58)
CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64)
Count Count
}; };
+10 -28
View File
@@ -2,7 +2,6 @@
#include <M5Cardputer.h> #include <M5Cardputer.h>
#include <SD.h> #include <SD.h>
#include <esp_task_wdt.h>
#include <atomic> #include <atomic>
#include <memory> #include <memory>
@@ -27,6 +26,7 @@
#include "services/irc_service.h" #include "services/irc_service.h"
#include "services/clock_service.h" #include "services/clock_service.h"
#include "services/debug_console.h" #include "services/debug_console.h"
#include "services/gnss_service.h"
#include "services/power_service.h" #include "services/power_service.h"
#include "services/storage_service.h" #include "services/storage_service.h"
#include "services/update_service.h" #include "services/update_service.h"
@@ -52,6 +52,7 @@ static BatteryService* battery;
static StorageService* storageService; static StorageService* storageService;
static PowerService* power; static PowerService* power;
static ClockService* clockService; static ClockService* clockService;
static GnssService* gnssService;
static SavedNetworks* savedNetworks; static SavedNetworks* savedNetworks;
static WifiService* wifi; static WifiService* wifi;
static IrcService* irc; static IrcService* irc;
@@ -131,6 +132,7 @@ void setup() {
storageService = new StorageService(bus); storageService = new StorageService(bus);
power = new PowerService(settings); power = new PowerService(settings);
clockService = new ClockService(settings, bus); clockService = new ClockService(settings, bus);
gnssService = new GnssService(settings, *clockService, bus);
savedNetworks = new SavedNetworks(nvs); savedNetworks = new SavedNetworks(nvs);
savedNetworks->load(); savedNetworks->load();
wifi = new WifiService(settings, *savedNetworks, *clockService); wifi = new WifiService(settings, *savedNetworks, *clockService);
@@ -141,6 +143,7 @@ void setup() {
services.add(*power); services.add(*power);
services.add(*clockService); services.add(*clockService);
services.add(*battery); services.add(*battery);
services.add(*gnssService);
services.add(*storageService); services.add(*storageService);
services.add(*wifi); services.add(*wifi);
services.add(*irc); services.add(*irc);
@@ -325,6 +328,7 @@ static const char* const kHelp =
"reboot restart\n" "reboot restart\n"
"boot other restart into the other app slot (manual Rollback)\n" "boot other restart into the other app slot (manual Rollback)\n"
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n" "log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
"gnss status | gnss restart | gnss nmea on|off | gnss send <sentence without $ and checksum>\n"
"crash the last crash: firmware, reason, task, backtrace\n" "crash the last crash: firmware, reason, task, backtrace\n"
"coredump erase forget the core dump in flash\n" "coredump erase forget the core dump in flash\n"
"key <name|char> press a key: up down left right select back home del tab, or one character\n" "key <name|char> press a key: up down left right select back home del tab, or one character\n"
@@ -386,33 +390,11 @@ static void runCommand(String line) {
}); });
} }
if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD if (line.startsWith("install ")) update->installFromSd(line.substring(8).c_str()); // Update from SD
if (line == "gnss probe") { // M2 step 1: which pins and baud rate carry NMEA (temporary) if (line == "gnss status" && gnssService) gnssService->printStatus(console, millis());
struct Try { int rx, tx; uint32_t baud; } tries[] = {{15, 13, 115200}, {13, 15, 115200}, {15, 13, 9600}, {13, 15, 9600}}; if ((line == "gnss nmea on" || line == "gnss nmea off") && gnssService) gnssService->setEcho(line.endsWith("on"));
for (auto& t : tries) { if (line == "gnss restart" && gnssService) gnssService->restart(millis());
Serial1.begin(t.baud, SERIAL_8N1, t.rx, t.tx); if (line.startsWith("gnss send ") && gnssService) // the checksum is added
uint32_t start = millis(), bytes = 0, lines = 0; console.println(gnssService->send(line.substring(10).c_str()) ? "gnss: sent" : "gnss: off");
std::string current, sample;
while (millis() - start < 1200) {
esp_task_wdt_reset(); // four listens outlast the 5 s loop watchdog
while (Serial1.available()) {
char c = Serial1.read();
bytes++;
if (c == '\n') {
if (current.size() > 6 && current[0] == '$') {
lines++;
if (sample.size() < 400) sample += " " + current + "\n";
}
current.clear();
} else if (c != '\r' && current.size() < 120) current += c;
}
delay(5);
}
Serial1.end();
console.printf("gnss probe: rx %d tx %d at %lu: %lu bytes, %lu NMEA lines\n", t.rx, t.tx,
(unsigned long)t.baud, (unsigned long)bytes, (unsigned long)lines);
if (lines) console.print(sample.c_str());
}
}
if (line == "crash") crash_report::print(console, nvs); if (line == "crash") crash_report::print(console, nvs);
if (line == "coredump erase") console.println(crash_report::erase() ? "coredump: erased" : "coredump: nothing to erase"); if (line == "coredump erase") console.println(crash_report::erase() ? "coredump: erased" : "coredump: nothing to erase");
#ifdef RORO_DEBUG #ifdef RORO_DEBUG
+131
View File
@@ -0,0 +1,131 @@
#include "services/gnss_service.h"
#include <Arduino.h>
#include <algorithm>
#include <cstdio>
#include "platform/console.h"
namespace roro {
namespace {
const char* fixName(gnss::FixType f) {
return f == gnss::FixType::ThreeD ? "3D" : f == gnss::FixType::TwoD ? "2D" : "none";
}
} // namespace
GnssService::GnssService(const Settings& settings, ClockService& clock, EventBus& bus)
: settings_(settings), clock_(clock), bus_(bus) {
parser_.onLine = [this](const std::string& line) {
lastLineMs_ = millis();
if (echo_) console.printf("nmea: %s\n", line.c_str());
};
}
void GnssService::start() { open(millis()); }
void GnssService::stop() { close(); }
void GnssService::open(uint32_t nowMs) {
Serial1.setRxBufferSize(1024); // before begin(): over 2 s of NMEA
Serial1.begin(kBaud, SERIAL_8N1, kRxPin, kTxPin);
open_ = true;
active_ = false; // tick() wakes it or puts it to sleep, per the setting
standbyMs_ = 0;
}
void GnssService::wake(uint32_t nowMs) {
send("PCAS10,0"); // hot start: ends a standby, keeps what the receiver knows
active_ = true;
openedMs_ = nowMs;
firstFixMs_ = 0;
lastLineMs_ = 0;
lastFix_ = gnss::FixType::None;
console.println("gnss: on");
}
void GnssService::standby(uint32_t nowMs) {
send("PCAS12,65535");
if (active_) console.println("gnss: off (receiver in standby)");
active_ = false;
standbyMs_ = nowMs;
}
void GnssService::close() {
if (!open_) return;
Serial1.end();
open_ = active_ = false;
lastLineMs_ = 0;
}
void GnssService::tick(uint32_t nowMs) {
if (!open_) open(nowMs);
bool wanted = settings_.getBool(Setting::GnssEnabled);
if (wanted && !active_) wake(nowMs);
if (!wanted && (active_ || !standbyMs_ || nowMs - standbyMs_ >= kStandbyRenewMs)) standby(nowMs);
char buf[128];
for (int avail; (avail = Serial1.available()) > 0;) {
size_t n = Serial1.read(reinterpret_cast<uint8_t*>(buf), std::min<size_t>(avail, sizeof buf));
if (n == 0) break;
bytes_ += n;
if (active_) parser_.feed(buf, n); // in standby: the last lines before it, discarded
}
if (!active_) return;
const gnss::GnssState& s = parser_.state();
if (s.fix != lastFix_) {
if (s.fix != gnss::FixType::None && !firstFixMs_) {
firstFixMs_ = nowMs - openedMs_;
console.printf("gnss: first Fix after %lu s\n", (unsigned long)(firstFixMs_ / 1000));
}
console.printf("gnss: Fix %s, %d satellites used\n", fixName(s.fix), s.satellitesUsed);
lastFix_ = s.fix;
}
// GNSS time is the most trusted source (ClockModel): set it with a Fix, then now and then.
if (s.timeValid && s.fix != gnss::FixType::None && (!clockSetMs_ || nowMs - clockSetMs_ >= kClockEveryMs)) {
if (clock_.set(s.utcSeconds(), TimeSource::Gnss) && !clockSetMs_) console.println("gnss: clock set");
clockSetMs_ = nowMs;
}
}
bool GnssService::send(const std::string& body) {
if (!open_) return false;
uint8_t sum = 0;
for (char c : body) sum ^= static_cast<uint8_t>(c);
char tail[8];
std::snprintf(tail, sizeof tail, "*%02X\r\n", sum);
std::string line = "$" + body + tail;
Serial1.write(reinterpret_cast<const uint8_t*>(line.data()), line.size());
return true;
}
void GnssService::printStatus(Print& out, uint32_t nowMs) const {
if (!active_) return (void)out.println("gnss: off (receiver in standby)");
const gnss::GnssState& s = parser_.state();
out.printf("gnss: %s, Fix %s, %d used, %u in view, HDOP %.1f\n",
receiving(nowMs) ? "receiving" : "silent", fixName(s.fix), s.satellitesUsed,
(unsigned)s.satellites.size(), s.hdop);
if (s.positionValid)
out.printf("gnss: %.6f %.6f, %s%.0f m, %.1f km/h\n", s.latitude, s.longitude,
s.altitudeValid ? "" : "altitude unknown ", s.altitudeM, s.speedKmh);
if (s.timeValid)
out.printf("gnss: %04d-%02d-%02d %02d:%02d:%02d UTC\n", s.year, s.month, s.day, s.hour, s.minute, s.second);
int perSystem[8] = {}, usedPerSystem[8] = {};
for (auto& sat : s.satellites) {
perSystem[static_cast<int>(sat.system)]++;
if (sat.used) usedPerSystem[static_cast<int>(sat.system)]++;
}
out.print("gnss: in view (used)");
for (int c = 1; c < 7; c++)
if (perSystem[c])
out.printf(" %s %d (%d)", gnss::constellationName(static_cast<gnss::Constellation>(c)), perSystem[c],
usedPerSystem[c]);
out.println();
out.printf("gnss: up %lu s, first Fix %s, %lu bytes, %lu sentences, %lu bad\n", (unsigned long)((nowMs - openedMs_) / 1000),
firstFixMs_ ? (std::to_string(firstFixMs_ / 1000) + " s").c_str() : "not yet", (unsigned long)bytes_,
(unsigned long)parser_.goodSentences(), (unsigned long)parser_.badSentences());
}
} // namespace roro
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#include <Print.h>
#include <string>
#include "event_bus.h"
#include "nmea_parser.h"
#include "service.h"
#include "services/clock_service.h"
#include "settings.h"
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 1 KB UART buffer covers any stall),
// holds the current Fix, and sets the clock from it. Settings → GNSS switches it on and off.
class GnssService : public Service {
public:
static constexpr int kRxPin = 15, kTxPin = 13; // measured: M2 step 1
static constexpr uint32_t kBaud = 115200;
static constexpr uint32_t kSilentAfterMs = 3000; // no sentence for this long: receiver silent
static constexpr uint32_t kClockEveryMs = 600000; // refresh the clock from the Fix this often
// Off (Q58, measured): "$PCAS12,<s>" puts the receiver in standby for that long, and any
// command wakes it. Standby is renewed while off, in case the duration is clamped.
static constexpr uint32_t kStandbyRenewMs = 3600000;
GnssService(const Settings& settings, ClockService& clock, EventBus& bus);
const char* name() const override { return "gnss"; }
uint32_t tickIntervalMs() const override { return 50; }
void start() override;
void stop() override;
void tick(uint32_t nowMs) override;
bool on() const { return active_; }
bool receiving(uint32_t nowMs) const { return active_ && lastLineMs_ && nowMs - lastLineMs_ < kSilentAfterMs; }
const gnss::GnssState& state() const { return parser_.state(); }
// Debug aids (Q68): raw sentences to the console, a status report, and sending a command
// ("PCAS10,0" becomes "$PCAS10,0*1C\r\n").
void setEcho(bool echo) { echo_ = echo; }
void printStatus(Print& out, uint32_t nowMs) const;
bool send(const std::string& body);
void restart(uint32_t nowMs) {
close();
open(nowMs);
}
private:
void open(uint32_t nowMs); // the UART; the receiver's state follows the setting in tick()
void close();
void wake(uint32_t nowMs);
void standby(uint32_t nowMs);
const Settings& settings_;
ClockService& clock_;
EventBus& bus_;
gnss::NmeaParser parser_;
bool open_ = false, active_ = false, echo_ = false;
uint32_t bytes_ = 0, standbyMs_ = 0;
uint32_t openedMs_ = 0, lastLineMs_ = 0, firstFixMs_ = 0, clockSetMs_ = 0;
gnss::FixType lastFix_ = gnss::FixType::None;
};
} // namespace roro
+13
View File
@@ -2,6 +2,7 @@
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include <vector>
#include "nmea_parser.h" #include "nmea_parser.h"
@@ -195,6 +196,17 @@ void test_an_overlong_line_is_dropped() {
TEST_ASSERT_TRUE(p.state().positionValid); TEST_ASSERT_TRUE(p.state().positionValid);
} }
void test_every_complete_line_reaches_the_echo() {
NmeaParser p;
std::vector<std::string> seen;
p.onLine = [&](const std::string& l) { seen.push_back(l); };
std::string data = "$GNVTG,,,,,,,,,N*2E\r\nbroken\r\n$GN";
p.feed(data.data(), data.size());
TEST_ASSERT_EQUAL(2, static_cast<int>(seen.size()));
TEST_ASSERT_EQUAL_STRING("$GNVTG,,,,,,,,,N*2E", seen[0].c_str());
TEST_ASSERT_EQUAL_STRING("broken", seen[1].c_str());
}
void test_constellation_names() { void test_constellation_names() {
TEST_ASSERT_EQUAL_STRING("GPS", constellationName(Constellation::Gps)); TEST_ASSERT_EQUAL_STRING("GPS", constellationName(Constellation::Gps));
TEST_ASSERT_EQUAL_STRING("Galileo", constellationName(Constellation::Galileo)); TEST_ASSERT_EQUAL_STRING("Galileo", constellationName(Constellation::Galileo));
@@ -218,6 +230,7 @@ int main() {
RUN_TEST(test_gsa_marks_the_satellites_used_per_system); RUN_TEST(test_gsa_marks_the_satellites_used_per_system);
RUN_TEST(test_split_feeds_and_noise_are_handled); RUN_TEST(test_split_feeds_and_noise_are_handled);
RUN_TEST(test_an_overlong_line_is_dropped); RUN_TEST(test_an_overlong_line_is_dropped);
RUN_TEST(test_every_complete_line_reaches_the_echo);
RUN_TEST(test_constellation_names); RUN_TEST(test_constellation_names);
return UNITY_END(); return UNITY_END();
} }
+2
View File
@@ -26,6 +26,8 @@ void test_defaults_when_store_is_empty() {
TEST_ASSERT_TRUE(s.getBool(Setting::Sound)); TEST_ASSERT_TRUE(s.getBool(Setting::Sound));
TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw)); TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw));
TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled)); TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled));
TEST_ASSERT_TRUE(s.getBool(Setting::GnssEnabled)); // Q58: on by default
TEST_ASSERT_FALSE(s.getBool(Setting::CoordinatesDms)); // Q64: decimal degrees
TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str()); TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str());
TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str()); TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str());
} }
@@ -107,6 +107,19 @@ void test_names_are_text_rows_with_their_byte_limits() {
TEST_ASSERT_EQUAL_STRING("RORO", f.menu.value(shortName).c_str()); TEST_ASSERT_EQUAL_STRING("RORO", f.menu.value(shortName).c_str());
} }
void test_gnss_and_coordinate_rows_toggle() {
Fixture f;
int gnss = f.row(SettingsMenu::Row::Gnss), coords = f.row(SettingsMenu::Row::Coordinates);
TEST_ASSERT_EQUAL_STRING("On", f.menu.value(gnss).c_str());
TEST_ASSERT_EQUAL_STRING("Decimal", f.menu.value(coords).c_str());
f.menu.toggle(gnss);
f.menu.toggle(coords);
TEST_ASSERT_FALSE(f.settings.getBool(Setting::GnssEnabled));
TEST_ASSERT_TRUE(f.settings.getBool(Setting::CoordinatesDms));
TEST_ASSERT_EQUAL_STRING("Off", f.menu.value(gnss).c_str());
TEST_ASSERT_EQUAL_STRING("Deg min sec", f.menu.value(coords).c_str());
}
int main() { int main() {
UNITY_BEGIN(); UNITY_BEGIN();
RUN_TEST(test_every_row_has_a_label); RUN_TEST(test_every_row_has_a_label);
@@ -118,5 +131,6 @@ int main() {
RUN_TEST(test_brightness_steps_with_left_right_within_bounds); RUN_TEST(test_brightness_steps_with_left_right_within_bounds);
RUN_TEST(test_wifi_is_a_page); RUN_TEST(test_wifi_is_a_page);
RUN_TEST(test_names_are_text_rows_with_their_byte_limits); RUN_TEST(test_names_are_text_rows_with_their_byte_limits);
RUN_TEST(test_gnss_and_coordinate_rows_toggle);
return UNITY_END(); return UNITY_END();
} }