feat: Added Net control

This commit is contained in:
2026-06-22 23:40:25 +02:00
parent 81c60628c6
commit 8b831145ad
8 changed files with 1198 additions and 66 deletions
+330 -62
View File
@@ -18,6 +18,7 @@ import (
"time"
"hamlog/internal/adif"
"hamlog/internal/antgenius"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/award"
@@ -25,23 +26,23 @@ import (
"hamlog/internal/backup"
"hamlog/internal/cat"
"hamlog/internal/clublog"
"hamlog/internal/cwdecode"
"hamlog/internal/cluster"
"hamlog/internal/cwdecode"
"hamlog/internal/db"
"hamlog/internal/dxcc"
"hamlog/internal/email"
"hamlog/internal/extsvc"
"hamlog/internal/integrations/udp"
"hamlog/internal/lookup"
"hamlog/internal/netctl"
"hamlog/internal/operating"
"hamlog/internal/pota"
"hamlog/internal/powergenius"
"hamlog/internal/profile"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/rotator/pst"
"hamlog/internal/settings"
"hamlog/internal/antgenius"
"hamlog/internal/powergenius"
"hamlog/internal/ultrabeam"
"hamlog/internal/winkeyer"
@@ -223,8 +224,8 @@ const (
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
@@ -374,57 +375,65 @@ type LookupSettings struct {
// App is the application context bound to the Wails runtime.
type App struct {
ctx context.Context
db *sql.DB
qso *qso.Repo
settings *settings.Store
profiles *profile.Repo
lookup *lookup.Manager
cache *lookup.Cache
cat *cat.Manager
dxcc *dxcc.Manager
cluster *cluster.Manager
pota *pota.Cache
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
liveBand string
liveMode string
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
ctx context.Context
db *sql.DB
qso *qso.Repo
settings *settings.Store
profiles *profile.Repo
lookup *lookup.Manager
cache *lookup.Cache
cat *cat.Manager
dxcc *dxcc.Manager
cluster *cluster.Manager
pota *pota.Cache
awardRefs *awardref.Repo
qslTemplates *qslcard.Repo
operating *operating.Repo
udp *udp.Manager
udpRepo *udp.Repo
extsvc *extsvc.Manager
winkeyer *winkeyer.Manager
clublog *clublog.Manager
ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
// NET Control: persistent net definitions/rosters (global JSON) + the live
// session (in-memory only — active stations currently in QSO).
netStore *netctl.Store
netMu sync.Mutex
netOpenID string // id of the currently open net ("" = none)
netActive []*netActiveEntry // stations on the air right now, in check-in order
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
pttMu sync.Mutex
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
liveBand string
liveMode string
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
// shuttingDown gates beforeClose re-entry: the first user attempt to
// close fires shutdown tasks (backup, future LoTW upload, ...) while
@@ -827,6 +836,13 @@ func (a *App) startup(ctx context.Context) {
a.qsoRec = audio.NewRecorder()
a.startQSORecorderIfEnabled()
// NET Control store (global JSON, shared across logbooks).
if ns, err := netctl.Open(filepath.Join(a.dataDir, "nets.json")); err != nil {
applog.Printf("netctl: open failed: %v", err)
} else {
a.netStore = ns
}
// Ultrabeam antenna: connect in the background if enabled.
a.startUltrabeam()
// Antenna Genius switch: connect in the background if enabled.
@@ -1055,7 +1071,6 @@ func userDataDir() (string, error) {
return filepath.Join(filepath.Dir(exe), "data"), nil
}
// fileExists reports whether path exists and is a regular file.
func fileExists(path string) bool {
fi, err := os.Stat(path)
@@ -1114,8 +1129,8 @@ func copyFileData(src, dst string) error {
// (MySQL). The MySQL connection lives here — not in the settings table — for
// the same reason: we need it to choose and open the backend at startup.
type dbPointer struct {
DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"`
DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"`
}
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
@@ -1181,9 +1196,9 @@ type MySQLSettings struct {
// the Settings UI can confirm the shared MySQL connection (or explain a
// fallback to SQLite when the configured server was unreachable).
type DBBackendStatus struct {
Active string `json:"active"` // "sqlite" | "mysql"
Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite
Error string `json:"error"` // the MySQL open error, when Fallback is true
Active string `json:"active"` // "sqlite" | "mysql"
Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite
Error string `json:"error"` // the MySQL open error, when Fallback is true
}
// GetDBBackendStatus returns the active backend and any MySQL fallback error.
@@ -4260,6 +4275,259 @@ func (a *App) QSOAudioCancel() {
// RestartQSORecorder applies new audio settings to the running recorder.
func (a *App) RestartQSORecorder() { a.startQSORecorderIfEnabled() }
// ── NET Control ────────────────────────────────────────────────────────────
//
// A NET is a named net with a station roster (persisted globally in nets.json).
// Opening a net starts an in-memory live session: stations moved "on the air"
// (NetActivate) accumulate a time_on; moving one back off (NetDeactivate) logs
// the QSO into the active logbook with live CAT freq/mode and removes it from
// the session. The session is RAM-only — closing the app mid-net drops any
// active stations that were never logged.
// netActiveEntry is one station currently on the air in the open net.
type netActiveEntry struct {
Callsign string `json:"callsign"`
Name string `json:"name"`
QTH string `json:"qth"`
Country string `json:"country"`
RSTSent string `json:"rst_sent"`
RSTRcvd string `json:"rst_rcvd"`
Comment string `json:"comment"`
TimeOn time.Time `json:"time_on"`
}
// NetList returns all nets (with rosters), ordered by name.
func (a *App) NetList() []netctl.Net {
if a.netStore == nil {
return nil
}
return a.netStore.List()
}
// NetCreate adds a new named net (defaults 59/59).
func (a *App) NetCreate(name string) (netctl.Net, error) {
if a.netStore == nil {
return netctl.Net{}, fmt.Errorf("net store unavailable")
}
return a.netStore.Create(name)
}
// NetRename renames a net.
func (a *App) NetRename(id, name string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.Rename(id, name)
}
// NetSetDefaults updates a net's default report/comment values.
func (a *App) NetSetDefaults(id, rstSent, rstRcvd, comment string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.SetDefaults(id, rstSent, rstRcvd, comment)
}
// NetDelete removes a net and its roster (closing it first if it's open).
func (a *App) NetDelete(id string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
a.netMu.Lock()
if a.netOpenID == id {
a.netOpenID, a.netActive = "", nil
}
a.netMu.Unlock()
return a.netStore.Delete(id)
}
// NetRoster returns a net's roster, sorted by callsign.
func (a *App) NetRoster(id string) ([]netctl.Station, error) {
if a.netStore == nil {
return nil, fmt.Errorf("net store unavailable")
}
return a.netStore.Roster(id)
}
// NetRosterUpsert adds or updates a roster station.
func (a *App) NetRosterUpsert(id string, s netctl.Station) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.RosterUpsert(id, s)
}
// NetRosterRemove deletes a callsign from a net's roster.
func (a *App) NetRosterRemove(id, callsign string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
return a.netStore.RosterRemove(id, callsign)
}
// NetLookup resolves a callsign via the configured provider (QRZ) so the
// Add-contact dialog can pre-fill name/QTH/country/zones. Best-effort — returns
// just the callsign if no provider or no match.
func (a *App) NetLookup(callsign string) netctl.Station {
st := netctl.Station{Callsign: strings.ToUpper(strings.TrimSpace(callsign))}
if a.lookup == nil || st.Callsign == "" {
return st
}
if lr, err := a.lookup.Lookup(a.ctx, st.Callsign); err == nil {
st.Name, st.QTH, st.Country = lr.Name, lr.QTH, lr.Country
st.DXCC, st.CQ, st.ITU = lr.DXCC, lr.CQZ, lr.ITUZ
}
return st
}
// NetOpen starts a live session for the given net (clears any prior session).
func (a *App) NetOpen(id string) error {
if a.netStore == nil {
return fmt.Errorf("net store unavailable")
}
if _, ok := a.netStore.Get(id); !ok {
return fmt.Errorf("net not found")
}
a.netMu.Lock()
a.netOpenID, a.netActive = id, nil
a.netMu.Unlock()
return nil
}
// NetClose ends the live session (does NOT log remaining actives — the operator
// moves each station back to the roster side to log it before closing).
func (a *App) NetClose() {
a.netMu.Lock()
a.netOpenID, a.netActive = "", nil
a.netMu.Unlock()
}
// NetOpenID returns the id of the currently open net ("" = none).
func (a *App) NetOpenID() string {
a.netMu.Lock()
defer a.netMu.Unlock()
return a.netOpenID
}
// NetActiveList returns the stations currently on the air, in check-in order.
func (a *App) NetActiveList() []netActiveEntry {
a.netMu.Lock()
defer a.netMu.Unlock()
out := make([]netActiveEntry, len(a.netActive))
for i, e := range a.netActive {
out[i] = *e
}
return out
}
// NetActivate puts a station on the air (records time_on, seeds defaults from
// the net + roster). No-op if already active. The net must be open.
func (a *App) NetActivate(callsign string) (netActiveEntry, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
if call == "" {
return netActiveEntry{}, fmt.Errorf("callsign required")
}
a.netMu.Lock()
defer a.netMu.Unlock()
if a.netOpenID == "" {
return netActiveEntry{}, fmt.Errorf("no net open")
}
for _, e := range a.netActive {
if e.Callsign == call {
return *e, nil // already on the air
}
}
e := &netActiveEntry{Callsign: call, TimeOn: time.Now().UTC()}
if net, ok := a.netStore.Get(a.netOpenID); ok {
e.RSTSent, e.RSTRcvd, e.Comment = net.DefaultRSTSent, net.DefaultRSTRcvd, net.DefaultComment
for _, st := range net.Stations {
if strings.EqualFold(st.Callsign, call) {
e.Name, e.QTH, e.Country = st.Name, st.QTH, st.Country
break
}
}
}
if e.RSTSent == "" {
e.RSTSent = "59"
}
if e.RSTRcvd == "" {
e.RSTRcvd = "59"
}
a.netActive = append(a.netActive, e)
return *e, nil
}
// NetUpdateActive edits the live fields (report/QTH/name/comment) of a station
// already on the air. TimeOn is preserved.
func (a *App) NetUpdateActive(e netActiveEntry) error {
call := strings.ToUpper(strings.TrimSpace(e.Callsign))
a.netMu.Lock()
defer a.netMu.Unlock()
for _, cur := range a.netActive {
if cur.Callsign == call {
cur.Name, cur.QTH, cur.Country = e.Name, e.QTH, e.Country
cur.RSTSent, cur.RSTRcvd, cur.Comment = e.RSTSent, e.RSTRcvd, e.Comment
return nil
}
}
return fmt.Errorf("station not active")
}
// NetDeactivate ends a station's QSO: it logs the contact to the active logbook
// (live CAT freq/mode, time_on→now) and removes it from the session. Returns
// the new QSO id.
func (a *App) NetDeactivate(callsign string) (int64, error) {
call := strings.ToUpper(strings.TrimSpace(callsign))
a.netMu.Lock()
var entry *netActiveEntry
idx := -1
for i, e := range a.netActive {
if e.Callsign == call {
entry, idx = e, i
break
}
}
if entry == nil {
a.netMu.Unlock()
return 0, fmt.Errorf("station not active")
}
a.netActive = append(a.netActive[:idx], a.netActive[idx+1:]...)
a.netMu.Unlock()
// Frequency/mode come live from the rig; fall back to the last UI-reported
// values when CAT is off.
var st cat.RigState
if a.cat != nil {
st = a.cat.State()
}
freq, band, mode := st.FreqHz, st.Band, st.Mode
if freq == 0 {
a.liveActMu.Lock()
freq, band, mode = a.liveFreqHz, a.liveBand, a.liveMode
a.liveActMu.Unlock()
}
if band == "" && freq > 0 {
band = bandForHz(freq)
}
q := qso.QSO{
Callsign: call,
QSODate: entry.TimeOn,
QSODateOff: time.Now().UTC(),
Band: band,
Mode: mode,
RSTSent: entry.RSTSent,
RSTRcvd: entry.RSTRcvd,
Name: entry.Name,
QTH: entry.QTH,
Comment: entry.Comment,
}
if freq > 0 {
f := freq
q.FreqHz = &f
}
return a.AddQSO(q)
}
// ── E-mail / SMTP (send QSO recordings) ───────────────────────────────
const (