diff --git a/app.go b/app.go index 4a201d3..cc1214c 100644 --- a/app.go +++ b/app.go @@ -132,6 +132,13 @@ const ( keyCATYaesuPort = "cat.yaesu.port" // Yaesu CAT serial port (e.g. COM4) keyCATYaesuBaud = "cat.yaesu.baud" // Yaesu CAT baud (FTDX10/101 default 38400) keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port" + // keyCATKenwoodLink says HOW the radio is reached: "usb" (a COM port), + // "bridge" (the same CAT bytes over TCP — ser2net, an Ethernet-serial + // adapter), or "native" (the radio's own network protocol). Kept apart from + // the address because the three are different transports, and inferring the + // choice from whether a field happened to be filled in is how an operator + // ends up with a host typed in and a radio that never answers. + keyCATKenwoodLink = "cat.kenwood.link" 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) // One key PER BACKEND, deliberately not shared. A Xiegu fix that reached @@ -457,6 +464,9 @@ type CATSettings struct { KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial // adapter). NOT the radio’s own RJ45, which speaks Kenwood’s KNS/ARCP. KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft) + // KenwoodLink: "usb" | "bridge" | "native". Empty means usb, unless a host + // was already configured by an older build — see GetCATSettings. + KenwoodLink string `json:"kenwood_link"` KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600) // What a DATA/digital mode (FT8, PSK…) sets on the rig: "usb" (default), "data" // (MD6 — Elecraft K3/K4 DATA mode) or "keep" (leave the rig's mode untouched, @@ -8044,7 +8054,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, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDVKDax, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATKenwoodLink, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATKenwoodDataMode, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPttHotkeyEnabled, keyCATPttHotkey, keyCATPttHotkeyToggle, keyCATPollMs, keyCATDelayMs, keyCATOffsetOn, keyCATOffsetHz, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort, keyCATShareProto, keyCATShareTCIPort) if err != nil { return CATSettings{}, err } @@ -8065,6 +8075,9 @@ func (a *App) GetCATSettings() (CATSettings, error) { YaesuBaud: 38400, KenwoodPort: m[keyCATKenwoodPort], KenwoodHost: m[keyCATKenwoodHost], + // An install that predates the setting is read from what it has: a host + // filled in meant the bridge, since that is what the old code preferred. + KenwoodLink: kenwoodLinkOr(m[keyCATKenwoodLink], m[keyCATKenwoodHost]), KenwoodBaud: 9600, YaesuLowLines: m[keyCATYaesuLowLines] == "1", KenwoodLowLines: m[keyCATKenwoodLowLines] == "1", @@ -8264,6 +8277,7 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATYaesuBaud: strconv.Itoa(s.YaesuBaud), keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort), keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost), + keyCATKenwoodLink: kenwoodLinkOr(s.KenwoodLink, s.KenwoodHost), keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud), keyCATYaesuLowLines: b01(s.YaesuLowLines), keyCATKenwoodLowLines: b01(s.KenwoodLowLines), @@ -15199,37 +15213,38 @@ func (a *App) reloadCAT() { yz := cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault) yz.SetLowerLines(s.YaesuLowLines) a.cat.Start(yz) - case "kenwood": + case "kenwood", "elecraft": // 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 network address wins over the COM port when both are filled: it is the - // more deliberate setting, and silently preferring the wire would leave an - // operator staring at a host they typed and a radio that never answers. - if h := strings.TrimSpace(s.KenwoodHost); h != "" { - kw := cat.NewKenwoodTCP(h, s.DigitalDefault) + // same dialect, the Elecraft K3/K4 included: the K3 emulates the Kenwood + // command set, so one transport serves both and the Elecraft flag only + // turns on its specifics (digital modes → DATA A via MD6+DT0). + elecraft := s.Backend == "elecraft" + switch kenwoodLinkOr(s.KenwoodLink, s.KenwoodHost) { + case kenwoodLinkNative: + // The radio's OWN network protocol — a session, its own framing, its + // own authentication. Nothing here speaks it: it is not the CAT byte + // stream with a socket in front, and pretending otherwise would open a + // connection that answers nothing and blame the radio for it. + applog.Printf("cat: %s over the radio's own network protocol is not implemented — use USB, or an RS-232-to-Ethernet bridge", s.Backend) + a.cat.Stop() + // Said on screen as well as in the log: a CAT panel that simply + // stays disconnected sends the operator hunting a cable. + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "cat:state", cat.RigState{ + Error: "this radio's own network protocol is not supported yet — use USB, or an RS-232-to-Ethernet bridge", + }) + } + return + case kenwoodLinkBridge: + kw := cat.NewKenwoodTCP(strings.TrimSpace(s.KenwoodHost), s.DigitalDefault) kw.SetDataMode(s.KenwoodDataMode) + kw.SetElecraft(elecraft) a.cat.Start(kw) - } else { + default: kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault) kw.SetLowerLines(s.KenwoodLowLines) kw.SetDataMode(s.KenwoodDataMode) - a.cat.Start(kw) - } - case "elecraft": - // Elecraft K3/K4: the Kenwood-dialect client with the Elecraft specifics on - // (digital modes → DATA A via MD6+DT0). Reuses the Kenwood port/baud/host - // settings — the K3 emulates the Kenwood command set, so a separate transport - // would be a near-total duplicate. - if h := strings.TrimSpace(s.KenwoodHost); h != "" { - kw := cat.NewKenwoodTCP(h, s.DigitalDefault) - kw.SetElecraft(true) - a.cat.Start(kw) - } else { - kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault) - kw.SetLowerLines(s.KenwoodLowLines) - kw.SetElecraft(true) + kw.SetElecraft(elecraft) a.cat.Start(kw) } case "icom": diff --git a/catlink.go b/catlink.go new file mode 100644 index 0000000..60b94b8 --- /dev/null +++ b/catlink.go @@ -0,0 +1,34 @@ +package main + +// How a Kenwood-dialect radio is reached. +// +// Three transports, and they are genuinely different things rather than three +// spellings of one: a COM port, the same CAT bytes carried over TCP by a serial +// bridge, and the radio's own network protocol — which is a session with its own +// framing and authentication, and is not implemented here. +// +// Kept as an explicit setting rather than inferred from which field an operator +// happened to fill in. The old code preferred a network address whenever one was +// present, which is invisible from the settings page: someone who typed a host +// months ago, then set a COM port, had a radio that never answered and nothing +// on screen to explain it. + +const ( + kenwoodLinkUSB = "usb" // a COM port + kenwoodLinkBridge = "bridge" // RS-232 to Ethernet: the same CAT bytes over TCP + kenwoodLinkNative = "native" // the radio's own network protocol — not supported +) + +// kenwoodLinkOr resolves the stored choice, falling back to what an older +// install can be read as: a configured host meant the bridge, because that is +// what the previous code used it for. +func kenwoodLinkOr(link, host string) string { + switch link { + case kenwoodLinkUSB, kenwoodLinkBridge, kenwoodLinkNative: + return link + } + if host != "" { + return kenwoodLinkBridge + } + return kenwoodLinkUSB +} diff --git a/catlink_test.go b/catlink_test.go new file mode 100644 index 0000000..ff44b4d --- /dev/null +++ b/catlink_test.go @@ -0,0 +1,33 @@ +package main + +import "testing" + +// The transport is a stored choice now, but installs exist that predate it and +// hold only a host. Reading those as "bridge" is what keeps a working station +// working across the upgrade — the old code used a host for exactly that. +func TestTheLinkIsReadFromAnOlderInstall(t *testing.T) { + cases := []struct { + link, host, want string + }{ + {"", "", kenwoodLinkUSB}, // nothing configured + {"", "192.168.1.50:4999", kenwoodLinkBridge}, // upgraded: host only + {kenwoodLinkUSB, "192.168.1.50:4999", kenwoodLinkUSB}, // chose USB, host left behind + {kenwoodLinkBridge, "", kenwoodLinkBridge}, + {kenwoodLinkNative, "", kenwoodLinkNative}, + {"nonsense", "", kenwoodLinkUSB}, // a value nobody wrote: fall back, don't guess + } + for _, c := range cases { + if got := kenwoodLinkOr(c.link, c.host); got != c.want { + t.Errorf("link=%q host=%q → %q, want %q", c.link, c.host, got, c.want) + } + } +} + +// The one that matters after the fact: an operator who picks USB while an old +// host is still stored must get the COM port. The previous code preferred the +// host whenever it was non-empty, which is invisible from the settings page. +func TestChoosingUSBBeatsALeftoverHost(t *testing.T) { + if got := kenwoodLinkOr(kenwoodLinkUSB, "10.0.0.9:4999"); got != kenwoodLinkUSB { + t.Fatalf("a leftover host overrode the operator's choice: %q", got) + } +} diff --git a/changelog.json b/changelog.json index 8d17840..98095bc 100644 --- a/changelog.json +++ b/changelog.json @@ -10,7 +10,7 @@ "PowerGenius XL: the amplifier's real state is read at startup. Its status frame carries no 'operate' field — the state is in 'state' — so on the direct GSCP link the flag was never read at all and OpsLog opened claiming STANDBY on an amp that was in line, with the first press of the button then commanding the state it was already in. IDLE means in line, not keyed.", "Antenna Genius: an option to write the SELECTED antenna into MY_ANTENNA, under the name it carries on the switch, ahead of the band default from Operating conditions. Which of the two ports counts is decided by the antenna jack the radio is transmitting on (ANT1/ANT2 on a Flex), with the jack-to-port wiring set once in Preferences — neither device can report it. When the port cannot be told, the log keeps the band default rather than naming an antenna at random.", "The awards tab beside the entry form (F3) now offers only the awards this station follows, and drops the ones switched off — the same list the Awards tab reads. It offered every award that existed, so a station chasing three of them picked references out of a list of twenty.", - "Kenwood / Elecraft CAT: USB and network are now an explicit choice, and only the fields of the chosen one are shown. Both were offered at once with nothing to say that the network address wins whenever it is filled in — and the example address named port 4532, which is Hamlib rigctld, a different protocol and the one OpsLog itself serves under Share CAT. Pointed at the wrong kind of port, the link now says so instead of blaming the baud rate." + "Kenwood / Elecraft CAT: the connection is a choice of three — USB / serial, RS-232 to Ethernet (a serial bridge carrying the same CAT), or the radio's own network protocol, which is named and refused rather than silently absent. Only the fields of the chosen one are shown. Before, the COM port and the network address sat side by side with nothing to say that the address wins whenever it is filled in, and the example address named port 4532 — Hamlib rigctld, a different protocol, and the one OpsLog itself serves under Share CAT." ], "fr": [ "Console Elecraft : les mesures d'émission sont lues dès que la RADIO se déclare en émission, et plus seulement quand OpsLog l'a mise en émission. Passer en émission par le PTT de façade, une pédale ou le bouton du micro laissait le panneau croire à une réception — et comme les barres de puissance et de ROS ne sont lues qu'en émission, elles ne l'étaient jamais pour qui manipule à la main.", @@ -20,7 +20,7 @@ "Power Genius XL : l'état réel de l'amplificateur est lu au démarrage. Sa trame d'état ne contient pas de champ « operate » — l'état est dans « state » — si bien que sur la liaison GSCP directe l'indicateur n'était jamais lu : OpsLog s'ouvrait en annonçant STANDBY sur un ampli en ligne, et le premier appui commandait l'état dans lequel il se trouvait déjà. IDLE veut dire en ligne, pas en émission.", "Antenna Genius : une option pour inscrire l'antenne SÉLECTIONNÉE dans MY_ANTENNA, sous le nom qu'elle porte sur le switch, avant l'antenne par défaut des conditions de trafic. C'est la prise d'antenne sur laquelle la radio émet (ANT1/ANT2 sur un Flex) qui décide du port retenu, le câblage prise→port se règlant une fois dans les préférences — aucun des deux appareils ne peut le dire. Quand le port ne peut pas être déterminé, le journal conserve l'antenne par défaut plutôt que d'en nommer une au hasard.", "L'onglet des diplômes à côté de la saisie (F3) ne propose plus que les diplômes suivis par la station, et écarte ceux qui sont désactivés — la même liste que l'onglet Diplômes. Il proposait tous les diplômes existants : une station qui en chasse trois choisissait ses références dans une liste de vingt.", - "CAT Kenwood / Elecraft : USB et réseau sont désormais un choix explicite, et seuls les champs du mode retenu s'affichent. Les deux étaient proposés en même temps sans rien indiquer que l'adresse réseau l'emporte dès qu'elle est renseignée — et l'exemple d'adresse citait le port 4532, celui de Hamlib rigctld, un autre protocole, et justement celui qu'OpsLog propose lui-même sous « Partager le CAT ». Pointé sur un port de mauvaise nature, le lien le dit maintenant au lieu d'accuser la vitesse série." + "CAT Kenwood / Elecraft : la connexion devient un choix entre trois — USB / série, RS-232 vers Ethernet (un pont série qui transporte le même CAT), ou le protocole réseau propre à la radio, qui est nommé et refusé plutôt qu'absent en silence. Seuls les champs du mode retenu s'affichent. Auparavant, le port COM et l'adresse réseau coexistaient sans rien indiquer que l'adresse l'emporte dès qu'elle est renseignée, et l'exemple citait le port 4532 — Hamlib rigctld, un autre protocole, et celui qu'OpsLog propose lui-même sous « Partager le CAT »." ] }, { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 05b0f90..ad7198a 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1514,7 +1514,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ 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, flex_dvk_dax: false, - yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '', + yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', kenwood_link: 'usb', kenwood_data_mode: 'usb', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '', icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, offset_on: false, offset_hz: 0, digital_default: 'FT8', share_enabled: false, share_port: 4532, share_proto: 'rigctl', share_tci_port: 40001, @@ -3187,19 +3187,20 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan the field is not empty, which is invisible from here. */}
-
- {[['usb', t('cat.kwLinkUsb')], ['net', t('cat.kwLinkNet')]].map(([v, label]) => ( - - ))} -
+ + {((catCfg as any).kenwood_link || 'usb') === 'native' && ( +

{t('cat.kwLinkNativeHint')}

+ )}
- {(catCfg.kenwood_host || '').trim() === '' && (<> + {(((catCfg as any).kenwood_link || 'usb') === 'usb') && (<>
@@ -3222,7 +3223,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)} - {(catCfg.kenwood_host || '').trim() !== '' && ( + {((catCfg as any).kenwood_link === 'bridge') && (