Compare commits

..
5 Commits
Author SHA1 Message Date
rouggy 549adc8c0e chore: release v0.22.8 2026-08-01 13:19:49 +02:00
rouggy 0e1e7d9f3c feat(cat): ID 022 is a TS-990S
Reported by one in the field. Kenwood's documentation gives 024 for that
radio; both are mapped rather than betting on which is right, and the log
no longer reads "Kenwood (022)".
2026-08-01 12:09:57 +02:00
rouggy 3dd428d748 fix(cat): stop deasserting DTR/RTS on Kenwood and Yaesu
d4bfd30 fixed a Xiegu G90 behind a DE-19 that keyed on connect, and I
applied the same line-lowering to Kenwood and Yaesu with no evidence that
either needed it. It silenced them: many USB-serial interfaces will not
transmit with RTS low — hardware flow control, or an output stage the
line enables. A TS-990 on COM3 opened cleanly and answered nothing, which
reached us as "CAT stopped working after the update".

Xiegu keeps the behaviour: that is where the fault was reported, and that
backend now has an explicit setting for which line keys the rig. The CI-V
backend keeps its own, which predates all of this.

Kenwood also distinguishes a silent port from one sending data that never
answers, and quotes what arrived — "the rig is not answering" sent an
operator checking the power switch on a radio whose frames were visibly
on the wire.
2026-08-01 12:09:11 +02:00
rouggy c62d992ad6 fix(cat): keep the Kenwood read buffer across commands; log the CAT error
A TS-480 connected, answered ID, then never reported a state:

  discarding " 000000000010000000;" while waiting for IF
  connected on COM3 @ 115200 baud
  cat:state → connected=false freq=0

A headless frame tail can only come from bytes that were thrown away.
ask() buffered into a local slice, so everything left after the matched
frame — including the head of a frame still arriving — went with it, and
the link could sit one answer behind its questions. The buffer now lives
on the backend, and Connect drains whatever the rig said before AI0 took
effect rather than inheriting it.

Also: the cat:state diagnostic prints RigState.Error. It read
"connected=false freq=0" and said nothing about why, so every report of a
CAT failure arrived without the one fact that explains it — the reason
only ever reached a tooltip.

New work moves to 0.22.8; 0.22.7 is released.
2026-08-01 12:04:32 +02:00
rouggy a83acb0f9a feat(main): draggable divider between the two panes
The Main tab was a fixed 50/50 grid, but a map and a cluster list want
very different widths and which one deserves the room changes with what
the operator is doing.

The share is clamped to 15..85: a pane can be made small but never
dragged out of existence, because recovering from that would mean
grabbing a divider no longer on screen. Double-click restores even.

The drag uses pointer capture on the divider — without it Leaflet
swallows the moves as soon as the cursor crosses the map. Persisted
through writeUiPref, so it travels with data/ like the other UI prefs.
2026-08-01 11:11:32 +02:00
10 changed files with 214 additions and 51 deletions
+12 -2
View File
@@ -971,8 +971,18 @@ func (a *App) startup(ctx context.Context) {
// burst of these lines = the frontend is being re-rendered rapidly (the // burst of these lines = the frontend is being re-rendered rapidly (the
// "screen flickers" symptom). Shows WHAT is churning — connection flap, // "screen flickers" symptom). Shows WHAT is churning — connection flap,
// or freq/split/mode oscillating between slices during FT8. // or freq/split/mode oscillating between slices during FT8.
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s", // The error goes in the line too. Without it a disconnect read
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band) // "connected=false freq=0" and said nothing about WHY — the reason was in
// RigState.Error, which only ever reached a tooltip, so every report of
// "it stopped connecting" arrived without the one fact that explains it.
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s%s",
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band,
func() string {
if s.Error == "" {
return ""
}
return " err=" + s.Error
}())
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cat:state", s) wruntime.EventsEmit(a.ctx, "cat:state", s)
} }
+18
View File
@@ -1,4 +1,22 @@
[ [
{
"version": "0.22.8",
"date": "",
"en": [
"Main tab: drag the divider between the two panes to resize them. The setting is remembered and travels with the data folder; double-click the divider to even them out.",
"Kenwood: bytes arriving glued to the previous answer are no longer discarded, and the link starts from a clean buffer — a rig whose replies ran together could end up one answer behind and never report connected.",
"The CAT log line now carries the error explaining a disconnection.",
"Kenwood and Yaesu CAT connect again: 0.22.7 lowered the DTR and RTS lines on those ports, which stops many USB-serial interfaces from transmitting. Only Xiegu, where the problem was reported, still does it.",
"Kenwood: a port that opens but sends nothing is now reported differently from one sending data that does not answer, and the error quotes what arrived."
],
"fr": [
"Onglet Principal : faites glisser le séparateur entre les deux volets pour les redimensionner. Le réglage est mémorisé et suit le dossier de données ; double-clic pour les égaliser.",
"Kenwood : les octets reçus collés à la réponse précédente ne sont plus jetés, et la liaison démarre sur un tampon vide — une radio dont les réponses senchaînent pouvait rester une réponse en retard et ne jamais apparaître connectée.",
"La ligne de journal CAT indique désormais lerreur expliquant une déconnexion.",
"Le CAT Kenwood et Yaesu se connecte de nouveau : la 0.22.7 abaissait les lignes DTR et RTS sur ces ports, ce qui empêche beaucoup dinterfaces USB-série d’émettre. Seul Xiegu, où le problème avait été signalé, le fait encore.",
"Kenwood : un port qui souvre sans rien émettre est désormais distingué dun port qui émet sans répondre, et lerreur cite ce qui est arrivé."
]
},
{ {
"version": "0.22.7", "version": "0.22.7",
"date": "", "date": "",
+46 -1
View File
@@ -871,6 +871,36 @@ export default function App() {
return ALWAYS_TABS.includes(saved) ? saved : 'recent'; return ALWAYS_TABS.includes(saved) ? saved : 'recent';
}); });
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]); useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
// Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85
// so a pane can be made small but never dragged out of existence — recovering
// from that means finding a divider that is no longer on screen.
const [mainSplit, setMainSplit] = useState<number>(() => {
const n = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50;
});
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]);
const mainSplitRef = useRef<HTMLDivElement | null>(null);
const startMainSplitDrag = (e: React.PointerEvent) => {
e.preventDefault();
const host = mainSplitRef.current;
if (!host) return;
// Pointer capture on the divider, so dragging over a map keeps working —
// Leaflet would otherwise swallow the moves the moment the cursor entered it.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
const r = host.getBoundingClientRect();
if (r.width <= 0) return;
const pct = ((ev.clientX - r.left) / r.width) * 100;
setMainSplit(Math.min(85, Math.max(15, pct)));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager. // QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
const [qslTabOpen, setQslTabOpen] = useState(false); const [qslTabOpen, setQslTabOpen] = useState(false);
const [qslDesignerOpen, setQslDesignerOpen] = useState(false); const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
@@ -6018,8 +6048,23 @@ export default function App() {
{/* Two configurable panes (per-profile, Settings Main view). {/* Two configurable panes (per-profile, Settings Main view).
Each side shows one of: great-circle map, locator map, cluster Each side shows one of: great-circle map, locator map, cluster
or worked-before. */} or worked-before. */}
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2"> {/* The divider is draggable: a map and a cluster list want very
different widths, and which one deserves the room changes with
what the operator is doing. The share is persisted (portable),
and a double-click puts it back to even. */}
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
style={{ gridTemplateColumns: `${mainSplit}fr 6px ${100 - mainSplit}fr` }}>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div> <div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
<div
role="separator"
aria-orientation="vertical"
title={t('main.splitTip')}
onPointerDown={startMainSplitDrag}
onDoubleClick={() => setMainSplit(50)}
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
>
<span className="h-10 w-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
</div>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div> <div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
</div> </div>
</TabsContent> </TabsContent>
+2 -2
View File
@@ -51,7 +51,7 @@ const en: Dict = {
'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name', 'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name',
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.', 'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
// Tabs // Tabs
'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before', 'tab.main': 'Main', 'main.splitTip': 'Drag to resize the panes — double-click to even them out', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net', 'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net',
// Entry form // Entry form
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid', 'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
@@ -464,7 +464,7 @@ const fr: Dict = {
'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer', 'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer',
'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom', 'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom',
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.", 'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté', 'tab.main': 'Principal', 'main.splitTip': 'Faites glisser pour redimensionner les volets — double-clic pour les égaliser', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET', 'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator', 'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire', 'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
+1
View File
@@ -36,6 +36,7 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter', 'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked', 'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab 'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here. // NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER // They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them // PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.22.7'; export const APP_VERSION = '0.22.8';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+103 -29
View File
@@ -29,6 +29,7 @@ package cat
// from the radio, and the rig file decides what a "Freq" property means. // from the radio, and the rig file decides what a "Freq" property means.
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@@ -71,6 +72,31 @@ type Kenwood struct {
curVFO string // "A" or "B" curVFO string // "A" or "B"
// Commands this rig answered "?;" to — asked once, then never again. // Commands this rig answered "?;" to — asked once, then never again.
unsupported map[string]bool unsupported map[string]bool
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
//
// It has to survive: a rig answers faster than we ask, so one Read often
// returns a whole reply plus the start of the next frame. When this buffer
// was local to ask, everything after the matched frame was dropped — half a
// frame included — and the link desynchronised permanently: every ask then
// found the PREVIOUS command's answer and timed out waiting for its own.
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
// a TS-480 reported, followed by connected=false for ever.
rx []byte
// heard is whatever arrived during Connect that was not a reply we wanted.
// Kept only to put it in the error message: "not answering" and "answering
// something unreadable" are different faults with different fixes.
heard string
}
// where names the link for a message, so an error does not read "COM @ 0 baud"
// after a network connect.
func (k *Kenwood) where() string {
if k.host != "" {
return k.host
}
return k.portName
} }
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of // NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
@@ -122,6 +148,14 @@ func (k *Kenwood) Connect() error {
// request/response pairs and make a reply impossible to attribute. We poll. // request/response pairs and make a reply impossible to attribute. We poll.
_ = k.write("AI0;") _ = k.write("AI0;")
// Start from silence. A reconnect inherits whatever the rig said last —
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
// read — and one stale frame is enough to leave every ask one reply behind
// its question for the rest of the session.
k.rx = nil
k.drain(250 * time.Millisecond)
k.heard = ""
answered := false answered := false
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") { if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
answered = true answered = true
@@ -140,10 +174,22 @@ func (k *Kenwood) Connect() error {
} }
if !answered { if !answered {
k.model = "" k.model = ""
if k.host != "" { if k.heard == "" && len(k.rx) > 0 {
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host) k.heard = string(k.rx) // an unterminated fragment is evidence too
} }
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) // Distinguish silence from noise. "The rig is not answering" sent an
// operator checking the power switch and the baud rate on a radio that was
// visibly talking — its frames were arriving, they just did not match what
// was asked (wrong baud garbles them; an interface echoing our own
// commands back produces the same). Say which of the two it is, and quote
// what came back, because that is the fact that decides where to look.
if seen := k.heard; seen != "" {
return fmt.Errorf("kenwood: %s is sending data but no reply to ID; or IF; — got %q. Check the baud rate (set to %d here) and that nothing else is echoing the port", k.where(), seen, k.baud)
}
if k.host != "" {
return fmt.Errorf("kenwood: %s accepted the connection but the rig sent nothing — check that the serial bridge points at the radio and that the radio is switched on", k.host)
}
return fmt.Errorf("kenwood: %s opened but the rig sent nothing — check that it is switched on and set to %d baud", k.portName, k.baud)
} }
// Name what was actually connected to. A log reading "connected on @ 0 baud" // Name what was actually connected to. A log reading "connected on @ 0 baud"
// after a network connect is the kind of line that sends someone hunting a // after a network connect is the kind of line that sends someone hunting a
@@ -300,6 +346,23 @@ func (k *Kenwood) write(cmd string) error {
return err return err
} }
// drain reads and throws away whatever the rig has already sent, until it stays
// quiet for one read timeout or the budget runs out.
func (k *Kenwood) drain(budget time.Duration) {
if k.port == nil {
return
}
tmp := make([]byte, 256)
deadline := time.Now().Add(budget)
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil || n == 0 {
return // an error here is not interesting: we are throwing this away
}
traceText("kenwood", "RX-drop", string(tmp[:n]))
}
}
// ask sends a query and returns the reply belonging to THAT command. Anything // 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 // 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 // "lost the rig" to the Manager, which then reconnects — the CAT link dropping
@@ -312,25 +375,18 @@ func (k *Kenwood) ask(cmd string) (string, error) {
if err := k.write(cmd); err != nil { if err := k.write(cmd); err != nil {
return "", err return "", err
} }
buf := make([]byte, 0, 64)
tmp := make([]byte, 64) tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond) deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) { for {
n, err := k.port.Read(tmp) // Consume whatever is already buffered BEFORE reading more: the answer
if err != nil { // may have arrived attached to the previous one.
return "", err
}
if n == 0 {
continue // read timeout — the rig may still be composing its answer
}
buf = append(buf, tmp[:n]...)
for { for {
i := strings.IndexByte(string(buf), ';') i := bytes.IndexByte(k.rx, ';')
if i < 0 { if i < 0 {
break break
} }
frame := string(buf[:i+1]) frame := string(k.rx[:i+1])
buf = buf[i+1:] k.rx = k.rx[i+1:]
traceText("kenwood", "RX", frame) traceText("kenwood", "RX", frame)
if frame == "?;" { if frame == "?;" {
// The rig rejected the command. Remember it so the poll loop stops // The rig rejected the command. Remember it so the poll loop stops
@@ -343,9 +399,25 @@ func (k *Kenwood) ask(cmd string) (string, error) {
return frame, nil return frame, nil
} }
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want) debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
// Remember the first unexpected frame: if the whole handshake fails, this
// is what tells the operator the radio was talking after all.
if k.heard == "" {
k.heard = frame
}
} }
if !time.Now().Before(deadline) {
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
}
n, err := k.port.Read(tmp)
if err != nil {
return "", err
}
if n > 0 {
k.rx = append(k.rx, tmp[:n]...)
}
// n == 0 is a read timeout, not silence for good: the rig may still be
// composing its answer.
} }
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
} }
// ── Frame parsing ────────────────────────────────────────────────────────── // ── Frame parsing ──────────────────────────────────────────────────────────
@@ -475,6 +547,10 @@ var kenwoodModels = map[string]string{
"019": "TS-2000", "019": "TS-2000",
"020": "TS-480", "020": "TS-480",
"021": "TS-590S", "021": "TS-590S",
// 022 reported by a real TS-990S in the field. Kenwood's documentation gives
// 024 for that radio, so both are kept: the observed value wins where they
// disagree, and neither maps to anything else.
"022": "TS-990S",
"023": "TS-590SG", "023": "TS-590SG",
"024": "TS-990S", "024": "TS-990S",
"025": "TS-890S", "025": "TS-890S",
@@ -496,20 +572,18 @@ func (k *Kenwood) openPort() (serial.Port, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Deassert DTR and RTS. // The modem lines are LEFT ALONE.
// //
// Windows raises both when a serial port is opened, and a great many // They were briefly deasserted here, to stop an interface that reads them as
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into // PTT from keying the rig on connect. That silenced radios instead: a TS-990
// transmit the moment OpsLog connects and STAYS there — Xiegu's own // on COM3 opened fine and answered nothing, because a great many USB-serial
// documentation asks for RTS and DTR low. The same applies to an Icom with // interfaces will not transmit with RTS low — hardware flow control, or an
// USB SEND mapped to a line (which is why the CI-V backend has done this // output stage the line enables. "Opened but the rig sent nothing" was this,
// from the start) and to any rig keyed by a home-made cable. // and it arrived as "CAT stopped working after the update".
// //
// PTT on this backend is a CAT command, so neither line should ever be // The fault that change was written for was a Xiegu G90 behind a DE-19, and
// asserted here. A station keying by RTS/DTR configures that separately, // that backend now has an explicit setting for which line keys it. A rig
// on its own port. // whose PTT is a CAT command has no business touching DTR or RTS at all.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
return p, nil return p, nil
} }
+25 -2
View File
@@ -265,8 +265,8 @@ func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("a silent port was reported as a connected rig") t.Fatal("a silent port was reported as a connected rig")
} }
if !strings.Contains(err.Error(), "not answering") { if !strings.Contains(err.Error(), "sent nothing") {
t.Errorf("error was %q — it should say the rig is not answering", err) t.Errorf("error was %q — a silent port should be reported as silence", err)
} }
if k.model != "" { if k.model != "" {
t.Errorf("a stale model survived a failed connect: %q", k.model) t.Errorf("a stale model survived a failed connect: %q", k.model)
@@ -355,3 +355,26 @@ func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz) t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
} }
} }
// A rig that talks but never answers what was asked must not be reported as a
// silent one.
//
// The two faults need opposite responses: silence means the radio is off, the
// wrong port, or a dead cable; noise means the baud rate is wrong or something
// is echoing the line. "The rig is not answering" sent an operator checking the
// power switch on a radio whose frames were visibly arriving.
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = func() (serial.Port, error) {
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
return "XX9999;" // something, but never the reply to ID; or IF;
}}, nil
}
err := k.Connect()
if err == nil {
t.Fatal("a rig answering gibberish was reported as connected")
}
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
t.Errorf("error was %q — it should say data arrived, and quote it", err)
}
}
+5 -13
View File
@@ -150,20 +150,12 @@ func (y *Yaesu) Connect() error {
if err != nil { if err != nil {
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err) return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
} }
// Deassert DTR and RTS. // The modem lines are LEFT ALONE — see the same note in kenwood.go.
// //
// Windows raises both when a serial port is opened, and a great many // Deasserting them to keep an interface from keying the rig silenced radios
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into // instead: many USB-serial interfaces will not transmit with RTS low. The
// transmit the moment OpsLog connects and STAYS there — Xiegu's own // Xiegu fault that change was written for is handled in that backend, which
// documentation asks for RTS and DTR low. The same applies to an Icom with // now has an explicit setting for which line keys the rig.
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
p.SetReadTimeout(300 * time.Millisecond) p.SetReadTimeout(300 * time.Millisecond)
y.port = p y.port = p
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.22.7" appVersion = "0.22.8"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.