feat: native Xiegu CAT backend (G90 / X6100 / X6200 / X5105)
Xiegu speaks CI-V with a reduced command set: frames, BCD encoding, addressing and the opcodes for frequency, mode, PTT and split are Icom's, so this reuses internal/cat/civ wholesale instead of re-deriving a codec. It is a SEPARATE backend rather than the Icom one at address 0x70, because what the two rigs do NOT share is the deciding part. The Icom backend reads the spectrum scope, the DSP block, data mode via 1A 06 and the model id via 19 — none of which a Xiegu implements. Pointed at a G90 it would poll every cycle for answers that never come, and spend its silence tolerance on commands the radio was never going to support. Two consequences of the rig's smaller mode table are handled explicitly rather than left to fail: there is no data mode, so a digital QSO is set to plain sideband (what the operator does on the radio anyway) instead of being refused; and the split TX frequency is NOT reported, because reading the unselected VFO needs 0x25, which the Xiegu table does not list — a split flag carrying a wrong TX frequency is worse than the flag alone, since the frequency is what gets logged. The published command table has rows that slipped during typesetting (0x07 and 0x0F share a block). Where it contradicts itself the Icom meaning is used, the rest of the table matching Icom exactly, and every unexpected reply is logged raw so a first on-air run settles it. NOT yet verified on a radio.
This commit is contained in:
@@ -111,6 +111,9 @@ 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)
|
||||
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)
|
||||
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)
|
||||
@@ -347,6 +350,9 @@ type CATSettings struct {
|
||||
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
|
||||
FlexDecodeSecs int `json:"flex_decode_secs"` // decode spot display duration (s) before removal (default 120)
|
||||
XieguPort string `json:"xiegu_port"` // Xiegu CI-V serial port (G90/X6100…)
|
||||
XieguBaud int `json:"xiegu_baud"` // Xiegu CI-V baud (G90 default 19200)
|
||||
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
|
||||
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)
|
||||
@@ -6582,7 +6588,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, keyCATYaesuPort, keyCATYaesuBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATYaesuPort, keyCATYaesuBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||
if err != nil {
|
||||
return CATSettings{}, err
|
||||
}
|
||||
@@ -6595,6 +6601,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
FlexSpots: m[keyCATFlexSpots] == "1",
|
||||
FlexDecodeSpots: m[keyCATFlexDecodeSpots] == "1",
|
||||
FlexDecodeSecs: 120,
|
||||
XieguPort: m[keyCATXieguPort],
|
||||
XieguBaud: 19200,
|
||||
XieguAddr: cat.XieguDefaultAddr,
|
||||
YaesuPort: m[keyCATYaesuPort],
|
||||
YaesuBaud: 38400,
|
||||
IcomPort: m[keyCATIcomPort],
|
||||
@@ -6625,6 +6634,12 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
||||
if n, _ := strconv.Atoi(m[keyCATSharePort]); n > 0 && n <= 65535 {
|
||||
out.SharePort = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguBaud]); n > 0 {
|
||||
out.XieguBaud = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATXieguAddr]); n > 0 && n <= 0xFF {
|
||||
out.XieguAddr = n
|
||||
}
|
||||
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
|
||||
out.YaesuBaud = n
|
||||
}
|
||||
@@ -6672,6 +6687,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
if s.SharePort <= 0 || s.SharePort > 65535 {
|
||||
s.SharePort = 4532
|
||||
}
|
||||
if s.XieguBaud <= 0 {
|
||||
s.XieguBaud = 19200
|
||||
}
|
||||
if s.XieguAddr <= 0 || s.XieguAddr > 0xFF {
|
||||
s.XieguAddr = cat.XieguDefaultAddr
|
||||
}
|
||||
if s.YaesuBaud <= 0 {
|
||||
s.YaesuBaud = 38400
|
||||
}
|
||||
@@ -6727,6 +6748,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
||||
keyCATFlexSpots: flexSpots,
|
||||
keyCATFlexDecodeSpots: flexDecodeSpots,
|
||||
keyCATFlexDecodeSecs: strconv.Itoa(s.FlexDecodeSecs),
|
||||
keyCATXieguPort: strings.TrimSpace(s.XieguPort),
|
||||
keyCATXieguBaud: strconv.Itoa(s.XieguBaud),
|
||||
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||
keyCATIcomPort: strings.TrimSpace(s.IcomPort),
|
||||
@@ -12104,6 +12128,11 @@ func (a *App) reloadCAT() {
|
||||
}
|
||||
}
|
||||
a.cat.Start(fb)
|
||||
case "xiegu":
|
||||
// Xiegu G90/X6100/X6200/X5105 — CI-V, but a REDUCED command set: no scope,
|
||||
// no DSP block, no data mode. Its own backend rather than the Icom one at
|
||||
// another address, so we never poll a G90 for answers it cannot give.
|
||||
a.cat.Start(cat.NewXiegu(s.XieguPort, s.XieguBaud, s.XieguAddr, s.DigitalDefault))
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "0.21.9",
|
||||
"date": "2026-07-28",
|
||||
"en": [
|
||||
"Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.",
|
||||
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).",
|
||||
"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.",
|
||||
@@ -14,6 +15,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."
|
||||
],
|
||||
"fr": [
|
||||
"CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.",
|
||||
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).",
|
||||
"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.",
|
||||
|
||||
@@ -1061,7 +1061,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
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,
|
||||
yaesu_port: '', yaesu_baud: 38400,
|
||||
yaesu_port: '', yaesu_baud: 38400, xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
|
||||
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,
|
||||
@@ -2348,6 +2348,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectItem value="omnirig">{t('cat.optOmnirig')}</SelectItem>
|
||||
<SelectItem value="flex">{t('cat.optFlex')}</SelectItem>
|
||||
<SelectItem value="yaesu">{t('cat.optYaesu')}</SelectItem>
|
||||
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
|
||||
<SelectItem value="icom">{t('cat.optIcom')}</SelectItem>
|
||||
<SelectItem value="icom-net">{t('cat.optIcomNet')}</SelectItem>
|
||||
<SelectItem value="tci">{t('cat.optTci')}</SelectItem>
|
||||
@@ -2415,6 +2416,43 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'xiegu' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.xieguPort')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={catCfg.xiegu_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_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>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.baud')}</Label>
|
||||
<Select value={String(catCfg.xiegu_baud || 19200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, xiegu_baud: parseInt(v) || 19200 }))}>
|
||||
<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.xieguBaudHint')}</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('cat.civAddr')}</Label>
|
||||
<Input value={'0x' + (catCfg.xiegu_addr || 0x70).toString(16).toUpperCase().padStart(2, '0')}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value.replace(/^0x/i, ''), 16);
|
||||
if (!isNaN(n) && n > 0 && n <= 0xFF) setCatCfg((s) => ({ ...s, xiegu_addr: n }));
|
||||
}} />
|
||||
<span className="text-xs text-muted-foreground">{t('cat.xieguAddrHint')}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'yaesu' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -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 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.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||
// CAT panel body
|
||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: 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.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.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: 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.optXiegu': 'Xiegu (native CI-V)', '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.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.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)',
|
||||
@@ -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.",
|
||||
'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.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.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : 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.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.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : 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.optXiegu': 'Xiegu (CI-V natif)', '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.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.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)',
|
||||
|
||||
@@ -1835,6 +1835,9 @@ export namespace main {
|
||||
flex_spots: boolean;
|
||||
flex_decode_spots: boolean;
|
||||
flex_decode_secs: number;
|
||||
xiegu_port: string;
|
||||
xiegu_baud: number;
|
||||
xiegu_addr: number;
|
||||
yaesu_port: string;
|
||||
yaesu_baud: number;
|
||||
icom_port: string;
|
||||
@@ -1868,6 +1871,9 @@ export namespace main {
|
||||
this.flex_spots = source["flex_spots"];
|
||||
this.flex_decode_spots = source["flex_decode_spots"];
|
||||
this.flex_decode_secs = source["flex_decode_secs"];
|
||||
this.xiegu_port = source["xiegu_port"];
|
||||
this.xiegu_baud = source["xiegu_baud"];
|
||||
this.xiegu_addr = source["xiegu_addr"];
|
||||
this.yaesu_port = source["yaesu_port"];
|
||||
this.yaesu_baud = source["yaesu_baud"];
|
||||
this.icom_port = source["icom_port"];
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package cat
|
||||
|
||||
// Native Xiegu CAT (G90, X6100, X6200, X5105 and relatives) over the rig's
|
||||
// serial/USB port.
|
||||
//
|
||||
// Xiegu speaks CI-V — Icom's bus protocol — with a REDUCED command set. Frames,
|
||||
// BCD encoding, addressing and the opcodes for frequency (0x03/0x05), mode
|
||||
// (0x04/0x06), PTT (0x1C 0x00), split (0x0F) and the meters (0x15) are the same
|
||||
// as Icom's, which is why this backend reuses internal/cat/civ wholesale rather
|
||||
// than re-deriving it.
|
||||
//
|
||||
// It is a SEPARATE backend rather than the Icom one with another address,
|
||||
// because what the two rigs DON'T share is the important part. The Icom backend
|
||||
// reads the spectrum scope, the DSP block, data-mode via 0x1A 0x06, the model id
|
||||
// via 0x19 — none of which a Xiegu implements. Pointed at a G90 it would poll
|
||||
// for answers that never come on every cycle, and its silence tolerance would
|
||||
// spend itself on commands the radio was never going to support.
|
||||
//
|
||||
// Mode differences that matter: the Xiegu table lists LSB, USB, AM, CW and CWR
|
||||
// only — no FM, no RTTY, and no data mode. A digital QSO therefore runs in USB
|
||||
// (which is what the operator does on the radio anyway), and the mode is
|
||||
// reported as the operator's configured digital mode when they select one, not
|
||||
// invented from the rig.
|
||||
//
|
||||
// Verified on: nothing yet — written from the Xiegu CI-V command table. The
|
||||
// command table published by Xiegu has rows that clearly slipped during
|
||||
// typesetting (0x07 and 0x0F share a block), so where it contradicts itself the
|
||||
// Icom meaning is used, since the rest of the table matches Icom exactly. Every
|
||||
// unexpected reply is logged raw so a first on-air run settles it.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// XieguDefaultAddr is the factory CI-V address of the G90/X6100 family.
|
||||
const XieguDefaultAddr = 0x70
|
||||
|
||||
type Xiegu struct {
|
||||
portName string
|
||||
baud int
|
||||
rigAddr byte
|
||||
digital string
|
||||
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
|
||||
curFreq int64
|
||||
// splitSupported is cleared when the rig ignores the split query, so we stop
|
||||
// asking every cycle — a Xiegu that has no split must not cost a timeout per
|
||||
// poll, which would slow the whole loop to a crawl.
|
||||
splitSupported bool
|
||||
}
|
||||
|
||||
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
|
||||
if baud <= 0 {
|
||||
baud = 19200 // G90 factory default
|
||||
}
|
||||
if addr <= 0 || addr > 0xFF {
|
||||
addr = XieguDefaultAddr
|
||||
}
|
||||
if strings.TrimSpace(digital) == "" {
|
||||
digital = "FT8"
|
||||
}
|
||||
return &Xiegu{
|
||||
portName: strings.TrimSpace(portName), baud: baud,
|
||||
rigAddr: byte(addr), digital: digital, splitSupported: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Xiegu) Name() string { return "xiegu" }
|
||||
|
||||
func (x *Xiegu) Connect() error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.portName == "" {
|
||||
return fmt.Errorf("xiegu: no serial port configured")
|
||||
}
|
||||
p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
|
||||
}
|
||||
p.SetReadTimeout(200 * time.Millisecond)
|
||||
x.port = p
|
||||
x.splitSupported = true
|
||||
|
||||
// Prove the link before declaring success: an open COM port says nothing
|
||||
// about a radio being on the other end, and a backend that reports
|
||||
// "connected" to a powered-off rig sends the operator hunting for a fault in
|
||||
// the wrong place.
|
||||
if _, err := x.readFreq(); err != nil {
|
||||
_ = p.Close()
|
||||
x.port = nil
|
||||
return fmt.Errorf("xiegu: no answer on %s @ %d baud (address 0x%02X): %w", x.portName, x.baud, x.rigAddr, err)
|
||||
}
|
||||
debugLog.Printf("xiegu: connected on %s @ %d baud, CI-V address 0x%02X", x.portName, x.baud, x.rigAddr)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Xiegu) Disconnect() {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port != nil {
|
||||
_ = x.port.Close()
|
||||
x.port = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (x *Xiegu) ReadState() (RigState, error) {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return RigState{}, fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
s := RigState{Backend: x.Name(), Connected: true, Rig: "Xiegu"}
|
||||
|
||||
hz, err := x.readFreq()
|
||||
if err != nil {
|
||||
return RigState{}, err // let the Manager reconnect
|
||||
}
|
||||
s.FreqHz = hz
|
||||
x.curFreq = hz
|
||||
|
||||
if d, err := x.ask(civ.CmdReadMode); err == nil && len(d.Data) >= 1 {
|
||||
s.Mode = civ.ModeToADIF(d.Data[0], false)
|
||||
// The rig has no data mode, so it reports USB on the digital watering
|
||||
// holes. Naming the operator's digital mode there is the frontend's job
|
||||
// (it infers from frequency); reporting USB honestly is ours.
|
||||
}
|
||||
|
||||
if x.splitSupported {
|
||||
d, err := x.ask(civ.CmdSplit)
|
||||
switch {
|
||||
case err != nil:
|
||||
debugLog.Printf("xiegu: split query got no answer (%v) — not asking again this session", err)
|
||||
x.splitSupported = false
|
||||
case len(d.Data) >= 1:
|
||||
s.Split = d.Data[0] == 0x01
|
||||
}
|
||||
}
|
||||
// Split TX frequency is deliberately NOT reported. Reading the unselected
|
||||
// VFO needs 0x25, which the Xiegu table does not list — and a split flag with
|
||||
// a wrong TX frequency is worse than a split flag alone, because it is the
|
||||
// frequency that gets logged.
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (x *Xiegu) SetFrequency(hz int64) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
if hz <= 0 {
|
||||
return fmt.Errorf("xiegu: invalid frequency %d", hz)
|
||||
}
|
||||
payload := append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)
|
||||
return x.send(payload...)
|
||||
}
|
||||
|
||||
func (x *Xiegu) SetMode(mode string) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
m, ok := xieguModeByte(mode, x.curFreq)
|
||||
if !ok {
|
||||
return fmt.Errorf("xiegu: no CAT mode for %q", mode)
|
||||
}
|
||||
return x.send(civ.CmdSetMode, m)
|
||||
}
|
||||
|
||||
func (x *Xiegu) SetPTT(on bool) error {
|
||||
x.mu.Lock()
|
||||
defer x.mu.Unlock()
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
v := byte(0x00)
|
||||
if on {
|
||||
v = 0x01
|
||||
}
|
||||
return x.send(civ.CmdPTT, 0x00, v)
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (x *Xiegu) send(payload ...byte) error {
|
||||
if x.port == nil {
|
||||
return fmt.Errorf("xiegu: not connected")
|
||||
}
|
||||
_, err := x.port.Write(civ.Frame(x.rigAddr, civ.AddrController, payload...))
|
||||
return err
|
||||
}
|
||||
|
||||
// ask sends a query and returns the rig's answer frame.
|
||||
//
|
||||
// CI-V is a shared bus: the rig echoes back what we sent before answering, so
|
||||
// our own frame has to be skipped. Matching on the SENDER (From == the rig)
|
||||
// rather than on position is what makes this robust when an echo is dropped or
|
||||
// an unsolicited frame arrives from the dial being turned.
|
||||
func (x *Xiegu) ask(payload ...byte) (civ.Decoded, error) {
|
||||
if err := x.send(payload...); err != nil {
|
||||
return civ.Decoded{}, err
|
||||
}
|
||||
buf := make([]byte, 0, 64)
|
||||
tmp := make([]byte, 64)
|
||||
deadline := time.Now().Add(600 * time.Millisecond)
|
||||
for time.Now().Before(deadline) {
|
||||
n, err := x.port.Read(tmp)
|
||||
if err != nil {
|
||||
return civ.Decoded{}, err
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
frames, consumed := civ.Scan(buf)
|
||||
buf = buf[consumed:]
|
||||
for _, f := range frames {
|
||||
if f.From != x.rigAddr {
|
||||
continue // our own echo on the bus
|
||||
}
|
||||
if f.Cmd == 0xFA {
|
||||
return civ.Decoded{}, fmt.Errorf("xiegu: rig rejected command 0x%02X", payload[0])
|
||||
}
|
||||
if f.Cmd == payload[0] {
|
||||
return f, nil
|
||||
}
|
||||
// An unsolicited update (the operator turning the dial) — useful, but
|
||||
// not the answer we asked for.
|
||||
debugLog.Printf("xiegu: unsolicited frame cmd=0x%02X data=% X", f.Cmd, f.Data)
|
||||
}
|
||||
}
|
||||
return civ.Decoded{}, fmt.Errorf("xiegu: timeout answering 0x%02X", payload[0])
|
||||
}
|
||||
|
||||
func (x *Xiegu) readFreq() (int64, error) {
|
||||
d, err := x.ask(civ.CmdReadFreq)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
hz := civ.BCDToFreq(d.Data)
|
||||
if hz <= 0 {
|
||||
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
|
||||
}
|
||||
return hz, nil
|
||||
}
|
||||
|
||||
// xieguModeByte maps an ADIF mode to the Xiegu's mode byte.
|
||||
//
|
||||
// The rig has LSB, USB, AM, CW and CWR — nothing else. A digital mode therefore
|
||||
// becomes USB (or LSB below 10 MHz), which is what the operator selects on the
|
||||
// radio; claiming a DATA mode it does not have would just be refused.
|
||||
func xieguModeByte(mode string, freqHz int64) (byte, bool) {
|
||||
lowBand := freqHz > 0 && freqHz < 10_000_000
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "":
|
||||
return 0, false
|
||||
case "LSB":
|
||||
return 0x00, true
|
||||
case "USB":
|
||||
return 0x01, true
|
||||
case "SSB":
|
||||
if lowBand {
|
||||
return 0x00, true
|
||||
}
|
||||
return 0x01, true
|
||||
case "AM":
|
||||
return 0x02, true
|
||||
case "CW":
|
||||
return 0x03, true
|
||||
case "CWR", "CW-R":
|
||||
return 0x07, true
|
||||
default:
|
||||
// Every digital sub-mode rides on plain sideband here.
|
||||
if lowBand {
|
||||
return 0x00, true
|
||||
}
|
||||
return 0x01, true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hamlog/internal/cat/civ"
|
||||
)
|
||||
|
||||
// The Xiegu mode table is SHORTER than Icom's: LSB, USB, AM, CW, CWR and
|
||||
// nothing else. The interesting cases are the ones with no equivalent — a
|
||||
// digital mode must land on plain sideband rather than be refused, because
|
||||
// refusing it would leave the rig on whatever it was and silently log the wrong
|
||||
// mode.
|
||||
func TestXieguModeByte(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
hz int64
|
||||
want byte
|
||||
ok bool
|
||||
}{
|
||||
{"SSB", 7150000, 0x00, true}, // LSB below 10 MHz
|
||||
{"SSB", 14250000, 0x01, true}, // USB above
|
||||
{"LSB", 14250000, 0x00, true}, // explicit beats the convention
|
||||
{"USB", 7150000, 0x01, true},
|
||||
{"AM", 7150000, 0x02, true},
|
||||
{"CW", 7030000, 0x03, true},
|
||||
{"CWR", 7030000, 0x07, true},
|
||||
{"FT8", 7074000, 0x00, true}, // no data mode on this rig → LSB
|
||||
{"FT8", 14074000, 0x01, true}, // → USB
|
||||
{"RTTY", 14080000, 0x01, true},
|
||||
{"FM", 145000000, 0x01, true}, // no FM either; sideband is the honest fallback
|
||||
{"", 14074000, 0x00, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := xieguModeByte(c.mode, c.hz)
|
||||
if got != c.want || ok != c.ok {
|
||||
t.Errorf("xieguModeByte(%q, %d) = 0x%02X,%v — want 0x%02X,%v", c.mode, c.hz, got, ok, c.want, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Xiegu mode bytes must decode through the shared Icom table, since that is
|
||||
// the whole reason this backend reuses internal/cat/civ instead of its own
|
||||
// codec. If the two ever disagree, the radio would be set to one mode and read
|
||||
// back as another.
|
||||
func TestXieguModesRoundTripThroughCIV(t *testing.T) {
|
||||
cases := []struct {
|
||||
mode string
|
||||
adif string
|
||||
}{
|
||||
// ADIF has no LSB/USB distinction — both sidebands ARE the SSB mode, and
|
||||
// that is what gets logged. The sideband still matters to the radio, which
|
||||
// is why xieguModeByte picks it from the frequency.
|
||||
{"LSB", "SSB"},
|
||||
{"USB", "SSB"},
|
||||
{"AM", "AM"},
|
||||
{"CW", "CW"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
b, ok := xieguModeByte(c.mode, 14200000)
|
||||
if !ok {
|
||||
t.Fatalf("xieguModeByte(%q) refused", c.mode)
|
||||
}
|
||||
if got := civ.ModeToADIF(b, false); got != c.adif {
|
||||
t.Errorf("%s → 0x%02X → %q, want %q", c.mode, b, got, c.adif)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frequency encoding is shared with Icom, and it is the one field where a byte
|
||||
// out of place mistunes the radio silently. Pinned here at the boundaries a
|
||||
// Xiegu actually covers (it is an HF rig, so the 5-byte BCD upper bytes are 0).
|
||||
func TestXieguFrequencyEncoding(t *testing.T) {
|
||||
for _, hz := range []int64{1840000, 7074000, 14074000, 28500000, 50313000} {
|
||||
b := civ.FreqToBCD(hz)
|
||||
if len(b) != 5 {
|
||||
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
|
||||
}
|
||||
if got := civ.BCDToFreq(b); got != hz {
|
||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user