diff --git a/app.go b/app.go index 4865486..83d71fa 100644 --- a/app.go +++ b/app.go @@ -19,6 +19,7 @@ import ( "sync/atomic" "time" + "hamlog/internal/acom" "hamlog/internal/adif" "hamlog/internal/alerts" "hamlog/internal/antgenius" @@ -44,15 +45,15 @@ import ( "hamlog/internal/operating" "hamlog/internal/pota" "hamlog/internal/powergenius" - "hamlog/internal/spe" "hamlog/internal/profile" "hamlog/internal/qslcard" "hamlog/internal/qso" - "hamlog/internal/rotator/pst" "hamlog/internal/relaydev" + "hamlog/internal/rotator/pst" "hamlog/internal/rotgenius" "hamlog/internal/settings" "hamlog/internal/solar" + "hamlog/internal/spe" "hamlog/internal/steppir" "hamlog/internal/uls" "hamlog/internal/ultrabeam" @@ -446,8 +447,8 @@ type App struct { // wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never // queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL). // Loaded once, appended to on each log, rebuilt after bulk changes. - wcbm map[string]struct{} - wcbmMu sync.RWMutex + wcbm map[string]struct{} + wcbmMu sync.RWMutex pota *pota.Cache uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened awardRefs *awardref.Repo @@ -466,6 +467,7 @@ type App struct { antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE + acom *acom.Client // ACOM 500S/600S/700S/1200S/2020S amplifier (serial/TCP); nil when disabled or not ACOM audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping @@ -481,27 +483,27 @@ type App struct { alertStore *alerts.Store // DX-cluster spot alert rules (global JSON) - startupProfile string // --profile from the command line (activate at startup) - dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) - dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends + startupProfile string // --profile from the command line (activate at startup) + dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord) + dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends pttMu sync.Mutex - udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check - adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets) - relayAutoMu sync.Mutex // serialises relay auto-control evaluation - relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change - relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off - relayDrvMu sync.Mutex // guards the cached relay drivers below + udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check + adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets) + relayAutoMu sync.Mutex // serialises relay auto-control evaluation + relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change + relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off + relayDrvMu sync.Mutex // guards the cached relay drivers below relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call) - pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted - pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle - pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) - startupErr string // captured for surfacing to the frontend - dbPath string // active database file (may be a user-chosen location) - logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) - dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup - dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite - offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable - offlineMode bool // last write failed because the DB was unreachable + pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted + pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle + pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission) + startupErr string // captured for surfacing to the frontend + dbPath string // active database file (may be a user-chosen location) + logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite) + dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup + dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite + offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable + offlineMode bool // last write failed because the DB was unreachable catFlexSpots bool // push cluster spots to the FlexRadio panadapter catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter @@ -511,11 +513,13 @@ type App struct { liveBand string liveMode string livePublishTimer *time.Timer // debounced live-status publish on activity change - liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline - awardSnapMu sync.Mutex // guards the award QSO snapshot - awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations - awardSnapRev string // logbook revision the snapshot was built at ("" = none) - dataDir string // /data — holds config.json, logs, cty.dat + liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline + liveTableMu sync.Mutex // guards liveTableFor + liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call) + awardSnapMu sync.Mutex // guards the award QSO snapshot + awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations + awardSnapRev string // logbook revision the snapshot was built at ("" = none) + dataDir string // /data — holds config.json, logs, cty.dat // shuttingDown gates beforeClose re-entry: the first user attempt to // close fires shutdown tasks (backup, future LoTW upload, ...) while @@ -859,9 +863,9 @@ func (a *App) startup(ctx context.Context) { applog.Printf("startup: logbook backend = %s", backend) a.logDb = logbookConn a.qso = qso.NewRepo(logbookConn) - a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs - go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks - go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs + a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs + go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks + go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag // cty.dat for offline DXCC / country resolution. Cached on disk; first @@ -1955,7 +1959,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { if err == nil { q.ID = id a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory) - a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async) + a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async) // Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry // form clears immediately — the operator is not made to wait on the DB. wruntime.EventsEmit(a.ctx, "qso:logged", id) @@ -4334,8 +4338,8 @@ func (a *App) ExportAwardForCatalog(code string, version int) (string, error) { return "", fmt.Errorf("load references: %w", err) } entry := struct { - Def award.Def `json:"def"` - References []awardref.Ref `json:"references,omitempty"` + Def award.Def `json:"def"` + References []awardref.Ref `json:"references,omitempty"` }{Def: def, References: refs} b, err := json.MarshalIndent(entry, "", " ") if err != nil { @@ -6309,7 +6313,7 @@ type clusterQueue struct { mu sync.Mutex cond *sync.Cond buf []clusterEvent - head int // index of the next event to pop (waste reclaimed by compaction) + head int // index of the next event to pop (waste reclaimed by compaction) closed bool } @@ -11342,7 +11346,6 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error { return nil } - // RotatorHeading is the live antenna heading for the status bar. type RotatorHeading struct { Enabled bool `json:"enabled"` @@ -11461,14 +11464,14 @@ func boolStr(b bool) string { // StationDevice is one configured relay board for the Station Control tab. type StationDevice struct { - ID string `json:"id"` - Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay" - Name string `json:"name"` - Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay - User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none) - Pass string `json:"pass,omitempty"` - Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16) - Labels []string `json:"labels"` // per-relay label (index 0 = relay 1) + ID string `json:"id"` + Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay" + Name string `json:"name"` + Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay + User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none) + Pass string `json:"pass,omitempty"` + Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16) + Labels []string `json:"labels"` // per-relay label (index 0 = relay 1) } // deviceRelayCount is the relay count for a configured device — fixed by type, @@ -11741,12 +11744,12 @@ type motorAntenna interface { // convention, so commands pass straight through). type ubAdapter struct{ c *ultrabeam.Client } -func (a ubAdapter) Start() error { return a.c.Start() } -func (a ubAdapter) Stop() { a.c.Stop() } -func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) } -func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) } -func (a ubAdapter) Retract() error { return a.c.Retract() } -func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() } +func (a ubAdapter) Start() error { return a.c.Start() } +func (a ubAdapter) Stop() { a.c.Stop() } +func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) } +func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) } +func (a ubAdapter) Retract() error { return a.c.Retract() } +func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() } func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) } func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() } func (a ubAdapter) Elements() []int { @@ -12269,11 +12272,12 @@ func (a *App) AntGeniusDeselect(port int) error { // ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ───── // PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers -// the 4O3A PowerGenius XL (TCP) and the SPE Expert amps (USB serial or an -// RS232-to-Ethernet bridge). The type name is kept for binding compatibility. +// the 4O3A PowerGenius XL (TCP), the SPE Expert amps and the ACOM S-series amps +// (both: USB serial or an RS232-to-Ethernet bridge). The type name is kept for +// binding compatibility. type PGXLSettings struct { Enabled bool `json:"enabled"` - Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k" + Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k" | "acom500" | "acom600" | "acom700" | "acom1200" | "acom2020" Transport string `json:"transport"` // "tcp" | "serial" Host string `json:"host"` // TCP transport Port int `json:"port"` // TCP transport @@ -12354,6 +12358,10 @@ func (a *App) startPGXL() { go a.spe.Stop() a.spe = nil } + if a.acom != nil { + go a.acom.Stop() + a.acom = nil + } s, err := a.GetPGXLSettings() if err != nil || !s.Enabled { return @@ -12366,13 +12374,18 @@ func (a *App) startPGXL() { _ = a.pgxl.Start() return } - // SPE Expert — USB serial or an RS232-to-Ethernet bridge. + // SPE Expert / ACOM — USB serial or an RS232-to-Ethernet bridge. if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" { return } if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" { return } + if model, ok := strings.CutPrefix(s.Type, "acom"); ok { + a.acom = acom.New(acom.Config{Model: model + "S", Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port}) + _ = a.acom.Start() + return + } a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port}) _ = a.spe.Start() } @@ -12413,6 +12426,35 @@ func (a *App) SPESetPowerLevel(level string) error { return a.spe.SetPowerLevel(level) } +// GetACOMStatus returns the ACOM amplifier state for the UI poll. +func (a *App) GetACOMStatus() acom.Status { + if a.acom == nil { + return acom.Status{} + } + return a.acom.GetStatus() +} + +// ACOMSetOperate puts the ACOM amp in OPERATE (true) or STANDBY (false) — the +// protocol has explicit commands for each, unlike the SPE's toggle key. +func (a *App) ACOMSetOperate(on bool) error { + if a.acom == nil { + return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier") + } + return a.acom.Operate(on) +} + +// ACOMSetPower turns the ACOM amp on (true, a DTR/RTS pulse — serial only, needs +// the power-on pins wired in the cable) or off (false, the OFF data command). +func (a *App) ACOMSetPower(on bool) error { + if a.acom == nil { + return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier") + } + if on { + return a.acom.PowerOn() + } + return a.acom.PowerOff() +} + // GetPGXLStatus returns the amp's fan/connection state for the UI poll. func (a *App) GetPGXLStatus() powergenius.Status { if a.pgxl == nil { diff --git a/changelog.json b/changelog.json index 0c29ec8..3999ee4 100644 --- a/changelog.json +++ b/changelog.json @@ -3,11 +3,15 @@ "version": "0.20.7", "date": "2026-07-21", "en": [ - "Multi-operator events no longer exhaust the shared MySQL server's connections: each instance now uses a small connection pool, so many operators can run against one database at once. Previously the server hit its connection limit and the 'stations on air' list, award columns and grid would silently stop updating. (All operators should update, and you may want to raise max_connections on the server.)", + "New: ACOM amplifier support (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, live telemetry (power, SWR, PA temperature, band, fan) in Settings and the radio panel, over USB serial or an RS232-to-Ethernet bridge. Power-ON works over serial when the cable wires the DTR/RTS pins. The Amplifier settings now pick Brand then Model.", + "Multi-operator live sync fixed: the shared-MySQL connection pool was too small, so after a while the grid, chat and 'stations on air' silently stopped updating. The pool is now sized for real multi-op use (with 5 operators it stays well within a max_connections=300 server).", + "Live operator status is now always published on a shared MySQL logbook — the Settings toggle is gone. You still go off-air automatically 5 minutes after your last QSO, and the 'stations on air' panel refreshes noticeably faster (a redundant table check on every read was removed).", "The By-Continent statistics donut now totals the same as your QSO count — QSOs whose continent couldn't be resolved are shown as an 'Unknown' slice instead of being dropped (the Continents count still lists only real continents)." ], "fr": [ - "Les événements multi-opérateurs ne saturent plus les connexions du serveur MySQL partagé : chaque instance utilise maintenant un petit pool de connexions, donc de nombreux opérateurs peuvent travailler sur une même base en même temps. Avant, le serveur atteignait sa limite de connexions et la liste « stations on air », les colonnes d'award et la grille cessaient silencieusement de se mettre à jour. (Tous les opérateurs doivent mettre à jour, et il peut être utile d'augmenter max_connections sur le serveur.)", + "Nouveau : prise en charge des amplificateurs ACOM (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, télémétrie en direct (puissance, ROS, température PA, bande, ventilateur) dans les Réglages et le panneau radio, en USB série ou via un pont RS232-vers-Ethernet. La mise en marche fonctionne en série si le câble relie les broches DTR/RTS. Les réglages Amplificateur se font maintenant par Marque puis Modèle.", + "Synchronisation multi-opérateurs corrigée : le pool de connexions MySQL partagé était trop petit, et au bout d'un moment la grille, le chat et « stations on air » cessaient silencieusement de se mettre à jour. Le pool est maintenant dimensionné pour un vrai multi-op (à 5 opérateurs, on reste largement sous un serveur à max_connections=300).", + "Le statut opérateur en direct est maintenant toujours publié sur un logbook MySQL partagé — l'option des Réglages a disparu. Vous passez toujours hors antenne automatiquement 5 minutes après votre dernier QSO, et le panneau « stations on air » se rafraîchit nettement plus vite (une vérification de table redondante à chaque lecture a été supprimée).", "La donut « Par continent » des statistiques totalise maintenant le même nombre que le total de QSO — les QSO dont le continent n'a pas pu être résolu apparaissent dans un segment « Unknown » au lieu d'être ignorés (le compteur Continents ne liste toujours que les vrais continents)." ] }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9e1179..2fc77c4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,7 +42,7 @@ import { QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, GetAwardDefs, GetUIPref, GetActiveProfile, QuitApp, - ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec, + ReportLiveActivity, LiveLastQSOAgeSec, } from '../wailsjs/go/main/App'; import { Combobox } from '@/components/ui/combobox'; import { applyAwardRefs } from '@/lib/awardRefs'; @@ -1121,22 +1121,22 @@ export default function App() { useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]); // "ON AIR" status-bar badge: mirrors the multi-op live status this operator // publishes — online (blinking) when a QSO was logged in the last 5 min, else - // offline. Only shown when live-status publishing is enabled (Settings→General). - const [liveStatusOn, setLiveStatusOn] = useState(false); + // offline. Publishing is always on for a shared MySQL logbook (no user toggle: + // 5 min without a QSO removes the row automatically), so the badge simply + // follows the backend. const [onAir, setOnAir] = useState(false); - useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]); useEffect(() => { // Read the ON-AIR state straight from the backend (single source of truth: // liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll // it + refresh on each logged QSO — no fragile frontend timestamp to drift. const refresh = () => LiveLastQSOAgeSec() - .then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300)) + .then((sec: number) => setOnAir(typeof sec === 'number' && sec >= 0 && sec < 300)) .catch(() => {}); refresh(); const off = EventsOn('qso:logged', refresh); - const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan) + const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (single indexed row) return () => { off(); window.clearInterval(id); }; - }, [liveStatusOn]); + }, []); // QSO-rate meter (10/60 min) in the header — opt-in via Settings→General. const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1'); useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]); @@ -5122,7 +5122,7 @@ export default function App() { disabled={!rotatorHeading.enabled} onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }} /> - {liveStatusOn && ( + {liveStationsOn && (
{ alive = false; window.clearInterval(id); }; }, []); - const isSPE = ampEnabled && ampType !== 'pgxl'; + const isACOM = ampEnabled && ampType.startsWith('acom'); + const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl'; // SPE Expert live status (only polled when an SPE amp is configured). const [spe, setSpe] = useState({ connected: false }); useEffect(() => { @@ -363,6 +365,16 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number const id = window.setInterval(tick, 1500); return () => { alive = false; window.clearInterval(id); }; }, [isSPE]); + // ACOM live status (only polled when an ACOM amp is configured). + const [acom, setAcom] = useState({ connected: false }); + useEffect(() => { + if (!isACOM) return; + let alive = true; + const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {}); + tick(); + const id = window.setInterval(tick, 1500); + return () => { alive = false; window.clearInterval(id); }; + }, [isACOM]); const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise) => { hold.current[key] = Date.now() + 900; @@ -872,10 +884,55 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number )} + {/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven + by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */} + {isACOM && ( + +
+ + {/* Power ON is a DTR/RTS pulse — serial only, and it works while the amp + is off (the port stays open), so gate on port_open not connected. */} +
+ + +
+ + + {acom.connected ? acom.state : t('flxp.acomOffline')} + + {acom.connected && ( + + {acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan} + + )} +
+ {acom.err_text && ( + ⚠ {acom.err_text} + )} +
+ {acom.connected && ( + (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} /> + )} + + )} + {/* External amplifier (PowerGenius XL) — only when the Flex reports one AND - PowerGenius is the selected amp type. Running an SPE Expert hides this - Flex-reported card so the two amps don't both show. */} - {st.amp_available && !isSPE && ( + PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides + this Flex-reported card so two amps don't both show. */} + {st.amp_available && !isSPE && !isACOM && (
); } +// Live ACOM amplifier status + OPERATE/STANDBY. Module-scoped for the same +// remount reason as SPEStatusCard. Polls once a second while shown. +function ACOMStatusCard() { + const [st, setSt] = useState({ connected: false }); + useEffect(() => { + let alive = true; + const tick = () => GetACOMStatus().then((s) => alive && setSt(s || {})).catch(() => {}); + tick(); + const id = window.setInterval(tick, 1000); + return () => { alive = false; window.clearInterval(id); }; + }, []); + const operate = !!st.operate; + return ( +
+
+ + {st.connected ? `ACOM ${st.model || ''}` : `ACOM ${st.model || ''} — not connected`} + {!st.connected && st.last_error && {st.last_error}} + + +
+ {st.connected && ( +
+
{st.state}
+
Band {st.band || '—'}
+
{st.fwd_w} W
+
SWR {Number(st.swr ?? 0).toFixed(2)}
+
Refl {st.refl_w} W
+
Drive {st.drive_w} W
+
{st.temp_c}°C
+
Fan {st.fan}
+ {st.err_text &&
⚠ {st.err_text} ({st.err_code})
} +
+ )} +
+ ); +} function RelayAutoPanel() { const { t } = useI18n(); const [enabled, setEnabled] = useState(false); @@ -2690,7 +2710,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan function PGXLPanelSettings() { const isPGXL = pgxl.type === 'pgxl'; + const isACOM = (pgxl.type || '').startsWith('acom'); const isSerial = pgxl.transport === 'serial'; + // The stored `type` stays the single flat value ("spe13", "acom700", "pgxl" — + // binding compatibility); the UI presents it as brand + model. + const brand = isPGXL ? 'pgxl' : isACOM ? 'acom' : 'spe'; + const brandModels: Record = { + spe: [ + { value: 'spe13', label: 'Expert 1.3K-FA' }, + { value: 'spe15', label: 'Expert 1.5K-FA' }, + { value: 'spe2k', label: 'Expert 2K-FA' }, + ], + acom: [ + { value: 'acom500', label: '500S' }, + { value: 'acom600', label: '600S' }, + { value: 'acom700', label: '700S' }, + { value: 'acom1200', label: '1200S' }, + { value: 'acom2020', label: '2020S' }, + ], + }; + // Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is + // 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only. + const applyType = (v: string) => setPgxl((s) => ({ + ...s, type: v, + transport: v === 'pgxl' ? 'tcp' : s.transport, + baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : s.baud, + })); return ( <> @@ -2700,21 +2745,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan Enable amplifier control -
+ {/* Connection gets the widest column — "Network (RS232-to-Ethernet)" was + truncated with 3 equal columns. */} +
- - { + if (b === brand) return; + applyType(b === 'pgxl' ? 'pgxl' : brandModels[b][0].value); + }}> - 4O3A PowerGenius XL - SPE Expert 1.3K-FA - SPE Expert 1.5K-FA - SPE Expert 2K-FA + 4O3A + SPE + ACOM
- {/* PowerGenius is TCP-only; SPE amps connect over USB serial or an - RS232-to-Ethernet bridge, so they offer both. */} +
+ + {isPGXL ? ( + // The PowerGenius is the brand's single model — fixed, no choice. + + ) : ( + + )} +
+ {/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial or + an RS232-to-Ethernet bridge, so they offer both. */} {!isPGXL && (
@@ -2767,12 +2835,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)} - {!isPGXL && pgxl.enabled && } - {!isPGXL && ( + {!isPGXL && pgxl.enabled && (isACOM ? : )} + {!isPGXL && !isACOM && (

SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.

)} + {isACOM && ( +

+ ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself. +

+ )}
); @@ -4515,7 +4588,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {t('gen.checkUpdates')} {t('gen.checkUpdatesHint')} - diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 27f3792..d84d282 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -92,8 +92,6 @@ const en: Dict = { 'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.', 'settings.telemetry': 'Send anonymous usage statistics', 'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data', - 'settings.liveStatus': 'Publish live operator status', - 'settings.liveStatusHint': 'multi-op on shared MySQL — feeds a QRZ live page', 'settings.mainView': 'Main view', 'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).', 'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane', @@ -283,7 +281,7 @@ const en: Dict = { 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', - 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', + 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline', 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?', 'rst.clickToFill': 'Click to set RST tx from the signal', 'qrz.openTitle': 'Open {call} on QRZ.com', @@ -407,8 +405,6 @@ const fr: Dict = { 'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.", 'settings.telemetry': "Envoyer des statistiques d'usage anonymes", 'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO", - 'settings.liveStatus': 'Publier le statut opérateur en direct', - 'settings.liveStatusHint': 'multi-op sur MySQL partagé — alimente une page live QRZ', 'settings.mainView': 'Vue principale', 'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).", 'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit', @@ -581,7 +577,7 @@ const fr: Dict = { 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante', 'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', - 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', + 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne', 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'qrz.openTitle': 'Ouvrir {call} sur QRZ.com', diff --git a/frontend/src/version.ts b/frontend/src/version.ts index f00b624..3007210 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -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.20.6'; +export const APP_VERSION = '0.20.7'; // Author / credits, shown in Help -> About. export const APP_AUTHOR = 'F4BPO'; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 8c05637..21036bb 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -5,6 +5,7 @@ import {qso} from '../models'; import {main} from '../models'; import {cat} from '../models'; import {profile} from '../models'; +import {acom} from '../models'; import {antgenius} from '../models'; import {award} from '../models'; import {awardref} from '../models'; @@ -23,6 +24,10 @@ import {lotwusers} from '../models'; import {lookup} from '../models'; import {netctl} from '../models'; +export function ACOMSetOperate(arg1:boolean):Promise; + +export function ACOMSetPower(arg1:boolean):Promise; + export function ADIFFields():Promise>; export function ADIFVersion():Promise; @@ -289,6 +294,8 @@ export function FlexStopCW():Promise; export function FlexTune(arg1:boolean):Promise; +export function GetACOMStatus():Promise; + export function GetADIFMonitor():Promise; export function GetActiveProfile():Promise; @@ -365,8 +372,6 @@ export function GetListsSettings():Promise; export function GetLiveStations():Promise>; -export function GetLiveStatusEnabled():Promise; - export function GetLoTWUsersStatus():Promise; export function GetLogFilePath():Promise; @@ -813,8 +818,6 @@ export function SetCompactMode(arg1:boolean):Promise; export function SetDVKLabel(arg1:number,arg2:string):Promise; -export function SetLiveStatusEnabled(arg1:boolean):Promise; - export function SetPassphrase(arg1:string):Promise; export function SetTelemetryEnabled(arg1:boolean):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 088ddba..5b38a11 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -2,6 +2,14 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function ACOMSetOperate(arg1) { + return window['go']['main']['App']['ACOMSetOperate'](arg1); +} + +export function ACOMSetPower(arg1) { + return window['go']['main']['App']['ACOMSetPower'](arg1); +} + export function ADIFFields() { return window['go']['main']['App']['ADIFFields'](); } @@ -534,6 +542,10 @@ export function FlexTune(arg1) { return window['go']['main']['App']['FlexTune'](arg1); } +export function GetACOMStatus() { + return window['go']['main']['App']['GetACOMStatus'](); +} + export function GetADIFMonitor() { return window['go']['main']['App']['GetADIFMonitor'](); } @@ -686,10 +698,6 @@ export function GetLiveStations() { return window['go']['main']['App']['GetLiveStations'](); } -export function GetLiveStatusEnabled() { - return window['go']['main']['App']['GetLiveStatusEnabled'](); -} - export function GetLoTWUsersStatus() { return window['go']['main']['App']['GetLoTWUsersStatus'](); } @@ -1582,10 +1590,6 @@ export function SetDVKLabel(arg1, arg2) { return window['go']['main']['App']['SetDVKLabel'](arg1, arg2); } -export function SetLiveStatusEnabled(arg1) { - return window['go']['main']['App']['SetLiveStatusEnabled'](arg1); -} - export function SetPassphrase(arg1) { return window['go']['main']['App']['SetPassphrase'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index c4c8ca9..a8152b5 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1,3 +1,58 @@ +export namespace acom { + + export class Status { + connected: boolean; + port_open: boolean; + transport: string; + last_error?: string; + model?: string; + state?: string; + operate: boolean; + tx: boolean; + fwd_w: number; + refl_w: number; + swr: number; + drive_w: number; + dc_w: number; + temp_c: number; + band?: string; + fan: number; + err_code: number; + err_text?: string; + nominal_w: number; + max_w: number; + + static createFrom(source: any = {}) { + return new Status(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.connected = source["connected"]; + this.port_open = source["port_open"]; + this.transport = source["transport"]; + this.last_error = source["last_error"]; + this.model = source["model"]; + this.state = source["state"]; + this.operate = source["operate"]; + this.tx = source["tx"]; + this.fwd_w = source["fwd_w"]; + this.refl_w = source["refl_w"]; + this.swr = source["swr"]; + this.drive_w = source["drive_w"]; + this.dc_w = source["dc_w"]; + this.temp_c = source["temp_c"]; + this.band = source["band"]; + this.fan = source["fan"]; + this.err_code = source["err_code"]; + this.err_text = source["err_text"]; + this.nominal_w = source["nominal_w"]; + this.max_w = source["max_w"]; + } + } + +} + export namespace adif { export class ExportResult { diff --git a/internal/acom/acom.go b/internal/acom/acom.go new file mode 100644 index 0000000..54ec0ac --- /dev/null +++ b/internal/acom/acom.go @@ -0,0 +1,504 @@ +// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S / +// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by +// the ACOM-Controller project (bjornekelund, C#). The amp is reached either +// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet +// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE +// backend. +// +// Wire format (host → amp): fixed raw byte strings, validated by the same rule as +// telemetry (sum of ALL frame bytes ≡ 0 mod 256): +// +// enable telemetry: 55 92 04 15 +// disable telemetry: 55 91 04 16 +// OPERATE: 55 81 08 02 00 06 00 1A +// STANDBY: 55 81 08 02 00 05 00 1B +// OFF (power down): 55 81 08 02 00 0A 00 16 +// +// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS +// lines (the amp's remote power-on pins) — serial transport only, and only if the +// cable wires those pins (a telemetry-only cable uses just RX/TX/GND). +// +// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them +// permanently blocks the amplifier's front-panel power button. +// +// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting +// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are +// decoded in decodeFrame below. +package acom + +import ( + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + "go.bug.st/serial" + + "hamlog/internal/applog" +) + +var ( + cmdEnableTelemetry = []byte{0x55, 0x92, 0x04, 0x15} + cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16} + cmdOperate = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A} + cmdStandby = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B} + cmdOff = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16} +) + +const ( + frameLen = 72 + sync0 = 0x55 + sync1 = 0x2F + dialTimeout = 5 * time.Second + ioTimeout = 3 * time.Second + // The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid + // arrives for this long the link (or the amp) is considered down. + staleAfter = 5 * time.Second +) + +// model carries the per-model constants: the temperature word offset and the +// nominal/max forward power (for UI bar scaling). +type model struct { + Name string + TempOffset int + NominalW int + MaxW int +} + +var models = map[string]model{ + "500S": {"500S", 282, 500, 600}, + "600S": {"600S", 273, 600, 700}, + "700S": {"700S", 282, 700, 800}, + "1200S": {"1200S", 281, 1200, 1400}, + "2020S": {"2020S", 282, 1800, 2000}, +} + +// paStatusNames maps the PAstatus nibble to a display string. +var paStatusNames = map[int]string{ + 1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE", + 5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF", +} + +// acomBands maps the band nibble to a band label. +var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"} + +// errText translates the error code (frame byte 66) shown on the amp's display. +// 0xFF means NO error — everything else, including 0x00, is a fault/warning. +func errText(code int) string { + switch code { + case 0xFF: + return "" + case 0x00, 0x08: + return "Hot switching" + case 0x03: + return "Drive power at wrong time" + case 0x04, 0x05: + return "Reflected power warning" + case 0x06, 0x07: + return "Drive power too high" + case 0x0C: + return "RF power at wrong time" + case 0x0E: + return "Stop transmission first" + case 0x0F: + return "Remove drive power" + case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59: + return "Excessive PAM current" + case 0x70: + return "CAT error" + default: + return "ERROR — see display" + } +} + +// Status is the decoded amplifier state for the UI. +type Status struct { + Connected bool `json:"connected"` // valid telemetry is flowing + PortOpen bool `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off + Transport string `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line) + LastError string `json:"last_error,omitempty"` + Model string `json:"model,omitempty"` + State string `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / … + Operate bool `json:"operate"` // RECEIVE or TRANSMIT (vs STANDBY) + TX bool `json:"tx"` + FwdW int `json:"fwd_w"` // forward/output power + ReflW int `json:"refl_w"` // reflected power + SWR float64 `json:"swr"` + DriveW int `json:"drive_w"` + DCPowerW int `json:"dc_w"` + TempC int `json:"temp_c"` // PA temperature + Band string `json:"band,omitempty"` + FanLevel int `json:"fan"` // 1..4 + ErrCode int `json:"err_code"` // raw code from the frame; 0xFF = none + ErrText string `json:"err_text,omitempty"` // human message; empty = no error + NominalW int `json:"nominal_w"` + MaxW int `json:"max_w"` +} + +// Config selects the transport and amplifier model. +type Config struct { + Model string // "500S" | "600S" | "700S" | "1200S" | "2020S" + Transport string // "serial" | "tcp" + ComPort string // serial + Baud int // serial (the amp is fixed 9600 8N1) + Host string // tcp (RS232-to-Ethernet bridge) + Port int // tcp +} + +type Client struct { + cfg Config + mdl model + + mu sync.Mutex // serialises access to the connection + conn io.ReadWriteCloser + + statusMu sync.RWMutex + status Status + + // Diagnostics: raw byte count + first-bytes capture, so a hardware session log + // tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid + // frame" (framing/checksum) without a serial sniffer. + rawSeen int64 + dbgBytes []byte + dbgLogged bool + ckFails int + frameLogged bool + + stop chan struct{} + running bool +} + +func New(cfg Config) *Client { + if cfg.Baud <= 0 { + cfg.Baud = 9600 + } + mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))] + if !ok { + mdl = models["700S"] + } + c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})} + c.status.Model = mdl.Name + c.status.Transport = cfg.Transport + c.status.NominalW = mdl.NominalW + c.status.MaxW = mdl.MaxW + return c +} + +func (c *Client) Start() error { + if c.running { + return nil + } + c.running = true + go c.readLoop() + return nil +} + +func (c *Client) Stop() { + if !c.running { + return + } + c.running = false + close(c.stop) + c.mu.Lock() + if c.conn != nil { + _, _ = c.conn.Write(cmdDisableTelemetry) // best effort: stop the stream + } + c.dropLocked() + c.mu.Unlock() +} + +func (c *Client) GetStatus() Status { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.status +} + +func (c *Client) setErr(msg string) { + c.statusMu.Lock() + c.status.Connected = false + c.status.LastError = msg + c.statusMu.Unlock() +} + +// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's +// single toggle key, the ACOM protocol has explicit commands for each state. +func (c *Client) Operate(on bool) error { + if on { + return c.send(cmdOperate) + } + return c.send(cmdStandby) +} + +// PowerOff sends the power-down command (same as pressing OFF on the amp). +func (c *Client) PowerOff() error { return c.send(cmdOff) } + +// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired +// like a press of the front-panel power button. Hardware line ⇒ serial transport +// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have +// those pins connected. Pulse length to be confirmed on real hardware. +func (c *Client) PowerOn() error { + c.mu.Lock() + defer c.mu.Unlock() + sp, ok := c.conn.(serial.Port) + if !ok { + return fmt.Errorf("power-on needs the serial DTR/RTS lines — not available over a network bridge") + } + if err := sp.SetDTR(true); err != nil { + return err + } + if err := sp.SetRTS(true); err != nil { + _ = sp.SetDTR(false) + return err + } + time.Sleep(2500 * time.Millisecond) + err1 := sp.SetDTR(false) + err2 := sp.SetRTS(false) + if err1 != nil { + return err1 + } + return err2 +} + +func (c *Client) send(cmd []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil { + return fmt.Errorf("not connected") + } + if nc, ok := c.conn.(net.Conn); ok { + _ = nc.SetWriteDeadline(time.Now().Add(ioTimeout)) + } + _, err := c.conn.Write(cmd) + return err +} + +// readLoop keeps the link up and consumes the telemetry stream. When no valid +// frame arrives for a while it re-arms telemetry (the amp forgets the enable +// across a power cycle) and, on TCP, reconnects. A serial port is kept open even +// with the amp off, so the DTR power-on pulse stays available. +func (c *Client) readLoop() { + var lastFrame time.Time + var lastEnable time.Time + for { + select { + case <-c.stop: + return + default: + } + if err := c.ensureConn(); err != nil { + c.setErr("connect: " + err.Error()) + select { + case <-c.stop: + return + case <-time.After(2 * time.Second): + } + continue + } + frame, err := c.readFrame() + now := time.Now() + if err == nil { + lastFrame = now + if !c.frameLogged { + c.frameLogged = true + applog.Printf("acom: telemetry up — first valid frame received") + } + c.decodeFrame(frame) + continue + } + // No valid frame. Re-send the telemetry enable briskly — there is no way to + // know whether the amp has it on (the reference controller re-sends every + // 200ms until frames flow), and it revives the stream after a power cycle. + if now.Sub(lastEnable) >= 500*time.Millisecond { + lastEnable = now + _ = c.send(cmdEnableTelemetry) + } + if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter { + if c.cfg.Transport == "tcp" { + // The bridge socket may be dead — force a reconnect. + c.mu.Lock() + c.dropLocked() + c.mu.Unlock() + } + if prev := c.GetStatus(); prev.Connected || prev.LastError == "" { + applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails) + } + c.setErr("no telemetry — amplifier off or cable/bridge issue") + } + } +} + +func (c *Client) ensureConn() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn != nil { + return nil + } + if c.cfg.Transport == "tcp" { + nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout) + if err != nil { + return err + } + c.conn = nc + } else { + sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud}) + if err != nil { + return err + } + // CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while + // asserted the amp's front-panel power button is blocked (they are its + // remote power-on lines). + _ = sp.SetDTR(false) + _ = sp.SetRTS(false) + // Short read timeout so the frame reader can poll the stop channel and + // detect staleness rather than blocking forever. + _ = sp.SetReadTimeout(200 * time.Millisecond) + c.conn = sp + } + c.statusMu.Lock() + c.status.PortOpen = true + c.statusMu.Unlock() + applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport) + _, _ = c.conn.Write(cmdEnableTelemetry) + return nil +} + +func (c *Client) dropLocked() { + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.statusMu.Lock() + c.status.PortOpen = false + c.statusMu.Unlock() +} + +// readByte reads a single byte, honouring the transport's short timeout. A serial +// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error +// here so callers can distinguish "no data yet". +var errNoData = fmt.Errorf("no data") + +func (c *Client) readByte(deadline time.Time) (byte, error) { + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + if conn == nil { + return 0, fmt.Errorf("not connected") + } + buf := make([]byte, 1) + for { + if nc, ok := conn.(net.Conn); ok { + _ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond)) + } + n, err := conn.Read(buf) + if n == 1 { + c.rawSeen++ + // Capture the first 144 raw bytes (~2 frames) once, so a session log shows + // what the wire actually carries when frames won't validate. + if !c.dbgLogged { + c.dbgBytes = append(c.dbgBytes, buf[0]) + if len(c.dbgBytes) >= 144 { + c.dbgLogged = true + applog.Printf("acom: first raw bytes: % X", c.dbgBytes) + c.dbgBytes = nil + } + } + return buf[0], nil + } + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + err = nil // treat like the serial short-timeout: just no data yet + } else { + return 0, err + } + } + select { + case <-c.stop: + return 0, fmt.Errorf("stopped") + default: + } + if time.Now().After(deadline) { + return 0, errNoData + } + } +} + +// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies +// the mod-256 checksum (sum of ALL 72 bytes ≡ 0). +func (c *Client) readFrame() ([]byte, error) { + deadline := time.Now().Add(ioTimeout) + // Sync: hunt for 0x55 followed by 0x2F. + for { + b, err := c.readByte(deadline) + if err != nil { + return nil, err + } + if b != sync0 { + continue + } + b2, err := c.readByte(deadline) + if err != nil { + return nil, err + } + if b2 == sync1 { + break + } + } + frame := make([]byte, frameLen) + frame[0], frame[1] = sync0, sync1 + for i := 2; i < frameLen; i++ { + b, err := c.readByte(deadline) + if err != nil { + return nil, err + } + frame[i] = b + } + var sum int + for _, b := range frame { + sum += int(b) + } + if sum%256 != 0 { + c.ckFails++ + if c.ckFails <= 3 { + applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame) + } + return nil, fmt.Errorf("bad checksum") + } + return frame, nil +} + +// decodeFrame extracts the telemetry fields (see the package comment for the +// reverse-engineered layout; 16-bit values are little-endian lo + hi*256). +func (c *Client) decodeFrame(f []byte) { + u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 } + paStatus := int(f[3]&0xF0) >> 4 + state := paStatusNames[paStatus] + if state == "" { + state = fmt.Sprintf("?%d", paStatus) + } + bandIdx := int(f[69] & 0x0F) + band := "" + if bandIdx > 0 && bandIdx < len(acomBands) { + band = acomBands[bandIdx] + } + + c.statusMu.Lock() + defer c.statusMu.Unlock() + c.status.Connected = true + c.status.LastError = "" + c.status.State = state + c.status.Operate = paStatus == 6 || paStatus == 7 + c.status.TX = paStatus == 7 + c.status.DCPowerW = u16(8) / 10 + c.status.TempC = u16(16) - c.mdl.TempOffset + c.status.DriveW = u16(20) + c.status.FwdW = u16(22) + c.status.ReflW = u16(24) + c.status.SWR = float64(u16(26)) / 100 + c.status.ErrCode = int(f[66]) + c.status.ErrText = errText(int(f[66])) + c.status.Band = band + c.status.FanLevel = int(f[69]&0xF0) >> 4 +} diff --git a/internal/db/mysql.go b/internal/db/mysql.go index 85755c0..3eec589 100644 --- a/internal/db/mysql.go +++ b/internal/db/mysql.go @@ -134,15 +134,19 @@ func translateTextColumns(s string) string { // server's global max_connections — "Error 1040: Too many connections". // - Too SMALL a pool starves this instance's OWN concurrent UI queries — the live // multi-op sync (revision poll every 2s + a 3-query grid refresh), live_status, -// chat, stats, cluster — so the grid stops showing other operators' QSOs. (8 was -// too tight and reintroduced this "deadlock-like stall".) -// 16 is the balance: plenty for the UI's bursts, and ~15 ops still fit under a -// raised max_connections=300 (advise operators to raise it for a big multi-op). +// chat, stats, cluster. Under real multi-op load, 8 and even 16 stalled: hot +// paths (WorkedBefore fires ~10 sequential round-trips; every poll uses the +// app-lifetime ctx with no per-query timeout) hold a connection for the whole +// chain of remote-MySQL latency, so a handful of concurrent UI bursts drained +// the pool and "after a while nothing updated" — grid, chat, live_status froze. +// 40 fits the actual deployment (max 5 ops on a server with max_connections=300 → +// 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE +// pool, so keep max_connections >= pool*ops + headroom. // Brisk idle reaping returns slots to the server; no max lifetime so a slow first // migration on one connection isn't reaped mid-run (drops the selected database). func tuneMySQLPool(conn *sql.DB) { - conn.SetMaxOpenConns(16) - conn.SetMaxIdleConns(6) + conn.SetMaxOpenConns(40) + conn.SetMaxIdleConns(8) conn.SetConnMaxIdleTime(30 * time.Second) conn.SetConnMaxLifetime(0) } diff --git a/internal/qso/qso.go b/internal/qso/qso.go index e75a32f..bf8c3e7 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -1954,23 +1954,21 @@ func (r *Repo) Count(ctx context.Context) (int64, error) { // operator who just worked someone before (re)starting OpsLog shows online right // away instead of waiting for their next QSO. Empty operator matches every QSO. func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) { - rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`) - if err != nil { - return time.Time{}, false - } - defer rows.Close() + // ONE indexed row, not 400 filtered in Go: this runs every ~5 s (the ON-AIR + // badge) and ~15 s (live-status publish), so pulling 400 rows each time over a + // remote MySQL hammered the connection pool and, on a busy multi-op event, was a + // big part of "after a while nothing updates". Match the operator the same + // (case/space-insensitive) way, newest first. opFilter := strings.ToUpper(strings.TrimSpace(operator)) - for rows.Next() { - var oper, dateStr sql.NullString - if err := rows.Scan(&oper, &dateStr); err != nil { - return time.Time{}, false - } - if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter { - continue - } - if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() { - return t, true - } + var dateStr sql.NullString + err := r.db.QueryRowContext(ctx, + `SELECT qso_date FROM qso WHERE UPPER(TRIM(operator)) = ? AND qso_date IS NOT NULL AND qso_date <> '' ORDER BY id DESC LIMIT 1`, + opFilter).Scan(&dateStr) + if err != nil { + return time.Time{}, false // ErrNoRows or a real error — no known last QSO + } + if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() { + return t, true } return time.Time{}, false } diff --git a/livestatus.go b/livestatus.go index 6024361..707614f 100644 --- a/livestatus.go +++ b/livestatus.go @@ -2,7 +2,6 @@ package main import ( "database/sql" - "fmt" "strings" "time" @@ -30,8 +29,6 @@ func (a *App) emitLiveStatusChanged() { // to the DB — it is not a web server. Rows older than a couple of minutes are // "stale" (operator went offline); the web side ignores them. -const keyLiveStatusEnabled = "livestatus.enabled" - // liveOnlineWindow is how long after the last logged contact an operator still // counts as "on air". Leaving the log open without working anyone flips them // offline once this elapses; logging a new QSO flips them back online. @@ -49,37 +46,6 @@ func (a *App) noteLiveQSO() { } } -// GetLiveStatusEnabled reports whether this operator publishes live status. -func (a *App) GetLiveStatusEnabled() bool { - if a.settings == nil { - return false - } - v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled) - return strings.TrimSpace(v) == "1" -} - -// SetLiveStatusEnabled turns live-status publishing on or off (off also removes -// this operator's row immediately). -func (a *App) SetLiveStatusEnabled(on bool) error { - if a.settings == nil { - return fmt.Errorf("db not initialized") - } - val := "0" - if on { - val = "1" - } - if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil { - return err - } - if on { - applog.Printf("livestatus: enabled (logbook backend=%q, mysql conn=%v)", a.dbBackend, a.logDb != nil) - go a.publishLiveStatus() // show up right away - } else { - a.clearLiveStatus() - } - return nil -} - // seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who // worked someone shortly before (re)starting OpsLog is shown "on air" right away // instead of offline until their next QSO. @@ -143,9 +109,11 @@ func (a *App) liveStatusLoop() { } } -// liveStatusActive reports whether publishing should run (MySQL logbook + on). +// liveStatusActive reports whether publishing should run. Always on for a shared +// MySQL logbook (no user toggle: going offline is automatic — no QSO for 5 min +// removes the row — so there is nothing to opt out of). func (a *App) liveStatusActive() bool { - return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled() + return a.logDb != nil && a.dbBackend == "mysql" } // liveStatusOperator returns this instance's operator id (the operator callsign, @@ -197,9 +165,6 @@ func (a *App) publishLiveStatus() { if a.logDb == nil || a.dbBackend != "mysql" { return // not a MySQL logbook — nothing to do (silent, runs every 15s) } - if !a.GetLiveStatusEnabled() { - return // disabled (silent) - } op, station := a.liveStatusOperator() if op == "" { applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)") @@ -316,7 +281,29 @@ func (a *App) GetLiveStations() []LiveStation { return out } +// ensureLiveStatusTable creates/upgrades the live_status table ONCE per logbook +// connection. It used to run the CREATE + 3 ALTERs on every call — and it is +// called from every publish (15s heartbeat) AND every GetLiveStations poll (5s), +// so that was 4 wasted round-trips per call on a remote MySQL, adding latency to +// the "who's on air" widget and holding pool connections for nothing. func (a *App) ensureLiveStatusTable() error { + db := a.logDb + a.liveTableMu.Lock() + done := a.liveTableFor == db // re-ensure after a profile switch swaps the logbook + a.liveTableMu.Unlock() + if done { + return nil + } + if err := a.ensureLiveStatusTableDDL(); err != nil { + return err + } + a.liveTableMu.Lock() + a.liveTableFor = db + a.liveTableMu.Unlock() + return nil +} + +func (a *App) ensureLiveStatusTableDDL() error { if _, err := a.logDb.ExecContext(a.ctx, "CREATE TABLE IF NOT EXISTS live_status ("+ "operator VARCHAR(32) PRIMARY KEY, "+ diff --git a/telemetry.go b/telemetry.go index 2a08b3b..d89bc10 100644 --- a/telemetry.go +++ b/telemetry.go @@ -21,7 +21,7 @@ import ( const ( // appVersion is stamped on every heartbeat (and could feed the About box). - appVersion = "0.20.6" + appVersion = "0.20.7" // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // to https://us.i.posthog.com for a US project.