diff --git a/changelog.json b/changelog.json index 3848ab5..8d58ae5 100644 --- a/changelog.json +++ b/changelog.json @@ -10,7 +10,10 @@ "CW over TCI: a SunSDR can now be keyed through its own macro keyer — pick TCI as the keyer engine (Settings → CW Keyer) and macros, auto-call and the speed control all work over the link already open, with no WinKeyer and no second serial port. NOT TESTED on the air yet.", "LoTW upload: a refusal now shows TQSL’s own explanation — which contacts were already uploaded, which fell outside the certificate’s dates — instead of the bare “no QSOs processed”, and names the two settings that cause it.", "Main tab: the docked cluster now has ONE header row — its title, live count and Filters button sit with Clear filters and Columns, as Recent QSOs beside it already did. The pane is titled DX Cluster.", - "A busy cluster no longer makes the rest of the interface sluggish: incoming spots are grouped into fewer, larger updates as the feed gets faster (up to half a second), instead of redrawing the window twenty times a second. A quiet cluster still shows each spot as it lands." + "A busy cluster no longer makes the rest of the interface sluggish: incoming spots are grouped into fewer, larger updates as the feed gets faster (up to half a second), instead of redrawing the window twenty times a second. A quiet cluster still shows each spot as it lands.", + "SunSDR console: the meters work. The S-meter, transmit power and SWR are pushed by the radio only to a client that subscribes, and OpsLog never did — it was reading commands ExpertSDR3 does not send.", + "Cluster: the “N new spots” counter no longer jumps to the whole buffer. It was looking for the row it had frozen on, and a station spotted again replaces its row — so the count fell through to “everything is new”.", + "E-mail: a refused SMTP login now says what to do about it — Microsoft 365 and outlook.com have switched off password-based SMTP, and an app password does not bring it back." ], "fr": [ "Téléchargement LoTW : les détails QSL (date du QSL, locator, état, comté) deviennent optionnels et désactivés par défaut — LoTW met environ dix fois plus longtemps à construire ce rapport, vingt minutes contre deux sur le même compte, et marquer une confirmation n'en a pas besoin. Toujours demandés automatiquement quand on ajoute les QSO absents du log.", @@ -20,7 +23,10 @@ "CW en TCI : un SunSDR peut désormais être manipulé par son propre keyer à macros — choisissez TCI comme moteur (Réglages → Manipulateur CW) et les macros, l'appel automatique et le réglage de vitesse passent par la liaison déjà ouverte, sans WinKeyer ni second port série. PAS ENCORE TESTÉ sur l'air.", "Envoi LoTW : un refus affiche désormais l'explication de TQSL — quels contacts étaient déjà envoyés, lesquels tombaient hors des dates du certificat — au lieu du seul « no QSOs processed », et nomme les deux réglages qui en sont la cause.", "Onglet Main : le cluster ancré n'a plus qu'UNE ligne d'en-tête — son titre, le compteur live et le bouton Filtres rejoignent Effacer les filtres et Colonnes, comme le faisait déjà la liste des QSO récents à côté. Le panneau s'intitule DX Cluster.", - "Un cluster chargé ne ralentit plus le reste de l'interface : les spots entrants sont regroupés en mises à jour moins nombreuses à mesure que le flux s'accélère (jusqu'à une demi-seconde), au lieu de redessiner la fenêtre vingt fois par seconde. Sur un cluster calme, chaque spot s'affiche toujours dès son arrivée." + "Un cluster chargé ne ralentit plus le reste de l'interface : les spots entrants sont regroupés en mises à jour moins nombreuses à mesure que le flux s'accélère (jusqu'à une demi-seconde), au lieu de redessiner la fenêtre vingt fois par seconde. Sur un cluster calme, chaque spot s'affiche toujours dès son arrivée.", + "Console SunSDR : les mesures fonctionnent. Le S-mètre, la puissance et le ROS ne sont envoyés qu'à un client qui s'abonne, ce qu'OpsLog ne faisait pas — il lisait des commandes qu'ExpertSDR3 n'envoie pas.", + "Cluster : le compteur « N nouveaux spots » ne saute plus à la taille du tampon. Il cherchait la ligne sur laquelle il s'était figé, or une station re-spottée remplace sa ligne — le compte basculait donc sur « tout est nouveau ».", + "E-mail : un refus d'authentification SMTP explique désormais quoi faire — Microsoft 365 et outlook.com ont désactivé le SMTP par mot de passe, et un mot de passe d'application ne le rétablit pas." ] }, { diff --git a/frontend/src/components/ClusterGrid.tsx b/frontend/src/components/ClusterGrid.tsx index f237a01..9fdeed3 100644 --- a/frontend/src/components/ClusterGrid.tsx +++ b/frontend/src/components/ClusterGrid.tsx @@ -627,16 +627,27 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect, heade const [held, setHeld] = useState(null); const shown = held ?? rows; - // How many arrived since the freeze. Counted by finding the frozen top row in - // the live list rather than by comparing lengths: the list is a ring buffer, - // so once it is full the length stops growing and a length comparison would - // report nothing new for the rest of the evening. - const spotID = (r: ClusterSpot) => `${(r as any).received_at}-${r.dx_call}-${(r as any).source_id}`; + // How many arrived since the freeze — counted by TIME, not by finding the + // frozen top row again. + // + // Looking for that row was wrong in the ordinary case: a station spotted again + // REPLACES its row (that is the de-dupe), so the row we froze on disappears + // from the live list the moment somebody re-spots it — and the count fell + // through to "everything is new", jumping from 4 to the buffer cap. Reported + // as "it shows 4, 5 new spots and then 500 all at once". + // + // A timestamp survives both the replacement and the ring buffer, which was the + // reason the length was not used either. + const spotTime = (r: ClusterSpot) => Date.parse(String((r as any).received_at ?? '')) || 0; const waiting = useMemo(() => { if (!held || held.length === 0) return 0; - const top = spotID(held[0]); - const i = rows.findIndex((r) => spotID(r) === top); - return i < 0 ? rows.length : i; // fell out of the buffer: everything is new + const since = spotTime(held[0]); + if (!since) return 0; // no usable timestamp — say nothing rather than a number + let n = 0; + for (const r of rows) { + if (spotTime(r) > since) n++; + } + return n; }, [held, rows]); const onBodyScroll = (e: { top: number }) => { diff --git a/internal/cat/tci.go b/internal/cat/tci.go index bfdadbc..4b82786 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -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 diff --git a/internal/cat/tci_panel.go b/internal/cat/tci_panel.go index 566d420..0204c73 100644 --- a/internal/cat/tci_panel.go +++ b/internal/cat/tci_panel.go @@ -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:,; (deprecated in 2.0) + // RX_CHANNEL_SENSORS:,,; (its replacement) + // TX_SENSORS:,,,,; + // + // 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)) diff --git a/internal/email/email.go b/internal/email/email.go index 27048ee..fca36ec 100644 --- a/internal/email/email.go +++ b/internal/email/email.go @@ -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 diff --git a/internal/email/email_explain_test.go b/internal/email/email_explain_test.go new file mode 100644 index 0000000..f4ca531 --- /dev/null +++ b/internal/email/email_explain_test.go @@ -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) + } +}