Skip to content
Open
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
43 changes: 41 additions & 2 deletions conf/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,27 @@ import (

// Conf is the app config
type Conf struct {
JournalsFolder string `yaml:"journalsfolder"`
JournalsFolder string `yaml:"ed_journal_folder"`
BindsFile string `yaml:"ed_binds_file"`
Pages map[string]bool `yaml:"pages"`
CheckForUpdates bool `yaml:"checkforupdates"`
Loglevel string `json:"loglevel" yaml:"loglevel"`
Lights LightConf `yaml:"lights"`
}

type LightConf struct {
Enabled bool `yaml:"enabled"`
FlashInterval int `yaml:"flash_interval"`
Default LightState `yaml:"default"`
HardpointsDeployed LightState `yaml:"hardpoints_deployed"`
NightVision LightState `yaml:"night_vision"`
}

type LightState struct {
Inactive []string `yaml:"inactive"`
Active []string `yaml:"active"`
Blocked []string `yaml:"blocked"`
Alert []string `yaml:"alert"`
}

// LoadOrCreateConf loads the config from the given path, or creates a default one if missing.
Expand All @@ -25,7 +42,10 @@ func LoadOrCreateConf(confPath string) Conf {
// If config does not exist, create it with defaults
if _, err := os.Stat(confPath); os.IsNotExist(err) {
log.Warn().Str("path", confPath).Msg("Config file not found, creating default config.")
defaultYAML := `journalsfolder: "%USERPROFILE%\\Saved Games\\Frontier Developments\\Elite Dangerous"
defaultYAML := `# Automatically generated configuration. Leave 'ed_journal_folder' blank to auto-detect.
# Use standard %LOCALAPPDATA% or %HOMEPATH% windows variables to resolve locations dynamically.
ed_journal_folder: "%USERPROFILE%\\Saved Games\\Frontier Developments\\Elite Dangerous"
ed_binds_file: "%LOCALAPPDATA%\\Frontier Developments\\Elite Dangerous\\Options\\Bindings\\Custom.4.2.binds"

pages:
destination: true
Expand All @@ -34,6 +54,25 @@ pages:

checkforupdates: true
loglevel: info

lights:
enabled: true
flash_interval: 500
default:
inactive: [on, green]
active: [on, amber]
blocked: [off, red]
alert: [flash, amber-flash]
hardpoints_deployed:
inactive: [on, red]
active: [on, amber]
blocked: [off, off]
alert: [flash, amber-flash]
night_vision:
inactive: [off, off]
active: [on, green]
blocked: [off, off]
alert: [flash, green-flash]
`
if err := os.MkdirAll(filepath.Dir(confPath), 0755); err != nil {
log.Fatal().Err(err).Msg("Failed to create config directory")
Expand Down
199 changes: 199 additions & 0 deletions edreader/binds.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package edreader

import (
"encoding/xml"
"os"
"path/filepath"

"github.com/rs/zerolog/log"
"golang.org/x/sys/windows/registry"
)

type Light uint32

const (
LightFire Light = 1
LightFireA Light = 2
LightFireB Light = 3
LightFireD Light = 4
LightFireE Light = 5
LightPoV2 Light = 6
LightT1T2 Light = 7
LightT3T4 Light = 8
LightT5T6 Light = 9
LightThrottle Light = 10
LightClutch Light = 11
)

// BindingInput represents a single binding attribute in XML
type BindingInput struct {
Device string `xml:"Device,attr"`
Key string `xml:"Key,attr"`
}

// ControlBinding represents a primary, secondary, and standard binding
type ControlBinding struct {
Primary BindingInput `xml:"Primary"`
Secondary BindingInput `xml:"Secondary"`
Binding BindingInput `xml:"Binding"`
}

// ControlBindings models the Custom.4.2.binds XML root
type ControlBindings struct {
XMLName xml.Name `xml:"Root"`
ExternalLights ControlBinding `xml:"ShipSpotLightToggle"`
CargoScoop ControlBinding `xml:"ToggleCargoScoop"`
LandingGear ControlBinding `xml:"LandingGearToggle"`
Boost ControlBinding `xml:"UseBoostJuice"`
HyperSuperCombination ControlBinding `xml:"HyperSuperCombination"`
Supercruise ControlBinding `xml:"Supercruise"`
Hyperspace ControlBinding `xml:"Hyperspace"`
SilentRunning ControlBinding `xml:"ToggleButtonUpInput"`
HeatSink ControlBinding `xml:"DeployHeatSink"`
Hardpoints ControlBinding `xml:"DeployHardpointToggle"`
Throttle ControlBinding `xml:"ThrottleAxis"`
NightVision ControlBinding `xml:"NightVisionToggle"`
}

var currentBindings ControlBindings
var bindsLoaded bool

// LoadBindings loads the binds from the user's Local AppData
func LoadBindings(bindsPath string) {
exp, _ := registry.ExpandString(bindsPath)

if exp == "" {
// Use default
localAppData := os.Getenv("LOCALAPPDATA")
exp = filepath.Join(localAppData, `Frontier Developments\Elite Dangerous\Options\Bindings\Custom.4.2.binds`)
}

data, err := os.ReadFile(exp)
if err != nil {
log.Warn().Err(err).Str("path", exp).Msg("Could not read bindings file, LED buttons may not map correctly to custom profiles")
bindsLoaded = false
return
}

err = xml.Unmarshal(data, &currentBindings)
if err != nil {
log.Warn().Err(err).Msg("Failed to parse Elite Dangerous Bindings XML")
bindsLoaded = false
return
}

log.Info().Str("path", exp).Msg("Loaded game control bindings")
bindsLoaded = true
}

// GetLightInputsForAttribute returns a list of Light LEDs mapped to a game attribute.
func GetLightInputsForAttribute(attr Attribute) []Light {
if !bindsLoaded {
// Fallback to defaults
return GetDefaultLightInputsForAttribute(attr)
}

var binding ControlBinding

switch attr {
case AttrBoost:
binding = currentBindings.Boost
case AttrCargoScoop:
binding = currentBindings.CargoScoop
case AttrExternalLights:
binding = currentBindings.ExternalLights
case AttrFrameShiftDrive:
// FSD incorporates 3 separate binds in Elite
return append(
append(getLightFromBinding(currentBindings.Hyperspace), getLightFromBinding(currentBindings.Supercruise)...),
getLightFromBinding(currentBindings.HyperSuperCombination)...,
)
case AttrHardpoints:
binding = currentBindings.Hardpoints
case AttrHeatSink:
binding = currentBindings.HeatSink
case AttrLandingGear:
binding = currentBindings.LandingGear
case AttrNightVision:
binding = currentBindings.NightVision
case AttrSilentRunning:
binding = currentBindings.SilentRunning
case AttrThrottle:
binding = currentBindings.Throttle
default:
return nil
}

return getLightFromBinding(binding)
}

func getLightFromBinding(binding ControlBinding) []Light {
var lights []Light

inputs := []BindingInput{binding.Primary, binding.Secondary, binding.Binding}
for _, in := range inputs {
if l := mapX52InputToLight(in.Device, in.Key); l != 0 {
lights = append(lights, l)
}
}

return lights
}

func mapX52InputToLight(device string, key string) Light {
if device != "SaitekX52Pro" && device != "06A30762" {
return 0
}

switch key {
case "Joy_31":
return LightClutch
case "Joy_2":
return LightFire
case "Joy_3":
return LightFireA
case "Joy_4":
return LightFireB
case "Joy_7":
return LightFireD
case "Joy_8":
return LightFireE
case "Joy_22", "Joy_23", "Joy_21", "Joy_20":
return LightPoV2
case "Joy_9", "Joy_10":
return LightT1T2
case "Joy_11", "Joy_12":
return LightT3T4
case "Joy_13", "Joy_14":
return LightT5T6
case "Joy_ZAxis":
return LightThrottle
}
return 0
}

func GetDefaultLightInputsForAttribute(attr Attribute) []Light {
switch attr {
case AttrBoost:
return []Light{LightFireD}
case AttrCargoScoop:
return []Light{LightT1T2}
case AttrExternalLights:
return []Light{LightT3T4}
case AttrFrameShiftDrive:
return []Light{LightT1T2, LightT3T4, LightT5T6}
case AttrHardpoints:
return []Light{LightFireB}
case AttrHeatSink:
return []Light{LightT5T6}
case AttrLandingGear:
return []Light{LightT1T2, LightT3T4}
case AttrNightVision:
return []Light{LightPoV2}
case AttrSilentRunning:
return []Light{LightFireA}
case AttrThrottle:
return []Light{LightFireE, LightThrottle}
}
return nil
}
9 changes: 9 additions & 0 deletions edreader/edreader.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ func Start(cfg conf.Conf) {
// Set the first enabled page key for splash logic
SetFirstEnabledPageKey(cfg.Pages)

SetLightConfig(cfg)
updateMFD(journalfolder, cfg)

var err error
Expand Down Expand Up @@ -131,6 +132,13 @@ func updateMFD(journalfolder string, cfg conf.Conf) {
Mfd = mfd.Display{Pages: enabledPages}
MfdLock.Unlock()

if cfg.Lights.Enabled {
stateMu.RLock()
state := lastJournalState
stateMu.RUnlock()
UpdateLights(state)
}

swapMfd()
}

Expand All @@ -142,6 +150,7 @@ func Stop() {
if watcher != nil {
watcher.Close()
}
StopLights()
}

func findJournalFile(folder string) string {
Expand Down
54 changes: 54 additions & 0 deletions edreader/journal.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,28 @@ type Journalstate struct {
LastFSDTargetAddress int64
ShowSplashScreen bool // NEW: splash flag
SplashScreenStartTime time.Time // NEW: splash start time
ShipFlags uint64 // NEW: ship status flags
ShipFlags2 uint64 // NEW: ship status flags 2
LegalState string // NEW: legal state
}

const (
FlagLandingGearDeployed uint64 = 1 << 2
FlagSupercruise uint64 = 1 << 4
FlagHardpointsDeployed uint64 = 1 << 6
FlagLightsOn uint64 = 1 << 8
FlagCargoScoopDeployed uint64 = 1 << 9
FlagSilentRunning uint64 = 1 << 10
FlagMassLocked uint64 = 1 << 16
FlagFsdCharging uint64 = 1 << 17
FlagFsdCooldown uint64 = 1 << 18
FlagOverheating uint64 = 1 << 20
FlagNightVision uint64 = 1 << 28
FlagFsdJump uint64 = 1 << 30
FlagIsSpeeding uint64 = 1 << (32 + 1) // Virtual flag for legal state speeding
FlagDocking uint64 = 1 << (32 + 2) // Virtual flag for docking state
)

// Location indicates the players current location in the game
type Location struct {
Type LocationType
Expand Down Expand Up @@ -240,7 +260,9 @@ func handleJournalFile(filename string) {
linesRead++
}
if linesRead > 0 {
stateMu.Lock()
lastJournalState = state // Only update if new lines were read
stateMu.Unlock()
log.Debug().Int("linesRead", linesRead).Str("filename", logging.CleanPath(filename)).Msg("Processed new journal lines")
}

Expand Down Expand Up @@ -319,6 +341,23 @@ func handleStatusFile(filename string) {
// After updating Destination, check for arrival
checkArrival()

stateMu.Lock()
flags, _ := jsonparser.GetInt(data, "Flags")
flags2, _ := jsonparser.GetInt(data, "Flags2")
// Preserve virtual flags (derived from journal events, not present in Status.json)
virtualFlags := lastJournalState.ShipFlags & (FlagIsSpeeding | FlagDocking)
lastJournalState.ShipFlags = uint64(flags) | virtualFlags
lastJournalState.ShipFlags2 = uint64(flags2)

legalState, _ := jsonparser.GetString(data, "LegalState")
lastJournalState.LegalState = legalState
if legalState == "Speeding" {
lastJournalState.ShipFlags |= FlagIsSpeeding
} else {
lastJournalState.ShipFlags &= ^FlagIsSpeeding
}
stateMu.Unlock()

checkSplashScreen()
}

Expand Down Expand Up @@ -361,11 +400,25 @@ func ParseJournalLine(line []byte, state *Journalstate) {
state.ArrivedAtFSDTargetTime = time.Time{}
case "ReceiveText":
eReceiveText(p)
case "DockingGranted":
eDockingGranted(state)
case "Docked":
eDocked(p, state)
case "DockingCancelled", "DockingTimeout":
eDockingCancelled(state)
case "FSSDiscoveryScan":
// No specific state change for FSSDiscoveryScan in this context
}
}

func eDockingGranted(state *Journalstate) {
state.ShipFlags |= FlagDocking
}

func eDockingCancelled(state *Journalstate) {
state.ShipFlags &= ^FlagDocking
}

func eLocation(p parser, state *Journalstate) {
// clear current location completely
state.Type = LocationSystem
Expand Down Expand Up @@ -486,6 +539,7 @@ func eDocked(p parser, state *Journalstate) {
systemAddress, _ := p.getInt("SystemAddress")
systemName, _ := p.getString("StarSystem")

state.ShipFlags &= ^FlagDocking
state.Type = LocationDocked
state.Location.Body = stationName
state.Location.BodyID = 0
Expand Down
Loading