Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 | ||
|
|
05d64024ef | ||
|
|
cb27aa5ebf | ||
|
|
aefb984974 |
@@ -30,6 +30,7 @@ import (
|
|||||||
"hamlog/internal/backup"
|
"hamlog/internal/backup"
|
||||||
"hamlog/internal/cabrillo"
|
"hamlog/internal/cabrillo"
|
||||||
"hamlog/internal/cat"
|
"hamlog/internal/cat"
|
||||||
|
"hamlog/internal/catemu"
|
||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
"hamlog/internal/contest"
|
"hamlog/internal/contest"
|
||||||
@@ -279,6 +280,14 @@ const (
|
|||||||
keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
|
keyExtEQSLAutoUpload = "extsvc.eqsl.auto_upload"
|
||||||
keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"
|
keyExtEQSLUploadMode = "extsvc.eqsl.upload_mode"
|
||||||
|
|
||||||
|
// Cloudlog and its fork Wavelog are self-hosted, so the instance URL is part
|
||||||
|
// of the configuration (there is no fixed endpoint).
|
||||||
|
keyExtCloudlogURL = "extsvc.cloudlog.url"
|
||||||
|
keyExtCloudlogAPIKey = "extsvc.cloudlog.api_key"
|
||||||
|
keyExtCloudlogStationID = "extsvc.cloudlog.station_id"
|
||||||
|
keyExtCloudlogAutoUpload = "extsvc.cloudlog.auto_upload"
|
||||||
|
keyExtCloudlogUploadMode = "extsvc.cloudlog.upload_mode"
|
||||||
|
|
||||||
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
keyExtPotaToken = "extsvc.pota.token" // pota.app session token for hunter-log sync
|
||||||
|
|
||||||
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
keyExtLoTWTQSLPath = "extsvc.lotw.tqsl_path"
|
||||||
@@ -909,6 +918,10 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||||
}
|
}
|
||||||
a.emitRadioUDP(s)
|
a.emitRadioUDP(s)
|
||||||
|
// Feed the frequency to any amplifier we are pretending to be a radio
|
||||||
|
// for. Just two atomic stores per amp — the reply itself is built when
|
||||||
|
// the amp polls, so a fast-tuning VFO costs nothing here.
|
||||||
|
a.feedAmpBandFollow(s)
|
||||||
// Drive station relays by the current frequency/band (PstRotator-style
|
// Drive station relays by the current frequency/band (PstRotator-style
|
||||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||||
// feature is off; when on, run off this callback so a slow relay board never
|
// feature is off; when on, run off this callback so a slow relay board never
|
||||||
@@ -1504,6 +1517,74 @@ type dbPointer struct {
|
|||||||
|
|
||||||
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
|
||||||
|
|
||||||
|
// ── Portable paths ─────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// OpsLog is meant to be carried on a stick or copied between machines: the exe,
|
||||||
|
// its data folder and the databases travel together. Storing "C:\OpsLog\data\
|
||||||
|
// logbook.db" broke that — dropped into D:\OpsLog on another PC, the pointer
|
||||||
|
// still named C:, and the app either lost the database or (worse) silently
|
||||||
|
// created a NEW empty one at a C: path that happened to be creatable.
|
||||||
|
//
|
||||||
|
// So a path INSIDE the application folder is stored relative to it, and any
|
||||||
|
// stored path is resolved against the CURRENT location at read time. Absolute
|
||||||
|
// paths outside the folder (a deliberate choice — a synced folder, another
|
||||||
|
// drive) keep working exactly as before: they are only re-rooted if they have
|
||||||
|
// gone missing AND the same file exists in this install's data folder.
|
||||||
|
|
||||||
|
// appDir is the folder the running exe lives in — the anchor for relative paths.
|
||||||
|
func appDir() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Dir(exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// portablePath prepares a path for STORAGE: relative to the app folder when it
|
||||||
|
// sits inside it, unchanged otherwise. Forward slashes so the value survives a
|
||||||
|
// round trip through a folder copied between machines.
|
||||||
|
func portablePath(p string) string {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
base := appDir()
|
||||||
|
if p == "" || base == "" || !filepath.IsAbs(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(base, p)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
return p // outside the app folder — the user meant that exact place
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePath turns a stored path back into an absolute one for THIS install.
|
||||||
|
// dataDir is where the app's own data lives, used for the re-rooting rescue.
|
||||||
|
func resolvePath(dataDir, p string) string {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !filepath.IsAbs(p) {
|
||||||
|
if base := appDir(); base != "" {
|
||||||
|
return filepath.Join(base, filepath.FromSlash(p))
|
||||||
|
}
|
||||||
|
return filepath.FromSlash(p)
|
||||||
|
}
|
||||||
|
if fileExists(p) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
// An absolute path from another machine. Rescue it ONLY if a file of the same
|
||||||
|
// name is sitting in this install's data folder — that is the copied-folder
|
||||||
|
// case. Anything else is left alone so a genuinely-missing database is
|
||||||
|
// reported rather than silently replaced by an unrelated file.
|
||||||
|
if dataDir != "" {
|
||||||
|
if cand := filepath.Join(dataDir, filepath.Base(p)); fileExists(cand) {
|
||||||
|
applog.Printf("path: %q not found — using %q from this install (folder moved?)", p, cand)
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// ── Window geometry (window.json) ──────────────────────────────────────
|
// ── Window geometry (window.json) ──────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Remembered across restarts so the window reopens where and how you left it.
|
// Remembered across restarts so the window reopens where and how you left it.
|
||||||
@@ -1623,12 +1704,16 @@ func readBootstrap(dataDir string) dbPointer {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(b, &c)
|
_ = json.Unmarshal(b, &c)
|
||||||
c.DBPath = strings.TrimSpace(c.DBPath)
|
// Stored relative when it lives inside the app folder, so the pointer follows
|
||||||
|
// the folder from C:OpsLog to D:OpsLog or to a stick.
|
||||||
|
c.DBPath = resolvePath(dataDir, c.DBPath)
|
||||||
|
c.DeletePending = resolvePath(dataDir, c.DeletePending)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeBootstrap(dataDir string, c dbPointer) error {
|
func writeBootstrap(dataDir string, c dbPointer) error {
|
||||||
c.DBPath = strings.TrimSpace(c.DBPath)
|
c.DBPath = portablePath(c.DBPath)
|
||||||
|
c.DeletePending = portablePath(c.DeletePending)
|
||||||
b, _ := json.MarshalIndent(c, "", " ")
|
b, _ := json.MarshalIndent(c, "", " ")
|
||||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
||||||
}
|
}
|
||||||
@@ -1766,6 +1851,15 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
|||||||
if lp == "" {
|
if lp == "" {
|
||||||
return a.db, "sqlite", nil
|
return a.db, "sqlite", nil
|
||||||
}
|
}
|
||||||
|
// Resolve against THIS install before opening. Without it, a profile carried
|
||||||
|
// from C:\OpsLog to D:\OpsLog kept naming the C: path — and since db.Open
|
||||||
|
// CREATES what is missing, the operator silently got a brand-new empty
|
||||||
|
// logbook instead of an error. Their QSOs were still on disk, in the folder
|
||||||
|
// they had just copied.
|
||||||
|
if r := resolvePath(a.dataDir, lp); r != lp {
|
||||||
|
applog.Printf("logbook: profile path %q resolved to %q", lp, r)
|
||||||
|
lp = r
|
||||||
|
}
|
||||||
c, err := db.Open(lp)
|
c, err := db.Open(lp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
|
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
|
||||||
@@ -8512,7 +8606,9 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||||
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
keyExtLoTWUsername, keyExtLoTWWebPassword,
|
||||||
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
keyExtHRDLogCallsign, keyExtHRDLogCode, keyExtHRDLogAutoUpload, keyExtHRDLogUploadMode,
|
||||||
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode)
|
keyExtEQSLUsername, keyExtEQSLPassword, keyExtEQSLQTHNick, keyExtEQSLAutoUpload, keyExtEQSLUploadMode,
|
||||||
|
keyExtCloudlogURL, keyExtCloudlogAPIKey, keyExtCloudlogStationID,
|
||||||
|
keyExtCloudlogAutoUpload, keyExtCloudlogUploadMode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -8582,6 +8678,13 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
|||||||
out.EQSL.Username = p.Callsign
|
out.EQSL.Username = p.Callsign
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
out.Cloudlog = extsvc.ServiceConfig{
|
||||||
|
URL: m[keyExtCloudlogURL],
|
||||||
|
APIKey: m[keyExtCloudlogAPIKey],
|
||||||
|
StationID: m[keyExtCloudlogStationID],
|
||||||
|
AutoUpload: m[keyExtCloudlogAutoUpload] == "1",
|
||||||
|
UploadMode: extsvc.UploadMode(m[keyExtCloudlogUploadMode]),
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8639,6 +8742,17 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
if cfg.EQSL.AutoUpload {
|
if cfg.EQSL.AutoUpload {
|
||||||
eqAuto = "1"
|
eqAuto = "1"
|
||||||
}
|
}
|
||||||
|
// Cloudlog has no per-QSO upload-status column in the logbook, so the
|
||||||
|
// on-close sweep (which selects by that column) cannot apply — force any
|
||||||
|
// stored on_close back to immediate rather than silently doing nothing.
|
||||||
|
clgMode := modeOf(cfg.Cloudlog.UploadMode)
|
||||||
|
if clgMode == string(extsvc.ModeOnClose) {
|
||||||
|
clgMode = string(extsvc.ModeImmediate)
|
||||||
|
}
|
||||||
|
clgAuto := "0"
|
||||||
|
if cfg.Cloudlog.AutoUpload {
|
||||||
|
clgAuto = "1"
|
||||||
|
}
|
||||||
scope := a.profileScope() // write under the active profile's prefix
|
scope := a.profileScope() // write under the active profile's prefix
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
keyExtQRZAPIKey: strings.TrimSpace(cfg.QRZ.APIKey),
|
||||||
@@ -8674,6 +8788,12 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
|||||||
keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname),
|
keyExtEQSLQTHNick: strings.TrimSpace(cfg.EQSL.QTHNickname),
|
||||||
keyExtEQSLAutoUpload: eqAuto,
|
keyExtEQSLAutoUpload: eqAuto,
|
||||||
keyExtEQSLUploadMode: eqMode,
|
keyExtEQSLUploadMode: eqMode,
|
||||||
|
|
||||||
|
keyExtCloudlogURL: strings.TrimSpace(cfg.Cloudlog.URL),
|
||||||
|
keyExtCloudlogAPIKey: strings.TrimSpace(cfg.Cloudlog.APIKey),
|
||||||
|
keyExtCloudlogStationID: strings.TrimSpace(cfg.Cloudlog.StationID),
|
||||||
|
keyExtCloudlogAutoUpload: clgAuto,
|
||||||
|
keyExtCloudlogUploadMode: clgMode,
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
if err := a.settings.Set(a.ctx, scope+k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -8712,6 +8832,12 @@ func (a *App) TestEQSLUpload() (string, error) {
|
|||||||
return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
|
return extsvc.TestEQSL(a.ctx, nil, a.loadExternalServices().EQSL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCloudlogUpload checks the Cloudlog/Wavelog URL, API key and station id
|
||||||
|
// against the user's own instance, without inserting anything.
|
||||||
|
func (a *App) TestCloudlogUpload() (string, error) {
|
||||||
|
return extsvc.TestCloudlog(a.ctx, nil, a.loadExternalServices().Cloudlog)
|
||||||
|
}
|
||||||
|
|
||||||
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
// ── QSL Manager (manual upload) ────────────────────────────────────────
|
||||||
|
|
||||||
// uploadColumnFor maps a service id to its QSO sent-status column.
|
// uploadColumnFor maps a service id to its QSO sent-status column.
|
||||||
@@ -9999,6 +10125,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
case extsvc.ServiceCloudlog:
|
||||||
|
// No per-QSO status column for Cloudlog: it dedupes server-side (its API
|
||||||
|
// checks each incoming record against the log), so re-sending is harmless
|
||||||
|
// and every QSO is eligible. The cost is that a permanently failed upload
|
||||||
|
// is not remembered — hence no on-close mode and no manual backlog.
|
||||||
|
return true
|
||||||
case extsvc.ServiceLoTW:
|
case extsvc.ServiceLoTW:
|
||||||
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||||
if strings.EqualFold(q.LOTWSent, f) {
|
if strings.EqualFold(q.LOTWSent, f) {
|
||||||
@@ -10034,6 +10166,9 @@ func (a *App) markExtUploaded(svc extsvc.Service, id int64, logID string) {
|
|||||||
err = a.qso.MarkHRDLogUploaded(ctx, id, date)
|
err = a.qso.MarkHRDLogUploaded(ctx, id, date)
|
||||||
case extsvc.ServiceEQSL:
|
case extsvc.ServiceEQSL:
|
||||||
err = a.qso.MarkEQSLSent(ctx, id, date)
|
err = a.qso.MarkEQSLSent(ctx, id, date)
|
||||||
|
case extsvc.ServiceCloudlog:
|
||||||
|
// Nothing to stamp — see extShouldUpload. Still logged and announced so
|
||||||
|
// the upload is visible in the diagnostic log and the UI.
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
|
applog.Printf("extsvc: mark %s uploaded %d failed: %v", svc, id, err)
|
||||||
@@ -11906,6 +12041,10 @@ func (a *App) SaveProfile(p profile.Profile) (profile.Profile, error) {
|
|||||||
if a.profiles == nil {
|
if a.profiles == nil {
|
||||||
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
return profile.Profile{}, fmt.Errorf("profiles not initialized")
|
||||||
}
|
}
|
||||||
|
// Store a logbook that lives inside the app folder RELATIVE to it, so the
|
||||||
|
// profile keeps working when the folder is copied to another machine or
|
||||||
|
// another drive letter.
|
||||||
|
p.DB.Path = portablePath(p.DB.Path)
|
||||||
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
if err := a.profiles.Save(a.ctx, &p); err != nil {
|
||||||
return profile.Profile{}, err
|
return profile.Profile{}, err
|
||||||
}
|
}
|
||||||
@@ -13294,6 +13433,18 @@ type AmpConfig struct {
|
|||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
ComPort string `json:"com_port"`
|
ComPort string `json:"com_port"`
|
||||||
Baud int `json:"baud"`
|
Baud int `json:"baud"`
|
||||||
|
|
||||||
|
// Band-follow: an ACOM is the MASTER on its CAT/AUX connector — it polls a
|
||||||
|
// transceiver and changes band from the reply, so following OpsLog means
|
||||||
|
// answering those polls on a SECOND serial port (independent of the one used
|
||||||
|
// above for metering; both run at once). See internal/catemu.
|
||||||
|
FreqOut bool `json:"freq_out"`
|
||||||
|
FreqComPort string `json:"freq_com_port"`
|
||||||
|
FreqBaud int `json:"freq_baud"`
|
||||||
|
// FreqBroadcastMs > 0 also SENDS the frequency unprompted at that interval,
|
||||||
|
// for an amplifier that listens to a transceiver's CAT stream instead of
|
||||||
|
// polling it. 0 = answer polls only.
|
||||||
|
FreqBroadcastMs int `json:"freq_broadcast_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ampInst struct {
|
type ampInst struct {
|
||||||
@@ -13301,6 +13452,7 @@ type ampInst struct {
|
|||||||
pgxl *powergenius.Client
|
pgxl *powergenius.Client
|
||||||
spe *spe.Client
|
spe *spe.Client
|
||||||
acom *acom.Client
|
acom *acom.Client
|
||||||
|
catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *ampInst) stopAll() {
|
func (i *ampInst) stopAll() {
|
||||||
@@ -13313,6 +13465,9 @@ func (i *ampInst) stopAll() {
|
|||||||
if i.acom != nil {
|
if i.acom != nil {
|
||||||
i.acom.Stop()
|
i.acom.Stop()
|
||||||
}
|
}
|
||||||
|
if i.catemu != nil {
|
||||||
|
i.catemu.Stop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ampTypeLabel is the default display name for an amp type.
|
// ampTypeLabel is the default display name for an amp type.
|
||||||
@@ -13442,12 +13597,61 @@ func (a *App) startAmps() {
|
|||||||
a.spe = inst.spe
|
a.spe = inst.spe
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Band-follow on a SECOND serial port, for any amp that takes its band from
|
||||||
|
// a transceiver CAT link (ACOM and SPE both do). Independent of the control
|
||||||
|
// transport above, so metering and band-follow run at the same time.
|
||||||
|
if c.FreqOut && strings.TrimSpace(c.FreqComPort) != "" {
|
||||||
|
inst.catemu = catemu.New(catemu.Config{
|
||||||
|
ComPort: c.FreqComPort, Baud: c.FreqBaud,
|
||||||
|
BroadcastMs: c.FreqBroadcastMs,
|
||||||
|
}, applog.Printf)
|
||||||
|
if st := a.cat.State(); st.Connected {
|
||||||
|
inst.catemu.SetFrequency(st.FreqHz)
|
||||||
|
inst.catemu.SetMode(st.Mode)
|
||||||
|
}
|
||||||
|
inst.catemu.Start()
|
||||||
|
applog.Printf("amp %s: band-follow on %s at %d baud (Kenwood format, broadcast=%dms)",
|
||||||
|
c.Name, c.FreqComPort, c.FreqBaud, c.FreqBroadcastMs)
|
||||||
|
}
|
||||||
a.ampsMu.Lock()
|
a.ampsMu.Lock()
|
||||||
a.ampInsts[c.ID] = inst
|
a.ampInsts[c.ID] = inst
|
||||||
a.ampsMu.Unlock()
|
a.ampsMu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// feedAmpBandFollow pushes the rig's frequency/mode to every amplifier we are
|
||||||
|
// emulating a transceiver for. Called on each CAT state change.
|
||||||
|
//
|
||||||
|
// The TX frequency is what the amp must follow: in split it has to be tuned
|
||||||
|
// for where we transmit, not where we listen.
|
||||||
|
func (a *App) feedAmpBandFollow(s cat.RigState) {
|
||||||
|
if !s.Connected || s.FreqHz <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
defer a.ampsMu.Unlock()
|
||||||
|
for _, inst := range a.ampInsts {
|
||||||
|
if inst.catemu != nil {
|
||||||
|
inst.catemu.SetFrequency(s.FreqHz)
|
||||||
|
inst.catemu.SetMode(s.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAmpBandFollowStatus returns the band-follow link state per amplifier id,
|
||||||
|
// so the settings panel can show whether the amp is actually polling us.
|
||||||
|
func (a *App) GetAmpBandFollowStatus() map[string]catemu.Status {
|
||||||
|
out := map[string]catemu.Status{}
|
||||||
|
a.ampsMu.Lock()
|
||||||
|
defer a.ampsMu.Unlock()
|
||||||
|
for id, inst := range a.ampInsts {
|
||||||
|
if inst.catemu != nil {
|
||||||
|
out[id] = inst.catemu.GetStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// AmpStatus is one amp's live state for the UI poll — exactly one of the
|
// AmpStatus is one amp's live state for the UI poll — exactly one of the
|
||||||
// per-family payloads is set, per the amp's type.
|
// per-family payloads is set, per the amp's type.
|
||||||
type AmpStatus struct {
|
type AmpStatus struct {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
keyExtHRDLogCode: true,
|
keyExtHRDLogCode: true,
|
||||||
keyExtEQSLPassword: true,
|
keyExtEQSLPassword: true,
|
||||||
|
keyExtCloudlogAPIKey: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||||
|
|||||||
@@ -1,4 +1,44 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.21.5",
|
||||||
|
"date": "2026-07-27",
|
||||||
|
"en": [
|
||||||
|
"Column layouts (widths, order, hidden columns) now stick — five faults were undoing them.",
|
||||||
|
"Recent QSOs keeps a separate column layout in the Main tab and in its own tab — the pane is half as wide there.",
|
||||||
|
"ACOM and SPE amplifiers can follow OpsLog's frequency, on a second COM port (Settings → Amplifier).",
|
||||||
|
"Portable folder: moving OpsLog to another drive or PC (C:OpsLog → D:OpsLog) no longer loses the databases — paths inside the folder are now stored relative to it."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Les dispositions de colonnes (largeurs, ordre, colonnes masquées) tiennent enfin — cinq défauts les défaisaient.",
|
||||||
|
"Les QSO récents gardent une disposition de colonnes distincte dans l'onglet Principal et dans leur propre onglet — le volet y est deux fois moins large.",
|
||||||
|
"Les amplificateurs ACOM et SPE peuvent suivre la fréquence d'OpsLog, sur un second port COM (Réglages → Amplificateur).",
|
||||||
|
"Dossier portable : déplacer OpsLog sur un autre disque ou un autre PC (C:OpsLog → D:OpsLog) ne fait plus perdre les bases — les chemins internes au dossier sont désormais enregistrés relativement à celui-ci."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.4",
|
||||||
|
"date": "2026-07-26",
|
||||||
|
"en": [
|
||||||
|
"OmniRig on an FTDX101D: the frequency follows the SUB VFO again, and split is detected. The stock rig file never names a single VFO — it reports only the A/B pair and alternates between the two values, split included, from one poll to the next. OpsLog now takes the displayed frequency as the reference on Yaesu and keeps a split reported once for a few seconds, so the contradicting reading no longer cancels it.",
|
||||||
|
"The SMTP password can no longer be revealed in the settings — the eye button is gone. The settings window is often open while the screen is being shared or shown to someone in the shack.",
|
||||||
|
"Cloudlog and Wavelog: each QSO can now be uploaded to your own instance as it is logged. Settings → External services → CLOUDLOG: the address of your instance, a read/write API key, the station ID, and a Test connection button. Sending is immediate or delayed by 1–2 minutes so a mistake can still be corrected.",
|
||||||
|
"Recent QSOs and Worked before: new Distance (km) column, like the cluster's. It is computed from the QSO's own locator pair, so a contact made portable keeps the distance from where you actually were; QSOs with no locator on either side stay blank.",
|
||||||
|
"OmniRig: the frequency follows the SUB VFO on radios that report the two VFOs as a pair (AA / AB / BA / BB) — the whole Yaesu range. Only the single-VFO form was recognised, so pressing SUB left OpsLog on the main VFO, and a QSO worked on SUB was logged on the wrong frequency.",
|
||||||
|
"OmniRig split is held briefly ONLY for a radio whose rig file reports it erratically. On a radio that reports it correctly, split now clears the instant you cancel it on the front panel instead of a few seconds later.",
|
||||||
|
"The diagnostic log now records what OpsLog concluded from OmniRig (VFO, TX/RX frequencies, split), not just what OmniRig reported.",
|
||||||
|
"Locator: a precise grid from QRZ/HamQTH is no longer replaced by the country centroid (JN05JG becoming JN16 for a French station). The lookup runs more than once per QSO and the provider only gets two seconds to answer; on a slower connection the second answer fell back to cty.dat and downgraded the locator, while the name and QTH stayed — which made it look like QRZ had returned a wrong grid."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"OmniRig sur FTDX101D : la fréquence suit de nouveau le VFO SUB, et le split est détecté. Le fichier radio d'origine ne nomme jamais un VFO seul — il ne rapporte que la paire A/B, et alterne entre les deux valeurs, split compris, d'une interrogation à l'autre. OpsLog prend désormais la fréquence affichée comme référence sur Yaesu et conserve quelques secondes un split annoncé une fois, pour que la lecture contradictoire ne l'annule plus.",
|
||||||
|
"Le mot de passe SMTP ne peut plus être affiché en clair dans les réglages — le bouton en forme d'œil a été retiré. La fenêtre des réglages est souvent ouverte pendant un partage d'écran ou devant quelqu'un au shack.",
|
||||||
|
"Cloudlog et Wavelog : chaque QSO peut désormais être envoyé vers votre propre instance au moment où il est enregistré. Réglages → Services externes → CLOUDLOG : l'adresse de votre instance, une clé API lecture/écriture, l'ID de station, et un bouton de test de connexion. L'envoi est immédiat ou différé de 1 à 2 minutes pour laisser le temps de corriger une erreur.",
|
||||||
|
"QSO récents et Déjà contacté : nouvelle colonne Distance (km), comme celle du cluster. Elle est calculée à partir des locators propres au QSO, si bien qu'un contact fait en portable garde la distance depuis l'endroit où vous étiez réellement ; les QSO sans locator d'un côté ou de l'autre restent vides.",
|
||||||
|
"OmniRig : la fréquence suit le VFO SUB sur les radios qui rapportent les deux VFO sous forme de paire (AA / AB / BA / BB) — toute la gamme Yaesu. Seule la forme à un seul VFO était reconnue, si bien qu'un passage sur SUB laissait OpsLog sur le VFO principal, et qu'un QSO fait sur SUB était enregistré sur la mauvaise fréquence.",
|
||||||
|
"Le split OmniRig n'est maintenu brièvement que pour une radio dont le fichier de définition le rapporte de façon erratique. Sur une radio qui le rapporte correctement, le split disparaît désormais dès que vous le coupez en façade, au lieu de quelques secondes plus tard.",
|
||||||
|
"Le journal de diagnostic enregistre maintenant ce qu'OpsLog a déduit d'OmniRig (VFO, fréquences TX/RX, split), et pas seulement ce qu'OmniRig a rapporté.",
|
||||||
|
"Locator : un locator précis venu de QRZ/HamQTH n'est plus remplacé par le centre du pays (JN05JG devenant JN16 pour une station française). La recherche est lancée plusieurs fois par QSO et le fournisseur ne dispose que de deux secondes ; sur une connexion plus lente, la seconde réponse retombait sur cty.dat et dégradait le locator, alors que le nom et le QTH restaient — d'où l'impression que QRZ renvoyait un mauvais locator."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.21.3",
|
"version": "0.21.3",
|
||||||
"date": "2026-07-25",
|
"date": "2026-07-25",
|
||||||
|
|||||||
+39
-6
@@ -95,7 +95,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
|||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { setGridPrefsProfile } from '@/lib/gridPrefs';
|
import { setGridPrefsProfile, flushGridPrefs } from '@/lib/gridPrefs';
|
||||||
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -612,6 +612,10 @@ export default function App() {
|
|||||||
|
|
||||||
const userEditedRef = useRef<Set<string>>(new Set());
|
const userEditedRef = useRef<Set<string>>(new Set());
|
||||||
const lastLookedUpRef = useRef<string>('');
|
const lastLookedUpRef = useRef<string>('');
|
||||||
|
// Callsign for which a PROVIDER (QRZ/HamQTH) grid is currently in the field.
|
||||||
|
// A later cty.dat-only answer for the same call must not replace that precise
|
||||||
|
// locator with the entity centroid — see the apply block in doLookup.
|
||||||
|
const providerGridCallRef = useRef<string>('');
|
||||||
// Tracks the call we last auto-switched to the Worked-before tab for, so we
|
// Tracks the call we last auto-switched to the Worked-before tab for, so we
|
||||||
// don't keep yanking the tab on every wb refresh of the same callsign.
|
// don't keep yanking the tab on every wb refresh of the same callsign.
|
||||||
const lastWbFocusRef = useRef<string>('');
|
const lastWbFocusRef = useRef<string>('');
|
||||||
@@ -2352,6 +2356,14 @@ export default function App() {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// A column resized in the last seconds before quitting must still reach the
|
||||||
|
// database: the DB write is debounced, so flush it on the way out.
|
||||||
|
useEffect(() => {
|
||||||
|
const flush = () => flushGridPrefs();
|
||||||
|
window.addEventListener('beforeunload', flush);
|
||||||
|
return () => { window.removeEventListener('beforeunload', flush); flush(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Every setting is per-profile, so when the active profile changes the whole
|
// Every setting is per-profile, so when the active profile changes the whole
|
||||||
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||||
// side reloads its managers; this keeps the React state in sync.
|
// side reloads its managers; this keeps the React state in sync.
|
||||||
@@ -2723,6 +2735,7 @@ export default function App() {
|
|||||||
|
|
||||||
function resetAutoFill() {
|
function resetAutoFill() {
|
||||||
setName(''); setQth(''); setCountry(''); setGrid('');
|
setName(''); setQth(''); setCountry(''); setGrid('');
|
||||||
|
providerGridCallRef.current = ''; // the grid field is empty again
|
||||||
// NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms
|
// NOTE: don't clear `wb` here. It's owned by runWorkedBefore (fast 150 ms
|
||||||
// pass) and the short-callsign guard in scheduleLookup. Clearing it inside
|
// pass) and the short-callsign guard in scheduleLookup. Clearing it inside
|
||||||
// runLookup blanked the Worked-before table for the whole (possibly slow,
|
// runLookup blanked the Worked-before table for the whole (possibly slow,
|
||||||
@@ -3015,12 +3028,24 @@ export default function App() {
|
|||||||
if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? '');
|
if (!ue.has('name') && (r.name ?? '') !== '') setName(r.name ?? '');
|
||||||
if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? '');
|
if (!ue.has('qth') && (r.qth ?? '') !== '') setQth(r.qth ?? '');
|
||||||
if (!ue.has('grid')) {
|
if (!ue.has('grid')) {
|
||||||
if ((r.grid ?? '') !== '') setGrid(r.grid ?? '');
|
if ((r.grid ?? '') !== '') {
|
||||||
|
setGrid(r.grid ?? '');
|
||||||
|
providerGridCallRef.current = call;
|
||||||
|
}
|
||||||
// No provider grid (cty.dat-only / portable): derive a 4-char grid from
|
// No provider grid (cty.dat-only / portable): derive a 4-char grid from
|
||||||
// the entity centroid (e.g. Svalbard → JQ88) so the field — and the
|
// the entity centroid (e.g. Svalbard → JQ88) so the field — and the
|
||||||
// bearing/map — aren't empty. 4 chars signals it's entity-level, not a
|
// bearing/map — aren't empty. 4 chars signals it's entity-level, not a
|
||||||
// precise QTH (matches how Log4OM shows it).
|
// precise QTH (matches how Log4OM shows it).
|
||||||
else if (r.lat || r.lon) setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
|
//
|
||||||
|
// Skipped when QRZ already gave a precise grid for THIS call: lookups run
|
||||||
|
// more than once per QSO (debounced typing, then blur/Enter), and the
|
||||||
|
// provider only has a 2-second budget — one slow answer fell back to
|
||||||
|
// cty.dat and downgraded JN05JG to the France centroid JN16, while Name
|
||||||
|
// and QTH stayed (they are only written when non-empty). Same rule as
|
||||||
|
// those fields: a cty.dat answer never overwrites a richer one.
|
||||||
|
else if ((r.lat || r.lon) && providerGridCallRef.current !== call) {
|
||||||
|
setGrid(latLonToGrid(r.lat || 0, r.lon || 0, 4));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Country/zones are exactly what cty.dat IS authoritative for — set them
|
// Country/zones are exactly what cty.dat IS authoritative for — set them
|
||||||
// (only skipped if empty, so we never blank a known country).
|
// (only skipped if empty, so we never blank a known country).
|
||||||
@@ -4130,7 +4155,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 flex">
|
<div className="flex-1 min-h-0 flex">
|
||||||
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
<div className="flex-1 min-w-0 flex flex-col min-h-0">
|
||||||
<ClusterGrid rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
|
<ClusterGrid key={`clg-${activeProfileId ?? 'x'}`} rows={clusterRenderedRows as any} spotStatus={spotStatus} onSpotClick={handleSpotClick} />
|
||||||
</div>
|
</div>
|
||||||
{clusterShowFilters && renderClusterFilters()}
|
{clusterShowFilters && renderClusterFilters()}
|
||||||
</div>
|
</div>
|
||||||
@@ -4139,7 +4164,7 @@ export default function App() {
|
|||||||
case 'worked':
|
case 'worked':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
||||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||||
@@ -4175,7 +4200,13 @@ export default function App() {
|
|||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
key={`rqg-${activeProfileId ?? 'x'}`}
|
key={`rqg-${activeProfileId ?? 'x'}`}
|
||||||
|
// Its OWN layout, separate from the full-width Recent QSOs tab.
|
||||||
|
// This pane is roughly half as wide, so it wants fewer columns and
|
||||||
|
// narrower ones; sharing one key meant whichever was opened last
|
||||||
|
// rewrote the other's widths.
|
||||||
|
storageKey="mainpane"
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
|
myGrid={station.my_grid}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
@@ -5309,6 +5340,7 @@ export default function App() {
|
|||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
key={`rqg2-${activeProfileId ?? 'x'}`}
|
key={`rqg2-${activeProfileId ?? 'x'}`}
|
||||||
rows={qsosWithAwards as any}
|
rows={qsosWithAwards as any}
|
||||||
|
myGrid={station.my_grid}
|
||||||
total={total}
|
total={total}
|
||||||
awardCols={awardCols}
|
awardCols={awardCols}
|
||||||
onFilteredCountChange={setGridFilteredCount}
|
onFilteredCountChange={setGridFilteredCount}
|
||||||
@@ -5466,6 +5498,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ClusterGrid
|
<ClusterGrid
|
||||||
|
key={`clg2-${activeProfileId ?? 'x'}`}
|
||||||
rows={rendered as any}
|
rows={rendered as any}
|
||||||
spotStatus={spotStatus}
|
spotStatus={spotStatus}
|
||||||
onSpotClick={handleSpotClick}
|
onSpotClick={handleSpotClick}
|
||||||
@@ -5550,7 +5583,7 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<WorkedBeforeGrid wb={wbWithAwards as any} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
@@ -371,10 +371,28 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
// A rebuild makes AG Grid re-apply every colDef hide/width DEFAULT and fire the
|
||||||
|
// matching column events. Without this guard those events were persisted, so a
|
||||||
|
// single language change overwrote the saved cluster layout — in the cache AND
|
||||||
|
// in the database, with nothing to restore from.
|
||||||
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => {
|
||||||
|
restoringRef.current = true;
|
||||||
|
return COL_CATALOG.map((c) => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
return { ...rest, hide: !defaultVisible };
|
return { ...rest, hide: !defaultVisible };
|
||||||
}), [COL_CATALOG]);
|
});
|
||||||
|
}, [COL_CATALOG]);
|
||||||
|
|
||||||
|
// Re-apply the saved state after every rebuild, then re-enable saving.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
const tm = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
|
return () => window.clearTimeout(tm);
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
@@ -394,17 +412,20 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
|
|||||||
gridRef.current?.api?.refreshCells({ force: true });
|
gridRef.current?.api?.refreshCells({ force: true });
|
||||||
}, [spotStatus]);
|
}, [spotStatus]);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// Restore AFTER the profile scope is known — this grid has no key= remount to
|
||||||
|
// save it from reading the wrong (unscoped) cache key at first paint.
|
||||||
|
async function onGridReady(e: GridReadyEvent) {
|
||||||
|
await whenGridPrefsReady();
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
const remote = await loadRemote(COL_STATE_KEY);
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -427,6 +427,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
// drives which QSOs the apply-form below updates; a search selects all.
|
// drives which QSOs the apply-form below updates; a search selects all.
|
||||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
// Its OWN column layout. Without a storageKey this fell back to
|
||||||
|
// the main log's key, so every resize or hidden column here
|
||||||
|
// silently rewrote the Recent QSOs layout, and vice versa.
|
||||||
|
storageKey="qslmgr.paper"
|
||||||
rows={paperRows as any}
|
rows={paperRows as any}
|
||||||
total={paperRows.length}
|
total={paperRows.length}
|
||||||
selectAllSignal={paperSelAllSig}
|
selectAllSignal={paperSelAllSig}
|
||||||
@@ -532,6 +536,7 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||||
<RecentQSOsGrid
|
<RecentQSOsGrid
|
||||||
|
storageKey="qslmgr.upload"
|
||||||
rows={rows as any}
|
rows={rows as any}
|
||||||
total={rows.length}
|
total={rows.length}
|
||||||
selectAllSignal={uploadSelAllSig}
|
selectAllSignal={uploadSelAllSig}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { gridToLatLon, pathBetweenLatLon } from '@/lib/maidenhead';
|
||||||
|
|
||||||
// Register every Community feature once. v32+ requires explicit registration;
|
// Register every Community feature once. v32+ requires explicit registration;
|
||||||
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
|
||||||
@@ -27,6 +28,9 @@ const hamlogTheme = hamlogGridTheme.withParams({ rowHeight: 32, headerHeight: 34
|
|||||||
type Props = {
|
type Props = {
|
||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
|
// Operator's CURRENT locator — fallback for the distance column on older QSOs
|
||||||
|
// that carry no my_grid of their own.
|
||||||
|
myGrid?: string;
|
||||||
// Bump this number to programmatically select every row (e.g. after a search
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
// in the QSL Manager, where the default is "all selected").
|
// in the QSL Manager, where the default is "all selected").
|
||||||
selectAllSignal?: number;
|
selectAllSignal?: number;
|
||||||
@@ -111,7 +115,26 @@ export type ColEntry = ColDef<QSOForm> & { group: string; label: string; default
|
|||||||
// hooks can't run at module level, so the component calls this with its own t.
|
// hooks can't run at module level, so the component calls this with its own t.
|
||||||
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
type TFn = (key: string, vars?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
export const makeColCatalog = (t: TFn): ColEntry[] => [
|
// qsoDistanceKm returns the great-circle distance for one row, in km.
|
||||||
|
//
|
||||||
|
// The QSO's OWN my_grid / my_lat / my_lon come first: a log spans years and
|
||||||
|
// portable outings, so the station the QSO was made from is not necessarily the
|
||||||
|
// one configured today. `myGrid` (the current profile) is only the fallback for
|
||||||
|
// the many older records that carry no my_* fields at all.
|
||||||
|
function qsoDistanceKm(d: any, myGrid?: string): number | undefined {
|
||||||
|
if (!d) return undefined;
|
||||||
|
const here =
|
||||||
|
(d.my_grid && gridToLatLon(d.my_grid)) ||
|
||||||
|
(d.my_lat || d.my_lon ? { lat: d.my_lat || 0, lon: d.my_lon || 0 } : null) ||
|
||||||
|
(myGrid ? gridToLatLon(myGrid) : null);
|
||||||
|
const there =
|
||||||
|
(d.grid && gridToLatLon(d.grid)) ||
|
||||||
|
(d.lat || d.lon ? { lat: d.lat || 0, lon: d.lon || 0 } : null);
|
||||||
|
if (!here || !there) return undefined;
|
||||||
|
return Math.round(pathBetweenLatLon(here, there).distanceShort);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeColCatalog = (t: TFn, myGrid?: string): ColEntry[] => [
|
||||||
// ── QSO basics ──
|
// ── QSO basics ──
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
{ group: 'QSO', label: t('rqg.c.qso_date'), colId: 'qso_date', headerName: t('rqg.c.qso_date'), field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
|
||||||
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
{ group: 'QSO', label: t('rqg.c.qso_date_off'), colId: 'qso_date_off', headerName: t('rqg.c.qso_date_off'), field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
|
||||||
@@ -146,6 +169,11 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
{ group: 'Contacted', label: t('rqg.c.age'), colId: 'age', headerName: t('rqg.c.age'), field: 'age' as any, width: 60, type: 'rightAligned' },
|
||||||
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lat'), colId: 'lat', headerName: t('rqg.c.lat'), field: 'lat' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
{ group: 'Contacted', label: t('rqg.c.lon'), colId: 'lon', headerName: t('rqg.c.lon'), field: 'lon' as any, width: 90, type: 'rightAligned', cellClass: 'font-mono' },
|
||||||
|
// Derived, not stored: computed from the two locations at display time, like
|
||||||
|
// the cluster grid's own distance column.
|
||||||
|
{ group: 'Contacted', label: t('rqg.c.distance_km'), colId: 'distance_km', headerName: t('rqg.h.distance_km'), width: 90, type: 'rightAligned', cellClass: 'font-mono',
|
||||||
|
valueGetter: (p) => qsoDistanceKm(p.data, myGrid),
|
||||||
|
comparator: (a, b) => (a ?? 0) - (b ?? 0), defaultVisible: true },
|
||||||
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.email'), colId: 'email', headerName: t('rqg.c.email'), field: 'email' as any, width: 180 },
|
||||||
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
{ group: 'Contacted', label: t('rqg.c.web'), colId: 'web', headerName: t('rqg.c.web'), field: 'web' as any, width: 180 },
|
||||||
|
|
||||||
@@ -263,7 +291,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -275,7 +303,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
const [dispCount, setDispCount] = useState(0); // rows currently displayed (post column filters) — drives Select all ↔ Unselect all
|
||||||
|
|
||||||
// Localized column catalog — rebuilt when the language changes.
|
// Localized column catalog — rebuilt when the language changes.
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||||
|
|
||||||
// Right-click: if the clicked row isn't already part of the selection,
|
// Right-click: if the clicked row isn't already part of the selection,
|
||||||
// select just it; then open the bulk-action menu on the whole selection.
|
// select just it; then open the bulk-action menu on the whole selection.
|
||||||
@@ -493,7 +521,13 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
// Re-enable saving once AG Grid has settled the column events from the rebuild.
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols, awardShown]);
|
// Keyed on columnDefs ITSELF, not on the reasons it was rebuilt. Listing the
|
||||||
|
// causes here (awardCols/awardShown) missed the others — switching the UI
|
||||||
|
// language rebuilds the memo through `t`, which set restoringRef and left it
|
||||||
|
// set, so every column change for the rest of the session was silently
|
||||||
|
// discarded and the layout reverted on reload. Any future dependency of the
|
||||||
|
// memo is now covered for free.
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
|
||||||
ChevronDown, ChevronRight,
|
ChevronDown, ChevronRight,
|
||||||
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
|
||||||
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
|
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Pencil,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload, TestCloudlogUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
DownloadLoTWUsers, GetLoTWUsersStatus,
|
DownloadLoTWUsers, GetLoTWUsersStatus,
|
||||||
@@ -627,7 +627,10 @@ function ADIFMonitorPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
// AmpUI mirrors the backend AmpConfig — one configured amplifier.
|
||||||
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number };
|
type AmpUI = { id: string; name: string; enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number;
|
||||||
|
// Band-follow (ACOM): a SECOND serial port on which OpsLog answers the amp's
|
||||||
|
// frequency polls, pretending to be a Kenwood-format transceiver.
|
||||||
|
freq_out?: boolean; freq_com_port?: string; freq_baud?: number; freq_broadcast_ms?: number };
|
||||||
|
|
||||||
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
// RelayAutoPanel configures automatic control of the Station Control relay boards
|
||||||
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
// from the rig's frequency / band (PstRotator-style). Each relay carries one rule:
|
||||||
@@ -1178,7 +1181,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
from: '', reply_to: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
|
||||||
});
|
});
|
||||||
const [emailMsg, setEmailMsg] = useState('');
|
const [emailMsg, setEmailMsg] = useState('');
|
||||||
const [showSmtpPass, setShowSmtpPass] = useState(false);
|
|
||||||
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
|
||||||
// eQSL card e-mail (subject/body templates + auto-send on log).
|
// eQSL card e-mail (subject/body templates + auto-send on log).
|
||||||
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
type EQSLCfg = { subject: string; body: string; auto_send: boolean };
|
||||||
@@ -1223,20 +1225,21 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
type ExtServiceCfg = {
|
type ExtServiceCfg = {
|
||||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||||
code: string; qth_nickname: string;
|
code: string; qth_nickname: string;
|
||||||
|
url: string; station_id: string; // Cloudlog/Wavelog: own instance + station profile
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
tqsl_path: string; station_location: string; key_password: string;
|
tqsl_path: string; station_location: string; key_password: string;
|
||||||
upload_flags: string[]; write_log: boolean;
|
upload_flags: string[]; write_log: boolean;
|
||||||
auto_upload: boolean; upload_mode: string;
|
auto_upload: boolean; upload_mode: string;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg; cloudlog: ExtServiceCfg };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
api_key: '', url: '', station_id: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||||
upload_flags: ['N', 'R'], write_log: false,
|
upload_flags: ['N', 'R'], write_log: false,
|
||||||
auto_upload: false, upload_mode: 'immediate',
|
auto_upload: false, upload_mode: 'immediate',
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
|
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(), cloudlog: emptyExtCfg(),
|
||||||
});
|
});
|
||||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [qrzTesting, setQrzTesting] = useState(false);
|
const [qrzTesting, setQrzTesting] = useState(false);
|
||||||
@@ -1299,12 +1302,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
};
|
};
|
||||||
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||||
|
const [cloudlogTest, setCloudlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [cloudlogTesting, setCloudlogTesting] = useState(false);
|
||||||
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [eqslTesting, setEqslTesting] = useState(false);
|
const [eqslTesting, setEqslTesting] = useState(false);
|
||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
// Active tab in the External Services panel — lifted here because
|
// Active tab in the External Services panel — lifted here because
|
||||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'cloudlog' | 'pota'>('qrz');
|
||||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||||
const [potaToken, setPotaToken] = useState('');
|
const [potaToken, setPotaToken] = useState('');
|
||||||
const [potaBusy, setPotaBusy] = useState(false);
|
const [potaBusy, setPotaBusy] = useState(false);
|
||||||
@@ -2809,6 +2814,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
});
|
});
|
||||||
const addAmp = () => setAmps((l) => [...l, {
|
const addAmp = () => setAmps((l) => [...l, {
|
||||||
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200,
|
||||||
|
freq_out: false, freq_com_port: '', freq_baud: 9600, freq_broadcast_ms: 0,
|
||||||
}]);
|
}]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2925,6 +2931,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Band-follow, for any amp that takes its band from a transceiver
|
||||||
|
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||||||
|
driven over its network protocol. A SECOND serial port,
|
||||||
|
separate from the metering one above. */}
|
||||||
|
{!isPGXL && (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={!!amp.freq_out}
|
||||||
|
onCheckedChange={(c) => patchAmp(i, { freq_out: !!c })}
|
||||||
|
/>
|
||||||
|
{t('amp.freqOut')}
|
||||||
|
</label>
|
||||||
|
{amp.freq_out && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('amp.freqPort')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={amp.freq_com_port || '_'} onValueChange={(v) => patchAmp(i, { freq_com_port: v === '_' ? '' : v })}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud</Label>
|
||||||
|
<Input type="number" min={1200} value={amp.freq_baud ?? 9600}
|
||||||
|
onChange={(e) => patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('amp.freqBroadcast')}</Label>
|
||||||
|
<Select value={String(amp.freq_broadcast_ms ?? 0)} onValueChange={(v) => patchAmp(i, { freq_broadcast_ms: parseInt(v) })}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">{t('amp.freqPollOnly')}</SelectItem>
|
||||||
|
<SelectItem value="500">{t('amp.freqEvery', { ms: 500 })}</SelectItem>
|
||||||
|
<SelectItem value="1000">{t('amp.freqEvery', { ms: 1000 })}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground">{t('amp.freqHint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
{amp.enabled && amp.id && <AmpStatusCard id={amp.id} />}
|
||||||
{!isPGXL && !isACOM && (
|
{!isPGXL && !isACOM && (
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
@@ -3756,6 +3818,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
||||||
{ k: 'eqsl', label: 'EQSL', ready: true },
|
{ k: 'eqsl', label: 'EQSL', ready: true },
|
||||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||||
|
{ k: 'cloudlog', label: 'CLOUDLOG', ready: true },
|
||||||
{ k: 'pota', label: 'POTA', ready: true },
|
{ k: 'pota', label: 'POTA', ready: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -3825,6 +3888,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cloudlog = extSvc.cloudlog;
|
||||||
|
const setCloudlog = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, cloudlog: { ...s.cloudlog, ...patch } }));
|
||||||
|
|
||||||
|
async function testCloudlog() {
|
||||||
|
setCloudlogTesting(true);
|
||||||
|
setCloudlogTest(null);
|
||||||
|
try {
|
||||||
|
// Save first: the backend test reads the stored config, so it must see
|
||||||
|
// what was just typed (same as the other services' Test buttons).
|
||||||
|
await SaveExternalServices(extSvc as any);
|
||||||
|
const msg = await TestCloudlogUpload();
|
||||||
|
setCloudlogTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setCloudlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setCloudlogTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const eqsl = extSvc.eqsl;
|
const eqsl = extSvc.eqsl;
|
||||||
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
|
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
|
||||||
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
|
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
|
||||||
@@ -4077,6 +4160,74 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'cloudlog' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">{t('es.cloudlogUrl')}</Label>
|
||||||
|
<Input
|
||||||
|
value={cloudlog.url}
|
||||||
|
onChange={(e) => setCloudlog({ url: e.target.value })}
|
||||||
|
placeholder={t('es.cloudlogUrlPh')}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">{t('es.apiKey')}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={cloudlog.api_key}
|
||||||
|
onChange={(e) => setCloudlog({ api_key: e.target.value })}
|
||||||
|
placeholder={t('es.cloudlogKeyPh')}
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">{t('es.cloudlogStationId')}</Label>
|
||||||
|
<Input
|
||||||
|
value={cloudlog.station_id}
|
||||||
|
onChange={(e) => setCloudlog({ station_id: e.target.value })}
|
||||||
|
placeholder="1"
|
||||||
|
className="font-mono text-xs w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||||
|
{t('es.cloudlogHint')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={cloudlog.auto_upload}
|
||||||
|
onCheckedChange={(c) => setCloudlog({ auto_upload: !!c })}
|
||||||
|
/>
|
||||||
|
{t('es.autoUpload')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">{t('es.uploadTiming')}</Label>
|
||||||
|
<Select
|
||||||
|
value={cloudlog.upload_mode === 'delayed' ? 'delayed' : 'immediate'}
|
||||||
|
onValueChange={(v) => setCloudlog({ upload_mode: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="immediate">{t('es.immediate')}</SelectItem>
|
||||||
|
<SelectItem value="delayed">{t('es.delayed')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{/* No "on close" option: unlike the other services, Cloudlog has no
|
||||||
|
per-QSO status column in the logbook, so there is nothing to
|
||||||
|
select a backlog from at shutdown. */}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testCloudlog} disabled={cloudlogTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {cloudlogTesting ? t('es.testing') : t('es.testConn')}
|
||||||
|
</Button>
|
||||||
|
{cloudlogTest && (
|
||||||
|
<span className={cn('text-xs', cloudlogTest.ok ? 'text-success' : 'text-danger')}>
|
||||||
|
{cloudlogTest.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : extSvcTab === 'eqsl' ? (
|
) : extSvcTab === 'eqsl' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
@@ -4992,15 +5143,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Label className="text-sm">{t('em.username')}</Label>
|
<Label className="text-sm">{t('em.username')}</Label>
|
||||||
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
|
||||||
<Label className="text-sm">{t('es.password')}</Label>
|
<Label className="text-sm">{t('es.password')}</Label>
|
||||||
<div className="relative">
|
{/* No reveal button: the settings window is often open while the
|
||||||
<Input type={showSmtpPass ? 'text' : 'password'} className="h-8 pr-9" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
screen is shared or shown to someone in the shack. */}
|
||||||
<button type="button" tabIndex={-1} onClick={() => setShowSmtpPass((v) => !v)}
|
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
|
||||||
title={showSmtpPass ? t('es.hidePass') : t('es.showPass')}
|
|
||||||
className="absolute inset-y-0 right-0 flex items-center px-2.5 text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
||||||
disabled={!emailCfg.auth}>
|
|
||||||
{showSmtpPass ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
<Label className="text-sm">{t('em.fromAddr')}</Label>
|
||||||
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
<Input className="h-8" placeholder="[email protected]" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
|
||||||
<Label className="text-sm">{t('em.replyTo')}</Label>
|
<Label className="text-sm">{t('em.replyTo')}</Label>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||||
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
@@ -25,6 +25,8 @@ const hamlogTheme = hamlogGridTheme;
|
|||||||
type WorkedEntry = QSOForm; // entries are now full QSO records
|
type WorkedEntry = QSOForm; // entries are now full QSO records
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
// Operator's CURRENT locator — fallback for the distance column (see catalog).
|
||||||
|
myGrid?: string;
|
||||||
wb: WorkedBeforeView | null;
|
wb: WorkedBeforeView | null;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
currentCall: string;
|
currentCall: string;
|
||||||
@@ -54,14 +56,14 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
|
|
||||||
// Localized column catalog (shared with the Recent QSOs grid).
|
// Localized column catalog (shared with the Recent QSOs grid).
|
||||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
const COL_CATALOG = useMemo(() => makeColCatalog(t, myGrid), [t, myGrid]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
@@ -111,15 +113,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
function onGridReady(e: GridReadyEvent) {
|
// Restore AFTER the profile scope is known: this grid has no key= remount to
|
||||||
|
// save it from reading the wrong (unscoped) cache key at first paint, so it
|
||||||
|
// used to miss the cache every time and then save under the scoped key.
|
||||||
|
async function onGridReady(e: GridReadyEvent) {
|
||||||
|
await whenGridPrefsReady();
|
||||||
const local = loadLocal(COL_STATE_KEY);
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
const remote = await loadRemote(COL_STATE_KEY);
|
||||||
if (remote && !local) {
|
if (remote && !local) {
|
||||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||||
seedLocal(COL_STATE_KEY, remote);
|
seedLocal(COL_STATE_KEY, remote);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
if (restoringRef.current) return; // ignore events fired by a column rebuild
|
||||||
@@ -135,7 +140,9 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
if (api && local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, [awardCols]);
|
// columnDefs itself, not the reasons it was rebuilt — see the same note in
|
||||||
|
// RecentQSOsGrid: a language change rebuilt the memo and left saving off.
|
||||||
|
}, [columnDefs]);
|
||||||
|
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
|
|||||||
@@ -19,6 +19,26 @@ let lsScope = '';
|
|||||||
// changes so each profile keeps its own column layout / widths.
|
// changes so each profile keeps its own column layout / widths.
|
||||||
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||||
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
lsScope = id == null || id === '' ? '' : `p${id}.`;
|
||||||
|
markReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active profile is only known after an async call, but the grids mount and
|
||||||
|
// read their state on the first paint. A grid that read before the scope was set
|
||||||
|
// looked up the UNSCOPED cache key (always a miss) and then saved under the
|
||||||
|
// scoped one — so its layout could never be restored, only rewritten. Grids now
|
||||||
|
// wait for this instead of racing it.
|
||||||
|
//
|
||||||
|
// The 5 s fallback matters: if the profile lookup fails outright, the grids must
|
||||||
|
// still come up with whatever the unscoped cache holds rather than hang with no
|
||||||
|
// columns restored.
|
||||||
|
let markReady: () => void = () => {};
|
||||||
|
const gridPrefsReady = new Promise<void>((resolve) => {
|
||||||
|
markReady = resolve;
|
||||||
|
setTimeout(resolve, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function whenGridPrefsReady(): Promise<void> {
|
||||||
|
return gridPrefsReady;
|
||||||
}
|
}
|
||||||
|
|
||||||
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
|
||||||
@@ -40,22 +60,66 @@ export function loadLocal(key: string): any[] | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||||
export async function loadRemote(key: string): Promise<any[] | null> {
|
//
|
||||||
|
// GetUIPref returns an ERROR — not "" — while the settings store is still
|
||||||
|
// coming up and not yet scoped to the active profile. Treating that as "no
|
||||||
|
// preference" was the silent data-loss path: the grid rendered defaults and the
|
||||||
|
// first column event then wrote those defaults over the good saved copy. So
|
||||||
|
// retry for a few seconds instead, and only give up on a real absence.
|
||||||
|
export async function loadRemote(key: string, attempts = 10): Promise<any[] | null> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
try {
|
try {
|
||||||
const v = await GetUIPref(key);
|
const v = await GetUIPref(key);
|
||||||
const parsed = v ? JSON.parse(v) : null;
|
const parsed = v ? JSON.parse(v) : null;
|
||||||
return Array.isArray(parsed) ? parsed : null;
|
return Array.isArray(parsed) ? parsed : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
// Not ready yet (or a parse failure on a corrupt value — one more read
|
||||||
|
// costs nothing). 300 ms × 10 covers a slow startup without hanging.
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
// saveState write-throughs to both the cache and the DB (fire-and-forget). Only
|
||||||
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
// the cache key is profile-scoped; the DB key is scoped by the backend.
|
||||||
|
// The DB write is DEBOUNCED. AG-Grid emits a column-resized event per drag
|
||||||
|
// frame, so dragging one border used to fire dozens of settings writes; the
|
||||||
|
// cache write stays synchronous so nothing is lost if the window closes.
|
||||||
|
const pendingDB = new Map<string, string>();
|
||||||
|
const dbTimers = new Map<string, number>();
|
||||||
|
|
||||||
export function saveState(key: string, state: any[]) {
|
export function saveState(key: string, state: any[]) {
|
||||||
const json = JSON.stringify(state);
|
const json = JSON.stringify(state);
|
||||||
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
|
||||||
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
|
pendingDB.set(key, json);
|
||||||
|
const prev = dbTimers.get(key);
|
||||||
|
if (prev) clearTimeout(prev);
|
||||||
|
dbTimers.set(key, window.setTimeout(() => flushOne(key), 400));
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushOne(key: string) {
|
||||||
|
const json = pendingDB.get(key);
|
||||||
|
dbTimers.delete(key);
|
||||||
|
if (json == null) return;
|
||||||
|
pendingDB.delete(key);
|
||||||
|
SetUIPref(key, json).catch(() => {
|
||||||
|
// The store may not be scoped yet at startup. Keep the value and try once
|
||||||
|
// more shortly — dropping it here is how a fresh profile ended up with no
|
||||||
|
// portable copy at all.
|
||||||
|
pendingDB.set(key, json);
|
||||||
|
if (!dbTimers.has(key)) dbTimers.set(key, window.setTimeout(() => flushOne(key), 2000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushGridPrefs writes any debounced state immediately. Call it when the app is
|
||||||
|
// about to close so a resize made in the last moments still reaches the DB.
|
||||||
|
export function flushGridPrefs() {
|
||||||
|
for (const key of Array.from(pendingDB.keys())) {
|
||||||
|
const t = dbTimers.get(key);
|
||||||
|
if (t) clearTimeout(t);
|
||||||
|
flushOne(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedLocal writes a value into the cache without touching the DB (used after
|
// seedLocal writes a value into the cache without touching the DB (used after
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// Single source of truth for the app version shown in the UI (header + About).
|
||||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.21.3';
|
export const APP_VERSION = '0.21.5';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+5
@@ -6,6 +6,7 @@ import {main} from '../models';
|
|||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
import {acom} from '../models';
|
import {acom} from '../models';
|
||||||
|
import {catemu} from '../models';
|
||||||
import {antgenius} from '../models';
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
@@ -344,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
|
|||||||
|
|
||||||
export function GetAlertEmailTo():Promise<string>;
|
export function GetAlertEmailTo():Promise<string>;
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
|
||||||
|
|
||||||
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||||
|
|
||||||
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||||
@@ -914,6 +917,8 @@ export function SwitchCATRig(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
|
export function TestCloudlogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestEQSLUpload():Promise<string>;
|
export function TestEQSLUpload():Promise<string>;
|
||||||
|
|||||||
@@ -638,6 +638,10 @@ export function GetAlertEmailTo() {
|
|||||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAmpBandFollowStatus() {
|
||||||
|
return window['go']['main']['App']['GetAmpBandFollowStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAmpStatuses() {
|
export function GetAmpStatuses() {
|
||||||
return window['go']['main']['App']['GetAmpStatuses']();
|
return window['go']['main']['App']['GetAmpStatuses']();
|
||||||
}
|
}
|
||||||
@@ -1778,6 +1782,10 @@ export function SyncPOTAHunterLog(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestCloudlogUpload() {
|
||||||
|
return window['go']['main']['App']['TestCloudlogUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestClublogUpload() {
|
export function TestClublogUpload() {
|
||||||
return window['go']['main']['App']['TestClublogUpload']();
|
return window['go']['main']['App']['TestClublogUpload']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1184,6 +1184,8 @@ export namespace extsvc {
|
|||||||
|
|
||||||
export class ServiceConfig {
|
export class ServiceConfig {
|
||||||
api_key: string;
|
api_key: string;
|
||||||
|
url: string;
|
||||||
|
station_id: string;
|
||||||
email: string;
|
email: string;
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -1206,6 +1208,8 @@ export namespace extsvc {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.api_key = source["api_key"];
|
this.api_key = source["api_key"];
|
||||||
|
this.url = source["url"];
|
||||||
|
this.station_id = source["station_id"];
|
||||||
this.email = source["email"];
|
this.email = source["email"];
|
||||||
this.username = source["username"];
|
this.username = source["username"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
@@ -1228,6 +1232,7 @@ export namespace extsvc {
|
|||||||
lotw: ServiceConfig;
|
lotw: ServiceConfig;
|
||||||
hrdlog: ServiceConfig;
|
hrdlog: ServiceConfig;
|
||||||
eqsl: ServiceConfig;
|
eqsl: ServiceConfig;
|
||||||
|
cloudlog: ServiceConfig;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ExternalServices(source);
|
return new ExternalServices(source);
|
||||||
@@ -1240,6 +1245,7 @@ export namespace extsvc {
|
|||||||
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
||||||
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||||
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||||
|
this.cloudlog = this.convertValues(source["cloudlog"], ServiceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -1436,6 +1442,10 @@ export namespace main {
|
|||||||
port: number;
|
port: number;
|
||||||
com_port: string;
|
com_port: string;
|
||||||
baud: number;
|
baud: number;
|
||||||
|
freq_out: boolean;
|
||||||
|
freq_com_port: string;
|
||||||
|
freq_baud: number;
|
||||||
|
freq_broadcast_ms: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AmpConfig(source);
|
return new AmpConfig(source);
|
||||||
@@ -1452,6 +1462,10 @@ export namespace main {
|
|||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.com_port = source["com_port"];
|
this.com_port = source["com_port"];
|
||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
|
this.freq_out = source["freq_out"];
|
||||||
|
this.freq_com_port = source["freq_com_port"];
|
||||||
|
this.freq_baud = source["freq_baud"];
|
||||||
|
this.freq_broadcast_ms = source["freq_broadcast_ms"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class AmpStatus {
|
export class AmpStatus {
|
||||||
|
|||||||
+100
-14
@@ -46,6 +46,19 @@ type OmniRig struct {
|
|||||||
// the sideband (freq moved, but mode read the old band → wrong sideband).
|
// the sideband (freq moved, but mode read the old band → wrong sideband).
|
||||||
lastSetFreq int64
|
lastSetFreq int64
|
||||||
lastSetFreqAt time.Time
|
lastSetFreqAt time.Time
|
||||||
|
|
||||||
|
// lastSplitOnAt is when OmniRig last reported PM_SPLITON cleanly. See the
|
||||||
|
// FTDX101D note in ReadState — some .ini files alternate between ON and OFF
|
||||||
|
// on consecutive polls, so the flag has to be latched to be usable.
|
||||||
|
lastSplitOnAt time.Time
|
||||||
|
|
||||||
|
// splitFlaky records that THIS rig's .ini flips the split flag on its own,
|
||||||
|
// which is what arms the latch. lastSplitFlag / splitFlips / splitFlipWindow
|
||||||
|
// count the flips inside a rolling window to detect it.
|
||||||
|
lastSplitFlag bool
|
||||||
|
splitFlaky bool
|
||||||
|
splitFlips int
|
||||||
|
splitFlipWindow time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||||
@@ -251,21 +264,61 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
splitRaw = v.Val
|
splitRaw = v.Val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so
|
// FTDX101D field capture: OmniRig alternates between two contradictory
|
||||||
// normal operation stays quiet but toggling split on the radio is captured —
|
// readings on consecutive polls — "Vfo=AB Split=0x10000(OFF)" then
|
||||||
// needed to pin down this rig's PM_SPLITON value.
|
// "Vfo=BA Split=0x8000(ON)", ~1.5 s apart, with the rig untouched. The stock
|
||||||
|
// .ini evidently has two status commands that each write these params. A
|
||||||
|
// sample-by-sample test therefore reports split for half the polls and no
|
||||||
|
// split for the other half, which the UI shows as no split at all. Latch the
|
||||||
|
// ON flag briefly so one truthful sample survives the contradicting one; the
|
||||||
|
// latch expires on its own once the rig stops reporting ON, so cancelling
|
||||||
|
// split on the radio still clears within a few seconds.
|
||||||
|
//
|
||||||
|
// The latch is ARMED ONLY for a rig that actually oscillates, because it costs
|
||||||
|
// ~6 s before split clears on screen. A correct .ini (FTDX10 with VS; and FT;
|
||||||
|
// read as separate frames, confirmed on the air 2026-07-26) never flips
|
||||||
|
// unprompted, and there the latch would be a pure delay on a reading that was
|
||||||
|
// already right.
|
||||||
|
//
|
||||||
|
// 8 flips in 30 s: a first cut at 3-in-15s was armed by the OPERATOR toggling
|
||||||
|
// split three times while testing, which then imposed the 6 s delay on a rig
|
||||||
|
// that did not need it. A misreading .ini flips every 1.5–3 s without being
|
||||||
|
// touched — a dozen in the same window — so the gap is wide. The arming also
|
||||||
|
// expires after 30 s without a flip, so a rig that behaves is never stuck with
|
||||||
|
// the delay because of one burst.
|
||||||
|
const flipWindow, flipsToArm = 30 * time.Second, 8
|
||||||
|
now := time.Now()
|
||||||
|
flagOn := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
|
||||||
|
if flagOn {
|
||||||
|
o.lastSplitOnAt = now
|
||||||
|
}
|
||||||
|
if flagOn != o.lastSplitFlag {
|
||||||
|
o.lastSplitFlag = flagOn
|
||||||
|
if now.Sub(o.splitFlipWindow) > flipWindow {
|
||||||
|
o.splitFlipWindow, o.splitFlips, o.splitFlaky = now, 0, false
|
||||||
|
}
|
||||||
|
o.splitFlips++
|
||||||
|
if o.splitFlips >= flipsToArm {
|
||||||
|
o.splitFlaky = true
|
||||||
|
}
|
||||||
|
} else if o.splitFlaky && now.Sub(o.splitFlipWindow) > flipWindow {
|
||||||
|
o.splitFlaky, o.splitFlips = false, 0 // stopped oscillating — drop the delay
|
||||||
|
}
|
||||||
|
splitRecentOn := o.splitFlaky && !o.lastSplitOnAt.IsZero() && now.Sub(o.lastSplitOnAt) < 6*time.Second
|
||||||
|
|
||||||
|
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(o.rigType, freqMain, freqA, freqB, s.Vfo, splitRaw, splitRecentOn)
|
||||||
|
|
||||||
|
// Diagnostic logged ONLY when Split or VFO changes (not on a timer), so normal
|
||||||
|
// operation stays quiet but toggling split or SUB VFO on the radio is
|
||||||
|
// captured. It logs the RESOLVED tx/rx/split too: with the raw values alone a
|
||||||
|
// user's log showed what OmniRig said but not what OpsLog concluded, which is
|
||||||
|
// the half that was wrong.
|
||||||
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
|
if sig := fmt.Sprintf("%x:%x", splitRaw, rawVfo); sig != o.lastSig {
|
||||||
o.lastSig = sig
|
o.lastSig = sig
|
||||||
debugLog.Printf("OmniRig Rig%d raw: Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X status=%d",
|
debugLog.Printf("OmniRig Rig%d raw: rig=%q Freq=%d FreqA=%d FreqB=%d Vfo=%q(raw=0x%X) Split=0x%X sticky=%v → tx=%d rx=%d split=%v",
|
||||||
o.RigNum, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, func() int64 {
|
o.RigNum, o.rigType, freqMain, freqA, freqB, s.Vfo, rawVfo, splitRaw, splitRecentOn,
|
||||||
if v, e := oleutil.GetProperty(o.rig, "Status"); e == nil {
|
s.FreqHz, s.RxFreqHz, s.Split)
|
||||||
return v.Val
|
|
||||||
}
|
}
|
||||||
return -1
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|
||||||
s.FreqHz, s.RxFreqHz, s.Split = resolveOmniRigVFOs(freqMain, freqA, freqB, s.Vfo, splitRaw)
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,14 +329,14 @@ func (o *OmniRig) ReadState() (RigState, error) {
|
|||||||
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
|
// contradict each other — what fixes a Yaesu can break an Icom — and the only way
|
||||||
// to change it safely is with every known rig's behaviour pinned in a test. COM
|
// to change it safely is with every known rig's behaviour pinned in a test. COM
|
||||||
// cannot be exercised from a test; this can.
|
// cannot be exercised from a test; this can.
|
||||||
func resolveOmniRigVFOs(freqMain, freqA, freqB int64, vfo string, splitRaw int64) (txHz, rxHz int64, split bool) {
|
func resolveOmniRigVFOs(rigType string, freqMain, freqA, freqB int64, vfo string, splitRaw int64, splitRecentOn bool) (txHz, rxHz int64, split bool) {
|
||||||
// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
|
// PM_SPLITON is tested as a BIT, not by equality. OmniRig's Split is a flag
|
||||||
// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
|
// word: an exact `== 0x8000` holds only for a rig whose ini sets that bit and
|
||||||
// nothing else, and silently reports "no split" for any rig reporting the bit
|
// nothing else, and silently reports "no split" for any rig reporting the bit
|
||||||
// alongside another. Requiring ON set and OFF clear keeps the two states apart
|
// alongside another. Requiring ON set and OFF clear keeps the two states apart
|
||||||
// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
|
// (both flags are non-zero, so a bare `!= 0` would read OFF as split) while
|
||||||
// tolerating extra bits.
|
// tolerating extra bits.
|
||||||
splitFlagged := splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0
|
splitFlagged := (splitRaw&pmSplitOn != 0 && splitRaw&pmSplitOff == 0) || splitRecentOn
|
||||||
|
|
||||||
// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
|
// A genuine split also needs two distinct, non-zero VFOs in the SAME band. The
|
||||||
// band test kills the common false positive where VFO B merely holds a
|
// band test kills the common false positive where VFO B merely holds a
|
||||||
@@ -316,6 +369,32 @@ func resolveOmniRigVFOs(freqMain, freqA, freqB int64, vfo string, splitRaw int64
|
|||||||
// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
|
// the IC-7610, whose stock ini reports the generic Freq as VFO B; and for the
|
||||||
// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
|
// PM_FREQA rigs (Yaesu, Kenwood) versus the Icoms (IC-9100) that populate only
|
||||||
// the generic Freq.
|
// the generic Freq.
|
||||||
|
// The PAIR enums name BOTH VFOs at once — first letter = the one being
|
||||||
|
// listened on, second = the one that transmits. Only the single-letter forms
|
||||||
|
// were honoured here, so a rig that reports nothing but pairs (the whole Yaesu
|
||||||
|
// family) always fell through to freqA and never followed the operator to SUB.
|
||||||
|
// Log4OM reads that first letter and logs the right frequency on the same
|
||||||
|
// rigs, which is what showed this was readable data and not a dead end.
|
||||||
|
//
|
||||||
|
// Checked BEFORE the Yaesu fallback below: an enum that names a VFO is the
|
||||||
|
// rig speaking, the fallback is only an inference.
|
||||||
|
switch {
|
||||||
|
case (vfo == "BA" || vfo == "BB") && freqB != 0:
|
||||||
|
return freqB, 0, false
|
||||||
|
case (vfo == "AA" || vfo == "AB") && freqA != 0:
|
||||||
|
return freqA, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yaesu fallback, for a rig file that names no VFO at all (the stock FTDX10
|
||||||
|
// one: it answers neither VS; nor FR; usably, verified on the air 2026-07-26).
|
||||||
|
// There the generic Freq is the last clue — matching FreqB and not FreqA means
|
||||||
|
// the operator is on SUB. Limited to Yaesu: the IC-7610's stock ini reports
|
||||||
|
// the generic Freq as VFO B permanently, where this would name the wrong VFO —
|
||||||
|
// that case is pinned in the test table.
|
||||||
|
if isYaesuRig(rigType) && freqMain != 0 && freqMain == freqB && freqB != freqA {
|
||||||
|
return freqB, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case (vfo == "B" || vfo == "BB") && freqB != 0:
|
case (vfo == "B" || vfo == "BB") && freqB != 0:
|
||||||
return freqB, 0, false
|
return freqB, 0, false
|
||||||
@@ -601,6 +680,13 @@ func omniRigMode(m int64) string {
|
|||||||
|
|
||||||
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
|
// omniRigVfo maps the OmniRig Vfo RigParamX enum to a short label, using the
|
||||||
// documented PM_VFO* constants.
|
// documented PM_VFO* constants.
|
||||||
|
// isYaesuRig recognises a Yaesu from OmniRig's RigType (the .ini title, e.g.
|
||||||
|
// "FTDX101D", "FT-891"). Only used to gate rules that are true for Yaesu and
|
||||||
|
// false for Icom, so a mis-titled ini simply keeps the generic behaviour.
|
||||||
|
func isYaesuRig(rigType string) bool {
|
||||||
|
return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(rigType)), "FT")
|
||||||
|
}
|
||||||
|
|
||||||
func omniRigVfo(v int64) string {
|
func omniRigVfo(v int64) string {
|
||||||
switch {
|
switch {
|
||||||
case v&0x40 != 0: // PM_VFOAA
|
case v&0x40 != 0: // PM_VFOAA
|
||||||
|
|||||||
@@ -14,40 +14,61 @@ func TestResolveOmniRigVFOs(t *testing.T) {
|
|||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
rig string
|
||||||
main, fa, fb int64
|
main, fa, fb int64
|
||||||
vfo string
|
vfo string
|
||||||
split int64
|
split int64
|
||||||
|
sticky bool
|
||||||
wantTX, wantRX int64
|
wantTX, wantRX int64
|
||||||
wantSplit bool
|
wantSplit bool
|
||||||
}{
|
}{
|
||||||
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
|
// The reported failure: FTDX101D, SUB VFO pressed. OmniRig names VFO B and
|
||||||
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
|
// reports it as the generic Freq; freqA still holds the main VFO. Preferring
|
||||||
// freqA meant the display never followed the operator to B.
|
// freqA meant the display never followed the operator to B.
|
||||||
{"FTDX101D on SUB VFO", b14205, a14200, b14205, "B", pmSplitOff, b14205, 0, false},
|
{"FTDX101D on SUB VFO", "FTDX101D", b14205, a14200, b14205, "B", pmSplitOff, false, b14205, 0, false},
|
||||||
{"FTDX101D on MAIN VFO", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
|
{"FTDX101D on MAIN VFO", "FTDX101D", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
|
||||||
|
|
||||||
|
// Yaesu fallback for a rig file that names NO VFO at all (the stock FTDX10
|
||||||
|
// one, confirmed 2026-07-26): the generic Freq matching FreqB is then the
|
||||||
|
// only sign that the operator is on SUB. It applies ONLY when the enum is
|
||||||
|
// silent — when the enum names a VFO, the enum wins (pair cases below).
|
||||||
|
{"FTDX10, no enum, generic Freq is B", "FTDX10", b14205, a14200, b14205, "", pmSplitOff, false, b14205, 0, false},
|
||||||
|
// Same alternating ini: one poll says OFF while the rig IS in split. The
|
||||||
|
// latched ON flag has to survive the contradicting sample.
|
||||||
|
{"FTDX101D split, contradicting OFF sample", "FTDX101D", a14200, a14200, b14205, "AB", pmSplitOff, true, b14205, a14200, true},
|
||||||
|
// The latch must not manufacture a split out of a stale cross-band VFO B.
|
||||||
|
{"FTDX101D latch, VFOs on different bands", "FTDX101D", a14200, a14200, b21000, "AB", pmSplitOff, true, a14200, 0, false},
|
||||||
|
|
||||||
|
// Pair enums: first letter is the VFO being listened on. Reported by the
|
||||||
|
// whole Yaesu family; ignoring them pinned the display to VFO A (F4NBZ,
|
||||||
|
// 2026-07-26 — Log4OM follows SUB on the same rig through OmniRig).
|
||||||
|
{"pair enum BA, simplex → listening on B", "", b14205, a14200, b14205, "BA", pmSplitOff, false, b14205, 0, false},
|
||||||
|
{"pair enum BB, simplex → listening on B", "", b14205, a14200, b14205, "BB", pmSplitOff, false, b14205, 0, false},
|
||||||
|
{"pair enum AB, simplex → listening on A", "", a14200, a14200, b14205, "AB", pmSplitOff, false, a14200, 0, false},
|
||||||
|
{"pair enum AA, simplex → listening on A", "", a14200, a14200, b14205, "AA", pmSplitOff, false, a14200, 0, false},
|
||||||
|
|
||||||
// Non-regression: a rig that does not report the VFO enum keeps the old
|
// Non-regression: a rig that does not report the VFO enum keeps the old
|
||||||
// order — freqA, then the generic Freq, then freqB.
|
// order — freqA, then the generic Freq, then freqB.
|
||||||
{"no VFO enum, freqA populated (Yaesu/Kenwood)", a14200, a14200, 0, "", pmSplitOff, a14200, 0, false},
|
{"no VFO enum, freqA populated (Yaesu/Kenwood)", "FT-891", a14200, a14200, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||||
{"no VFO enum, only generic Freq (IC-9100)", a14200, 0, 0, "", pmSplitOff, a14200, 0, false},
|
{"no VFO enum, only generic Freq (IC-9100)", "IC-9100", a14200, 0, 0, "", pmSplitOff, false, a14200, 0, false},
|
||||||
{"IC-7610: generic Freq reports B, enum says A", b14205, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
|
{"IC-7610: generic Freq reports B, enum says A", "IC-7610", b14205, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
|
||||||
|
|
||||||
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
|
// Split: PM_SPLITON must be read as a BIT. An exact == 0x8000 reported "no
|
||||||
// split" for any rig that sets the flag alongside another bit.
|
// split" for any rig that sets the flag alongside another bit.
|
||||||
{"split, ON flag alone", a14200, a14200, b14205, "AB", pmSplitOn, b14205, a14200, true},
|
{"split, ON flag alone", "", a14200, a14200, b14205, "AB", pmSplitOn, false, b14205, a14200, true},
|
||||||
{"split, ON flag with extra bits set", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, b14205, a14200, true},
|
{"split, ON flag with extra bits set", "", a14200, a14200, b14205, "AB", pmSplitOn | 0x40, false, b14205, a14200, true},
|
||||||
{"listening on B → TX on A", b14205, a14200, b14205, "BA", pmSplitOn, a14200, b14205, true},
|
{"listening on B → TX on A", "", b14205, a14200, b14205, "BA", pmSplitOn, false, a14200, b14205, true},
|
||||||
|
|
||||||
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
|
// Split must NOT be inferred when the rig says OFF, nor from a stale VFO B
|
||||||
// left on another band (the FT-710 / TS-570 false positive).
|
// left on another band (the FT-710 / TS-570 false positive).
|
||||||
{"OFF flag, two distinct VFOs", a14200, a14200, b14205, "A", pmSplitOff, a14200, 0, false},
|
{"OFF flag, two distinct VFOs", "", a14200, a14200, b14205, "A", pmSplitOff, false, a14200, 0, false},
|
||||||
{"ON flag but VFOs on different bands", a14200, a14200, b21000, "AB", pmSplitOn, a14200, 0, false},
|
{"ON flag but VFOs on different bands", "", a14200, a14200, b21000, "AB", pmSplitOn, false, a14200, 0, false},
|
||||||
{"ON flag but both VFOs identical", a14200, a14200, a14200, "AB", pmSplitOn, a14200, 0, false},
|
{"ON flag but both VFOs identical", "", a14200, a14200, a14200, "AB", pmSplitOn, false, a14200, 0, false},
|
||||||
{"ON and OFF both set — ambiguous, treat as no split", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, a14200, 0, false},
|
{"ON and OFF both set — ambiguous, treat as no split", "", a14200, a14200, b14205, "A", pmSplitOn | pmSplitOff, false, a14200, 0, false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
tx, rx, split := resolveOmniRigVFOs(c.main, c.fa, c.fb, c.vfo, c.split)
|
tx, rx, split := resolveOmniRigVFOs(c.rig, c.main, c.fa, c.fb, c.vfo, c.split, c.sticky)
|
||||||
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
|
if tx != c.wantTX || rx != c.wantRX || split != c.wantSplit {
|
||||||
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
|
t.Errorf("%s:\n got TX=%d RX=%d split=%v\n want TX=%d RX=%d split=%v",
|
||||||
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
|
c.name, tx, rx, split, c.wantTX, c.wantRX, c.wantSplit)
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
// Package catemu emulates a transceiver on a serial port so a device that
|
||||||
|
// POLLS a radio for its frequency can follow OpsLog instead.
|
||||||
|
//
|
||||||
|
// This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is
|
||||||
|
// the master — it polls the transceiver every few hundred milliseconds and
|
||||||
|
// changes band only when it gets a valid reply. There is no way to push a
|
||||||
|
// frequency to it, so following OpsLog means answering its polls.
|
||||||
|
//
|
||||||
|
// The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what
|
||||||
|
// Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is
|
||||||
|
// the simplest of the five sets by a wide margin, which is why the SDC utility
|
||||||
|
// uses it to steer an ACOM with no physical radio attached.
|
||||||
|
//
|
||||||
|
// Only the handful of commands an amp actually asks for are implemented:
|
||||||
|
//
|
||||||
|
// FA; → FA00014025000; TX frequency, 11 digits, Hz
|
||||||
|
// FB; → same (sub VFO — amps poll it on some firmware)
|
||||||
|
// IF; → the 38-character TS-2000 status frame
|
||||||
|
// ID; → ID019; (TS-2000 — a known model keeps the amp from timing out)
|
||||||
|
//
|
||||||
|
// Anything else is ignored rather than answered: a wrong-length reply is worse
|
||||||
|
// than none, because it desynchronises the amp's parser for the next poll.
|
||||||
|
package catemu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is the serial port the amplifier's CAT/AUX cable is wired to. This is
|
||||||
|
// a SECOND port, independent of the one used for the amp's own remote/metering
|
||||||
|
// protocol — both run at the same time on an ACOM.
|
||||||
|
type Config struct {
|
||||||
|
ComPort string
|
||||||
|
Baud int
|
||||||
|
|
||||||
|
// BroadcastMs > 0 also sends the frequency UNPROMPTED every that many
|
||||||
|
// milliseconds. Some amplifiers do not poll at all: they sit in parallel on
|
||||||
|
// the radio↔PC CAT line and read whatever goes past. Such an amp would never
|
||||||
|
// hear us, since answering polls means speaking only when spoken to.
|
||||||
|
// 0 = answer polls only.
|
||||||
|
BroadcastMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what the settings panel shows about the link.
|
||||||
|
type Status struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Port string `json:"port"`
|
||||||
|
Polls int64 `json:"polls"` // replies sent since start
|
||||||
|
LastCmd string `json:"last_cmd"` // last command received, e.g. "FA;"
|
||||||
|
LastAt string `json:"last_at"` // RFC3339 of the last poll, "" if none
|
||||||
|
FreqHz int64 `json:"freq_hz"` // what we are currently answering
|
||||||
|
Error string `json:"error"` // last open/IO failure
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server answers a polling amplifier on one serial port.
|
||||||
|
type Server struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
status Status
|
||||||
|
|
||||||
|
freqHz atomic.Int64
|
||||||
|
mode atomic.Value // string, ADIF-ish ("CW", "USB"…)
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
|
||||||
|
logf func(string, ...any)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a server. Nothing is opened until Start.
|
||||||
|
func New(cfg Config, logf func(string, ...any)) *Server {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 9600
|
||||||
|
}
|
||||||
|
s := &Server{cfg: cfg, logf: logf}
|
||||||
|
s.mode.Store("")
|
||||||
|
s.status.Port = cfg.ComPort
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) log(format string, args ...any) {
|
||||||
|
if s.logf != nil {
|
||||||
|
s.logf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency updates the frequency reported to the amplifier. Called from the
|
||||||
|
// CAT state callback; safe from any goroutine and never blocks — the serve loop
|
||||||
|
// reads the value when a poll arrives, so a fast-tuning VFO costs nothing.
|
||||||
|
func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) }
|
||||||
|
|
||||||
|
// SetMode updates the mode digit in the IF frame. Optional: the amp only cares
|
||||||
|
// about the frequency, but a coherent frame avoids odd firmware behaviour.
|
||||||
|
func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) }
|
||||||
|
|
||||||
|
// Start opens the port and serves polls until Stop. It returns immediately;
|
||||||
|
// a port that is missing or busy is retried every 5 s, because the amplifier is
|
||||||
|
// often powered on after the software.
|
||||||
|
func (s *Server) Start() {
|
||||||
|
s.stop = make(chan struct{})
|
||||||
|
s.done = make(chan struct{})
|
||||||
|
go s.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes the port and waits for the loop to end.
|
||||||
|
func (s *Server) Stop() {
|
||||||
|
if s.stop == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
close(s.stop)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.port != nil {
|
||||||
|
_ = s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
<-s.done
|
||||||
|
s.stop = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns a snapshot for the UI.
|
||||||
|
func (s *Server) GetStatus() Status {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
st := s.status
|
||||||
|
st.Enabled = true
|
||||||
|
st.FreqHz = s.freqHz.Load()
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setErr(msg string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.Error = msg
|
||||||
|
s.status.Connected = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) run() {
|
||||||
|
defer close(s.done)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err := s.open(); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err)
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.serve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) open() error {
|
||||||
|
if strings.TrimSpace(s.cfg.ComPort) == "" {
|
||||||
|
return fmt.Errorf("no COM port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(s.cfg.ComPort, &serial.Mode{
|
||||||
|
BaudRate: s.cfg.Baud,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: serial.NoParity,
|
||||||
|
StopBits: serial.OneStopBit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A short read timeout keeps the loop responsive to Stop while idle: the amp
|
||||||
|
// may poll only every few hundred ms, and a blocking read would hold the
|
||||||
|
// port open past shutdown.
|
||||||
|
_ = p.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.port = p
|
||||||
|
s.status.Connected = true
|
||||||
|
s.status.Error = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// broadcast sends an unsolicited FA frame at the configured interval, for an
|
||||||
|
// amplifier that listens to the CAT line rather than polling it. It stops when
|
||||||
|
// the port is closed or Stop is called.
|
||||||
|
func (s *Server) broadcast(stopServe <-chan struct{}) {
|
||||||
|
if s.cfg.BroadcastMs <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Below ~100 ms this is pure noise on the wire; the band only ever changes
|
||||||
|
// at human speed.
|
||||||
|
every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond
|
||||||
|
if every < 100*time.Millisecond {
|
||||||
|
every = 100 * time.Millisecond
|
||||||
|
}
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
case <-stopServe:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
hz := s.freqHz.Load()
|
||||||
|
if hz <= 0 {
|
||||||
|
continue // nothing known yet — say nothing rather than "0 Hz"
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
p := s.port
|
||||||
|
s.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve reads commands until the port fails or Stop is called.
|
||||||
|
func (s *Server) serve() {
|
||||||
|
// The broadcaster shares this port and must die with it, or it would write
|
||||||
|
// into a closed handle after a reopen.
|
||||||
|
stopServe := make(chan struct{})
|
||||||
|
defer close(stopServe)
|
||||||
|
go s.broadcast(stopServe)
|
||||||
|
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
var acc []byte
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
p := s.port
|
||||||
|
s.mu.Unlock()
|
||||||
|
if p == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := p.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err)
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.port != nil {
|
||||||
|
_ = s.port.Close()
|
||||||
|
s.port = nil
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
acc = append(acc, buf[:n]...)
|
||||||
|
// Commands are ';'-terminated; handle every complete one in the buffer.
|
||||||
|
for {
|
||||||
|
i := indexByte(acc, ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i])))
|
||||||
|
acc = acc[i+1:]
|
||||||
|
s.handle(p, cmd)
|
||||||
|
}
|
||||||
|
// A runaway buffer means we are seeing something that is not this
|
||||||
|
// protocol (wrong baud, or the amp's other port); drop it rather than
|
||||||
|
// grow without bound.
|
||||||
|
if len(acc) > 512 {
|
||||||
|
acc = acc[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexByte(b []byte, c byte) int {
|
||||||
|
for i := range b {
|
||||||
|
if b[i] == c {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle answers one command. cmd has no trailing ';'.
|
||||||
|
func (s *Server) handle(p serial.Port, cmd string) {
|
||||||
|
hz := s.freqHz.Load()
|
||||||
|
var reply string
|
||||||
|
switch {
|
||||||
|
case cmd == "FA" || cmd == "FB":
|
||||||
|
reply = fmt.Sprintf("%s%011d;", cmd, hz)
|
||||||
|
case cmd == "IF":
|
||||||
|
reply = s.ifFrame(hz)
|
||||||
|
case cmd == "ID":
|
||||||
|
reply = "ID019;" // TS-2000
|
||||||
|
default:
|
||||||
|
// Unknown or a SET command (FA00014025000;) — silently ignored: an amp
|
||||||
|
// never sets our frequency, and answering the wrong length would break
|
||||||
|
// its parser for the following poll.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := p.Write([]byte(reply)); err != nil {
|
||||||
|
s.setErr(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status.Polls++
|
||||||
|
s.status.LastCmd = cmd + ";"
|
||||||
|
s.status.LastAt = time.Now().Format(time.RFC3339)
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeDigit maps our mode name to the Kenwood mode digit used in IF.
|
||||||
|
func (s *Server) modeDigit() byte {
|
||||||
|
m, _ := s.mode.Load().(string)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(m, "CW"):
|
||||||
|
return '3'
|
||||||
|
case strings.HasPrefix(m, "LSB"):
|
||||||
|
return '1'
|
||||||
|
case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"):
|
||||||
|
return '2'
|
||||||
|
case strings.HasPrefix(m, "FM"):
|
||||||
|
return '4'
|
||||||
|
case strings.HasPrefix(m, "AM"):
|
||||||
|
return '5'
|
||||||
|
case m == "RTTY", strings.HasPrefix(m, "FSK"):
|
||||||
|
return '6'
|
||||||
|
case m == "":
|
||||||
|
return '2'
|
||||||
|
default:
|
||||||
|
// Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares.
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ifFrame builds the 38-character TS-2000 IF status frame:
|
||||||
|
//
|
||||||
|
// IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode |
|
||||||
|
// FR | scan | split | tone | tone#(2) | shift | ;
|
||||||
|
//
|
||||||
|
// Only the frequency and mode carry meaning here; the rest is a valid, inert
|
||||||
|
// state (no RIT, receiving, simplex) so the amp's parser is satisfied.
|
||||||
|
func (s *Server) ifFrame(hz int64) string {
|
||||||
|
return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;",
|
||||||
|
hz, // P1 frequency, Hz
|
||||||
|
0, // P2 frequency step
|
||||||
|
0, // P3 RIT/XIT offset, signed 5 digits
|
||||||
|
0, // P4-P6 RIT off, XIT off, channel-bank
|
||||||
|
0, // P7 memory channel
|
||||||
|
0, // P8 0 = RX
|
||||||
|
s.modeDigit(), // P9 mode
|
||||||
|
0, // P10 VFO A
|
||||||
|
0, // P11 scan off
|
||||||
|
0, // P12 split off
|
||||||
|
0, // P13 tone off
|
||||||
|
0, // P14 tone number
|
||||||
|
0, // P15 shift
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package catemu
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The reply LENGTHS are the contract: an amplifier parses these frames by
|
||||||
|
// fixed offsets, so a frame one character short desynchronises its parser for
|
||||||
|
// every following poll. Pin them.
|
||||||
|
func TestReplyShapes(t *testing.T) {
|
||||||
|
s := New(Config{ComPort: "COM99", Baud: 9600}, nil)
|
||||||
|
s.SetFrequency(14025000)
|
||||||
|
s.SetMode("CW")
|
||||||
|
|
||||||
|
if got := s.ifFrame(14025000); len(got) != 38 {
|
||||||
|
t.Errorf("IF frame is %d chars, want 38: %q", len(got), got)
|
||||||
|
}
|
||||||
|
// TS-2000 IF: "IF" then the 11-digit frequency in Hz.
|
||||||
|
if got := s.ifFrame(14025000); got[:13] != "IF00014025000" {
|
||||||
|
t.Errorf("IF frame frequency field = %q", got[:13])
|
||||||
|
}
|
||||||
|
if got := s.ifFrame(14025000); got[len(got)-1] != ';' {
|
||||||
|
t.Errorf("IF frame not terminated by ';': %q", got)
|
||||||
|
}
|
||||||
|
// Mode digit sits at P9, right after freq(11)+step(4)+rit(6)+3+2+1 — index 29 in the frame.
|
||||||
|
if got := s.ifFrame(14025000)[29]; got != '3' {
|
||||||
|
t.Errorf("CW mode digit = %q, want '3'", got)
|
||||||
|
}
|
||||||
|
s.SetMode("FT8") // data rides on SSB as far as an amp cares
|
||||||
|
if got := s.ifFrame(14074000)[29]; got != '2' {
|
||||||
|
t.Errorf("FT8 mode digit = %q, want '2'", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeDigit(t *testing.T) {
|
||||||
|
cases := map[string]byte{
|
||||||
|
"CW": '3', "CW-R": '3', "LSB": '1', "USB": '2', "SSB": '2',
|
||||||
|
"FM": '4', "AM": '5', "RTTY": '6', "": '2', "FT8": '2',
|
||||||
|
}
|
||||||
|
for mode, want := range cases {
|
||||||
|
s := New(Config{}, nil)
|
||||||
|
s.SetMode(mode)
|
||||||
|
if got := s.modeDigit(); got != want {
|
||||||
|
t.Errorf("mode %q → %q, want %q", mode, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cloudlog (and its fork Wavelog) are self-hosted logbooks, so there is no
|
||||||
|
// fixed endpoint: the user gives the base URL of their own instance and we
|
||||||
|
// append the API path. Both expose the SAME contract — an ADIF record wrapped
|
||||||
|
// in JSON — which is why one uploader serves both.
|
||||||
|
//
|
||||||
|
// POST <base>/index.php/api/qso
|
||||||
|
// {"key":"…","station_profile_id":"1","type":"adif","string":"<call:4>… <eor>"}
|
||||||
|
//
|
||||||
|
// The station profile id is NOT optional: Cloudlog files the QSO under one of
|
||||||
|
// the account's station locations, and a wrong id silently lands the contact in
|
||||||
|
// someone else's log slot.
|
||||||
|
const cloudlogAPIPath = "index.php/api/qso"
|
||||||
|
|
||||||
|
// cloudlogEndpoint builds the API URL from whatever the user pasted. People
|
||||||
|
// paste the dashboard URL, the API URL, with or without a trailing slash or
|
||||||
|
// index.php, so normalise all of it rather than make them guess the exact form.
|
||||||
|
func cloudlogEndpoint(base string) (string, error) {
|
||||||
|
u := strings.TrimSpace(base)
|
||||||
|
if u == "" {
|
||||||
|
return "", fmt.Errorf("cloudlog: URL not set")
|
||||||
|
}
|
||||||
|
// A bare host or IP is almost always meant as http:// on a LAN instance;
|
||||||
|
// requiring the scheme just produces a confusing "unsupported protocol".
|
||||||
|
if !strings.HasPrefix(strings.ToLower(u), "http://") && !strings.HasPrefix(strings.ToLower(u), "https://") {
|
||||||
|
u = "http://" + u
|
||||||
|
}
|
||||||
|
u = strings.TrimRight(u, "/")
|
||||||
|
// Trim anything the user copied past the site root, so both
|
||||||
|
// "https://log.f4bpo.fr" and "https://log.f4bpo.fr/index.php/api/qso" work.
|
||||||
|
for _, suffix := range []string{"/index.php/api/qso", "/api/qso", "/index.php"} {
|
||||||
|
if strings.HasSuffix(strings.ToLower(u), suffix) {
|
||||||
|
u = u[:len(u)-len(suffix)]
|
||||||
|
u = strings.TrimRight(u, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u + "/" + cloudlogAPIPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloudlogRequest is the JSON body both Cloudlog and Wavelog expect.
|
||||||
|
type cloudlogRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
StationID string `json:"station_profile_id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
String string `json:"string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloudlogReply covers the documented failure shape
|
||||||
|
// ({"status":"failed","reason":"missing api key"}); success replies vary
|
||||||
|
// between versions, so success is judged on the HTTP status plus the ABSENCE
|
||||||
|
// of a failure marker rather than on a field that may not be there.
|
||||||
|
type cloudlogReply struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
String string `json:"string"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloudlogPost sends one JSON body and returns the trimmed response.
|
||||||
|
func cloudlogPost(ctx context.Context, client *http.Client, endpoint string, body cloudlogRequest) (string, int, error) {
|
||||||
|
buf, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("cloudlog: encode request: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("cloudlog: build request: %w", err)
|
||||||
|
}
|
||||||
|
// Content-Type is what most "wrong JSON" reports come down to: without it
|
||||||
|
// Cloudlog falls back to form-decoding and never sees the fields.
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("cloudlog: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
return strings.TrimSpace(string(raw)), resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloudlogReason turns a reply into a human-readable failure reason, or ""
|
||||||
|
// when the reply looks like a success.
|
||||||
|
func cloudlogReason(body string, status int) string {
|
||||||
|
var r cloudlogReply
|
||||||
|
if json.Unmarshal([]byte(body), &r) == nil && strings.EqualFold(r.Status, "failed") {
|
||||||
|
if r.Reason != "" {
|
||||||
|
return r.Reason
|
||||||
|
}
|
||||||
|
return "rejected"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case status == http.StatusUnauthorized || status == http.StatusForbidden:
|
||||||
|
// The documented 401 body is {"status":"failed","reason":"missing api key"},
|
||||||
|
// but a reverse proxy in front of the instance can swallow it.
|
||||||
|
return "API key refused"
|
||||||
|
case status == http.StatusNotFound:
|
||||||
|
return "API not found at this URL — check the address of your Cloudlog/Wavelog instance"
|
||||||
|
case status >= 400:
|
||||||
|
msg := body
|
||||||
|
if len(msg) > 200 {
|
||||||
|
msg = msg[:200]
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", status)
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadCloudlog pushes one ADIF record to a Cloudlog or Wavelog instance.
|
||||||
|
//
|
||||||
|
// Duplicates are handled by the server (Cloudlog dedupes on the fly), so a
|
||||||
|
// retry of an already-accepted QSO is harmless — the upload stays idempotent
|
||||||
|
// without OpsLog having to track it.
|
||||||
|
func UploadCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig, adifRecord string) (UploadResult, error) {
|
||||||
|
endpoint, err := cloudlogEndpoint(cfg.URL)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{}, err
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(cfg.APIKey)
|
||||||
|
station := strings.TrimSpace(cfg.StationID)
|
||||||
|
if key == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("cloudlog: API key not set")
|
||||||
|
}
|
||||||
|
if station == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("cloudlog: station ID not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(adifRecord) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("cloudlog: empty adif record")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
|
||||||
|
Key: key, StationID: station, Type: "adif", String: adifRecord,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{OK: false, Message: body}, err
|
||||||
|
}
|
||||||
|
// The endpoint is echoed in both outcomes: a self-hosted instance means the
|
||||||
|
// URL itself is a prime suspect, and a log line naming what was actually
|
||||||
|
// called settles it without the operator having to guess how we normalised
|
||||||
|
// what they typed.
|
||||||
|
if reason := cloudlogReason(body, status); reason != "" {
|
||||||
|
return UploadResult{OK: false, Message: reason},
|
||||||
|
fmt.Errorf("cloudlog: POST %s → HTTP %d: %s", endpoint, status, reason)
|
||||||
|
}
|
||||||
|
return UploadResult{OK: true, Message: fmt.Sprintf("uploaded to %s (HTTP %d)", endpoint, status)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloudlog validates URL, API key and station ID with a REAL request.
|
||||||
|
//
|
||||||
|
// It posts a well-formed body with an EMPTY ADIF string: the credentials are
|
||||||
|
// checked by the server before the record is parsed, so a bad key or id fails
|
||||||
|
// exactly as it would for a real QSO, while nothing is inserted.
|
||||||
|
func TestCloudlog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
endpoint, err := cloudlogEndpoint(cfg.URL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := strings.TrimSpace(cfg.APIKey)
|
||||||
|
station := strings.TrimSpace(cfg.StationID)
|
||||||
|
if key == "" {
|
||||||
|
return "", fmt.Errorf("cloudlog: API key not set")
|
||||||
|
}
|
||||||
|
if station == "" {
|
||||||
|
return "", fmt.Errorf("cloudlog: station ID not set")
|
||||||
|
}
|
||||||
|
body, status, err := cloudlogPost(ctx, client, endpoint, cloudlogRequest{
|
||||||
|
Key: key, StationID: station, Type: "adif", String: "",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reason := cloudlogReason(body, status); reason != "" {
|
||||||
|
return "", fmt.Errorf("cloudlog: %s", reason)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Connected — station profile %s", station), nil
|
||||||
|
}
|
||||||
@@ -33,6 +33,9 @@ const (
|
|||||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||||
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||||||
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||||||
|
// ServiceCloudlog covers Cloudlog AND its fork Wavelog: same API contract,
|
||||||
|
// only the instance URL differs, so one service handles both.
|
||||||
|
ServiceCloudlog Service = "cloudlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||||
@@ -63,6 +66,8 @@ const (
|
|||||||
// user can run e.g. Club Log immediate and QRZ delayed).
|
// user can run e.g. Club Log immediate and QRZ delayed).
|
||||||
type ServiceConfig struct {
|
type ServiceConfig struct {
|
||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
|
URL string `json:"url"` // Cloudlog/Wavelog: base URL of the user's own instance
|
||||||
|
StationID string `json:"station_id"` // Cloudlog/Wavelog: station profile (location) id
|
||||||
Email string `json:"email"` // Club Log account email
|
Email string `json:"email"` // Club Log account email
|
||||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||||
Password string `json:"password"` // Club Log account / LoTW website password
|
Password string `json:"password"` // Club Log account / LoTW website password
|
||||||
@@ -83,6 +88,8 @@ type ServiceConfig struct {
|
|||||||
// mode (defaults to immediate).
|
// mode (defaults to immediate).
|
||||||
func (c ServiceConfig) normalised() ServiceConfig {
|
func (c ServiceConfig) normalised() ServiceConfig {
|
||||||
c.APIKey = strings.TrimSpace(c.APIKey)
|
c.APIKey = strings.TrimSpace(c.APIKey)
|
||||||
|
c.URL = strings.TrimSpace(c.URL)
|
||||||
|
c.StationID = strings.TrimSpace(c.StationID)
|
||||||
c.Email = strings.TrimSpace(c.Email)
|
c.Email = strings.TrimSpace(c.Email)
|
||||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||||
c.Code = strings.TrimSpace(c.Code)
|
c.Code = strings.TrimSpace(c.Code)
|
||||||
@@ -120,6 +127,7 @@ type ExternalServices struct {
|
|||||||
LoTW ServiceConfig `json:"lotw"`
|
LoTW ServiceConfig `json:"lotw"`
|
||||||
HRDLog ServiceConfig `json:"hrdlog"`
|
HRDLog ServiceConfig `json:"hrdlog"`
|
||||||
EQSL ServiceConfig `json:"eqsl"`
|
EQSL ServiceConfig `json:"eqsl"`
|
||||||
|
Cloudlog ServiceConfig `json:"cloudlog"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResult is the outcome of a single upload attempt.
|
// UploadResult is the outcome of a single upload attempt.
|
||||||
|
|||||||
@@ -138,7 +138,30 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
cfg.LoTW = cfg.LoTW.normalised()
|
cfg.LoTW = cfg.LoTW.normalised()
|
||||||
cfg.HRDLog = cfg.HRDLog.normalised()
|
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||||
cfg.EQSL = cfg.EQSL.normalised()
|
cfg.EQSL = cfg.EQSL.normalised()
|
||||||
|
cfg.Cloudlog = cfg.Cloudlog.normalised()
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
|
|
||||||
|
// Summary of what is armed, written at startup and on every settings save.
|
||||||
|
// It answers "is the service even switched on for this profile?" — the
|
||||||
|
// settings are per-profile, so a service configured under another profile
|
||||||
|
// looks enabled in the UI of the one and silent in the other.
|
||||||
|
var on []string
|
||||||
|
for _, s := range []struct {
|
||||||
|
name string
|
||||||
|
cfg ServiceConfig
|
||||||
|
}{
|
||||||
|
{"qrz", cfg.QRZ}, {"clublog", cfg.Clublog}, {"lotw", cfg.LoTW},
|
||||||
|
{"hrdlog", cfg.HRDLog}, {"eqsl", cfg.EQSL}, {"cloudlog", cfg.Cloudlog},
|
||||||
|
} {
|
||||||
|
if s.cfg.AutoUpload {
|
||||||
|
on = append(on, fmt.Sprintf("%s(%s)", s.name, s.cfg.UploadMode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(on) == 0 {
|
||||||
|
m.logf("extsvc: auto-upload disabled for every service")
|
||||||
|
} else {
|
||||||
|
m.logf("extsvc: auto-upload armed for %s", strings.Join(on, " "))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config returns the current snapshot.
|
// Config returns the current snapshot.
|
||||||
@@ -182,6 +205,28 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
||||||
m.route(ServiceEQSL, id, e)
|
m.route(ServiceEQSL, id, e)
|
||||||
}
|
}
|
||||||
|
// Cloudlog / Wavelog — the instance URL, an API key and the station id.
|
||||||
|
if c := cfg.Cloudlog; c.AutoUpload {
|
||||||
|
// Say WHY nothing happens when the toggle is on but a field is missing.
|
||||||
|
// Without this the whole path was silent — the operator saw no upload and
|
||||||
|
// no log line, with no way to tell "disabled" from "broken".
|
||||||
|
var missing []string
|
||||||
|
if c.URL == "" {
|
||||||
|
missing = append(missing, "URL")
|
||||||
|
}
|
||||||
|
if c.APIKey == "" {
|
||||||
|
missing = append(missing, "API key")
|
||||||
|
}
|
||||||
|
if c.StationID == "" {
|
||||||
|
missing = append(missing, "station ID")
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
m.logf("extsvc: cloudlog auto-upload is ON but not configured — missing %s (QSO %d not sent)",
|
||||||
|
strings.Join(missing, ", "), id)
|
||||||
|
} else {
|
||||||
|
m.route(ServiceCloudlog, id, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// route sends a logged QSO down the configured timing path: queue it for the
|
// route sends a logged QSO down the configured timing path: queue it for the
|
||||||
@@ -229,6 +274,9 @@ func (m *Manager) onCloseServices() []Service {
|
|||||||
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
||||||
out = append(out, ServiceEQSL)
|
out = append(out, ServiceEQSL)
|
||||||
}
|
}
|
||||||
|
if c := cfg.Cloudlog; c.AutoUpload && c.UploadMode == ModeOnClose && c.URL != "" && c.APIKey != "" && c.StationID != "" {
|
||||||
|
out = append(out, ServiceCloudlog)
|
||||||
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +336,12 @@ func (m *Manager) FlushOnClose() int {
|
|||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case ServiceCloudlog:
|
||||||
|
for _, id := range ids {
|
||||||
|
if ok, _ := m.upload(svc, id, cfg.Cloudlog); ok {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return uploaded
|
return uploaded
|
||||||
@@ -376,6 +430,11 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One line per attempt, BEFORE the request: a failure that never returns
|
||||||
|
// (hung TCP connect to a self-hosted instance) otherwise leaves no trace at
|
||||||
|
// all, and "did it even try?" is the first question when an upload is missing.
|
||||||
|
m.logf("extsvc: %s uploading QSO %d…", svc, id)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -426,6 +485,15 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
|||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||||
|
case ServiceCloudlog:
|
||||||
|
// Cloudlog/Wavelog file the QSO under a station profile chosen by id,
|
||||||
|
// so the ADIF keeps the QSO's own station call (no override).
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
res, err = UploadCloudlog(ctx, m.deps.Client, cfg, record)
|
||||||
default:
|
default:
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
@@ -441,7 +509,7 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) (ok bool, ret
|
|||||||
return false, true // transient (rate-limit / network) → worth a retry
|
return false, true // transient (rate-limit / network) → worth a retry
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logf("extsvc: %s upload of QSO %d OK (logid=%q)", svc, id, res.LogID)
|
m.logf("extsvc: %s upload of QSO %d OK (logid=%q) %s", svc, id, res.LogID, res.Message)
|
||||||
if m.deps.MarkUploaded != nil {
|
if m.deps.MarkUploaded != nil {
|
||||||
m.deps.MarkUploaded(svc, id, res.LogID)
|
m.deps.MarkUploaded(svc, id, res.LogID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The portable-folder contract: a database that lives inside the application
|
||||||
|
// folder must be stored relative to it, so copying C:\OpsLog to D:\OpsLog (or to
|
||||||
|
// a USB stick) keeps working. A path the operator deliberately put elsewhere
|
||||||
|
// must be left exactly as it is.
|
||||||
|
func TestPortablePath(t *testing.T) {
|
||||||
|
base := appDir()
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("no executable dir available")
|
||||||
|
}
|
||||||
|
|
||||||
|
inside := filepath.Join(base, "data", "logbook.db")
|
||||||
|
if got := portablePath(inside); got != "data/logbook.db" {
|
||||||
|
t.Errorf("inside the app folder: got %q, want %q", got, "data/logbook.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside: an absolute path on another drive / a synced folder is a
|
||||||
|
// deliberate choice and must survive untouched.
|
||||||
|
outside := filepath.FromSlash("Z:/Sync/ham/logbook.db")
|
||||||
|
if got := portablePath(outside); got != outside {
|
||||||
|
t.Errorf("outside the app folder: got %q, want it unchanged", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := portablePath(""); got != "" {
|
||||||
|
t.Errorf("empty path: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePath(t *testing.T) {
|
||||||
|
base := appDir()
|
||||||
|
if base == "" {
|
||||||
|
t.Skip("no executable dir available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A relative path is anchored to the CURRENT install, whatever drive it is on.
|
||||||
|
want := filepath.Join(base, "data", "logbook.db")
|
||||||
|
if got := resolvePath("", "data/logbook.db"); got != want {
|
||||||
|
t.Errorf("relative: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An absolute path that still exists is honoured as-is.
|
||||||
|
dir := t.TempDir()
|
||||||
|
real := filepath.Join(dir, "logbook.db")
|
||||||
|
if err := os.WriteFile(real, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := resolvePath(dir, real); got != real {
|
||||||
|
t.Errorf("existing absolute: got %q, want %q", got, real)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rescue: a path from ANOTHER machine, with the same file present in this
|
||||||
|
// install's data folder — the copied-folder case.
|
||||||
|
stale := filepath.FromSlash("C:/OldPC/OpsLog/data/logbook.db")
|
||||||
|
if got := resolvePath(dir, stale); got != real {
|
||||||
|
t.Errorf("stale absolute with a local twin: got %q, want %q", got, real)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No local twin: leave it alone so the failure is REPORTED rather than
|
||||||
|
// silently replaced by an unrelated database.
|
||||||
|
missing := filepath.FromSlash("C:/OldPC/OpsLog/data/other.db")
|
||||||
|
if got := resolvePath(dir, missing); got != missing {
|
||||||
|
t.Errorf("stale absolute with no twin: got %q, want it unchanged", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.21.3"
|
appVersion = "0.21.5"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user