Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
549adc8c0e | ||
|
|
0e1e7d9f3c | ||
|
|
3dd428d748 | ||
|
|
c62d992ad6 | ||
|
|
a83acb0f9a |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 s’enchaînent pouvait rester une réponse en retard et ne jamais apparaître connectée.",
|
||||||
|
"La ligne de journal CAT indique désormais l’erreur 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 d’interfaces USB-série d’émettre. Seul Xiegu, où le problème avait été signalé, le fait encore.",
|
||||||
|
"Kenwood : un port qui s’ouvre sans rien émettre est désormais distingué d’un port qui émet sans répondre, et l’erreur cite ce qui est arrivé."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.22.7",
|
"version": "0.22.7",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+46
-1
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|||||||
+102
-28
@@ -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) {
|
|
||||||
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 {
|
for {
|
||||||
i := strings.IndexByte(string(buf), ';')
|
// Consume whatever is already buffered BEFORE reading more: the answer
|
||||||
|
// may have arrived attached to the previous one.
|
||||||
|
for {
|
||||||
|
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,10 +399,26 @@ 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)
|
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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user