From 5ae2bad549e45b38428ba2babaf90d839b1b26c3 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 9 Jul 2026 15:52:04 +0200 Subject: [PATCH] fix: Antenna Genius show only available antennas per band --- frontend/src/App.tsx | 1 + frontend/src/components/AntGeniusPanel.tsx | 44 ++++++++++++++++++++-- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/models.ts | 2 + internal/antgenius/antgenius.go | 24 ++++++++++-- 5 files changed, 66 insertions(+), 9 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ad54df2..1a94c10 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3760,6 +3760,7 @@ export default function App() { status={agStatus} onActivate={agActivate} onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }} + band={band} /> )} diff --git a/frontend/src/components/AntGeniusPanel.tsx b/frontend/src/components/AntGeniusPanel.tsx index 9590ee0..203b8f0 100644 --- a/frontend/src/components/AntGeniusPanel.tsx +++ b/frontend/src/components/AntGeniusPanel.tsx @@ -1,14 +1,30 @@ -import { Antenna, X } from 'lucide-react'; +import { useState } from 'react'; +import { Antenna, X, ListFilter } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; -export type AGAntenna = { index: number; name: string }; +export type AGAntenna = { index: number; name: string; bands?: number }; export type AGStatus = { connected: boolean; host?: string; last_error?: string; port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean; antennas: AGAntenna[]; }; +// AG_BAND_BITS maps a band name to its bit in the 4O3A Antenna Genius band +// bitmask (standard HF+6m order, bit 0 = 160m). Used to show only the antennas +// configured for the current band, like the native app. If the mapping ever +// proves off on real hardware, the debug log (antgenius: antenna raw …) shows the +// device's actual mask so it can be corrected. +const AG_BAND_BITS: Record = { + '160m': 1 << 0, '80m': 1 << 1, '60m': 0, '40m': 1 << 2, '30m': 1 << 3, + '20m': 1 << 4, '17m': 1 << 5, '15m': 1 << 6, '12m': 1 << 7, '10m': 1 << 8, '6m': 1 << 9, +}; + +function bandBit(band?: string): number { + if (!band) return 0; + return AG_BAND_BITS[band.trim().toLowerCase()] ?? 0; +} + // Format an antenna name: first letter uppercase, the rest lowercase // (e.g. "DX COMMANDER" → "Dx commander"). function pretty(name: string): string { @@ -22,13 +38,28 @@ function pretty(name: string): string { // a port-A button (left) and port-B button (right). Colours: green = selected on // port A, blue = selected on port B, red (pulsing) = that port is transmitting. // Clicking an already-selected port deselects it (port → None). -export function AntGeniusPanel({ status, onActivate, onClose }: { +export function AntGeniusPanel({ status, onActivate, onClose, band }: { status: AGStatus; onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect onClose: () => void; + band?: string; // current operating band (e.g. "20m") for the band filter }) { const { t } = useI18n(); - const list = status.antennas ?? []; + const allAntennas = status.antennas ?? []; + + // Band filter (persisted, default ON): show only antennas whose band mask + // includes the current band — matching the native 4O3A app. Antennas with an + // unknown mask (0) always show, and if the filter would hide EVERYTHING (e.g. a + // band we can't map, or masks the device didn't send) we fall back to the full + // list so the panel is never mysteriously empty. + const [bandFilter, setBandFilter] = useState(() => localStorage.getItem('opslog.agBandFilter') !== '0'); + const toggleBandFilter = () => setBandFilter((v) => { const n = !v; localStorage.setItem('opslog.agBandFilter', n ? '1' : '0'); return n; }); + const bit = bandBit(band); + let list = allAntennas; + if (bandFilter && bit !== 0) { + const filtered = allAntennas.filter((a) => !a.bands || (a.bands & bit) !== 0); + if (filtered.length > 0) list = filtered; + } const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => { const letter = port === 1 ? 'A' : 'B'; @@ -57,6 +88,11 @@ export function AntGeniusPanel({ status, onActivate, onClose }: { Antenna Genius + {status.connected ? t('agp.online') : t('agp.offline')} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index a9d251c..3969f21 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -193,7 +193,7 @@ const en: Dict = { 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL", 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', - 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', + 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?', @@ -382,7 +382,7 @@ const fr: Dict = { 'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL", 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', - 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', + 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante', 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index dfeac3b..92f6f14 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -117,6 +117,7 @@ export namespace antgenius { export class Antenna { index: number; name: string; + bands: number; static createFrom(source: any = {}) { return new Antenna(source); @@ -126,6 +127,7 @@ export namespace antgenius { if ('string' === typeof source) source = JSON.parse(source); this.index = source["index"]; this.name = source["name"]; + this.bands = source["bands"]; } } export class Status { diff --git a/internal/antgenius/antgenius.go b/internal/antgenius/antgenius.go index 7b7b155..06aa175 100644 --- a/internal/antgenius/antgenius.go +++ b/internal/antgenius/antgenius.go @@ -32,9 +32,13 @@ const ( ) // Antenna is one configured antenna (index + name as stored on the device). +// Bands is the device's band bitmask for the antenna (which bands it covers); +// 0 = unknown/all. The UI uses it to show only band-appropriate antennas, like +// the native 4O3A app. type Antenna struct { Index int `json:"index"` Name string `json:"name"` + Bands int `json:"bands"` } // Status is the snapshot the UI renders. @@ -65,6 +69,8 @@ type Client struct { statusMu sync.RWMutex status Status antennas map[int]string // index → name (rebuilt into status.Antennas) + antBands map[int]int // index → band bitmask (which bands the antenna covers) + antRawN int // one-shot: how many raw antenna lines we've logged stop chan struct{} running bool @@ -80,6 +86,7 @@ func New(host string, port int, password string) *Client { password: strings.TrimSpace(password), stop: make(chan struct{}), antennas: map[int]string{}, + antBands: map[int]int{}, status: Status{Host: host}, } } @@ -356,13 +363,24 @@ func (c *Client) parseAntenna(msg string) { // The device stores spaces as underscores in names. name = strings.TrimSpace(strings.ReplaceAll(name, "_", " ")) } + // Band bitmask: which bands this antenna is configured for. The device sends + // it as "band=" (a bitmask). Kept so the UI can show only band-relevant + // antennas (like the native app). Log the first few raw antenna lines so the + // exact field name + bit values can be confirmed on real hardware. + bands := kvInt(msg, "band") c.statusMu.Lock() + if c.antRawN < 8 { + c.antRawN++ + applog.Printf("antgenius: antenna raw #%d: %q (parsed band mask=%d/0x%X)", c.antRawN, msg, bands, bands) + } if name != "" && !isPlaceholderName(name) { c.antennas[id] = name + c.antBands[id] = bands } else { delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown + delete(c.antBands, id) } - c.status.Antennas = sortedAntennas(c.antennas) + c.status.Antennas = sortedAntennas(c.antennas, c.antBands) c.status.Connected = true c.statusMu.Unlock() } @@ -431,10 +449,10 @@ func isPlaceholderName(name string) bool { return err == nil } -func sortedAntennas(m map[int]string) []Antenna { +func sortedAntennas(m map[int]string, bands map[int]int) []Antenna { out := make([]Antenna, 0, len(m)) for idx, name := range m { - out = append(out, Antenna{Index: idx, Name: name}) + out = append(out, Antenna{Index: idx, Name: name, Bands: bands[idx]}) } sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index }) return out