From dd0a34dc0a72e964ac67b96b02ee5bdf5a6e15f5 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sun, 5 Jul 2026 11:12:39 +0200 Subject: [PATCH] feat: Flexradio/Icom, click on signal to fill the RST tx --- frontend/src/App.tsx | 8 +++--- frontend/src/components/FlexPanel.tsx | 25 ++++++++++-------- frontend/src/components/IcomPanel.tsx | 37 +++++++++++++++++---------- frontend/src/lib/i18n.tsx | 2 ++ frontend/src/lib/rst.ts | 16 ++++++++++++ 5 files changed, 62 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/rst.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a8f7a14..1aa5830 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3006,7 +3006,8 @@ export default function App() { case 'flex': return (
- { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} /> + { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} + onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
); case 'recent': @@ -4069,7 +4070,8 @@ export default function App() { backend is a FlexRadio. */} {catState.backend === 'flex' && ( - { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} /> + { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }} + onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> )} @@ -4077,7 +4079,7 @@ export default function App() { is an Icom. */} {catState.backend === 'icom' && ( - + { setRstSent(r); rstUserEditedRef.current = true; }} /> )} diff --git a/frontend/src/components/FlexPanel.tsx b/frontend/src/components/FlexPanel.tsx index 54830b8..2d5d481 100644 --- a/frontend/src/components/FlexPanel.tsx +++ b/frontend/src/components/FlexPanel.tsx @@ -12,6 +12,7 @@ import { } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; +import { sMeterRST } from '@/lib/rst'; type FlexState = { available: boolean; model?: string; @@ -140,15 +141,17 @@ function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, slide // `display` overrides the numeric readout; `segColor` colours segments by their // 0..1 position (zones); the top ~18% light red by default (overload/peak). const METER_SEGMENTS = 26; -function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor }: { +function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title }: { label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string; - segColor?: (frac: number) => string; + segColor?: (frac: number) => string; onClick?: () => void; title?: string; }) { const span = hi - lo; const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0; const lit = Math.round((pct / 100) * METER_SEGMENTS); return ( -
+
{label} @@ -190,7 +193,7 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin // onCWSpeed (optional): notified when the operator changes CW speed here, so the // host can keep the WinKeyer (which actually sends the macros) in sync. -export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } = {}) { +export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); const hold = useRef>({}); @@ -547,17 +550,19 @@ export function FlexPanel({ onCWSpeed }: { onCWSpeed?: (wpm: number) => void } = : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a'; // S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit). const sUnit = (dbm: number) => { - const s = (dbm + 127) / 6; // S0 = -127 dBm - if (s >= 9) { - const over = Math.round(dbm + 73); // dB over S9 - return { display: over > 0 ? `S9+${over}` : 'S9', bar: s }; + const sv = (dbm + 127) / 6; // S0 = -127 dBm + if (sv >= 9) { + const over = Math.max(0, Math.round(dbm + 73)); // dB over S9 + return { display: over > 0 ? `S9+${over}` : 'S9', bar: sv, s: 9, over }; } - return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) }; + return { display: `S${Math.max(0, Math.round(sv))}`, bar: Math.max(0, sv), s: Math.max(0, sv), over: 0 }; }; const cur = [ sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return ( { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 12.33 ? '#f59e0b' : '#dc2626'; }} /> + title={onReportRST ? t('rst.clickToFill') : undefined} + onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined} + segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} /> ); })(), fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return ( void; title?: string }) { const v = Math.max(0, Math.min(100, value)); - return ( -
+ const body = ( + <> {label}
{scale ?? v} -
+ ); + if (onClick) { + return ; + } + return
{body}
; } -// S-meter raw 0-100 → S-unit label (S9 ≈ 47% on the CI-V 0-255 scale; above = +dB). -function sUnit(v: number): string { +// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the +// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and +// the RST-tx value on click. +function sParts(v: number): { s: number; over: number; label: string } { if (v >= 47) { - const over = Math.round((v - 47) / 8.8) * 10; - return over > 0 ? `S9+${over}` : 'S9'; + const over = Math.max(0, Math.round((v - 47) * 60 / 47)); + return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' }; } - return `S${Math.max(0, Math.min(9, Math.round(v / 5.2)))}`; + const s = Math.max(0, Math.min(9, Math.round(v / 5.2))); + return { s, over: 0, label: `S${s}` }; } // ScopePanadapter — enables the rig's spectrum-scope stream and draws the @@ -315,7 +324,7 @@ function ScopePanadapter() { // Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are // read every cache cycle; DSP set-controls are optimistic and reconcile on the // next poll. Front-panel knob changes for DSP show after ↻ Refresh. -export function IcomPanel() { +export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); const [busy, setBusy] = useState(false); @@ -397,9 +406,11 @@ export function IcomPanel() { 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} /> - ) : ( - - )} + ) : (() => { const sp = sParts(st.s_meter); return ( + onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} /> + ); })()} {/* Transmit controls. */} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 8931632..34a8255 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -184,6 +184,7 @@ const en: Dict = { '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.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.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', + 'rst.clickToFill': 'Click to set RST tx from the signal', // Misc panels/modals (alerts / send-spot / net / udp / filter / details) 'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close', 'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot', @@ -358,6 +359,7 @@ const fr: Dict = { '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.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.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', + 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer', 'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot', 'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET', diff --git a/frontend/src/lib/rst.ts b/frontend/src/lib/rst.ts new file mode 100644 index 0000000..3d0837d --- /dev/null +++ b/frontend/src/lib/rst.ts @@ -0,0 +1,16 @@ +// sMeterRST turns an S-meter reading into the RST report you SEND to the other +// station (RST tx / rst_sent) — because your S-meter is how strong THEY are. +// +// s : S-unit 0-9 (used when below S9) +// overDb : dB over S9 (0 when at/below S9) +// mode : operating mode — CW/digital get a 3-digit RST, phone a 2-digit RS +// +// For S9+ the dB is rounded UP to the nearest 5 (a 9+3 reading reports 59+5, +// 9+12 reports 59+15, …), matching how operators give strong-signal reports. +export function sMeterRST(s: number, overDb: number, mode?: string): string { + const cw = /CW|RTTY|PSK|FT[0-9]|JT|JS8|MFSK|OLIVIA|DATA|DIG/i.test(mode || ''); + const strength = Math.max(1, Math.min(9, Math.round(s))); + if (cw) return overDb > 0 ? '599' : `5${strength}9`; + if (overDb > 0) return `59+${Math.ceil(overDb / 5) * 5}`; + return `5${strength}`; +}