feat: Added Net control
This commit is contained in:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user