#include "app_keys.h" #include "gnss_app.h" #include #include #include #include #include #include #include "geo_format.h" #include "track.h" #include "ui/canvas.h" #include "ui/fonts.h" #include "ui/theme.h" #include "ui/widgets.h" namespace roro { using namespace gnss; namespace { // 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 theme::kRed; case Constellation::Galileo: return theme::kGood; case Constellation::BeiDou: return theme::kYellow; case Constellation::Qzss: return theme::kViolet; default: return theme::kText; } } const char* fixLabel(FixType f) { return f == FixType::ThreeD ? "3D Fix" : f == FixType::TwoD ? "2D Fix" : "No Fix"; } std::string duration(uint32_t ms) { uint32_t s = ms / 1000; char buf[24]; if (s < 60) std::snprintf(buf, sizeof buf, "%lu s", (unsigned long)s); else std::snprintf(buf, sizeof buf, "%lu min %02lu s", (unsigned long)(s / 60), (unsigned long)(s % 60)); return buf; } // How the IMU sits in the device, checked by hand (issue #16): which way the sensor's x and y run on the // screen, for the level's bubble, which goes to the side that is higher. constexpr float kLevelX = 1.0f, kLevelY = -1.0f; constexpr uint32_t kImuEveryMs = 10; constexpr uint32_t kCompassFrameMs = 50; constexpr uint32_t kSayMs = 4000; } // namespace void GnssApp::onEnter() { mode_ = Mode::View; dialog_.reset(); heading_.forget(); // the turns made while the App was closed were not followed imuUs_ = 0; courseSecond_ = -1; requestRedraw(); } void GnssApp::say(const std::string& text) { refusal_ = text; refusalMs_ = millis(); requestRedraw(); } bool GnssApp::onKey(const KeyEvent& e) { requestRedraw(); if (mode_ != Mode::View) return placesKey(e); if (e.key == Key::Tab) { view_ = view_ == View::Position ? View::Sky : view_ == View::Sky ? View::Compass : View::Position; if (view_ == View::Compass) loadPlaces(); return true; } if (e.key == Key::Char && (e.ch == 'r' || e.ch == 'R')) { // record a Track, or stop it if (gnss_.tracking()) gnss_.stopTrack(); else say(gnss_.startTrack(millis())); return true; } if (view_ == View::Compass && gnss_.on()) return compassKey(e); return false; // Back leaves the App } bool GnssApp::compassKey(const KeyEvent& e) { if (e.key != Key::Char) return false; switch (e.ch) { case 'm': case 'M': mark(); return true; case 's': case 'S': aimAtSun(); return true; case 't': case 'T': loadPlaces(); list_.setCount(static_cast(places_.size()) + 1); list_.select(target_ + 1); mode_ = Mode::Places; return true; default: return false; } } bool GnssApp::placesKey(const KeyEvent& e) { if (dialog_) { // remove this Place? dialog_->onKey(e); int result = dialog_->result(); if (result == DialogModel::kPending) return true; dialog_.reset(); int at = list_.selected() - 1; if (result == 1 && at >= 0 && at < static_cast(places_.size())) { places_.erase(places_.begin() + at); if (target_ == at) target_ = -1; else if (target_ > at) target_--; savePlaces(); list_.setCount(static_cast(places_.size()) + 1); } return true; } if (mode_ == Mode::Name) { switch (e.key) { case Key::Char: name_.insert(e.ch); break; case Key::Delete: name_.backspace(); break; case Key::Left: name_.left(); break; case Key::Right: name_.right(); break; case Key::Back: mode_ = Mode::View; break; case Key::Select: { const GnssState& s = gnss_.state(); mode_ = Mode::View; if (!s.positionValid) { say("The Fix was lost"); break; } std::string name = name_.text(); if (name.empty()) name = "Place " + std::to_string(places_.size() + 1); places_.push_back({name, s.latitude, s.longitude}); target_ = static_cast(places_.size()) - 1; savePlaces(); say("Marked"); break; } default: break; } return true; } switch (e.key) { case Key::Up: list_.up(); break; case Key::Down: list_.down(); break; case Key::Back: mode_ = Mode::View; break; case Key::Select: target_ = list_.selected() - 1; mode_ = Mode::View; break; case Key::Delete: if (list_.selected() > 0) dialog_.reset(new DialogModel({"Cancel", "Remove"})); break; case Key::Char: if ((e.ch == 'd' || e.ch == 'D') && list_.selected() > 0) dialog_.reset(new DialogModel({"Cancel", "Remove"})); break; default: break; } return true; } void GnssApp::mark() { loadPlaces(); if (!gnss_.state().positionValid) return say("No Fix yet"); if (!storage_.state().present) return say("No SD card"); if (places_.size() >= kMaxPlaces) return say("Too many places"); name_.setText(""); mode_ = Mode::Name; } void GnssApp::aimAtSun() { const GnssState& s = gnss_.state(); if (!s.positionValid || !s.timeValid) return say("No Fix yet"); SunPosition sun = sunPosition(s.utcSeconds(), s.latitude, s.longitude); if (sun.elevationDeg < 0) return say("The sun is down"); heading_.sun(sun.azimuthDeg); say("North from the sun"); } // The Places are few and the file is small: read whole once, written whole at each change. void GnssApp::loadPlaces() { if (placesLoaded_ || !storage_.state().present) return; std::string text; bool ran = storage_.runAndWait([&]() { File f = SD.open(kPlacesPath, FILE_READ); if (!f) return; size_t size = std::min(f.size(), 16 * 1024); text.resize(size); text.resize(f.read(reinterpret_cast(&text[0]), size)); f.close(); }); if (!ran) return; places_ = parsePlaces(text); placesLoaded_ = true; target_ = -1; } void GnssApp::savePlaces() { std::string text = placesGpx(places_); storage_.runJob([text]() { if (!SD.exists("/gnss")) SD.mkdir("/gnss"); File f = SD.open(kPlacesPath, FILE_WRITE); if (!f) return; f.write(reinterpret_cast(text.data()), text.size()); f.close(); }); } void GnssApp::readImu() { if (!M5.Imu.update()) return; auto data = M5.Imu.getImuData(); uint32_t us = micros(); float dt = imuUs_ ? (us - imuUs_) / 1e6f : 0; imuUs_ = us; if (dt > 0.2f) dt = 0; // the loop was held up: a turn that long ago is not known const float gyro[3] = {data.gyro.x, data.gyro.y, data.gyro.z}; const float accel[3] = {data.accel.x, data.accel.y, data.accel.z}; heading_.motion(gyro, accel, dt); } void GnssApp::update(uint32_t nowMs) { // The heading is followed in every view, so the Compass is right when Tab comes back to it. if (nowMs - imuMs_ >= kImuEveryMs) { imuMs_ = nowMs; readImu(); } const GnssState& s = gnss_.state(); if (gnss_.on() && s.timeValid && s.second != courseSecond_) { // a new report from the receiver courseSecond_ = s.second; if (s.positionValid && s.courseValid) heading_.course(s.courseDeg, s.speedKmh); } if (view_ == View::Compass && mode_ == Mode::View && nowMs - lastDrawMs_ >= kCompassFrameMs) { bool turned = heading_.known() && std::fabs(angleDiff(heading_.heading(), shownHeading_)) >= 1.0f; bool tilted = std::fabs(heading_.tiltX() - shownTiltX_) > 0.01f || std::fabs(heading_.tiltY() - shownTiltY_) > 0.01f; if (turned || tilted) { lastDrawMs_ = nowMs; requestRedraw(); } } if (nowMs - lastDrawMs_ >= 1000) { // the receiver reports once a second lastDrawMs_ = nowMs; requestRedraw(); } } void GnssApp::draw(Canvas& c) { c.setTextDatum(top_left); const auto& area = theme::kContent; if (!gnss_.on()) { c.setFont(&fonts::bold); c.setTextColor(theme::kText); bool held = gnss_.heldForLora(); 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" : "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; } if (mode_ != Mode::View) return drawPlaces(c); switch (view_) { case View::Position: drawPosition(c); break; case View::Sky: drawSky(c); break; case View::Compass: drawCompass(c); break; } } void GnssApp::help(std::vector& out) const { if (dialog_) return keys::add(out, keys::kDialog); if (mode_ == Mode::Name) return keys::add(out, keys::kText); if (mode_ == Mode::Places) return keys::add(out, keys::kGnssPlaces); if (view_ == View::Compass) return keys::add(out, keys::kGnssCompass); keys::add(out, keys::kGnss); } const char* GnssApp::helpTitle() const { if (mode_ == Mode::Name) return "GNSS: a new place"; if (mode_ == Mode::Places) return "GNSS: the places"; return view_ == View::Compass ? "GNSS: the compass" : nullptr; } void GnssApp::drawPosition(Canvas& c) { const auto& area = theme::kContent; const GnssState& s = gnss_.state(); uint32_t now = millis(); int used = 0; for (auto& sat : s.satellites) used += sat.used; char line[64]; c.setFont(&fonts::bold); 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(), duration(now - gnss_.onSinceMs()).c_str()); else std::snprintf(line, sizeof line, "%s, %d of %u satellites", fixLabel(s.fix), s.satellitesUsed, (unsigned)s.satellites.size()); c.drawString(line, 4, area.y + 2); c.setFont(&fonts::body); int y = area.y + 19; auto row = [&](const char* label, const std::string& value) { c.setTextColor(theme::kMuted); c.drawString(label, 4, y); c.setTextColor(theme::kText); c.drawString(value.c_str(), 64, y); y += theme::kLineHeight; }; bool dms = settings_.getBool(Setting::CoordinatesDms); if (s.positionValid) { row("Lat", dms ? formatDms(s.latitude, true) : formatDecimal(s.latitude, true)); row("Lon", dms ? formatDms(s.longitude, false) : formatDecimal(s.longitude, false)); row("Locator", maidenhead(s.latitude, s.longitude)); std::snprintf(line, sizeof line, s.altitudeValid ? "%.0f m" : "-", s.altitudeM); row("Altitude", line); if (s.courseValid && s.speedKmh >= 1.0f) std::snprintf(line, sizeof line, "%.1f km/h, %.0f\xC2\xB0", s.speedKmh, s.courseDeg); else std::snprintf(line, sizeof line, "%.1f km/h", s.speedKmh); row("Speed", line); } else { row("Lat", "-"); row("Lon", "-"); y += theme::kLineHeight * 3; } std::snprintf(line, sizeof line, "%.1f", s.hdop); row("HDOP", s.fix == FixType::None ? "-" : line); if (s.timeValid) std::snprintf(line, sizeof line, "%02d:%02d:%02d UTC", s.hour, s.minute, s.second); row("Time", s.timeValid ? line : "-"); // The Track line, at the bottom left. c.setFont(&fonts::small); if (gnss_.tracking()) { c.setTextColor(theme::kWarning); std::snprintf(line, sizeof line, "REC %d point%s, %s", gnss_.trackPoints(), gnss_.trackPoints() == 1 ? "" : "s", duration(now - gnss_.trackStartMs()).c_str()); } else if (!refusal_.empty() && now - refusalMs_ < kSayMs) { c.setTextColor(theme::kWarning); std::snprintf(line, sizeof line, "%s", refusal_.c_str()); } else line[0] = 0; c.drawString(line, 4, area.y + area.h - 9); } void GnssApp::drawSky(Canvas& c) { const auto& area = theme::kContent; const GnssState& s = gnss_.state(); const int radius = (area.h - 6) / 2, cx = 4 + radius, cy = area.y + area.h / 2; // 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, 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); auto compass = [&](const char* letter, int x, int y) { c.fillRect(x - 3, y - 4, 7, 9, theme::kBackground); // off the lines, so it reads c.drawString(letter, x, y); }; compass("N", cx, cy - radius + 5); compass("S", cx, cy + radius - 4); compass("E", cx + radius - 4, cy); compass("W", cx - radius + 4, cy); c.setTextDatum(top_left); int inView[8] = {}, usedBy[8] = {}; for (auto& sat : s.satellites) { int k = static_cast(sat.system); inView[k]++; usedBy[k] += sat.used; SkyPoint p = skyPosition(sat.azimuth, sat.elevation, cx, cy, radius - 3); uint16_t color = colorOf(sat.system); if (sat.used) c.fillCircle(p.x, p.y, 3, color); else c.drawCircle(p.x, p.y, 3, sat.snr < 0 ? theme::kMuted : color); } // Legend: used / in view per constellation, then the Fix. int x = cx + radius + 12, y = area.y + 4; c.setFont(&fonts::body); for (int k = 1; k < 7; k++) { if (!inView[k]) continue; auto system = static_cast(k); c.fillCircle(x + 3, y + 6, 3, colorOf(system)); char line[32]; std::snprintf(line, sizeof line, "%-8s%d/%d", constellationName(system), usedBy[k], inView[k]); c.setTextColor(theme::kText); c.drawString(line, x + 10, y); y += theme::kLineHeight; } if (s.satellites.empty()) { c.setTextColor(theme::kMuted); c.drawString("No satellites", x, y); y += theme::kLineHeight; } 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); c.drawString("filled: in use", x, area.y + area.h - 20); } void GnssApp::drawCompass(Canvas& c) { const auto& area = theme::kContent; const GnssState& s = gnss_.state(); const int radius = 54, cx = 4 + radius + 2, cy = area.y + area.h / 2 + 2; bool known = heading_.known(); float heading = known ? heading_.heading() : 0; // north up until north is known shownHeading_ = heading; shownTiltX_ = heading_.tiltX(); shownTiltY_ = heading_.tiltY(); // A point of the rose: `bearing` from north, `r` from the centre. The top of the screen is // where the top of the device points. auto at = [&](float bearing, float r, int& x, int& y) { float a = (bearing - heading) * static_cast(M_PI) / 180.0f; x = cx + static_cast(std::lround(r * std::sin(a))); y = cy - static_cast(std::lround(r * std::cos(a))); }; c.drawCircle(cx, cy, radius, known ? theme::kMuted : theme::kFaint); for (int b = 0; b < 360; b += 10) { int x0, y0, x1, y1; bool major = b % 30 == 0; at(b, radius - (major ? 6 : 3), x0, y0); at(b, radius - 1, x1, y1); c.drawLine(x0, y0, x1, y1, major && known ? theme::kMuted : theme::kFaint); } if (known) { // the needle: its bright half to the north int nx, ny, sx, sy, lx, ly, rx, ry; at(0, radius - 24, nx, ny); at(180, radius - 24, sx, sy); at(90, 5, rx, ry); at(270, 5, lx, ly); c.fillTriangle(sx, sy, lx, ly, rx, ry, theme::kFaint); c.fillTriangle(nx, ny, lx, ly, rx, ry, theme::kRed); } c.setTextDatum(middle_center); static const char* const kLetters[4] = {"N", "E", "S", "W"}; for (int i = 0; i < 4; i++) { int x, y; at(i * 90.0f, radius - 14, x, y); c.setFont(i == 0 ? &fonts::bold : &fonts::body); c.setTextColor(!known ? theme::kFaint : i == 0 ? theme::kRed : theme::kText); c.drawString(kLetters[i], x, y); } bool placed = s.positionValid; if (known && placed && s.timeValid) { // the sun, where it is now: something to check north against SunPosition sun = sunPosition(s.utcSeconds(), s.latitude, s.longitude); if (sun.elevationDeg > 0) { int x, y; at(sun.azimuthDeg, radius - 4, x, y); c.fillCircle(x, y, 3, theme::kYellow); } } const Place* target = target_ >= 0 && target_ < static_cast(places_.size()) ? &places_[static_cast(target_)] : nullptr; double bearing = 0, distance = 0; if (target && placed) { bearing = bearingDegrees(s.latitude, s.longitude, target->latitude, target->longitude); distance = distanceMeters(s.latitude, s.longitude, target->latitude, target->longitude); if (known) { // an arrowhead on the rim, the way to go int tx, ty, ax, ay, bx, by; at(static_cast(bearing), radius + 1, tx, ty); at(static_cast(bearing) - 7, radius - 12, ax, ay); at(static_cast(bearing) + 7, radius - 12, bx, by); c.fillTriangle(tx, ty, ax, ay, bx, by, theme::kAccent); } } // The mark the rose is read against: the top of the device. c.fillTriangle(cx, cy - radius + 2, cx - 4, cy - radius - 5, cx + 4, cy - radius - 5, theme::kText); // The readouts, to the right. const int x = cx + radius + 10; char line[48]; c.setTextDatum(top_left); c.setFont(&fonts::large); c.setTextColor(known ? theme::kText : theme::kMuted); if (known) std::snprintf(line, sizeof line, "%03d\xC2\xB0 %s", static_cast(std::lround(heading)) % 360, cardinal(heading)); else std::snprintf(line, sizeof line, "---\xC2\xB0"); c.drawString(line, x, area.y + 3); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); if (!known) { c.drawString("North is not known:", x, area.y + 22); c.drawString("walk, or aim at the", x, area.y + 31); c.drawString("sun and press s", x, area.y + 40); } else { const char* from = heading_.source() == HeadingFilter::Source::Sun ? "the sun" : "the course"; int held = static_cast(heading_.heldSeconds()); if (held < 3) std::snprintf(line, sizeof line, "from %s", from); else if (held < 120) std::snprintf(line, sizeof line, "%s, %d s ago", from, held); else std::snprintf(line, sizeof line, "%s, %d min ago", from, held / 60); // The gyroscope drifts: after a few minutes without north, say it is a guess. c.setTextColor(held >= 180 ? theme::kWarning : theme::kMuted); c.drawString(line, x, area.y + 22); c.setTextColor(theme::kMuted); if (placed) { std::snprintf(line, sizeof line, "%.1f km/h", s.speedKmh); c.drawString(line, x, area.y + 31); } } c.setFont(&fonts::bold); int y = area.y + 54; if (target) { c.setTextColor(theme::kAccent); c.drawString(target->name.c_str(), x, y); c.setFont(&fonts::body); c.setTextColor(theme::kText); if (placed) { std::snprintf(line, sizeof line, "%s %03d\xC2\xB0", formatDistance(distance).c_str(), static_cast(std::lround(bearing)) % 360); c.drawString(line, x, y + theme::kLineHeight); } else c.drawString("No Fix", x, y + theme::kLineHeight); } else { c.setFont(&fonts::small); c.setTextColor(theme::kMuted); c.drawString("No target: t", x, y); c.drawString("m marks this place", x, y + 9); } // The level: the bubble goes to the higher side, and is in the middle when the device is flat. const int lr = 17, bubble = 5, lx = x + lr, ly = area.y + area.h - lr - 3; float bx = kLevelX * heading_.tiltX(), by = kLevelY * heading_.tiltY(); bool flat = std::sqrt(bx * bx + by * by) < 0.035f; // within 2 degrees const float kFull = 0.26f; // 15 degrees puts the bubble at the edge float reach = std::sqrt(bx * bx + by * by) / kFull; if (reach > 1.0f) { bx /= reach; by /= reach; } c.drawCircle(lx, ly, lr, theme::kMuted); c.drawCircle(lx, ly, bubble + 2, theme::kFaint); const float travel = lr - bubble - 1; c.fillCircle(lx + static_cast(std::lround(bx / kFull * travel)), ly + static_cast(std::lround(by / kFull * travel)), bubble, flat ? theme::kGood : theme::kText); c.setFont(&fonts::small); if (!refusal_.empty() && millis() - refusalMs_ < kSayMs) { c.setTextColor(theme::kWarning); c.drawString(refusal_.c_str(), lx + lr + 5, ly - 3); } else { c.setTextColor(theme::kMuted); c.drawString(flat ? "flat" : "tilted", lx + lr + 5, ly - 3); } } void GnssApp::drawPlaces(Canvas& c) { const auto& area = theme::kContent; const GnssState& s = gnss_.state(); c.setFont(&fonts::bold); c.setTextColor(theme::kText); if (mode_ == Mode::Name) { c.drawString("A name for this place", 4, area.y + 4); widgets::lineEditor(c, name_, {4, area.y + 24, area.w - 8, 0}); c.setFont(&fonts::body); c.setTextColor(theme::kMuted); c.drawString("The Compass will lead back to it.", 4, area.y + 46); c.drawString("Left empty, it gets a number.", 4, area.y + 46 + theme::kLineHeight); return; } c.drawString("Where to?", 4, area.y + 2); theme::Rect rows{0, area.y + 18, area.w, list_.visibleRows() * theme::kLineHeight}; widgets::list( c, list_, rows, [&](int i) { return i == 0 ? std::string("No target") : places_[static_cast(i - 1)].name; }, [&](int i) { if (i == 0 || !s.positionValid) return std::string(); const Place& p = places_[static_cast(i - 1)]; return formatDistance(distanceMeters(s.latitude, s.longitude, p.latitude, p.longitude)); }); c.setFont(&fonts::small); c.setTextColor(theme::kMuted); c.drawString(places_.empty() ? "No place yet: m in the compass marks one" : "Enter: go there d: remove it", 4, area.y + area.h - 9); if (dialog_ && list_.selected() > 0) widgets::dialog(c, "Remove this place?", places_[static_cast(list_.selected() - 1)].name, *dialog_); } } // namespace roro