feat: native Kenwood CAT backend
A sixth CAT backend, talking to a TS-590/890/990/2000 over its serial port with
no OmniRig in between — frequency, mode, VFO, split and PTT. Elecraft K3/K4 and
the "Kenwood" setting on other radios speak the same dialect.
The protocol was not researched from scratch: internal/catemu already ANSWERS
these commands, pretending to be a TS-2000 so an ACOM amplifier follows OpsLog.
The IF frame layout here is read from the same format string catemu emits, so
the two halves of the repository agree by construction — and the test caught my
own fixture being one character short, which is exactly the error that layout
invites.
Design notes worth keeping:
- IF; is the poll. One frame carries frequency, TX state, mode, VFO and split,
so simplex operation costs a single round trip; the other VFO is only asked
for when split is actually on.
- FA/FB take ELEVEN digits here where Yaesu uses nine. That is the likeliest
place to copy the Yaesu backend and be wrong by a factor of a hundred, so it
has its own test.
- Every lesson the Yaesu backend learned the hard way is built in from the
start: replies matched to the command that asked, "?;" remembered so a poll
stops paying a timeout for an unsupported command, the serial handle closed
before reopening, and a rig that answers nothing reported as absent rather
than "connected".
Untested on hardware — I have no Kenwood here. The frame parsing is covered by
tests against catemu's own format.
This commit is contained in:
@@ -116,6 +116,8 @@ const (
|
|||||||
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
keyCATXieguAddr = "cat.xiegu.addr" // Xiegu CI-V address (factory 0x70)
|
||||||
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
|
keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4)
|
||||||
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
|
keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400)
|
||||||
|
keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
||||||
|
keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200)
|
||||||
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)
|
||||||
@@ -357,6 +359,8 @@ type CATSettings struct {
|
|||||||
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
|
XieguAddr int `json:"xiegu_addr"` // Xiegu CI-V address (factory 0x70)
|
||||||
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
|
YaesuPort string `json:"yaesu_port"` // Yaesu CAT serial port (e.g. COM4)
|
||||||
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
|
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
|
||||||
|
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
|
||||||
|
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
|
||||||
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)
|
||||||
@@ -6610,7 +6614,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, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, 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, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -6628,6 +6632,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
XieguAddr: cat.XieguDefaultAddr,
|
XieguAddr: cat.XieguDefaultAddr,
|
||||||
YaesuPort: m[keyCATYaesuPort],
|
YaesuPort: m[keyCATYaesuPort],
|
||||||
YaesuBaud: 38400,
|
YaesuBaud: 38400,
|
||||||
|
KenwoodPort: m[keyCATKenwoodPort],
|
||||||
|
KenwoodBaud: 9600,
|
||||||
IcomPort: m[keyCATIcomPort],
|
IcomPort: m[keyCATIcomPort],
|
||||||
IcomBaud: 115200,
|
IcomBaud: 115200,
|
||||||
IcomAddr: 0x98, // IC-7610 default
|
IcomAddr: 0x98, // IC-7610 default
|
||||||
@@ -6665,6 +6671,9 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
|
if n, _ := strconv.Atoi(m[keyCATYaesuBaud]); n > 0 {
|
||||||
out.YaesuBaud = n
|
out.YaesuBaud = n
|
||||||
}
|
}
|
||||||
|
if n, _ := strconv.Atoi(m[keyCATKenwoodBaud]); n > 0 {
|
||||||
|
out.KenwoodBaud = n
|
||||||
|
}
|
||||||
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
|
if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 {
|
||||||
out.IcomBaud = n
|
out.IcomBaud = n
|
||||||
}
|
}
|
||||||
@@ -6718,6 +6727,9 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.YaesuBaud <= 0 {
|
if s.YaesuBaud <= 0 {
|
||||||
s.YaesuBaud = 38400
|
s.YaesuBaud = 38400
|
||||||
}
|
}
|
||||||
|
if s.KenwoodBaud <= 0 {
|
||||||
|
s.KenwoodBaud = 9600
|
||||||
|
}
|
||||||
if s.IcomBaud <= 0 {
|
if s.IcomBaud <= 0 {
|
||||||
s.IcomBaud = 115200
|
s.IcomBaud = 115200
|
||||||
}
|
}
|
||||||
@@ -6775,6 +6787,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
keyCATXieguAddr: strconv.Itoa(s.XieguAddr),
|
||||||
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
keyCATYaesuPort: strings.TrimSpace(s.YaesuPort),
|
||||||
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud),
|
||||||
|
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
|
||||||
|
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
|
||||||
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),
|
||||||
@@ -12201,6 +12215,12 @@ func (a *App) reloadCAT() {
|
|||||||
// (a rig file that hides the VFO, a Freq property meaning A on one model
|
// (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.
|
// and B on another); talking to the radio directly removes it.
|
||||||
a.cat.Start(cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault))
|
a.cat.Start(cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault))
|
||||||
|
case "kenwood":
|
||||||
|
// Native Kenwood CAT — TS-590/890/990/2000 and everything that speaks the
|
||||||
|
// same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs).
|
||||||
|
// One IF; frame carries frequency, mode, VFO and split, so the poll costs a
|
||||||
|
// single round trip where OmniRig needed a rig file to describe each one.
|
||||||
|
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, 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.
|
||||||
|
|||||||
+4
-2
@@ -18,7 +18,8 @@
|
|||||||
"The Antenna Genius and Tuner Genius settings entries carry a small 4O3A mark, so the panels driving that hardware are recognisable at a glance in the sidebar.",
|
"The Antenna Genius and Tuner Genius settings entries carry a small 4O3A mark, so the panels driving that hardware are recognisable at a glance in the sidebar.",
|
||||||
"The database shown in the status bar is the LOGBOOK file. On a local SQLite setup it named the settings database instead, so anyone checking where their QSOs live — or which file to back up — was pointed at the wrong one.",
|
"The database shown in the status bar is the LOGBOOK file. On a local SQLite setup it named the settings database instead, so anyone checking where their QSOs live — or which file to back up — was pointed at the wrong one.",
|
||||||
"Yaesu: the mode follows the VFO you are on. A spot clicked while working the SUB receiver tuned the sub VFO but set the mode on MAIN, leaving the VFO in use on its old mode; the console read main mode too.",
|
"Yaesu: the mode follows the VFO you are on. A spot clicked while working the SUB receiver tuned the sub VFO but set the mode on MAIN, leaving the VFO in use on its old mode; the console read main mode too.",
|
||||||
"Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1."
|
"Rotator control now covers any GS-232A controller, ERC (Easy Rotor Control) included: the serial speed is selectable and the entry is named after the protocol rather than one device. Set the ERC to GS-232 emulation, not Hy-Gain DCU-1.",
|
||||||
|
"Native Kenwood CAT: a sixth backend talks straight to a TS-590, TS-890, TS-990 or TS-2000 over its serial port — frequency, mode, VFO, split and PTT — with no OmniRig in between. Elecraft K3/K4 speak the same dialect. Pick \"Kenwood (native)\" in Settings → CAT."
|
||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Cliquer sur un spot du cluster en écoutant sur le VFO SUB ne ramène plus la radio sur le VFO principal. La commande utilisée pour s'accorder agit par définition sur le VFO principal ; sur SUB, c'est désormais le VFO secondaire qui est écrit, sans toucher à la sélection.",
|
"Cliquer sur un spot du cluster en écoutant sur le VFO SUB ne ramène plus la radio sur le VFO principal. La commande utilisée pour s'accorder agit par définition sur le VFO principal ; sur SUB, c'est désormais le VFO secondaire qui est écrit, sans toucher à la sélection.",
|
||||||
@@ -36,7 +37,8 @@
|
|||||||
"Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.",
|
"Les entrées de réglages Antenna Genius et Tuner Genius portent une petite marque 4O3A : les panneaux pilotant ce matériel se repèrent d'un coup d'œil dans le menu.",
|
||||||
"La base affichée dans la barre d'état est bien le fichier du CARNET. En SQLite local, elle désignait la base des réglages : qui vérifiait où résident ses QSO — ou quel fichier sauvegarder — était orienté vers le mauvais.",
|
"La base affichée dans la barre d'état est bien le fichier du CARNET. En SQLite local, elle désignait la base des réglages : qui vérifiait où résident ses QSO — ou quel fichier sauvegarder — était orienté vers le mauvais.",
|
||||||
"Yaesu : le mode suit le VFO utilisé. Un spot cliqué en travaillant sur le récepteur SUB accordait bien le VFO secondaire mais réglait le mode sur le MAIN, laissant le VFO en service sur son ancien mode ; la console lisait également le mode du principal.",
|
"Yaesu : le mode suit le VFO utilisé. Un spot cliqué en travaillant sur le récepteur SUB accordait bien le VFO secondaire mais réglait le mode sur le MAIN, laissant le VFO en service sur son ancien mode ; la console lisait également le mode du principal.",
|
||||||
"Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1."
|
"Le contrôle de rotator couvre désormais tout contrôleur GS-232A, ERC (Easy Rotor Control) compris : la vitesse du port série est sélectionnable et l'entrée porte le nom du protocole plutôt que celui d'un seul appareil. Réglez l'ERC sur l'émulation GS-232, pas Hy-Gain DCU-1.",
|
||||||
|
"CAT Kenwood natif : un sixième backend parle directement à un TS-590, TS-890, TS-990 ou TS-2000 par son port série — fréquence, mode, VFO, split et PTT — sans OmniRig. Les Elecraft K3/K4 parlent le même dialecte. À choisir sous « Kenwood (natif) » dans Réglages → CAT."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1092,7 +1092,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, xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70,
|
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, 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,
|
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', share_enabled: false, share_port: 4532,
|
digital_default: 'FT8', share_enabled: false, share_port: 4532,
|
||||||
@@ -2379,6 +2379,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<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="yaesu">{t('cat.optYaesu')}</SelectItem>
|
||||||
|
<SelectItem value="kenwood">{t('cat.optKenwood')}</SelectItem>
|
||||||
<SelectItem value="xiegu">{t('cat.optXiegu')}</SelectItem>
|
<SelectItem value="xiegu">{t('cat.optXiegu')}</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>
|
||||||
@@ -2513,6 +2514,35 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{catCfg.backend === 'kenwood' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.kenwoodPort')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={catCfg.kenwood_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_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.kenwoodPortHint')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('cat.baud')}</Label>
|
||||||
|
<Select value={String(catCfg.kenwood_baud || 9600)} onValueChange={(v) => setCatCfg((s) => ({ ...s, kenwood_baud: parseInt(v) || 9600 }))}>
|
||||||
|
<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.kenwoodBaudHint')}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{catCfg.backend === 'icom' && (
|
{catCfg.backend === 'icom' && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<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).',
|
'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',
|
'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 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.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.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.optKenwood': 'Kenwood (native)', '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.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)',
|
||||||
@@ -678,7 +678,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 à 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.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 à 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.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).",
|
'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',
|
'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.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.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.optKenwood': 'Kenwood (natif)', '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.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 l’audio RX par le réseau (expérimental)',
|
'cat.icomNetAudio': 'Diffuser l’audio RX par le réseau (expérimental)',
|
||||||
|
|||||||
@@ -1906,6 +1906,8 @@ export namespace main {
|
|||||||
xiegu_addr: number;
|
xiegu_addr: number;
|
||||||
yaesu_port: string;
|
yaesu_port: string;
|
||||||
yaesu_baud: number;
|
yaesu_baud: number;
|
||||||
|
kenwood_port: string;
|
||||||
|
kenwood_baud: number;
|
||||||
icom_port: string;
|
icom_port: string;
|
||||||
icom_baud: number;
|
icom_baud: number;
|
||||||
icom_addr: number;
|
icom_addr: number;
|
||||||
@@ -1942,6 +1944,8 @@ export namespace main {
|
|||||||
this.xiegu_addr = source["xiegu_addr"];
|
this.xiegu_addr = source["xiegu_addr"];
|
||||||
this.yaesu_port = source["yaesu_port"];
|
this.yaesu_port = source["yaesu_port"];
|
||||||
this.yaesu_baud = source["yaesu_baud"];
|
this.yaesu_baud = source["yaesu_baud"];
|
||||||
|
this.kenwood_port = source["kenwood_port"];
|
||||||
|
this.kenwood_baud = source["kenwood_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"];
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
// Native Kenwood CAT — TS-590, TS-890, TS-2000 and the many rigs that speak the
|
||||||
|
// same dialect (Elecraft K3/K4, and the "Kenwood/Elecraft" setting on Flex and
|
||||||
|
// SunSDR). Plain ASCII, every command terminated by ';', same shape as Yaesu but
|
||||||
|
// a different vocabulary.
|
||||||
|
//
|
||||||
|
// The dialect is already proven in this repository from the other side:
|
||||||
|
// internal/catemu ANSWERS these commands, pretending to be a TS-2000 so an ACOM
|
||||||
|
// amplifier follows OpsLog. The frame layouts here and there are the same ones.
|
||||||
|
//
|
||||||
|
// Commands used:
|
||||||
|
//
|
||||||
|
// FA; → FA00014025000; VFO A frequency, ELEVEN digits, Hz
|
||||||
|
// FB; → FB00014030000; VFO B frequency
|
||||||
|
// IF; → 38-char status frame: frequency, RX/TX, mode, VFO, split —
|
||||||
|
// the whole operating state in ONE round trip, which is why it
|
||||||
|
// is the poll rather than asking four separate questions.
|
||||||
|
// MD; → MD3; mode (1=LSB 2=USB 3=CW 4=FM 5=AM 6=FSK
|
||||||
|
// 7=CW-R 9=FSK-R)
|
||||||
|
// FR0;/FR1; receive VFO — 0 = A, 1 = B
|
||||||
|
// FT0;/FT1; transmit VFO (split = the two differ)
|
||||||
|
// TX;/RX; key / unkey
|
||||||
|
// ID; → ID020; model number
|
||||||
|
// AI0; silence unsolicited status reports
|
||||||
|
//
|
||||||
|
// Why not OmniRig: the same reason the Yaesu backend exists. Every Kenwood
|
||||||
|
// fault reported through OmniRig came from its interpretation layer rather than
|
||||||
|
// from the radio, and the rig file decides what a "Freq" property means.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Kenwood is the native backend. One serial port, one mutex: a command and its
|
||||||
|
// reply are never interleaved with another exchange.
|
||||||
|
type Kenwood struct {
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
digital string // mode name logged for data (FT8 by default)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
port serial.Port
|
||||||
|
|
||||||
|
model string
|
||||||
|
curFreq int64
|
||||||
|
curRXFreq int64
|
||||||
|
curVFO string // "A" or "B"
|
||||||
|
// Commands this rig answered "?;" to — asked once, then never again.
|
||||||
|
unsupported map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewKenwood(portName string, baud int, digital string) *Kenwood {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 9600 // TS-590 factory default; TS-890 ships at 115200
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(digital) == "" {
|
||||||
|
digital = "FT8"
|
||||||
|
}
|
||||||
|
return &Kenwood{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) Name() string { return "kenwood" }
|
||||||
|
|
||||||
|
func (k *Kenwood) Connect() error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.portName == "" {
|
||||||
|
return fmt.Errorf("kenwood: no serial port configured")
|
||||||
|
}
|
||||||
|
// Close any handle still held before opening another: Connect runs again on
|
||||||
|
// every reconnect, and Windows opens a serial port exclusively, so a leaked
|
||||||
|
// handle makes the next open fail with "port busy" (the fault found in the
|
||||||
|
// Yaesu backend — same shape here).
|
||||||
|
if k.port != nil {
|
||||||
|
_ = k.port.Close()
|
||||||
|
k.port = nil
|
||||||
|
}
|
||||||
|
p, err := serial.Open(k.portName, &serial.Mode{BaudRate: k.baud})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("kenwood: open %s @ %d baud: %w", k.portName, k.baud, err)
|
||||||
|
}
|
||||||
|
p.SetReadTimeout(300 * time.Millisecond)
|
||||||
|
k.port = p
|
||||||
|
k.unsupported = map[string]bool{}
|
||||||
|
|
||||||
|
// Silence unsolicited status reports: they interleave with our
|
||||||
|
// request/response pairs and make a reply impossible to attribute. We poll.
|
||||||
|
_ = k.write("AI0;")
|
||||||
|
|
||||||
|
answered := false
|
||||||
|
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
|
||||||
|
answered = true
|
||||||
|
code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";")
|
||||||
|
if name, ok := kenwoodModels[code]; ok {
|
||||||
|
k.model = name
|
||||||
|
} else {
|
||||||
|
k.model = "Kenwood (" + code + ")"
|
||||||
|
debugLog.Printf("kenwood: unknown model id %q — add it to kenwoodModels", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// IF is the command everything else depends on, so it is also the honest
|
||||||
|
// test of whether a radio is really there.
|
||||||
|
if r, err := k.ask("IF;"); err == nil && strings.HasPrefix(r, "IF") {
|
||||||
|
answered = true
|
||||||
|
}
|
||||||
|
if !answered {
|
||||||
|
k.model = ""
|
||||||
|
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud)
|
||||||
|
}
|
||||||
|
debugLog.Printf("kenwood: connected on %s @ %d baud, model=%q", k.portName, k.baud, k.model)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) Disconnect() {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port != nil {
|
||||||
|
_ = k.port.Close()
|
||||||
|
k.port = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadState polls the rig. IF carries frequency, mode, VFO, split and TX state
|
||||||
|
// in one frame; the other VFO is only asked for when split is actually on, so
|
||||||
|
// the common simplex case costs a single round trip.
|
||||||
|
func (k *Kenwood) ReadState() (RigState, error) {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return RigState{}, fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
raw, err := k.ask("IF;")
|
||||||
|
if err != nil {
|
||||||
|
return RigState{}, err
|
||||||
|
}
|
||||||
|
f, ok := parseKenwoodIF(raw)
|
||||||
|
if !ok {
|
||||||
|
return RigState{}, fmt.Errorf("kenwood: unparsable IF frame %q", raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := RigState{Connected: true, Backend: "kenwood"}
|
||||||
|
k.curVFO = f.VFO
|
||||||
|
s.Vfo = f.VFO
|
||||||
|
s.Mode = kenwoodModeToADIF(f.Mode, k.digital)
|
||||||
|
s.Rig = k.model
|
||||||
|
|
||||||
|
// IF reports the frequency of the VFO in USE (what the operator hears).
|
||||||
|
rx := f.FreqHz
|
||||||
|
tx := rx
|
||||||
|
if f.Split {
|
||||||
|
// The transmit VFO is the other one. Read it rather than assume, and fall
|
||||||
|
// back to simplex if it cannot be read: a wrong TX frequency is written
|
||||||
|
// into the log, which is worse than showing no split at all.
|
||||||
|
other := "FB;"
|
||||||
|
if f.VFO == "B" {
|
||||||
|
other = "FA;"
|
||||||
|
}
|
||||||
|
if r, err := k.ask(other); err == nil {
|
||||||
|
if hz, ok := parseKenwoodFreq(r, strings.TrimSuffix(other, ";")); ok && hz > 0 && hz != rx {
|
||||||
|
tx = hz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tx != rx {
|
||||||
|
s.FreqHz = tx // ADIF: FREQ is the TRANSMIT frequency
|
||||||
|
s.RxFreqHz = rx
|
||||||
|
s.Split = true
|
||||||
|
} else {
|
||||||
|
s.FreqHz = rx
|
||||||
|
}
|
||||||
|
k.curFreq = s.FreqHz
|
||||||
|
if s.Split {
|
||||||
|
k.curRXFreq = s.RxFreqHz
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency tunes the VFO the operator is actually on — writing FA blind is
|
||||||
|
// what makes a display disagree with the radio when they are on B.
|
||||||
|
func (k *Kenwood) SetFrequency(hz int64) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
if hz <= 0 || hz > 99_999_999_999 {
|
||||||
|
return fmt.Errorf("kenwood: frequency %d out of the 11-digit CAT range", hz)
|
||||||
|
}
|
||||||
|
cmd := "FA"
|
||||||
|
if k.curVFO == "B" {
|
||||||
|
cmd = "FB"
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) SetMode(mode string) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
d := kenwoodModeDigit(mode, k.curFreq)
|
||||||
|
if d == 0 {
|
||||||
|
return fmt.Errorf("kenwood: no CAT mode for %q", mode)
|
||||||
|
}
|
||||||
|
return k.write(fmt.Sprintf("MD%c;", d))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) SetPTT(on bool) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
return k.write("TX;")
|
||||||
|
}
|
||||||
|
return k.write("RX;")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *Kenwood) write(cmd string) error {
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
_, err := k.port.Write([]byte(cmd))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask sends a query and returns the reply belonging to THAT command. Anything
|
||||||
|
// else on the wire is discarded: a stray frame parsed as a frequency reads as
|
||||||
|
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping
|
||||||
|
// for no reason (found the hard way on the Yaesu backend).
|
||||||
|
func (k *Kenwood) ask(cmd string) (string, error) {
|
||||||
|
want := cmdPrefix(cmd)
|
||||||
|
if k.unsupported[want] {
|
||||||
|
return "", fmt.Errorf("kenwood: %s is not supported by this rig", want)
|
||||||
|
}
|
||||||
|
if err := k.write(cmd); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, 64)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
deadline := time.Now().Add(600 * time.Millisecond)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := k.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]...)
|
||||||
|
for {
|
||||||
|
i := strings.IndexByte(string(buf), ';')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
frame := string(buf[:i+1])
|
||||||
|
buf = buf[i+1:]
|
||||||
|
if frame == "?;" {
|
||||||
|
// The rig rejected the command. Remember it so the poll loop stops
|
||||||
|
// paying a 600 ms timeout for it on every cycle.
|
||||||
|
k.unsupported[want] = true
|
||||||
|
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
|
||||||
|
return "", fmt.Errorf("kenwood: %s rejected", want)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(frame, want) {
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame parsing ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// kenwoodIF is what the 38-character IF status frame carries.
|
||||||
|
type kenwoodIF struct {
|
||||||
|
FreqHz int64
|
||||||
|
Mode byte
|
||||||
|
VFO string // "A" or "B"
|
||||||
|
Split bool
|
||||||
|
// TX is parsed even though RigState has no PTT field: it is one character of
|
||||||
|
// the same frame, and having it here means a future "transmitting" indicator
|
||||||
|
// costs no extra round trip.
|
||||||
|
TX bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKenwoodIF reads the TS-2000/TS-590 status frame. Its layout — the same
|
||||||
|
// one internal/catemu emits — is:
|
||||||
|
//
|
||||||
|
// IF | freq(11) | step(4) | RIT(±5) | RIT/XIT/bank(3) | mem(2) | rx-tx(1) |
|
||||||
|
// mode(1) | VFO(1) | scan(1) | split(1) | tone(1) | tone#(2) | shift(1) | ;
|
||||||
|
//
|
||||||
|
// Fields are read by POSITION, so the length is checked first: a short frame
|
||||||
|
// means a truncated read, and indexing into it would panic or, worse, silently
|
||||||
|
// yield a wrong frequency.
|
||||||
|
func parseKenwoodIF(reply string) (kenwoodIF, bool) {
|
||||||
|
r := strings.TrimSpace(reply)
|
||||||
|
if !strings.HasPrefix(r, "IF") || len(r) < 38 {
|
||||||
|
return kenwoodIF{}, false
|
||||||
|
}
|
||||||
|
hz, err := strconv.ParseInt(strings.TrimSpace(r[2:13]), 10, 64)
|
||||||
|
if err != nil || hz <= 0 {
|
||||||
|
return kenwoodIF{}, false
|
||||||
|
}
|
||||||
|
out := kenwoodIF{FreqHz: hz, Mode: r[29], VFO: "A", TX: r[28] == '1', Split: r[32] == '1'}
|
||||||
|
if r[30] == '1' {
|
||||||
|
out.VFO = "B"
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseKenwoodFreq reads an FA/FB reply — ELEVEN digits on Kenwood, where Yaesu
|
||||||
|
// uses nine. The prefix is checked so an FB reply is never accepted as FA.
|
||||||
|
func parseKenwoodFreq(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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Modes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// kenwoodModeDigit maps an ADIF mode to the Kenwood digit. The sideband follows
|
||||||
|
// the frequency by worldwide convention — a backend that puts USB on 40 m makes
|
||||||
|
// every SSB QSO in the log wrong.
|
||||||
|
func kenwoodModeDigit(mode string, hz int64) byte {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
switch m {
|
||||||
|
case "":
|
||||||
|
return 0
|
||||||
|
case "LSB":
|
||||||
|
return '1'
|
||||||
|
case "USB":
|
||||||
|
return '2'
|
||||||
|
case "CW":
|
||||||
|
return '3'
|
||||||
|
case "CW-R", "CWR":
|
||||||
|
return '7'
|
||||||
|
case "FM":
|
||||||
|
return '4'
|
||||||
|
case "AM":
|
||||||
|
return '5'
|
||||||
|
case "RTTY", "FSK":
|
||||||
|
return '6'
|
||||||
|
case "RTTY-R", "FSK-R":
|
||||||
|
return '9'
|
||||||
|
case "SSB":
|
||||||
|
if hz > 0 && hz < 10_000_000 {
|
||||||
|
return '1'
|
||||||
|
}
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
// Any other digital mode rides on the data sideband, which on Kenwood is
|
||||||
|
// plain USB/LSB with the rig's DATA input selected.
|
||||||
|
if hz > 0 && hz < 10_000_000 {
|
||||||
|
return '1'
|
||||||
|
}
|
||||||
|
return '2'
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodModeToADIF turns the rig's mode digit into what the log records.
|
||||||
|
// Digital modes are indistinguishable from SSB over CAT — the rig only knows
|
||||||
|
// it is on USB — so the operator's configured digital mode is used, exactly as
|
||||||
|
// the other backends do.
|
||||||
|
func kenwoodModeToADIF(d byte, digital string) string {
|
||||||
|
switch d {
|
||||||
|
case '1':
|
||||||
|
return "LSB"
|
||||||
|
case '2':
|
||||||
|
return "USB"
|
||||||
|
case '3', '7':
|
||||||
|
return "CW"
|
||||||
|
case '4':
|
||||||
|
return "FM"
|
||||||
|
case '5':
|
||||||
|
return "AM"
|
||||||
|
case '6', '9':
|
||||||
|
return "RTTY"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// kenwoodModels maps the ID reply to a name for the status bar. An unknown code
|
||||||
|
// is shown as-is rather than refused: the model name is cosmetic, and a rig that
|
||||||
|
// answers everything else must not be rejected over it.
|
||||||
|
var kenwoodModels = map[string]string{
|
||||||
|
"017": "TS-570",
|
||||||
|
"019": "TS-2000",
|
||||||
|
"020": "TS-480",
|
||||||
|
"021": "TS-590S",
|
||||||
|
"023": "TS-590SG",
|
||||||
|
"024": "TS-990S",
|
||||||
|
"025": "TS-890S",
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// The IF status frame, read by POSITION.
|
||||||
|
//
|
||||||
|
// One frame carries frequency, TX state, mode, VFO and split, which is why it is
|
||||||
|
// the poll. Every field is a fixed offset, so a length check comes first: a
|
||||||
|
// truncated read would otherwise index into the wrong characters and yield a
|
||||||
|
// plausible-looking wrong frequency — the worst kind, because it reaches the log.
|
||||||
|
//
|
||||||
|
// The layout is the one internal/catemu already EMITS to satisfy an ACOM
|
||||||
|
// amplifier, so these two halves of the repository agree by construction.
|
||||||
|
func TestParseKenwoodIF(t *testing.T) {
|
||||||
|
// IF | freq(11) | step(4) | RIT(±5) | 3 | mem(2) | rx/tx | mode | VFO | scan |
|
||||||
|
// split | tone | tone#(2) | shift | ; → 38 characters
|
||||||
|
const rx20mUSB = "IF00014250000" + "0000" + "+00000" + "000" + "00" + "0" + "2" + "0" + "0" + "0" + "0" + "00" + "0" + ";"
|
||||||
|
if len(rx20mUSB) != 38 {
|
||||||
|
t.Fatalf("the test's own frame is %d chars, not 38 — fix the fixture first", len(rx20mUSB))
|
||||||
|
}
|
||||||
|
|
||||||
|
f, ok := parseKenwoodIF(rx20mUSB)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("a valid frame was rejected: %q", rx20mUSB)
|
||||||
|
}
|
||||||
|
if f.FreqHz != 14250000 {
|
||||||
|
t.Errorf("frequency = %d, want 14250000", f.FreqHz)
|
||||||
|
}
|
||||||
|
if f.Mode != '2' {
|
||||||
|
t.Errorf("mode = %q, want '2' (USB)", f.Mode)
|
||||||
|
}
|
||||||
|
if f.VFO != "A" || f.Split || f.TX {
|
||||||
|
t.Errorf("got VFO=%s split=%v tx=%v — want A, simplex, receiving", f.VFO, f.Split, f.TX)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same frame transmitting, on VFO B, split, in CW.
|
||||||
|
const txSplitB = "IF00007030000" + "0000" + "+00000" + "000" + "00" + "1" + "3" + "1" + "0" + "1" + "0" + "00" + "0" + ";"
|
||||||
|
f2, ok := parseKenwoodIF(txSplitB)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("valid frame rejected: %q", txSplitB)
|
||||||
|
}
|
||||||
|
if !f2.TX || f2.Mode != '3' || f2.VFO != "B" || !f2.Split {
|
||||||
|
t.Errorf("got tx=%v mode=%q vfo=%s split=%v — want transmitting, CW, B, split",
|
||||||
|
f2.TX, f2.Mode, f2.VFO, f2.Split)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejections: anything that would make position-reading unsafe.
|
||||||
|
for _, bad := range []string{
|
||||||
|
"",
|
||||||
|
"IF;", // query echoed with no payload
|
||||||
|
"IF0001425000;", // short — the trap this guards against
|
||||||
|
"FA00014250000;", // another command's reply
|
||||||
|
"IF0001425000x0000+00000000000203000000;", // non-numeric frequency
|
||||||
|
} {
|
||||||
|
if _, ok := parseKenwoodIF(bad); ok {
|
||||||
|
t.Errorf("accepted a frame it should have refused: %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FA/FB carry ELEVEN digits on Kenwood where Yaesu uses nine — the single most
|
||||||
|
// likely place to copy the Yaesu backend and be wrong by a factor of a hundred.
|
||||||
|
func TestParseKenwoodFreq(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
reply, prefix string
|
||||||
|
want int64
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"FA00014250000;", "FA", 14250000, true},
|
||||||
|
{"FB00007030000;", "FB", 7030000, true},
|
||||||
|
{"FA00000474000;", "FA", 474000, true}, // 630 m — leading zeros must not truncate
|
||||||
|
{"FA10368000000;", "FA", 10368000000, true}, // 3 cm
|
||||||
|
{"FA00014250000;", "FB", 0, false}, // wrong VFO is never silently accepted
|
||||||
|
{"FA;", "FA", 0, false},
|
||||||
|
{"FAxxxxxxxxxxx;", "FA", 0, false},
|
||||||
|
{"", "FA", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseKenwoodFreq(c.reply, c.prefix)
|
||||||
|
if got != c.want || ok != c.ok {
|
||||||
|
t.Errorf("parseKenwoodFreq(%q,%q) = %d,%v — want %d,%v", c.reply, c.prefix, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sideband follows the frequency by worldwide convention. A backend that
|
||||||
|
// puts USB on 40 m makes every SSB QSO in the log wrong, which is why this is
|
||||||
|
// pinned rather than left to the rig.
|
||||||
|
func TestKenwoodModeDigit(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'}, // an explicit choice wins over the convention
|
||||||
|
{"USB", 7150000, '2'},
|
||||||
|
{"CW", 7030000, '3'},
|
||||||
|
{"RTTY", 14080000, '6'},
|
||||||
|
{"AM", 7150000, '5'},
|
||||||
|
{"FM", 145000000, '4'},
|
||||||
|
{"FT8", 7074000, '1'}, // data rides on the sideband for the band
|
||||||
|
{"FT8", 14074000, '2'},
|
||||||
|
{"", 14074000, 0}, // nothing to set
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := kenwoodModeDigit(c.mode, c.hz); got != c.want {
|
||||||
|
t.Errorf("kenwoodModeDigit(%q, %d) = %q, want %q", c.mode, c.hz, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the log records for each mode digit the rig reports.
|
||||||
|
func TestKenwoodModeToADIF(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
d byte
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{'1', "LSB"}, {'2', "USB"},
|
||||||
|
{'3', "CW"}, {'7', "CW"}, // CW-R is still CW in the log
|
||||||
|
{'4', "FM"}, {'5', "AM"},
|
||||||
|
{'6', "RTTY"}, {'9', "RTTY"}, // FSK-R likewise
|
||||||
|
{'0', ""}, // unknown → say nothing rather than guess
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := kenwoodModeToADIF(c.d, "FT8"); got != c.want {
|
||||||
|
t.Errorf("kenwoodModeToADIF(%q) = %q, want %q", c.d, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user