Add Starlink status lines for the physical gateway pane (closes #3)
check / check (push) Failing after 1s
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:
@@ -0,0 +1,129 @@
|
||||
package starlink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
pb "git.eeqj.de/sneak/rtnetmon/internal/starlink/pb"
|
||||
)
|
||||
|
||||
// errNoDishStatus is returned when the dish answers but the response
|
||||
// carries no dish status.
|
||||
var errNoDishStatus = errors.New("no dish status in response")
|
||||
|
||||
// Per-call deadlines so a silent dish never wedges the monitor loop.
|
||||
const (
|
||||
probeTimeout = 2 * time.Second
|
||||
statusTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
// DialFunc opens a TCP connection to addr bound to a chosen interface. The
|
||||
// monitor supplies one that binds the same way its TCP latency probes do,
|
||||
// so the dish is reached over the physical (non-VPN) interface rather than
|
||||
// a VPN tunnel that may hold the default route.
|
||||
type DialFunc func(ctx context.Context, addr string) (net.Conn, error)
|
||||
|
||||
// GRPCClient is the real Client: it reaches a dish at Address over whatever
|
||||
// interface dial binds to.
|
||||
type GRPCClient struct {
|
||||
dial DialFunc
|
||||
}
|
||||
|
||||
// NewClient returns a client that talks to a dish at Address, dialing with
|
||||
// dial so the connection leaves the intended interface.
|
||||
func NewClient(dial DialFunc) *GRPCClient {
|
||||
return &GRPCClient{dial: dial}
|
||||
}
|
||||
|
||||
// Probe connects to the dish's endpoint and closes it. A successful TCP
|
||||
// connect over the bound interface is the detection signal.
|
||||
func (c *GRPCClient) Probe(ctx context.Context) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
conn, err := c.dial(ctx, Address)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
_ = conn.Close()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Status calls the dish's get_status RPC and reduces the response to the
|
||||
// fields rtnetmon shows.
|
||||
func (c *GRPCClient) Status(ctx context.Context) (Status, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, statusTimeout)
|
||||
defer cancel()
|
||||
|
||||
// passthrough:/// hands Address straight to our dialer, with no name
|
||||
// resolution, so the connection is made over the bound interface.
|
||||
conn, err := grpc.NewClient("passthrough:///"+Address,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
return c.dial(ctx, addr)
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return Status{}, fmt.Errorf("starlink dial: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
resp, err := pb.NewDeviceClient(conn).Handle(ctx, &pb.Request{
|
||||
Request: &pb.Request_GetStatus{GetStatus: &pb.GetStatusRequest{}},
|
||||
})
|
||||
if err != nil {
|
||||
return Status{}, fmt.Errorf("starlink get_status: %w", err)
|
||||
}
|
||||
|
||||
dish := resp.GetDishGetStatus()
|
||||
if dish == nil {
|
||||
return Status{}, errNoDishStatus
|
||||
}
|
||||
|
||||
return statusFromDish(dish), nil
|
||||
}
|
||||
|
||||
// statusFromDish reduces the dish's status message to a Status. The
|
||||
// generated getters are nil-safe, so absent sub-messages read as zero.
|
||||
func statusFromDish(d *pb.DishGetStatusResponse) Status {
|
||||
obstruction := float64(d.GetObstructionStats().GetFractionObstructed())
|
||||
//nolint:gosec // G115: dish uptime in seconds never approaches int64 max
|
||||
uptime := time.Duration(d.GetDeviceState().GetUptimeS()) * time.Second
|
||||
|
||||
return Status{
|
||||
State: d.GetState().String(),
|
||||
Uptime: uptime,
|
||||
ObstructionPct: obstruction * percentScale,
|
||||
Alerts: countAlerts(d.GetAlerts()),
|
||||
PopPingMs: float64(d.GetPopPingLatencyMs()),
|
||||
PopPingDropPct: float64(d.GetPopPingDropRate()) * percentScale,
|
||||
DownlinkMbps: float64(d.GetDownlinkThroughputBps()) / bitsPerMegabit,
|
||||
UplinkMbps: float64(d.GetUplinkThroughputBps()) / bitsPerMegabit,
|
||||
}
|
||||
}
|
||||
|
||||
// countAlerts counts the active dish alert flags.
|
||||
func countAlerts(a *pb.DishAlerts) int {
|
||||
n := 0
|
||||
|
||||
for _, on := range []bool{
|
||||
a.GetMotorsStuck(),
|
||||
a.GetThermalShutdown(),
|
||||
a.GetThermalThrottle(),
|
||||
a.GetUnexpectedLocation(),
|
||||
} {
|
||||
if on {
|
||||
n++
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user