Let the operator edit the station config in the window instead of hand-editing the file and relaunching.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-11 19:19:05 +02:00
co-authored by Cursor
parent e642b24be0
commit 775d1b5c82
12 changed files with 1567 additions and 179 deletions
+68 -23
View File
@@ -27,12 +27,25 @@ type State struct {
Protocol string `json:"protocol"`
Talker string `json:"talker"`
Transmitting bool `json:"transmitting"`
NeedsConfig bool `json:"needsConfig"`
ConfigOpen bool `json:"configOpen"`
}
// Station is the seam tests and the window use.
type Station struct {
cfg loaded
clock Clock
cfg loaded
clock Clock
path string
loaded bool
configOpen bool
configErr string
openBlocked string
form ConfigForm
epoch uint64
kick bool
conn net.Conn
cancelDial context.CancelFunc
apply chan struct{}
mu sync.Mutex
state State
@@ -76,38 +89,55 @@ type Station struct {
talkStart time.Time
}
// New loads the config at opt.ConfigPath. A missing file is created mode 0600
// and New returns an error so the caller can exit before opening a window.
// New loads the config at opt.ConfigPath. A missing or invalid file does not
// stop the station: the Config window opens and the file is left uncreated
// until Save. New returns an error when the path cannot be prepared, such as
// an unknown home directory.
func New(opt Options) (*Station, error) {
cfg, err := loadConfig(opt.ConfigPath)
if err != nil {
return nil, err
}
clock := opt.Clock
if clock == nil {
clock = wallClock{}
}
out, err := loadConfig(opt.ConfigPath)
if err != nil {
return nil, err
}
detail := "Idle"
if cfg.certFatal != "" {
detail = cfg.certFatal
} else if cfg.logNotice != "" {
detail = cfg.logNotice
if out.detail != "" {
detail = out.detail
}
call, tg := out.cfg.callsign, out.cfg.talkGroup
if !out.loaded && out.cfg.callsign == "" {
call = out.form.Callsign
tg = out.form.TalkGroup
}
errText := ""
if !out.loaded {
errText = out.detail
}
s := &Station{
cfg: cfg,
clock: clock,
wake: make(chan struct{}, 1),
logPath: cfg.logPath,
logNotice: cfg.logNotice,
fatal: cfg.certFatal,
cfg: out.cfg,
clock: clock,
path: opt.ConfigPath,
loaded: out.loaded,
configOpen: !out.loaded,
configErr: errText,
form: out.form,
wake: make(chan struct{}, 1),
apply: make(chan struct{}, 1),
logPath: out.cfg.logPath,
logNotice: out.cfg.logNotice,
fatal: out.cfg.certFatal,
state: State{
Connection: "Disconnected",
Detail: detail,
Callsign: cfg.callsign,
TalkGroup: cfg.talkGroup,
Connection: "Disconnected",
Detail: detail,
Callsign: call,
TalkGroup: tg,
NeedsConfig: !out.loaded,
ConfigOpen: !out.loaded,
},
}
if cfg.enableLogging {
if out.loaded && out.cfg.enableLogging {
s.openLog()
}
return s, nil
@@ -120,6 +150,18 @@ func (s *Station) State() State {
return s.state
}
// Config returns the Config window's form and whether it is open.
func (s *Station) Config() ConfigState {
s.mu.Lock()
defer s.mu.Unlock()
return ConfigState{
Open: s.configOpen,
NeedsConfig: !s.loaded,
Error: s.configErr,
Form: s.form,
}
}
// Wait blocks until pred is true or ctx ends.
func (s *Station) Wait(ctx context.Context, pred func(State) bool) (State, error) {
s.mu.Lock()
@@ -169,6 +211,9 @@ func (s *Station) afterHold(was bool) {
if was && !s.transmitting() {
s.endTransmit()
}
if !s.buttonHeld && !s.spaceHeld {
s.openBlocked = ""
}
s.publish()
}