From 5394b55bb7690e834ec97bb2eaa638f794a730b4 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 27 Jul 2026 11:56:30 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20amplifier=20band-follow=20=E2=80=94=20a?= =?UTF-8?q?nswer=20the=20amp's=20frequency=20polls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On its CAT/AUX connector an ACOM is the MASTER: it polls a transceiver and changes band from the reply, so nothing can be pushed to it. internal/catemu answers those polls on a second serial port, in ACOM command set 5 (Kenwood / Elecraft): FA;, FB;, IF; and ID;, and nothing else — a wrong-length reply is worse than none, it desynchronises the amp's parser for the following poll. Also offered for SPE. Some amps do not poll at all but read the radio↔PC CAT line in parallel; those never hear a responder, so an optional unprompted send (500/1000 ms) covers them. The TX frequency is what is sent: in split the amp must be tuned where we transmit. Frame lengths are pinned by tests — the failure they guard against is silent and only shows up as an amp that mistunes. Untested on hardware. --- app.go | 78 ++++- frontend/src/components/SettingsModal.tsx | 62 +++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 3 + frontend/wailsjs/go/main/App.js | 4 + frontend/wailsjs/go/models.ts | 8 + internal/catemu/catemu.go | 376 ++++++++++++++++++++++ internal/catemu/catemu_test.go | 45 +++ 8 files changed, 573 insertions(+), 7 deletions(-) create mode 100644 internal/catemu/catemu.go create mode 100644 internal/catemu/catemu_test.go diff --git a/app.go b/app.go index eaae288..8b46517 100644 --- a/app.go +++ b/app.go @@ -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 @@ -13343,13 +13348,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 +13380,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 +13512,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 { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 895fb65..1ec1a30 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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 )} + {/* 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 && ( +
+ + {amp.freq_out && ( + <> +
+
+ +
+ + +
+
+
+ + patchAmp(i, { freq_baud: parseInt(e.target.value) || 9600 })} className="font-mono" /> +
+
+
+
+ + +
+
+

{t('amp.freqHint')}

+ + )} +
+ )} + {amp.enabled && amp.id && } {!isPGXL && !isACOM && (

diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index cf6dbaf..3f8448a 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index d902ca9..9c36a65 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -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; export function GetAlertEmailTo():Promise; +export function GetAmpBandFollowStatus():Promise>; + export function GetAmpStatuses():Promise>; export function GetAmplifiers():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 8acb2f4..f360ad1 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index af04a9f..1ae26a9 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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 { diff --git a/internal/catemu/catemu.go b/internal/catemu/catemu.go new file mode 100644 index 0000000..88d56db --- /dev/null +++ b/internal/catemu/catemu.go @@ -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 + ) +} diff --git a/internal/catemu/catemu_test.go b/internal/catemu/catemu_test.go new file mode 100644 index 0000000..bc7e34f --- /dev/null +++ b/internal/catemu/catemu_test.go @@ -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) + } + } +}