diff --git a/app.go b/app.go
index 3c250d4..415bb1c 100644
--- a/app.go
+++ b/app.go
@@ -68,6 +68,7 @@ import (
"hamlog/internal/spe"
"hamlog/internal/steppir"
"hamlog/internal/syncfolder"
+ "hamlog/internal/tciserver"
"hamlog/internal/tunergenius"
"hamlog/internal/uls"
"hamlog/internal/ultrabeam"
@@ -120,6 +121,8 @@ const (
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
keyCATShareEnabled = "cat.share.enabled" // expose CAT to other programs (Hamlib NET rigctl)
keyCATSharePort = "cat.share.port" // TCP port for that server (rigctld default 4532)
+ keyCATShareProto = "cat.share.proto" // which sharing server runs: "rigctl" (Hamlib NET) or "tci"
+ keyCATShareTCIPort = "cat.share.tci_port" // WebSocket port for the TCI server (TCI default 40001)
keyCATXieguPort = "cat.xiegu.port" // Xiegu CI-V serial port (G90/X6100…)
keyCATXieguBaud = "cat.xiegu.baud" // Xiegu CI-V baud (G90 default 19200)
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
@@ -457,8 +460,13 @@ type CATSettings struct {
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
- ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
- SharePort int `json:"share_port"` // TCP port for it (default 4532)
+ ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs
+ SharePort int `json:"share_port"` // TCP port for the rigctl server (default 4532)
+ // ShareProto picks WHICH server runs — "rigctl" or "tci". One or the other,
+ // not both: they are two ways of asking the same radio the same questions,
+ // and a second listener is only a second thing to go wrong.
+ ShareProto string `json:"share_proto"`
+ ShareTCIPort int `json:"share_tci_port"` // WebSocket port for the TCI server (default 40001)
// PTT hotkey — a keyboard key that keys the transmitter while OpsLog is
// focused (hold-to-talk, or toggle). Uses the configured Audio → PTT method,
// falling back to CAT keying when that is VOX/none.
@@ -596,8 +604,12 @@ type App struct {
// port: without it, choosing native CAT locks WSJT-X and friends out of the
// radio entirely. nil when the operator has not enabled sharing.
catShare *rigctld.Server
- dxcc *dxcc.Manager
- cluster *cluster.Manager
+ // catShareTCI serves the same link to programs built around Expert
+ // Electronics' TCI instead. One or the other runs, never both — they answer
+ // the same questions about the same radio, and nothing speaks both.
+ catShareTCI *tciserver.Server
+ dxcc *dxcc.Manager
+ cluster *cluster.Manager
// Cluster spots/lines are processed OFF the socket-read goroutine. Enriching a
// spot (DXCC/POTA), emitting it to the UI, running alert rules — which can hit
// a remote MySQL via isWorkedBandMode — and mirroring it to the Flex all used
@@ -1691,6 +1703,10 @@ func (a *App) shutdown(ctx context.Context) {
a.catShare.Stop()
a.catShare = nil
}
+ if a.catShareTCI != nil {
+ a.catShareTCI.Stop()
+ a.catShareTCI = nil
+ }
if a.cat != nil {
a.cat.Stop()
}
@@ -7449,7 +7465,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
}
- m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
+ m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort)
if err != nil {
return CATSettings{}, err
}
@@ -7491,6 +7507,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
DigitalDefault: m[keyCATDigitalDefault],
ShareEnabled: m[keyCATShareEnabled] == "1",
SharePort: 4532,
+ ShareProto: "rigctl",
+ ShareTCIPort: tciserver.DefaultPort,
}
if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 {
out.FlexPort = n
@@ -7504,6 +7522,12 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
out.SharePort = n
}
+ if p := strings.ToLower(strings.TrimSpace(m[keyCATShareProto])); p == "tci" {
+ out.ShareProto = p
+ }
+ if n, _ := strconv.Atoi(m[keyCATShareTCIPort]); n > 0 && n <= 65535 {
+ out.ShareTCIPort = n
+ }
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
out.XieguBaud = n
}
@@ -7561,6 +7585,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.SharePort <= 0 || s.SharePort > 65535 {
s.SharePort = 4532
}
+ if s.ShareProto != "tci" {
+ s.ShareProto = "rigctl"
+ }
+ if s.ShareTCIPort <= 0 || s.ShareTCIPort > 65535 {
+ s.ShareTCIPort = tciserver.DefaultPort
+ }
if s.XieguBaud <= 0 {
s.XieguBaud = 19200
}
@@ -7661,6 +7691,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
keyCATShareEnabled: shareEnabled,
keyCATSharePort: strconv.Itoa(s.SharePort),
+ keyCATShareProto: s.ShareProto,
+ keyCATShareTCIPort: strconv.Itoa(s.ShareTCIPort),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -18177,6 +18209,18 @@ func (r catShareRig) Split() (bool, int64) {
return true, st.FreqHz
}
+// RxFreq is where we LISTEN. Only the TCI server asks for it: TCI's channel A
+// is the receive frequency and channel B the transmit one, the opposite way
+// round from RigState, and a client handed these two the wrong way about would
+// transmit on the DX's own frequency.
+func (r catShareRig) RxFreq() int64 {
+ st := r.a.cat.State()
+ if st.Split && st.RxFreqHz > 0 {
+ return st.RxFreqHz
+ }
+ return st.FreqHz
+}
+
func (r catShareRig) SetFreq(hz int64) error { return r.a.cat.SetFrequency(hz) }
func (r catShareRig) SetMode(m string) error { return r.a.cat.SetMode(m) }
func (r catShareRig) SetPTT(on bool) error { return r.a.cat.SetPTT(on) }
@@ -18191,17 +18235,35 @@ func (r catShareRig) SetSplit(on bool, txHz int64) error {
// reloadCATShare starts, stops or restarts the sharing server to match the
// settings. Called from reloadCAT so one "Save & Close" settles both.
+//
+// One server or the other, never both. rigctl and TCI are two ways of asking
+// the same radio the same questions; running both would only double the ways a
+// port clash or a confused client can go wrong, and no program speaks both.
func (a *App) reloadCATShare(s CATSettings) {
want := s.Enabled && s.ShareEnabled
- // Always tear down first: the port may have changed, and a listener bound to
- // the old one would keep answering while the client is told to use the new.
+ // Always tear down first: the port — or the protocol — may have changed, and
+ // a listener bound to the old one would keep answering while the client is
+ // told to use the new.
if a.catShare != nil {
a.catShare.Stop()
a.catShare = nil
}
+ if a.catShareTCI != nil {
+ a.catShareTCI.Stop()
+ a.catShareTCI = nil
+ }
if !want {
return
}
+ if s.ShareProto == "tci" {
+ srv := tciserver.New(s.ShareTCIPort, catShareRig{a: a}, applog.Printf)
+ if err := srv.Start(); err != nil {
+ applog.Printf("cat share: %v", err)
+ return
+ }
+ a.catShareTCI = srv
+ return
+ }
srv := rigctld.New(s.SharePort, catShareRig{a: a}, applog.Printf)
if err := srv.Start(); err != nil {
// The usual cause is another rigctld — or a previous OpsLog — already on
diff --git a/changelog.json b/changelog.json
index dc2db35..7ce547e 100644
--- a/changelog.json
+++ b/changelog.json
@@ -3,10 +3,12 @@
"version": "0.25.8",
"date": "",
"en": [
- "An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed."
+ "An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed.",
+ "CAT sharing can now speak TCI instead of Hamlib, so a TCI-only program reaches whatever radio OpsLog is on."
],
"fr": [
- "Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook."
+ "Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.",
+ "Le partage CAT peut désormais parler TCI au lieu de Hamlib : un logiciel TCI atteint la radio, quelle qu’elle soit."
]
},
{
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index b823ae1..3c4be36 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -1403,7 +1403,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
- digital_default: 'FT8', share_enabled: false, share_port: 4532,
+ digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001,
ptt_hotkey_enabled: false, ptt_hotkey: '', ptt_hotkey_toggle: false,
});
// While true, the next key press is captured as the PTT hotkey.
@@ -3149,15 +3149,47 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
+ {/* One protocol or the other. They answer the same questions about
+ the same radio, and no program speaks both — so this is a
+ choice, not two switches. */}
+
+
+
+
+
+
+ {(catCfg as any).share_proto === 'tci' ? (
+ setCatCfg((s) => ({ ...s, share_tci_port: n } as any))}
+ />
+ ) : (
+ setCatCfg((s) => ({ ...s, share_port: n }))}
+ />
+ )}
+
)}
+ {catCfg.share_enabled && (catCfg as any).share_proto === 'tci' && catCfg.backend === 'tci' && (
+ // Both ends TCI: ExpertSDR is almost certainly already holding
+ // 40001 on this machine, and our server would fail to bind. Worth
+ // saying here rather than leaving it in the log.
+
{t('cat.shareTciClash')}
+ )}
{/* PTT hotkey — a keyboard key that keys the rig while OpsLog is focused.
Uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT. */}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx
index 21fdb21..f7cb2b8 100644
--- a/frontend/src/lib/i18n.tsx
+++ b/frontend/src/lib/i18n.tsx
@@ -294,7 +294,7 @@ const en: Dict = {
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.motorBands': 'Covered bands', 'hw.motorStep': 'Re-tune step', 'hw.motorBandFreqHint': 'Frequency each band button tunes the antenna to (kHz). Leave empty for the default shown.', 'hw.motorFollow': 'Follow rig frequency (auto-tune the antenna)', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body
- 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'In the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.pttKey': 'Enable PTT hotkey', 'cat.pttKeyPress': 'Press a key…', 'cat.pttKeyNone': 'Click to set a key', 'cat.pttKeyClear': 'Clear', 'cat.pttKeyToggle': 'Toggle mode (press to key, press again to unkey)', 'cat.pttKeyHint': 'While OpsLog is focused, this key keys the transmitter — held down by default (release to stop), or latched in toggle mode. It uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT keying. Pick a key you never type while logging (e.g. Pause, ScrollLock, or a footswitch mapped to one) — OpsLog swallows it so it never lands in a field.', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.optElecraft': 'Elecraft K3/K4 (USB, network)', 'cat.elecraftHint': 'Digital modes automatically use DATA A (MD6+DT0) — the sub-mode FT8 audio needs.', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'If your radio is always on TX, tick this.', 'cat.kwDataMode': 'Data modes (FT8/PSK…) use', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA A — MD6+DT0 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Leave the rig’s mode unchanged', 'cat.kwDataHint': 'What OpsLog sets on the rig for a data mode. No single command fits every rig: an Elecraft K3/K4 wants DATA (MD6); a TS-590SG/TS-990S data mode is a USB modifier set on the rig, so pick USB or, safest, "Leave unchanged" and switch the rig to DATA yourself. On MD6 a plain Kenwood (TS-590/990) would land on FSK/RTTY — do not use it there.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
+ 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'Another program then reaches the radio through OpsLog — with every backend, not only the native ones. For Hamlib pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532; for TCI point the program at 127.0.0.1:40001.', 'cat.shareProto': 'Protocol', 'cat.shareRigctl': 'Hamlib NET rigctl — WSJT-X, JTDX, MSHV, Log4OM', 'cat.shareTci': 'TCI — Expert Electronics', 'cat.shareTciClash': 'The CAT backend is TCI too: ExpertSDR is probably already using port 40001 on this PC. Give the server another port, or share over Hamlib instead.', 'cat.sharePort': 'Sharing port', 'cat.pttKey': 'Enable PTT hotkey', 'cat.pttKeyPress': 'Press a key…', 'cat.pttKeyNone': 'Click to set a key', 'cat.pttKeyClear': 'Clear', 'cat.pttKeyToggle': 'Toggle mode (press to key, press again to unkey)', 'cat.pttKeyHint': 'While OpsLog is focused, this key keys the transmitter — held down by default (release to stop), or latched in toggle mode. It uses the Audio → PTT method (CAT / RTS / DTR), falling back to CAT keying. Pick a key you never type while logging (e.g. Pause, ScrollLock, or a footswitch mapped to one) — OpsLog swallows it so it never lands in a field.', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.optElecraft': 'Elecraft K3/K4 (USB, network)', 'cat.elecraftHint': 'Digital modes automatically use DATA A (MD6+DT0) — the sub-mode FT8 audio needs.', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'If your radio is always on TX, tick this.', 'cat.kwDataMode': 'Data modes (FT8/PSK…) use', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA A — MD6+DT0 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Leave the rig’s mode unchanged', 'cat.kwDataHint': 'What OpsLog sets on the rig for a data mode. No single command fits every rig: an Elecraft K3/K4 wants DATA (MD6); a TS-590SG/TS-990S data mode is a USB modifier set on the rig, so pick USB or, safest, "Leave unchanged" and switch the rig to DATA yourself. On MD6 a plain Kenwood (TS-590/990) would land on FSK/RTTY — do not use it there.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
@@ -723,7 +723,7 @@ const fr: Dict = {
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.testOkRead': 'Connecté — le contrôleur a répondu avec son azimut. Rien n’a bougé : ce test ne fait que lire la position.', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.dual': 'Deux rotors', 'rot.rotor1': 'Rotor 1', 'rot.rotor2': 'Rotor 2', 'rot.name': 'Nom', 'rot.antenna': 'Antenne', 'rot.antenna1': 'Antenne rotor 1', 'rot.antenna2': 'Antenne rotor 2', 'rot.name1': 'Nom du rotor 1', 'rot.name2': 'Nom du rotor 2', 'rot.antStd': 'Antenne standard', 'rot.antMotor': 'Motorisée (Ultrabeam/SteppIR)', 'rot.add': 'Ajouter un rotor', 'rot.remove': 'Supprimer le rotor', 'rot.none': 'Aucun rotor configuré. Cliquez sur « Ajouter un rotor ».', 'rot.rgDual': 'Ce Rotator Genius pilote deux rotors (en ajoute un second)', 'rot.testRotor1': 'Tester rotor 1', 'rot.testRotor2': 'Tester rotor 2', 'rot.dualHint': 'Deux rotors indépendants de n\'importe quel type — par exemple un ARCO et un Rotator Genius côte à côte. Configurez chacun ci-dessous ; OpsLog affiche un sélecteur Rotor 1/2 sur le compas pour choisir celui qu\'il tourne et affiche. Réglez chaque rotor sur « Motorisée » pour que les tracés de boom/diagramme (inverse, bidirectionnel) n\'apparaissent que pour celui qui porte l\'Ultrabeam/SteppIR. (Pour un seul Rotator Genius pilotant deux rotors, réglez les deux sur Rotator Genius avec le même hôte et le rotator n° 1 et 2.)', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", 'rot.dcu1Hint': "Parle le jeu de commandes Hy-Gain DCU-1 (RotorCard DXA, Idiom Press Rotor-EZ, Green Heron). Connexion via le port COM du contrôleur (un DCU-1 est à 4800 bauds ; RotorCard/Green Heron peuvent différer — reprendre la vitesse du contrôleur) ou en TCP via un pont série-sur-IP. Azimut uniquement, pas d'élévation. Nouveau pilote — signalez si votre contrôleur nécessite une autre commande ou vitesse.", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.motorBands': 'Bandes couvertes', 'hw.motorStep': 'Pas de réaccord', 'hw.motorBandFreqHint': "Fréquence sur laquelle chaque bouton de bande accorde l'antenne (kHz). Laisser vide pour le défaut affiché.", 'hw.motorFollow': "Suivre la fréquence du rig (accord auto de l'antenne)", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
- 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.pttKey': 'Activer la touche PTT', 'cat.pttKeyPress': 'Appuyez sur une touche…', 'cat.pttKeyNone': 'Cliquez pour définir une touche', 'cat.pttKeyClear': 'Effacer', 'cat.pttKeyToggle': 'Mode bascule (appui = émission, nouvel appui = arrêt)', 'cat.pttKeyHint': "Quand OpsLog a le focus, cette touche passe la radio en émission — maintenue par défaut (relâcher pour arrêter), ou verrouillée en mode bascule. Elle utilise la méthode PTT de Audio → PTT (CAT / RTS / DTR), avec repli sur le CAT. Choisissez une touche que vous ne tapez jamais en journalisant (ex. Pause, Arrêt défil., ou une pédale mappée dessus) — OpsLog l'intercepte pour qu'elle n'atterrisse pas dans un champ.", 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.optElecraft': 'Elecraft K3/K4 (USB, réseau)', 'cat.elecraftHint': 'Les modes numériques passent automatiquement en DATA A (MD6+DT0) — le sous-mode dont l’audio FT8 a besoin.', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Si votre radio est toujours en émission, cochez ceci.', 'cat.kwDataMode': 'Modes data (FT8/PSK…)', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA A — MD6+DT0 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Ne pas changer le mode de la radio', 'cat.kwDataHint': "Ce qu'OpsLog règle sur la radio pour un mode data. Aucune commande unique ne convient à toutes : un Elecraft K3/K4 veut DATA (MD6) ; sur un TS-590SG/TS-990S le mode data est un modificateur d'USB réglé sur la radio, choisissez donc USB ou, plus sûr, « Ne pas changer » et passez la radio en DATA vous-même. En MD6 un Kenwood classique (TS-590/990) tomberait en FSK/RTTY — à ne pas utiliser là.", 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
+ 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un autre logiciel atteint alors la radio à travers OpsLog — avec tous les backends, pas seulement les natifs. Pour Hamlib, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532 ; pour TCI, pointez le logiciel sur 127.0.0.1:40001.", 'cat.shareProto': 'Protocole', 'cat.shareRigctl': 'Hamlib NET rigctl — WSJT-X, JTDX, MSHV, Log4OM', 'cat.shareTci': 'TCI — Expert Electronics', 'cat.shareTciClash': 'Le backend CAT est aussi en TCI : ExpertSDR occupe probablement déjà le port 40001 sur ce PC. Donnez un autre port au serveur, ou partagez en Hamlib.', 'cat.sharePort': 'Port de partage', 'cat.pttKey': 'Activer la touche PTT', 'cat.pttKeyPress': 'Appuyez sur une touche…', 'cat.pttKeyNone': 'Cliquez pour définir une touche', 'cat.pttKeyClear': 'Effacer', 'cat.pttKeyToggle': 'Mode bascule (appui = émission, nouvel appui = arrêt)', 'cat.pttKeyHint': "Quand OpsLog a le focus, cette touche passe la radio en émission — maintenue par défaut (relâcher pour arrêter), ou verrouillée en mode bascule. Elle utilise la méthode PTT de Audio → PTT (CAT / RTS / DTR), avec repli sur le CAT. Choisissez une touche que vous ne tapez jamais en journalisant (ex. Pause, Arrêt défil., ou une pédale mappée dessus) — OpsLog l'intercepte pour qu'elle n'atterrisse pas dans un champ.", 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.optElecraft': 'Elecraft K3/K4 (USB, réseau)', 'cat.elecraftHint': 'Les modes numériques passent automatiquement en DATA A (MD6+DT0) — le sous-mode dont l’audio FT8 a besoin.', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Si votre radio est toujours en émission, cochez ceci.', 'cat.kwDataMode': 'Modes data (FT8/PSK…)', 'cat.kwDataUsb': 'USB', 'cat.kwDataMd6': 'DATA A — MD6+DT0 (Elecraft K3/K4)', 'cat.kwDataKeep': 'Ne pas changer le mode de la radio', 'cat.kwDataHint': "Ce qu'OpsLog règle sur la radio pour un mode data. Aucune commande unique ne convient à toutes : un Elecraft K3/K4 veut DATA (MD6) ; sur un TS-590SG/TS-990S le mode data est un modificateur d'USB réglé sur la radio, choisissez donc USB ou, plus sûr, « Ne pas changer » et passez la radio en DATA vous-même. En MD6 un Kenwood classique (TS-590/990) tomberait en FSK/RTTY — à ne pas utiliser là.", 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 8de7ee9..5a0c9a3 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -2050,6 +2050,8 @@ export namespace main {
digital_default: string;
share_enabled: boolean;
share_port: number;
+ share_proto: string;
+ share_tci_port: number;
ptt_hotkey_enabled: boolean;
ptt_hotkey: string;
ptt_hotkey_toggle: boolean;
@@ -2096,6 +2098,8 @@ export namespace main {
this.digital_default = source["digital_default"];
this.share_enabled = source["share_enabled"];
this.share_port = source["share_port"];
+ this.share_proto = source["share_proto"];
+ this.share_tci_port = source["share_tci_port"];
this.ptt_hotkey_enabled = source["ptt_hotkey_enabled"];
this.ptt_hotkey = source["ptt_hotkey"];
this.ptt_hotkey_toggle = source["ptt_hotkey_toggle"];
diff --git a/internal/tciserver/tciserver.go b/internal/tciserver/tciserver.go
new file mode 100644
index 0000000..e220a12
--- /dev/null
+++ b/internal/tciserver/tciserver.go
@@ -0,0 +1,521 @@
+// Package tciserver shares OpsLog's CAT link with programs that speak TCI.
+//
+// It is the second half of internal/rigctld, and exists for the same reason:
+// Windows gives a COM port to ONE process, so the moment OpsLog talks to the
+// radio directly nothing else can. rigctld answers the programs that speak
+// Hamlib NET rigctl (WSJT-X, JTDX, MSHV, Log4OM); this answers the ones built
+// around Expert Electronics' TCI instead — and it answers them whatever radio
+// is actually connected, because it sits on the same backend-agnostic
+// interface. An operator with an Icom or a Yaesu can hand a TCI-only program a
+// working rig.
+//
+// ── The protocol ──────────────────────────────────────────────────────────
+// Text commands over a WebSocket, "name:arg,arg;", the same syntax in both
+// directions. On connection the server sends a block of initialisation
+// commands describing the device, ending with ready; and start;. Thereafter
+// either side may send a control command, and the server echoes every change
+// to all connected clients so they stay in step with each other.
+//
+// vfo:0,0,14074000; receiver 0, channel A (RX), Hz
+// vfo:0,1,14080000; channel B — the TX frequency when split is on
+// modulation:0,usb; mode
+// trx:0,true; PTT
+// split_enable:0,true; split
+// vfo:0,0; a READ: the reply is the three-argument form
+//
+// Written against the official TCI Protocol document (ExpertSDR3/TCI, 12
+// January 2024, MIT) — the initialisation set and the argument order of every
+// command below are from §4.1 and §4.2, not from guesswork about what a client
+// might accept.
+package tciserver
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
+// package stays testable without a radio and without importing internal/cat —
+// which also keeps it building on every platform.
+type Rig interface {
+ Freq() int64 // TX frequency in Hz (ADIF sense), 0 if unknown
+ RxFreq() int64 // RX frequency in Hz; equals Freq when not split
+ Mode() string // ADIF mode (SSB, CW, FT8…)
+ Split() (bool, int64) // split on?, and the TX frequency
+ SetFreq(hz int64) error
+ SetMode(mode string) error
+ SetPTT(on bool) error
+ SetSplit(on bool, txHz int64) error
+}
+
+// DefaultPort is TCI's own default, which is what a client offers first.
+const DefaultPort = 40001
+
+// pollInterval is how often the rig is compared with what the clients were last
+// told. TCI is an event protocol — a client is entitled to sit silent and be
+// told when something moves — so this is the rate at which a knob turned on the
+// radio reaches it.
+const pollInterval = 250 * time.Millisecond
+
+type Server struct {
+ port int
+ rig Rig
+ log func(string, ...any)
+
+ mu sync.Mutex
+ ln net.Listener
+ http *http.Server
+ conns map[*client]struct{}
+ closed bool
+
+ // last is what the clients have been told, so only changes are sent. TCI
+ // clients redraw on every command they receive; re-sending an unchanged
+ // frequency four times a second makes a VFO readout flicker and, in some
+ // clients, fights the operator's own tuning.
+ last state
+}
+
+// state is the part of the rig the clients are kept in step with.
+type state struct {
+ rxHz int64
+ txHz int64
+ mode string
+ split bool
+ valid bool
+}
+
+// client is one connected program.
+type client struct {
+ conn *websocket.Conn
+ mu sync.Mutex // one writer at a time: gorilla panics on concurrent writes
+}
+
+func (c *client) send(s string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.conn == nil {
+ return nil // a client with no socket: the tests exercise the protocol, not the transport
+ }
+ _ = c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
+ return c.conn.WriteMessage(websocket.TextMessage, []byte(s))
+}
+
+func New(port int, rig Rig, logf func(string, ...any)) *Server {
+ if port <= 0 || port > 65535 {
+ port = DefaultPort
+ }
+ if logf == nil {
+ logf = func(string, ...any) {}
+ }
+ return &Server{port: port, rig: rig, log: logf, conns: map[*client]struct{}{}}
+}
+
+// Start binds the port and serves until Stop.
+func (s *Server) Start() error {
+ ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
+ if err != nil {
+ return fmt.Errorf("tci server: port %d: %w", s.port, err)
+ }
+ up := websocket.Upgrader{
+ // Any origin: the clients are desktop programs on the same machine or
+ // LAN, and they send whatever Origin their toolkit happens to set. This
+ // is the same trust boundary as the rigctl server on 4532 — a plain TCP
+ // port with no authentication, which is what every logger expects.
+ CheckOrigin: func(*http.Request) bool { return true },
+ }
+ mux := http.NewServeMux()
+ // Any path: clients connect to ws://host:port/ but some append a name.
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ conn, err := up.Upgrade(w, r, nil)
+ if err != nil {
+ s.log("tci server: upgrade from %s failed: %v", r.RemoteAddr, err)
+ return
+ }
+ s.serve(&client{conn: conn}, r.RemoteAddr)
+ })
+ srv := &http.Server{Handler: mux}
+ s.mu.Lock()
+ s.ln, s.http, s.closed = ln, srv, false
+ s.mu.Unlock()
+ go func() { _ = srv.Serve(ln) }()
+ go s.pushLoop()
+ s.log("tci server: listening on :%d", s.port)
+ return nil
+}
+
+// Stop closes the listener and every client.
+func (s *Server) Stop() {
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return
+ }
+ s.closed = true
+ ln, srv := s.ln, s.http
+ conns := make([]*client, 0, len(s.conns))
+ for c := range s.conns {
+ conns = append(conns, c)
+ }
+ s.conns = map[*client]struct{}{}
+ s.last = state{}
+ s.mu.Unlock()
+ for _, c := range conns {
+ _ = c.conn.Close()
+ }
+ if srv != nil {
+ _ = srv.Close()
+ }
+ if ln != nil {
+ _ = ln.Close()
+ }
+ s.log("tci server: stopped")
+}
+
+// Clients reports how many programs are connected — the one thing an operator
+// wants to know when a client says it cannot find the rig.
+func (s *Server) Clients() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return len(s.conns)
+}
+
+// serve runs one connection: the initialisation block, then commands until it
+// closes.
+func (s *Server) serve(c *client, remote string) {
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ _ = c.conn.Close()
+ return
+ }
+ s.conns[c] = struct{}{}
+ s.mu.Unlock()
+ s.log("tci server: %s connected", remote)
+
+ for _, line := range s.initBlock() {
+ if err := c.send(line); err != nil {
+ break
+ }
+ }
+ for {
+ _, data, err := c.conn.ReadMessage()
+ if err != nil {
+ break
+ }
+ // One frame may carry several ";"-terminated commands.
+ for _, cmd := range strings.Split(string(data), ";") {
+ if cmd = strings.TrimSpace(cmd); cmd != "" {
+ s.handle(c, cmd)
+ }
+ }
+ }
+ s.mu.Lock()
+ delete(s.conns, c)
+ s.mu.Unlock()
+ _ = c.conn.Close()
+ s.log("tci server: %s disconnected", remote)
+}
+
+// initBlock is the initialisation set from §4.1 of the protocol document, in
+// the documented order, followed by the current state so a client that has just
+// connected shows the right frequency instead of waiting for the first change.
+//
+// A client will not proceed without these: they are how it learns the device
+// exists, what it can do, and that the server has finished setting up.
+func (s *Server) initBlock() []string {
+ rx, tx, mode, split := s.read()
+ return []string{
+ "protocol:ExpertSDR3,1.9;",
+ "device:OpsLog;",
+ "receive_only:false;",
+ "trx_count:1;",
+ "channel_count:2;",
+ // The whole HF/VHF/UHF span OpsLog itself works over. A client uses this
+ // to bound its own tuning; too narrow a range and it refuses to follow the
+ // rig onto 2 m.
+ "vfo_limits:10000,470000000;",
+ "if_limits:-48000,48000;",
+ "modulations_list:am,sam,dsb,lsb,usb,cw,nfm,digl,digu;",
+ "ready;",
+ "start;",
+ fmt.Sprintf("vfo:0,0,%d;", rx),
+ fmt.Sprintf("vfo:0,1,%d;", tx),
+ fmt.Sprintf("modulation:0,%s;", mode),
+ fmt.Sprintf("split_enable:0,%t;", split),
+ "trx:0,false;",
+ }
+}
+
+// read takes one consistent snapshot of the rig in TCI's terms: channel A is
+// where we LISTEN and channel B where we transmit, which is the opposite way
+// round from ADIF's RigState and the one mistake here that would make a client
+// transmit on the DX's frequency.
+func (s *Server) read() (rxHz, txHz int64, mode string, split bool) {
+ split, txHz = s.rig.Split()
+ rxHz = s.rig.RxFreq()
+ if !split {
+ txHz = s.rig.Freq()
+ if rxHz == 0 {
+ rxHz = txHz
+ }
+ }
+ if rxHz == 0 {
+ rxHz = s.rig.Freq()
+ }
+ if txHz == 0 {
+ txHz = rxHz
+ }
+ mode = adifToTCIMode(s.rig.Mode(), rxHz)
+ return rxHz, txHz, mode, split
+}
+
+// pushLoop tells the clients what has changed on the radio.
+func (s *Server) pushLoop() {
+ t := time.NewTicker(pollInterval)
+ defer t.Stop()
+ for range t.C {
+ s.mu.Lock()
+ done := s.closed
+ s.mu.Unlock()
+ if done {
+ return
+ }
+ s.publish()
+ }
+}
+
+// publish sends only what moved. Returns the lines sent, for the tests.
+func (s *Server) publish() []string {
+ rx, tx, mode, split := s.read()
+ cur := state{rxHz: rx, txHz: tx, mode: mode, split: split, valid: true}
+
+ s.mu.Lock()
+ prev := s.last
+ s.last = cur
+ s.mu.Unlock()
+
+ var lines []string
+ if !prev.valid || prev.rxHz != cur.rxHz {
+ lines = append(lines, fmt.Sprintf("vfo:0,0,%d;", cur.rxHz))
+ }
+ if !prev.valid || prev.txHz != cur.txHz {
+ lines = append(lines, fmt.Sprintf("vfo:0,1,%d;", cur.txHz))
+ }
+ if (!prev.valid || prev.mode != cur.mode) && cur.mode != "" {
+ lines = append(lines, fmt.Sprintf("modulation:0,%s;", cur.mode))
+ }
+ if !prev.valid || prev.split != cur.split {
+ lines = append(lines, fmt.Sprintf("split_enable:0,%t;", cur.split))
+ }
+ for _, l := range lines {
+ s.broadcast(l)
+ }
+ return lines
+}
+
+func (s *Server) broadcast(line string) {
+ s.mu.Lock()
+ conns := make([]*client, 0, len(s.conns))
+ for c := range s.conns {
+ conns = append(conns, c)
+ }
+ s.mu.Unlock()
+ for _, c := range conns {
+ _ = c.send(line)
+ }
+}
+
+// handle answers one command from a client. Returns what was sent back, which
+// is "" for a command that only acts on the radio.
+//
+// A command that SETS something is echoed to every client, not just answered to
+// the one that sent it: the protocol document is explicit that the server
+// synchronises all connected clients, and two loggers that disagree about the
+// frequency are worse than one that is merely slow.
+func (s *Server) handle(c *client, cmd string) string {
+ name, args := cmd, ""
+ if i := strings.IndexByte(cmd, ':'); i >= 0 {
+ name, args = cmd[:i], cmd[i+1:]
+ }
+ f := strings.Split(args, ",")
+ arg := func(i int) string {
+ if i < len(f) {
+ return strings.TrimSpace(f[i])
+ }
+ return ""
+ }
+ num := func(i int) int64 {
+ v, _ := strconv.ParseInt(arg(i), 10, 64)
+ return v
+ }
+ reply := func(line string) string {
+ _ = c.send(line)
+ return line
+ }
+ rx, tx, mode, split := s.read()
+
+ switch strings.ToLower(strings.TrimSpace(name)) {
+ case "vfo":
+ // Read form: two arguments. Set form: three.
+ if len(f) < 3 || arg(2) == "" {
+ if arg(1) == "1" {
+ return reply(fmt.Sprintf("vfo:0,1,%d;", tx))
+ }
+ return reply(fmt.Sprintf("vfo:0,0,%d;", rx))
+ }
+ hz := num(2)
+ if hz <= 0 {
+ return ""
+ }
+ if arg(1) == "1" {
+ // Channel B is the transmit frequency, and it only means anything
+ // with split armed. Setting it while simplex would silently move the
+ // rig's only VFO — the client asked to prepare a split TX frequency,
+ // not to QSY.
+ if !split {
+ return ""
+ }
+ if err := s.rig.SetSplit(true, hz); err != nil {
+ s.log("tci server: split TX %d Hz refused: %v", hz, err)
+ return ""
+ }
+ } else if err := s.rig.SetFreq(hz); err != nil {
+ s.log("tci server: tune to %d Hz refused: %v", hz, err)
+ return ""
+ }
+ s.broadcast(fmt.Sprintf("vfo:0,%s,%d;", orZero(arg(1)), hz))
+ return ""
+
+ case "modulation":
+ if len(f) < 2 || arg(1) == "" {
+ return reply(fmt.Sprintf("modulation:0,%s;", mode))
+ }
+ m := tciModeToADIF(arg(1))
+ if m == "" {
+ return ""
+ }
+ if err := s.rig.SetMode(m); err != nil {
+ s.log("tci server: mode %s refused: %v", m, err)
+ return ""
+ }
+ s.broadcast(fmt.Sprintf("modulation:0,%s;", strings.ToLower(arg(1))))
+ return ""
+
+ case "trx":
+ if len(f) < 2 || arg(1) == "" {
+ return reply("trx:0,false;")
+ }
+ on := strings.EqualFold(arg(1), "true")
+ if err := s.rig.SetPTT(on); err != nil {
+ s.log("tci server: PTT %v refused: %v", on, err)
+ return ""
+ }
+ s.broadcast(fmt.Sprintf("trx:0,%t;", on))
+ return ""
+
+ case "split_enable":
+ if len(f) < 2 || arg(1) == "" {
+ return reply(fmt.Sprintf("split_enable:0,%t;", split))
+ }
+ on := strings.EqualFold(arg(1), "true")
+ if err := s.rig.SetSplit(on, tx); err != nil {
+ // The refusal is the useful part: a backend that cannot split says
+ // so, and the client can tell the operator instead of transmitting
+ // on the wrong frequency believing all is well.
+ s.log("tci server: split %v refused: %v", on, err)
+ return ""
+ }
+ s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
+ return ""
+
+ case "dds":
+ // The panorama's centre frequency. OpsLog has no panorama, so it answers
+ // with the receive frequency — which is where a client draws its own.
+ return reply(fmt.Sprintf("dds:0,%d;", rx))
+
+ case "if":
+ // Offset of the tuning filter inside the panorama: zero, since our "dds"
+ // is the receive frequency itself.
+ return reply("if:0,0,0;")
+
+ case "start", "stop", "ready":
+ return ""
+
+ default:
+ // Everything else — audio streams, CW macros, the E-Coder, the
+ // panorama's own settings — belongs to a radio, not to a CAT link.
+ // Silence rather than an error: a client sends these hopefully at
+ // connect, and a refusal it did not ask for reads as a fault.
+ return ""
+ }
+}
+
+func orZero(s string) string {
+ if s == "" {
+ return "0"
+ }
+ return s
+}
+
+// adifToTCIMode maps an ADIF mode to a TCI modulation.
+//
+// SSB carries no sideband, so it is resolved from the frequency the way every
+// operator does: below 10 MHz lower, above it upper. A client told "ssb" would
+// not recognise it — the modulation list is the vocabulary.
+func adifToTCIMode(mode string, hz int64) string {
+ switch strings.ToUpper(strings.TrimSpace(mode)) {
+ case "":
+ return ""
+ case "CW", "CWR":
+ return "cw"
+ case "USB":
+ return "usb"
+ case "LSB":
+ return "lsb"
+ case "SSB":
+ if hz > 0 && hz < 10_000_000 {
+ return "lsb"
+ }
+ return "usb"
+ case "AM":
+ return "am"
+ case "FM", "NFM":
+ return "nfm"
+ case "RTTY":
+ return "digl"
+ }
+ // Everything else is a data mode: FT8, FT4, JT65, PSK31, MSK144, VARA…
+ // TCI has one pair for the whole family, and the sideband follows the same
+ // rule the data modes themselves use — upper, but for the few HF corners
+ // where LSB is conventional the radio is already there.
+ return "digu"
+}
+
+// tciModeToADIF maps a TCI modulation back to an ADIF mode.
+func tciModeToADIF(m string) string {
+ switch strings.ToLower(strings.TrimSpace(m)) {
+ case "cw":
+ return "CW"
+ case "usb":
+ return "USB"
+ case "lsb":
+ return "LSB"
+ case "am", "sam":
+ return "AM"
+ case "nfm", "fm", "wfm":
+ return "FM"
+ case "digl", "digu", "dsb", "drm":
+ // The data family: the mode the operator is actually running (FT8, RTTY)
+ // is chosen in OpsLog, and a client switching to "digital" must not
+ // overwrite it with a guess. DATA is the honest ADIF answer.
+ return "DATA"
+ }
+ return ""
+}
diff --git a/internal/tciserver/tciserver_test.go b/internal/tciserver/tciserver_test.go
new file mode 100644
index 0000000..17e657b
--- /dev/null
+++ b/internal/tciserver/tciserver_test.go
@@ -0,0 +1,242 @@
+package tciserver
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+)
+
+// fakeRig is a radio that remembers what it was told. Everything here is about
+// what OpsLog does with a client's command, so the rig only has to answer and
+// record.
+type fakeRig struct {
+ freq, rxFreq int64
+ mode string
+ split bool
+ txHz int64
+ ptt bool
+ splitErr error
+ calls []string
+}
+
+func (r *fakeRig) Freq() int64 { return r.freq }
+func (r *fakeRig) RxFreq() int64 { return r.rxFreq }
+func (r *fakeRig) Mode() string { return r.mode }
+func (r *fakeRig) Split() (bool, int64) { return r.split, r.txHz }
+func (r *fakeRig) SetFreq(hz int64) error {
+ r.calls = append(r.calls, fmt.Sprintf("freq=%d", hz))
+ r.freq, r.rxFreq = hz, hz
+ return nil
+}
+func (r *fakeRig) SetMode(m string) error {
+ r.calls = append(r.calls, "mode="+m)
+ r.mode = m
+ return nil
+}
+func (r *fakeRig) SetPTT(on bool) error {
+ r.calls = append(r.calls, fmt.Sprintf("ptt=%v", on))
+ r.ptt = on
+ return nil
+}
+func (r *fakeRig) SetSplit(on bool, txHz int64) error {
+ if r.splitErr != nil {
+ return r.splitErr
+ }
+ r.calls = append(r.calls, fmt.Sprintf("split=%v,%d", on, txHz))
+ r.split, r.txHz = on, txHz
+ return nil
+}
+
+// srv builds a server with no listener — handle() and publish() are the whole
+// protocol, and neither needs a socket.
+func srv(r *fakeRig) *Server { return New(0, r, nil) }
+
+// A client with no connection: send() would need one, so reads are checked
+// through the returned line instead. This is why handle returns what it sent.
+func ask(t *testing.T, s *Server, cmd string) string {
+ t.Helper()
+ return s.handle(&client{}, cmd)
+}
+
+// The initialisation block is what a client needs before it will believe there
+// is a radio at all. Its contents come from §4.1 of the protocol document, and
+// a client that does not see ready; simply waits for ever.
+func TestInitBlockCarriesTheDocumentedInitialisationSet(t *testing.T) {
+ s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"})
+ block := strings.Join(s.initBlock(), "")
+ for _, want := range []string{
+ "protocol:ExpertSDR3,", "device:", "receive_only:false;", "trx_count:1;",
+ "channel_count:2;", "vfo_limits:", "if_limits:", "modulations_list:",
+ "ready;", "start;",
+ } {
+ if !strings.Contains(block, want) {
+ t.Errorf("the initialisation block is missing %q — a client would not proceed past connect", want)
+ }
+ }
+ // And the current state, so a client that connects mid-session shows the
+ // right frequency instead of waiting for the operator to touch something.
+ if !strings.Contains(block, "vfo:0,0,14074000;") {
+ t.Errorf("no current frequency in the block:\n%s", block)
+ }
+ if !strings.Contains(block, "modulation:0,usb;") {
+ t.Errorf("no current mode in the block:\n%s", block)
+ }
+}
+
+// Channel A is where we LISTEN, channel B where we transmit. Handing these to a
+// client the wrong way round is the one mistake here that puts a station on the
+// DX's own frequency, so it is pinned in both directions.
+func TestSplitPutsTheListeningFrequencyOnChannelA(t *testing.T) {
+ // OpsLog's RigState is ADIF: Freq is the TRANSMIT frequency, RxFreq where we
+ // listen. A DX transmitting on 14025 and listening up 2.
+ r := &fakeRig{freq: 14027000, rxFreq: 14025000, mode: "CW", split: true, txHz: 14027000}
+ s := srv(r)
+ block := strings.Join(s.initBlock(), "")
+ if !strings.Contains(block, "vfo:0,0,14025000;") {
+ t.Errorf("channel A is not the receive frequency:\n%s", block)
+ }
+ if !strings.Contains(block, "vfo:0,1,14027000;") {
+ t.Errorf("channel B is not the transmit frequency:\n%s", block)
+ }
+ if !strings.Contains(block, "split_enable:0,true;") {
+ t.Errorf("split was not announced:\n%s", block)
+ }
+}
+
+// Simplex: both channels report the one frequency, so a client reading either
+// gets the right answer.
+func TestSimplexReportsTheSameFrequencyOnBothChannels(t *testing.T) {
+ s := srv(&fakeRig{freq: 7100000, rxFreq: 7100000, mode: "SSB"})
+ if got := ask(t, s, "vfo:0,0"); got != "vfo:0,0,7100000;" {
+ t.Errorf("read of channel A = %q", got)
+ }
+ if got := ask(t, s, "vfo:0,1"); got != "vfo:0,1,7100000;" {
+ t.Errorf("read of channel B = %q", got)
+ }
+ // 7 MHz is below 10, so SSB is lower sideband — a client told "ssb" would
+ // not recognise it at all, the modulation list is the vocabulary.
+ if got := ask(t, s, "modulation:0"); got != "modulation:0,lsb;" {
+ t.Errorf("read of the mode = %q, want lsb below 10 MHz", got)
+ }
+}
+
+// The client tunes the radio.
+func TestAClientCanTuneAndSetModeAndKey(t *testing.T) {
+ r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
+ s := srv(r)
+ ask(t, s, "vfo:0,0,14200000")
+ ask(t, s, "modulation:0,cw")
+ ask(t, s, "trx:0,true")
+ ask(t, s, "trx:0,false")
+ want := []string{"freq=14200000", "mode=CW", "ptt=true", "ptt=false"}
+ if strings.Join(r.calls, " ") != strings.Join(want, " ") {
+ t.Errorf("the radio was told %v, want %v", r.calls, want)
+ }
+}
+
+// Channel B is the SPLIT transmit frequency. Writing it while the rig is
+// simplex must not move the only VFO there is: the client asked to prepare a
+// transmit frequency, not to QSY — and a logger that did this on every spot
+// click would drag the operator off the station they were listening to.
+func TestWritingChannelBWhileSimplexLeavesTheRigAlone(t *testing.T) {
+ r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
+ s := srv(r)
+ ask(t, s, "vfo:0,1,14080000")
+ if len(r.calls) != 0 {
+ t.Errorf("the radio was told %v — a split TX frequency moved a simplex rig", r.calls)
+ }
+ // With split armed it means what it says.
+ r.split, r.txHz = true, 14074000
+ ask(t, s, "vfo:0,1,14080000")
+ if len(r.calls) != 1 || r.calls[0] != "split=true,14080000" {
+ t.Errorf("with split on the radio was told %v, want the new transmit frequency", r.calls)
+ }
+}
+
+// A backend that cannot split says so, and the refusal must not be dressed up
+// as success: the client can then tell the operator to use Fake It, where
+// before it would transmit on the receive frequency believing all was well.
+func TestARefusedSplitIsNotAnnouncedAsDone(t *testing.T) {
+ r := &fakeRig{freq: 14025000, rxFreq: 14025000, mode: "CW", splitErr: fmt.Errorf("this backend cannot split")}
+ s := srv(r)
+ if got := ask(t, s, "split_enable:0,true"); got != "" {
+ t.Errorf("a refused split answered %q", got)
+ }
+ if r.split {
+ t.Error("the rig was recorded as split after the backend refused")
+ }
+}
+
+// Only what moved is sent. TCI clients redraw on every command they receive, so
+// re-sending an unchanged frequency four times a second makes a VFO readout
+// flicker and, in some clients, fights the operator's own tuning.
+func TestOnlyChangesAreSent(t *testing.T) {
+ r := &fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"}
+ s := srv(r)
+
+ first := s.publish()
+ if len(first) == 0 {
+ t.Fatal("the first pass sent nothing — a client would never learn the state")
+ }
+ if got := s.publish(); len(got) != 0 {
+ t.Errorf("an unchanged radio produced %v", got)
+ }
+
+ r.freq, r.rxFreq = 14200000, 14200000
+ got := s.publish()
+ if len(got) != 2 || !strings.Contains(strings.Join(got, ""), "14200000") {
+ // Both channels move together on a simplex rig, and both are reported.
+ t.Errorf("after a QSY: %v", got)
+ }
+ if got := s.publish(); len(got) != 0 {
+ t.Errorf("the QSY was re-sent: %v", got)
+ }
+}
+
+// Modes travel both ways, and the data family is the interesting half: a client
+// switching to "digital" must not overwrite the mode the operator chose in
+// OpsLog with a guess at which data mode it was.
+func TestModeMapping(t *testing.T) {
+ up := []struct {
+ adif string
+ hz int64
+ want string
+ }{
+ {"CW", 14025000, "cw"},
+ {"SSB", 14200000, "usb"},
+ {"SSB", 7100000, "lsb"},
+ {"USB", 7100000, "usb"}, // an explicit sideband is never second-guessed
+ {"FT8", 14074000, "digu"},
+ {"RTTY", 14080000, "digl"},
+ {"AM", 3700000, "am"},
+ {"FM", 145500000, "nfm"},
+ {"", 14074000, ""},
+ }
+ for _, c := range up {
+ if got := adifToTCIMode(c.adif, c.hz); got != c.want {
+ t.Errorf("adifToTCIMode(%q, %d) = %q, want %q", c.adif, c.hz, got, c.want)
+ }
+ }
+ down := map[string]string{
+ "cw": "CW", "usb": "USB", "lsb": "LSB", "am": "AM", "sam": "AM",
+ "nfm": "FM", "digu": "DATA", "digl": "DATA", "": "",
+ }
+ for in, want := range down {
+ if got := tciModeToADIF(in); got != want {
+ t.Errorf("tciModeToADIF(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// A command for something OpsLog is not — audio streams, CW macros, the
+// panorama's settings — is met with silence rather than an error. A client
+// sends these hopefully at connect, and a refusal it did not ask for reads as a
+// fault with the rig.
+func TestUnknownCommandsAreQuiet(t *testing.T) {
+ s := srv(&fakeRig{freq: 14074000, rxFreq: 14074000, mode: "USB"})
+ for _, cmd := range []string{"audio_start:0", "cw_macros_speed:25", "rx_filter_band:0,-2700,-100", "iq_start:0"} {
+ if got := ask(t, s, cmd); got != "" {
+ t.Errorf("%q answered %q", cmd, got)
+ }
+ }
+}