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
59 changes: 59 additions & 0 deletions Petabit.Tests/StarlinkTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,64 @@ public async Task FailedRefreshKeepsLastGoodCountAndDoesNotRepeatedlyDownload()
Assert.Equal(2, handler.Requests);
}

private async Task WriteRecoveryAsync(StarlinkSnapshot value)
{
Directory.CreateDirectory(Path.Combine(directory, "Data"));
await using var file = File.Create(Path.Combine(directory, "Data", "starlink-bootstrap.json.gz"));
await using var gzip = new System.IO.Compression.GZipStream(file, System.IO.Compression.CompressionLevel.SmallestSize);
await JsonSerializer.SerializeAsync(gzip, value);
}

[Fact]
public async Task NewerRecoveryReplacesLegacyCacheAndPersistsPartialPositionsDuringOutage()
{
Directory.CreateDirectory(Path.Combine(directory, "App_Data"));
var legacy = new StarlinkSnapshot(1, 2, 1, DateTimeOffset.UtcNow.AddHours(-4),
DateTimeOffset.Parse("2026-10-04T22:36:12.73824Z"), DateTimeOffset.Parse("2026-10-04T22:36:12.73824Z"),
[JsonSerializer.SerializeToElement(Orbit(1))]);
var recovered = StarlinkService.Parse(JsonSerializer.Serialize(new[] { Catalog(1, "+"), Catalog(2, "P") }),
JsonSerializer.Serialize(new[] { Orbit(1), Orbit(2) }), DateTimeOffset.UtcNow.AddHours(-3));
await File.WriteAllTextAsync(Path.Combine(directory, "App_Data", "starlink-status.json"), JsonSerializer.Serialize(legacy));
await WriteRecoveryAsync(recovered);
var handler = new Handler { Unavailable = true };
var service = Create(handler);
var state = await service.GetAsync(CancellationToken.None);
Assert.True(state.StatusCoverageComplete);
Assert.True(service.RefreshFailed);
Assert.Equal(recovered.RetrievedAt, state.RetrievedAt);
Assert.Equal("P", state.Elements[1].GetProperty("PETABIT_STATUS").GetString());
var persisted = JsonSerializer.Deserialize<StarlinkSnapshot>(await File.ReadAllTextAsync(Path.Combine(directory, "App_Data", "starlink-status.json")))!;
Assert.True(persisted.StatusCoverageComplete);
Assert.Equal(recovered.RetrievedAt, persisted.RetrievedAt);
Assert.Equal(2, handler.Requests);
}

[Fact]
public async Task RecoveryNeverReplacesNewerPersistedSnapshot()
{
Directory.CreateDirectory(Path.Combine(directory, "App_Data"));
var current = StarlinkService.Parse(JsonSerializer.Serialize(new[] { Catalog(1, "+") }),
JsonSerializer.Serialize(new[] { Orbit(1) }), DateTimeOffset.UtcNow);
await File.WriteAllTextAsync(Path.Combine(directory, "App_Data", "starlink-status.json"), JsonSerializer.Serialize(current));
await WriteRecoveryAsync(current with { RetrievedAt = DateTimeOffset.UtcNow.AddHours(-1) });
var handler = new Handler();
var state = await Create(handler).GetAsync(CancellationToken.None);
Assert.Equal(current.RetrievedAt, state.RetrievedAt);
Assert.Equal(0, handler.Requests);
}

[Fact]
public async Task ExpiredRecoveryIsIgnoredAndNormalSourceIsUsed()
{
var old = StarlinkService.Parse(JsonSerializer.Serialize(new[] { Catalog(1, "+") }),
JsonSerializer.Serialize(new[] { Orbit(1) }), DateTimeOffset.UtcNow) with { RetrievedAt = DateTimeOffset.UtcNow.AddDays(-8) };
await WriteRecoveryAsync(old);
var handler = new Handler();
var state = await Create(handler).GetAsync(CancellationToken.None);
Assert.True(state.RetrievedAt > old.RetrievedAt);
Assert.Equal(2, handler.Requests);
}

public void Dispose() { if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true); }
private class Factory(Handler handler) : IHttpClientFactory
{ public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); }
Expand Down Expand Up @@ -186,3 +244,4 @@ private class EnvironmentStub : IWebHostEnvironment
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
}
}

15 changes: 13 additions & 2 deletions Petabit.Tests/starlink-view.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ const fs=require('node:fs');
function setup(){
const elements=new Map();
const element=id=>{if(!elements.has(id)) elements.set(id,{dataset:{},checked:true,value:'1',hidden:true,clientWidth:800,clientHeight:500,events:{},addEventListener(type,fn){this.events[type]=fn;},setAttribute(){},focus(){}});return elements.get(id);};
const layers=Array.from({length:4},(_,i)=>element('layer'+i));
const layers=Array.from({length:4},(_,i)=>{ const layer=element('layer'+i); layer.parentElement={}; return layer; });
const context2d=new Proxy({createRadialGradient(){return {addColorStop(){}};}},{get:(obj,key)=>key in obj?obj[key]:()=>{}});
element('starlink-globe').getContext=()=>context2d;
element('starlink-panel').querySelectorAll=()=>layers;
const sandbox=vm.createContext({document:{getElementById:element,hidden:false,body:{classList:{contains:()=>false}},addEventListener(){}},window:{matchMedia:()=>({matches:true}),addEventListener(){}},devicePixelRatio:1,cancelAnimationFrame(){},requestAnimationFrame(){return 1;},console,Date,Math,Number,String,Array});
const sandbox=vm.createContext({document:{getElementById:element,querySelector:selector=>element(selector),hidden:false,body:{classList:{contains:()=>false}},addEventListener(){}},window:{matchMedia:()=>({matches:true}),addEventListener(){}},devicePixelRatio:1,cancelAnimationFrame(){},requestAnimationFrame(){return 1;},console,Date,Math,Number,String,Array});
vm.runInContext(fs.readFileSync('Petabit/wwwroot/js/starlink-dashboard.js','utf8'),sandbox);
return {sandbox,element,layers};
}
Expand All @@ -27,3 +27,14 @@ test('layers filter rendered satellites independently while catalog count remain
for(const layer of layers)layer.checked=false;layers[1].checked=true;layers[1].events.change();assert.equal(element('starlink-globe').dataset.visibleCount,'1');
layers[1].checked=false;layers[1].events.change();assert.equal(element('starlink-globe').dataset.visibleCount,'0');assert.equal(vm.runInContext('frame.count',sandbox),4);
});

test('layer coverage reports propagated positions and disables unavailable groups',()=>{
const {sandbox,element,layers}=setup();
vm.runInContext('updateLayerCoverage({count:3,statuses:new Uint8Array([0,1,1])},new Intl.NumberFormat("en"))',sandbox);
assert.equal(element('[data-layer-count="0"]').textContent,'1');
assert.equal(element('[data-layer-count="1"]').textContent,'2');
assert.equal(element('[data-layer-count="2"]').textContent,'0');
assert.equal(layers[1].disabled,false);assert.equal(layers[2].disabled,true);
vm.runInContext('updateLayerCoverage({count:2},new Intl.NumberFormat("en"))',sandbox);
assert.equal(element('[data-layer-count="0"]').textContent,'2');assert.equal(layers[1].disabled,true);
});
13 changes: 13 additions & 0 deletions Petabit/Data/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Starlink recovery snapshot

Public CelesTrak data retrieved at 2026-10-08T12:29:06.8865001Z through StarlinkService and validated against SATCAT. Compressed JSON retains the original retrieval timestamp and each NORAD identifier / catalog status.

Sources:
- https://celestrak.org/satcat/records.php?NAME=STARLINK&FORMAT=JSON
- https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=JSON

Uncompressed SHA-256: 0842d4124248e6bc58293db4618d957b988bc200cb481c1d3135817cb347cd5d

Catalog: 10838 operational, 294 partial, 2 non-operational, 0 other, 11134 total. Available elements: 10812 operational and 293 partial; no elements for the two non-operational satellites.

This is a startup recovery snapshot, not a live feed. It may replace only missing/older validated cache, expires for startup selection after seven days, and never changes source timestamps. Normal two-hour refresh and stale-data warnings remain active. Newer successful server snapshots supersede it. The file is outside wwwroot.
Binary file added Petabit/Data/starlink-bootstrap.json.gz
Binary file not shown.
1 change: 1 addition & 0 deletions Petabit/Petabit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
</ItemGroup>

<ItemGroup>
<Content Include="Data\starlink-bootstrap.json.gz" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Remove="App_Data\**\*" />
<None Remove="App_Data\**\*" />
<Content Update="wwwroot\lib\**\*.map" CopyToPublishDirectory="Never" />
Expand Down
4 changes: 4 additions & 0 deletions Petabit/Resources/Views.Home.Index.de.resx
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,8 @@
<data name="StarlinkZoomIn" xml:space="preserve"><value>Vergrößern</value></data>
<data name="StarlinkZoomOut" xml:space="preserve"><value>Verkleinern</value></data>
<data name="StarlinkReset" xml:space="preserve"><value>Ansicht zurücksetzen</value></data>
<data name="StarlinkLayerEmpty" xml:space="preserve"><value>Für die gewählten Ebenen sind keine Positionen verfügbar.</value></data>
<data name="StarlinkAllHidden" xml:space="preserve"><value>Alle verfügbaren Ebenen sind ausgeblendet. Aktiviere mindestens eine Ebene.</value></data>
<data name="StarlinkNoPositions" xml:space="preserve"><value>Keine Positionen verfügbar</value></data>
<data name="StarlinkLayerHelp" xml:space="preserve"><value>Die Zahl je Ebene zeigt Satelliten mit verfügbaren Positionen, nicht die Gesamtzahl im Katalog. Ebenen ohne Positionen sind nicht verfügbar.</value></data>
</root>
4 changes: 4 additions & 0 deletions Petabit/Resources/Views.Home.Index.en.resx
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,8 @@
<data name="StarlinkZoomIn" xml:space="preserve"><value>Zoom in</value></data>
<data name="StarlinkZoomOut" xml:space="preserve"><value>Zoom out</value></data>
<data name="StarlinkReset" xml:space="preserve"><value>Reset view</value></data>
<data name="StarlinkLayerEmpty" xml:space="preserve"><value>No positions are available for the selected layers.</value></data>
<data name="StarlinkAllHidden" xml:space="preserve"><value>All available layers are hidden. Enable at least one layer.</value></data>
<data name="StarlinkNoPositions" xml:space="preserve"><value>No positions available</value></data>
<data name="StarlinkLayerHelp" xml:space="preserve"><value>Layer counts show satellites with available positions, not catalog totals. Layers without positions are unavailable.</value></data>
</root>
4 changes: 4 additions & 0 deletions Petabit/Resources/Views.Home.Index.hr.resx
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,8 @@
<data name="StarlinkZoomIn" xml:space="preserve"><value>Povećaj</value></data>
<data name="StarlinkZoomOut" xml:space="preserve"><value>Smanji</value></data>
<data name="StarlinkReset" xml:space="preserve"><value>Vrati pogled</value></data>
<data name="StarlinkLayerEmpty" xml:space="preserve"><value>Za odabrane slojeve nema dostupnih položaja.</value></data>
<data name="StarlinkAllHidden" xml:space="preserve"><value>Svi dostupni slojevi su isključeni. Uključi barem jedan sloj.</value></data>
<data name="StarlinkNoPositions" xml:space="preserve"><value>Nema dostupnih položaja</value></data>
<data name="StarlinkLayerHelp" xml:space="preserve"><value>Broj uz sloj označava satelite s dostupnim položajima, ne ukupan broj u katalogu. Slojevi bez položaja nisu dostupni.</value></data>
</root>
70 changes: 55 additions & 15 deletions Petabit/Services/StarlinkService.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.IO.Compression;
using System.Globalization;
using System.Text.Json;

Expand All @@ -14,6 +15,7 @@ public sealed class StarlinkService
private readonly IHttpClientFactory clients;
private readonly ILogger<StarlinkService> logger;
private readonly string path;
private readonly string bootstrapPath;
private readonly SemaphoreSlim gate = new(1, 1);
private readonly object refreshLock = new();
private Task<StarlinkSnapshot>? refresh;
Expand All @@ -28,6 +30,7 @@ public StarlinkService(IHttpClientFactory clients, ILogger<StarlinkService> logg
this.clients = clients;
this.logger = logger;
path = Path.Combine(environment.ContentRootPath, "App_Data", "starlink-status.json");
bootstrapPath = Path.Combine(environment.ContentRootPath, "Data", "starlink-bootstrap.json.gz");
}

public Task<StarlinkSnapshot> GetAsync(CancellationToken cancellationToken)
Expand Down Expand Up @@ -64,6 +67,35 @@ private async Task<StarlinkSnapshot> RefreshAsync()
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or FormatException)
{ logger.LogWarning(error, "Unable to restore Starlink cache."); }
// Recover older operational-only deployments without inventing missing positions.
if (File.Exists(bootstrapPath))
{
try
{
if (new FileInfo(bootstrapPath).Length > 12_000_000) throw new FormatException("Oversized recovery snapshot.");
await using var source = File.OpenRead(bootstrapPath);
await using var compressed = new GZipStream(source, CompressionMode.Decompress);
using var buffer = new MemoryStream();
var chunk = new byte[8192];
int length;
while ((length = await compressed.ReadAsync(chunk, cancellationToken)) > 0)
{
if (buffer.Length + length > 12_000_000) throw new FormatException("Oversized expanded recovery snapshot.");
buffer.Write(chunk, 0, length);
}
var recovered = JsonSerializer.Deserialize<StarlinkSnapshot>(buffer.ToArray());
if (recovered is not null && recovered.StatusCoverageComplete && ValidCache(recovered)
&& DateTimeOffset.UtcNow - recovered.RetrievedAt < TimeSpan.FromDays(7)
&& (snapshot is null || recovered.RetrievedAt > snapshot.RetrievedAt))
{
snapshot = recovered;
await PersistAsync(recovered, cancellationToken);
logger.LogInformation("Recovered complete Starlink status coverage from validated snapshot dated {RetrievedAt}.", recovered.RetrievedAt);
}
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException or JsonException or FormatException)
{ logger.LogWarning(error, "Unable to load Starlink recovery snapshot."); }
}
}
// CelesTrak publishes GP updates every two hours. A click must not re-download identical data.
var now = DateTimeOffset.UtcNow;
Expand All @@ -88,23 +120,28 @@ private async Task<StarlinkSnapshot> RefreshAsync()
if (snapshot is null) throw new HttpRequestException("Starlink data unavailable.", error);
return snapshot;
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path + ".tmp", JsonSerializer.Serialize(snapshot), cancellationToken);
File.Move(path + ".tmp", path, overwrite: true);
PersistenceFailed = false;
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
PersistenceFailed = true;
logger.LogWarning(error, "Unable to persist Starlink cache.");
}
await PersistAsync(snapshot, cancellationToken);
return snapshot;
}
finally { gate.Release(); }
}

private async Task PersistAsync(StarlinkSnapshot value, CancellationToken cancellationToken)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await File.WriteAllTextAsync(path + ".tmp", JsonSerializer.Serialize(value), cancellationToken);
File.Move(path + ".tmp", path, overwrite: true);
PersistenceFailed = false;
}
catch (Exception error) when (error is IOException or UnauthorizedAccessException)
{
PersistenceFailed = true;
logger.LogWarning(error, "Unable to persist Starlink cache.");
}
}

public static StarlinkSnapshot Parse(string catalogJson, string elementsJson, DateTimeOffset retrievedAt)
{
using var catalog = JsonDocument.Parse(catalogJson);
Expand Down Expand Up @@ -140,7 +177,7 @@ public static StarlinkSnapshot Parse(string catalogJson, string elementsJson, Da
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var epoch)
|| epoch > retrievedAt.AddDays(1)) throw new FormatException("Invalid Starlink epoch or name.");
foreach (var field in new[] { "MEAN_MOTION", "ECCENTRICITY", "INCLINATION", "RA_OF_ASC_NODE",
"ARG_OF_PERICENTER", "MEAN_ANOMALY", "BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT" })
"ARG_OF_PERICENTER", "MEAN_ANOMALY", "BSTAR", "MEAN_MOTION_DOT", "MEAN_MOTION_DDOT" })
if (!entry.TryGetProperty(field, out var value) || value.ValueKind != JsonValueKind.Number || !value.TryGetDouble(out var n) || !double.IsFinite(n))
throw new FormatException($"Invalid orbital field {field}.");
if (entry.GetProperty("MEAN_MOTION").GetDouble() <= 0
Expand Down Expand Up @@ -176,9 +213,12 @@ private static bool ValidCache(StarlinkSnapshot saved)
var catalog = saved.Elements.Select(entry => new
{
OBJECT_NAME = Text(entry, "OBJECT_NAME"),
NORAD_CAT_ID = entry.GetProperty("NORAD_CAT_ID"), OBJECT_TYPE = "PAY",
NORAD_CAT_ID = entry.GetProperty("NORAD_CAT_ID"),
OBJECT_TYPE = "PAY",
OPS_STATUS_CODE = saved.StatusCoverageComplete ? Text(entry, "PETABIT_STATUS") : "+",
ORBIT_CENTER = "EA", ORBIT_TYPE = "ORB", DECAY_DATE = ""
ORBIT_CENTER = "EA",
ORBIT_TYPE = "ORB",
DECAY_DATE = ""
});
try
{
Expand Down
Loading
Loading