// Package starlink detects a Starlink dish on the local network and reads a // short status summary from the dish's local gRPC endpoint. Detection and // the status fetch sit behind the Client interface, so the monitor is // tested with a fake and needs no dish and no network. Render turns a // Status into the two lines the UI draws and is a pure function. package starlink import ( "context" "fmt" "time" ) // Address is the dish's fixed local gRPC endpoint. Every dish answers here // from behind the Starlink router, whatever the LAN's own addressing is, so // this is not the default gateway (that is the router). const Address = "192.168.100.1:9200" // StateConnected is the dish state string that means the link is up; any // other state is treated as an alert condition by Render. const StateConnected = "CONNECTED" // Units used when reducing the dish's raw values to display values. const ( percentScale = 100.0 bitsPerMegabit = 1_000_000.0 hoursPerDay = 24 minutesPerHour = 60 ) // Status is the subset of the dish's get_status response that rtnetmon // shows. Throughput is megabits per second; obstruction and drop are // percentages on a 0..100 scale. type Status struct { State string Uptime time.Duration ObstructionPct float64 Alerts int PopPingMs float64 PopPingDropPct float64 DownlinkMbps float64 UplinkMbps float64 } // Client probes for a dish and reads its status. The real implementation // speaks gRPC to Address over one bound interface; tests use a fake. type Client interface { // Probe reports whether a dish answers on Address over the bound // interface. It is cheap: a TCP connect, no gRPC call. Probe(ctx context.Context) bool // Status fetches the dish's current status. Status(ctx context.Context) (Status, error) } // Render formats a Status as the two status lines drawn under the physical // pane, and reports whether they warrant an alert (red) color: the dish is // not connected, or an alert is active. func Render(s Status) (string, string, bool) { line1 := fmt.Sprintf( "Starlink: %s uptime %s obstruction %.1f%% alerts %d", s.State, formatUptime(s.Uptime), s.ObstructionPct, s.Alerts) line2 := fmt.Sprintf( " pop ping %.0fms / drop %.1f%% down %.1fMbps / up %.1fMbps", s.PopPingMs, s.PopPingDropPct, s.DownlinkMbps, s.UplinkMbps) alert := s.State != StateConnected || s.Alerts > 0 return line1, line2, alert } // formatUptime renders a duration as whole days, hours and minutes. func formatUptime(d time.Duration) string { if d < 0 { d = 0 } days := int64(d / (hoursPerDay * time.Hour)) hours := int64(d/time.Hour) % hoursPerDay mins := int64(d/time.Minute) % minutesPerHour return fmt.Sprintf("%dd %dh %dm", days, hours, mins) }