From 1070637c40bc8e46c013f75f11c2c935ca93154d Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 23 Jul 2026 01:08:47 +0200 Subject: [PATCH] feat+fix batch: Station Control dashboard (fixed-width panels, grip-handle drag reorder, amp meters from the Flex stream, compact relays), Ultrabeam element list capped to 3 (READ_BANDS over-read), spot-click on Flex panadapter now sets mode, DAX TX toggle, DVK panel state persists across relaunch, duplicate amp meters cleared on power-cycle, QSO audio recording snapshot taken synchronously at log time (was racing the form-clear cancel), Flex COMP meter max -20->-25 and MIC scale to 0; changelog 0.20.10 --- app.go | 27 +++-- changelog.json | 16 ++- frontend/src/App.tsx | 5 +- frontend/src/components/FlexPanel.tsx | 9 +- .../src/components/StationControlPanel.tsx | 111 +++++++++++------- frontend/src/lib/i18n.tsx | 4 +- internal/cat/flex.go | 32 +++++ 7 files changed, 142 insertions(+), 62 deletions(-) diff --git a/app.go b/app.go index ac1d55d..97bcc13 100644 --- a/app.go +++ b/app.go @@ -1983,6 +1983,12 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { q.ID = id a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory) a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async) + // Snapshot the QSO recording SYNCHRONOUSLY, BEFORE announcing the log: the + // qso:logged event clears the entry form, and clearing the callsign cancels + // the recorder — so if this ran on the async path below it raced the cancel + // and the audio was discarded (recordings silently stopped working). The + // snapshot is an in-memory copy; the heavy file encode still runs async. + a.saveQSORecording(&q) // Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry // form clears immediately — the operator is not made to wait on the DB. wruntime.EventsEmit(a.ctx, "qso:logged", id) @@ -1994,7 +2000,6 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) { qc := q go func() { a.materializeAwardRefs(qc) // fills the award_refs column - a.saveQSORecording(&qc) if a.extsvc != nil { a.extsvc.OnQSOLogged(id) } @@ -6226,19 +6231,14 @@ func (a *App) saveQSORecording(q *qso.QSO) { return } - // Stamp the recording's path on the QSO now, synchronously, so it's set - // before the eQSL auto-send reads the QSO (their full-row Updates would - // otherwise race and clobber each other's extras). + // Stamp the recording's path on the in-memory QSO now (the encode goroutine + // reads it). The DB write is deferred into that goroutine so this stays OFF + // the critical logging path — saveQSORecording is now called synchronously. if q.ID != 0 { if q.Extras == nil { q.Extras = map[string]string{} } - q.Extras["APP_OPSLOG_RECORDING"] = name // in-memory copy for the encode goroutine - // Persist ONLY this extras key (targeted) — a full-row Update from this - // in-memory copy could revert a column a concurrent post-log action changed. - if err := a.qso.SetExtra(a.ctx, q.ID, "APP_OPSLOG_RECORDING", name); err != nil { - applog.Printf("qso-rec: store recording path: %v", err) - } + q.Extras["APP_OPSLOG_RECORDING"] = name } // Clone the QSO for the goroutine with its OWN copy of the Extras map: a @@ -6264,6 +6264,13 @@ func (a *App) saveQSORecording(q *qso.QSO) { applog.Printf("qso-rec: save failed: %v", err) return } + // Persist the recording path (targeted extras write) now that the file + // exists — off the critical path, so a busy MySQL never delays logging. + if qc.ID != 0 { + if err := a.qso.SetExtra(a.ctx, qc.ID, "APP_OPSLOG_RECORDING", name); err != nil { + applog.Printf("qso-rec: store recording path: %v", err) + } + } applog.Printf("qso-rec: saved %s", path) // Auto-send the recording once the file exists. Gated ONLY on its own // "auto-send recording" toggle (email.auto_send) — NOT the SMTP panel's diff --git a/changelog.json b/changelog.json index 3e7db34..c2d3dac 100644 --- a/changelog.json +++ b/changelog.json @@ -3,10 +3,22 @@ "version": "0.20.10", "date": "2026-07-22", "en": [ - "Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode." + "Clicking an OpsLog spot on the FlexRadio panadapter now switches the mode too, not just the frequency: cluster spots (which carry no mode) get one inferred from the comment or the band plan, and every spot's mode is sent to the radio as a real SmartSDR mode (SSB resolves to USB/LSB by frequency), so the click tunes AND sets the mode.", + "Station Control is now a clean dashboard of fixed-width panels that wrap to fill the window. Each panel has a grip handle on its left edge — drag it to move the panel anywhere, and the order is remembered. Relay buttons are compact. The amplifier panel now shows the same live meters (forward power, drain current, PA temperature) as the FlexRadio panel.", + "Station Control: the Ultrabeam element list now shows the right number of elements (3 for a 3-element beam) instead of extra bogus rows that came from over-reading the controller's reply.", + "Fixed the FlexRadio amplifier meters showing twice after the amp was power-cycled — the old (stale) meters are now cleared when the amplifier is torn down, so only the fresh set remains.", + "Fixed QSO audio recordings silently not being saved: the recording snapshot had moved onto the background (post-log) path, where it raced the entry-form clearing that cancels the recorder — so the audio was discarded before it was captured. The snapshot is now taken the instant you log, before the form clears; the file encoding still happens in the background.", + "The Digital Voice Keyer (DVK) panel now stays open across a relaunch if you had it open — it used to reset to closed every time.", + "FlexRadio meter scales now match SmartSDR: MIC (level) tops out at 0 dB and COMP (compression) tops out at -25 dB (was -20)." ], "fr": [ - "Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode." + "Cliquer sur un spot OpsLog du panadapter FlexRadio change maintenant aussi le mode, plus seulement la fréquence : les spots cluster (qui n'ont pas de mode) en reçoivent un déduit du commentaire ou du plan de bande, et le mode de chaque spot est envoyé à la radio comme un vrai mode SmartSDR (SSB devient USB/LSB selon la fréquence), donc le clic accorde ET règle le mode.", + "Station Control est maintenant un tableau de bord de panneaux à largeur fixe qui s'alignent pour remplir la fenêtre. Chaque panneau a une poignée sur son bord gauche — glisse-la pour déplacer le panneau où tu veux, et l'ordre est mémorisé. Les boutons relais sont compacts. Le panneau amplificateur affiche maintenant les mêmes mesures en direct (puissance directe, courant de drain, température PA) que le panneau FlexRadio.", + "Station Control : la liste des éléments Ultrabeam affiche maintenant le bon nombre d'éléments (3 pour une beam 3 éléments) au lieu de lignes fantômes issues d'une sur-lecture de la réponse du contrôleur.", + "Correction des mesures de l'amplificateur FlexRadio affichées en double après un cycle d'alimentation de l'ampli — les anciennes mesures (périmées) sont maintenant effacées quand l'amplificateur est retiré, seul le jeu à jour reste.", + "Correction des enregistrements audio des QSO qui n'étaient plus sauvegardés : la capture de l'enregistrement était passée sur le chemin d'arrière-plan (après le log), où elle entrait en concurrence avec le vidage du formulaire qui annule l'enregistreur — l'audio était donc jeté avant d'être capturé. La capture se fait maintenant à l'instant du log, avant le vidage du formulaire ; l'encodage du fichier reste en arrière-plan.", + "Le panneau Digital Voice Keyer (DVK) reste maintenant ouvert après un relancement si vous l'aviez ouvert — il se refermait à chaque démarrage.", + "Les échelles des meters FlexRadio collent maintenant à SmartSDR : MIC (niveau) plafonne à 0 dB et COMP (compression) à -25 dB (au lieu de -20)." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8e3071..0b3ae04 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -950,7 +950,10 @@ export default function App() { const [contestTabEnabled, setContestTabEnabled] = useState(() => localStorage.getItem('opslog.contestTab') === '1'); useEffect(() => { localStorage.setItem('opslog.contestTab', contestTabEnabled ? '1' : '0'); }, [contestTabEnabled]); - const [dvkEnabled, setDvkEnabled] = useState(false); + // DVK panel open/closed — persisted like the other Tools toggles above so it + // survives a relaunch instead of resetting to closed. + const [dvkEnabled, setDvkEnabled] = useState(() => localStorage.getItem('opslog.dvkEnabled') === '1'); + useEffect(() => { localStorage.setItem('opslog.dvkEnabled', dvkEnabled ? '1' : '0'); }, [dvkEnabled]); const [dvkMsgs, setDvkMsgs] = useState([]); const [dvkStat, setDvkStat] = useState({ recording: false, playing: false, rec_slot: 0 }); const dvkActiveRef = useRef(false); diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index aa50f13..9b5404b 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -555,11 +555,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} /> ); })(), swr && , - // Mic input level: fixed -40..+10 dB scale, RED from 0 dB up (overdrive). - mic && (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />, - // Speech compression (dB of gain reduction). - comp && 0) ? comp.hi : 25} accent="#0891b2" />, + // Speech compression — original working meter, only the top of the + // scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max). + comp && , ].filter(Boolean); return (
{cur}
diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 17a8f5d..fe40127 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react'; +import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame, GripVertical } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -206,9 +206,11 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =

{t('station.noLengths')}

) : (
- {/* Hide 0-length elements — an Ultrabeam reports 6 slots but a - 3-element beam only uses the first few. */} - {lengths.map((mm, i) => ({ mm, i })).filter((e) => e.mm > 0).map(({ mm, i }) => ( + {/* The READ_BANDS reply is undocumented and its 16-bit parse picks + up structural bytes past the real data (varying by band), which + made 6+ bogus "elements" appear. Ultrabeam beams are 3-element, + and ModifyElement addresses elements 0..2 — so show just those. */} + {lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
{elementName(i, t)}
{isPGXL ? ( + <>
+ {/* Live amp meters (FWD / ID / TEMP …) from the FlexRadio UDP stream, so + this card matches the amplifier card in the Flex panel. */} + {viaFlex && (() => { + const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10); + const ampMeters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP') && !/^(RL|DRV)$/i.test((m.name || '').trim())); + if (ampMeters.length === 0) return null; + return ( +
+ {ampMeters.map((m) => { + const isPwr = /fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || ''); + const val = isPwr ? dbmToW(m.value) : m.value; + const unit = isPwr ? 'W' : (m.unit || ''); + const hi = isPwr ? 2000 : (/amp/i.test(m.unit || '') || /^ID$/i.test((m.name || '').trim())) ? 25 : (m.hi > 0 ? m.hi : 100); + const frac = Math.min(1, Math.max(0, val / hi)); + const acc = isPwr ? '#dc2626' : /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a'; + return ( +
+
+ {m.name} + {val.toFixed(isPwr ? 0 : 1)}{unit} +
+
+
+
+
+ ); + })} +
+ ); + })()} + ) : ( <>
@@ -393,7 +427,6 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } }); const dragId = useRef(null); - const [cols, setCols] = useState(() => localStorage.getItem('opslog.stationCols') || 'auto'); // Amplifiers (Settings → Amplifier): shown here so operators without a // FlexRadio panel still get the controls. Re-read every 5s so enabling an // amp in Settings makes the widget appear without reopening the tab. @@ -491,24 +524,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
-
+ {/* Compact one-line relay buttons at a FIXED width so they don't stretch + across the whole card — they wrap to fill the available width instead. */} +
{relays.map((r) => { const key = `${dev.id}:${r.number}`; const label = r.label || `${t('station.relay')} ${r.number}`; return ( ); @@ -536,32 +570,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && ampCount === 0; - // Column count controls the layout: with 4 widgets, choosing "2" lays them out - // 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to - // fill however many columns are available. - const gridCols: Record = { - auto: 'sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4', - '1': 'grid-cols-1', '2': 'grid-cols-2', '3': 'grid-cols-3', '4': 'grid-cols-4', - }; - return (

{t('station.title')}

-
-
- {(['auto', '2', '3', '4'] as const).map((c) => ( - - ))} -
- -
+
{noDevices && !editing && ( @@ -570,14 +585,24 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
)} -
+ {/* Dashboard of FIXED-WIDTH cards that wrap to fill the window — a clean, + evenly-sized grid. Each card has a grip handle (left rail) as the drag + initiator (the card body is full of buttons, so dragging the whole card + was unreliable). Drop on another card to reorder. */} +
{ordered.map((w) => ( -
{ dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} - onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }} - onDrop={(e) => { e.preventDefault(); onDrop(w.id); }} - className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}> - {w.node} +
{ if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} + onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}> +
{ dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} + onDragEnd={() => { dragId.current = null; }} + title={t('station.dragMove')} + className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors', + dragId.current === w.id && 'opacity-60')}> + +
+
{w.node}
))}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 4894593..b59b347 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -129,7 +129,7 @@ const en: Dict = { 'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.', 'uscty.backfillRun': 'Fill missing counties', 'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.', - 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.', + 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.', 'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer', 'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices', 'sec.relayauto': 'Relay auto-control', @@ -442,7 +442,7 @@ const fr: Dict = { 'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.", 'uscty.backfillRun': 'Remplir les comtés manquants', 'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.', - 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).', + 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).', 'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW', 'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio', 'sec.relayauto': 'Relais automatiques', diff --git a/internal/cat/flex.go b/internal/cat/flex.go index c324526..8729c4b 100644 --- a/internal/cat/flex.go +++ b/internal/cat/flex.go @@ -649,6 +649,18 @@ func (f *Flex) handleStatus(payload string) { } if removed { f.amp = flexAmp{} + // The amp's meters (FWD / ID / TEMP …) don't always get an individual + // "meter removed" push, so power-cycling the amp left the OLD meter + // ids in the maps while the re-registered amp added NEW ones — the + // panel then showed every amp meter twice. Drop every AMP-sourced + // meter here so only the fresh set survives. + for id, mi := range f.meterMeta { + if strings.Contains(strings.ToUpper(mi.src), "AMP") { + delete(f.meterMeta, id) + delete(f.meterVal, id) + delete(f.meterSub, id) + } + } } f.mu.Unlock() } @@ -660,6 +672,26 @@ func (f *Flex) handleStatus(payload string) { f.meterRawLogged = true debugLog.Printf("Flex: meter status raw: %s", payload) } + // Meter removal — "meter removed": drop it so a stale value can't + // linger (e.g. after an amp/slice is torn down). + removedMeter := false + for _, tok := range fields[1:] { + if tok == "removed" || tok == "in_use=0" { + removedMeter = true + } + } + if removedMeter { + f.mu.Lock() + for _, tok := range fields[1:] { + if n, err := strconv.Atoi(strings.TrimSpace(tok)); err == nil { + delete(f.meterMeta, n) + delete(f.meterVal, n) + delete(f.meterSub, n) + } + } + f.mu.Unlock() + return + } var newIDs []int f.mu.Lock() for _, tok := range fields[1:] {