diff --git a/changelog.json b/changelog.json index df0b80a..7d71afd 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "An entity that is a single island group now fills the IOTA reference on its own — no callbook subscription needed.", "CAT sharing can now speak TCI instead of Hamlib, so a TCI-only program reaches whatever radio OpsLog is on.", - "Lookup cache: a TTL of 0 switches it off, so a callbook record you are correcting is re-read every time." + "Lookup cache: a TTL of 0 switches it off, so a callbook record you are correcting is re-read every time.", + "TCI: when the radio forbids transmitting, PTT now says so instead of doing nothing silently." ], "fr": [ "Une entité qui est un seul groupe d’îles remplit désormais la référence IOTA toute seule, sans abonnement callbook.", "Le partage CAT peut désormais parler TCI au lieu de Hamlib : un logiciel TCI atteint la radio, quelle qu’elle soit.", - "Cache des recherches : un TTL à 0 le désactive, pour relire à chaque fois une fiche callbook en cours de correction." + "Cache des recherches : un TTL à 0 le désactive, pour relire à chaque fois une fiche callbook en cours de correction.", + "TCI : quand la radio interdit l’émission, le PTT le dit désormais au lieu de ne rien faire en silence." ] }, { diff --git a/internal/cat/tci.go b/internal/cat/tci.go index 8bb132f..1468422 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -46,6 +46,18 @@ type TCI struct { mode string split bool tx bool + // txAllowed is what the radio last said about TRANSMIT PERMISSION. + // + // TX_ENABLE is sent by ExpertSDR when a client connects and again whenever + // the band changes, "in case transmitter permission was changed" (§4.3). When + // it is false the radio silently ignores TRX — which is exactly what an + // operator sees as "PTT does nothing", with no error anywhere to explain it. + // + // txAllowedKnown keeps an OLDER ExpertSDR, or a TCI-compatible program that + // never sends TX_ENABLE at all, from being treated as refusing: without a + // word from the radio we key and let it decide. + txAllowed bool + txAllowedKnown bool lastSig string // last logged state signature (log only on change) @@ -109,6 +121,10 @@ func (t *TCI) Connect() error { t.mu.Lock() t.conn = conn t.ready = false + // Forget the previous session's transmit permission: the radio announces it + // again on connect, and a refusal remembered from a band we have since left + // would block PTT until it did. + t.txAllowed, t.txAllowedKnown = false, false t.mu.Unlock() debugLog.Printf("TCI: connected to %s", url) go t.reader(conn) @@ -298,7 +314,22 @@ func (t *TCI) SetMode(mode string) error { } // SetPTT keys or unkeys the transmitter (VFO 0). +// +// A refusal by the radio is reported rather than swallowed. ExpertSDR announces +// transmit permission with TX_ENABLE and then simply IGNORES trx when it is +// false — out-of-band frequency, TX disabled in the program, no PA. The command +// went out, nothing happened, and nothing anywhere said why. Now the operator +// is told, and the message names the place to look. func (t *TCI) SetPTT(on bool) error { + if on { + t.mu.Lock() + known, allowed := t.txAllowedKnown, t.txAllowed + t.mu.Unlock() + if known && !allowed { + return fmt.Errorf("the radio is refusing to transmit (TCI reports TX disabled) — " + + "check the frequency is inside a transmit band and that TX is enabled in ExpertSDR") + } + } return t.send(fmt.Sprintf("trx:0,%t;", on)) } @@ -392,6 +423,14 @@ func (t *TCI) handle(msg string) { if get(0) == "0" { t.tx = get(1) == "true" } + case "tx_enable": + if get(0) == "0" { + allowed := get(1) == "true" + if !t.txAllowedKnown || t.txAllowed != allowed { + debugLog.Printf("TCI: the radio %s transmitting", map[bool]string{true: "allows", false: "REFUSES"}[allowed]) + } + t.txAllowed, t.txAllowedKnown = allowed, true + } default: lname := strings.ToLower(name) // A click on one of our panorama spots comes back as diff --git a/internal/cat/tci_ptt_test.go b/internal/cat/tci_ptt_test.go new file mode 100644 index 0000000..b338d30 --- /dev/null +++ b/internal/cat/tci_ptt_test.go @@ -0,0 +1,80 @@ +//go:build windows + +package cat + +import "testing" + +// feed pushes messages at the backend the way the radio would. +func feed(t *TCI, msgs ...string) { + for _, m := range msgs { + t.handle(m) + } +} + +// "PTT via CAT does nothing on TCI." +// +// ExpertSDR announces transmit permission with TX_ENABLE — on connect, and +// again whenever the band changes "in case transmitter permission was changed" +// (§4.3 of the protocol document). When it is false the radio simply IGNORES +// trx. OpsLog sent the documented command, the radio discarded it, and nothing +// anywhere said why: the operator pressed a dead key. +func TestPTTIsRefusedOutLoudWhenTheRadioForbidsTransmitting(t *testing.T) { + tci := NewTCI("localhost", 40001, "FT8", false) + feed(tci, "tx_enable:0,false") + + err := tci.SetPTT(true) + if err == nil { + t.Fatal("keying was accepted while the radio forbids transmitting — the operator gets no reason at all") + } + // The message has to name where to look; "PTT failed" sends nobody anywhere. + for _, want := range []string{"transmit", "ExpertSDR"} { + if !contains(err.Error(), want) { + t.Errorf("the refusal reads %q, which does not mention %q", err.Error(), want) + } + } + + // Unkeying is never blocked. Whatever the radio thinks about permission, a + // request to STOP transmitting must always reach it. + if err := tci.SetPTT(false); err != nil && contains(err.Error(), "refusing") { + t.Errorf("unkeying was refused: %v", err) + } +} + +// Permission comes back when the operator returns to a band they may use, and +// PTT has to come back with it — not stay blocked until OpsLog is restarted. +func TestPermissionGrantedAgainRestoresPTT(t *testing.T) { + tci := NewTCI("localhost", 40001, "FT8", false) + feed(tci, "tx_enable:0,false") + if err := tci.SetPTT(true); err == nil { + t.Fatal("keying was accepted while forbidden") + } + feed(tci, "tx_enable:0,true") + // No connection here, so the send fails — but it must fail as a TRANSPORT + // error, never as a refusal. + if err := tci.SetPTT(true); err != nil && contains(err.Error(), "refusing") { + t.Errorf("still refusing after permission was granted: %v", err) + } +} + +// A radio that never mentions TX_ENABLE — an older ExpertSDR, or one of the +// other programs that speak TCI — must not be treated as refusing. Silence is +// not a "no": we key, and let the radio decide. +func TestSilenceAboutPermissionIsNotARefusal(t *testing.T) { + tci := NewTCI("localhost", 40001, "FT8", false) + if err := tci.SetPTT(true); err != nil && contains(err.Error(), "refusing") { + t.Errorf("a radio that never sent TX_ENABLE was treated as forbidding transmit: %v", err) + } +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +}