Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Apps/SatelliteTracker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 8 additions & 10 deletions Apps/SatelliteTracker/main/Source/Downloads.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand All @@ -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() {
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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));
}
4 changes: 3 additions & 1 deletion Apps/SatelliteTracker/main/Source/Downloads.h
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class Downloader {

public:

/** Main task only: reads the certificate file. */
bool init(TaskEventGroup* eventGroup);
void deinit();

Expand All @@ -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;
};
24 changes: 19 additions & 5 deletions Apps/SatelliteTracker/main/Source/MapView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,23 @@ 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;
int best = -1;
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (best < 0 || entry.nextPass.riseTime < bestRise) {
best = i;
bestRise = entry.nextPass.riseTime;
Expand All @@ -117,7 +126,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);
}

Expand Down Expand Up @@ -163,7 +172,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, "--");
Expand All @@ -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) {
Expand All @@ -187,6 +196,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;
}
Comment thread
AdaSzi marked this conversation as resolved.
switch (entry.passResult) {
case orbit::PassSearchResult::Found: {
formatClock(entry.nextPass.riseTime, rise, sizeof(rise));
Expand Down Expand Up @@ -462,7 +475,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);
Expand Down Expand Up @@ -527,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";
}
Expand Down
113 changes: 76 additions & 37 deletions Apps/SatelliteTracker/main/Source/PassesView.cpp
Original file line number Diff line number Diff line change
@@ -1,30 +1,25 @@
#include "SatelliteTracker.h"

#include <lvgl/fonts.h>
#include <lvgl/lvgl.h>

#include <algorithm>
#include <cmath>
#include <cstdio>
#include <ctime>
#include <vector>
#include <memory>

namespace {

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) +
Expand All @@ -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<orbit::Satellite>();
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);
Expand All @@ -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<PassRow> rows;
std::vector<int> 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);
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading