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 && (
+
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)
+ }
+ }
+}