fix: PGXL meters/auth/fan, TCI spots+click, PTT-over-CAT, CW <LOGQSO>
- PGXL: remote AUTH password; direct-link meter fallback (VITA-first) with 250 ms poll + peak-hold; fan mode uses bare "fanmode=" (was ignored). - TCI: spot colour sent as decimal ARGB (ExpertSDR dropped the hex string); spot click handled via CLICKED_ON_SPOT / RX_CLICKED_ON_SPOT to fill the entry. - FlexRadio: clicking an OpsLog spot on the panadapter now applies the mode too. - Filter presets: the trash icon deletes again (Radix pointer-down intercept). - PTT hotkey: keys over CAT when a backend is active (was hijacked by a stale Audio-tab RTS/DTR serial setting); AltGr keys allowed; presses logged. - CW macro <LOGQSO>: logs after the preceding CW is sent, not on the first letter.
This commit is contained in:
@@ -8984,15 +8984,21 @@ func (a *App) pttUnkey() {
|
||||
// configured Audio → PTT method (CAT / RTS / DTR); when that is VOX/none it
|
||||
// falls back to CAT keying so the hotkey still works with a plain CAT rig.
|
||||
func (a *App) PTTHotkeyDown() error {
|
||||
// This hotkey lives under CAT and is meant to key the rig over CAT — so prefer
|
||||
// CAT whenever a backend is active. Only a rig with no CAT link falls back to
|
||||
// the Audio-tab PTT method (RTS/DTR serial). Previously it always used the
|
||||
// Audio method, so a stale serial PTT setting (e.g. an RTS line on a COM port
|
||||
// that is no longer present) hijacked the hotkey and it failed with a serial
|
||||
// error — even though the operator only ever wanted CAT.
|
||||
if a.cat != nil {
|
||||
return a.pttKey(AudioSettings{PTTMethod: "cat"})
|
||||
}
|
||||
cfg, err := a.GetAudioSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.PTTMethod == "" || cfg.PTTMethod == "none" {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("no PTT method configured and CAT not active")
|
||||
}
|
||||
cfg.PTTMethod = "cat"
|
||||
return fmt.Errorf("no PTT method configured and CAT not active")
|
||||
}
|
||||
return a.pttKey(cfg)
|
||||
}
|
||||
@@ -14783,6 +14789,7 @@ type AmpConfig struct {
|
||||
Transport string `json:"transport"` // "tcp" | "serial"
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Password string `json:"password"` // PowerGenius XL remote-access code (blank on LAN); sent as AUTH like the Tuner/Antenna Genius
|
||||
ComPort string `json:"com_port"`
|
||||
Baud int `json:"baud"`
|
||||
|
||||
@@ -14931,7 +14938,7 @@ func (a *App) startAmps() {
|
||||
inst := &Inst{cfg: c}
|
||||
switch {
|
||||
case isPGXL:
|
||||
inst.pgxl = powergenius.New(c.Host, c.Port)
|
||||
inst.pgxl = powergenius.New(c.Host, c.Port, c.Password)
|
||||
_ = inst.pgxl.Start()
|
||||
if a.pgxl == nil {
|
||||
a.pgxl = inst.pgxl
|
||||
|
||||
+22
-2
@@ -5,12 +5,32 @@
|
||||
"en": [
|
||||
"Multiple rotors: Settings → Rotator is now a list, like the amplifiers — add rotors with “Add rotor”, remove with the trash icon. Mix any types (e.g. an ARCO and a Rotator Genius); a Rotator Genius set to “drives two rotors” adds a second one. The compass shows a selector to pick the active rotor, which it turns, displays and points to the DX. Each rotor can be named.",
|
||||
"Rotors: mark which one carries the motorized antenna (Ultrabeam/SteppIR). The boom and reverse/bidirectional pattern paths then show only for that rotor; a plain Yagi on another rotor shows just its heading.",
|
||||
"CAT: a keyboard PTT hotkey. Pick a key in Settings → CAT and it keys the transmitter while OpsLog is focused — hold-to-talk, or a toggle. It uses the Audio PTT method (CAT/RTS/DTR), falling back to CAT keying."
|
||||
"CAT: a keyboard PTT hotkey. Pick a key in Settings → CAT and it keys the transmitter over CAT while OpsLog is focused — hold-to-talk, or a toggle. A rig with no CAT link falls back to the Audio-tab PTT method (RTS/DTR).",
|
||||
"TCI spots: they now actually appear on the ExpertSDR/SunSDR panorama. The colour was sent as a hex string, which ExpertSDR silently rejected; it is now the decimal integer the TCI protocol expects, and the spot mode is a valid TCI modulation.",
|
||||
"PowerGenius XL meters: forward power and drain current now come from the amplifier's own link (like temperature already did) instead of the FlexRadio meter stream. The amp's telemetry survives a remote/VPN link where the radio's VITA meter stream does not, so the meters keep working when operating over the internet. The direct poll is faster too, for a smoother reading.",
|
||||
"PowerGenius XL remote code: a password field (Settings → Amplifier) for when the amp is reached from outside the LAN. Without it the amp announces \"AUTH\" and rejects every command as \"Unauthorized\" — same as the Tuner Genius and Antenna Genius.",
|
||||
"FlexRadio spot click: clicking one of OpsLog's spots on the SmartSDR panadapter now switches the mode too, not just the frequency — OpsLog applies the spot's mode itself, since SmartSDR only retunes.",
|
||||
"PowerGenius XL meters in the FlexRadio panel: the amplifier card now shows forward power, drain current and temperature from the amp's own link, so they appear even over a remote/VPN link where the radio's meter stream doesn't.",
|
||||
"PowerGenius XL fan mode: switching the fan to Standard / Contest / Broadcast now actually takes — the set command carried a stray prefix the amp ignored, so it reverted to Contest a few seconds later.",
|
||||
"PowerGenius XL card meters: the amp is polled every 250 ms with a longer peak-hold, so forward power tracks SSB more closely and stops collapsing to ~1 W between syllables. Note that over a plain public-IP link the reading still can't match SmartSDR, which relays the radio's meter stream over SmartLink; a VPN (WireGuard) restores the fast native stream.",
|
||||
"TCI spot click: clicking one of OpsLog's spots on the ExpertSDR/SunSDR panorama now fills the entry (callsign + frequency). ExpertSDR reports the click as CLICKED_ON_SPOT / RX_CLICKED_ON_SPOT, which OpsLog was ignoring because it expected a differently-named message.",
|
||||
"PTT hotkey fixes: it now keys over CAT (it was using the Audio-tab PTT method, so a stale RTS/DTR serial setting on a COM port that no longer exists hijacked it and failed with a serial error). A key that needs AltGr on your layout (e.g. \"\\\" on AZERTY) also works now — Ctrl+Alt was being rejected. And each press is logged to the diagnostic log (key, key/unkey, and any failure) instead of failing silently.",
|
||||
"CW macro <LOGQSO>: a macro ending in the token (e.g. \"BK UR <STX> TU <LOGQSO>\") now logs the QSO once the CW before it has been sent, not the instant the macro starts — it was logging on the first keyed letter."
|
||||
],
|
||||
"fr": [
|
||||
"Plusieurs rotors : Réglages → Rotator est désormais une liste, comme les amplis — ajoutez des rotors avec « Ajouter un rotor », supprimez avec la corbeille. Mélangez les types (ex. un ARCO et un Rotator Genius) ; un Rotator Genius réglé sur « pilote deux rotors » en ajoute un second. Le compas affiche un sélecteur pour choisir le rotor actif, qu'il tourne, affiche et pointe vers le DX. Chaque rotor peut être nommé.",
|
||||
"Rotors : indiquez lequel porte l'antenne motorisée (Ultrabeam/SteppIR). Les tracés de boom et de diagramme inverse/bidirectionnel n'apparaissent alors que pour ce rotor ; une Yagi simple sur un autre rotor n'affiche que son cap.",
|
||||
"CAT : une touche PTT au clavier. Choisissez une touche dans Réglages → CAT et elle passe la radio en émission quand OpsLog a le focus — maintien ou bascule. Elle utilise la méthode PTT audio (CAT/RTS/DTR), avec repli sur le CAT."
|
||||
"CAT : une touche PTT au clavier. Choisissez une touche dans Réglages → CAT et elle passe la radio en émission via CAT quand OpsLog a le focus — maintien ou bascule. Une radio sans liaison CAT retombe sur la méthode PTT de l'onglet Audio (RTS/DTR).",
|
||||
"Spots TCI : ils apparaissent enfin sur le panorama ExpertSDR/SunSDR. La couleur était envoyée en chaîne hexadécimale, silencieusement rejetée par ExpertSDR ; c'est désormais l'entier décimal attendu par le protocole TCI, et le mode du spot est une modulation TCI valide.",
|
||||
"Meters PowerGenius XL : la puissance directe et le courant de drain viennent désormais de la liaison propre de l'ampli (comme la température déjà) au lieu du flux de meters du FlexRadio. La télémétrie de l'ampli survit à une liaison distante/VPN là où le flux VITA du Flex non — les meters continuent donc de fonctionner en trafic à distance. Le poll direct est aussi plus rapide, pour une lecture plus fluide.",
|
||||
"Code distant PowerGenius XL : un champ mot de passe (Réglages → Amplificateur) pour joindre l'ampli hors du réseau local. Sans lui, l'ampli annonce « AUTH » et refuse toute commande (« Unauthorized ») — comme le Tuner Genius et l'Antenna Genius.",
|
||||
"Clic sur un spot FlexRadio : cliquer un spot d'OpsLog sur le panadapter SmartSDR change désormais aussi le mode, plus seulement la fréquence — OpsLog applique lui-même le mode du spot, SmartSDR ne faisant que se réaccorder.",
|
||||
"Meters PowerGenius XL dans le panneau FlexRadio : la carte ampli affiche maintenant puissance directe, courant de drain et température depuis la liaison propre de l'ampli — ils apparaissent donc même sur une liaison distante/VPN où le flux de meters de la radio ne passe pas.",
|
||||
"Mode ventilateur PowerGenius XL : passer le ventilateur en Standard / Contest / Broadcast est désormais réellement pris en compte — la commande portait un préfixe parasite que l'ampli ignorait, d'où le retour sur Contest au bout de quelques secondes.",
|
||||
"Meters de la carte PowerGenius XL : l'ampli est interrogé toutes les 250 ms avec un maintien de crête plus long, la puissance directe suit donc mieux la SSB et ne retombe plus à ~1 W entre les syllabes. À noter : sur une simple liaison IP publique la lecture ne peut pas égaler SmartSDR, qui relaie le flux de meters de la radio via SmartLink ; un VPN (WireGuard) rétablit le flux natif rapide.",
|
||||
"Clic sur un spot TCI : cliquer un spot d'OpsLog sur le panorama ExpertSDR/SunSDR remplit désormais la saisie (indicatif + fréquence). ExpertSDR signale le clic par CLICKED_ON_SPOT / RX_CLICKED_ON_SPOT, qu'OpsLog ignorait car il attendait un message nommé autrement.",
|
||||
"Corrections touche PTT : elle passe désormais par le CAT (elle utilisait la méthode PTT de l'onglet Audio, donc un réglage série RTS/DTR résiduel sur un port COM disparu la détournait et échouait avec une erreur série). Une touche nécessitant AltGr (ex. « \\ » en AZERTY) fonctionne aussi maintenant — Ctrl+Alt était rejeté. Et chaque appui est tracé dans le journal de diagnostic (touche, émission/repos, et toute erreur) au lieu d'échouer en silence.",
|
||||
"Macro CW <LOGQSO> : une macro se terminant par le token (ex. « BK UR <STX> TU <LOGQSO> ») journalise le QSO une fois le CW qui précède envoyé, et non à l'instant où la macro démarre — elle journalisait dès la première lettre manipulée."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+36
-30
@@ -1945,15 +1945,23 @@ export default function App() {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
const cfg = pttHotkeyRef.current;
|
||||
if (!cfg.enabled || !cfg.code || e.code !== cfg.code) return;
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return; // plain key only
|
||||
// Allow AltGr (Ctrl+Alt together) — French/other layouts need it to reach
|
||||
// keys like "\" — but never hijack a real Ctrl- or Win-shortcut.
|
||||
if (e.metaKey || (e.ctrlKey && !e.altKey)) return;
|
||||
e.preventDefault();
|
||||
if (e.repeat) return;
|
||||
// Surface the result: this feature was impossible to diagnose because every
|
||||
// error was swallowed. The line names the key, the action and any failure.
|
||||
const fail = (err: any) => { UILog(`ptt hotkey: ${e.code} FAILED: ${err}`).catch(() => {}); };
|
||||
if (cfg.toggle) {
|
||||
pttToggledRef.current = !pttToggledRef.current;
|
||||
(pttToggledRef.current ? PTTHotkeyDown() : PTTHotkeyUp())?.catch?.(() => {});
|
||||
const on = pttToggledRef.current;
|
||||
UILog(`ptt hotkey: ${e.code} toggle → ${on ? 'KEY' : 'UNKEY'}`).catch(() => {});
|
||||
(on ? PTTHotkeyDown() : PTTHotkeyUp())?.catch?.(fail);
|
||||
} else if (!pttHeldRef.current) {
|
||||
pttHeldRef.current = true;
|
||||
PTTHotkeyDown().catch(() => {});
|
||||
UILog(`ptt hotkey: ${e.code} down → KEY`).catch(() => {});
|
||||
PTTHotkeyDown().catch(fail);
|
||||
}
|
||||
};
|
||||
const up = (e: KeyboardEvent) => {
|
||||
@@ -2837,36 +2845,34 @@ export default function App() {
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : cwSourceRef.current === 'yaesu' ? YaesuSendCW : null;
|
||||
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
|
||||
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
|
||||
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
|
||||
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
|
||||
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
|
||||
if (isLast) {
|
||||
if (isRig) {
|
||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||
} else {
|
||||
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
|
||||
// send, so the estimate is a floor and the busy signal only extends it.
|
||||
//
|
||||
// Waiting on busy alone made the auto-call gap start early: busy travels
|
||||
// from the keyer to this window as an event, and if it had not arrived
|
||||
// and cleared within the one-second start window the wait simply ended.
|
||||
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
|
||||
// after a 4 s call, which is what an operator hears as "clearly not 6".
|
||||
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
|
||||
const started = Date.now();
|
||||
const capMs = Math.round(estMs * 1.4) + 2500;
|
||||
const deadline = started + capMs;
|
||||
// The message's own duration, first — this is the part that must not be skipped.
|
||||
while (Date.now() - started < estMs && !aborted()) await sleep(50);
|
||||
// Then let a still-sending keyer finish (slow link, long buffer), capped.
|
||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||
}
|
||||
// WAIT for THIS segment's CW to finish before moving on — so a <LOGQSO>
|
||||
// that follows logs AFTER the text before it (e.g. "TU") has actually been
|
||||
// sent, not the instant it's buffered into the keyer. Buffering then logging
|
||||
// "at once" made a macro ending in "TU <LOGQSO>" log on the very first
|
||||
// letter. Auto-call also needs the send finished before its gap+resend.
|
||||
if (isRig) {
|
||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||
} else {
|
||||
// WinKeyer / serial keyer: the message takes AT LEAST its own length to
|
||||
// send, so the estimate is a floor and the busy signal only extends it.
|
||||
//
|
||||
// Waiting on busy alone made the auto-call gap start early: busy travels
|
||||
// from the keyer to this window as an event, and if it had not arrived
|
||||
// and cleared within the one-second start window the wait simply ended.
|
||||
// A 6 s gap then ran from the START of the CQ — about 2 s of silence
|
||||
// after a 4 s call, which is what an operator hears as "clearly not 6".
|
||||
const estMs = Math.round(estimateCwMs(resolved, wkWpm));
|
||||
const started = Date.now();
|
||||
const capMs = Math.round(estMs * 1.4) + 2500;
|
||||
const deadline = started + capMs;
|
||||
// The message's own duration, first — this is the part that must not be skipped.
|
||||
while (Date.now() - started < estMs && !aborted()) await sleep(50);
|
||||
// Then let a still-sending keyer finish (slow link, long buffer), capped.
|
||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||
}
|
||||
}
|
||||
if (aborted()) return; // aborted while this segment was sending → don't log
|
||||
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
|
||||
if (logAfter) void save(); // the preceding CW has finished → log at the token's spot
|
||||
}
|
||||
}
|
||||
// stopAutoCall cancels any running auto-call loop.
|
||||
|
||||
@@ -189,13 +189,20 @@ function AmpBlock({ amp, flex, showName, t }: {
|
||||
const txing = typeof flex?.transmitting === 'boolean'
|
||||
? flex.transmitting
|
||||
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
|
||||
// Prefer the radio's VITA meter stream when it is actually flowing (local or
|
||||
// over a VPN) — it is fast and matches SmartSDR. Fall back to the amp's OWN
|
||||
// link only when the radio isn't streaming its meters (a remote link without
|
||||
// the VITA path), so the reading survives but is naturally slower.
|
||||
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
|
||||
const fwdW = hold('fwd', txing ? liveW : 0, txing);
|
||||
const id = meter(/^ID$|current/i);
|
||||
// Live drain current, for the same reason: peak_id latches like peak_w.
|
||||
// Live drain current, same source preference. peak_id latches like peak_w, so
|
||||
// use the instantaneous 'id' and gate it on the radio's transmit flag.
|
||||
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
|
||||
const temp = meter(/temp/i);
|
||||
const tempC = pg.temperature > 0 ? pg.temperature : (temp ? temp.value : undefined);
|
||||
// VITA temp matches SmartSDR (the amp's direct link exposes a different sensor
|
||||
// that can read ~10°C higher); fall back to the direct temp when no VITA.
|
||||
const tempC = temp ? temp.value : (pg.temperature > 0 ? pg.temperature : undefined);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col gap-1.5">
|
||||
|
||||
@@ -234,7 +234,14 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
||||
<SelectItem key={n} value={n}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{n}
|
||||
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
|
||||
{/* Radix SelectItem selects on pointer-up, so an onClick here
|
||||
fires too late — the menu has already loaded the preset and
|
||||
closed. Intercept pointer-down (preventDefault stops Radix
|
||||
claiming it) and delete there instead. */}
|
||||
<Trash2
|
||||
className="size-3 text-muted-foreground hover:text-destructive"
|
||||
onPointerDown={(e) => { e.preventDefault(); e.stopPropagation(); deletePreset(n); }}
|
||||
/>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
@@ -328,7 +328,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
// longer and the meters visibly trail the end of a transmission, which reads
|
||||
// as the app lagging rather than as a peak being held.
|
||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||
const peakHold = (key: string, val: number, windowMs = 1000) => {
|
||||
const peakHold = (key: string, val: number, windowMs = 1000, live = true) => {
|
||||
// live=false throws the held value away at once (e.g. the carrier dropped),
|
||||
// so the meter falls immediately instead of coasting for the window.
|
||||
if (!live) { delete peak.current[key]; return val; }
|
||||
const now = Date.now();
|
||||
const p = peak.current[key];
|
||||
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
|
||||
@@ -362,7 +365,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
let alive = true;
|
||||
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||
tick();
|
||||
const id = window.setInterval(tick, 2000);
|
||||
const id = window.setInterval(tick, 250); // match the amp's 250 ms poll so the card catches every envelope peak
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
@@ -1114,11 +1117,32 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Amplifier meters (FWD / ID / TEMP) — moved here from the Meters card
|
||||
and shown compact so they sit with the amp controls. */}
|
||||
{(() => {
|
||||
// Prefer the radio's VITA amp meters when they are flowing (local or
|
||||
// over a VPN) — fast, and the same values SmartSDR shows (incl. the
|
||||
// right temperature sensor). Fall back to the amp's OWN link only when
|
||||
// the radio isn't streaming them (a remote link without VITA).
|
||||
const meters = st.meters || [];
|
||||
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||
if (off || amp.length === 0) return null;
|
||||
if (off || amp.length === 0) {
|
||||
const P = pg as any;
|
||||
if (!P.connected) return null;
|
||||
// Direct-link fallback: a 500 ms poll samples the SSB envelope at
|
||||
// random points, so peak-hold it (live=txing drops it the instant
|
||||
// PTT releases). The LDMOS pair can pull well past 25 A → 50 A scale.
|
||||
const txing = typeof st.transmitting === 'boolean' ? st.transmitting : /TRANSMIT/i.test(P.state || '');
|
||||
const w = txing ? Math.round(Number(P.fwd_w) || 0) : 0;
|
||||
const idA = txing ? Number(P.id) || 0 : 0;
|
||||
const temp = Number(P.temperature) || 0;
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
<MeterBar label={t('ampw.pwr')} value={peakHold('pgw', w, 2500, txing)} unit="W" lo={0} hi={2000} accent="#dc2626" />
|
||||
<MeterBar label={t('ampw.id')} value={peakHold('pgid', idA, 2500, txing)} unit="A" lo={0} hi={50} accent="#16a34a" />
|
||||
<MeterBar label={t('ampw.temp')} value={temp} unit="°C" lo={0} hi={80} accent="#ea580c" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Power comes from the radio's meter stream and nothing else. The
|
||||
// amplifier also reports a "peakfwd", and using it was a mistake
|
||||
// twice over: it is a latched maximum that is never reset, and it
|
||||
@@ -1133,14 +1157,13 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
}
|
||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||
// for this meter (~3 A), so 1.5 A pinned the bar near half. The
|
||||
// value itself is fine — only the bar's range was wrong. Give it a
|
||||
// fixed 25 A scale (the PGXL's LDMOS pair tops out around 20 A), and
|
||||
// only override an unusable reported range, not a sane one.
|
||||
// for this meter, so it pinned the bar. Give it a fixed 50 A scale
|
||||
// (the LDMOS pair can pull ~45-50 A near full output), overriding an
|
||||
// unusable reported range but keeping a sane one.
|
||||
let lo = m.lo, hi = m.hi;
|
||||
if (/amp/i.test(m.unit || '') || /^ID$|current/i.test(m.name || '')) {
|
||||
lo = 0;
|
||||
hi = m.hi >= 25 ? m.hi : 25;
|
||||
hi = m.hi >= 50 ? m.hi : 50;
|
||||
}
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||
})}
|
||||
|
||||
@@ -3273,6 +3273,18 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* PowerGenius XL asks for a remote-access code when reached from
|
||||
outside the LAN (banner "V… AUTH"), like the Tuner/Antenna Genius.
|
||||
Blank on the LAN. */}
|
||||
{isPGXL && (
|
||||
<div className="space-y-1 max-w-xs">
|
||||
<Label>{t('amp.password')}</Label>
|
||||
<Input type="password" value={(amp as any).password ?? ''}
|
||||
onChange={(e) => patchAmp(i, { password: e.target.value } as any)}
|
||||
placeholder={t('amp.passwordPh')} className="font-mono" />
|
||||
<p className="text-[11px] text-muted-foreground">{t('amp.passwordHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Band-follow, for any amp that takes its band from a transceiver
|
||||
CAT link (ACOM and SPE both do) — never PowerGenius, which is
|
||||
|
||||
@@ -188,7 +188,7 @@ const en: Dict = {
|
||||
'gen.showBeam': 'Show the antenna beam heading on the Main map',
|
||||
'gen.startEqEnd': 'QSO start time = end time', 'gen.startEqEndHint': '(matches LoTW when you call a while)',
|
||||
'gen.showQsoRate': 'Show QSO rate in the header', 'gen.showQsoRateHint': '(QSOs/hour, projected from the last 10 / 60 min)',
|
||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.',
|
||||
'gen.lookupOnBlur': 'Look up the callsign only after leaving the field', 'gen.lookupOnBlurHint': '(not while typing)', 'amp.hint': 'Configure one or several amplifiers — each panel card has a dropdown to pick which one it shows.', 'amp.none': 'No amplifier configured yet.', 'amp.namePh': 'Name (e.g. SPE left)', 'amp.remove': 'Remove this amplifier', 'amp.add': 'Add amplifier', 'amp.password': 'Remote code', 'amp.passwordPh': 'blank on LAN', 'amp.passwordHint': 'PowerGenius XL only: needed when reaching the amp remotely — it then announces "AUTH" and rejects every command ("Unauthorized") until you log in. Leave blank on the local network.', 'amp.freqOut': 'Send the frequency to the amplifier (band follow)', 'amp.freqPort': 'CAT/AUX COM port', 'amp.freqBroadcast': 'Also send unprompted', 'amp.freqPollOnly': 'No — answer the amplifier only', 'amp.freqEvery': 'Yes, every {ms} ms', 'amp.freqHint': 'OpsLog pretends to be a transceiver on this second port, in Kenwood format: set the amplifier to that command set (set 5 on an ACOM) at the same baud rate, and put it in OPERATE — in standby it acknowledges but does not switch band. An amplifier that POLLS (ACOM) needs nothing more; one that only LISTENS to the CAT line of the radio hears nothing unless you also turn on the unprompted send.',
|
||||
'gen.groupDigital': 'Group digital modes as one (DXCC-style)', 'gen.groupDigitalHint': '(matrix badges + cluster: FT8/FT4/RTTY… count as a single Digital mode; off = each digital mode is its own slot)',
|
||||
// Password encryption
|
||||
'gen.pwEnc': 'Password encryption',
|
||||
@@ -604,7 +604,7 @@ const fr: Dict = {
|
||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||
'gen.startEqEnd': 'Heure de début du QSO = heure de fin', 'gen.startEqEndHint': '(correspond à LoTW quand tu appelles un moment)',
|
||||
'gen.showQsoRate': 'Afficher le rythme QSO dans la barre du haut', 'gen.showQsoRateHint': '(QSO/heure, projeté sur les 10 / 60 dernières min)',
|
||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.",
|
||||
'gen.lookupOnBlur': 'Rechercher l\'indicatif seulement après avoir quitté le champ', 'gen.lookupOnBlurHint': '(pas pendant la saisie)', 'amp.hint': 'Configurez un ou plusieurs amplificateurs — chaque carte de panneau a une liste déroulante pour choisir lequel afficher.', 'amp.none': 'Aucun amplificateur configuré.', 'amp.namePh': 'Nom (p. ex. SPE gauche)', 'amp.remove': 'Supprimer cet amplificateur', 'amp.add': 'Ajouter un amplificateur', 'amp.password': 'Code distant', 'amp.passwordPh': 'vide en LAN', 'amp.passwordHint': "PowerGenius XL uniquement : nécessaire pour joindre l'ampli à distance — il annonce alors « AUTH » et refuse toute commande (« Unauthorized ») tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'amp.freqOut': "Envoyer la fréquence à l'amplificateur (suivi de bande)", 'amp.freqPort': 'Port COM CAT/AUX', 'amp.freqBroadcast': 'Envoyer aussi sans être interrogé', 'amp.freqPollOnly': "Non — répondre seulement à l'amplificateur", 'amp.freqEvery': 'Oui, toutes les {ms} ms', 'amp.freqHint': "OpsLog se fait passer pour un transceiver sur ce second port, au format Kenwood : réglez l'amplificateur sur ce jeu de commandes (le jeu 5 sur un ACOM) à la même vitesse, et mettez-le en OPERATE — en veille il acquitte mais ne change pas de bande. Un amplificateur qui INTERROGE (ACOM) n'a besoin de rien de plus ; un amplificateur qui se contente d'ÉCOUTER la liaison CAT de la radio n'entendra rien tant que l'envoi spontané n'est pas activé.",
|
||||
'gen.groupDigital': 'Regrouper les modes digitaux en un seul (style DXCC)', 'gen.groupDigitalHint': '(badges de la matrice + cluster : FT8/FT4/RTTY… comptent comme un seul mode Digital ; décoché = chaque mode digital est un slot distinct)',
|
||||
// Chiffrement des mots de passe
|
||||
'gen.pwEnc': 'Chiffrement des mots de passe',
|
||||
|
||||
@@ -1520,6 +1520,7 @@ export namespace main {
|
||||
transport: string;
|
||||
host: string;
|
||||
port: number;
|
||||
password: string;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
freq_out: boolean;
|
||||
@@ -1540,6 +1541,7 @@ export namespace main {
|
||||
this.transport = source["transport"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.password = source["password"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
this.freq_out = source["freq_out"];
|
||||
|
||||
+21
-6
@@ -61,10 +61,12 @@ type Flex struct {
|
||||
|
||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
pendingSpotMode map[int]string // seq → ADIF mode, paired with pendingSpot
|
||||
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
|
||||
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
|
||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||
@@ -185,7 +187,7 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
return &Flex{
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, pendingSpotMode: map[int]string{}, spotCall: map[int]string{}, spotMode: map[int]string{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
pinnedSlice: -1,
|
||||
@@ -338,11 +340,17 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
if idx, err := strconv.Atoi(mm[1]); err == nil {
|
||||
f.mu.Lock()
|
||||
call := f.spotCall[idx]
|
||||
mode := f.spotMode[idx]
|
||||
handler := f.OnSpotClick
|
||||
f.mu.Unlock()
|
||||
if call != "" && handler != nil {
|
||||
debugLog.Printf("Flex: spot %d triggered → %s", idx, call)
|
||||
debugLog.Printf("Flex: spot %d triggered → %s (mode %s)", idx, call, mode)
|
||||
go handler(call, 0)
|
||||
// SmartSDR tunes the spot's frequency on click but does NOT apply
|
||||
// its mode=, so set the slice mode ourselves from what we spotted.
|
||||
if mode != "" {
|
||||
go func(m string) { _ = f.SetMode(m) }(mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,12 +387,15 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
// pair it with the callsign we stashed under this seq.
|
||||
f.mu.Lock()
|
||||
call, pending := f.pendingSpot[seq]
|
||||
spotMode := f.pendingSpotMode[seq]
|
||||
if pending {
|
||||
delete(f.pendingSpot, seq)
|
||||
delete(f.pendingSpotMode, seq)
|
||||
}
|
||||
if pending && ok && len(parts) >= 3 {
|
||||
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
||||
f.spotCall[idx] = call
|
||||
f.spotMode[idx] = spotMode
|
||||
f.spotIdx[idx] = true
|
||||
f.spotByCall[strings.ToUpper(call)] = idx
|
||||
}
|
||||
@@ -801,6 +812,7 @@ func (f *Flex) handleStatus(payload string) {
|
||||
if removed {
|
||||
delete(f.spotIdx, idx)
|
||||
delete(f.spotCall, idx)
|
||||
delete(f.spotMode, idx)
|
||||
} else {
|
||||
f.spotIdx[idx] = true
|
||||
}
|
||||
@@ -1263,6 +1275,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
if hadOld {
|
||||
delete(f.spotByCall, upperCall)
|
||||
delete(f.spotCall, old)
|
||||
delete(f.spotMode, old)
|
||||
delete(f.spotIdx, old)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
@@ -1288,6 +1301,7 @@ func (f *Flex) SendSpot(s SpotInfo) error {
|
||||
// (trigger) can be resolved back to the callsign.
|
||||
f.mu.Lock()
|
||||
f.pendingSpot[seq] = s.Callsign
|
||||
f.pendingSpotMode[seq] = s.Mode
|
||||
f.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
@@ -1320,6 +1334,7 @@ func (f *Flex) ClearSpots() error {
|
||||
f.mu.Lock()
|
||||
f.spotIdx = map[int]bool{}
|
||||
f.spotCall = map[int]string{}
|
||||
f.spotMode = map[int]string{}
|
||||
f.spotByCall = map[string]int{}
|
||||
connected := f.conn != nil
|
||||
f.mu.Unlock()
|
||||
|
||||
+47
-14
@@ -116,15 +116,32 @@ func (t *TCI) SendSpot(s SpotInfo) error {
|
||||
if call == "" || s.FreqHz <= 0 {
|
||||
return nil
|
||||
}
|
||||
color := strings.TrimSpace(s.Color)
|
||||
if color == "" {
|
||||
color = "#FFFFA500" // opaque orange default
|
||||
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
||||
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
||||
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
||||
// can't parse as a number, which is why spots never showed on the panorama
|
||||
// while tuning (a separate command) still worked.
|
||||
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
|
||||
if hex == "" {
|
||||
hex = "FFFFA500" // opaque orange default
|
||||
}
|
||||
if len(hex) == 6 {
|
||||
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
|
||||
}
|
||||
argb, err := strconv.ParseUint(hex, 16, 32)
|
||||
if err != nil {
|
||||
argb = 0xFFFFA500
|
||||
}
|
||||
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
|
||||
// fall back to the raw label if we can't map it. The click-to-tune path already
|
||||
// maps the mode separately, so this only affects the spot's displayed mode.
|
||||
mode := adifToTCIMode(s.Mode, s.FreqHz)
|
||||
if mode == "" {
|
||||
mode = strings.ToLower(strings.TrimSpace(s.Mode))
|
||||
}
|
||||
color = "0x" + strings.TrimPrefix(color, "#")
|
||||
mode := strings.ToLower(strings.TrimSpace(s.Mode))
|
||||
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
||||
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
||||
return t.send(fmt.Sprintf("spot:%s,%s,%d,%s,%s;", call, mode, s.FreqHz, color, text))
|
||||
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
|
||||
}
|
||||
|
||||
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
||||
@@ -302,14 +319,30 @@ func (t *TCI) handle(msg string) {
|
||||
}
|
||||
default:
|
||||
lname := strings.ToLower(name)
|
||||
// A click on one of our panorama spots comes back as a "spot…" message.
|
||||
// The exact shape isn't well documented, so read a callsign (+ freq) from
|
||||
// any spot-related message and fire the callback. The log line lets us pin
|
||||
// the real format against a radio if it differs.
|
||||
if strings.HasPrefix(lname, "spot") {
|
||||
debugLog.Printf("TCI: spot event: %s", msg)
|
||||
call := get(0)
|
||||
hz, _ := strconv.ParseInt(get(2), 10, 64)
|
||||
// A click on one of our panorama spots comes back as
|
||||
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
|
||||
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
|
||||
// Neither name starts with "spot", which is why the click was silently
|
||||
// ignored before. Read the callsign (the one non-numeric field) and the
|
||||
// frequency (the large numeric field) positionally-independently, so both
|
||||
// shapes work without depending on the exact arg order.
|
||||
if strings.Contains(lname, "spot") {
|
||||
var call string
|
||||
var hz int64
|
||||
for _, raw := range f {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
if n >= 10000 { // a real frequency, not an rx/channel index
|
||||
hz = n
|
||||
}
|
||||
} else if call == "" {
|
||||
call = strings.ToUpper(v) // callsigns always carry letters
|
||||
}
|
||||
}
|
||||
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
|
||||
if call != "" && t.OnSpotClick != nil {
|
||||
cb := t.OnSpotClick
|
||||
go cb(call, hz)
|
||||
|
||||
@@ -24,7 +24,13 @@ const (
|
||||
defaultPort = 9008
|
||||
dialTimeout = 5 * time.Second
|
||||
ioTimeout = 3 * time.Second
|
||||
pollEvery = 1500 * time.Millisecond
|
||||
// Poll fast enough that the amp's OWN forward/current figures make a usable
|
||||
// live meter on their own — the UI prefers them over the FlexRadio VITA stream
|
||||
// (which never traverses a public-IP/NAT link), so this direct reading is what
|
||||
// an operator watches when running the amp over the internet. At 250 ms the
|
||||
// SSB envelope is sampled often enough that peak-hold keeps a steady reading
|
||||
// instead of collapsing to ~1 W in the gaps between syllables.
|
||||
pollEvery = 250 * time.Millisecond
|
||||
reconnectDelay = 2 * time.Second
|
||||
)
|
||||
|
||||
@@ -50,8 +56,9 @@ type Status struct {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
host string
|
||||
port int
|
||||
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
|
||||
|
||||
mu sync.Mutex // serialises command send/recv on the connection
|
||||
conn net.Conn
|
||||
@@ -70,11 +77,11 @@ type Client struct {
|
||||
running bool
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
func New(host string, port int, password string) *Client {
|
||||
if port <= 0 || port > 65535 {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
|
||||
return &Client{host: host, port: port, password: strings.TrimSpace(password), stop: make(chan struct{}), status: Status{Host: host}}
|
||||
}
|
||||
|
||||
func (c *Client) Start() error {
|
||||
@@ -118,9 +125,15 @@ func (c *Client) SetFanMode(mode string) error {
|
||||
default:
|
||||
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
||||
}
|
||||
if _, err := c.command("setup fanmode=" + m); err != nil {
|
||||
// Set with the bare "key=value" verb the amp uses for its own status fields
|
||||
// (same convention as "operate=1"). The earlier "setup fanmode=…" carried a
|
||||
// bogus prefix the amp silently ignored, so the fan never changed and the next
|
||||
// status kept reporting the old mode — the revert-to-Contest the operator saw.
|
||||
reply, err := c.command("fanmode=" + m)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applog.Printf("pgxl: set fanmode=%s reply=%q", m, reply)
|
||||
c.statusMu.Lock()
|
||||
c.status.FanMode = m // optimistic
|
||||
c.fanPending, c.fanPendingAt = m, time.Now()
|
||||
@@ -177,12 +190,62 @@ func (c *Client) ensureConnected() error {
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(conn)
|
||||
// Discard the version banner the device sends on connect.
|
||||
// Banner: "V…" (LAN) or "V… AUTH" (remote → authentication required, exactly
|
||||
// like the Tuner Genius / Antenna Genius). Send the remote code when the amp
|
||||
// demands it, otherwise every command comes back "Unauthorized".
|
||||
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||
_, _ = c.reader.ReadString('\n')
|
||||
banner, _ := c.reader.ReadString('\n')
|
||||
banner = strings.TrimSpace(banner)
|
||||
applog.Printf("pgxl: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
|
||||
if strings.Contains(banner, "AUTH") {
|
||||
if c.password == "" {
|
||||
applog.Printf("pgxl: device requires AUTH but no remote code set (Settings → Amplifier)")
|
||||
} else if err := c.authLocked(); err != nil {
|
||||
c.conn.Close()
|
||||
c.conn, c.reader = nil, nil
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authLocked authenticates the remote link. Must be called with c.mu held
|
||||
// (during ensureConnected). 4O3A boxes want "auth code=<pw>" and reply
|
||||
// "R<seq>|<hex>|" with an EMPTY message — hex "0" means accepted, so the response
|
||||
// CODE decides, not the text. The device also rejects the FIRST attempt (R|FF)
|
||||
// and accepts a retry, so resend a few times before giving up.
|
||||
func (c *Client) authLocked() error {
|
||||
var lastHex string
|
||||
for try := 1; try <= 4; try++ {
|
||||
id := c.cmdID.Add(1)
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||
if _, err := fmt.Fprintf(c.conn, "C%d|auth code=%s\n", id, c.password); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
applog.Printf("pgxl: auth reply=%q (try %d)", line, try)
|
||||
hex, msg := "", ""
|
||||
if p := strings.SplitN(line, "|", 3); len(p) >= 2 {
|
||||
hex = strings.TrimSpace(p[1])
|
||||
if len(p) == 3 {
|
||||
msg = strings.TrimSpace(p[2])
|
||||
}
|
||||
}
|
||||
// hex "0" is the standard accept; also treat an explicit OK message as success.
|
||||
if hex == "0" || (msg != "" && strings.Contains(strings.ToLower(msg), "ok")) {
|
||||
applog.Printf("pgxl: authenticated")
|
||||
return nil
|
||||
}
|
||||
lastHex = hex
|
||||
}
|
||||
return fmt.Errorf("powergenius: authentication failed after 4 tries (R|%s|) — check the remote code", lastHex)
|
||||
}
|
||||
|
||||
func (c *Client) dropConn() {
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
@@ -237,9 +300,10 @@ func (c *Client) parse(resp string) {
|
||||
c.statusMu.Lock()
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
// One raw frame per session is enough to learn the field set — the frames
|
||||
// carry live meter values, so "log on change" logged every frame.
|
||||
if c.lastRaw == "" {
|
||||
// Log the first REAL status frame (one that carries "key=value" fields) so the
|
||||
// field set is visible — even if an earlier reply was junk like "Unauthorized"
|
||||
// (which would otherwise latch lastRaw and hide the real frame).
|
||||
if strings.Contains(data, "=") && !strings.Contains(c.lastRaw, "=") {
|
||||
c.lastRaw = data
|
||||
applog.Printf("pgxl: status raw=%q", data)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user