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:
+8
-2
@@ -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.",
|
"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.",
|
"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.",
|
"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": [
|
"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.",
|
"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.",
|
"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.",
|
"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.",
|
"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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -627,16 +627,27 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick, onSpotSelect, heade
|
|||||||
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
|
const [held, setHeld] = useState<ClusterSpot[] | null>(null);
|
||||||
const shown = held ?? rows;
|
const shown = held ?? rows;
|
||||||
|
|
||||||
// How many arrived since the freeze. Counted by finding the frozen top row in
|
// How many arrived since the freeze — counted by TIME, not by finding the
|
||||||
// the live list rather than by comparing lengths: the list is a ring buffer,
|
// frozen top row again.
|
||||||
// so once it is full the length stops growing and a length comparison would
|
//
|
||||||
// report nothing new for the rest of the evening.
|
// Looking for that row was wrong in the ordinary case: a station spotted again
|
||||||
const spotID = (r: ClusterSpot) => `${(r as any).received_at}-${r.dx_call}-${(r as any).source_id}`;
|
// 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(() => {
|
const waiting = useMemo(() => {
|
||||||
if (!held || held.length === 0) return 0;
|
if (!held || held.length === 0) return 0;
|
||||||
const top = spotID(held[0]);
|
const since = spotTime(held[0]);
|
||||||
const i = rows.findIndex((r) => spotID(r) === top);
|
if (!since) return 0; // no usable timestamp — say nothing rather than a number
|
||||||
return i < 0 ? rows.length : i; // fell out of the buffer: everything is new
|
let n = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (spotTime(r) > since) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
}, [held, rows]);
|
}, [held, rows]);
|
||||||
|
|
||||||
const onBodyScroll = (e: { top: number }) => {
|
const onBodyScroll = (e: { top: number }) => {
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ func (t *TCI) Connect() error {
|
|||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
debugLog.Printf("TCI: connected to %s", url)
|
debugLog.Printf("TCI: connected to %s", url)
|
||||||
go t.reader(conn)
|
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 {
|
if t.spotsEnabled {
|
||||||
// Forget what we thought was on the panorama at the same moment the radio
|
// 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
|
// is told to drop it. Kept, the memory would suppress the next spot for
|
||||||
|
|||||||
@@ -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() {
|
if n, ok := num(get(1)); ok && forRX0() {
|
||||||
p.SMeter = n
|
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":
|
case "tune":
|
||||||
if forRX0() {
|
if forRX0() {
|
||||||
p.Tuning = yes(get(1))
|
p.Tuning = yes(get(1))
|
||||||
|
|||||||
+33
-2
@@ -5,6 +5,7 @@ package email
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/wneessen/go-mail"
|
"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)
|
return fmt.Errorf("smtp client: %w", err)
|
||||||
}
|
}
|
||||||
if err := client.DialAndSend(m); err != nil {
|
if err := client.DialAndSend(m); err != nil {
|
||||||
return fmt.Errorf("send via %s:%d (%s, %s): %w",
|
return fmt.Errorf("send via %s:%d (%s, %s): %w%s",
|
||||||
cfg.Host, cfg.Port, cfg.Encryption, describeSize(attachPaths), err)
|
cfg.Host, cfg.Port, cfg.Encryption, describeSize(attachPaths), err, explainSMTP(err))
|
||||||
}
|
}
|
||||||
return nil
|
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.
|
// describeSize reports what was attached, in bytes.
|
||||||
//
|
//
|
||||||
// "An existing connection was forcibly closed" during DATA is the same message
|
// "An existing connection was forcibly closed" during DATA is the same message
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user