Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e00488fad8 | ||
|
|
12c61dc35a | ||
|
|
0c0e8b06ba | ||
|
|
1bd3896ca7 | ||
|
|
7e6c0b4f7e | ||
|
|
2cde1a2c27 | ||
|
|
53100eb6c8 | ||
|
|
3b555219d2 | ||
|
|
ba35d4094c | ||
|
|
9bd6d988aa | ||
|
|
daabbc63c7 | ||
|
|
997bc81d5e | ||
|
|
3c59507bc3 | ||
|
|
721c43d569 | ||
|
|
25eda98612 | ||
|
|
0b909a4d63 | ||
|
|
37298afd77 |
@@ -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
|
||||
@@ -10228,7 +10239,7 @@ func titleEntity(s string) string {
|
||||
// the configured "Recording mic", transmit via "To Radio", preview via
|
||||
// "Listening".
|
||||
|
||||
const dvkSlots = 6
|
||||
const dvkSlots = 12
|
||||
|
||||
// DVKMessage is one voice-keyer slot for the UI.
|
||||
type DVKMessage struct {
|
||||
@@ -10289,6 +10300,22 @@ func (a *App) GetDVKMessages() []DVKMessage {
|
||||
return out
|
||||
}
|
||||
|
||||
// DVKDelete removes a slot's recording AND its label — a deleted message is a
|
||||
// free slot, per its operator.
|
||||
func (a *App) DVKDelete(slot int) error {
|
||||
if slot < 1 || slot > dvkSlots {
|
||||
return fmt.Errorf("bad slot")
|
||||
}
|
||||
if err := os.Remove(a.dvkPath(slot)); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("delete message %d: %w", slot, err)
|
||||
}
|
||||
if a.settings != nil {
|
||||
_ = a.settings.Set(a.ctx, dvkLabelKey(slot), "")
|
||||
}
|
||||
applog.Printf("dvk: message F%d deleted (label cleared)", slot)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDVKLabel renames a voice-keyer slot.
|
||||
func (a *App) SetDVKLabel(slot int, label string) error {
|
||||
if a.settings == nil {
|
||||
@@ -13905,7 +13932,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.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
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"
|
||||
keyWsjtFollowMode = "udp.wsjt.followmode" // spot clicks switch the decoder's mode
|
||||
)
|
||||
|
||||
// wsjtModes are the modes a Configure message can meaningfully ask for — the
|
||||
// decoder's own vocabulary. Anything else (CW, SSB, RTTY) is none of its
|
||||
// business and is not sent.
|
||||
var wsjtModes = map[string]bool{
|
||||
"FT8": true, "FT4": true, "JT65": true, "JT9": true,
|
||||
"MSK144": true, "Q65": true, "FST4": true, "JS8": false, // JS8Call speaks another protocol
|
||||
}
|
||||
|
||||
// GetWsjtFollowMode reports whether spot clicks retune the decoder's mode.
|
||||
func (a *App) GetWsjtFollowMode() bool {
|
||||
return a.settingOr(keyWsjtFollowMode, "1") == "1"
|
||||
}
|
||||
|
||||
// SetWsjtFollowMode flips it.
|
||||
func (a *App) SetWsjtFollowMode(on bool) {
|
||||
v := "0"
|
||||
if on {
|
||||
v = "1"
|
||||
}
|
||||
a.setSetting(keyWsjtFollowMode, v)
|
||||
}
|
||||
|
||||
// ConfigureDecoderMode asks the connected decoders to switch mode — called by
|
||||
// the frontend after a spot click has tuned the radio. A no-op for modes the
|
||||
// decoder does not speak, and when the option is off or nothing is connected.
|
||||
func (a *App) ConfigureDecoderMode(mode string) {
|
||||
mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
if a.udp == nil || !wsjtModes[mode] || !a.GetWsjtFollowMode() {
|
||||
return
|
||||
}
|
||||
a.udp.SendConfigureMode(mode)
|
||||
}
|
||||
|
||||
// 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, ""
|
||||
}
|
||||
@@ -1,4 +1,44 @@
|
||||
[
|
||||
{
|
||||
"version": "0.27.3",
|
||||
"date": "",
|
||||
"en": [
|
||||
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500’s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500’s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list.",
|
||||
"FT decodes: within a period, decodes are listed in arrival order — mirroring the decoder’s own window — instead of strongest-first.",
|
||||
"Voice keyer: twelve message slots (F1–F12) instead of six.",
|
||||
"Preferences open smoothly on a busy station: while the dialog is open, cluster spots, FT decodes and CAT snapshots queue quietly instead of repainting the whole window behind it — everything catches up the moment it closes.",
|
||||
"Voice keyer: a delete button per message — removes the recording and clears the label."
|
||||
],
|
||||
"fr": [
|
||||
"KPA500 : l’ampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont l’interrupteur du KPA500. Les lignes sont désormais tenues stables, le silence n’est plus traité comme un lien mort, et le baud se choisit dans une liste.",
|
||||
"FT decodes : dans une période, les décodages sont listés dans l’ordre d’arrivée — comme la fenêtre du décodeur — au lieu du plus fort d’abord.",
|
||||
"Manipulateur vocal : douze messages (F1–F12) au lieu de six.",
|
||||
"Les Préférences restent fluides sur une station chargée : dialogue ouvert, les spots cluster, les décodages FT et les instantanés CAT patientent en file au lieu de repeindre toute la fenêtre derrière — tout se rattrape à la fermeture.",
|
||||
"Manipulateur vocal : un bouton supprimer par message — efface l’enregistrement et le libellé."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.2",
|
||||
"date": "",
|
||||
"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.",
|
||||
"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.",
|
||||
"WSJT-X / JTDX / MSHV: only a CHANGED DX Call updates the entry — the decoder re-broadcasts the same call endlessly, and it kept overwriting a spot clicked in OpsLog.",
|
||||
"Map: Zoom DX toward a polar entity no longer frames a band of blank white above the top of the world — the camera stays within the map’s ±85°, the path still draws.",
|
||||
"WSJT-X / JTDX / MSHV: clicking a spot in a digital mode the decoder speaks (FT8, FT4, JT65…) switches the decoder’s mode too — option in Settings → Connections, on by default."
|
||||
],
|
||||
"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.",
|
||||
"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.",
|
||||
"WSJT-X / JTDX / MSHV : seul un DX Call qui CHANGE met à jour la saisie — le décodeur rediffuse le même call sans fin, et il écrasait un spot cliqué dans OpsLog.",
|
||||
"Carte : Zoom DX vers une entité polaire ne cadre plus une bande blanche au-dessus du haut du monde — la caméra reste dans les ±85° de la carte, le trajet se dessine toujours.",
|
||||
"WSJT-X / JTDX / MSHV : cliquer un spot dans un mode numérique que le décodeur parle (FT8, FT4, JT65…) change aussi le mode du décodeur — option dans Réglages → Connections, activée par défaut."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.27.1",
|
||||
"date": "",
|
||||
|
||||
+60
-2
@@ -33,7 +33,7 @@ import {
|
||||
GetSolarData,
|
||||
GetQSORate,
|
||||
LoTWUserInfo,
|
||||
OperatingDefaultForBand, ActiveRadioMyRig,
|
||||
OperatingDefaultForBand, ActiveRadioMyRig, ConfigureDecoderMode,
|
||||
LogUDPLoggedADIF,
|
||||
ListCountries,
|
||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
|
||||
@@ -2228,6 +2228,15 @@ export default function App() {
|
||||
const [bulkEditIds, setBulkEditIds] = useState<number[]>([]);
|
||||
const [bulkEditOpen, setBulkEditOpen] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// While the Settings dialog is open, the spot/decode flushes and the CAT
|
||||
// snapshot stream are PAUSED (data keeps accumulating in the pending refs).
|
||||
// Every flush re-renders the whole App tree behind the dialog — cluster
|
||||
// grid, decodes panel, thousands of nodes — and with a busy cluster plus
|
||||
// two decoders the pointer visibly stuttered over the preferences.
|
||||
const showSettingsRef = useRef(false);
|
||||
useEffect(() => { showSettingsRef.current = showSettings; }, [showSettings]);
|
||||
const flushSpotsRef = useRef<() => void>(() => {});
|
||||
const flushDecodesRef = useRef<() => void>(() => {});
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
useEffect(() => { if (!showSettings) setRotorCompact(localStorage.getItem('opslog.rotorCompact') === '1'); }, [showSettings]);
|
||||
@@ -2566,6 +2575,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; }
|
||||
@@ -2663,6 +2676,11 @@ export default function App() {
|
||||
// "the field still shows the previous broadcast" (safe to update) from "the
|
||||
// user has typed a different call" (must not clobber).
|
||||
const lastUdpCallRef = useRef('');
|
||||
// Edge detection for the DECODER'S stream: WSJT-X/JTDX/MSHV re-broadcast the
|
||||
// same DX Call in every Status packet, seconds apart, forever. Applying each
|
||||
// one meant a spot clicked in OpsLog was overwritten moments later by the
|
||||
// decoder restating old news. Only a CHANGE in this stream is an event.
|
||||
const lastWsjtEdgeRef = useRef('');
|
||||
|
||||
// When the entered callsign turns out to be worked-before, jump to the
|
||||
// Worked-before tab so the history is front-and-centre. Only once per call,
|
||||
@@ -3338,6 +3356,10 @@ export default function App() {
|
||||
void tuneRigCAT(s.freq_hz, m).then(() => window.setTimeout(zoom, 300));
|
||||
} else zoom();
|
||||
if (m) applyModeFromSpot(m);
|
||||
// And the DECODER follows too: an FT4 spot clicked while WSJT-X sits in
|
||||
// FT8 switches its mode (Configure, message 15). The backend filters —
|
||||
// only modes the decoder speaks, only when the option is on.
|
||||
if (m) ConfigureDecoderMode(m).catch(() => {});
|
||||
onCallsignInput(s.dx_call, { force: true });
|
||||
applySpotRefs((s as any).pota_ref, (s as any).sota_ref);
|
||||
if (s.dx_call?.trim()) restartRecordingForNewTarget(s.dx_call);
|
||||
@@ -3422,7 +3444,15 @@ export default function App() {
|
||||
// Apply a CAT snapshot to the entry strip (freq/band/mode), unless the user
|
||||
// just typed something (freeze window) or locked a field. Shared by the live
|
||||
// cat:state event and the startup poll below.
|
||||
const lastCatWhileSettingsRef = useRef(0);
|
||||
function applyCatState(s: CATState) {
|
||||
// Behind the Settings dialog nobody reads a frequency four times a second;
|
||||
// each snapshot re-renders the whole App tree under the pointer.
|
||||
if (showSettingsRef.current) {
|
||||
const now = Date.now();
|
||||
if (now - lastCatWhileSettingsRef.current < 2000) return;
|
||||
lastCatWhileSettingsRef.current = now;
|
||||
}
|
||||
setCatState(s);
|
||||
if (!s?.connected) return;
|
||||
// A snapshot arriving during the freeze used to be DROPPED, and that lost the
|
||||
@@ -3567,11 +3597,19 @@ export default function App() {
|
||||
// Commit the staged spots: resolve the status for any slot we don't know yet
|
||||
// FIRST, then insert the rows — so they appear with the right badge already
|
||||
// painted instead of flashing plain text then flipping to a pill.
|
||||
// eslint-disable-next-line prefer-const
|
||||
const flushPendingSpots = async () => {
|
||||
pendingSpotTimer.current = undefined;
|
||||
// Settings open: leave everything queued (bounded) and repaint nothing.
|
||||
if (showSettingsRef.current) {
|
||||
const cap = spotsCapRef.current;
|
||||
if (pendingSpotsRef.current.length > cap) pendingSpotsRef.current = pendingSpotsRef.current.slice(-cap);
|
||||
return;
|
||||
}
|
||||
const batch = pendingSpotsRef.current;
|
||||
pendingSpotsRef.current = [];
|
||||
if (batch.length === 0) return;
|
||||
// (registered below so closing Settings can drain the queue)
|
||||
// Resolve unknown statuses before the rows go in.
|
||||
try {
|
||||
const known = spotStatusRef.current;
|
||||
@@ -3627,6 +3665,7 @@ export default function App() {
|
||||
return next.length > cap ? next.slice(0, cap) : next;
|
||||
});
|
||||
};
|
||||
flushSpotsRef.current = () => { void flushPendingSpots(); };
|
||||
const unsubSpot = EventsOn('cluster:spot', (sp: ClusterSpot) => {
|
||||
// Stage the spot; a short timer resolves its status then commits it.
|
||||
pendingSpotsRef.current.push(sp);
|
||||
@@ -3674,6 +3713,10 @@ export default function App() {
|
||||
// decodes panel and plain worked in the cluster list two seconds later.
|
||||
const flushDecodes = async () => {
|
||||
pendingDecodeTimer.current = undefined;
|
||||
if (showSettingsRef.current) {
|
||||
if (pendingDecodesRef.current.length > 3000) pendingDecodesRef.current = pendingDecodesRef.current.slice(-3000);
|
||||
return;
|
||||
}
|
||||
const batch = pendingDecodesRef.current;
|
||||
pendingDecodesRef.current = [];
|
||||
if (batch.length === 0) return;
|
||||
@@ -3716,6 +3759,7 @@ export default function App() {
|
||||
return next;
|
||||
});
|
||||
};
|
||||
flushDecodesRef.current = () => { void flushDecodes(); };
|
||||
|
||||
const unsubDecode = EventsOn('udp:decode', (d: DecodeRow) => {
|
||||
pendingDecodesRef.current.push(d);
|
||||
@@ -3792,6 +3836,13 @@ export default function App() {
|
||||
// Anything that isn't WSJT-X (N1MM, ADIF, a panadapter/cluster click relayed
|
||||
// over UDP…) is an explicit pick → force it over an existing call.
|
||||
const force = String(p?.service ?? '').toLowerCase() !== 'wsjt';
|
||||
if (!force) {
|
||||
// The decoder's stream: same value as last time = no edge = no update.
|
||||
// Only a changed DX Call is the operator doing something over there.
|
||||
const upper = String(p?.call ?? '').trim().toUpperCase();
|
||||
if (upper && upper === lastWsjtEdgeRef.current) return;
|
||||
lastWsjtEdgeRef.current = upper;
|
||||
}
|
||||
// External app moved to a new station → fresh recording for the new target.
|
||||
if (applyUdpCall(p?.call, force)) restartRecordingForNewTarget(String(p?.call ?? ''));
|
||||
});
|
||||
@@ -3802,6 +3853,9 @@ export default function App() {
|
||||
// Only when something is actually in the entry, so an idle digital app doesn't
|
||||
// wipe a call being typed by hand.
|
||||
const unsubClear = EventsOn('udp:clear_call', () => {
|
||||
// The decoder cleared its DX Call: the next call it announces — even the
|
||||
// same one re-selected — is a fresh edge.
|
||||
lastWsjtEdgeRef.current = '';
|
||||
if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
|
||||
});
|
||||
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
||||
@@ -4352,6 +4406,10 @@ export default function App() {
|
||||
// The stable wrapper the dialog actually receives.
|
||||
const openQSOFromSettings = useCallback((id: number) => openEditRef.current(id), []);
|
||||
const closeSettings = useCallback(() => {
|
||||
// Synchronously: the ref effect runs after the next render, and flushing
|
||||
// through a still-true ref would hit the pause gate again.
|
||||
showSettingsRef.current = false;
|
||||
window.setTimeout(() => { flushSpotsRef.current(); flushDecodesRef.current(); }, 50);
|
||||
setShowSettings(false);
|
||||
setSettingsSection(undefined);
|
||||
refreshChaseNew();
|
||||
@@ -5180,7 +5238,7 @@ export default function App() {
|
||||
if (dvkActiveRef.current) {
|
||||
// Voice keyer: plain F1..F6 transmit the message; Ctrl+F1..F5 → tabs.
|
||||
if (mod && n <= 5) { e.preventDefault(); setDetailTab(TABS[n - 1]); return; }
|
||||
if (plain && n <= 6) { e.preventDefault(); dvkPlayRef.current(n); return; }
|
||||
if (plain && n <= 12) { e.preventDefault(); dvkPlayRef.current(n); return; }
|
||||
return;
|
||||
}
|
||||
// No keyer: plain F1..F5 switch the detail tab (labels read "F1…").
|
||||
|
||||
@@ -498,10 +498,11 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
|
||||
// the same way it was grouped.
|
||||
tr: trSeconds(g.decodes[0]?.mode ?? g.tx[0]?.mode, g.decodes[0]?.tr_period),
|
||||
tx: g.tx,
|
||||
// Strongest first inside a period: the eye should land on what is
|
||||
// workable, and time within a slot means nothing — they were all
|
||||
// transmitting simultaneously.
|
||||
decodes: g.decodes.sort((x, y) => y.snr - x.snr),
|
||||
// ARRIVAL order inside a period, per the operator: it mirrors the
|
||||
// decoder's own window line for line, which makes the two screens
|
||||
// comparable at a glance — the strongest-first sort scrambled that
|
||||
// correspondence, and SNR is right there in its column anyway.
|
||||
decodes: g.decodes,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = {
|
||||
phoneOk: boolean; // false when the rig is on a non-phone mode → DVK TX blocked
|
||||
};
|
||||
|
||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F6
|
||||
// Operating panel for the Digital Voice Keyer — transmits the recorded F1–F12
|
||||
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
|
||||
// the reserved area. Recording/labeling lives in Settings → Audio.
|
||||
export function DvkPanel({ messages, status, onPlay, onStop, onClose, autoCq, autoCqSecs, onToggleAutoCq, onSetAutoCqSecs, phoneOk }: Props) {
|
||||
|
||||
@@ -18,7 +18,7 @@ type KenwoodState = {
|
||||
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
||||
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
||||
s_meter: number; s_meter_raw: number;
|
||||
power_meter: number; swr: number; swr_raw: number;
|
||||
power_meter: number; power_w?: number; swr: number; swr_raw: number;
|
||||
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
|
||||
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
||||
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
|
||||
@@ -222,7 +222,8 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
|
||||
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
||||
}}
|
||||
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
||||
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9" />
|
||||
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9"
|
||||
display={view.transmitting && view.elecraft ? `${view.power_w ?? 0} W` : undefined} />
|
||||
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
||||
a match that looks ideal on an antenna nobody has measured is the one
|
||||
reading that can cost a radio. */}
|
||||
|
||||
@@ -370,8 +370,14 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
|
||||
if (autoZoom) {
|
||||
if (from && to && arcPts) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
arcPts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
// Latitudes clamped to Mercator's edge (±85°): the arc to a polar
|
||||
// entity (Franz Josef Land) peaks near 88°N, and fitting the raw
|
||||
// points framed a band of tile-less white above the top of the world.
|
||||
// The line itself still draws to wherever it goes — only the CAMERA
|
||||
// stays where there is a map to show.
|
||||
const clamp = (lat: number) => Math.max(-85, Math.min(85, lat));
|
||||
const bounds = L.latLngBounds([[clamp(from.lat), from.lon], [clamp(to.lat), to.lon]]);
|
||||
arcPts.forEach((p) => bounds.extend([clamp(p[0]), p[1]] as L.LatLngExpression));
|
||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||
} else if (to) {
|
||||
wm.setView([to.lat, to.lon], 3);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import {
|
||||
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
|
||||
GetListsSettings, SaveListsSettings,
|
||||
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios,
|
||||
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
|
||||
GetAudioMonitorPref,
|
||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
|
||||
@@ -4416,8 +4416,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Baud</Label>
|
||||
<Input type="number" min={1200} value={amp.baud}
|
||||
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
|
||||
{/* A list, not a free number: the KPA500 report that
|
||||
began this had its operator wondering whether a typed
|
||||
baud was the whole problem. These are the rates the
|
||||
supported amplifiers actually speak. */}
|
||||
<select value={String(amp.baud)}
|
||||
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) })}
|
||||
className="h-9 w-full px-2 rounded-md border border-border bg-background text-sm font-mono">
|
||||
{[4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
|
||||
<option key={b} value={String(b)}>{b}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -5716,10 +5725,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
function UDPIntegrationsPanelWrapper() {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title={t('sec.udp')}
|
||||
hint={t('udp.hint')}
|
||||
/>
|
||||
<SectionHeader title={t('sec.udp')} />
|
||||
<UDPIntegrationsPanel onError={(m) => setErr(m)} />
|
||||
</>
|
||||
);
|
||||
@@ -7172,6 +7178,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
>
|
||||
{dvkStat.playing ? t('aud.stop') : t('aud.play')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline" size="sm" className="h-8 w-9 shrink-0 px-0 text-danger hover:text-danger"
|
||||
title={t('aud.deleteMsg')}
|
||||
disabled={!m.has_audio || dvkStat.recording || dvkStat.playing}
|
||||
onClick={() => DVKDelete(m.slot).then(reloadDvk).catch((err) => setDvkErr(String(err?.message ?? err)))}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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, GetWsjtFollowMode, SetWsjtFollowMode,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -158,6 +159,12 @@ const TRIGGERS = [
|
||||
type Props = { onError: (msg: string) => void };
|
||||
|
||||
export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
const [highlightOn, setHighlightOn] = useState(false);
|
||||
const [followMode, setFollowMode] = useState(true);
|
||||
useEffect(() => {
|
||||
GetWsjtHighlight().then((v) => setHighlightOn(!!v)).catch(() => {});
|
||||
GetWsjtFollowMode().then((v) => setFollowMode(!!v)).catch(() => {});
|
||||
}, []);
|
||||
const { t } = useI18n();
|
||||
const [items, setItems] = useState<UDPConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -229,10 +236,24 @@ export function UDPIntegrationsPanel({ onError }: Props) {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-[11px] text-muted-foreground max-w-2xl leading-relaxed">
|
||||
{t('udpp.intro')}
|
||||
</div>
|
||||
|
||||
{/* Log-aware colours in WSJT-X / JTDX's own window — lives HERE because
|
||||
this panel is where the WSJT-X link is configured. */}
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||
<Checkbox checked={highlightOn}
|
||||
onCheckedChange={(c) => { setHighlightOn(!!c); void SetWsjtHighlight(!!c); }} />
|
||||
<span>
|
||||
{t('udpp.highlight')}
|
||||
<span className="block text-[11px] text-muted-foreground">{t('udpp.highlightHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm cursor-pointer max-w-2xl">
|
||||
<Checkbox checked={followMode}
|
||||
onCheckedChange={(c) => { setFollowMode(!!c); void SetWsjtFollowMode(!!c); }} />
|
||||
<span>
|
||||
{t('udpp.followMode')}
|
||||
<span className="block text-[11px] text-muted-foreground">{t('udpp.followModeHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
<Section
|
||||
title={t('udpp.inboundTitle')}
|
||||
icon={<ArrowDownToLine className="size-4" />}
|
||||
|
||||
@@ -247,15 +247,18 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
<div className="flex flex-col min-h-0 flex-1 gap-2 p-2 w-full max-w-5xl mx-auto">
|
||||
{/* Header: the counters alone, centred — they are the tab's headline.
|
||||
Everything one INTERACTS with lives on the second row. */}
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center justify-center gap-2.5 text-xs text-muted-foreground">
|
||||
<Eye className="size-4 text-primary shrink-0" />
|
||||
<span>
|
||||
{t('wl.cTotal')} <b className="text-foreground">{counters.total}</b>
|
||||
<span className="mx-1.5 opacity-50">|</span>
|
||||
{t('wl.cActive')} <b className="text-info">{counters.active}</b>
|
||||
<span className="mx-1.5 opacity-50">|</span>
|
||||
{t('wl.cNeeded')} <b className="text-warning">{counters.needed}</b>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">{t('wl.cTotal')}
|
||||
<b className="px-2 py-0.5 rounded bg-muted text-foreground">{counters.total}</b></span>
|
||||
<span className="opacity-40">|</span>
|
||||
<span className="flex items-center gap-1.5">{t('wl.cActive')}
|
||||
<b className="px-2 py-0.5 rounded text-info border border-info/30 bg-info/10">{counters.active}</b></span>
|
||||
<span className="opacity-40">|</span>
|
||||
<span className="flex items-center gap-1.5">{t('wl.cNeeded')}
|
||||
<b className={cn('px-2 py-0.5 rounded border', counters.needed > 0
|
||||
? 'text-warning border-warning/40 bg-warning/10'
|
||||
: 'text-muted-foreground border-border bg-muted/40')}>{counters.needed}</b></span>
|
||||
</div>
|
||||
{/* toolbar */}
|
||||
<div className="flex items-start justify-between gap-x-3 gap-y-1.5 flex-wrap">
|
||||
@@ -328,11 +331,11 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
const list = neededOnly ? all.filter((s) => !workedFor(e, s)) : all;
|
||||
return (
|
||||
<div key={e.callsign}
|
||||
className={cn('rounded-lg border bg-card p-3',
|
||||
needed > 0 ? 'border-warning/50' : 'border-border',
|
||||
className={cn('rounded-lg border bg-card/70 p-3 transition-colors hover:bg-accent/20',
|
||||
needed > 0 ? 'border-warning/40' : 'border-border/70',
|
||||
e.isContest && 'border-l-4 border-l-warning')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-lg font-bold font-mono text-primary">{e.callsign}</span>
|
||||
<span className="text-lg font-bold font-mono" style={{ color: '#f472b6' }}>{e.callsign}</span>
|
||||
{isOnAir(e) && chip('var(--danger)', t('wl.onAir'), 'animate-pulse')}
|
||||
{e.isContest && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold bg-warning-muted text-warning-muted-foreground border border-warning-border"
|
||||
@@ -340,7 +343,7 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
<Trophy className="size-3" /> {t('wl.contest')}
|
||||
</span>
|
||||
)}
|
||||
{e.isExpedition && chip('var(--chart-5)', t('wl.expedition'))}
|
||||
{e.isExpedition && chip('var(--chart-5)', '⚡ ' + t('wl.expedition'))}
|
||||
{e.clubLogTotalQSOs > 0 && <span className="text-[11px] text-muted-foreground">{e.clubLogTotalQSOs.toLocaleString()} QSOs{e.clubLogQSOs24h > 0 ? ` · ${e.clubLogQSOs24h}/24h` : ''}</span>}
|
||||
{e.clubLogHasOQRS && chip('var(--success)', 'OQRS')}
|
||||
{e.clubLogLiveStream && (
|
||||
@@ -385,8 +388,8 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
|
||||
onClick={() => onSpotSelect?.(s)}
|
||||
onDoubleClick={() => onSpotClick?.(s)}
|
||||
title={t('wl.spotTip')}
|
||||
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/40 hover:bg-muted text-left',
|
||||
!done && 'border-l-2 border-warning')}>
|
||||
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
|
||||
!done && 'border-l-[3px] border-warning')}>
|
||||
{done && <span className='font-bold shrink-0 text-success'>✓</span>}
|
||||
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
|
||||
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
|
||||
|
||||
@@ -449,7 +449,7 @@ const en: Dict = {
|
||||
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
|
||||
'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.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', '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',
|
||||
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', '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–F12.', '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.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
||||
'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows',
|
||||
'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide',
|
||||
@@ -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 <PST><FREQUENCY> 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 <PST><FREQUENCY> 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.followMode': 'Switch the decoder\u2019s mode from spots', 'udpp.followModeHint': 'Clicking an FT4 spot while WSJT-X / JTDX sits in FT8 switches its mode too (Configure message).', '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)
|
||||
@@ -535,7 +535,7 @@ const en: Dict = {
|
||||
'aud.preroll': 'Pre-roll (seconds)', 'aud.format': 'File format', 'aud.wav': 'WAV (lossless, larger)', 'aud.mp3': 'MP3 (compressed, small)',
|
||||
'aud.fromLevel': 'From Radio level', 'aud.txLevel': 'Voice keyer level', 'aud.txLevelHint': 'Level of the recorded messages sent to the radio. Raise it if your voice keyer is much quieter than your microphone; Play previews at this same level. If the radio transmits almost nothing, its modulation source is still the front microphone: on an FTDX10 set MENU → SSB MOD SOURCE to REAR (the USB input).', 'aud.micLevel': 'Mic level', 'aud.qsoPlayLevel': 'QSO playback level', 'aud.levelHint': 'If your voice is louder than the station, lower Mic level.',
|
||||
'aud.autoSend': 'Auto-send the recording to the station by e-mail when I log a QSO',
|
||||
'aud.dvkTitle': 'Voice keyer messages (F1–F6)', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
||||
'aud.dvkTitle': 'Voice keyer messages (F1–F12)', 'aud.deleteMsg': 'Delete this message', 'aud.pttMethod': 'PTT method', 'aud.pttNone': 'None (VOX)', 'aud.pttCat': 'CAT (the radio link)', 'aud.pttRts': 'Serial RTS', 'aud.pttDtr': 'Serial DTR',
|
||||
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
|
||||
'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop',
|
||||
'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ',
|
||||
@@ -955,7 +955,7 @@ const fr: Dict = {
|
||||
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
|
||||
'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.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', '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',
|
||||
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', '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–F12.', '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.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',
|
||||
'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget',
|
||||
'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer',
|
||||
@@ -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 <PST><FREQUENCY> à 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 <PST><FREQUENCY> à 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.followMode': 'Changer le mode du décodeur depuis les spots', 'udpp.followModeHint': 'Cliquer un spot FT4 pendant que WSJT-X / JTDX est en FT8 change aussi son mode.', '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.',
|
||||
@@ -1037,7 +1037,7 @@ const fr: Dict = {
|
||||
'aud.preroll': 'Pré-enregistrement (secondes)', 'aud.format': 'Format de fichier', 'aud.wav': 'WAV (sans perte, plus volumineux)', 'aud.mp3': 'MP3 (compressé, léger)',
|
||||
'aud.fromLevel': 'Niveau depuis la radio', 'aud.txLevel': 'Niveau du voice keyer', 'aud.txLevelHint': "Niveau des messages enregistrés envoyés à la radio. Augmentez-le si votre voice keyer est bien plus faible que votre micro ; Lire fait entendre ce même niveau. Si la radio n'émet presque rien, c'est que sa source de modulation est restée le micro de façade : sur un FTDX10, réglez MENU → SSB MOD SOURCE sur REAR (l'entrée USB).", 'aud.micLevel': 'Niveau micro', 'aud.qsoPlayLevel': 'Niveau de relecture QSO', 'aud.levelHint': 'Si votre voix est plus forte que la station, baissez le niveau micro.',
|
||||
'aud.autoSend': "Envoyer automatiquement l'enregistrement à la station par e-mail lorsque j'enregistre un QSO",
|
||||
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F6)', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
||||
'aud.dvkTitle': 'Messages du manipulateur vocal (F1–F12)', 'aud.deleteMsg': 'Supprimer ce message', 'aud.pttMethod': 'Méthode PTT', 'aud.pttNone': 'Aucune (VOX)', 'aud.pttCat': 'CAT (la liaison radio)', 'aud.pttRts': 'RTS série', 'aud.pttDtr': 'DTR série',
|
||||
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
|
||||
'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter',
|
||||
'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.27.1';
|
||||
export const APP_VERSION = '0.27.3';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+12
@@ -142,6 +142,8 @@ export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef
|
||||
|
||||
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
|
||||
|
||||
export function ConfigureDecoderMode(arg1:string):Promise<void>;
|
||||
|
||||
export function ConnectAllClusters():Promise<void>;
|
||||
|
||||
export function ConnectClusterServer(arg1:number):Promise<void>;
|
||||
@@ -158,6 +160,8 @@ export function CreateDatabase(arg1:string):Promise<void>;
|
||||
|
||||
export function DVKCancelRecord():Promise<void>;
|
||||
|
||||
export function DVKDelete(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKPlay(arg1:number):Promise<void>;
|
||||
|
||||
export function DVKPreview(arg1:number):Promise<void>;
|
||||
@@ -620,6 +624,10 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function GetWorkedCallVariants():Promise<boolean>;
|
||||
|
||||
export function GetWsjtFollowMode():Promise<boolean>;
|
||||
|
||||
export function GetWsjtHighlight():Promise<boolean>;
|
||||
|
||||
export function GetYaesuBandAntennas():Promise<Record<string, number>>;
|
||||
|
||||
export function GetYaesuState():Promise<cat.YaesuTXState>;
|
||||
@@ -1246,6 +1254,10 @@ export function SetWinkeyerTrace(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWorkedCallVariants(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWsjtFollowMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetWsjtHighlight(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SetYaesuAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function SetYaesuAGC(arg1:string):Promise<void>;
|
||||
|
||||
@@ -222,6 +222,10 @@ export function ComputeStationInfo(arg1, arg2) {
|
||||
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ConfigureDecoderMode(arg1) {
|
||||
return window['go']['main']['App']['ConfigureDecoderMode'](arg1);
|
||||
}
|
||||
|
||||
export function ConnectAllClusters() {
|
||||
return window['go']['main']['App']['ConnectAllClusters']();
|
||||
}
|
||||
@@ -254,6 +258,10 @@ export function DVKCancelRecord() {
|
||||
return window['go']['main']['App']['DVKCancelRecord']();
|
||||
}
|
||||
|
||||
export function DVKDelete(arg1) {
|
||||
return window['go']['main']['App']['DVKDelete'](arg1);
|
||||
}
|
||||
|
||||
export function DVKPlay(arg1) {
|
||||
return window['go']['main']['App']['DVKPlay'](arg1);
|
||||
}
|
||||
@@ -1178,6 +1186,14 @@ export function GetWorkedCallVariants() {
|
||||
return window['go']['main']['App']['GetWorkedCallVariants']();
|
||||
}
|
||||
|
||||
export function GetWsjtFollowMode() {
|
||||
return window['go']['main']['App']['GetWsjtFollowMode']();
|
||||
}
|
||||
|
||||
export function GetWsjtHighlight() {
|
||||
return window['go']['main']['App']['GetWsjtHighlight']();
|
||||
}
|
||||
|
||||
export function GetYaesuBandAntennas() {
|
||||
return window['go']['main']['App']['GetYaesuBandAntennas']();
|
||||
}
|
||||
@@ -2430,6 +2446,14 @@ export function SetWorkedCallVariants(arg1) {
|
||||
return window['go']['main']['App']['SetWorkedCallVariants'](arg1);
|
||||
}
|
||||
|
||||
export function SetWsjtFollowMode(arg1) {
|
||||
return window['go']['main']['App']['SetWsjtFollowMode'](arg1);
|
||||
}
|
||||
|
||||
export function SetWsjtHighlight(arg1) {
|
||||
return window['go']['main']['App']['SetWsjtHighlight'](arg1);
|
||||
}
|
||||
|
||||
export function SetYaesuAFGain(arg1) {
|
||||
return window['go']['main']['App']['SetYaesuAFGain'](arg1);
|
||||
}
|
||||
|
||||
@@ -1083,6 +1083,7 @@ export namespace cat {
|
||||
s_meter: number;
|
||||
s_meter_raw: number;
|
||||
power_meter: number;
|
||||
power_w: number;
|
||||
swr: number;
|
||||
swr_raw: number;
|
||||
rf_power: number;
|
||||
@@ -1120,6 +1121,7 @@ export namespace cat {
|
||||
this.s_meter = source["s_meter"];
|
||||
this.s_meter_raw = source["s_meter_raw"];
|
||||
this.power_meter = source["power_meter"];
|
||||
this.power_w = source["power_w"];
|
||||
this.swr = source["swr"];
|
||||
this.swr_raw = source["swr_raw"];
|
||||
this.rf_power = source["rf_power"];
|
||||
|
||||
@@ -52,9 +52,15 @@ type KenwoodTXState struct {
|
||||
SMeterRaw int `json:"s_meter_raw"`
|
||||
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
|
||||
// measured", NOT a perfect match.
|
||||
PowerMeter int `json:"power_meter"`
|
||||
SWR float64 `json:"swr"`
|
||||
SWRRaw int `json:"swr_raw"`
|
||||
PowerMeter int `json:"power_meter"`
|
||||
// PowerW is the transmit power in WATTS, derived from the bargraph and the
|
||||
// meter's RANGE. The K3's bar is relative to a range that flips at 12 W —
|
||||
// calibrated against a real one: 10 W showed 83 (10/12), 100 W showed 83
|
||||
// too (100/120). The bar alone never was watts; with the PC setting to
|
||||
// pick the range, it converts. 0 while receiving.
|
||||
PowerW int `json:"power_w"`
|
||||
SWR float64 `json:"swr"`
|
||||
SWRRaw int `json:"swr_raw"`
|
||||
|
||||
RFPower int `json:"rf_power"` // watts, the PC setting
|
||||
AFGain int `json:"af_gain"` // 0-100
|
||||
@@ -162,6 +168,7 @@ func (k *Kenwood) readPanel(mode string, split bool, txHz int64, txNow bool) {
|
||||
// Cleared, not frozen: a power bar left standing after the carrier drops
|
||||
// reads as a live transmission.
|
||||
k.panel.PowerMeter = 0
|
||||
k.panel.PowerW = 0
|
||||
k.panel.SWR, k.panel.SWRRaw = 0, 0
|
||||
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
|
||||
// The S-meter only means anything while receiving.
|
||||
@@ -341,6 +348,13 @@ func (k *Kenwood) readTXMeters() {
|
||||
defer func() { k.noLatch = false }()
|
||||
if v, ok := k.askNum("BG;", "BG", 2); ok {
|
||||
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
|
||||
if k.elecraft {
|
||||
scale := 120
|
||||
if k.panel.RFPower > 0 && k.panel.RFPower <= 12 {
|
||||
scale = 12 // the K3's QRP range
|
||||
}
|
||||
k.panel.PowerW = k.panel.PowerMeter * scale / 100
|
||||
}
|
||||
}
|
||||
// SW; — SETTLED, from Elecraft's own release note: three digits, tenths of a
|
||||
// ratio. "SW023;" is 2.3:1, and "SW999;" is the 99.9:1 it reports instead of
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// WSJT-X Configure (message 15) — change the decoder's settings remotely. Used
|
||||
// for ONE thing here: clicking an FT4 spot while the decoder sits in FT8
|
||||
// switches its mode too, so the operator lands ready to decode instead of
|
||||
// staring at a band of gibberish. Every other field is sent as "no change"
|
||||
// (empty strings, max-quint32), per the protocol.
|
||||
const wsjtMsgConfigure = 15
|
||||
|
||||
// EncodeConfigureMode builds a Configure datagram that changes only the mode.
|
||||
func EncodeConfigureMode(programID, mode string) []byte {
|
||||
const noChange32 = ^uint32(0)
|
||||
var b bytes.Buffer
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMagic))
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(2))
|
||||
_ = binary.Write(&b, binary.BigEndian, uint32(wsjtMsgConfigure))
|
||||
writeQString(&b, programID)
|
||||
writeQString(&b, mode) // Mode
|
||||
_ = binary.Write(&b, binary.BigEndian, noChange32) // Frequency Tolerance — no change
|
||||
writeQString(&b, "") // Submode — no change
|
||||
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // Fast Mode — off (right for every HF mode)
|
||||
_ = binary.Write(&b, binary.BigEndian, noChange32) // T/R Period — no change
|
||||
_ = binary.Write(&b, binary.BigEndian, noChange32) // Rx DF — no change
|
||||
writeQString(&b, "") // DX Call — no change
|
||||
writeQString(&b, "") // DX Grid — no change
|
||||
_ = binary.Write(&b, binary.BigEndian, uint8(0)) // Generate Messages — no
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// SendConfigureMode asks every decoder heard this session to switch mode.
|
||||
// Sent to all instances rather than one: the spot click does not say which
|
||||
// decoder the operator is looking at, and a second instance already in the
|
||||
// right mode treats the message as a no-op.
|
||||
func (m *Manager) SendConfigureMode(mode string) {
|
||||
for _, inst := range m.Instances() {
|
||||
if err := m.sendToInstance(inst, EncodeConfigureMode(inst, mode), "configure-mode"); err == nil {
|
||||
applog.Printf("udp: asked %q to switch to %s", inst, mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+27
-4
@@ -79,9 +79,10 @@ type Config struct {
|
||||
type Client struct {
|
||||
cfg Config
|
||||
|
||||
mu sync.Mutex // serialises the connection: one question at a time
|
||||
conn io.ReadWriteCloser
|
||||
rd *bufio.Reader
|
||||
mu sync.Mutex // serialises the connection: one question at a time
|
||||
conn io.ReadWriteCloser
|
||||
rd *bufio.Reader
|
||||
skipTP bool // ^TP went unanswered once — a KPA500, no ATU; never ask again
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
@@ -183,6 +184,13 @@ func (c *Client) connectLocked() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err)
|
||||
}
|
||||
// The KPA500 is POWER-CONTROLLED by these lines: the Elecraft utility
|
||||
// switches the amplifier on by raising them. Held asserted, once, and
|
||||
// never touched again — reconnect cycles that toggled them were
|
||||
// switching a KPA500 OFF twenty seconds after its operator pressed
|
||||
// nothing but Standby.
|
||||
_ = p.SetDTR(true)
|
||||
_ = p.SetRTS(true)
|
||||
_ = p.SetReadTimeout(ioTimeout)
|
||||
c.conn = p
|
||||
}
|
||||
@@ -214,7 +222,13 @@ func (c *Client) ask(cmd string) (string, error) {
|
||||
// the frame.
|
||||
line, err := c.rd.ReadString(';')
|
||||
if err != nil {
|
||||
c.dropLocked()
|
||||
// NOT dropped. A command this model simply does not know (^TP is the
|
||||
// KPA1500's ATU — a KPA500 never answers it) is silence, not a dead
|
||||
// link, and dropping here tore the connection down on every slow poll
|
||||
// cycle: two seconds of stalled commands, a reconnect, and a DTR
|
||||
// toggle the amplifier read as the off switch. Nothing arrived, so
|
||||
// nothing is left to desynchronise the next exchange. Write errors —
|
||||
// the genuinely dead link — still drop, above.
|
||||
return "", fmt.Errorf("no answer to %s: %w", cmd, err)
|
||||
}
|
||||
return strings.TrimSpace(line), nil
|
||||
@@ -364,12 +378,21 @@ func (c *Client) pollOnce(n uint64) {
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
}
|
||||
if c.skipTP {
|
||||
return
|
||||
}
|
||||
if reply, err := c.ask("^TP;"); err == nil {
|
||||
if v, err := parseInt(reply, "^TP"); err == nil {
|
||||
c.statusMu.Lock()
|
||||
c.status.Tuning = v == 1
|
||||
c.statusMu.Unlock()
|
||||
}
|
||||
} else {
|
||||
// One silence is the model's answer for good: a KPA500 has no ATU and
|
||||
// will never answer ^TP — asking again every cycle cost a two-second
|
||||
// stall each time.
|
||||
c.skipTP = true
|
||||
applog.Printf("kpa: ^TP unanswered — no ATU on this model, not asking again")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+119
-79
@@ -722,15 +722,14 @@ func (r *Repo) MarkUploadedBatch(ctx context.Context, statusCol, dateCol, date s
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
args = append(args, date, db.NowISO())
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
|
||||
args...)
|
||||
now := db.NowISO()
|
||||
_, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
args := append([]any{date, now}, idArgs...)
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
|
||||
args...)
|
||||
return 0, err
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark uploaded batch (%d): %w", len(ids), err)
|
||||
}
|
||||
@@ -872,6 +871,35 @@ var bulkEditableCols = map[string]bool{
|
||||
// own path, not the text one: the columns are nullable integers, and while
|
||||
// SQLite would coerce "14" quietly, a shared MySQL logbook would not — and an
|
||||
// empty string is NULL here, never "".
|
||||
// bulkByIDChunks runs one UPDATE per slice of ids, small enough for SQLite's
|
||||
// bound-variable cap: the single IN (…) with one placeholder per id worked at
|
||||
// 10 000 QSOs and failed at 168 000 with "too many SQL variables". Each call
|
||||
// gets the placeholder string and the id arguments for its slice; affected
|
||||
// rows are summed. 500 per statement keeps every backend far from any limit
|
||||
// while costing a few hundred statements on the largest logs.
|
||||
func bulkByIDChunks(ctx context.Context, ids []int64, run func(ph string, idArgs []any) (int64, error)) (int64, error) {
|
||||
const chunk = 500
|
||||
var total int64
|
||||
for start := 0; start < len(ids); start += chunk {
|
||||
end := start + chunk
|
||||
if end > len(ids) {
|
||||
end = len(ids)
|
||||
}
|
||||
part := ids[start:end]
|
||||
ph := strings.Repeat("?,", len(part)-1) + "?"
|
||||
args := make([]any, len(part))
|
||||
for i, id := range part {
|
||||
args[i] = id
|
||||
}
|
||||
n, err := run(ph, args)
|
||||
if err != nil {
|
||||
return total, err
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
var bulkEditableIntCols = map[string]bool{
|
||||
"my_dxcc": true,
|
||||
"my_cq_zone": true,
|
||||
@@ -886,23 +914,23 @@ func (r *Repo) BulkSetIntField(ctx context.Context, ids []int64, column string,
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
var val any
|
||||
if v != nil {
|
||||
val = *v
|
||||
}
|
||||
args = append(args, val, db.NowISO())
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+strings.Join(ph, ",")+")", args...)
|
||||
now := db.NowISO()
|
||||
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
args := append([]any{val, now}, idArgs...)
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
"UPDATE qso SET "+column+" = ?, updated_at = ? WHERE id IN ("+ph+")", args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
return n, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -913,13 +941,6 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
args = append(args, value, db.NowISO())
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
set := column + " = ?, updated_at = ?"
|
||||
if column == "mode" {
|
||||
// A submode belongs to the mode it was recorded under. Left behind, it
|
||||
@@ -928,13 +949,19 @@ func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value stri
|
||||
// only outcome that leaves the row meaning what the operator asked for.
|
||||
set += ", submode = ''"
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET `+set+` WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
now := db.NowISO()
|
||||
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
args := append([]any{value, now}, idArgs...)
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET `+set+` WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
return n, fmt.Errorf("bulk set %s: %w", column, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -995,26 +1022,26 @@ func (r *Repo) BulkSetExtra(ctx context.Context, ids []int64, adifKey, value str
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+2)
|
||||
head := []any{}
|
||||
expr := `json_set(COALESCE(extras_json, '{}'), '$.` + adifKey + `', ?)`
|
||||
if value == "" {
|
||||
expr = `json_remove(COALESCE(extras_json, '{}'), '$.` + adifKey + `')`
|
||||
} else {
|
||||
args = append(args, value)
|
||||
head = append(head, value)
|
||||
}
|
||||
args = append(args, db.NowISO())
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
head = append(head, db.NowISO())
|
||||
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
args := append(append([]any{}, head...), idArgs...)
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET extras_json = `+expr+`, updated_at = ? WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
|
||||
return n, fmt.Errorf("bulk set extra %s: %w", adifKey, err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -1026,20 +1053,19 @@ func (r *Repo) BulkSetFrequency(ctx context.Context, ids []int64, freqHz int64,
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+3)
|
||||
args = append(args, freqHz, band, db.NowISO())
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||
args...)
|
||||
now := db.NowISO()
|
||||
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
args := append([]any{freqHz, band, now}, idArgs...)
|
||||
res, err := r.db.ExecContext(ctx,
|
||||
`UPDATE qso SET freq_hz = ?, band = ?, updated_at = ? WHERE id IN (`+ph+`)`, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bulk set frequency: %w", err)
|
||||
return n, fmt.Errorf("bulk set frequency: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -1201,17 +1227,16 @@ func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
ph := make([]string, len(ids))
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
ph[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+strings.Join(ph, ",")+`)`, args...)
|
||||
n, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+ph+`)`, idArgs...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("delete qsos: %w", err)
|
||||
return n, fmt.Errorf("delete qsos: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -1701,27 +1726,42 @@ func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||
args := make([]any, len(ids))
|
||||
for i, id := range ids {
|
||||
args[i] = id
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...)
|
||||
// Chunked like every other by-ids statement (the one-placeholder-per-id IN
|
||||
// died at 168k with "too many SQL variables") — and because each chunk is
|
||||
// only locally ordered, the rows are collected and sorted once at the end
|
||||
// so the chronological contract holds across chunks.
|
||||
var all []QSO
|
||||
_, err := bulkByIDChunks(ctx, ids, func(ph string, idArgs []any) (int64, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`)`, idArgs...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
all = append(all, q)
|
||||
}
|
||||
return 0, rows.Err()
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("query qso: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if !all[i].QSODate.Equal(all[j].QSODate) {
|
||||
return all[i].QSODate.Before(all[j].QSODate)
|
||||
}
|
||||
return all[i].ID < all[j].ID
|
||||
})
|
||||
for _, q := range all {
|
||||
if err := fn(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GridKey builds the lookup key for the worked-grid index.
|
||||
|
||||
@@ -246,6 +246,11 @@ func (s *Server) serve(c net.Conn) {
|
||||
s.releasePTT(fmt.Sprintf("client %s left", c.RemoteAddr()))
|
||||
}()
|
||||
s.log("rigctld: client connected from %s", c.RemoteAddr())
|
||||
// The HANDSHAKE is always traced — the first few commands are where a
|
||||
// client decides to stay or hang up, and a connect that lasted 50 ms left
|
||||
// nothing in the log to say which answer it disliked. Steady-state polling
|
||||
// stays behind the CAT trace switch.
|
||||
traced := 0
|
||||
r := bufio.NewReader(c)
|
||||
w := bufio.NewWriter(c)
|
||||
for {
|
||||
@@ -265,7 +270,8 @@ func (s *Server) serve(c net.Conn) {
|
||||
// that preceded it — the one thing needed to tell whether OpsLog answered
|
||||
// something the client could not accept. Behind the same switch as the CAT
|
||||
// wire trace: this is one line per poll and would drown an ordinary log.
|
||||
if req != "" && cat.CIVTraceEnabled() {
|
||||
if req != "" && (traced < 6 || cat.CIVTraceEnabled()) {
|
||||
traced++
|
||||
s.log("rigctld: %s → %q ⇒ %q", c.RemoteAddr(), req, strings.TrimRight(resp, "\r\n"))
|
||||
}
|
||||
if resp != "" {
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.27.1"
|
||||
appVersion = "0.27.3"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user