Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 |
@@ -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"
|
||||||
@@ -917,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
|
||||||
@@ -1512,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.
|
||||||
@@ -1631,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)
|
||||||
}
|
}
|
||||||
@@ -1774,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)
|
||||||
@@ -11955,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
|
||||||
}
|
}
|
||||||
@@ -13343,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 {
|
||||||
@@ -13350,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() {
|
||||||
@@ -13362,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.
|
||||||
@@ -13491,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 {
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.21.4",
|
||||||
"date": "2026-07-26",
|
"date": "2026-07-26",
|
||||||
|
|||||||
+18
-4
@@ -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';
|
||||||
@@ -2356,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.
|
||||||
@@ -4147,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>
|
||||||
@@ -4156,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} myGrid={station.my_grid} 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}
|
||||||
@@ -4192,6 +4200,11 @@ 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}
|
myGrid={station.my_grid}
|
||||||
total={total}
|
total={total}
|
||||||
@@ -5485,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}
|
||||||
@@ -5569,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} myGrid={station.my_grid} 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}
|
||||||
|
|||||||
@@ -521,7 +521,13 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
// 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);
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -2811,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 (
|
||||||
<>
|
<>
|
||||||
@@ -2927,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">
|
||||||
|
|||||||
@@ -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]);
|
||||||
@@ -113,15 +113,18 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
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
|
||||||
@@ -137,7 +140,9 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
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
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ const en: Dict = {
|
|||||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||||
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
||||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier',
|
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.',
|
||||||
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
||||||
// Password encryption
|
// Password encryption
|
||||||
'gen.pwEnc': 'Password encryption',
|
'gen.pwEnc': 'Password encryption',
|
||||||
@@ -563,7 +563,7 @@ const fr: Dict = {
|
|||||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||||
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
||||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur',
|
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.",
|
||||||
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
||||||
// Chiffrement des mots de passe
|
// Chiffrement des mots de passe
|
||||||
'gen.pwEnc': 'Chiffrement des mots de passe',
|
'gen.pwEnc': 'Chiffrement des mots de passe',
|
||||||
|
|||||||
@@ -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.4';
|
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
+3
@@ -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>>;
|
||||||
|
|||||||
@@ -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']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1442,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);
|
||||||
@@ -1458,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 {
|
||||||
|
|||||||
@@ -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,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.4"
|
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