diff --git a/app.go b/app.go index e7e2ffc..09ad869 100644 --- a/app.go +++ b/app.go @@ -741,10 +741,15 @@ type App struct { watchlist *watchlist.Store // Tools → Watchlist (global watchlist.json) watchAlertMu sync.Mutex // throttles watchlist alerts… watchAlertAt map[string]time.Time // …per entry - watchPattern atomic.Value // auto-contest pattern (string), loaded at startup - operating *operating.Repo - udp *udp.Manager - udpRepo *udp.Repo + + // WSJT-X decode highlighting (message 13) — see app_wsjt_highlight.go. + wsjtHighlightOn atomic.Bool + wsjtHLMu sync.Mutex + wsjtHLSent map[string]string + watchPattern atomic.Value // auto-contest pattern (string), loaded at startup + operating *operating.Repo + udp *udp.Manager + udpRepo *udp.Repo // Program id of the last decoding application that reported its status. // Halt Tx is routed by id, and the panel's Halt button must work even when // nothing is transmitting at that instant — so the id is remembered from @@ -1205,6 +1210,11 @@ func (a *App) startup(ctx context.Context) { a.operating = operating.NewRepo(conn) a.udpRepo = udp.NewRepo(conn) a.udp = udp.NewManager(a.udpRepo) + // A program heard for the first time is asked to replay the decodes already + // on its screen, so the FT decodes panel starts full instead of waiting a + // period. Replayed decodes arrive marked not-new and are shown but never + // auto-answered. + a.udp.SetOnNewInstance(func(id string) { _ = a.udp.SendReplay(id) }) go a.consumeUDPEvents() a.cache = lookup.NewCache(conn, 30*24*time.Hour) a.lookup = lookup.NewManager(a.cache) @@ -1444,6 +1454,7 @@ func (a *App) startup(ctx context.Context) { a.pota = pota.New(func(format string, args ...any) { applog.Printf(format, args...) }) a.startWatchlistClubLog() a.watchPattern.Store(strings.ToUpper(strings.TrimSpace(a.settingOr(keyWatchlistContestPattern, "")))) + a.wsjtHighlightOn.Store(a.settingOr(keyWsjtHighlight, "0") == "1") go a.pota.Run(a.ctx) // DX Cluster (multi-server): the spot callback enriches each spot @@ -13905,7 +13916,13 @@ func (a *App) consumeUDPEvents() { "low_conf": ev.DecodeLowConf, "mode_raw": ev.DecodeModeRaw, "msg_raw": ev.DecodeMsgRaw, + // false on a Replay's resent history — shown, never auto-answered. + "is_new": ev.DecodeIsNew, }) + // Log-aware colour in the decoder's own window (see + // app_wsjt_highlight.go). After the emit: painting must never delay + // the panel. + a.maybeHighlightDecode(ev.ProgramID, ev.DecodeCall, bandForHz(ev.DecodeFreqHz)) // A WSJT-X decode (heard station). Render it on the FlexRadio // panadapter when the option is on; green + SNR comment, auto-expiring // after the configured duration. De-duped per call in the Flex backend. diff --git a/app_wsjt_highlight.go b/app_wsjt_highlight.go new file mode 100644 index 0000000..46a9eb5 --- /dev/null +++ b/app_wsjt_highlight.go @@ -0,0 +1,119 @@ +package main + +// Log-aware colours in WSJT-X / JTDX's own Band Activity window (message 13), +// the way JTAlert paints them: a decode of a watchlist member, a new DXCC or a +// new band for its entity is highlighted where the operator is actually +// looking. The verdicts come from the same cluster status cache that colours +// the spot grid, so the two windows can never disagree. + +import ( + "strings" + + "hamlog/internal/applog" + "hamlog/internal/dxcc" + udp "hamlog/internal/integrations/udp" +) + +const keyWsjtHighlight = "udp.wsjt.highlight" + +// The palette. Fixed colours, not theme tokens — they are painted into another +// application's window, which has no idea what theme OpsLog wears. +var ( + hlWatchlist = udp.RGB{R: 244, G: 114, B: 182} // the watchlist pink + hlNewDXCC = udp.RGB{R: 22, G: 130, B: 60} // green + hlNewBand = udp.RGB{R: 226, G: 122, B: 24} // orange + hlWhite = udp.RGB{R: 255, G: 255, B: 255} + hlBlack = udp.RGB{R: 20, G: 20, B: 20} +) + +// GetWsjtHighlight reports whether decode highlighting is on. +func (a *App) GetWsjtHighlight() bool { + return a.settingOr(keyWsjtHighlight, "0") == "1" +} + +// SetWsjtHighlight turns decode highlighting on or off. Turning it OFF also +// clears every instruction OpsLog installed in the running applications — a +// disabled option that leaves stale colours behind looks broken, not disabled. +func (a *App) SetWsjtHighlight(on bool) { + v := "0" + if on { + v = "1" + } + a.setSetting(keyWsjtHighlight, v) + a.wsjtHighlightOn.Store(on) + if !on && a.udp != nil { + for _, inst := range a.udp.Instances() { + _ = a.udp.SendClearHighlights(inst) + } + a.wsjtHLMu.Lock() + a.wsjtHLSent = map[string]string{} + a.wsjtHLMu.Unlock() + applog.Printf("wsjt highlight: off — cleared in every instance") + } +} + +// maybeHighlightDecode paints one decoded callsign in the instance that heard +// it, when the option is on and the verdict is worth a colour. De-duplicated +// per instance+call+verdict: a station CQing all evening is decoded four times +// a minute, and the instruction only needs to be said once. +func (a *App) maybeHighlightDecode(instance, call, band string) { + if !a.wsjtHighlightOn.Load() || a.udp == nil || call == "" || instance == "" { + return + } + bg, fg, verdict := a.decodeHighlightVerdict(call, band) + key := instance + "|" + strings.ToUpper(call) + "|" + band + a.wsjtHLMu.Lock() + if a.wsjtHLSent == nil { + a.wsjtHLSent = map[string]string{} + } + if len(a.wsjtHLSent) > 4000 { // bounded; a long session just re-says a few + a.wsjtHLSent = map[string]string{} + } + prev, had := a.wsjtHLSent[key] + if had && prev == verdict { + a.wsjtHLMu.Unlock() + return + } + a.wsjtHLSent[key] = verdict + a.wsjtHLMu.Unlock() + if verdict == "" { + // Was highlighted under an earlier verdict and no longer deserves it + // (the operator just worked them): clear that one callsign. + if had && prev != "" { + _ = a.udp.SendHighlight(instance, call, nil, nil, false) + } + return + } + _ = a.udp.SendHighlight(instance, call, bg, fg, false) +} + +// decodeHighlightVerdict ranks a callsign: watchlist beats new-DXCC beats +// new-band; anything else is "no colour". The empty verdict doubles as the +// clear signal in maybeHighlightDecode. +func (a *App) decodeHighlightVerdict(call, band string) (bg, fg *udp.RGB, verdict string) { + if a.watchlist != nil { + if _, ok := a.watchlist.Match(call); ok { + c := hlWatchlist + f := hlBlack + return &c, &f, "watchlist" + } + } + c := a.clusterStatusMaps() + if a.dxcc != nil { + if m, ok := a.dxcc.Lookup(call); ok && m.Entity != nil { + num := dxcc.EntityDXCC(m.Entity.Name) + ent := c.entities[num] + if ent == nil { + bgc, fgc := hlNewDXCC, hlWhite + return &bgc, &fgc, "new-dxcc" + } + if band != "" { + if _, workedBand := ent.Bands[strings.ToLower(band)]; !workedBand { + bgc, fgc := hlNewBand, hlBlack + return &bgc, &fgc, "new-band" + } + } + } + } + return nil, nil, "" +} diff --git a/changelog.json b/changelog.json index f19397c..52c957b 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "Bulk operations work on any size of selection — setting a field, fixing frequencies, deleting, marking uploads and exporting the selection all failed with “too many SQL variables” past a few tens of thousands of QSOs. Statements are now issued in slices.", "Elecraft console: the power meter reads in real watts. The K3’s bargraph is relative to a range that flips at 12 W — calibrated against a real radio’s full table, the PC setting picks the range and the bar converts to watts.", - "Watchlist: a visual pass toward DXHunter’s look — pink callsigns, counter pills, quieter cards with a hover, the ⚡ back on the DXpedition badge." + "Watchlist: a visual pass toward DXHunter’s look — pink callsigns, counter pills, quieter cards with a hover, the ⚡ back on the DXpedition badge.", + "WSJT-X / JTDX: OpsLog can highlight decodes in the decoder’s own Band Activity window from your log — watchlist members pink, new DXCC green, new band orange (option in Settings → Connections). And a freshly-started decoder is asked to replay its on-screen decodes, so the FT decodes panel starts full." ], "fr": [ "Les opérations groupées fonctionnent quelle que soit la taille de la sélection — définir un champ, corriger des fréquences, supprimer, marquer les uploads et exporter la sélection échouaient avec « too many SQL variables » au-delà de quelques dizaines de milliers de QSO. Les requêtes sont désormais émises par tranches.", "Console Elecraft : le wattmètre lit en vrais watts. Le bargraph du K3 est relatif à une gamme qui bascule à 12 W — calibré sur la table complète d’une vraie radio, le réglage PC choisit la gamme et la barre se convertit en watts.", - "Watchlist : une passe visuelle vers le look DXHunter — indicatifs roses, compteurs en pastilles, cartes plus feutrées avec survol, le ⚡ de retour sur le badge DXpedition." + "Watchlist : une passe visuelle vers le look DXHunter — indicatifs roses, compteurs en pastilles, cartes plus feutrées avec survol, le ⚡ de retour sur le badge DXpedition.", + "WSJT-X / JTDX : OpsLog peut surligner les décodages dans la fenêtre Band Activity du décodeur selon votre log — watchlist en rose, nouveau DXCC en vert, nouvelle bande en orange (option dans Réglages → Connections). Et un décodeur fraîchement détecté rejoue ses décodages à l’écran, donc le panneau FT decodes démarre plein." ] }, { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6aef60c..2620ebb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2566,6 +2566,10 @@ export default function App() { for (const d of decodes) { const seenKey = `${d.call}|${d.ms ?? d.at}|${d.instance ?? ''}`; if (autoSeenRef.current.has(seenKey)) continue; + // A Replay's resent history is display-only: answering a line the far + // end already dropped would fail anyway, and doing it at startup — the + // moment replays arrive — would be a transmitter firing on old news. + if ((d as any).is_new === false) { autoSeenRef.current.add(seenKey); continue; } // Only decodes from the CURRENT period are worth answering: replying to a // slot that has closed asks the far end to match a decode it has dropped. if (now - Date.parse(d.at) > 30_000) { autoSeenRef.current.add(seenKey); continue; } diff --git a/frontend/src/components/UDPIntegrationsPanel.tsx b/frontend/src/components/UDPIntegrationsPanel.tsx index e2a0abc..5f02615 100644 --- a/frontend/src/components/UDPIntegrationsPanel.tsx +++ b/frontend/src/components/UDPIntegrationsPanel.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Plus, Trash2, Edit2, RefreshCcw, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'; import { ListUDPIntegrations, SaveUDPIntegration, DeleteUDPIntegration, ReloadUDPIntegrations, + GetWsjtHighlight, SetWsjtHighlight, } from '../../wailsjs/go/main/App'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -158,6 +159,8 @@ const TRIGGERS = [ type Props = { onError: (msg: string) => void }; export function UDPIntegrationsPanel({ onError }: Props) { + const [highlightOn, setHighlightOn] = useState(false); + useEffect(() => { GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {}); }, []); const { t } = useI18n(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); @@ -229,6 +232,16 @@ export function UDPIntegrationsPanel({ onError }: Props) { return (
+ {/* Log-aware colours in WSJT-X / JTDX's own window — lives HERE because + this panel is where the WSJT-X link is configured. */} +
} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index cac6fe7..2da4097 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -471,7 +471,7 @@ const en: Dict = { 'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'mic-pass order · ⬆⬇ to reorder · double-click → edit · "Log & end" to save', 'ncp.moveUp': 'Move up the mic-pass order', 'ncp.moveDown': 'Move down the mic-pass order', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET', 'udpp.relayInstead': 'For an antenna switch or a relay board, use Station Control → relays instead: it holds the state, reads the boards at startup and does not re-switch while you tune inside a band. A home-made switch is the “HTTP relay” type there.', 'udpp.svcCustomLabel': 'Custom message', 'udpp.svcCustomHint': 'You choose what fires it and what it says. A UDP datagram or an HTTP request — the latter is how most antenna switches are driven.', 'udpp.trigger': 'Fires on', 'udpp.trgBand': 'Band change (radio)', 'udpp.trgQso': 'QSO logged', 'udpp.trgRotator': 'Rotator command', 'udpp.trgLookup': 'Callsign lookup', 'udpp.transport': 'Sends as', 'udpp.transportUdp': 'UDP message', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Values are URL-encoded. Credentials may be included as http://user:pass@host/… — stored as typed, so keep it to your own network.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Line end', 'udpp.lineEndNone': 'None', 'udpp.fieldsAvailable': 'Fields for this trigger', 'udpp.fieldsHint': 'Anything else renders empty.', - 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcWsjtRelayLabel': 'Relay the WSJT-X stream', 'udpp.svcWsjtRelayHint': 'Re-sends every datagram received from WSJT-X / JTDX / MSHV, byte for byte, to another program — JTAlert, GridTracker, a second logger. The sender only talks to one address, so this is what lets them run alongside OpsLog. Point it at the OTHER program’s port, never at one of OpsLog’s own.', 'udpp.svcWsjtLogLabel': 'WSJT-X logged QSO', 'udpp.svcWsjtLogHint': 'Announces each logged QSO on the WSJT-X UDP interface — both messages WSJT-X itself sends. For any logger that listens there rather than for plain-text ADIF (Logger32’s additional UDP sockets, for one).', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'Connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', + 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcWsjtRelayLabel': 'Relay the WSJT-X stream', 'udpp.svcWsjtRelayHint': 'Re-sends every datagram received from WSJT-X / JTDX / MSHV, byte for byte, to another program — JTAlert, GridTracker, a second logger. The sender only talks to one address, so this is what lets them run alongside OpsLog. Point it at the OTHER program’s port, never at one of OpsLog’s own.', 'udpp.svcWsjtLogLabel': 'WSJT-X logged QSO', 'udpp.svcWsjtLogHint': 'Announces each logged QSO on the WSJT-X UDP interface — both messages WSJT-X itself sends. For any logger that listens there rather than for plain-text ADIF (Logger32’s additional UDP sockets, for one).', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'Connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.highlight': 'Highlight decodes in WSJT-X / JTDX', 'udpp.highlightHint': 'Colours callsigns in the decoder’s own Band Activity window from your log: watchlist members pink, a new DXCC green, a new band for its entity orange. Applied live as decodes arrive.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save', 'fltb.fCallsign': 'Callsign', 'fltb.fCreated': 'Added to the log on', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL sent via', 'fltb.fQslRcvdVia': 'QSL rcvd via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fHamlogSent': 'HAMLOG.online sent', 'fltb.fHamlogSentDate': 'HAMLOG.online sent date', 'fltb.fHamlogRcvd': 'HAMLOG.online received', 'fltb.fHamlogRcvdDate': 'HAMLOG.online received date', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opIn': 'is one of', 'fltb.opNotIn': 'is none of', 'fltb.listPh': '2m, 70cm — comma separated', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.presetSaved': 'Filter “{name}” saved', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close', 'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.newCounty': 'NEW', 'detp.newCountyTip': 'County never worked before', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Detected — this contact will count for:', 'detp.ambiguous': 'Ambiguous — pick one:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email', 'detp.contactedWeb': 'Website', // Awards (ref picker / ref selector / awards panel / award editor) @@ -976,7 +976,7 @@ const fr: Dict = { '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.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', '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': 'ordre de passage du micro · ⬆⬇ pour réordonner · double-clic → éditer · « Logger & terminer »', 'ncp.moveUp': "Monter dans l'ordre de passage", 'ncp.moveDown': "Descendre dans l'ordre de passage", 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', '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', 'udpp.relayInstead': 'Pour un commutateur d’antennes ou une carte de relais, préférez Station Control → relais : il tient l’état, relit les cartes au démarrage et ne recommute pas quand vous bougez dans la même bande. Un commutateur fait main s’y déclare en type « Relais HTTP ».', 'udpp.svcCustomLabel': 'Message personnalisé', 'udpp.svcCustomHint': 'Vous choisissez ce qui le déclenche et ce qu’il dit. Datagramme UDP ou requête HTTP — cette dernière est la façon dont se pilotent la plupart des commutateurs d’antennes.', 'udpp.trigger': 'Déclencheur', 'udpp.trgBand': 'Changement de bande (radio)', 'udpp.trgQso': 'QSO enregistré', 'udpp.trgRotator': 'Commande de rotor', 'udpp.trgLookup': 'Recherche d’indicatif', 'udpp.transport': 'Envoi', 'udpp.transportUdp': 'Message UDP', 'udpp.transportUrl': 'URL (HTTP GET)', 'udpp.url': 'URL', 'udpp.urlHint': 'Les valeurs sont encodées pour l’URL. Les identifiants peuvent s’écrire http://user:pass@hôte/… — stockés tels quels, à réserver à votre réseau local.', 'udpp.template': 'Message', 'udpp.lineEnd': 'Fin de ligne', 'udpp.lineEndNone': 'Aucune', 'udpp.fieldsAvailable': 'Champs de ce déclencheur', 'udpp.fieldsHint': 'Tout autre champ rendra du vide.', - 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcWsjtRelayLabel': 'Relayer le flux WSJT-X', 'udpp.svcWsjtRelayHint': 'Réémet chaque datagramme reçu de WSJT-X / JTDX / MSHV, octet pour octet, vers un autre logiciel — JTAlert, GridTracker, un second carnet. L’émetteur ne parle qu’à une seule adresse : c’est ce qui permet de les faire tourner à côté d’OpsLog. À pointer sur le port de l’AUTRE logiciel, jamais sur un port d’écoute d’OpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur l’interface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que l’ADIF en texte brut (les sockets UDP supplémentaires de Logger32, par exemple).', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', + 'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcWsjtRelayLabel': 'Relayer le flux WSJT-X', 'udpp.svcWsjtRelayHint': 'Réémet chaque datagramme reçu de WSJT-X / JTDX / MSHV, octet pour octet, vers un autre logiciel — JTAlert, GridTracker, un second carnet. L’émetteur ne parle qu’à une seule adresse : c’est ce qui permet de les faire tourner à côté d’OpsLog. À pointer sur le port de l’AUTRE logiciel, jamais sur un port d’écoute d’OpsLog.', 'udpp.svcWsjtLogLabel': 'QSO enregistré WSJT-X', 'udpp.svcWsjtLogHint': 'Annonce chaque QSO enregistré sur l’interface UDP WSJT-X — les deux messages que WSJT-X émet lui-même. Pour tout logger qui écoute là plutôt que l’ADIF en texte brut (les sockets UDP supplémentaires de Logger32, par exemple).', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.highlight': 'Surligner les décodages dans WSJT-X / JTDX', 'udpp.highlightHint': 'Colore les indicatifs dans la fenêtre Band Activity du décodeur selon votre log : watchlist en rose, nouveau DXCC en vert, nouvelle bande pour son entité en orange. Appliqué en direct à l’arrivée des décodages.', 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer', 'fltb.fCallsign': 'Callsign', 'fltb.fCreated': 'Ajouté au journal le', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'Paper QSL sent status', 'fltb.fQslSentDate': 'Paper QSL sent date', 'fltb.fQslRcvd': 'Paper QSL received status', 'fltb.fQslRcvdDate': 'Paper QSL received date', 'fltb.fQslSentVia': 'QSL envoyée via', 'fltb.fQslRcvdVia': 'QSL reçue via', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent status', 'fltb.fLotwSentDate': 'LoTW sent date', 'fltb.fLotwRcvd': 'LoTW received status', 'fltb.fLotwRcvdDate': 'LoTW received date', 'fltb.fEqslSent': 'eQSL sent status', 'fltb.fEqslSentDate': 'eQSL sent date', 'fltb.fEqslRcvd': 'eQSL received status', 'fltb.fEqslRcvdDate': 'eQSL received date', 'fltb.fQrzSent': 'QRZ.com sent status', 'fltb.fQrzSentDate': 'QRZ.com sent date', 'fltb.fQrzRcvd': 'QRZ.com received status', 'fltb.fQrzRcvdDate': 'QRZ.com received date', 'fltb.fClublogSent': 'Club Log sent status', 'fltb.fClublogSentDate': 'Club Log sent date', 'fltb.fHrdlogSent': 'HRDLog sent status', 'fltb.fHrdlogSentDate': 'HRDLog sent date', 'fltb.fHamlogSent': 'HAMLOG.online envoyé', 'fltb.fHamlogSentDate': "HAMLOG.online date d'envoi", 'fltb.fHamlogRcvd': 'HAMLOG.online reçu', 'fltb.fHamlogRcvdDate': 'HAMLOG.online date de réception', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Station callsign (my call)', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opIn': 'est parmi', 'fltb.opNotIn': 'n est pas parmi', 'fltb.listPh': '2m, 70cm — séparés par des virgules', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.presetSaved': 'Filtre « {name} » enregistré', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer', 'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.newCounty': 'NOUV', 'detp.newCountyTip': 'Comté jamais contacté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'CQ', 'detp.ituZone': 'ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via (manager)', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.ambiguous': 'Ambigu — choisissez :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact', 'detp.contactedWeb': 'Site web', 'awrp.remove': 'Retirer', 'awrp.searchLabel': 'Rechercher {label}…', 'awrp.searching': 'Recherche…', 'awrp.noMatch': 'Aucune correspondance.', 'awrp.noMatchDxcc': 'Aucune correspondance pour ce DXCC.', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 9eca5fa..06d264e 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -620,6 +620,8 @@ export function GetWinkeyerStatus():Promise; export function GetWorkedCallVariants():Promise; +export function GetWsjtHighlight():Promise; + export function GetYaesuBandAntennas():Promise>; export function GetYaesuState():Promise; @@ -1246,6 +1248,8 @@ export function SetWinkeyerTrace(arg1:boolean):Promise; export function SetWorkedCallVariants(arg1:boolean):Promise; +export function SetWsjtHighlight(arg1:boolean):Promise; + export function SetYaesuAFGain(arg1:number):Promise; export function SetYaesuAGC(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 46e9f88..0cd6490 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1178,6 +1178,10 @@ export function GetWorkedCallVariants() { return window['go']['main']['App']['GetWorkedCallVariants'](); } +export function GetWsjtHighlight() { + return window['go']['main']['App']['GetWsjtHighlight'](); +} + export function GetYaesuBandAntennas() { return window['go']['main']['App']['GetYaesuBandAntennas'](); } @@ -2430,6 +2434,10 @@ export function SetWorkedCallVariants(arg1) { return window['go']['main']['App']['SetWorkedCallVariants'](arg1); } +export function SetWsjtHighlight(arg1) { + return window['go']['main']['App']['SetWsjtHighlight'](arg1); +} + export function SetYaesuAFGain(arg1) { return window['go']['main']['App']['SetYaesuAFGain'](arg1); } diff --git a/internal/integrations/udp/server.go b/internal/integrations/udp/server.go index c41bd81..29a6df6 100644 --- a/internal/integrations/udp/server.go +++ b/internal/integrations/udp/server.go @@ -156,6 +156,8 @@ type Event struct { DecodeModeRaw string // DecodeMsgRaw is the message as sent, untrimmed — see DecodeModeRaw. DecodeMsgRaw string + // DecodeIsNew is false on the history a Replay resends: display-only lines. + DecodeIsNew bool // ProgramID is the sending application's own id ("WSJT-X", "MSHV", or // "WSJT-X - 2" for a second instance started with --rig-name). It is what // tells two receivers apart on one multicast group — and it is the address a @@ -212,6 +214,9 @@ type Server struct { // lastFrom is the address each program's packets arrive from — where a Reply // has to be sent. See SendReply. lastFrom map[string]*net.UDPAddr + // onNewInstance fires (off the read loop) the first time a program id is + // heard on this listener — the hook the startup replay hangs from. + onNewInstance func(programID string) // instLabel names each running application, keyed by id AND sending address. // // WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV @@ -285,11 +290,12 @@ func describePacket(pkt []byte) string { func newServer(cfg Config, out chan<- Event, mgr *Manager) *Server { return &Server{ - cfg: cfg, - out: out, - mgr: mgr, - stop: make(chan struct{}), - done: make(chan struct{}), + cfg: cfg, + out: out, + mgr: mgr, + onNewInstance: mgr.onNewInstance, + stop: make(chan struct{}), + done: make(chan struct{}), } } @@ -515,13 +521,23 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { // must go to the sender's own address, never to the group. s.mu.Lock() inst := s.instanceLabel(w.ProgramID, remote) + newInstance := false if inst != "" && remote != nil { if s.lastFrom == nil { s.lastFrom = map[string]*net.UDPAddr{} } + if _, known := s.lastFrom[inst]; !known { + newInstance = true + } s.lastFrom[inst] = remote } + onNew := s.onNewInstance s.mu.Unlock() + // A program just heard for the first time this session: tell the app, so + // it can ask for a replay of the decodes already on that program's screen. + if newInstance && onNew != nil { + go onNew(inst) + } // Status carries the current dial frequency; remember it so Decode audio // offsets can be turned into RF frequencies for the panadapter. if w.FreqHz > 0 && !w.IsDecode { @@ -580,6 +596,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { ev.DecodeModeRaw = w.Mode ev.DecodeMsg = w.DecodeMsg ev.DecodeMsgRaw = w.DecodeMsgRaw + ev.DecodeIsNew = w.DecodeIsNew ev.DecodeAt = decodeTime(w.DecodeMsSinceMidnight) ev.DecodeTRPeriod = tr ev.DecodeDial = dial @@ -803,6 +820,10 @@ type Manager struct { repo *Repo out chan Event + // onNewInstance is copied onto every inbound listener as it starts; see + // Server.onNewInstance. + onNewInstance func(programID string) + // noADIFOnce keeps the "nothing to forward to" note to one line a session // rather than one per QSO logged. noADIFOnce sync.Once @@ -940,3 +961,11 @@ func (m *Manager) StopAll() { s.close() } } + +// SetOnNewInstance installs the first-sighting hook. Call before Reload so +// listeners are born with it. +func (m *Manager) SetOnNewInstance(fn func(programID string)) { + m.mu.Lock() + m.onNewInstance = fn + m.mu.Unlock() +} diff --git a/internal/integrations/udp/wsjthighlight.go b/internal/integrations/udp/wsjthighlight.go new file mode 100644 index 0000000..e83ff15 --- /dev/null +++ b/internal/integrations/udp/wsjthighlight.go @@ -0,0 +1,146 @@ +package udp + +import ( + "bytes" + "encoding/binary" + "fmt" + "strings" + + "hamlog/internal/applog" +) + +// WSJT-X Highlight Callsign (13) and Replay (7) — the two halves of making the +// Band Activity window log-aware. +// +// Highlight paints a callsign in the decoding application's own window with the +// colours OpsLog chooses — new DXCC, new band, a watchlist member — the way +// JTAlert does. Replay asks a freshly-discovered instance to resend the decodes +// it already has on screen, so the FT decodes panel starts full instead of +// empty until the next period. + +const ( + wsjtMsgReplay = 7 + wsjtMsgHighlight = 13 +) + +// RGB is one highlight colour. A nil *RGB means "invalid QColor", which is the +// protocol's way of saying "remove the highlight". +type RGB struct{ R, G, B uint8 } + +// writeQColor serializes a QColor as QDataStream does: a spec byte (1 = RGB, +// 0 = invalid) followed by five 16-bit channels (alpha, red, green, blue, pad), +// each 8-bit value doubled into 16 bits the way Qt stores them. +func writeQColor(b *bytes.Buffer, c *RGB) { + if c == nil { + b.WriteByte(0) // invalid — clears the highlight + for i := 0; i < 5; i++ { + _ = binary.Write(b, binary.BigEndian, uint16(0)) + } + return + } + b.WriteByte(1) // spec = RGB + wide := func(v uint8) uint16 { return uint16(v) * 0x101 } + _ = binary.Write(b, binary.BigEndian, uint16(0xFFFF)) // alpha, opaque + _ = binary.Write(b, binary.BigEndian, wide(c.R)) + _ = binary.Write(b, binary.BigEndian, wide(c.G)) + _ = binary.Write(b, binary.BigEndian, wide(c.B)) + _ = binary.Write(b, binary.BigEndian, uint16(0)) // pad +} + +// EncodeHighlight builds a Highlight Callsign datagram. bg/fg nil = invalid +// colour; both nil clears the callsign's highlight. +func EncodeHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) []byte { + var b bytes.Buffer + _ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic)) + _ = binary.Write(&b, binary.BigEndian, uint32(2)) + _ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgHighlight)) + writeQString(&b, programID) + writeQString(&b, callsign) + writeQColor(&b, bg) + writeQColor(&b, fg) + var last uint8 + if lastPeriodOnly { + last = 1 + } + _ = binary.Write(&b, binary.BigEndian, last) + return b.Bytes() +} + +// EncodeReplay builds a Replay datagram — "resend what your window holds". +func EncodeReplay(programID string) []byte { + var b bytes.Buffer + _ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic)) + _ = binary.Write(&b, binary.BigEndian, uint32(2)) + _ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgReplay)) + writeQString(&b, programID) + return b.Bytes() +} + +// sendToInstance routes a raw datagram to the application that owns programID, +// the same way SendReply does: to the address its packets actually arrive from. +func (m *Manager) sendToInstance(programID string, pkt []byte, what string) error { + if strings.TrimSpace(programID) == "" { + return fmt.Errorf("no application id") + } + m.mu.Lock() + servers := make([]*Server, 0, len(m.inbound)) + for _, s := range m.inbound { + servers = append(servers, s) + } + m.mu.Unlock() + for _, s := range servers { + conn, addr := s.replyTarget(programID) + if conn == nil || addr == nil { + continue + } + if _, err := conn.WriteToUDP(pkt, addr); err != nil { + return fmt.Errorf("send %s to %s at %s: %w", what, programID, addr, err) + } + return nil + } + return fmt.Errorf("no packet has arrived from %q yet", programID) +} + +// SendHighlight paints (or clears) one callsign in the given instance. +func (m *Manager) SendHighlight(programID, callsign string, bg, fg *RGB, lastPeriodOnly bool) error { + return m.sendToInstance(programID, EncodeHighlight(programID, callsign, bg, fg, lastPeriodOnly), "highlight") +} + +// SendClearHighlights removes every highlighting instruction OpsLog installed +// in the instance. "CLEARALL!" is the protocol's own magic callsign for it. +func (m *Manager) SendClearHighlights(programID string) error { + return m.sendToInstance(programID, EncodeHighlight(programID, "CLEARALL!", nil, nil, false), "clear-highlights") +} + +// SendReplay asks the instance to resend its on-screen decodes. +func (m *Manager) SendReplay(programID string) error { + err := m.sendToInstance(programID, EncodeReplay(programID), "replay") + if err == nil { + applog.Printf("udp: replay requested from %q — its existing decodes will arrive marked not-new", programID) + } + return err +} + +// Instances lists every program id a packet has arrived from, for "clear the +// highlights everywhere" and the startup replay. +func (m *Manager) Instances() []string { + m.mu.Lock() + servers := make([]*Server, 0, len(m.inbound)) + for _, s := range m.inbound { + servers = append(servers, s) + } + m.mu.Unlock() + seen := map[string]struct{}{} + var out []string + for _, s := range servers { + s.mu.Lock() + for id := range s.lastFrom { + if _, dup := seen[id]; !dup { + seen[id] = struct{}{} + out = append(out, id) + } + } + s.mu.Unlock() + } + return out +}