Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5c195ab4 | ||
|
|
139b4675e3 | ||
|
|
2ad72b19fb | ||
|
|
678c8821c2 | ||
|
|
5394b55bb7 |
@@ -30,6 +30,7 @@ import (
|
||||
"hamlog/internal/backup"
|
||||
"hamlog/internal/cabrillo"
|
||||
"hamlog/internal/cat"
|
||||
"hamlog/internal/catemu"
|
||||
"hamlog/internal/clublog"
|
||||
"hamlog/internal/cluster"
|
||||
"hamlog/internal/contest"
|
||||
@@ -917,6 +918,10 @@ func (a *App) startup(ctx context.Context) {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", 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
|
||||
// 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
|
||||
@@ -1512,6 +1517,74 @@ type dbPointer struct {
|
||||
|
||||
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) ──────────────────────────────────────
|
||||
//
|
||||
// Remembered across restarts so the window reopens where and how you left it.
|
||||
@@ -1631,12 +1704,16 @@ func readBootstrap(dataDir string) dbPointer {
|
||||
return 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
|
||||
}
|
||||
|
||||
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, "", " ")
|
||||
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
|
||||
}
|
||||
@@ -1774,6 +1851,15 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
||||
if lp == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
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 {
|
||||
return profile.Profile{}, err
|
||||
}
|
||||
@@ -13343,13 +13433,26 @@ type AmpConfig struct {
|
||||
Port int `json:"port"`
|
||||
ComPort string `json:"com_port"`
|
||||
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 {
|
||||
cfg AmpConfig
|
||||
pgxl *powergenius.Client
|
||||
spe *spe.Client
|
||||
acom *acom.Client
|
||||
cfg AmpConfig
|
||||
pgxl *powergenius.Client
|
||||
spe *spe.Client
|
||||
acom *acom.Client
|
||||
catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM)
|
||||
}
|
||||
|
||||
func (i *ampInst) stopAll() {
|
||||
@@ -13362,6 +13465,9 @@ func (i *ampInst) stopAll() {
|
||||
if i.acom != nil {
|
||||
i.acom.Stop()
|
||||
}
|
||||
if i.catemu != nil {
|
||||
i.catemu.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// ampTypeLabel is the default display name for an amp type.
|
||||
@@ -13491,12 +13597,61 @@ func (a *App) startAmps() {
|
||||
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.ampInsts[c.ID] = inst
|
||||
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
|
||||
// per-family payloads is set, per the amp's type.
|
||||
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",
|
||||
"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 { RotorCompass } from '@/components/RotorCompass';
|
||||
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 { Button } from '@/components/ui/button';
|
||||
@@ -2356,6 +2356,14 @@ export default function App() {
|
||||
}).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
|
||||
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go
|
||||
// side reloads its managers; this keeps the React state in sync.
|
||||
@@ -4147,7 +4155,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 flex">
|
||||
<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>
|
||||
{clusterShowFilters && renderClusterFilters()}
|
||||
</div>
|
||||
@@ -4156,7 +4164,7 @@ export default function App() {
|
||||
case 'worked':
|
||||
return (
|
||||
<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}
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
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">
|
||||
<RecentQSOsGrid
|
||||
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}
|
||||
myGrid={station.my_grid}
|
||||
total={total}
|
||||
@@ -5485,6 +5498,7 @@ export default function App() {
|
||||
}
|
||||
return (
|
||||
<ClusterGrid
|
||||
key={`clg2-${activeProfileId ?? 'x'}`}
|
||||
rows={rendered as any}
|
||||
spotStatus={spotStatus}
|
||||
onSpotClick={handleSpotClick}
|
||||
@@ -5569,7 +5583,7 @@ export default function App() {
|
||||
</TabsContent>
|
||||
|
||||
<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}
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
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';
|
||||
|
||||
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.
|
||||
const COL_CATALOG = useMemo(() => makeColCatalog(t), [t]);
|
||||
|
||||
const columnDefs = useMemo<ColDef<ClusterSpot>[]>(() => COL_CATALOG.map((c) => {
|
||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
}), [COL_CATALOG]);
|
||||
// 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;
|
||||
return { ...rest, hide: !defaultVisible };
|
||||
});
|
||||
}, [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>(() => ({
|
||||
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 });
|
||||
}, [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);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
});
|
||||
const remote = await loadRemote(COL_STATE_KEY);
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||
const state = gridRef.current?.api?.getColumnState();
|
||||
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.
|
||||
<div className="flex flex-col h-full min-h-0 -mx-3 -my-2">
|
||||
<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}
|
||||
total={paperRows.length}
|
||||
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">
|
||||
<RecentQSOsGrid
|
||||
storageKey="qslmgr.upload"
|
||||
rows={rows as any}
|
||||
total={rows.length}
|
||||
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.
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
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>) {
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
|
||||
@@ -627,7 +627,10 @@ function ADIFMonitorPanel() {
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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, {
|
||||
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 (
|
||||
<>
|
||||
@@ -2927,6 +2931,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</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} />}
|
||||
{!isPGXL && !isACOM && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import type { WorkedBeforeView, QSOForm } from '@/types';
|
||||
import { makeColCatalog, GROUP_ORDER, groupLabel } from './RecentQSOsGrid';
|
||||
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';
|
||||
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
@@ -113,15 +113,18 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
||||
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);
|
||||
if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||
loadRemote(COL_STATE_KEY).then((remote) => {
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
});
|
||||
const remote = await loadRemote(COL_STATE_KEY);
|
||||
if (remote && !local) {
|
||||
e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
|
||||
seedLocal(COL_STATE_KEY, remote);
|
||||
}
|
||||
}
|
||||
const saveColumnState = useCallback(() => {
|
||||
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 });
|
||||
const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
|
||||
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 {
|
||||
const col = gridRef.current?.api?.getColumn(colId);
|
||||
|
||||
@@ -19,6 +19,26 @@ let lsScope = '';
|
||||
// changes so each profile keeps its own column layout / widths.
|
||||
export function setGridPrefsProfile(id: number | string | null | undefined): void {
|
||||
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
|
||||
@@ -40,22 +60,66 @@ export function loadLocal(key: string): any[] | null {
|
||||
}
|
||||
|
||||
// loadRemote pulls the portable copy from the DB (null if none / unset).
|
||||
export async function loadRemote(key: string): Promise<any[] | null> {
|
||||
try {
|
||||
const v = await GetUIPref(key);
|
||||
const parsed = v ? JSON.parse(v) : null;
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return 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 {
|
||||
const v = await GetUIPref(key);
|
||||
const parsed = v ? JSON.parse(v) : null;
|
||||
return Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
// 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
|
||||
// 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[]) {
|
||||
const json = JSON.stringify(state);
|
||||
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
|
||||
|
||||
@@ -170,7 +170,7 @@ const en: Dict = {
|
||||
'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.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)',
|
||||
// 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.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.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)',
|
||||
// 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).
|
||||
// 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.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+3
@@ -6,6 +6,7 @@ import {main} from '../models';
|
||||
import {cat} from '../models';
|
||||
import {profile} from '../models';
|
||||
import {acom} from '../models';
|
||||
import {catemu} from '../models';
|
||||
import {antgenius} from '../models';
|
||||
import {award} from '../models';
|
||||
import {awardref} from '../models';
|
||||
@@ -344,6 +345,8 @@ export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAlertEmailTo():Promise<string>;
|
||||
|
||||
export function GetAmpBandFollowStatus():Promise<Record<string, catemu.Status>>;
|
||||
|
||||
export function GetAmpStatuses():Promise<Array<main.AmpStatus>>;
|
||||
|
||||
export function GetAmplifiers():Promise<Array<main.AmpConfig>>;
|
||||
|
||||
@@ -638,6 +638,10 @@ export function GetAlertEmailTo() {
|
||||
return window['go']['main']['App']['GetAlertEmailTo']();
|
||||
}
|
||||
|
||||
export function GetAmpBandFollowStatus() {
|
||||
return window['go']['main']['App']['GetAmpBandFollowStatus']();
|
||||
}
|
||||
|
||||
export function GetAmpStatuses() {
|
||||
return window['go']['main']['App']['GetAmpStatuses']();
|
||||
}
|
||||
|
||||
@@ -1442,6 +1442,10 @@ export namespace main {
|
||||
port: number;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
freq_out: boolean;
|
||||
freq_com_port: string;
|
||||
freq_baud: number;
|
||||
freq_broadcast_ms: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new AmpConfig(source);
|
||||
@@ -1458,6 +1462,10 @@ export namespace main {
|
||||
this.port = source["port"];
|
||||
this.com_port = source["com_port"];
|
||||
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 {
|
||||
|
||||
@@ -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 (
|
||||
// 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
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user