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 }