fix(tci): subscribe to the radio's meters; fix the new-spot count; explain an SMTP refusal

TCI meters: the S-meter came only from RX_SMETER and the transmit meters from
TX_POWER / TX_SWR — commands ExpertSDR3 does not send. The protocol's answer is
a subscription (RX_SENSORS_ENABLE / TX_SENSORS_ENABLE, §4.4 of the TCI PDF),
after which the radio pushes RX_CHANNEL_SENSORS and TX_SENSORS. Nobody had
asked, so the console's meters sat empty in RX and in TX while everything else
worked.

Cluster: the held-spot counter looked for the row it froze on. A station spotted
again REPLACES its row, so that row vanishes in the ordinary course of things
and the count fell through to 'everything is new' — 4, 5, then 500. Counted by
timestamp now, which survives both the replacement and the ring buffer.

SMTP: '535 5.7.139 basic authentication is disabled' is a policy, not a typo.
The message now says so, and says what actually helps, without claiming a policy
when the server simply rejected the password.
This commit is contained in:
2026-08-28 15:29:44 +02:00
parent 3e794f57e0
commit fd7ae77a61
6 changed files with 132 additions and 12 deletions
+7
View File
@@ -155,6 +155,13 @@ func (t *TCI) Connect() error {
t.mu.Unlock()
debugLog.Printf("TCI: connected to %s", url)
go t.reader(conn)
// Ask for the meters. Nothing measures anything until this goes out: the
// S-meter, the transmit power and the SWR are all pushed by the radio, and
// only to a client that has subscribed. 200 ms is the rate the protocol's own
// examples use — fast enough for a needle, slow enough not to flood a socket
// that also carries audio.
_ = t.send("rx_sensors_enable:true,200;")
_ = t.send("tx_sensors_enable:true,200;")
if t.spotsEnabled {
// Forget what we thought was on the panorama at the same moment the radio
// is told to drop it. Kept, the memory would suppress the next spot for
+36
View File
@@ -233,6 +233,42 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
if n, ok := num(get(1)); ok && forRX0() {
p.SMeter = n
}
// The meters of ExpertSDR3. The S-meter used to be read only from RX_SMETER
// and the transmit ones from TX_POWER / TX_SWR — commands this radio simply
// never sends, which is why the console's meters sat empty on a SunSDR in
// both RX and TX while everything else worked.
//
// The protocol's own answer (TCI Protocol.pdf, §4.4) is a SUBSCRIPTION:
//
// RX_SENSORS:<rx>,<dBm>; (deprecated in 2.0)
// RX_CHANNEL_SENSORS:<rx>,<channel>,<dBm>; (its replacement)
// TX_SENSORS:<trx>,<mic dBm>,<power W>,<peak W>,<SWR>;
//
// none of which arrives until the client asks with RX_SENSORS_ENABLE and
// TX_SENSORS_ENABLE — see Connect.
case "rx_sensors":
if v, err := strconv.ParseFloat(strings.TrimSpace(get(1)), 64); err == nil && forRX0() {
p.SMeter = int(v)
}
case "rx_channel_sensors":
// Main channel (A) of receiver 0: the one the console is showing.
if v, err := strconv.ParseFloat(strings.TrimSpace(get(2)), 64); err == nil &&
get(0) == "0" && get(1) == "0" {
p.SMeter = int(v)
}
case "tx_sensors":
if get(0) != "0" {
break
}
// arg3 is RMS power, arg4 the peak. The peak is what a power meter's
// needle does on speech; the RMS is what the operator is asked to keep
// under the amplifier's limit — so RMS is the number, as elsewhere.
if v, err := strconv.ParseFloat(strings.TrimSpace(get(2)), 64); err == nil {
p.TXPowerW = v
}
if v, err := strconv.ParseFloat(strings.TrimSpace(get(4)), 64); err == nil {
p.TXSWR = v
}
case "tune":
if forRX0() {
p.Tuning = yes(get(1))
+33 -2
View File
@@ -5,6 +5,7 @@ package email
import (
"fmt"
"os"
"strings"
"time"
"github.com/wneessen/go-mail"
@@ -86,12 +87,42 @@ func SendFiles(cfg Config, to, subject, body string, attachPaths []string) error
return fmt.Errorf("smtp client: %w", err)
}
if err := client.DialAndSend(m); err != nil {
return fmt.Errorf("send via %s:%d (%s, %s): %w",
cfg.Host, cfg.Port, cfg.Encryption, describeSize(attachPaths), err)
return fmt.Errorf("send via %s:%d (%s, %s): %w%s",
cfg.Host, cfg.Port, cfg.Encryption, describeSize(attachPaths), err, explainSMTP(err))
}
return nil
}
// explainSMTP turns a server's refusal into the thing to go and do.
//
// A rejection is quoted verbatim above it — the server's own words are the
// evidence — but several of them name a policy rather than a mistake, and no
// amount of re-checking the password will fix those. Microsoft's is the one
// operators keep hitting: basic authentication for SMTP is switched off across
// Microsoft 365 and outlook.com, and an app password does not bring it back.
func explainSMTP(err error) string {
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "basic authentication is disabled"),
strings.Contains(msg, "5.7.139"):
return "\n\nMicrosoft has switched off password-based SMTP for this account. " +
"An app password does not restore it — the server refuses the password itself, not the one you typed. " +
"On a Microsoft 365 tenant an administrator can re-enable it for this mailbox " +
"(Set-CASMailbox -SmtpClientAuthenticationDisabled $false, plus the tenant-wide setting); " +
"otherwise use another provider for alerts (a Gmail account with an app password works, so does any ordinary IMAP/SMTP host)."
case strings.Contains(msg, "application-specific password"),
strings.Contains(msg, "5.7.9"):
return "\n\nThis account needs an APP PASSWORD rather than the one you sign in with " +
"(Google, Yahoo and others require it once two-factor authentication is on)."
case strings.Contains(msg, "5.7.8"), strings.Contains(msg, "authentication failed"),
strings.Contains(msg, "535"):
return "\n\nThe server rejected the username or the password."
case strings.Contains(msg, "must issue a starttls"):
return "\n\nThe server requires encryption: set STARTTLS (usually port 587) or SSL (465)."
}
return ""
}
// describeSize reports what was attached, in bytes.
//
// "An existing connection was forcibly closed" during DATA is the same message
+29
View File
@@ -0,0 +1,29 @@
package email
import (
"errors"
"strings"
"testing"
)
func TestExplainSMTP(t *testing.T) {
// The real refusal, from an operator's Outlook account.
outlook := errors.New("SMTP AUTH failed: 535 5.7.139 Authentication unsuccessful, basic authentication is disabled.")
if got := explainSMTP(outlook); !strings.Contains(got, "Microsoft has switched off") {
t.Errorf("the Microsoft policy refusal is not explained: %q", got)
}
// A plain wrong password must NOT claim a policy: the advice would send the
// operator to an administrator over a typo.
wrong := errors.New("535 5.7.8 authentication failed")
got := explainSMTP(wrong)
if strings.Contains(got, "Microsoft") {
t.Errorf("a wrong password was explained as a Microsoft policy: %q", got)
}
if !strings.Contains(got, "rejected the username") {
t.Errorf("a wrong password is not explained: %q", got)
}
// Anything else is left to speak for itself.
if got := explainSMTP(errors.New("dial tcp: i/o timeout")); got != "" {
t.Errorf("an unrelated error got an explanation: %q", got)
}
}