Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions cmd/skyeye/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/dharmab/skyeye/internal/cli"
"github.com/dharmab/skyeye/internal/conf"
"github.com/dharmab/skyeye/pkg/coalitions"
"github.com/dharmab/skyeye/pkg/encyclopedia"
"github.com/dharmab/skyeye/pkg/locations"
"github.com/dharmab/skyeye/pkg/synthesizer/voices"
"github.com/ggerganov/whisper.cpp/bindings/go/pkg/whisper"
Expand Down Expand Up @@ -80,6 +81,7 @@ var (
exitAfter time.Duration
enableTerrainDetection bool
locationsFile string
aircraftFile string
)

const (
Expand Down Expand Up @@ -162,6 +164,10 @@ func init() {
if err := skyeye.MarkFlagFilename("locations-file", "json", "yaml", "yml"); err != nil {
log.Fatal().Err(err).Msg("failed to mark flag as filename")
}
skyeye.Flags().StringVar(&aircraftFile, "aircraft-file", "", "Path to file containing additional aircraft that extend or override the built-in encyclopedia.")
if err := skyeye.MarkFlagFilename("aircraft-file", "json", "yaml", "yml"); err != nil {
log.Fatal().Err(err).Msg("failed to mark flag as filename")
}

// Tracing
skyeye.Flags().BoolVar(&enableTracing, "enable-tracing", false, "Enable tracing")
Expand Down Expand Up @@ -361,6 +367,22 @@ func loadLocations() []locations.Location {
return locs
}

func loadAircraft() []encyclopedia.Aircraft {
if aircraftFile == "" {
return nil
}
data, err := os.ReadFile(aircraftFile)
if err != nil {
log.Fatal().Err(err).Str("path", aircraftFile).Msg("failed to read aircraft file")
}
aircraft, err := encyclopedia.LoadCustomAircraft(data)
if err != nil {
log.Fatal().Err(err).Str("path", aircraftFile).Msg("failed to load aircraft file")
}
log.Info().Int("count", len(aircraft)).Msg("loaded custom aircraft")
return aircraft
}

func preRun(cmd *cobra.Command, _ []string) error {
if err := initializeConfig(cmd); err != nil {
return fmt.Errorf("failed to initialize config: %w", err)
Expand Down Expand Up @@ -409,6 +431,7 @@ func run(_ *cobra.Command, _ []string) {
recognizerLock := loadLock(recognizerLockPath)
volume := loadVoiceVolume()
locs := loadLocations()
customAircraft := loadAircraft()

config := conf.Configuration{
ACMIFile: acmiFile,
Expand Down Expand Up @@ -453,6 +476,7 @@ func run(_ *cobra.Command, _ []string) {
GRPCAPIKey: grpcAPIKey,
EnableTerrainDetection: enableTerrainDetection,
Locations: locs,
CustomAircraft: customAircraft,
}

log.Info().Msg("starting application")
Expand Down
4 changes: 4 additions & 0 deletions docs/ADMIN.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ To enable this feature, first create a webhook in your Discord server (Server Se

SkyEye includes an optional feature to define custom locations that players can reference in VECTOR TO requests. This can be useful for providing navigation assistance to airbases and other points of interest. See [LOCATIONS.md](LOCATIONS.md) for a guide.

## Custom Aircraft

SkyEye includes an optional feature to extend or override its built-in aircraft encyclopedia. This is useful for supporting community aircraft mods that SkyEye does not recognize out of the box. See [AIRCRAFT.md](AIRCRAFT.md) for a guide.

## Autoscaling (Experimental)

The included `skyeye-scaler` program is an optional autoscaler tool. It monitors a set of frequencies in SRS, and continually sends POST requests to a custom webhook. The webhook URL is defined by setting the `--webhook-url` flag or `SKYEYE_SCALER_WEBHOOK_URL` environment variable.
Expand Down
106 changes: 106 additions & 0 deletions docs/AIRCRAFT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Custom Aircraft

SkyEye ships with an embedded encyclopedia of aircraft data, which the
controller uses for various GCI decisions.

You can extend this encyclopedia to add support for community mods, or new
modules which have not yet been added to SkyEye's embedded data.

## Enabling the Feature

Create a YAML file containing a list of one or more aircraft entries, then set the path to it
in the `aircraft-file` setting in SkyEye's configuration.

## Aircraft Properties

Each entry supports the following properties:

| Property | Required | Description |
| --- | --- | --- |
| `acmi_short_name` | Yes | The aircraft's `ShortName` in [Tacview/ACMI telemetry](https://raia-software-inc.gitbook.io/tacview/technical-documentation/acmi-telemetry-file-format). It must match exactly, including case and punctuation. |
| `tags` | Yes | A list of tags describing the aircraft. See [Tags](#tags). |
| `nato_reporting_name` | No | The NATO reporting name, e.g. `Flanker`, `Fulcrum`. Not all aircraft have one. |
| `nickname` | No | A common nickname, e.g. `Warthog`, `Viper`, `Scooter`. Not all aircraft have one. |
| `official_name` | No | The official name, e.g. `Thunderbolt II`, `Fighting Falcon`, `Skyhawk`. Not all aircraft have one. |
| `platform_designation` | No | The platform designation, e.g. `A-10`, `F-16`, `A-4`. |
| `type_designation` | No | The specific type designation, e.g. `A-10C`, `F-16C`, `A-4E`. |
| `threat_radius_nm` | No | The threat radius in nautical miles. See [Threat radius](#threat-radius). |
| `fuel_provider` | No | The refueling method this aircraft provides as a tanker. See [Refueling](#refueling). |
| `fuel_receiver` | No | The refueling method this aircraft requires to take fuel. See [Refueling](#refueling). |

### Reporting Name

The name SkyEye uses to call out a contact on the radio is its **reporting name**. SkyEye derives it
from the properties above in this order of preference: `nato_reporting_name`, then `nickname`, then
`official_name`, then `platform_designation`. Every aircraft must set at least one of these four
properties so SkyEye has a name to call it.

### Tags

`tags` is a list of one or more of the following values:

- `fixed-wing` — a fixed-wing aircraft.
- `rotary-wing` — a helicopter.
- `fighter` — a fighter armed with air-to-air missiles.
- `attack` — an attack aircraft with self-defense air-to-air missiles.
- `unarmed` — an aircraft with no air-to-air missiles (transports, tankers, AWACS, etc.).

Every aircraft must have **exactly one** of `fixed-wing` or `rotary-wing`. The `fighter`, `attack`,
and `unarmed` tags affect how contacts are grouped and, when you do not set `threat_radius_nm`, the
default threat radius.

### Threat Radius

`threat_radius_nm` is the range, in nautical miles, at which SkyEye considers the aircraft a threat.
This property is optional. If you omit it, SkyEye picks a sensible default from the aircraft's tags.

If you want to set it explicitly, these values are good starting points:

- **15 NM** for aircraft armed with older semi-active radar missiles, or infrared missiles.
- **25 NM** for aircraft armed with newer semi-active missiles, or active radar missiles.
- **35 NM** for fast interceptors and advanced fighters.

### Refueling

`fuel_provider` and `fuel_receiver` describe aerial refueling and drive VECTOR TANKER matching. Each
accepts one of:

- `boom` — flying boom refueling.
- `probe-and-drogue` — probe-and-drogue (basket) refueling.

The two properties apply to different kinds of aircraft, so set only the one relevant to yours:

- **Tanker aircraft:** set `fuel_provider` to the method the tanker dispenses. Leave `fuel_receiver`
unset. This is what marks the aircraft as a tanker for VECTOR TO TANKER commands.
- **Receiver aircraft:** set `fuel_receiver` to the method the aircraft takes fuel with, if any. Leave
`fuel_provider` unset.

When a player asks for a vector to a tanker, SkyEye only sends them to a tanker whose `fuel_provider`
matches their own aircraft's `fuel_receiver`.

## Overriding Built-In Aircraft

If an entry's `acmi_short_name` matches an aircraft already in the built-in encyclopedia, your entry
replaces the built-in one. This lets you customize the data for an aircraft that already ships with SkyEye.

## Example

The example below adds the A-4 Skyhawk community mod. It is an attack aircraft that uses
probe-and-drogue refueling, and its ACMI short name is `A-4E-C`.


```yaml
- acmi_short_name: A-4E-C
tags:
- fixed-wing
- attack
platform_designation: A-4
type_designation: A-4E
official_name: Skyhawk
nickname: Scooter
threat_radius_nm: 15
fuel_receiver: probe-and-drogue
```

With this file loaded, SkyEye recognizes the A-4, calls it a "Scooter", treats it as an attack
aircraft with a 15 NM threat radius, and sends it to probe-and-drogue tankers.
4 changes: 4 additions & 0 deletions internal/application/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/dharmab/skyeye/pkg/commands"
"github.com/dharmab/skyeye/pkg/composer"
"github.com/dharmab/skyeye/pkg/controller"
"github.com/dharmab/skyeye/pkg/encyclopedia"
"github.com/dharmab/skyeye/pkg/parser"
"github.com/dharmab/skyeye/pkg/radar"
"github.com/dharmab/skyeye/pkg/recognizer"
Expand Down Expand Up @@ -178,6 +179,9 @@ func NewApplication(config conf.Configuration) (*Application, error) {
log.Info().Msg("constructing request parser")
requestParser := parser.New(config.Callsign, locationNames, config.EnableTranscriptionLogging)

// Register custom aircraft into the encyclopedia before the radar, its first consumer, is built.
encyclopedia.AddCustomAircraft(config.CustomAircraft)

log.Info().Msg("constructing radar scope")

// When threat monitoring requires SRS, the radar uses the SRS client to restrict the
Expand Down
4 changes: 4 additions & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"time"

"github.com/dharmab/skyeye/pkg/coalitions"
"github.com/dharmab/skyeye/pkg/encyclopedia"
"github.com/dharmab/skyeye/pkg/locations"
"github.com/dharmab/skyeye/pkg/simpleradio"
"github.com/dharmab/skyeye/pkg/synthesizer/voices"
Expand Down Expand Up @@ -102,6 +103,9 @@ type Configuration struct {
ThreatMonitoringRequiresSRS bool
// Locations is a slice of named locations that can be referenced in VECTOR calls.
Locations []locations.Location
// CustomAircraft is a slice of user-provided aircraft entries that extend or override the
// built-in encyclopedia. Registered into the encyclopedia at application startup.
CustomAircraft []encyclopedia.Aircraft
// EnableTracing controls whether to publish traces
EnableTracing bool
// DiscordWebhookID is the ID of the Discord webhook
Expand Down
138 changes: 138 additions & 0 deletions pkg/encyclopedia/custom.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package encyclopedia

import (
"errors"
"fmt"
"strings"

"github.com/dharmab/collections/sets"
"github.com/martinlindhe/unit"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
)

// serializedAircraft is the on-disk representation of an Aircraft. It exists only to translate
// between the human-friendly string schema and the Aircraft type's unexported enum fields; it is
// never exposed outside this package.
type serializedAircraft struct {
ACMIShortName string `yaml:"acmi_short_name"`
Tags []string `yaml:"tags"`
PlatformDesignation string `yaml:"platform_designation"`
TypeDesignation string `yaml:"type_designation"`
NATOReportingName string `yaml:"nato_reporting_name"`
OfficialName string `yaml:"official_name"`
Nickname string `yaml:"nickname"`
ThreatRadiusNM float64 `yaml:"threat_radius_nm"`
FuelProvider string `yaml:"fuel_provider"`
FuelReceiver string `yaml:"fuel_receiver"`
}

// tagsByName maps the tag names accepted in custom aircraft files to their AircraftTag values.
var tagsByName = map[string]AircraftTag{
"fixed-wing": FixedWing,
"rotary-wing": RotaryWing,
"unarmed": Unarmed,
"fighter": Fighter,
"attack": Attack,
}

// refuelingByName maps the refueling method names accepted in custom aircraft files to their
// AirRefuelingMethod values. An empty string or "none" maps to NoAirRefueling.
var refuelingByName = map[string]AirRefuelingMethod{
"": NoAirRefueling,
"none": NoAirRefueling,
"boom": FlyingBoom,
"probe-and-drogue": ProbeAndDrogue,
}

// toAircraft validates the string schema and converts it into an Aircraft.
func (s serializedAircraft) toAircraft() (Aircraft, error) {
if strings.TrimSpace(s.ACMIShortName) == "" {
return Aircraft{}, errors.New("aircraft must have a non-empty acmi_short_name")
}

// SkyEye derives an aircraft's reporting name from these fields, so at least one is required or
// the GCI would have nothing to call the contact.
if strings.TrimSpace(s.NATOReportingName) == "" &&
strings.TrimSpace(s.Nickname) == "" &&
strings.TrimSpace(s.OfficialName) == "" &&
strings.TrimSpace(s.PlatformDesignation) == "" {
return Aircraft{}, fmt.Errorf("aircraft %q must have at least one of nato_reporting_name, nickname, official_name, or platform_designation", s.ACMIShortName)
}

if len(s.Tags) == 0 {
return Aircraft{}, fmt.Errorf("aircraft %q must have at least one tag", s.ACMIShortName)
}
tags := sets.New[AircraftTag]()
wingCount := 0
for _, name := range s.Tags {
tag, ok := tagsByName[strings.ToLower(strings.TrimSpace(name))]
if !ok {
return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized tag %q", s.ACMIShortName, name)
}
if tag == FixedWing || tag == RotaryWing {
wingCount++
}
sets.Add(tags, tag)
}
if wingCount != 1 {
return Aircraft{}, fmt.Errorf("aircraft %q must have exactly one of the tags fixed-wing or rotary-wing", s.ACMIShortName)
}

if s.ThreatRadiusNM < 0 {
return Aircraft{}, fmt.Errorf("aircraft %q has negative threat_radius_nm %v", s.ACMIShortName, s.ThreatRadiusNM)
}

fuelProvider, ok := refuelingByName[strings.ToLower(strings.TrimSpace(s.FuelProvider))]
if !ok {
return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized fuel_provider %q", s.ACMIShortName, s.FuelProvider)
}
fuelReceiver, ok := refuelingByName[strings.ToLower(strings.TrimSpace(s.FuelReceiver))]
if !ok {
return Aircraft{}, fmt.Errorf("aircraft %q has unrecognized fuel_receiver %q", s.ACMIShortName, s.FuelReceiver)
}

return Aircraft{
ACMIShortName: s.ACMIShortName,
tags: tags,
PlatformDesignation: s.PlatformDesignation,
TypeDesignation: s.TypeDesignation,
NATOReportingName: s.NATOReportingName,
OfficialName: s.OfficialName,
Nickname: s.Nickname,
threatRadius: unit.Length(s.ThreatRadiusNM) * unit.NauticalMile,
fuelProvider: fuelProvider,
fuelReceiver: fuelReceiver,
}, nil
}

// LoadCustomAircraft parses custom aircraft data. YAML is a superset of JSON, so the data may be in
// either format. Each entry is validated during conversion.
func LoadCustomAircraft(data []byte) ([]Aircraft, error) {
var serialized []serializedAircraft
if err := yaml.Unmarshal(data, &serialized); err != nil {
return nil, fmt.Errorf("failed to parse custom aircraft file: %w", err)
}
aircraft := make([]Aircraft, 0, len(serialized))
for _, s := range serialized {
a, err := s.toAircraft()
if err != nil {
return nil, err
}
aircraft = append(aircraft, a)
}
return aircraft, nil
}

// AddCustomAircraft registers custom aircraft into the encyclopedia, keyed by ACMI short name.
// An entry whose ACMI short name matches a built-in or previously added entry overrides it.
func AddCustomAircraft(aircraft []Aircraft) {
for _, data := range aircraft {
event := log.Info().Str("aircraft", data.ACMIShortName)
if _, exists := aircraftDataLUT[data.ACMIShortName]; exists {
event = event.Bool("override", true)
}
aircraftDataLUT[data.ACMIShortName] = data
event.Msg("loaded custom aircraft into encyclopedia")
}
}
Loading