feat: native Yaesu CAT backend (FTDX10 / FTDX101), no OmniRig

Every Yaesu fault reported so far came from OmniRig's interpretation layer, not
from the radio: a rig file that never exposes the VFO, a Freq property meaning A
on one model and B on another, a split flag that alternates between polls. This
talks to the rig directly, so what the radio answers is what is shown.

Modern Yaesu CAT is plain ASCII with a ';' terminator — FA/FB for the VFOs, MD0
for the mode, VS for the selected VFO, TX to key. Frequency is written to the VFO
the operator is actually on, not always to A, which is the failure that made a
display disagree with the radio.

Two things are genuinely uncertain across the family and are treated as such
rather than guessed. SPLIT is read through ST, then FT if the rig ignores ST —
whichever answers wins and the choice is remembered, because the two commands say
DIFFERENT things (a split flag vs which VFO transmits). If neither answers, split
is reported OFF and the fact is logged, rather than invented. Unknown model ids
and mode bytes are logged raw for the same reason.

Split resolution, frequency parsing and the mode mapping are pure functions with
a table test — the OmniRig equivalent is where every Yaesu bug lived, and it had
no test until late.

Written from the CAT reference; NOT yet verified on a radio.
This commit is contained in:
2026-07-29 10:33:26 +02:00
parent 9cfa7a4dd1
commit 67005a8d50
7 changed files with 553 additions and 3 deletions
+21 -1
View File
@@ -108,6 +108,8 @@ const (
keyCATPollMs = "cat.poll_ms" keyCATPollMs = "cat.poll_ms"
keyCATDelayMs = "cat.delay_ms" // pause between commands keyCATDelayMs = "cat.delay_ms" // pause between commands
keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
@@ -342,6 +344,8 @@ type CATSettings struct {
FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter
FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter FlexDecodeSpots bool `json:"flex_decode_spots"` // push WSJT-X decodes (heard stations) to the panadapter
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120) FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
@@ -6562,7 +6566,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") 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, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATYaesuPort, keyCATYaesuBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -6575,6 +6579,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
FlexSpots: m[keyCATFlexSpots] == "1", FlexSpots: m[keyCATFlexSpots] == "1",
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1", FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
FlexDecodeSecs: 120, FlexDecodeSecs: 120,
YaesuPort: m[keyCATYaesuPort],
YaesuBaud: 38400,
IcomPort: m[keyCATIcomPort], IcomPort: m[keyCATIcomPort],
IcomBaud: 115200, IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default IcomAddr: 0x98, // IC-7610 default
@@ -6598,6 +6604,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 { if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 {
out.TCIPort = n out.TCIPort = n
} }
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
out.YaesuBaud = n
}
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 { if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
out.IcomBaud = n out.IcomBaud = n
} }
@@ -6639,6 +6648,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexPort <= 0 || s.FlexPort > 65535 { if s.FlexPort <= 0 || s.FlexPort > 65535 {
s.FlexPort = 4992 s.FlexPort = 4992
} }
if s.YaesuBaud <= 0 {
s.YaesuBaud = 38400
}
if s.IcomBaud <= 0 { if s.IcomBaud <= 0 {
s.IcomBaud = 115200 s.IcomBaud = 115200
} }
@@ -6687,6 +6699,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATFlexSpots: flexSpots, keyCATFlexSpots: flexSpots,
keyCATFlexDecodeSpots: flexDecodeSpots, keyCATFlexDecodeSpots: flexDecodeSpots,
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs), keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
keyCATIcomPort: strings.TrimSpace(s.IcomPort), keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud), keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr), keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
@@ -12059,6 +12073,12 @@ func (a *App) reloadCAT() {
} }
} }
a.cat.Start(fb) a.cat.Start(fb)
case "yaesu":
// Native Yaesu CAT over the rig's USB/serial port — no OmniRig. Every
// Yaesu fault reported so far came from OmniRig's interpretation layer
// (a rig file that hides the VFO, a Freq property meaning A on one model
// and B on another); talking to the radio directly removes it.
a.cat.Start(cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault))
case "icom": case "icom":
// Native Icom CI-V over the radio's USB serial port (local control). // Native Icom CI-V over the radio's USB serial port (local control).
// Same civ protocol the network backend reuses for remote. // Same civ protocol the network backend reuses for remote.
+2
View File
@@ -3,6 +3,7 @@
"version": "0.21.9", "version": "0.21.9",
"date": "2026-07-28", "date": "2026-07-28",
"en": [ "en": [
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
"Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.", "Changing band from OpsLog now updates the displayed frequency straight away. The rig moved, but its answer arrived inside the short grace window that protects what you are typing and was discarded — so the frequency stayed on the old band until you nudged the VFO.",
"Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.", "Bulk edit can now set the contacted station gridsquare, so a batch of QSOs imported without a locator can be corrected in one go.",
"Filter builder: a confirmation date typed into a condition showed an empty box. The value was stored correctly, only the calendar field failed to display it.", "Filter builder: a confirmation date typed into a condition showed an empty box. The value was stored correctly, only the calendar field failed to display it.",
@@ -12,6 +13,7 @@
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes." "CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
], ],
"fr": [ "fr": [
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
"Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.", "Changer de bande depuis OpsLog met désormais la fréquence affichée à jour immédiatement. La radio se déplaçait bien, mais sa réponse arrivait pendant le court délai qui protège votre saisie et était ignorée — la fréquence restait donc sur l'ancienne bande jusqu'à ce qu'on touche le VFO.",
"L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.", "L'édition groupée peut désormais définir le locator de la station contactée : un lot de QSO importés sans locator se corrige en une fois.",
"Constructeur de filtres : une date de confirmation saisie dans une condition s'affichait dans une case vide. La valeur était bien enregistrée, seul le champ calendrier ne la montrait pas.", "Constructeur de filtres : une date de confirmation saisie dans une condition s'affichait dans une case vide. La valeur était bien enregistrée, seul le champ calendrier ne la montrait pas.",
+31
View File
@@ -1061,6 +1061,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
yaesu_port: '', yaesu_baud: 38400,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, 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, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', digital_default: 'FT8',
@@ -2346,6 +2347,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent> <SelectContent>
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem> <SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem> <SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem> <SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem> <SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
<SelectItem value="tci">{t('cat.optTci')}</SelectItem> <SelectItem value="tci">{t('cat.optTci')}</SelectItem>
@@ -2413,6 +2415,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)} )}
</> </>
)} )}
{catCfg.backend === 'yaesu' && (
<>
<div className="space-y-1">
<Label>{t('cat.yaesuPort')}</Label>
<div className="flex gap-2">
<Select value={catCfg.yaesu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_port: v }))}>
<SelectTrigger><SelectValue placeholder={t('cat.selectCom')} /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>{t('cat.noPorts')}</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button type="button" variant="outline" size="sm"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}></Button>
</div>
<span className="text-xs text-muted-foreground">{t('cat.yaesuPortHint')}</span>
</div>
<div className="space-y-1">
<Label>{t('cat.baud')}</Label>
<Select value={String(catCfg.yaesu_baud || 38400)} onValueChange={(v) => setCatCfg((s) => ({ ...s, yaesu_baud: parseInt(v) || 38400 }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span>
</div>
</>
)}
{catCfg.backend === 'icom' && ( {catCfg.backend === 'icom' && (
<> <>
<div className="space-y-1"> <div className="space-y-1">
+2 -2
View File
@@ -277,7 +277,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 12 min delay so a mis-logged QSO can still be fixed first).', '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 12 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 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', '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 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body // CAT panel body
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optYaesu': 'Yaesu (native CAT)', '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.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', '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.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)', 'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
@@ -679,7 +679,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.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', '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 à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.", 'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', '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 à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", '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 12 min pour corriger un QSO mal saisi avant).", '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 12 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.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', '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.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)', 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optYaesu': 'Yaesu (CAT natif)', '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.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', '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.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 laudio RX par le réseau (expérimental)', 'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
+4
View File
@@ -1835,6 +1835,8 @@ export namespace main {
flex_spots: boolean; flex_spots: boolean;
flex_decode_spots: boolean; flex_decode_spots: boolean;
flex_decode_secs: number; flex_decode_secs: number;
yaesu_port: string;
yaesu_baud: number;
icom_port: string; icom_port: string;
icom_baud: number; icom_baud: number;
icom_addr: number; icom_addr: number;
@@ -1864,6 +1866,8 @@ export namespace main {
this.flex_spots = source["flex_spots"]; this.flex_spots = source["flex_spots"];
this.flex_decode_spots = source["flex_decode_spots"]; this.flex_decode_spots = source["flex_decode_spots"];
this.flex_decode_secs = source["flex_decode_secs"]; this.flex_decode_secs = source["flex_decode_secs"];
this.yaesu_port = source["yaesu_port"];
this.yaesu_baud = source["yaesu_baud"];
this.icom_port = source["icom_port"]; this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"]; this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"]; this.icom_addr = source["icom_addr"];
+388
View File
@@ -0,0 +1,388 @@
package cat
// Native Yaesu CAT over the rig's serial/USB port — no OmniRig.
//
// Why this exists: OmniRig sits between OpsLog and the radio and adds its own
// rig-description files, its own VFO/split interpretation and its own polling.
// Every Yaesu problem reported so far came from that layer disagreeing with the
// radio — a .ini that never exposes the VFO, a Freq property that means A on one
// model and B on another, a split flag that alternates. Talking to the rig
// directly removes the disagreement: what the radio answers is what we show.
//
// ── The protocol ──────────────────────────────────────────────────────────
// Modern Yaesu CAT is plain ASCII: a command, its arguments, and a ';'
// terminator. A query is the command with no argument; the rig echoes the same
// command with the value. It is the same shape as Kenwood's, which is why an
// FTDX10 answers a Kenwood-speaking logger for the basics.
//
// FA; → FA014074000; VFO A frequency, 9 digits, Hz
// FB; → FB014100000; VFO B frequency
// MD0; → MD02; operating mode of the main receiver
// VS; → VS0; which VFO is selected (0=A, 1=B)
// ST; → ST1; split (FTDX10/FTDX101)
// FT; → FT1; TX VFO (FT-991A/FT-710/FT-891 family)
// TX1; / TX0; key / unkey
// ID; → ID0761; model identifier
//
// Two of these are genuinely uncertain across the family and are treated as
// such rather than guessed at: SPLIT is read through ST and, if the rig does not
// answer that, through FT — whichever replies wins, and the choice is
// remembered. Every unrecognised reply is logged raw, because that log is the
// only way to learn a model's real behaviour from an operator's shack.
//
// Verified on: nothing yet — written from the FTDX10/FTDX101 CAT reference and
// awaiting a first on-air run. Anything this file asserts about a rig it has not
// met should be read as a hypothesis with a log line attached.
import (
"fmt"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
// yaesuModels maps the ID reply to a display name. An unknown id is shown as
// itself rather than guessed — a wrong model name would be worse than a number,
// because it silently implies capabilities the rig may not have.
var yaesuModels = map[string]string{
"0761": "FTDX10",
"0681": "FTDX101D",
"0682": "FTDX101MP",
"0800": "FT-710",
"0570": "FT-991A",
"0650": "FT-891",
"0670": "FT-DX3000",
"0460": "FT-450D",
}
// yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants
// differ only by sideband, which ADIF does not record — they collapse to the
// operator's configured digital mode and to RTTY respectively.
var yaesuModeToADIF = map[byte]string{
'1': "LSB",
'2': "USB",
'3': "CW",
'4': "FM",
'5': "AM",
'6': "RTTY",
'7': "CW",
'8': "DATA",
'9': "RTTY",
'A': "FM",
'B': "FM",
'C': "DATA",
'D': "AM",
'E': "FM", // C4FM — digital voice, closest ADIF sense is FM
}
type Yaesu struct {
portName string
baud int
digital string // ADIF mode reported for DATA (FT8, RTTY…)
mu sync.Mutex
port serial.Port
model string
// splitCmd is learned at connect: "ST" or "FT" depending on which the rig
// answers. Empty means the rig answered neither, and split is reported as
// off rather than invented.
splitCmd string
curFreq int64
curVFO string // "A" or "B"
}
func NewYaesu(portName string, baud int, digital string) *Yaesu {
if baud <= 0 {
baud = 38400 // FTDX10/FTDX101 factory default
}
if strings.TrimSpace(digital) == "" {
digital = "FT8"
}
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
}
func (y *Yaesu) Name() string { return "yaesu" }
func (y *Yaesu) Connect() error {
y.mu.Lock()
defer y.mu.Unlock()
if y.portName == "" {
return fmt.Errorf("yaesu: no serial port configured")
}
p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud})
if err != nil {
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
}
p.SetReadTimeout(300 * time.Millisecond)
y.port = p
// Silence unsolicited status reports. The rig can push them on every knob
// movement (AI1), which interleaves with our request/response pairs and makes
// a reply impossible to attribute — we poll instead, so the traffic is ours.
_ = y.write("AI0;")
if id, err := y.ask("ID;"); err == nil {
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
if name, ok := yaesuModels[code]; ok {
y.model = name
} else {
y.model = "Yaesu (" + code + ")"
debugLog.Printf("yaesu: unknown model id %q — add it to yaesuModels", code)
}
} else {
debugLog.Printf("yaesu: ID query failed (%v) — continuing, the model name is cosmetic", err)
}
// Which command carries split on THIS rig. Asking once at connect and
// remembering the answer keeps the poll loop from paying for two round trips
// per cycle, and makes "neither answered" an explicit, logged state instead
// of a silent assumption that split is off.
for _, c := range []string{"ST", "FT"} {
if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) {
y.splitCmd = c
debugLog.Printf("yaesu: split is read through %s (answered %q)", c, r)
break
}
}
if y.splitCmd == "" {
debugLog.Printf("yaesu: neither ST; nor FT; answered — split will be reported as OFF. Send this log if the rig does have split.")
}
debugLog.Printf("yaesu: connected on %s @ %d baud, model=%q", y.portName, y.baud, y.model)
return nil
}
func (y *Yaesu) Disconnect() {
y.mu.Lock()
defer y.mu.Unlock()
if y.port != nil {
_ = y.port.Close()
y.port = nil
}
}
func (y *Yaesu) ReadState() (RigState, error) {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return RigState{}, fmt.Errorf("yaesu: not connected")
}
s := RigState{Backend: y.Name(), Connected: true, Rig: y.model}
faRaw, err := y.ask("FA;")
if err != nil {
return RigState{}, err // the rig stopped answering — let the Manager reconnect
}
freqA, ok := parseYaesuFreq(faRaw, "FA")
if !ok {
return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw)
}
freqB := int64(0)
if r, err := y.ask("FB;"); err == nil {
freqB, _ = parseYaesuFreq(r, "FB")
}
// Which VFO the operator is listening on. Unlike OmniRig there is no
// interpretation to do: VS answers 0 or 1.
vfo := "A"
if r, err := y.ask("VS;"); err == nil && len(r) >= 3 && r[2] == '1' {
vfo = "B"
}
y.curVFO = vfo
split := false
if y.splitCmd != "" {
if r, err := y.ask(y.splitCmd + ";"); err == nil {
split = yaesuSplitOn(r, y.splitCmd)
}
}
s.Vfo = vfo
s.FreqHz, s.RxFreqHz, s.Split = resolveYaesuVFOs(freqA, freqB, vfo, split)
y.curFreq = s.FreqHz
if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 {
if m, ok := yaesuModeToADIF[r[3]]; ok {
if m == "DATA" {
m = y.digital
}
s.Mode = m
} else {
debugLog.Printf("yaesu: unknown mode reply %q", r)
}
}
return s, nil
}
func (y *Yaesu) SetFrequency(hz int64) error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
if hz <= 0 || hz > 999_999_999 {
return fmt.Errorf("yaesu: frequency %d out of the 9-digit CAT range", hz)
}
// Write to the VFO the operator is ACTUALLY on. Always writing FA is what
// makes a display disagree with the radio when the operator is on B.
cmd := "FA"
if y.curVFO == "B" {
cmd = "FB"
}
return y.write(fmt.Sprintf("%s%09d;", cmd, hz))
}
func (y *Yaesu) SetMode(mode string) error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
d := yaesuModeDigit(mode, y.curFreq)
if d == 0 {
return fmt.Errorf("yaesu: no CAT mode for %q", mode)
}
return y.write(fmt.Sprintf("MD0%c;", d))
}
func (y *Yaesu) SetPTT(on bool) error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
if on {
return y.write("TX1;")
}
return y.write("TX0;")
}
// ── helpers ───────────────────────────────────────────────────────────────
// write sends one command. The caller holds the mutex.
func (y *Yaesu) write(cmd string) error {
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
_, err := y.port.Write([]byte(cmd))
return err
}
// ask sends a query and reads the reply up to its ';'. The caller holds the
// mutex, so a command and its answer are never interleaved with another's.
func (y *Yaesu) ask(cmd string) (string, error) {
if err := y.write(cmd); err != nil {
return "", err
}
buf := make([]byte, 0, 32)
tmp := make([]byte, 32)
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
n, err := y.port.Read(tmp)
if err != nil {
return "", err
}
if n == 0 {
continue // read timeout — the rig may still be composing its answer
}
buf = append(buf, tmp[:n]...)
if i := strings.IndexByte(string(buf), ';'); i >= 0 {
return string(buf[:i+1]), nil
}
if len(buf) > 512 {
return "", fmt.Errorf("yaesu: no ';' in %d bytes answering %q", len(buf), cmd)
}
}
return "", fmt.Errorf("yaesu: timeout answering %q", cmd)
}
// parseYaesuFreq reads "FA014074000;" into Hz.
func parseYaesuFreq(reply, prefix string) (int64, bool) {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, prefix) {
return 0, false
}
digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
if digits == "" {
return 0, false
}
hz, err := strconv.ParseInt(digits, 10, 64)
if err != nil || hz <= 0 {
return 0, false
}
return hz, true
}
// yaesuSplitOn reads the split reply for whichever command the rig answers.
//
// ST is a split flag: ST1 means split. FT names the TX VFO: FT1 means transmit
// on VFO B, which IS split when the operator is listening on A. The two are not
// the same statement, which is why the command in use is remembered rather than
// both being tried and merged.
func yaesuSplitOn(reply, cmd string) bool {
r := strings.TrimSpace(reply)
if !strings.HasPrefix(r, cmd) || len(r) < len(cmd)+1 {
return false
}
return r[len(cmd)] == '1'
}
// resolveYaesuVFOs turns the two frequencies plus the VFO and split flags into
// the ADIF pair: FreqHz is where we TRANSMIT, RxFreqHz only when split.
//
// Kept pure and separate from ReadState so the rules can be tested without a
// radio — the equivalent OmniRig function is where every Yaesu bug lived.
func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64, isSplit bool) {
listening, transmitting := freqA, freqB
if vfo == "B" {
listening, transmitting = freqB, freqA
}
if !split {
return listening, 0, false
}
// Split with a missing or identical other VFO is not split: reporting it
// would put a wrong TX frequency in the log, which is worse than ignoring a
// flag the rig may have left set.
if transmitting <= 0 || transmitting == listening {
return listening, 0, false
}
return transmitting, listening, true
}
// yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit —
// the sideband follows the worldwide convention (LSB below 10 MHz, USB above),
// which is why the current frequency is part of the decision.
func yaesuModeDigit(mode string, freqHz int64) byte {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB":
if freqHz > 0 && freqHz < 10_000_000 {
return '1' // LSB
}
return '2' // USB
case "LSB":
return '1'
case "USB":
return '2'
case "CW":
return '3'
case "FM":
return '4'
case "AM":
return '5'
case "RTTY":
return '6'
case "":
return 0
default:
// Everything else is a digital sub-mode (FT8, FT4, PSK31, JS8…). They all
// ride on the rig's DATA mode; the sideband follows the same convention.
if freqHz > 0 && freqHz < 10_000_000 {
return '8' // DATA-LSB
}
return 'C' // DATA-USB
}
}
+105
View File
@@ -0,0 +1,105 @@
package cat
import "testing"
func TestParseYaesuFreq(t *testing.T) {
cases := []struct {
reply, prefix string
want int64
ok bool
}{
{"FA014074000;", "FA", 14074000, true},
{"FB007100000;", "FB", 7100000, true},
{"FA000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
{"FA010368000000;", "FA", 10368000000, true},
{"FB;", "FB", 0, false}, // query echoed back with no value
{"FA014074000;", "FB", 0, false}, // wrong VFO — never silently accepted
{"", "FA", 0, false},
{"FAxxxxxxxxx;", "FA", 0, false},
}
for _, c := range cases {
got, ok := parseYaesuFreq(c.reply, c.prefix)
if got != c.want || ok != c.ok {
t.Errorf("parseYaesuFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
}
}
}
// The split rules. Getting these wrong writes a WRONG TX frequency into the log,
// which is why an ambiguous state resolves to "not split" rather than to a
// guess — the same principle the OmniRig backend arrived at the hard way.
func TestResolveYaesuVFOs(t *testing.T) {
const a, b = 14074000, 14100000
cases := []struct {
name string
fa, fb int64
vfo string
split bool
wantTX, wantRX int64
wantSplit bool
}{
{"simplex on A", a, b, "A", false, a, 0, false},
{"simplex on B", a, b, "B", false, b, 0, false},
{"split, listening on A → TX on B", a, b, "A", true, b, a, true},
{"split, listening on B → TX on A", a, b, "B", true, a, b, true},
{"split flag but the other VFO is unread", a, 0, "A", true, a, 0, false},
{"split flag but both VFOs identical", a, a, "A", true, a, 0, false},
}
for _, c := range cases {
tx, rx, sp := resolveYaesuVFOs(c.fa, c.fb, c.vfo, c.split)
if tx != c.wantTX || rx != c.wantRX || sp != c.wantSplit {
t.Errorf("%s: got tx=%d rx=%d split=%v — want tx=%d rx=%d split=%v",
c.name, tx, rx, sp, c.wantTX, c.wantRX, c.wantSplit)
}
}
}
// ST and FT say different things and must not be read the same way — see the
// comment on yaesuSplitOn.
func TestYaesuSplitReply(t *testing.T) {
cases := []struct {
reply, cmd string
want bool
}{
{"ST1;", "ST", true},
{"ST0;", "ST", false},
{"FT1;", "FT", true},
{"FT0;", "FT", false},
{"ST1;", "FT", false}, // reply for the other command — not accepted
{"ST", "ST", false}, // truncated
{"", "ST", false},
}
for _, c := range cases {
if got := yaesuSplitOn(c.reply, c.cmd); got != c.want {
t.Errorf("yaesuSplitOn(%q,%q) = %v, want %v", c.reply, c.cmd, got, c.want)
}
}
}
// The sideband follows the frequency, worldwide convention — a CAT backend that
// puts USB on 40 m makes every SSB QSO wrong.
func TestYaesuModeDigit(t *testing.T) {
cases := []struct {
mode string
hz int64
want byte
}{
{"SSB", 7150000, '1'}, // LSB below 10 MHz
{"SSB", 14250000, '2'}, // USB above
{"LSB", 14250000, '1'}, // explicit wins over the convention
{"USB", 7150000, '2'},
{"CW", 7030000, '3'},
{"RTTY", 14080000, '6'},
{"AM", 7150000, '5'},
{"FM", 145000000, '4'},
{"FT8", 7074000, '8'}, // DATA-LSB
{"FT8", 14074000, 'C'}, // DATA-USB
{"JS8", 14078000, 'C'}, // any unknown digital rides on DATA
{"", 14074000, 0}, // nothing to set
}
for _, c := range cases {
if got := yaesuModeDigit(c.mode, c.hz); got != c.want {
t.Errorf("yaesuModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
}
}
}