Files
rtnetmon/internal/starlink/starlink.go
T
sneak e3ef4f2f48
check / check (push) Failing after 1s
Add Starlink status lines for the physical gateway pane (closes #3)
When the non-VPN physical gateway is a Starlink dish, two lines are shown
under that pane: state, uptime, obstruction and alert count; then pop-ping
latency and drop rate with downlink/uplink throughput. They turn red when
the dish is not connected or an alert is active.

Detection is a TCP connect to the dish's fixed endpoint 192.168.100.1:9200,
bound to the physical interface the same way the latency probes bind. Until
a dish answers nothing is drawn and no status is fetched, so an absent dish
adds no noise. Status comes from the dish's local get_status gRPC call.

Detection and the fetch sit behind a small Client interface; the loop and
the pure Render function are tested with a fake, no dish and no network.

Model: opus-4-8
2026-09-21 22:51:45 +00:00

82 lines
2.7 KiB
Go

// 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)
}