fix(icom): a set whose acknowledgement is lost is sent once more

Extends to frequency and mode what PTT already had. A missing FB is not a
missing command: the rig acts on the frame as it decodes it, and what
expires is our wait for the answer, on a bus shared with the rig's own
transceive updates. JTDX in "Split: Fake It" moves the dial and the mode
immediately before every key-down, so those acks queue behind each other.

Losing one is fatal to the client rather than merely untidy: rigctld answers
RPRT -9, JTDX reads that as losing rig control and tears the connection down
mid-over. An operator's log shows three set_freq failures and one set_mode,
each followed within 300 ms by a fresh rigctld client -- and shows the PTT
resend rescuing an over that would otherwise have ended there.

Opt-in per caller rather than folded into exec: only a command that says
"be in this state" can be repeated safely, and a relative one must never
come through here.

The acknowledgement loss itself is still unexplained. Every failure in that
log is preceded by a state read reporting SSB on a rig in DATA, which points
at CI-V frame desync rather than a slow rig, and needs a trace to pin down.
This commit is contained in:
2026-08-17 16:23:54 +02:00
parent bbe1b3ce80
commit a81125eab1
2 changed files with 39 additions and 17 deletions
+14
View File
@@ -1,4 +1,18 @@
[ [
{
"version": "0.25.9",
"date": "",
"en": [
"The band/mode matrix colours can be chosen in Appearance, starting from the ones your theme already paints. Its legend is translated too.",
"TCI sharing: a refused un-key no longer leaves the rig stuck transmitting, and PTT is dropped if the client dies mid-over.",
"Icom: a frequency or mode change whose acknowledgement is lost is sent again, like PTT — losing one made JTDX drop the radio."
],
"fr": [
"Les couleurs de la matrice bandes/modes se choisissent dans Apparence, à partir de celles du thème. Sa légende est traduite aussi.",
"Partage TCI : un retour en réception refusé ne laisse plus le poste bloqué en émission, et le PTT retombe si le logiciel meurt.",
"Icom : un changement de fréquence ou de mode dont laccusé se perd est renvoyé, comme le PTT — en perdre un faisait lâcher JTDX."
]
},
{ {
"version": "0.25.8", "version": "0.25.8",
"date": "", "date": "",
+25 -17
View File
@@ -514,7 +514,8 @@ func (b *IcomSerial) SetFrequency(hz int64) error {
return fmt.Errorf("invalid frequency") return fmt.Errorf("invalid frequency")
} }
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now() b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...) return b.execIdempotent(fmt.Sprintf("set frequency %d Hz", hz),
append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
} }
func (b *IcomSerial) SetMode(mode string) error { func (b *IcomSerial) SetMode(mode string) error {
@@ -524,7 +525,7 @@ func (b *IcomSerial) SetMode(mode string) error {
} }
// Set the base mode (keeping the rig's current filter by sending only the // Set the base mode (keeping the rig's current filter by sending only the
// mode byte), then set the data-mode flag for digital modes. // mode byte), then set the data-mode flag for digital modes.
if err := b.exec(civ.CmdSetMode, code); err != nil { if err := b.execIdempotent("set mode "+mode, civ.CmdSetMode, code); err != nil {
return err return err
} }
dataByte := byte(0) dataByte := byte(0)
@@ -532,7 +533,7 @@ func (b *IcomSerial) SetMode(mode string) error {
dataByte = 1 dataByte = 1
} }
// Filter 0x01 (FIL1) is the conventional default for the data-mode set. // Filter 0x01 (FIL1) is the conventional default for the data-mode set.
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01) _ = b.execIdempotent("set data mode", civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
return nil return nil
} }
@@ -542,31 +543,38 @@ func (b *IcomSerial) SetMode(mode string) error {
// a formatted string so callers can tell the two apart. // a formatted string so callers can tell the two apart.
var errIcomAckLost = errors.New("icom: timeout waiting for response") var errIcomAckLost = errors.New("icom: timeout waiting for response")
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00), retrying ONCE when the // execIdempotent runs a SET command and sends it ONCE MORE if the
// acknowledgement is lost. // acknowledgement is lost.
// //
// A missing FB is not a missing command — the rig acts on the frame as soon as it // A missing FB is not a missing command — the rig acts on the frame as soon as it
// decodes it, and what expires is our wait for the answer on a bus shared with // decodes it, and what expires is our wait for the answer on a bus shared with
// the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the // the rig's own transceive updates. JTDX in "Split Operating: Fake It" moves the
// dial immediately before every key-down, so the PTT ack queues behind that // dial and the mode immediately before every key-down, so those acks queue behind
// traffic, and one lost ack was fatal: rigctld answered RPRT -9, JTDX read that // each other, and losing one was fatal: rigctld answers RPRT -9, JTDX reads that
// as losing rig control and tore the connection down mid-over, reopening it a // as losing rig control and tears the connection down mid-over. An operator's log
// moment later (an operator's log shows exactly that, twice, a new rigctld client // shows it happening on set_ptt, on set_freq and on set_mode alike, each failure
// within 300 ms of each failure). The same session over TCI never failed, because // followed within 300 ms by a fresh rigctld client — and shows this resend
// TCI carries no CI-V and needs no Fake It. // rescuing a PTT that would otherwise have ended the over.
// //
// Re-sending is safe: asking for a state the rig is already in changes nothing. // Only for commands that say "be in this state": re-sending one changes nothing
// if the first arrived. A relative or incremental command must not come through
// here, which is why this is opt-in per caller rather than folded into exec.
func (b *IcomSerial) execIdempotent(what string, payload ...byte) error {
err := b.exec(payload...)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
applog.Printf("icom: %s — no acknowledgement in %s, sending it once more", what, icomCmdTimeout)
return b.exec(payload...)
}
// SetPTT keys or unkeys the transmitter (CI-V 0x1C 0x00).
func (b *IcomSerial) SetPTT(on bool) error { func (b *IcomSerial) SetPTT(on bool) error {
state := byte(0) state := byte(0)
if on { if on {
state = 1 state = 1
} }
err := b.exec(civ.CmdPTT, civ.SubPTT, state) return b.execIdempotent(fmt.Sprintf("PTT %v", on), civ.CmdPTT, civ.SubPTT, state)
if err == nil || !errors.Is(err, errIcomAckLost) {
return err
}
applog.Printf("icom: PTT %v — no acknowledgement in %s, sending it once more", on, icomCmdTimeout)
return b.exec(civ.CmdPTT, civ.SubPTT, state)
} }
// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with // SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with