From fa7834cfca5bdd1aaa4b43f6a0771d43b48bd2ae Mon Sep 17 00:00:00 2001 From: AdaSzi115026 <81822538+AdaSzi@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:01:14 +0200 Subject: [PATCH 1/3] Satellite Tracker: support new firmware, faster start - Pass the certificate text to http_download_start (Tactility #701) - No file work on the LVGL thread: callbacks ask the app task to save, groups and downloads are read there - Pass search and the Next passes list run on the app task, the map shows at once - The last device location is saved and used at start, so the automatic selection no longer changes after a few seconds --- Apps/SatelliteTracker/README.md | 6 +- .../main/Source/Downloads.cpp | 18 +- Apps/SatelliteTracker/main/Source/Downloads.h | 4 +- Apps/SatelliteTracker/main/Source/MapView.cpp | 11 +- .../main/Source/PassesView.cpp | 113 +++++++---- .../main/Source/SatelliteTracker.cpp | 187 ++++++++++++++---- .../main/Source/SatelliteTracker.h | 58 +++++- .../main/Source/SatellitesView.cpp | 42 ++-- .../main/Source/SettingsView.cpp | 10 +- Apps/SatelliteTracker/main/Source/Storage.cpp | 57 +++++- Apps/SatelliteTracker/main/Source/Storage.h | 27 ++- .../main/Source/TrackerModel.cpp | 79 ++++++-- .../main/Source/TrackerModel.h | 35 ++++ Apps/SatelliteTracker/main/Source/main.cpp | 23 ++- 14 files changed, 516 insertions(+), 154 deletions(-) diff --git a/Apps/SatelliteTracker/README.md b/Apps/SatelliteTracker/README.md index 02586ad..a7ad5a8 100644 --- a/Apps/SatelliteTracker/README.md +++ b/Apps/SatelliteTracker/README.md @@ -7,7 +7,7 @@ Orbit data comes from [CelesTrak](https://celestrak.org) and the positions are c - **Clock:** the positions depend on the time, so the clock must be set. Connect to Wi-Fi once so Tactility can sync the time. Until then the map shows no satellites and the info line says that the clock is not set. - **Wi-Fi:** needed to download orbit data, groups and search results. Without it the app keeps working with the data it downloaded before. Satellites without any orbit data yet (for example on the first start) are listed but not shown on the map until their data arrives. -- **Location:** the app follows Tactility's location services (GPS or network based) when they are turned on in Tactility's settings and have a fix. Otherwise it uses the location from the app's settings, which is New York City until you enter your own. If you clear that location and there is no device location, there are no pass predictions, no elevation values and no automatic selection of the next pass. +- **Location:** the app follows Tactility's location services (GPS or network based) when they are turned on in Tactility's settings and have a fix. Until the first fix after a start it uses the last device location, so the passes and the automatic selection do not change a few seconds after the start. Otherwise it uses the location from the app's settings, which is New York City until you enter your own. If you clear that location and there is no device location, there are no pass predictions, no elevation values and no automatic selection of the next pass. ## Map screen @@ -38,7 +38,7 @@ The selected satellite has a solid frame, a satellite selected automatically has ### Info area - Name, catalog number, position, altitude, speed and whether the satellite is sunlit or in Earth's shadow. For a satellite without a position it says that its orbit data is still missing or can no longer be used. -- The pass: above the horizon now (elevation, direction, set time), the next pass (start and end time, highest elevation, rise and set direction, "tomorrow" when it is on the next day, the weekday when it is later), always above the horizon (geostationary satellites), no pass higher than 5 degrees in 36 hours, or "Set location in settings". +- The pass: above the horizon now (elevation, direction, set time), the next pass (start and end time, highest elevation, rise and set direction, "tomorrow" when it is on the next day, the weekday when it is later), always above the horizon (geostationary satellites), no pass higher than 5 degrees in 36 hours, "Set location in settings", or "Finding the next pass..." for the few seconds the search takes after the start or a new location. - The status line: when the map was updated and how old the orbit data is, or a message while downloading or after a failed download. When the clock was moved back after a download, the age is unknown and not shown. ### Layout @@ -77,7 +77,7 @@ The map, legend and info area are updated every 5 seconds on color screens and e ## Next passes screen -The calendar button lists the passes of all tracked satellites over your location in the next 24 hours, sorted by time. +The calendar button lists the passes of all tracked satellites over your location in the next 24 hours, sorted by time. The list is computed in the background, "Finding passes..." is shown meanwhile. - Each row shows the start and end time, the highest elevation, the rise and set direction and the satellite. Passes on the next day show the weekday. - Passes lower than 5 degrees are skipped. At most 10 passes per satellite and 40 rows are listed. diff --git a/Apps/SatelliteTracker/main/Source/Downloads.cpp b/Apps/SatelliteTracker/main/Source/Downloads.cpp index e8824d8..43849b7 100644 --- a/Apps/SatelliteTracker/main/Source/Downloads.cpp +++ b/Apps/SatelliteTracker/main/Source/Downloads.cpp @@ -11,10 +11,6 @@ namespace { constexpr const char* BASE_URL = "https://celestrak.org/NORAD/elements/gp.php"; constexpr const char* CERTIFICATE_FILE = "celestrak.pem"; -bool userFile(const char* name, char* out, size_t outSize) { - return storageEnsureDirectory() && storageUserPath(name, out, outSize); -} - void partFile(const char* target, char* out, size_t outSize) { snprintf(out, outSize, "%s.part", target); } @@ -34,8 +30,8 @@ bool Downloader::init(TaskEventGroup* eventGroup) { if (task_event_group_claim_bit(group, &wakeBit) != ERROR_NONE) return false; mutex = xSemaphoreCreateMutex(); if (mutex == nullptr) return false; - storageAssetPath(CERTIFICATE_FILE, certificatePath, sizeof(certificatePath)); - return true; + certificate = storageReadAsset(CERTIFICATE_FILE); + return certificate != nullptr; } void Downloader::deinit() { @@ -48,6 +44,8 @@ void Downloader::deinit() { vSemaphoreDelete(mutex); mutex = nullptr; } + largeFree(certificate); + certificate = nullptr; if (group != nullptr && wakeBit != 0) { task_event_group_release_bit(group, wakeBit); wakeBit = 0; @@ -118,7 +116,7 @@ bool Downloader::process(DownloadResult& out) { bool started = http_download_subscribe(&subscription, group) == ERROR_NONE; char partPath[sizeof(current.target) + 8]; partFile(current.target, partPath, sizeof(partPath)); - if (started && http_download_start(current.url, certificatePath, partPath, &subscription) != ERROR_NONE) { + if (started && http_download_start(current.url, certificate, partPath, &subscription) != ERROR_NONE) { http_download_unsubscribe(&subscription); started = false; } @@ -144,7 +142,7 @@ bool Downloader::buildCatalogJob(uint32_t catalogNumber, DownloadJob& out) { snprintf(out.url, sizeof(out.url), "%s?CATNR=%u&FORMAT=tle", BASE_URL, (unsigned)catalogNumber); char name[32]; snprintf(name, sizeof(name), "sat-%u.tle", (unsigned)catalogNumber); - return userFile(name, out.target, sizeof(out.target)); + return storageUserPath(name, out.target, sizeof(out.target)); } bool Downloader::buildGroupJob(const char* groupId, DownloadJob& out) { @@ -154,7 +152,7 @@ bool Downloader::buildGroupJob(const char* groupId, DownloadJob& out) { snprintf(out.url, sizeof(out.url), "%s?GROUP=%s&FORMAT=tle", BASE_URL, groupId); char name[48]; snprintf(name, sizeof(name), "group-%s.tle", groupId); - return userFile(name, out.target, sizeof(out.target)); + return storageUserPath(name, out.target, sizeof(out.target)); } bool Downloader::buildSearchJob(const char* text, DownloadJob& out) { @@ -189,5 +187,5 @@ bool Downloader::buildSearchJob(const char* text, DownloadJob& out) { written += snprintf(out.url + written, sizeof(out.url) - written, "%s", SUFFIX); } if (written >= (int)sizeof(out.url)) return false; - return userFile("search.tle", out.target, sizeof(out.target)); + return storageUserPath("search.tle", out.target, sizeof(out.target)); } diff --git a/Apps/SatelliteTracker/main/Source/Downloads.h b/Apps/SatelliteTracker/main/Source/Downloads.h index ccc469a..9a9d7cd 100644 --- a/Apps/SatelliteTracker/main/Source/Downloads.h +++ b/Apps/SatelliteTracker/main/Source/Downloads.h @@ -36,6 +36,7 @@ class Downloader { public: + /** Main task only: reads the certificate file. */ bool init(TaskEventGroup* eventGroup); void deinit(); @@ -60,5 +61,6 @@ class Downloader { bool running = false; DownloadJob current {}; HttpDownloadSubscription subscription {}; - char certificatePath[200] {}; + /** PEM text of the CelesTrak root certificate. */ + char* certificate = nullptr; }; diff --git a/Apps/SatelliteTracker/main/Source/MapView.cpp b/Apps/SatelliteTracker/main/Source/MapView.cpp index 6241428..e4b97a8 100644 --- a/Apps/SatelliteTracker/main/Source/MapView.cpp +++ b/Apps/SatelliteTracker/main/Source/MapView.cpp @@ -105,7 +105,7 @@ int pickNextPass(Context* ctx) { int64_t bestRise = 0; for (int i = 0; i < ctx->model.count(); i++) { const TrackedSatellite& entry = ctx->model.at(i); - if (entry.passResult != orbit::PassSearchResult::Found) continue; + if (!entry.passKnown || entry.passResult != orbit::PassSearchResult::Found) continue; if (best < 0 || entry.nextPass.riseTime < bestRise) { best = i; bestRise = entry.nextPass.riseTime; @@ -117,7 +117,7 @@ int pickNextPass(Context* ctx) { void toggleToolbar(Context* ctx, bool fromKeys) { ctx->refocusMap = fromKeys; ctx->settings.hideToolbar = !ctx->settings.hideToolbar; - settingsSave(ctx->settings); + requestTaskWork(ctx, TASK_WORK_SETTINGS); showView(ctx, View::Map); } @@ -163,7 +163,7 @@ void describeLegendValue(Context* ctx, const TrackedSatellite& entry, char* out, snprintf(out, outSize, "%.0f km", entry.position.altKm); } else if (entry.look.elevationDeg > 0.0) { snprintf(out, outSize, "up %.0f\xC2\xB0", entry.look.elevationDeg); - } else if (entry.passResult == orbit::PassSearchResult::Found) { + } else if (entry.passKnown && entry.passResult == orbit::PassSearchResult::Found) { formatClock(entry.nextPass.riseTime, out, outSize); } else { snprintf(out, outSize, "--"); @@ -187,6 +187,10 @@ void describePass(Context* ctx, const TrackedSatellite& entry, char* out, size_t } return; } + if (!entry.passKnown) { + snprintf(out, outSize, compact ? "Finding pass..." : "Finding the next pass..."); + return; + } switch (entry.passResult) { case orbit::PassSearchResult::Found: { formatClock(entry.nextPass.riseTime, rise, sizeof(rise)); @@ -462,7 +466,6 @@ void mapViewUpdate(Context* ctx, bool force) { } ctx->model.updatePositions((double)now); - ctx->model.updatePasses(now, PASS_SEARCH_SECONDS); if (ctx->focusedIndex >= ctx->model.count()) ctx->focusedIndex = -1; ctx->autoIndex = pickNextPass(ctx); int focus = effectiveFocus(ctx); diff --git a/Apps/SatelliteTracker/main/Source/PassesView.cpp b/Apps/SatelliteTracker/main/Source/PassesView.cpp index 983c885..9d245de 100644 --- a/Apps/SatelliteTracker/main/Source/PassesView.cpp +++ b/Apps/SatelliteTracker/main/Source/PassesView.cpp @@ -1,12 +1,13 @@ #include "SatelliteTracker.h" #include +#include #include #include #include #include -#include +#include namespace { @@ -14,17 +15,11 @@ constexpr int64_t PASS_LIST_SECONDS = 24 * 3600; constexpr size_t MAX_ROWS = 40; constexpr int MAX_PASSES_PER_SATELLITE = 10; -struct PassRow { - int index; - orbit::Pass pass; - bool visible; -}; - /** The satellite is in sunlight while the observer has a dark sky (sun at least 6 degrees below the horizon). */ -bool isVisiblePass(TrackedSatellite& entry, const orbit::Geodetic& observer, const orbit::Pass& pass) { +bool isVisiblePass(orbit::Satellite& propagator, const orbit::Geodetic& observer, const orbit::Pass& pass) { double t = (double)pass.maxTime; orbit::Vec3 r, v; - if (!entry.propagator.positionEci(t, r, v) || !orbit::isSunlit(r, t)) return false; + if (!propagator.positionEci(t, r, v) || !orbit::isSunlit(r, t)) return false; orbit::Geodetic sun = orbit::subsolarPoint(t); const double deg = 3.14159265358979323846 / 180.0; double sunHeight = std::sin(observer.latDeg * deg) * std::sin(sun.latDeg * deg) + @@ -41,6 +36,60 @@ void addMessage(Context* ctx, const char* text) { } +void satelliteTrackerBuildPassList(Context* ctx) { + if (!timeIsValid()) return; + PassJobs& jobs = ctx->passListJobs; + lvgl_lock(); + bool hasObserver = ctx->model.hasObserver(); + jobs.count = 0; + jobs.observer = ctx->model.observer(); + jobs.observerVersion = ctx->model.observerChanges(); + jobs.startTime = currentTime(); + for (int i = 0; i < ctx->model.count(); i++) { + const TrackedSatellite& entry = ctx->model.at(i); + if (!entry.hasOrbit) continue; + PassJob& job = jobs.jobs[jobs.count++]; + job.catalogNumber = entry.catalogNumber; + job.tle = entry.tle; + } + lvgl_unlock(); + + PassList list; + if (hasObserver) { + auto propagator = std::make_unique(); + int64_t now = jobs.startTime; + for (int i = 0; i < jobs.count; i++) { + const PassJob& job = jobs.jobs[i]; + if (!propagator->init(job.tle)) continue; + int64_t t = now; + for (int n = 0; n < MAX_PASSES_PER_SATELLITE && t < now + PASS_LIST_SECONDS; n++) { + orbit::Pass pass; + auto result = propagator->findNextPass(jobs.observer, t, now + PASS_LIST_SECONDS - t, MIN_PASS_ELEVATION_DEG, pass); + if (result == orbit::PassSearchResult::AlwaysUp) { + list.alwaysUp.push_back(job.catalogNumber); + break; + } + if (result != orbit::PassSearchResult::Found) break; + list.rows.push_back(PassListRow { job.catalogNumber, pass, isVisiblePass(*propagator, jobs.observer, pass) }); + t = pass.setTime + 60; + } + } + std::sort(list.rows.begin(), list.rows.end(), [](const PassListRow& a, const PassListRow& b) { return a.pass.riseTime < b.pass.riseTime; }); + if (list.rows.size() > MAX_ROWS) list.rows.resize(MAX_ROWS); + } + + lvgl_lock(); + if (jobs.observerVersion != ctx->model.observerChanges()) { + requestTaskWork(ctx, TASK_WORK_PASS_LIST); + } else { + ctx->passList.rows.swap(list.rows); + ctx->passList.alwaysUp.swap(list.alwaysUp); + ctx->passList.ready = true; + if (ctx->pendingView == View::Passes) showView(ctx, View::Passes); + } + lvgl_unlock(); +} + void passesViewCreate(Context* ctx) { lv_obj_set_flex_flow(ctx->content, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_row(ctx->content, 2, 0); @@ -57,43 +106,30 @@ void passesViewCreate(Context* ctx) { addMessage(ctx, "No satellites are tracked yet."); return; } + if (!ctx->passList.ready) { + addMessage(ctx, "Finding passes..."); + return; + } - int64_t now = currentTime(); - const orbit::Geodetic& observer = ctx->model.observer(); - std::vector rows; - std::vector alwaysUp; int withoutOrbit = 0; for (int i = 0; i < ctx->model.count(); i++) { - TrackedSatellite& entry = ctx->model.at(i); - if (!entry.hasOrbit) { - withoutOrbit++; - continue; - } - int64_t t = now; - for (int n = 0; n < MAX_PASSES_PER_SATELLITE && t < now + PASS_LIST_SECONDS; n++) { - orbit::Pass pass; - auto result = entry.propagator.findNextPass(observer, t, now + PASS_LIST_SECONDS - t, MIN_PASS_ELEVATION_DEG, pass); - if (result == orbit::PassSearchResult::AlwaysUp) { - alwaysUp.push_back(i); - break; - } - if (result != orbit::PassSearchResult::Found) break; - rows.push_back(PassRow { i, pass, isVisiblePass(entry, observer, pass) }); - t = pass.setTime + 60; - } + if (!ctx->model.at(i).hasOrbit) withoutOrbit++; } - std::sort(rows.begin(), rows.end(), [](const PassRow& a, const PassRow& b) { return a.pass.riseTime < b.pass.riseTime; }); - if (rows.size() > MAX_ROWS) rows.resize(MAX_ROWS); bool compact = lv_obj_get_content_width(ctx->content) < 400; const lv_font_t* font = lvgl_get_text_font(compact ? FONT_SIZE_SMALL : FONT_SIZE_DEFAULT); - time_t nowValue = (time_t)now; + time_t nowValue = (time_t)currentTime(); struct tm nowTm {}; localtime_r(&nowValue, &nowTm); bool anyVisible = false; + bool anyShown = false; - for (int index : alwaysUp) { + for (uint32_t catalogNumber : ctx->passList.alwaysUp) { + // The satellite may have been removed since the list was built. + int index = ctx->model.indexOfCatalog(catalogNumber); + if (index < 0) continue; const TrackedSatellite& entry = ctx->model.at(index); + anyShown = true; lv_obj_t* row = lv_obj_create(ctx->content); lv_obj_remove_style_all(row); lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); @@ -108,8 +144,11 @@ void passesViewCreate(Context* ctx) { lv_label_set_text(label, text); } - for (const auto& item : rows) { - const TrackedSatellite& entry = ctx->model.at(item.index); + for (const auto& item : ctx->passList.rows) { + int index = ctx->model.indexOfCatalog(item.catalogNumber); + if (index < 0) continue; + const TrackedSatellite& entry = ctx->model.at(index); + anyShown = true; char rise[8], set[8], day[8] = ""; formatClock(item.pass.riseTime, rise, sizeof(rise)); formatClock(item.pass.setTime, set, sizeof(set)); @@ -141,7 +180,7 @@ void passesViewCreate(Context* ctx) { lv_label_set_text(label, text); } - if (rows.empty() && alwaysUp.empty()) { + if (!anyShown) { addMessage(ctx, "No passes higher than 5° in the next 24 hours."); } else if (anyVisible) { lv_obj_t* note = lv_label_create(ctx->content); diff --git a/Apps/SatelliteTracker/main/Source/SatelliteTracker.cpp b/Apps/SatelliteTracker/main/Source/SatelliteTracker.cpp index 8f40101..c0462dd 100644 --- a/Apps/SatelliteTracker/main/Source/SatelliteTracker.cpp +++ b/Apps/SatelliteTracker/main/Source/SatelliteTracker.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -108,8 +109,7 @@ void scheduleDownloads(Context* ctx) { void saveDownloads(Context* ctx) { if (!ctx->downloadsUnsaved) return; ctx->downloadsUnsaved = false; - orbitCacheSave(ctx->model); - settingsSave(ctx->settings); + requestTaskWork(ctx, TASK_WORK_ORBITS | TASK_WORK_SETTINGS); } void freeMarkerIcons(Context* ctx) { @@ -142,7 +142,10 @@ void onBackClicked(lv_event_t* event) { } void onPassesClicked(lv_event_t* event) { - showView(static_cast(lv_event_get_user_data(event)), View::Passes); + auto* ctx = static_cast(lv_event_get_user_data(event)); + ctx->passList.ready = false; + requestTaskWork(ctx, TASK_WORK_PASS_LIST); + showView(ctx, View::Passes); } void onSatellitesClicked(lv_event_t* event) { @@ -171,6 +174,7 @@ void onUpdateTimer(lv_timer_t* timer) { auto* ctx = static_cast(lv_timer_get_user_data(timer)); if (window_manager_get_state(ctx->window) != WINDOW_STATE_GRANTED) return; scheduleDownloads(ctx); + if (timeIsValid() && ctx->model.passesStale(currentTime())) requestTaskWork(ctx, TASK_WORK_PASSES); if (ctx->view == View::Map) { mapViewUpdate(ctx, false); } else if (ctx->view == View::Settings) { @@ -246,7 +250,7 @@ void onViewTimer(lv_timer_t* timer) { buildView(ctx); } -void handleTrackedDownload(Context* ctx, const DownloadResult& result) { +void handleTrackedDownload(Context* ctx, const DownloadResult& result, const TleList& tles) { uint32_t catalog = (uint32_t)strtoul(result.job.tag, nullptr, 10); int index = ctx->model.indexOfCatalog(catalog); int64_t now = currentTime(); @@ -260,9 +264,6 @@ void handleTrackedDownload(Context* ctx, const DownloadResult& result) { return; } - TleList tles; - tleFileRead(result.job.target, tles, 4); - remove(result.job.target); // The satellite may have been removed while its download was queued. if (index < 0) return; @@ -287,36 +288,117 @@ void handleTrackedDownload(Context* ctx, const DownloadResult& result) { } } -/** Fills the catalog from a group or search download, when it still belongs to the screen that asked for it. */ -void handleCatalogDownload(Context* ctx, const DownloadResult& result) { - bool current; - size_t maxEntries; - if (result.job.kind == DownloadKind::Group) { - current = ctx->view == View::GroupList && strcmp(ctx->selectedGroup, result.job.tag) == 0; - maxEntries = ctx->groupLimit; - } else { - DownloadJob expected; - current = ctx->view == View::Search && Downloader::buildSearchJob(ctx->searchText, expected) && strcmp(expected.tag, result.job.tag) == 0; - maxEntries = ctx->searchLimit; +/** A group or search download still belongs to the screen that asked for it. */ +bool catalogDownloadIsCurrent(const Context* ctx, const DownloadJob& job) { + if (job.kind == DownloadKind::Group) { + return ctx->view == View::GroupList && strcmp(ctx->selectedGroup, job.tag) == 0; } - if (!current) return; + DownloadJob expected; + return ctx->view == View::Search && Downloader::buildSearchJob(ctx->searchText, expected) && strcmp(expected.tag, job.tag) == 0; +} + +/** After a failed download a group falls back to its saved copy, which a failed download leaves untouched. */ +bool catalogDownloadIsUsable(const DownloadResult& result) { + return result.success || result.job.kind == DownloadKind::Group; +} + +/** Fills the catalog from a group or search download, when it still belongs to the screen that asked for it. */ +void handleCatalogDownload(Context* ctx, const DownloadResult& result, TleList& tles) { + if (!catalogDownloadIsCurrent(ctx, result.job)) return; ctx->catalogLoading = false; - // After a failed download a group falls back to its saved copy, which a failed download leaves untouched. - bool fallback = !result.success && result.job.kind == DownloadKind::Group; - if (result.success || fallback) { - tleFileRead(result.job.target, ctx->catalog, maxEntries); - sortByName(ctx->catalog); - } + bool fallback = !result.success && catalogDownloadIsUsable(result); + if (catalogDownloadIsUsable(result)) ctx->catalog.swap(tles); catalogViewRefresh(ctx); if (fallback && !ctx->catalog.empty()) { setLabelText(ctx->messageLabel, "Download failed, this is the saved copy of the group."); } } +/** Main task: shows the saved copy of a group when it is recent, else downloads the group with the saved copy as fallback. */ +void loadGroup(Context* ctx, const char* groupId) { + char name[48], path[256]; + snprintf(name, sizeof(name), "group-%s.tle", groupId); + int64_t age = storageUserPath(name, path, sizeof(path)) ? fileAgeSeconds(path, currentTime()) : -1; + bool fresh = age >= 0 && (age < GROUP_CACHE_SECONDS || !timeIsValid()); + TleList tles; + if (fresh) tleFileRead(path, tles, ctx->groupLimit); + bool queued = false; + if (!fresh || tles.empty()) { + DownloadJob job; + queued = timeIsValid() && Downloader::buildGroupJob(groupId, job) && + (ctx->downloader.hasQueued(DownloadKind::Group, job.tag) || ctx->downloader.enqueue(job)); + if (!queued && !fresh && age >= 0) tleFileRead(path, tles, ctx->groupLimit); + } + sortByName(tles); + + lvgl_lock(); + if (ctx->pendingView == View::GroupList && strcmp(ctx->selectedGroup, groupId) == 0) { + ctx->catalog.swap(tles); + ctx->catalogReading = false; + ctx->catalogLoading = queued; + catalogViewRefresh(ctx); + } + lvgl_unlock(); +} + +/** Main task: the pass search takes seconds on chips without a double precision FPU, so it runs without the LVGL lock. */ +void searchPasses(Context* ctx) { + if (!timeIsValid()) return; + lvgl_lock(); + bool collected = ctx->model.collectPassJobs(currentTime(), ctx->passJobs); + lvgl_unlock(); + if (!collected) return; + TrackerModel::runPassJobs(ctx->passJobs, PASS_SEARCH_SECONDS); + lvgl_lock(); + if (ctx->model.applyPassJobs(ctx->passJobs)) ctx->forceRedraw = true; + lvgl_unlock(); +} + +} + +void requestTaskWork(Context* ctx, uint32_t work) { + ctx->taskWork |= work; + task_event_group_signal(ctx->eventGroup, ctx->taskWorkBit); +} + +void satelliteTrackerDoTaskWork(Context* ctx) { + lvgl_lock(); + uint32_t work = ctx->taskWork; + ctx->taskWork = 0; + Settings settings = ctx->settings; + if (work & (TASK_WORK_LIST | TASK_WORK_ORBITS)) savedListFromModel(ctx->model, ctx->savedList); + char group[sizeof(ctx->selectedGroup)]; + memcpy(group, ctx->selectedGroup, sizeof(group)); + lvgl_unlock(); + + if (work & TASK_WORK_SETTINGS) settingsSave(settings); + if (work & TASK_WORK_LIST) trackedListSave(ctx->savedList); + if (work & TASK_WORK_ORBITS) orbitCacheSave(ctx->savedList); + if (work & TASK_WORK_GROUP) loadGroup(ctx, group); + if (work & TASK_WORK_PASSES) searchPasses(ctx); + if (work & TASK_WORK_PASS_LIST) satelliteTrackerBuildPassList(ctx); +} + +void satelliteTrackerReadDownload(Context* ctx, const DownloadResult& result, TleList& out) { + if (result.job.kind == DownloadKind::Tracked) { + if (!result.success) return; + tleFileRead(result.job.target, out, 4); + remove(result.job.target); + return; + } + if (!catalogDownloadIsUsable(result)) return; + lvgl_lock(); + bool current = catalogDownloadIsCurrent(ctx, result.job); + lvgl_unlock(); + if (!current) return; + tleFileRead(result.job.target, out, result.job.kind == DownloadKind::Group ? ctx->groupLimit : ctx->searchLimit); + sortByName(out); } void applyObserver(Context* ctx) { - if (ctx->settings.useDeviceLocation && ctx->deviceFixValid) { + // Until the location service has a fix, deviceFix holds the last device location + bool lastKnown = ctx->settings.lastDeviceSet && location_is_enabled(); + if (ctx->settings.useDeviceLocation && (ctx->deviceFixValid || lastKnown)) { ctx->model.setObserver(true, ctx->deviceFix); return; } @@ -332,12 +414,21 @@ void satelliteTrackerSetDeviceLocation(Context* ctx, const Location* fix) { ctx->deviceAccuracyM = fix->horizontal_accuracy; snprintf(ctx->deviceProvider, sizeof(ctx->deviceProvider), "%s", fix->provider); // Movements below about 1 km are ignored, so GPS noise does not recalculate the passes every second. - bool moved = !ctx->deviceFixValid || std::fabs(fix->latitude - ctx->deviceFix.latDeg) > 0.01 || + bool known = ctx->deviceFixValid || ctx->settings.lastDeviceSet; + bool moved = !known || std::fabs(fix->latitude - ctx->deviceFix.latDeg) > 0.01 || std::fabs(fix->longitude - ctx->deviceFix.lonDeg) > 0.01; - if (!moved) return; - double altitudeM = std::isnan(fix->altitude) ? 0.0 : fix->altitude; - ctx->deviceFix = orbit::Geodetic { fix->latitude, fix->longitude, altitudeM / 1000.0 }; + bool wasValid = ctx->deviceFixValid; ctx->deviceFixValid = true; + if (!moved && wasValid) return; + if (moved) { + double altitudeM = std::isnan(fix->altitude) ? 0.0 : fix->altitude; + ctx->deviceFix = orbit::Geodetic { fix->latitude, fix->longitude, altitudeM / 1000.0 }; + ctx->settings.lastDeviceSet = true; + ctx->settings.lastDeviceLatDeg = fix->latitude; + ctx->settings.lastDeviceLonDeg = fix->longitude; + ctx->settings.lastDeviceAltM = altitudeM; + requestTaskWork(ctx, TASK_WORK_SETTINGS); + } } applyObserver(ctx); ctx->forceRedraw = true; @@ -353,7 +444,7 @@ int effectiveFocus(const Context* ctx) { int addTracked(Context* ctx, const orbit::Tle& tle) { int index = ctx->model.add(tle, nullptr); if (index < 0) return -1; - trackedListSave(ctx->model); + requestTaskWork(ctx, TASK_WORK_LIST); // The orbit data comes from a group or search download and goes into the cache with the next save. ctx->downloadsUnsaved = true; ctx->autoIndex = -1; @@ -363,7 +454,7 @@ int addTracked(Context* ctx, const orbit::Tle& tle) { void removeTracked(Context* ctx, int index) { ctx->model.remove(index); - trackedListSave(ctx->model); + requestTaskWork(ctx, TASK_WORK_LIST); if (ctx->focusedIndex == index) ctx->focusedIndex = -1; else if (ctx->focusedIndex > index) ctx->focusedIndex--; ctx->autoIndex = -1; @@ -465,6 +556,19 @@ lv_obj_t* createMarkerImage(Context* ctx, lv_obj_t* parent, int slot) { return canvas; } +void satelliteTrackerLoad(Context* ctx) { + storageEnsureDirectory(); + settingsLoad(ctx->settings); + if (!trackedLoad(ctx->model)) { + // First start: the list is saved right away, the orbit data follows when the network is available. + for (const auto& satellite : DEFAULT_SATELLITES) { + ctx->model.add(satellite.catalogNumber, satellite.shortName, -1, true); + } + savedListFromModel(ctx->model, ctx->savedList); + trackedListSave(ctx->savedList); + } +} + void satelliteTrackerInit(Context* ctx) { DisplayInfo display { false, false, false }; device_for_each_of_type(&DISPLAY_TYPE, &display, onDisplayFound); @@ -475,20 +579,17 @@ void satelliteTrackerInit(Context* ctx) { ctx->groupLimit = 150; ctx->searchLimit = 60; } - - settingsLoad(ctx->settings); - if (!trackedLoad(ctx->model)) { - // First start: the list is saved right away, the orbit data follows when the network is available. - for (const auto& satellite : DEFAULT_SATELLITES) { - ctx->model.add(satellite.catalogNumber, satellite.shortName, -1, true); - } - trackedListSave(ctx->model); + if (ctx->settings.lastDeviceSet) { + ctx->deviceFix = orbit::Geodetic { ctx->settings.lastDeviceLatDeg, ctx->settings.lastDeviceLonDeg, ctx->settings.lastDeviceAltM / 1000.0 }; } + applyObserver(ctx); ctx->focusedIndex = (ctx->settings.autoSelectPass || ctx->model.count() == 0) ? -1 : 0; + requestTaskWork(ctx, TASK_WORK_PASSES); } void satelliteTrackerTeardown(Context* ctx) { + ctx->taskWork &= ~(TASK_WORK_GROUP | TASK_WORK_PASSES | TASK_WORK_PASS_LIST); saveDownloads(ctx); freeMarkerIcons(ctx); } @@ -550,7 +651,7 @@ bool requestTrackedRefresh(Context* ctx, int64_t minAgeSeconds) { return queueRefresh(ctx, !recent, true); } -void satelliteTrackerHandleDownload(Context* ctx, const DownloadResult& result) { +void satelliteTrackerHandleDownload(Context* ctx, const DownloadResult& result, TleList& tles) { if (!result.success) { char text[96]; if (result.statusCode != 0) { @@ -561,9 +662,9 @@ void satelliteTrackerHandleDownload(Context* ctx, const DownloadResult& result) setStatus(ctx, text); } if (result.job.kind == DownloadKind::Tracked) { - handleTrackedDownload(ctx, result); + handleTrackedDownload(ctx, result, tles); } else { - handleCatalogDownload(ctx, result); + handleCatalogDownload(ctx, result, tles); } if (!ctx->downloader.isBusy()) saveDownloads(ctx); } diff --git a/Apps/SatelliteTracker/main/Source/SatelliteTracker.h b/Apps/SatelliteTracker/main/Source/SatelliteTracker.h index 9cdf115..1a0018c 100644 --- a/Apps/SatelliteTracker/main/Source/SatelliteTracker.h +++ b/Apps/SatelliteTracker/main/Source/SatelliteTracker.h @@ -24,6 +24,34 @@ enum class View { Settings, }; +/** Work that the main task does for the LVGL thread, which must not access files or block for long. */ +enum TaskWork : uint32_t { + TASK_WORK_SETTINGS = 1 << 0, + TASK_WORK_LIST = 1 << 1, + TASK_WORK_ORBITS = 1 << 2, + /** Show the saved copy of the selected group, or download it when it is old or missing. */ + TASK_WORK_GROUP = 1 << 3, + /** Search the passes that are missing or over. */ + TASK_WORK_PASSES = 1 << 4, + /** Build the list of the Next passes screen. */ + TASK_WORK_PASS_LIST = 1 << 5, +}; + +struct PassListRow { + uint32_t catalogNumber; + orbit::Pass pass; + /** Sunlit satellite in a dark sky. */ + bool visible; +}; + +/** The passes of the next 24 hours, built on the main task. */ +struct PassList { + /** False until the list for the current screen is built. */ + bool ready = false; + std::vector> rows; + std::vector> alwaysUp; +}; + struct Context; struct RowAction { @@ -57,6 +85,17 @@ struct Context { WindowId window = 0; Downloader downloader; + TaskEventGroup* eventGroup = nullptr; + uint32_t taskWorkBit = 0; + /** TaskWork flags, set with the LVGL lock held. */ + uint32_t taskWork = 0; + /** Main task only. */ + SavedList savedList; + PassJobs passJobs; + PassJobs passListJobs; + /** Set on the main task with the LVGL lock held. */ + PassList passList; + TrackerModel model; Settings settings; /** New orbit data or a new download time that is saved once the queued downloads are done. */ @@ -86,6 +125,8 @@ struct Context { size_t searchLimit = 100; std::vector rowActions; bool catalogLoading = false; + /** The main task reads the saved copy of the selected group. */ + bool catalogReading = false; int renameIndex = -1; map::MapRenderer renderer; @@ -121,14 +162,23 @@ constexpr int64_t TRACKED_MIN_REFRESH_SECONDS = 2 * 3600; constexpr int64_t GROUP_CACHE_SECONDS = 24 * 3600; constexpr int64_t PASS_SEARCH_SECONDS = 36 * 3600; +/** Main task, before the window exists: reads the settings and the tracked satellites. */ +void satelliteTrackerLoad(Context* ctx); void satelliteTrackerInit(Context* ctx); void satelliteTrackerCreateWidgets(lv_obj_t* parent, void* userData); void satelliteTrackerDestroyWidgets(void* userData); -/** Saves what is still unsaved and frees the marker icons. */ +/** Requests saving what is still unsaved and frees the marker icons. */ void satelliteTrackerTeardown(Context* ctx); -/** Called on the main task with the LVGL lock held. */ -void satelliteTrackerHandleDownload(Context* ctx, const DownloadResult& result); +/** Asks the main task to do TaskWork. Called with the LVGL lock held. */ +void requestTaskWork(Context* ctx, uint32_t work); +/** Main task, without the LVGL lock: does the requested TaskWork. */ +void satelliteTrackerDoTaskWork(Context* ctx); + +/** Main task, without the LVGL lock: reads the downloaded file that the result needs. */ +void satelliteTrackerReadDownload(Context* ctx, const DownloadResult& result, TleList& out); +/** Called on the main task with the LVGL lock held. tles holds what satelliteTrackerReadDownload() read. */ +void satelliteTrackerHandleDownload(Context* ctx, const DownloadResult& result, TleList& tles); /** * Queues downloads of the orbit data of all tracked satellites when it is older than minAgeSeconds, else only of those @@ -167,6 +217,8 @@ void mapViewDestroy(Context* ctx); void mapViewUpdate(Context* ctx, bool force); void passesViewCreate(Context* ctx); +/** Main task, without the LVGL lock: builds the pass list and shows it when the Next passes screen is still open. */ +void satelliteTrackerBuildPassList(Context* ctx); void satellitesViewCreate(Context* ctx); void groupsViewCreate(Context* ctx); void groupListViewCreate(Context* ctx); diff --git a/Apps/SatelliteTracker/main/Source/SatellitesView.cpp b/Apps/SatelliteTracker/main/Source/SatellitesView.cpp index 59ae744..a14ffeb 100644 --- a/Apps/SatelliteTracker/main/Source/SatellitesView.cpp +++ b/Apps/SatelliteTracker/main/Source/SatellitesView.cpp @@ -10,6 +10,11 @@ namespace { constexpr size_t MAX_CATALOG_ROWS = 250; +/** Also frees the memory, so the next list does not need room next to the old one. */ +void clearCatalog(Context* ctx) { + TleList().swap(ctx->catalog); +} + struct Group { const char* id; const char* name; @@ -83,8 +88,9 @@ void onAddFromGroupClicked(lv_event_t* event) { void onSearchClicked(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); - ctx->catalog.clear(); + clearCatalog(ctx); ctx->catalogLoading = false; + ctx->catalogReading = false; showView(ctx, View::Search); } @@ -94,7 +100,10 @@ void onGroupClicked(lv_event_t* event) { const Group& group = GROUPS[action->index]; snprintf(ctx->selectedGroup, sizeof(ctx->selectedGroup), "%s", group.id); snprintf(ctx->selectedGroupName, sizeof(ctx->selectedGroupName), "%s", group.name); - ctx->catalog.clear(); + clearCatalog(ctx); + ctx->catalogLoading = false; + ctx->catalogReading = true; + requestTaskWork(ctx, TASK_WORK_GROUP); showView(ctx, View::GroupList); } @@ -145,7 +154,7 @@ void startSearch(Context* ctx) { setLabelText(ctx->messageLabel, "Downloads are not available. Restart the app."); return; } - ctx->catalog.clear(); + clearCatalog(ctx); ctx->catalogLoading = true; catalogViewRefresh(ctx); } @@ -165,7 +174,7 @@ void onRenameSave(lv_event_t* event) { } else if (entry.hasOrbit) { TrackerModel::makeShortName(entry.tle.name, entry.shortName, sizeof(entry.shortName)); } - trackedListSave(ctx->model); + requestTaskWork(ctx, TASK_WORK_LIST); ctx->forceRedraw = true; } showView(ctx, View::Satellites); @@ -266,27 +275,6 @@ void groupListViewCreate(Context* ctx) { lv_obj_set_style_pad_row(ctx->content, 4, 0); addMessage(ctx, ctx->content, ""); ctx->listWidget = createColumn(ctx->content); - - char name[48], path[256]; - snprintf(name, sizeof(name), "group-%s.tle", ctx->selectedGroup); - int64_t age = storageUserPath(name, path, sizeof(path)) ? fileAgeSeconds(path, currentTime()) : -1; - bool fresh = age >= 0 && (age < GROUP_CACHE_SECONDS || !timeIsValid()); - ctx->catalogLoading = false; - if (fresh) { - tleFileRead(path, ctx->catalog, ctx->groupLimit); - sortByName(ctx->catalog); - } - if (!fresh || ctx->catalog.empty()) { - DownloadJob job; - bool queued = timeIsValid() && Downloader::buildGroupJob(ctx->selectedGroup, job) && - (ctx->downloader.hasQueued(DownloadKind::Group, job.tag) || ctx->downloader.enqueue(job)); - if (queued) { - ctx->catalogLoading = true; - } else if (age >= 0) { - tleFileRead(path, ctx->catalog, ctx->groupLimit); - sortByName(ctx->catalog); - } - } catalogViewRefresh(ctx); } @@ -325,6 +313,10 @@ void catalogViewRefresh(Context* ctx) { setLabelText(ctx->messageLabel, "Downloading from CelesTrak..."); return; } + if (ctx->catalogReading) { + setLabelText(ctx->messageLabel, "Loading..."); + return; + } if (ctx->catalog.empty()) { if (ctx->view == View::Search && ctx->searchText[0] == '\0') { setLabelText(ctx->messageLabel, "Type a satellite name (for example ISS or NOAA 19) or a NORAD catalog number."); diff --git a/Apps/SatelliteTracker/main/Source/SettingsView.cpp b/Apps/SatelliteTracker/main/Source/SettingsView.cpp index 82b0315..468c96c 100644 --- a/Apps/SatelliteTracker/main/Source/SettingsView.cpp +++ b/Apps/SatelliteTracker/main/Source/SettingsView.cpp @@ -113,7 +113,7 @@ void onSaveClicked(lv_event_t* event) { settings.longitudeDeg = longitude; settings.altitudeM = altitude; } - settingsSave(settings); + requestTaskWork(ctx, TASK_WORK_SETTINGS); applyObserver(ctx); ctx->forceRedraw = true; setLabelText(ctx->messageLabel, "Saved."); @@ -122,7 +122,7 @@ void onSaveClicked(lv_event_t* event) { void onTracksChanged(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); ctx->settings.tracksForAll = lv_obj_has_state(widgets.tracksSwitch, LV_STATE_CHECKED); - settingsSave(ctx->settings); + requestTaskWork(ctx, TASK_WORK_SETTINGS); ctx->forceRedraw = true; } @@ -135,6 +135,8 @@ void describeDeviceLocation(Context* ctx, char* out, size_t outSize) { const orbit::Geodetic& fix = ctx->deviceFix; snprintf(out, outSize, "Using the device location: %.3f\xC2\xB0%s %.3f\xC2\xB0%s (\xC2\xB1%.0f m, %s).", fix.latDeg < 0 ? -fix.latDeg : fix.latDeg, fix.latDeg >= 0 ? "N" : "S", fix.lonDeg < 0 ? -fix.lonDeg : fix.lonDeg, fix.lonDeg >= 0 ? "E" : "W", ctx->deviceAccuracyM, ctx->deviceProvider); + } else if (ctx->settings.lastDeviceSet) { + snprintf(out, outSize, "Waiting for the device location, its last known position is used until then."); } else { snprintf(out, outSize, "Waiting for the device location, the location below is used until then."); } @@ -143,7 +145,7 @@ void describeDeviceLocation(Context* ctx, char* out, size_t outSize) { void onDeviceLocationChanged(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); ctx->settings.useDeviceLocation = lv_obj_has_state(lv_event_get_target_obj(event), LV_STATE_CHECKED); - settingsSave(ctx->settings); + requestTaskWork(ctx, TASK_WORK_SETTINGS); applyObserver(ctx); ctx->forceRedraw = true; settingsViewUpdate(ctx); @@ -152,7 +154,7 @@ void onDeviceLocationChanged(lv_event_t* event) { void onAutoSelectChanged(lv_event_t* event) { auto* ctx = static_cast(lv_event_get_user_data(event)); ctx->settings.autoSelectPass = lv_obj_has_state(widgets.autoSwitch, LV_STATE_CHECKED); - settingsSave(ctx->settings); + requestTaskWork(ctx, TASK_WORK_SETTINGS); ctx->forceRedraw = true; } diff --git a/Apps/SatelliteTracker/main/Source/Storage.cpp b/Apps/SatelliteTracker/main/Source/Storage.cpp index 09e8279..34a3127 100644 --- a/Apps/SatelliteTracker/main/Source/Storage.cpp +++ b/Apps/SatelliteTracker/main/Source/Storage.cpp @@ -55,6 +55,26 @@ bool storageAssetPath(const char* fileName, char* out, size_t outSize) { return app_paths_get_assets_path(APP_ID, fileName, out, outSize) == ERROR_NONE; } +char* storageReadAsset(const char* fileName) { + char path[256]; + if (!storageAssetPath(fileName, path, sizeof(path))) return nullptr; + FILE* file = fopen(path, "rb"); + if (file == nullptr) return nullptr; + char* text = nullptr; + long size = fseek(file, 0, SEEK_END) == 0 ? ftell(file) : -1; + if (size >= 0 && fseek(file, 0, SEEK_SET) == 0) { + text = static_cast(largeAlloc((size_t)size + 1)); + if (text != nullptr && fread(text, 1, (size_t)size, file) == (size_t)size) { + text[size] = '\0'; + } else { + largeFree(text); + text = nullptr; + } + } + fclose(file); + return text; +} + void settingsLoad(Settings& settings) { char path[256]; if (!storageUserPath(SETTINGS_FILE, path, sizeof(path))) return; @@ -70,6 +90,13 @@ void settingsLoad(Settings& settings) { preferences_opt_bool(preferences, "autoSelect", &settings.autoSelectPass); preferences_opt_bool(preferences, "hideToolbar", &settings.hideToolbar); preferences_opt_bool(preferences, "useDeviceLoc", &settings.useDeviceLocation); + int32_t deviceLatMicro = 0, deviceLonMicro = 0, deviceAltM = 0; + if (preferences_opt_int32(preferences, "devLatMicro", &deviceLatMicro) && preferences_opt_int32(preferences, "devLonMicro", &deviceLonMicro)) { + settings.lastDeviceSet = true; + settings.lastDeviceLatDeg = deviceLatMicro / 1.0e6; + settings.lastDeviceLonDeg = deviceLonMicro / 1.0e6; + if (preferences_opt_int32(preferences, "devAltM", &deviceAltM)) settings.lastDeviceAltM = deviceAltM; + } preferences_opt_int64(preferences, "lastRefresh", &settings.lastTrackedRefresh); preferences_close(preferences); } @@ -89,6 +116,11 @@ void settingsSave(const Settings& settings) { preferences_put_bool(preferences, "autoSelect", settings.autoSelectPass); preferences_put_bool(preferences, "hideToolbar", settings.hideToolbar); preferences_put_bool(preferences, "useDeviceLoc", settings.useDeviceLocation); + if (settings.lastDeviceSet) { + preferences_put_int32(preferences, "devLatMicro", (int32_t)(settings.lastDeviceLatDeg * 1.0e6)); + preferences_put_int32(preferences, "devLonMicro", (int32_t)(settings.lastDeviceLonDeg * 1.0e6)); + preferences_put_int32(preferences, "devAltM", (int32_t)settings.lastDeviceAltM); + } preferences_put_int64(preferences, "lastRefresh", settings.lastTrackedRefresh); preferences_close(preferences); } @@ -129,21 +161,34 @@ bool trackedLoad(TrackerModel& model) { return true; } -void trackedListSave(const TrackerModel& model) { +void savedListFromModel(const TrackerModel& model, SavedList& out) { + out.count = model.count() < MAX_TRACKED ? model.count() : MAX_TRACKED; + for (int i = 0; i < out.count; i++) { + const TrackedSatellite& entry = model.at(i); + SavedSatellite& saved = out.entries[i]; + saved.catalogNumber = entry.catalogNumber; + saved.slot = entry.slot; + memcpy(saved.shortName, entry.shortName, sizeof(saved.shortName)); + saved.hasOrbit = entry.hasOrbit; + saved.tle = entry.tle; + } +} + +void trackedListSave(const SavedList& list) { FILE* file = openUserFile(LIST_FILE); if (file == nullptr) return; - for (int i = 0; i < model.count(); i++) { - const TrackedSatellite& entry = model.at(i); + for (int i = 0; i < list.count; i++) { + const SavedSatellite& entry = list.entries[i]; fprintf(file, "%u|%d|%s\n", (unsigned)entry.catalogNumber, entry.slot, entry.shortName); } fclose(file); } -void orbitCacheSave(const TrackerModel& model) { +void orbitCacheSave(const SavedList& list) { FILE* file = openUserFile(ORBIT_FILE); if (file == nullptr) return; - for (int i = 0; i < model.count(); i++) { - const TrackedSatellite& entry = model.at(i); + for (int i = 0; i < list.count; i++) { + const SavedSatellite& entry = list.entries[i]; if (!entry.hasOrbit) continue; fprintf(file, "%s\n%.69s\n%.69s\n", entry.tle.name, entry.tle.line1, entry.tle.line2); } diff --git a/Apps/SatelliteTracker/main/Source/Storage.h b/Apps/SatelliteTracker/main/Source/Storage.h index 9879cae..5385f1f 100644 --- a/Apps/SatelliteTracker/main/Source/Storage.h +++ b/Apps/SatelliteTracker/main/Source/Storage.h @@ -24,6 +24,11 @@ struct Settings { bool hideToolbar = false; /** Follow Tactility's location service (GPS or network) when it has a fix. */ bool useDeviceLocation = true; + /** Last device location, used at the start until the location service has a fix, so the passes do not change then. */ + bool lastDeviceSet = false; + double lastDeviceLatDeg = 0.0; + double lastDeviceLonDeg = 0.0; + double lastDeviceAltM = 0.0; /** When orbit data of a tracked satellite was last downloaded. */ int64_t lastTrackedRefresh = 0; }; @@ -31,6 +36,8 @@ struct Settings { bool storageEnsureDirectory(); bool storageUserPath(const char* fileName, char* out, size_t outSize); bool storageAssetPath(const char* fileName, char* out, size_t outSize); +/** Reads an asset file as text. Returns nullptr when it cannot be read, else a buffer to free with largeFree(). */ +char* storageReadAsset(const char* fileName); void settingsLoad(Settings& settings); void settingsSave(const Settings& settings); @@ -40,10 +47,26 @@ void settingsSave(const Settings& settings); * restored without it. Returns false when no list was saved yet, which means the app starts for the first time. */ bool trackedLoad(TrackerModel& model); + +/** The saved part of the tracked satellites. It is copied from the model with the LVGL lock held and written without it. */ +struct SavedSatellite { + uint32_t catalogNumber; + int slot; + char shortName[SHORT_NAME_LENGTH]; + bool hasOrbit; + orbit::Tle tle; +}; + +struct SavedList { + int count = 0; + SavedSatellite entries[MAX_TRACKED] {}; +}; + +void savedListFromModel(const TrackerModel& model, SavedList& out); /** Writes the list of tracked satellites: catalog number, slot and short name. */ -void trackedListSave(const TrackerModel& model); +void trackedListSave(const SavedList& list); /** Writes the orbit data of the tracked satellites that have it. */ -void orbitCacheSave(const TrackerModel& model); +void orbitCacheSave(const SavedList& list); /** Reads a CelesTrak TLE file (name line plus two element lines). Stops after maxEntries, which is also reserved up front. */ bool tleFileRead(const char* path, TleList& out, size_t maxEntries); diff --git a/Apps/SatelliteTracker/main/Source/TrackerModel.cpp b/Apps/SatelliteTracker/main/Source/TrackerModel.cpp index b46ea57..ca26aac 100644 --- a/Apps/SatelliteTracker/main/Source/TrackerModel.cpp +++ b/Apps/SatelliteTracker/main/Source/TrackerModel.cpp @@ -95,6 +95,8 @@ bool TrackerModel::setTle(int index, const orbit::Tle& tle) { entry.hasOrbit = true; entry.passResult = orbit::PassSearchResult::None; entry.passSearchedAt = 0; + entry.passKnown = false; + entry.orbitVersion = ++orbitVersions; return true; } @@ -102,7 +104,10 @@ void TrackerModel::setObserver(bool available, const orbit::Geodetic& location) bool changed = available != observerAvailable || location.latDeg != observerLocation.latDeg || location.lonDeg != observerLocation.lonDeg; observerAvailable = available; observerLocation = location; - if (changed) invalidatePasses(); + if (changed) { + observerVersion++; + invalidatePasses(); + } } void TrackerModel::updatePositions(double unixTime) { @@ -120,26 +125,78 @@ void TrackerModel::updatePositions(double unixTime) { } } -bool TrackerModel::updatePasses(int64_t unixTime, int64_t searchSeconds) { +bool TrackerModel::passStale(const TrackedSatellite& entry, int64_t unixTime) const { + if (!entry.hasOrbit) return false; + if (entry.passSearchedAt == 0) return true; + if (entry.passResult == orbit::PassSearchResult::Found) return entry.nextPass.setTime < unixTime; + return unixTime - entry.passSearchedAt > 3600; +} + +bool TrackerModel::passesStale(int64_t unixTime) const { + if (!observerAvailable) return false; + for (int i = 0; i < satelliteCount; i++) { + if (passStale(*satellites[i], unixTime)) return true; + } + return false; +} + +bool TrackerModel::collectPassJobs(int64_t unixTime, PassJobs& out) { + out.count = 0; if (!observerAvailable) return false; - bool changed = false; + out.observer = observerLocation; + out.observerVersion = observerVersion; + out.startTime = unixTime; for (int i = 0; i < satelliteCount; i++) { TrackedSatellite& entry = *satellites[i]; - if (!entry.hasOrbit) continue; - bool stale = entry.passSearchedAt == 0; - if (entry.passResult == orbit::PassSearchResult::Found && entry.nextPass.setTime < unixTime) stale = true; - if (entry.passResult != orbit::PassSearchResult::Found && unixTime - entry.passSearchedAt > 3600) stale = true; - if (!stale) continue; - entry.passResult = entry.propagator.findNextPass(observerLocation, unixTime, searchSeconds, MIN_PASS_ELEVATION_DEG, entry.nextPass); + if (!passStale(entry, unixTime)) continue; + PassJob& job = out.jobs[out.count++]; + job.catalogNumber = entry.catalogNumber; + job.orbitVersion = entry.orbitVersion; + job.tle = entry.tle; entry.passSearchedAt = unixTime; - changed = true; } - return changed; + return out.count > 0; +} + +void TrackerModel::runPassJobs(PassJobs& jobs, int64_t searchSeconds) { + auto propagator = std::make_unique(); + for (int i = 0; i < jobs.count; i++) { + PassJob& job = jobs.jobs[i]; + job.pass = orbit::Pass {}; + job.result = propagator->init(job.tle) + ? propagator->findNextPass(jobs.observer, jobs.startTime, searchSeconds, MIN_PASS_ELEVATION_DEG, job.pass) + : orbit::PassSearchResult::Error; + } +} + +bool TrackerModel::applyPassJobs(const PassJobs& jobs) { + if (jobs.observerVersion != observerVersion) return false; + bool stored = false; + for (int i = 0; i < jobs.count; i++) { + const PassJob& job = jobs.jobs[i]; + int index = indexOfCatalog(job.catalogNumber); + if (index < 0) continue; + TrackedSatellite& entry = *satellites[index]; + if (entry.orbitVersion != job.orbitVersion) continue; + entry.passResult = job.result; + entry.nextPass = job.pass; + entry.passKnown = true; + stored = true; + } + return stored; +} + +bool TrackerModel::updatePasses(int64_t unixTime, int64_t searchSeconds) { + PassJobs jobs; + if (!collectPassJobs(unixTime, jobs)) return false; + runPassJobs(jobs, searchSeconds); + return applyPassJobs(jobs); } void TrackerModel::invalidatePasses() { for (int i = 0; i < satelliteCount; i++) { satellites[i]->passSearchedAt = 0; + satellites[i]->passKnown = false; } } diff --git a/Apps/SatelliteTracker/main/Source/TrackerModel.h b/Apps/SatelliteTracker/main/Source/TrackerModel.h index 5a49165..0cff7f4 100644 --- a/Apps/SatelliteTracker/main/Source/TrackerModel.h +++ b/Apps/SatelliteTracker/main/Source/TrackerModel.h @@ -33,12 +33,33 @@ struct TrackedSatellite { orbit::PassSearchResult passResult = orbit::PassSearchResult::None; orbit::Pass nextPass {}; int64_t passSearchedAt = 0; + /** True once passResult belongs to the current orbit data and observer. */ + bool passKnown = false; + /** Unique for every new orbit data in the model, so a pass search with older data is dropped. */ + uint32_t orbitVersion = 0; /** Download state, not saved: failed attempts and the earliest time of the next attempt for missing orbit data. */ int downloadFailures = 0; int64_t retryAt = 0; }; +/** Pass searches that run outside the LVGL lock, see TrackerModel::collectPassJobs(). */ +struct PassJob { + uint32_t catalogNumber; + uint32_t orbitVersion; + orbit::Tle tle; + orbit::PassSearchResult result; + orbit::Pass pass; +}; + +struct PassJobs { + int count = 0; + PassJob jobs[MAX_TRACKED] {}; + orbit::Geodetic observer {}; + uint32_t observerVersion = 0; + int64_t startTime = 0; +}; + class TrackerModel { public: @@ -65,6 +86,8 @@ class TrackerModel { void setObserver(bool available, const orbit::Geodetic& location); bool hasObserver() const { return observerAvailable; } const orbit::Geodetic& observer() const { return observerLocation; } + /** Changes with every new observer location. */ + uint32_t observerChanges() const { return observerVersion; } void updatePositions(double unixTime); @@ -72,6 +95,15 @@ class TrackerModel { bool updatePasses(int64_t unixTime, int64_t searchSeconds); void invalidatePasses(); + /** True when a pass is missing or over. */ + bool passesStale(int64_t unixTime) const; + /** Copies the stale satellites into jobs and marks them as searched. Returns false when there is nothing to search. */ + bool collectPassJobs(int64_t unixTime, PassJobs& out); + /** Searches the passes of the jobs. Uses only the jobs, so it can run without the lock that guards the model. */ + static void runPassJobs(PassJobs& jobs, int64_t searchSeconds); + /** Stores the results whose orbit data and observer did not change since they were collected. Returns true when any was stored. */ + bool applyPassJobs(const PassJobs& jobs); + void buildScene(double unixTime, int focusedIndex, bool tracksForAll, map::Scene& out); /** The name from the orbit data without the part in brackets ("ISS" for "ISS (ZARYA)"). */ @@ -81,10 +113,13 @@ class TrackerModel { int freeSlot() const; bool slotUsed(int slot) const; + bool passStale(const TrackedSatellite& entry, int64_t unixTime) const; std::array, MAX_TRACKED> satellites; int satelliteCount = 0; int trackedLimit = DEFAULT_TRACKED_LIMIT; bool observerAvailable = false; orbit::Geodetic observerLocation {}; + uint32_t observerVersion = 0; + uint32_t orbitVersions = 0; }; diff --git a/Apps/SatelliteTracker/main/Source/main.cpp b/Apps/SatelliteTracker/main/Source/main.cpp index f9a4c9b..0aa4b25 100644 --- a/Apps/SatelliteTracker/main/Source/main.cpp +++ b/Apps/SatelliteTracker/main/Source/main.cpp @@ -24,7 +24,11 @@ int main(int argc, char* argv[]) { struct AppEventSubscription sub {}; check(app_event_subscribe(&sub, &event_group) == ERROR_NONE); + ctx->eventGroup = &event_group; + check(task_event_group_claim_bit(&event_group, &ctx->taskWorkBit) == ERROR_NONE); + bool downloads_ready = ctx->downloader.init(&event_group); + satelliteTrackerLoad(ctx.get()); lvgl_lock(); satelliteTrackerInit(ctx.get()); @@ -52,19 +56,26 @@ int main(int argc, char* argv[]) { if (downloads_ready) { DownloadResult result; while (ctx->downloader.process(result)) { + TleList tles; + satelliteTrackerReadDownload(ctx.get(), result, tles); lvgl_lock(); - satelliteTrackerHandleDownload(ctx.get(), result); + satelliteTrackerHandleDownload(ctx.get(), result, tles); lvgl_unlock(); } } - device_location.update(&event_group, ctx->settings.useDeviceLocation); + satelliteTrackerDoTaskWork(ctx.get()); + + lvgl_lock(); + bool use_device_location = ctx->settings.useDeviceLocation; + lvgl_unlock(); + device_location.update(&event_group, use_device_location); Location fix; if (device_location.poll(fix)) { lvgl_lock(); satelliteTrackerSetDeviceLocation(ctx.get(), &fix); lvgl_unlock(); - } else if (!device_location.isActive() && ctx->deviceFixValid) { + } else if (!device_location.isActive()) { lvgl_lock(); satelliteTrackerSetDeviceLocation(ctx.get(), nullptr); lvgl_unlock(); @@ -74,11 +85,13 @@ int main(int argc, char* argv[]) { window_manager_remove(window); ctx->downloader.deinit(); - check(app_event_unsubscribe(&sub) == ERROR_NONE); - task_event_group_destruct(&event_group); lvgl_lock(); satelliteTrackerTeardown(ctx.get()); lvgl_unlock(); + satelliteTrackerDoTaskWork(ctx.get()); + task_event_group_release_bit(&event_group, ctx->taskWorkBit); + check(app_event_unsubscribe(&sub) == ERROR_NONE); + task_event_group_destruct(&event_group); return 0; } From 35304cdd0fcf4c50fb02f1a8f41d7606f96b6cd5 Mon Sep 17 00:00:00 2001 From: AdaSzi115026 <81822538+AdaSzi@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:04:09 +0200 Subject: [PATCH 2/3] Satellite Tracker: bump version to 0.2.0 --- Apps/SatelliteTracker/manifest.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Apps/SatelliteTracker/manifest.properties b/Apps/SatelliteTracker/manifest.properties index c45e96b..89056fa 100644 --- a/Apps/SatelliteTracker/manifest.properties +++ b/Apps/SatelliteTracker/manifest.properties @@ -2,8 +2,8 @@ manifest.version=0.3 target.sdk=0.8.0-dev target.platforms=esp32,esp32s3,esp32c6,esp32p4,posix-x86_64 id=tactility.satellitetracker -version.name=0.1.0 -version.code=1 +version.name=0.2.0 +version.code=2 name=Satellite Tracker description=World map with live satellite positions and passes app.0.id=tactility.satellitetracker From cda4638a3b416ec82fbb6bd9b695cb61723a641e Mon Sep 17 00:00:00 2001 From: AdaSzi115026 <81822538+AdaSzi@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:22:04 +0200 Subject: [PATCH 3/3] Satellite Tracker: show pending pass search on the map --- Apps/SatelliteTracker/main/Source/MapView.cpp | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Apps/SatelliteTracker/main/Source/MapView.cpp b/Apps/SatelliteTracker/main/Source/MapView.cpp index e4b97a8..042b6b9 100644 --- a/Apps/SatelliteTracker/main/Source/MapView.cpp +++ b/Apps/SatelliteTracker/main/Source/MapView.cpp @@ -98,6 +98,15 @@ void onLegendClicked(lv_event_t* event) { toggleFocus(click->ctx, click->index); } +/** A satellite with orbit data has no pass result yet, the search runs in the background. */ +bool passSearchPending(const Context* ctx) { + for (int i = 0; i < ctx->model.count(); i++) { + const TrackedSatellite& entry = ctx->model.at(i); + if (entry.hasOrbit && !entry.passKnown) return true; + } + return false; +} + /** The tracked satellite whose pass is in progress or starts first. */ int pickNextPass(Context* ctx) { if (!ctx->model.hasObserver()) return -1; @@ -176,7 +185,7 @@ void describePass(Context* ctx, const TrackedSatellite& entry, char* out, size_t return; } char rise[8], set[8]; - bool passInProgress = entry.passResult == orbit::PassSearchResult::Found && entry.nextPass.riseTime <= currentTime(); + bool passInProgress = entry.passKnown && entry.passResult == orbit::PassSearchResult::Found && entry.nextPass.riseTime <= currentTime(); if (entry.look.elevationDeg > 0.0 && passInProgress) { formatClock(entry.nextPass.setTime, set, sizeof(set)); if (compact) { @@ -530,6 +539,8 @@ void mapViewUpdate(Context* ctx, bool force) { } else if (!ctx->model.hasObserver()) { hint = w.detailed ? "No satellite selected. Set your location in the settings to select the next pass automatically." : "Set location for automatic selection"; + } else if (passSearchPending(ctx)) { + hint = "Finding the next pass..."; } else { hint = w.detailed ? "No satellite selected and no pass higher than 5° in the next 36 hours." : "No pass in 36 h"; }