Add Starlink status lines for the physical gateway pane (closes #3)
check / check (push) Failing after 1s

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
This commit is contained in:
2026-09-21 22:51:45 +00:00
parent 486a47397c
commit e3ef4f2f48
15 changed files with 1535 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
package monitor
import (
"context"
"net"
"sync"
"time"
tcell "github.com/gdamore/tcell/v2"
"git.eeqj.de/sneak/rtnetmon/internal/starlink"
)
// Starlink probe and status cadence: detect once a minute until a dish
// answers, then refresh the status every few seconds.
const (
starlinkProbePeriod = time.Minute
starlinkStatusPeriod = 5 * time.Second
)
// starlinkState holds the latest dish status for the physical pane. Nothing
// is drawn until a dish first answers, so an absent dish adds no lines.
type starlinkState struct {
mu sync.RWMutex
detected bool
haveStatus bool
status starlink.Status
}
// snapshot returns whether a dish was detected, whether a status has been
// read, and the latest status, all under the read lock.
func (s *starlinkState) snapshot() (bool, bool, starlink.Status) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.detected, s.haveStatus, s.status
}
// EnableStarlink turns on Starlink detection for the named interface, which
// must be the non-VPN physical gateway pane. It is a no-op if no monitored
// interface has that name.
func (m *Monitor) EnableStarlink(iface string) {
for _, st := range m.interfaces {
if st.Name == iface {
m.slPane = st
m.slState = &starlinkState{}
m.slClient = starlink.NewClient(m.starlinkDialer(iface))
return
}
}
}
// starlinkDialer returns a dialer that binds to iface exactly as the TCP
// latency probes do, so the dish is reached over the physical interface.
func (m *Monitor) starlinkDialer(iface string) starlink.DialFunc {
return func(ctx context.Context, addr string) (net.Conn, error) {
la, err := localAddr(iface)
if err != nil {
return nil, err
}
d := net.Dialer{LocalAddr: la, Control: bindControl(iface)}
return d.DialContext(ctx, "tcp", addr)
}
}
// starlinkLoop probes for a dish and refreshes its status until the context
// is cancelled. It does nothing when Starlink was not enabled.
func (m *Monitor) starlinkLoop(ctx context.Context) {
if m.slClient == nil {
return
}
m.logf("Starting Starlink monitoring for %s", m.slPane.Name)
m.starlinkProbe(ctx)
probe := time.NewTicker(starlinkProbePeriod)
status := time.NewTicker(starlinkStatusPeriod)
defer probe.Stop()
defer status.Stop()
for {
select {
case <-ctx.Done():
m.logf("Stopping Starlink monitoring for %s", m.slPane.Name)
return
case <-probe.C:
m.starlinkProbe(ctx)
case <-status.C:
m.starlinkRefresh(ctx)
}
}
}
// starlinkProbe detects the dish; once detected it stays detected, and a
// first status is fetched immediately.
func (m *Monitor) starlinkProbe(ctx context.Context) {
if detected, _, _ := m.slState.snapshot(); detected {
return
}
if !m.slClient.Probe(ctx) {
return
}
m.slState.mu.Lock()
m.slState.detected = true
m.slState.mu.Unlock()
m.notifyUI()
m.starlinkRefresh(ctx)
}
// starlinkRefresh fetches status only once a dish has been detected, so an
// absent dish produces no status traffic.
func (m *Monitor) starlinkRefresh(ctx context.Context) {
if detected, _, _ := m.slState.snapshot(); !detected {
return
}
st, err := m.slClient.Status(ctx)
if err != nil {
m.logf("Starlink status error: %v", err)
return
}
m.slState.mu.Lock()
m.slState.status = st
m.slState.haveStatus = true
m.slState.mu.Unlock()
m.notifyUI()
}
// drawStarlink draws the two dish status lines under the physical pane. It
// draws nothing until a dish has answered.
func (m *Monitor) drawStarlink(scr tcell.Screen, y int) int {
detected, haveStatus, st := m.slState.snapshot()
if !detected {
return y
}
if !haveStatus {
Put(scr, colLeft, y, "Starlink: detected, status unavailable", styleBrightRed())
return y + lineStep
}
line1, line2, alert := starlink.Render(st)
style := tcell.StyleDefault
if alert {
style = styleBrightRed()
}
Put(scr, colLeft, y, line1, style)
y += lineStep
Put(scr, colLeft, y, line2, style)
return y + lineStep
}