datafeed: export pilot rating and flight plan via online_users; stop fabricating QNH

Extend serviceapi pilot/ATC DTOs; store PilotRating on LoginData at auth;
populate online_users from session snapshot. Datafeed drops outer
PilotRating/TextATIS, maps info-section to FlightPlan object, zeros
military/QNH, and uses ConfigFsdServerIdent for server (fallback OPENFSD).
This commit is contained in:
Reese Norris
2026-07-27 13:58:10 -04:00
parent ac103cabe0
commit 49229b740a
10 changed files with 335 additions and 30 deletions

View File

@@ -191,10 +191,14 @@ func TestOnlineUsersSyntheticBadge(t *testing.T) {
require.NoError(t, err)
human := newSess("HUMAN1", false, NetworkRatingObserver)
human.PilotRating = int(protocol.PilotRatingPPL)
human.FlightPlan.Store("I:B738:450:KLAX:1200:0000:FL350:KJFK:2:0:3:0::rmk:DCT")
human.AssignedBeaconCode.Store("4321")
require.NoError(t, reg.Register(human))
synth := newSess("SBX1", false, NetworkRatingObserver)
synth.Synthetic = true
synth.PilotRating = 0
require.NoError(t, reg.Register(synth))
e := srv.setupRoutes()
@@ -214,9 +218,13 @@ func TestOnlineUsersSyntheticBadge(t *testing.T) {
case "HUMAN1":
sawHuman = true
require.False(t, p.Synthetic, "human pilot must not be synthetic")
require.Equal(t, int(protocol.PilotRatingPPL), p.PilotRating)
require.Contains(t, p.FlightPlan, "KLAX")
require.Equal(t, "4321", p.AssignedBeaconCode)
case "SBX1":
sawSynth = true
require.True(t, p.Synthetic, "sweatbox pilot must be synthetic")
require.Equal(t, 0, p.PilotRating)
}
}
require.True(t, sawHuman && sawSynth, "expected both pilots in snapshot")
@@ -224,6 +232,8 @@ func TestOnlineUsersSyntheticBadge(t *testing.T) {
require.Contains(t, body, `"synthetic":true`)
// Human pilots must omit synthetic when false (omitempty).
require.NotContains(t, body, `"synthetic":false`)
// pilot_rating always present as a number.
require.Contains(t, body, `"pilot_rating"`)
}
func TestRunServiceHTTPAndListen(t *testing.T) {

View File

@@ -119,6 +119,7 @@ func (s *Server) attemptAuthentication(client *session.Session, token string) (e
if err = s.enforcePilotPPLRequirement(client, user); err != nil {
return
}
setSessionPilotRating(client, user)
return
}
@@ -157,10 +158,24 @@ func (s *Server) attemptAuthentication(client *session.Session, token string) (e
if err = s.enforcePilotPPLRequirement(client, user); err != nil {
return
}
setSessionPilotRating(client, user)
return
}
// setSessionPilotRating stores the certificate pilot rating on the session.
// Invalid / unknown ratings become 0 (P0).
func setSessionPilotRating(client *session.Session, user *db.User) {
if client == nil || user == nil {
return
}
if protocol.IsValidPilotRating(user.PilotRating) {
client.PilotRating = user.PilotRating
return
}
client.PilotRating = 0
}
// enforcePilotPPLRequirement rejects pilot (#AP) logins when REQUIRE_PILOT_PPL is
// enabled and the certificate's pilot_rating is below PPL. ATC is unaffected.
func (s *Server) enforcePilotPPLRequirement(client *session.Session, user *db.User) error {

View File

@@ -145,6 +145,7 @@ func (s *Server) handleGetOnlineUsers(c *gin.Context) {
Frequency: client.Frequency.Load(),
Facility: int(client.FacilityType.Load()),
VisRange: int(client.VisRange.Load() * 0.000539957), // Convert meters to nautical miles
// TextATIS empty: openfsd does not persist NEWINFO by default.
}
resData.ATC = append(resData.ATC, atc)
} else {
@@ -155,6 +156,9 @@ func (s *Server) handleGetOnlineUsers(c *gin.Context) {
Heading: int(client.Heading.Load()),
Transponder: client.Transponder.Load(),
Synthetic: client.Synthetic,
PilotRating: client.PilotRating,
FlightPlan: client.FlightPlan.Load(),
AssignedBeaconCode: client.AssignedBeaconCode.Load(),
}
resData.Pilots = append(resData.Pilots, pilot)
}

View File

@@ -448,7 +448,11 @@ func TestAttemptAuth_SuccessPassword(t *testing.T) {
t.Fatal(err)
}
f := &fakeUserStore{byCID: map[int]*db.User{
5: {CID: 5, Password: string(hash), NetworkRating: int(protocol.NetworkRatingController1)},
5: {
CID: 5, Password: string(hash),
NetworkRating: int(protocol.NetworkRatingController1),
PilotRating: int(protocol.PilotRatingATPL),
},
}}
srv := newAuthTestServer(t, f, nil)
client := session.New(context.Background(), &discardConn{}, nil, session.LoginData{
@@ -463,6 +467,9 @@ func TestAttemptAuth_SuccessPassword(t *testing.T) {
if client.MaxNetworkRating != protocol.NetworkRatingController1 {
t.Fatalf("MaxNetworkRating=%v", client.MaxNetworkRating)
}
if client.PilotRating != int(protocol.PilotRatingATPL) {
t.Fatalf("PilotRating=%d want ATPL(%d)", client.PilotRating, protocol.PilotRatingATPL)
}
}
func TestAttemptAuth_JWT(t *testing.T) {

View File

@@ -474,6 +474,7 @@ func (h *SweatboxHost) buildSession(ac sweatbox.AircraftSnapshot) *session.Sessi
RealName: "SWEATBOX",
NetworkRating: protocol.NetworkRatingObserver,
MaxNetworkRating: protocol.NetworkRatingObserver,
PilotRating: 0, // synthetic: no DB certificate (P0)
ProtoRevision: 100,
LoginTime: now,
IsAtc: false,

View File

@@ -30,6 +30,18 @@ type OnlineUserPilot struct {
// Synthetic is true for in-process sweatbox pilots (no TCP client).
// Omitted from JSON when false so human pilots stay compact.
Synthetic bool `json:"synthetic,omitempty"`
// PilotRating is the VATSIM pilot rating wire ID from the certificate
// at login (0,1,3,7,15,31,63). 0 if unknown (e.g. synthetic without DB).
// Always present as a number (0 is valid P0).
PilotRating int `json:"pilot_rating"`
// FlightPlan is the session info-section string (no $FP source/dest),
// empty if none filed. Same layout as session.FlightPlan / encodeFlightPlanInfo.
FlightPlan string `json:"flight_plan,omitempty"`
// AssignedBeaconCode is the ATC-assigned squawk if set; may be empty.
AssignedBeaconCode string `json:"assigned_beacon_code,omitempty"`
}
// OnlineUserATC is an ATC entry in the online-users snapshot.
@@ -38,6 +50,10 @@ type OnlineUserATC struct {
Frequency string `json:"frequency"`
Facility int `json:"facility"`
VisRange int `json:"visual_range"`
// TextATIS is multi-line controller ATIS when the server stores it.
// Empty when not available (openfsd does not persist NEWINFO by default).
TextATIS []string `json:"text_atis,omitempty"`
}
// OnlineUsersResponseData is the JSON body for GET /online_users.

View File

@@ -37,10 +37,13 @@ type LoginData struct {
RealName string // Real name
NetworkRating protocol.NetworkRating // Network rating of the client
MaxNetworkRating protocol.NetworkRating // Maximum allowed network rating (from DB/JWT)
ProtoRevision int // Protocol revision
LoginTime time.Time // Time of login
ClientID uint16 // Client ID (from ident packet)
IsAtc bool // True if the client is ATC, false if a pilot
// PilotRating is the certificate pilot rating wire ID set at auth
// (0,1,3,7,15,31,63). Immutable after login; 0 for synthetic/unknown.
PilotRating int
ProtoRevision int // Protocol revision
LoginTime time.Time // Time of login
ClientID uint16 // Client ID (from ident packet)
IsAtc bool // True if the client is ATC, false if a pilot
}
// LatLon is a geographic coordinate pair (API convenience; storage is non-boxing atomics).
@@ -71,7 +74,7 @@ type LatLon struct {
//
// Immutable after login (set during login; safe to read concurrently afterward):
// - LoginData fields (Callsign, CID, RealName, NetworkRating, ProtoRevision, …)
// - MaxNetworkRating is fixed once authentication completes
// - MaxNetworkRating / PilotRating fixed once authentication completes
// - Synthetic — set before Register for in-process (sweatbox) participants;
// never set on the TCP login path; concurrent readers OK
//

View File

@@ -8,12 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/internal/auth"
"github.com/renorris/openfsd/internal/db"
"github.com/renorris/openfsd/internal/serviceapi"
"github.com/renorris/openfsd/pkg/protocol"
"go.uber.org/atomic"
"fmt"
"io"
"log/slog"
"net/http"
@@ -21,6 +16,13 @@ import (
"strings"
"text/template"
"time"
"github.com/gin-gonic/gin"
"github.com/renorris/openfsd/internal/auth"
"github.com/renorris/openfsd/internal/db"
"github.com/renorris/openfsd/internal/serviceapi"
"github.com/renorris/openfsd/pkg/protocol"
"go.uber.org/atomic"
)
//go:embed data_templates/status.txt
@@ -284,14 +286,19 @@ type DatafeedGeneral struct {
UniqueUsers int `json:"unique_users"`
}
// DatafeedPilot embeds OnlineUserPilot so pilot_rating / assigned_beacon_code /
// position fields come from service-HTTP. FlightPlan intentionally shadows the
// embed: service-HTTP carries a string info section; the public datafeed wants
// a VATSIM-shaped object (or omit when nil). Do NOT redeclare PilotRating —
// encoding/json prefers outer fields and would zero honest embed values.
type DatafeedPilot struct {
serviceapi.OnlineUserPilot
Server string `json:"server"`
PilotRating int `json:"pilot_rating"` // INOP placeholder
MilitaryRating int `json:"military_rating"` // INOP placeholder
QnhIHg float64 `json:"qnh_i_hg"` // INOP placeholder
QnhMb int `json:"qnh_mb"` // INOP placeholder
FlightPlan *DatafeedFlightplan `json:"flight_plan,omitempty"` // INOP placeholder
serviceapi.OnlineUserPilot // promotes general/position/synthetic/pilot_rating/assigned_beacon_code
Server string `json:"server"`
MilitaryRating int `json:"military_rating"` // always 0: not stored
QnhIHg float64 `json:"qnh_i_hg"` // always 0: no weather model
QnhMb int `json:"qnh_mb"` // always 0: no weather model
// Intentional shadow of embed flight_plan string → object.
FlightPlan *DatafeedFlightplan `json:"flight_plan,omitempty"`
}
type DatafeedFlightplan struct {
@@ -311,10 +318,11 @@ type DatafeedFlightplan struct {
AssignedTransponder string `json:"assigned_transponder"`
}
// DatafeedATC embeds OnlineUserATC (incl. text_atis when present).
// Do NOT redeclare TextATIS — embed owns json:"text_atis".
type DatafeedATC struct {
serviceapi.OnlineUserATC
Server string `json:"server"`
TextATIS []string `json:"text_atis"` // INOP placeholder
Server string `json:"server"`
}
type DatafeedCache struct {
@@ -362,22 +370,27 @@ func (s *Server) generateDatafeed() (feed *DatafeedCache, err error) {
ATC: []DatafeedATC{},
}
serverIdent, _, _, serverErr := s.getFsdServerInfo()
if serverErr != nil || serverIdent == "" {
serverIdent = "OPENFSD"
}
for _, pilot := range onlineUsers.Pilots {
dataFeed.Pilots = append(dataFeed.Pilots, DatafeedPilot{
OnlineUserPilot: pilot,
Server: "OPENFSD",
PilotRating: 1,
MilitaryRating: 1,
QnhIHg: 29.92,
QnhMb: 1013,
OnlineUserPilot: pilot, // pilot_rating / assigned_beacon from embed
Server: serverIdent,
MilitaryRating: 0,
QnhIHg: 0,
QnhMb: 0,
// Intentional shadow: string info section → VATSIM-shaped object.
FlightPlan: mapInfoSectionToDatafeedFP(pilot.FlightPlan, pilot.AssignedBeaconCode),
})
}
for _, atc := range onlineUsers.ATC {
dataFeed.ATC = append(dataFeed.ATC, DatafeedATC{
OnlineUserATC: atc,
Server: "OPENFSD",
TextATIS: []string{},
OnlineUserATC: atc, // text_atis from embed when present
Server: serverIdent,
})
}
@@ -453,3 +466,59 @@ func (s *Server) updateDataFeedCache() {
}
datafeedCache.Store(feed)
}
// parseNonNegIntField returns 0 for empty, non-numeric, or negative input.
func parseNonNegIntField(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return 0
}
return n
}
// formatHHMM formats hour/minute info-section fields as "HHMM" with zero padding.
// sweatbox/encodeFlightPlanInfo may emit single-digit "0" rather than "00".
func formatHHMM(hoursField, minutesField string) string {
h := parseNonNegIntField(hoursField)
m := parseNonNegIntField(minutesField)
return fmt.Sprintf("%02d%02d", h, m)
}
// mapInfoSectionToDatafeedFP maps a session/serviceapi flight-plan info section
// (colon fields after $FP SOURCE:DEST) to a VATSIM-shaped DatafeedFlightplan.
// Empty info returns nil so the intentional outer FlightPlan shadow omits the key.
func mapInfoSectionToDatafeedFP(info, assignedBeacon string) *DatafeedFlightplan {
info = strings.TrimSpace(info)
if info == "" {
return nil
}
parts := strings.Split(info, ":")
field := func(i int) string {
if i < 0 || i >= len(parts) {
return ""
}
return parts[i]
}
ac := field(1)
fp := &DatafeedFlightplan{
FlightRules: field(0),
Aircraft: ac,
AircraftFAA: ac,
AircraftShort: ac,
Departure: field(3),
DepTime: field(4),
Arrival: field(7),
EnrouteTime: formatHHMM(field(8), field(9)),
FuelTime: formatHHMM(field(10), field(11)),
Alternate: field(12),
Remarks: field(13),
Route: field(14),
RevisionID: 0,
AssignedTransponder: assignedBeacon,
}
return fp
}

View File

@@ -0,0 +1,150 @@
package web
import (
"encoding/json"
"testing"
"github.com/renorris/openfsd/internal/serviceapi"
)
func TestFormatHHMM(t *testing.T) {
cases := []struct {
h, m, want string
}{
{"0", "0", "0000"},
{"2", "5", "0205"},
{"2", "40", "0240"},
{"", "", "0000"},
{"x", "3", "0003"},
{"-1", "5", "0005"},
{"12", "y", "1200"},
}
for _, tc := range cases {
got := formatHHMM(tc.h, tc.m)
if got != tc.want {
t.Errorf("formatHHMM(%q,%q)=%q want %q", tc.h, tc.m, got, tc.want)
}
}
}
func TestMapInfoSectionToDatafeedFP(t *testing.T) {
if got := mapInfoSectionToDatafeedFP("", "1200"); got != nil {
t.Fatalf("empty info → nil, got %#v", got)
}
info := "I:B738:450:KLAX:1200:0000:FL350:KJFK:2:40:5:30:KBOS:RMK:DCT"
fp := mapInfoSectionToDatafeedFP(info, "4321")
if fp == nil {
t.Fatal("expected non-nil FP")
}
if fp.FlightRules != "I" || fp.Aircraft != "B738" || fp.AircraftFAA != "B738" || fp.AircraftShort != "B738" {
t.Fatalf("type/rules: %#v", fp)
}
if fp.Departure != "KLAX" || fp.Arrival != "KJFK" || fp.Alternate != "KBOS" {
t.Fatalf("airports: %#v", fp)
}
if fp.DepTime != "1200" {
t.Fatalf("deptime=%q", fp.DepTime)
}
if fp.EnrouteTime != "0240" || fp.FuelTime != "0530" {
t.Fatalf("times enroute=%q fuel=%q", fp.EnrouteTime, fp.FuelTime)
}
if fp.Remarks != "RMK" || fp.Route != "DCT" {
t.Fatalf("remarks/route: %#v", fp)
}
if fp.AssignedTransponder != "4321" || fp.RevisionID != 0 {
t.Fatalf("beacon/rev: %#v", fp)
}
// Partial fields: map what is present
partial := mapInfoSectionToDatafeedFP("V:C172", "")
if partial == nil || partial.FlightRules != "V" || partial.Aircraft != "C172" {
t.Fatalf("partial: %#v", partial)
}
if partial.EnrouteTime != "0000" || partial.FuelTime != "0000" {
t.Fatalf("partial times: %#v", partial)
}
}
func TestDatafeedPilotJSONMarshal_EmbedShadowing(t *testing.T) {
info := "I:B738:450:KLAX:1200:0000:FL350:KJFK:2:5:3:0::remarks:route"
pilot := serviceapi.OnlineUserPilot{
OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{
Callsign: "UAL1",
CID: 100,
},
Altitude: 35000,
PilotRating: 15,
FlightPlan: info,
AssignedBeaconCode: "1200",
}
dp := DatafeedPilot{
OnlineUserPilot: pilot,
Server: "TESTSRV",
MilitaryRating: 0,
QnhIHg: 0,
QnhMb: 0,
FlightPlan: mapInfoSectionToDatafeedFP(pilot.FlightPlan, pilot.AssignedBeaconCode),
}
b, err := json.Marshal(dp)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
// Real pilot_rating from embed (not outer zero).
if pr, ok := m["pilot_rating"].(float64); !ok || pr != 15 {
t.Fatalf("pilot_rating=%v want 15 (json=%s)", m["pilot_rating"], b)
}
// flight_plan must be an object, not a string and not omitted.
fpObj, ok := m["flight_plan"].(map[string]any)
if !ok {
t.Fatalf("flight_plan type %T want object; json=%s", m["flight_plan"], b)
}
if fpObj["departure"] != "KLAX" || fpObj["arrival"] != "KJFK" {
t.Fatalf("flight_plan airports: %#v", fpObj)
}
if fpObj["enroute_time"] != "0205" {
t.Fatalf("enroute_time=%v want 0205", fpObj["enroute_time"])
}
if m["military_rating"].(float64) != 0 || m["qnh_i_hg"].(float64) != 0 || m["qnh_mb"].(float64) != 0 {
t.Fatalf("expected honest zero QNH/military: %s", b)
}
if m["server"] != "TESTSRV" {
t.Fatalf("server=%v", m["server"])
}
}
func TestDatafeedATCJSON_NoOuterTextATISZero(t *testing.T) {
atc := serviceapi.OnlineUserATC{
OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{Callsign: "LAX_TWR"},
Frequency: "118500",
// TextATIS empty → omitempty on embed
}
da := DatafeedATC{OnlineUserATC: atc, Server: "S1"}
b, err := json.Marshal(da)
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if _, has := m["text_atis"]; has {
t.Fatalf("empty text_atis should omit: %s", b)
}
// With embed ATIS present:
atc.TextATIS = []string{"line1"}
da = DatafeedATC{OnlineUserATC: atc, Server: "S1"}
b, err = json.Marshal(da)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
atis, ok := m["text_atis"].([]any)
if !ok || len(atis) != 1 || atis[0] != "line1" {
t.Fatalf("text_atis from embed: %s", b)
}
}

View File

@@ -153,10 +153,14 @@ func TestGenerateDatafeedWithStubOnline(t *testing.T) {
Pilots: []serviceapi.OnlineUserPilot{{
OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{Callsign: "N1", CID: 1},
Altitude: 1000,
PilotRating: 7,
FlightPlan: "I:B738:450:KLAX:1200:0000:FL350:KJFK:2:5:3:0::rmk:DCT",
AssignedBeaconCode: "1200",
}, {
OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{Callsign: "SBX1", CID: 900001},
Altitude: 2000,
Synthetic: true,
PilotRating: 0,
}},
ATC: []serviceapi.OnlineUserATC{{
OnlineUserGeneralData: serviceapi.OnlineUserGeneralData{Callsign: "TWR", CID: 2},
@@ -170,6 +174,32 @@ func TestGenerateDatafeedWithStubOnline(t *testing.T) {
assert.Contains(t, s, "N1")
assert.Contains(t, s, `"synthetic":true`)
assert.NotContains(t, s, `"synthetic":false`)
// Honest datafeed mapping: no fabricated QNH 29.92 / ratings of 1; real pilot_rating + object FP.
ident, _, _, err := env.server.getFsdServerInfo()
require.NoError(t, err)
if ident == "" {
ident = "OPENFSD"
}
var pilots []DatafeedPilot
for _, pilot := range ou.Pilots {
pilots = append(pilots, DatafeedPilot{
OnlineUserPilot: pilot,
Server: ident,
MilitaryRating: 0,
QnhIHg: 0,
QnhMb: 0,
FlightPlan: mapInfoSectionToDatafeedFP(pilot.FlightPlan, pilot.AssignedBeaconCode),
})
}
jb, err := json.Marshal(pilots)
require.NoError(t, err)
js := string(jb)
assert.NotContains(t, js, "29.92")
assert.NotContains(t, js, "1013")
assert.Contains(t, js, `"pilot_rating":7`)
assert.Contains(t, js, `"flight_plan":{`)
assert.Contains(t, js, `"server":"`+ident+`"`)
}
func TestMakeFsdHttpServiceRequest(t *testing.T) {