Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c1d7235f0 | ||
|
|
adf9844d1b | ||
|
|
7afe40a48e | ||
|
|
311ea8341f | ||
|
|
a2958d458e | ||
|
|
d9b7e48e83 | ||
|
|
3e9ebdb89a | ||
|
|
be66ac1e19 | ||
|
|
6aab4a6989 | ||
|
|
9c62bf0152 | ||
|
|
1630c8fdf4 | ||
|
|
408791ddf5 | ||
|
|
3de47a8825 | ||
|
|
880ecdbbb5 | ||
|
|
615df0dc10 | ||
|
|
2194279602 | ||
|
|
51c4bda71a | ||
|
|
7d7d175ede | ||
|
|
be1ae76eb3 | ||
|
|
50157a25d3 | ||
|
|
fe1a77a54d | ||
|
|
b818a2d947 | ||
|
|
5c4ae0cfd7 | ||
|
|
69635a15bc | ||
|
|
78220e700f | ||
|
|
42a6b9c76a | ||
|
|
56affa4bed | ||
|
|
aa5af4fc75 | ||
|
|
46772e54fe | ||
|
|
a0cea352ff |
+37
-7
@@ -112,7 +112,10 @@ Affiché uniquement quand le backend CAT est une FlexRadio :
|
||||
parole (NOR/DX/DX+), VOX (+ niveau + délai), moniteur (+ niveau), gain micro.
|
||||
- **Réception (slice active) :** mode/seuil AGC, niveau audio, NB / NR / ANF.
|
||||
- **Coupleur d'antenne (ATU) :** accord / bypass / mémoires.
|
||||
- **Amplificateur :** PowerGenius XL operate/standby + défaut.
|
||||
- **Amplificateur :** la carte de commande suit l'ampli configuré —
|
||||
**PowerGenius XL** (operate/standby, mode ventilateur, défaut) ou
|
||||
**SPE Expert** (operate/standby, Marche/Arrêt, niveau Low/Mid/High, barre de
|
||||
puissance de sortie, bande & température).
|
||||
- **Mesures en direct** via le flux UDP VITA-49 : S-mètre (unités S), puissance
|
||||
directe (W), ROS, ALC, température PA, tension, plus les mesures de l'ampli.
|
||||
|
||||
@@ -159,9 +162,10 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
||||
## Keyers & audio
|
||||
|
||||
- **Keyer CW** avec macros et macros sur touches F. Le moteur du keyer est
|
||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **Icom** (le keyer
|
||||
intégré de la radio via CI-V — sans matériel supplémentaire, fonctionne aussi à
|
||||
distance) ou **TCI**.
|
||||
sélectionnable : **WinKeyer** (K1EL WK1/2/3 sur port COM), **FlexRadio CWX**
|
||||
(le keyer intégré de la radio via l'API SmartSDR — type-ahead et retour arrière,
|
||||
sans WinKeyer ni SmartCAT), **Icom** (le keyer intégré de la radio via CI-V —
|
||||
sans matériel supplémentaire, fonctionne aussi à distance) ou **TCI**.
|
||||
- **Keyer vocal numérique** (DVK) : enregistrer les messages vocaux F1–F6 et les
|
||||
émettre.
|
||||
- **Enregistrement audio des QSO :** capture continue en tampon glissant ; au
|
||||
@@ -170,10 +174,24 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
||||
|
||||
## Amplis & commutateurs
|
||||
|
||||
- Amplificateur **PowerGenius XL** (4O3A) — TCP direct : operate/standby,
|
||||
sélecteur de mode ventilateur et affichage des défauts.
|
||||
- **Amplificateurs** (Réglages → Amplificateur — la carte de commande apparaît
|
||||
sur l'onglet FlexRadio, ou seule quand ni Flex ni Icom n'est actif) :
|
||||
- **PowerGenius XL** (4O3A) en TCP direct — operate/standby, sélecteur de mode
|
||||
ventilateur et affichage des défauts.
|
||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) via **USB** (COM virtuel) ou le
|
||||
**réseau** (pont RS232-Ethernet) — operate/standby, Marche/Arrêt, niveau de
|
||||
sortie Low / Mid / High, une barre de puissance et le statut en direct (bande,
|
||||
ROS, courant PA, température, avertissements/alarmes).
|
||||
- Commutateur d'antenne **Antenna Genius** (4O3A) via TCP/GSCP — un widget de
|
||||
commutation A/B ancré.
|
||||
- Panneau **Station Control** (ancrable, widgets réordonnables par glisser) : le
|
||||
**rotor**, la commande d'éléments **Ultrabeam** et des **cartes relais** —
|
||||
WebSwitch 1216H, KMTronic, **Denkovi** USB (bit-bang FT245 D2XX, 4 ou 8 relais)
|
||||
et USB-série générique (CH340 / LCUS, protocole A0) — pour l'alimentation, les
|
||||
antennes et accessoires de la station.
|
||||
- **Relais automatiques** (Réglages) : bascule les relais de Station Control
|
||||
automatiquement selon la fréquence / bande de la radio (comme PstRotator) — par
|
||||
relais, une fenêtre de fréquence ou un ensemble de bandes.
|
||||
|
||||
## QSL & diplômes
|
||||
|
||||
@@ -201,6 +219,15 @@ avec le panadapter en flux, et Marche/Arrêt manuel. (L'audio est hors périmèt
|
||||
un début/fin de fenêtre, signale les doublons et tient un tableau de score en
|
||||
direct.
|
||||
|
||||
## Statistiques
|
||||
|
||||
- **Tableau de bord des statistiques :** tuiles de synthèse (QSO, indicatifs
|
||||
uniques, entités DXCC, continents, % confirmés) plus des graphiques — QSO **par
|
||||
mode**, un split **CW / phonie / data** par bande, l'**activité dans le temps**
|
||||
(vues glissantes jour / 7 jours / 30 jours / 12 mois), par opérateur et par
|
||||
continent. Filtres par plage de dates, par opérateur et par concours, et une vue
|
||||
**Table** qui reprend chaque graphique.
|
||||
|
||||
## Statut opérateur en direct (événements spéciaux)
|
||||
|
||||
Pour un indicatif d'événement spécial multi-op sur un journal MySQL partagé (ex.
|
||||
@@ -247,7 +274,10 @@ ce n'est pas un serveur web.
|
||||
- **Démarrage automatique :** lancer des programmes externes (WSJT-X, JTAlert,
|
||||
contrôle de rotor…) au démarrage d'OpsLog, en sautant ceux déjà lancés.
|
||||
- **Sauvegarde :** sauvegarde optionnelle base + ADIF à la fermeture.
|
||||
- **Vérification de mise à jour** au démarrage avec un toast (désactivable).
|
||||
- **Vérification de mise à jour** au démarrage et toutes les 5 minutes (et à
|
||||
l'ouverture d'Aide → À propos), avec un toast (désactivable), plus une fenêtre
|
||||
**Nouveautés** qui affiche le changelog (anglais / français) au premier
|
||||
lancement après une mise à jour — réouvrable à tout moment depuis le menu Aide.
|
||||
- **Télémétrie d'usage anonyme** (un battement de cœur quotidien : ID
|
||||
d'installation aléatoire + version + OS — aucune donnée d'indicatif ou de QSO ;
|
||||
désactivable dans les Préférences).
|
||||
|
||||
@@ -100,7 +100,10 @@ Shown only when the CAT backend is a FlexRadio:
|
||||
VOX (+ level + delay), monitor (+ level), mic gain.
|
||||
- **Receive (active slice):** AGC mode/threshold, audio level, NB / NR / ANF.
|
||||
- **Antenna tuner (ATU):** tune / bypass / memories.
|
||||
- **Amplifier:** PowerGenius XL operate/standby + fault.
|
||||
- **Amplifier:** the amp card follows whichever amplifier is configured —
|
||||
**PowerGenius XL** (operate/standby, fan mode, fault) or **SPE Expert**
|
||||
(operate/standby, ON/OFF, Low/Mid/High level, output-power bar, band &
|
||||
temperature).
|
||||
- **Live meters** over the UDP VITA-49 stream: S-meter (S-units), forward power
|
||||
(W), SWR, ALC, PA temperature, voltage, plus the amplifier's meters.
|
||||
|
||||
@@ -141,18 +144,33 @@ as Mumble.)
|
||||
## Keyers & audio
|
||||
|
||||
- **CW keyer** with macros and F-key macros. The keyer engine is selectable:
|
||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **Icom** (the radio's own keyer
|
||||
over CI-V — no extra hardware, works over the remote link too) or **TCI**.
|
||||
**WinKeyer** (K1EL WK1/2/3 over a COM port), **FlexRadio CWX** (the radio's
|
||||
built-in keyer over the SmartSDR API — type-ahead and backspace, no WinKeyer or
|
||||
SmartCAT needed), **Icom** (the radio's own keyer over CI-V — no extra hardware,
|
||||
works over the remote link too) or **TCI**.
|
||||
- **Digital Voice Keyer** (DVK): record F1–F6 voice messages and transmit them.
|
||||
- **QSO audio recording:** continuous rolling capture; on *Log QSO* the contact
|
||||
is saved to a per-QSO WAV (`CALL_YYYYMMDD_HHMMSS.wav`); mixes RX + mic.
|
||||
|
||||
## Amplifiers & switches
|
||||
|
||||
- **PowerGenius XL** (4O3A) amplifier — direct TCP: operate/standby, fan-mode
|
||||
selector and fault display.
|
||||
- **Amplifiers** (Settings → Amplifier — the control card appears on the
|
||||
FlexRadio tab, or on its own when neither Flex nor Icom is active):
|
||||
- **PowerGenius XL** (4O3A) over direct TCP — operate/standby, fan-mode
|
||||
selector and fault display.
|
||||
- **SPE Expert** (1.3K-FA / 1.5K-FA / 2K-FA) over **USB** (virtual COM) or the
|
||||
**network** (RS232-to-Ethernet bridge) — operate/standby, ON/OFF,
|
||||
Low / Mid / High output level, an output-power bar and live status (band,
|
||||
SWR, PA current, temperature, warnings/alarms).
|
||||
- **Antenna Genius** (4O3A) antenna switch over TCP/GSCP — a docked A/B
|
||||
antenna-switch widget.
|
||||
- **Station Control** panel (dockable, drag-to-reorder widgets): the **rotator**,
|
||||
**Ultrabeam** element control and **relay boards** — WebSwitch 1216H, KMTronic,
|
||||
**Denkovi** USB (FT245 D2XX bit-bang, 4 or 8 relays) and generic USB-serial
|
||||
(CH340 / LCUS, A0 protocol) — for station power, antennas and accessories.
|
||||
- **Relay auto-control** (Settings): switch Station-Control relays automatically
|
||||
from the rig frequency / band (like PstRotator) — per relay, a frequency window
|
||||
or a set of bands.
|
||||
|
||||
## QSL & awards
|
||||
|
||||
@@ -176,6 +194,14 @@ as Mumble.)
|
||||
and the sent/received serials (`STX` / `SRX`), enforces a window start/end,
|
||||
flags dupes and keeps a live scoreboard.
|
||||
|
||||
## Statistics
|
||||
|
||||
- **Logbook statistics dashboard:** headline tiles (QSOs, unique callsigns, DXCC
|
||||
entities, continents, % confirmed) plus charts — QSOs **by mode**, a per-band
|
||||
**CW / phone / data** split, **activity over time** (rolling day / 7-day /
|
||||
30-day / 12-month views), by operator and by continent. Date-range, per-operator
|
||||
and per-contest filters, and a **Table** view that mirrors every chart.
|
||||
|
||||
## Multi-operator live status (special events)
|
||||
|
||||
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
||||
@@ -218,7 +244,10 @@ only writes to the DB — it is not a web server.
|
||||
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||
OpsLog startup, skipping any already running.
|
||||
- **Backup:** optional database + ADIF backup at shutdown.
|
||||
- **Update check** at startup with a toast (toggleable).
|
||||
- **Update check** at startup and every 5 minutes (and on opening Help → About),
|
||||
with a toast (toggleable), plus a **What's new** dialog that shows the changelog
|
||||
(English / French) on the first launch after an update — reopenable any time
|
||||
from the Help menu.
|
||||
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||
|
||||
|
||||
@@ -490,6 +490,8 @@ type App struct {
|
||||
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
||||
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
||||
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
||||
relayDrvMu sync.Mutex // guards the cached relay drivers below
|
||||
relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call)
|
||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||
@@ -812,6 +814,33 @@ func (a *App) startup(ctx context.Context) {
|
||||
a.lookup = lookup.NewManager(a.cache)
|
||||
a.reloadLookupProviders()
|
||||
|
||||
// CAT manager: emit pushes state to the frontend via Wails events, and
|
||||
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
||||
// Started HERE — before the (possibly slow, remote) MySQL logbook connect
|
||||
// below — because the rig link only needs the local settings, and blocking it
|
||||
// behind a 10–30s MySQL dial made the FlexRadio (and every CAT backend) take
|
||||
// that long to come up at launch.
|
||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||
// DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a
|
||||
// burst of these lines = the frontend is being re-rendered rapidly (the
|
||||
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
|
||||
// or freq/split/mode oscillating between slices during FT8.
|
||||
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s",
|
||||
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||
}
|
||||
a.emitRadioUDP(s)
|
||||
// Drive station relays by the current frequency/band (PstRotator-style
|
||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||
// feature is off; when on, run off this callback so a slow relay board never
|
||||
// stalls rig-state processing.
|
||||
if a.relayAutoOn.Load() {
|
||||
go a.applyRelayAuto(s.FreqHz, s.Band)
|
||||
}
|
||||
})
|
||||
a.reloadCAT()
|
||||
|
||||
// The QSO logbook lives where the ACTIVE PROFILE points it: the local SQLite
|
||||
// file, or a per-profile shared MySQL database. Switching profiles switches
|
||||
// the logbook (see switchLogbook). One-time: adopt any legacy config.json
|
||||
@@ -906,28 +935,6 @@ func (a *App) startup(ctx context.Context) {
|
||||
fmt.Printf("OpsLog: clublog cty.xml loaded — %d exceptions (%s)\n", n, d)
|
||||
}
|
||||
}()
|
||||
// CAT manager: emit pushes state to the frontend via Wails events, and
|
||||
// forwards frequency/mode to any outbound UDP emitters (PstRotator, N1MM).
|
||||
a.cat = cat.NewManager(func(s cat.RigState) {
|
||||
// DIAGNOSTIC: the manager only fires this on a USER-relevant change, so a
|
||||
// burst of these lines = the frontend is being re-rendered rapidly (the
|
||||
// "screen flickers" symptom). Shows WHAT is churning — connection flap,
|
||||
// or freq/split/mode oscillating between slices during FT8.
|
||||
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s",
|
||||
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band)
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "cat:state", s)
|
||||
}
|
||||
a.emitRadioUDP(s)
|
||||
// Drive station relays by the current frequency/band (PstRotator-style
|
||||
// automatic control). Cheap cached-flag check keeps this a no-op when the
|
||||
// feature is off; when on, run off this callback so a slow relay board never
|
||||
// stalls rig-state processing.
|
||||
if a.relayAutoOn.Load() {
|
||||
go a.applyRelayAuto(s.FreqHz, s.Band)
|
||||
}
|
||||
})
|
||||
a.reloadCAT()
|
||||
|
||||
// POTA: background poller of api.pota.app so cluster spots can be tagged
|
||||
// when the DX station is currently activating a park. Best-effort.
|
||||
@@ -1191,8 +1198,15 @@ func (a *App) runShutdownTasks(ctx context.Context, steps []shutdownStep) {
|
||||
a.emitShutdownEvent("shutdown:update", steps)
|
||||
}
|
||||
a.emitShutdownEvent("shutdown:done", steps)
|
||||
// Give the UI a moment to show the "done" state before we yank the
|
||||
// window away. 600ms feels purposeful without being annoying.
|
||||
// Cleanly tear down hardware links before quitting. Especially the CAT
|
||||
// backend: a FlexRadio keeps our API client registered until it notices the
|
||||
// TCP is gone, and just letting the process exit left the slot stale — the
|
||||
// NEXT launch then spent ~30s retrying the connect before the radio freed it.
|
||||
// cat.Stop() closes the socket with a proper FIN so the radio drops us at once.
|
||||
if a.cat != nil {
|
||||
a.cat.Stop()
|
||||
}
|
||||
a.closeRelayDrivers() // release FTDI/serial relay handles for the next launch
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
wruntime.Quit(ctx)
|
||||
}
|
||||
@@ -9441,6 +9455,15 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
// Serialise the WHOLE operation — parse, callsign LOOKUP, dedup and insert —
|
||||
// so simultaneous UDP QSOs are processed one at a time (a queue). A multi-stream
|
||||
// MSHV finishing 4 QSOs at once used to fire 4 concurrent callsign lookups and DB
|
||||
// writes, which the QRZ/HamQTH service (and a remote DB) rejected with "too many
|
||||
// requests". Holding the lock across the lookup spaces them out; the UDP reader
|
||||
// runs on its own goroutine, so this never blocks packet reception — callers just
|
||||
// queue behind each other. (adifwatch shares this lock for the same reason.)
|
||||
a.udpLogMu.Lock()
|
||||
defer a.udpLogMu.Unlock()
|
||||
// Pull the first record out of the payload. WSJT-X / JTDX / MSHV
|
||||
// always send a single QSO per UDP packet (no header) but we tolerate
|
||||
// either form via adif.Parse.
|
||||
@@ -9583,12 +9606,10 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
// a minute (the two apps stamp their own time), so a minute-exact key
|
||||
// missed it and the contact got duplicated.
|
||||
//
|
||||
// The check + insert is guarded by udpLogMu: MSHV/WSJT can deliver the same
|
||||
// logged-QSO packet twice in quick succession (re-broadcast, or two
|
||||
// listeners), and without serialisation both goroutines read the dedup set
|
||||
// BEFORE either inserts, both pass, and the QSO lands twice.
|
||||
a.udpLogMu.Lock()
|
||||
defer a.udpLogMu.Unlock()
|
||||
// The check + insert is covered by udpLogMu (taken at the top of this function):
|
||||
// MSHV/WSJT can deliver the same logged-QSO packet twice in quick succession
|
||||
// (re-broadcast, or two listeners), and without serialisation both goroutines
|
||||
// read the dedup set BEFORE either inserts, both pass, and the QSO lands twice.
|
||||
seen, err := a.qso.ExistingDedupeKeys(a.ctx)
|
||||
if err == nil {
|
||||
base := q.QSODate.UTC()
|
||||
@@ -9602,16 +9623,35 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
||||
|
||||
id, err := a.qso.Add(a.ctx, q)
|
||||
if err != nil {
|
||||
// DB UNREACHABLE (drop / timeout on a slow-or-broken MySQL) — park the QSO
|
||||
// in the offline outbox rather than lose it, exactly like the manual log
|
||||
// path. Without this, a laggy shared MySQL silently dropped UDP-logged QSOs
|
||||
// from a multi-stream MSHV. Returns -1 so the caller treats it as "saved,
|
||||
// waiting to sync", not a failure.
|
||||
if db.IsConnLost(err) && a.queueOffline(q, err) {
|
||||
return -1, nil
|
||||
}
|
||||
return 0, fmt.Errorf("insert qso: %w", err)
|
||||
}
|
||||
q.ID = id
|
||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
||||
a.materializeAwardRefs(q)
|
||||
// Announce the log so UI widgets refresh AT ONCE (the Recent-QSOs grid, the
|
||||
// ON-AIR badge and the "stations on air" widget). The manual log path always
|
||||
// did this; the UDP/FT8 path did not, so a station running MSHV saw the top
|
||||
// "on air" list lag ~15-45s behind the instant bottom badge.
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||
}
|
||||
a.saveQSORecording(&q)
|
||||
if a.extsvc != nil {
|
||||
a.extsvc.OnQSOLogged(id)
|
||||
}
|
||||
a.maybeAutoSendEQSL(q)
|
||||
// A successful write means the link is healthy again — flush anything parked.
|
||||
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
|
||||
go func() { _, _ = a.replayOfflineQueue() }()
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -11433,8 +11473,15 @@ func relayCountFor(typ string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// deviceDriver builds the wire driver for a configured device.
|
||||
func deviceDriver(d StationDevice) relaydev.Device {
|
||||
// cachedRelay is a live driver plus the config signature it was built from, so a
|
||||
// changed device (new COM port, serial or channel count) rebuilds it.
|
||||
type cachedRelay struct {
|
||||
key string
|
||||
dev relaydev.Device
|
||||
}
|
||||
|
||||
// buildDeviceDriver builds a fresh wire driver for a configured device.
|
||||
func buildDeviceDriver(d StationDevice) relaydev.Device {
|
||||
switch d.Type {
|
||||
case "kmtronic":
|
||||
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
|
||||
@@ -11449,12 +11496,74 @@ func deviceDriver(d StationDevice) relaydev.Device {
|
||||
}
|
||||
}
|
||||
|
||||
// deviceKey is the config signature that, when unchanged, lets us reuse a device's
|
||||
// open driver (and its OS handle) instead of rebuilding it every poll.
|
||||
func deviceKey(d StationDevice) string {
|
||||
return fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d))
|
||||
}
|
||||
|
||||
// driverFor returns the cached, still-open driver for a device, building it once
|
||||
// and reusing it thereafter. Stateful boards (Denkovi FTDI, USB-serial) hold an OS
|
||||
// handle that only one opener can own, so rebuilding a driver every call — as the
|
||||
// old code did — leaked the handle and made every poll after the first fail with
|
||||
// "device in use", greying the relays out. Reuse fixes that.
|
||||
func (a *App) driverFor(d StationDevice) relaydev.Device {
|
||||
key := deviceKey(d)
|
||||
a.relayDrvMu.Lock()
|
||||
defer a.relayDrvMu.Unlock()
|
||||
if a.relayDrv == nil {
|
||||
a.relayDrv = map[string]cachedRelay{}
|
||||
}
|
||||
if c, ok := a.relayDrv[d.ID]; ok {
|
||||
if c.key == key {
|
||||
return c.dev
|
||||
}
|
||||
_ = c.dev.Close() // config changed → release the old handle before rebuilding
|
||||
delete(a.relayDrv, d.ID)
|
||||
}
|
||||
dev := buildDeviceDriver(d)
|
||||
a.relayDrv[d.ID] = cachedRelay{key: key, dev: dev}
|
||||
return dev
|
||||
}
|
||||
|
||||
// closeRelayDrivers closes and drops every cached driver (e.g. after the device
|
||||
// list changes) so stale handles are released and rebuilt fresh on next use.
|
||||
func (a *App) closeRelayDrivers() {
|
||||
a.relayDrvMu.Lock()
|
||||
defer a.relayDrvMu.Unlock()
|
||||
for id, c := range a.relayDrv {
|
||||
_ = c.dev.Close()
|
||||
delete(a.relayDrv, id)
|
||||
}
|
||||
}
|
||||
|
||||
// ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI
|
||||
// boards, for the settings picker. Windows-only (FTDI D2XX).
|
||||
func (a *App) ListDenkoviDevices() ([]string, error) {
|
||||
return relaydev.ListDenkovi()
|
||||
}
|
||||
|
||||
// StationTestResult is the outcome of probing one relay board, for the settings
|
||||
// dialog's Connect/Test button so the user gets a clear connected/failed message.
|
||||
type StationTestResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Relays int `json:"relays"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TestStationDevice opens the given board and reads its state, returning a clear
|
||||
// success/failure so the settings UI can tell the user whether it's reachable.
|
||||
// Reuses the cached driver (no double-open conflict with the live poll).
|
||||
func (a *App) TestStationDevice(d StationDevice) StationTestResult {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
states, err := a.driverFor(d).Status(ctx)
|
||||
if err != nil {
|
||||
return StationTestResult{OK: false, Error: err.Error()}
|
||||
}
|
||||
return StationTestResult{OK: true, Relays: len(states)}
|
||||
}
|
||||
|
||||
// GetStationDevices returns the configured relay boards (without live state).
|
||||
func (a *App) GetStationDevices() []StationDevice {
|
||||
out := []StationDevice{}
|
||||
@@ -11497,6 +11606,9 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Release any open board handles so the new config reopens cleanly (a changed
|
||||
// COM port / FTDI serial must not stay held by a stale driver).
|
||||
a.closeRelayDrivers()
|
||||
return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b))
|
||||
}
|
||||
|
||||
@@ -11532,7 +11644,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
|
||||
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
states, err := deviceDriver(d).Status(ctx)
|
||||
states, err := a.driverFor(d).Status(ctx)
|
||||
if err != nil {
|
||||
ds.Error = err.Error()
|
||||
} else {
|
||||
@@ -11563,7 +11675,7 @@ func (a *App) StationSetRelay(id string, relay int, on bool) error {
|
||||
if d.ID == id {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
return deviceDriver(d).Set(ctx, relay, on)
|
||||
return a.driverFor(d).Set(ctx, relay, on)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("station device %q not found", id)
|
||||
@@ -12261,6 +12373,25 @@ func (a *App) SPESetOperate(on bool) error {
|
||||
return a.spe.Operate(on)
|
||||
}
|
||||
|
||||
// SPESetPower turns the SPE amp on (true, POWER key) or off (false, OFF key).
|
||||
func (a *App) SPESetPower(on bool) error {
|
||||
if a.spe == nil {
|
||||
return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
|
||||
}
|
||||
if on {
|
||||
return a.spe.PowerOn()
|
||||
}
|
||||
return a.spe.PowerOff()
|
||||
}
|
||||
|
||||
// SPESetPowerLevel selects the SPE output power level ("L"/"M"/"H").
|
||||
func (a *App) SPESetPowerLevel(level string) error {
|
||||
if a.spe == nil {
|
||||
return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
|
||||
}
|
||||
return a.spe.SetPowerLevel(level)
|
||||
}
|
||||
|
||||
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
|
||||
func (a *App) GetPGXLStatus() powergenius.Status {
|
||||
if a.pgxl == nil {
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
[
|
||||
{
|
||||
"version": "0.20.6",
|
||||
"date": "2026-07-21",
|
||||
"en": [
|
||||
"Amplifier controls now live right in the FlexRadio panel: OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, an output-power bar and live band / SWR / temperature — for both PowerGenius XL and SPE Expert. The card is hidden when no amplifier is selected.",
|
||||
"Faster startup: the radio (CAT) connects immediately instead of waiting behind the shared-MySQL connection, and MySQL itself connects much faster.",
|
||||
"No more lost QSOs: an FT8/MSHV QSO that can't reach a laggy shared database is parked and re-logged automatically once it recovers; simultaneous QSOs from a multi-stream are now queued instead of failing with 'too many requests'.",
|
||||
"The 'stations on air' widget now updates within a second of a QSO (it used to lag up to ~45s behind the ON-AIR badge).",
|
||||
"Relay boards (Denkovi / USB-serial) stay connected reliably now, with a Test-connection button and detect feedback in the Station Control setup.",
|
||||
"<LOGQSO> in a CW macro logs the contact at that exact point in the macro, and pressing ESC before it runs cancels the log.",
|
||||
"Auto-call now waits exactly the gap you set between calls (it was waiting far longer).",
|
||||
"Statistics: the redundant per-band chart was merged into the CW / phone / data split.",
|
||||
"Cluster self-spot pop-ups now show the band.",
|
||||
"The bottom status bar is no longer hidden behind a scrollbar in a small window.",
|
||||
"Update check now also runs when you open Help - About, and every 5 minutes."
|
||||
],
|
||||
"fr": [
|
||||
"Les commandes d'amplificateur sont maintenant directement dans le panneau FlexRadio : OPERATE/STANDBY, Marche/Arrêt, niveau Low/Mid/High, une barre de puissance de sortie et l'état en direct bande / ROS / température — pour PowerGenius XL comme pour SPE Expert. La carte est masquée si aucun ampli n'est sélectionné.",
|
||||
"Démarrage plus rapide : la radio (CAT) se connecte immédiatement au lieu d'attendre derrière la connexion MySQL partagée, et MySQL lui-même se connecte bien plus vite.",
|
||||
"Plus de QSO perdus : un QSO FT8/MSHV qui n'atteint pas une base partagée lente est mis de côté et ré-enregistré automatiquement dès qu'elle répond ; les QSO simultanés d'un multi-stream sont maintenant mis en file au lieu d'échouer avec « too many requests ».",
|
||||
"Le widget « stations on air » se met à jour en une seconde après un QSO (il pouvait accuser jusqu'à ~45s de retard sur le badge ON AIR).",
|
||||
"Les cartes relais (Denkovi / USB-série) restent connectées de façon fiable, avec un bouton « Tester la connexion » et un retour de détection dans la configuration Station Control.",
|
||||
"<LOGQSO> dans une macro CW enregistre le contact à cet endroit précis de la macro, et appuyer sur ÉCHAP avant son exécution annule l'enregistrement.",
|
||||
"L'appel automatique respecte maintenant exactement l'intervalle réglé entre les appels (il attendait bien plus longtemps).",
|
||||
"Statistiques : le graphique par bande redondant a été fusionné avec la répartition CW / phone / data.",
|
||||
"Les popups d'auto-spot du cluster affichent maintenant la bande.",
|
||||
"La barre de statut du bas n'est plus masquée par une barre de défilement en fenêtre réduite.",
|
||||
"La vérification de mise à jour se déclenche aussi à l'ouverture d'Aide - À propos, et toutes les 5 minutes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.20.5",
|
||||
"date": "2026-07-20",
|
||||
|
||||
+94
-57
@@ -452,9 +452,13 @@ export default function App() {
|
||||
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 5 * 1000);
|
||||
// Small delay lets the async publishLiveStatus MySQL write land before we read.
|
||||
// Refresh the instant the backend actually publishes a row change (a QSO put
|
||||
// someone on air, or a 5-min silence took them off) — so this widget tracks the
|
||||
// bottom ON-AIR badge instead of lagging behind its poll. Keep the qso:logged
|
||||
// fallback too (a small delay lets the async publish land) for older paths.
|
||||
const offStatus = EventsOn('livestatus:updated', load);
|
||||
const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
|
||||
return () => { window.clearInterval(id); offLogged?.(); };
|
||||
return () => { window.clearInterval(id); offStatus?.(); offLogged?.(); };
|
||||
}, [liveStationsOn]);
|
||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||
@@ -811,6 +815,11 @@ export default function App() {
|
||||
const wkEscClearsRef = useRef(true);
|
||||
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
|
||||
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
|
||||
// Generation token for an in-flight macro send: bumped on every new send AND on
|
||||
// any abort (ESC / Stop / callsign cleared). wkSend checks it before each CW
|
||||
// segment and before every <LOGQSO> log, so aborting mid-macro stops sending AND
|
||||
// skips the log that hasn't happened yet.
|
||||
const wkSendGenRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
|
||||
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
|
||||
@@ -1150,15 +1159,28 @@ export default function App() {
|
||||
// "What's new": the changelog for the version(s) since the operator last ran,
|
||||
// shown once on the first launch after an update (EN/FR per the UI language).
|
||||
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
||||
// Which language the What's-new modal shows — defaults to the UI language but
|
||||
// toggleable, so a French operator running the English UI can still read it.
|
||||
const [clLang, setClLang] = useState<'en' | 'fr'>(lang);
|
||||
useEffect(() => { setClLang(lang); }, [lang]);
|
||||
useEffect(() => {
|
||||
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
|
||||
}, []);
|
||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
// Fresh update check on demand (opening About), so it never shows a stale
|
||||
// "you're up to date". Clears updateInfo when the latest check finds nothing.
|
||||
const checkUpdateNow = useCallback(() => {
|
||||
setCheckingUpdate(true);
|
||||
CheckForUpdate().then((u: any) => {
|
||||
setUpdateInfo(u?.available && u?.latest ? { latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') } : null);
|
||||
}).catch(() => {}).finally(() => setCheckingUpdate(false));
|
||||
}, []);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateProgress, setUpdateProgress] = useState(0);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
// Check GitHub for a newer release at startup AND every 10 minutes (unless
|
||||
// Check GitHub for a newer release at startup AND every 5 minutes (unless
|
||||
// disabled in General). Best effort — silent on failure.
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('opslog.checkUpdates') === '0') return;
|
||||
@@ -1166,7 +1188,7 @@ export default function App() {
|
||||
if (u?.available && u?.latest) setUpdateInfo({ latest: String(u.latest), url: String(u.url ?? ''), downloadUrl: String(u.download_url ?? '') });
|
||||
}).catch(() => {});
|
||||
check();
|
||||
const id = window.setInterval(check, 10 * 60 * 1000);
|
||||
const id = window.setInterval(check, 5 * 60 * 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
// Live download progress for the in-app updater.
|
||||
@@ -1921,7 +1943,8 @@ export default function App() {
|
||||
if (mine && (sp.dx_call ?? '').toUpperCase() === mine) {
|
||||
const by = cleanSpotter(sp.spotter ?? '') || '?';
|
||||
const c = (sp.comment ?? '').trim();
|
||||
showToast(`Spotted by ${by}${c ? ` with ${c}` : ''}`);
|
||||
const bnd = (sp.band ?? '').trim();
|
||||
showToast(`Spotted by ${by}${bnd ? ` on ${bnd}` : ''}${c ? ` with ${c}` : ''}`);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -2112,44 +2135,59 @@ export default function App() {
|
||||
return out.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
async function wkSend(rawText: string) {
|
||||
const gen = ++wkSendGenRef.current; // this send supersedes any previous; abort bumps it too
|
||||
setWkSent('');
|
||||
const resolved = resolveCW(rawText);
|
||||
// Trailing word space so two macros fired back-to-back don't run together in
|
||||
// the keyer buffer ("CQ" + "TEST" → "CQTEST"). The keyer keys a space as a
|
||||
// word gap at the CURRENT speed, so it scales with WPM automatically.
|
||||
const keyed = resolved ? resolved + ' ' : resolved;
|
||||
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
|
||||
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
|
||||
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
|
||||
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show
|
||||
// the text we sent and, for <LOGQSO>, wait the estimated send duration
|
||||
// before logging.
|
||||
setWkSent(resolved);
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
|
||||
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
|
||||
return;
|
||||
const aborted = () => gen !== wkSendGenRef.current;
|
||||
// Cancellable wait: sleeps up to ms, but bails out immediately on abort.
|
||||
const waitMs = async (ms: number) => {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end && !aborted()) await sleep(50);
|
||||
};
|
||||
// Split the macro on <LOGQSO> so the log happens AT the token's position, not
|
||||
// only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
|
||||
// Each split boundary (every part except the last) is one <LOGQSO>.
|
||||
const parts = rawText.split(/<LOGQSO>/i);
|
||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
|
||||
for (let p = 0; p < parts.length; p++) {
|
||||
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||
const resolved = resolveCW(parts[p]);
|
||||
if (resolved) {
|
||||
// Trailing word space so back-to-back sends don't run together in the keyer
|
||||
// buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
|
||||
// current WPM, so it scales automatically.
|
||||
const keyed = resolved + ' ';
|
||||
setWkSent(resolved);
|
||||
if (isRig) {
|
||||
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so
|
||||
// wait the ESTIMATED send duration before moving on / logging.
|
||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
|
||||
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + (p < parts.length - 1 ? 200 : 600));
|
||||
} else {
|
||||
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
// Wait for the keyer to actually finish this segment before we log /
|
||||
// continue. We'd like to watch busy rise then fall, but over a
|
||||
// remote/serial-over-IP link that echo can lag tens of seconds, so cap
|
||||
// the wait at the ESTIMATED send duration + slack: proceed when busy
|
||||
// clears OR the estimate elapses, whichever comes first.
|
||||
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
|
||||
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
|
||||
const deadline = Date.now() + capMs;
|
||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||
}
|
||||
}
|
||||
if (aborted()) return; // aborted while this segment was sending → don't log
|
||||
// A <LOGQSO> token follows every part except the last → log the contact here.
|
||||
if (p < parts.length - 1) void save();
|
||||
}
|
||||
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
|
||||
// finished sending — so the QSO isn't logged (and the form cleared) while CW
|
||||
// is still going out. We'd like to wait for the busy flag to rise then fall,
|
||||
// but over a remote/serial-over-IP link that status echo can lag by tens of
|
||||
// seconds, which used to delay logging ~50s. So cap the wait at the ESTIMATED
|
||||
// send duration (text length × WPM) plus slack: log when busy clears OR the
|
||||
// estimate elapses, whichever comes first.
|
||||
if (!doLog) return;
|
||||
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500; // slack for keyer buffering/lag
|
||||
for (let i = 0; i < 20 && !wkBusyRef.current; i++) await sleep(50); // ≤1s for sending to start
|
||||
const deadline = Date.now() + capMs;
|
||||
while (wkBusyRef.current && Date.now() < deadline) await sleep(50);
|
||||
void save();
|
||||
}
|
||||
// stopAutoCall cancels any running auto-call loop.
|
||||
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
|
||||
// stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine
|
||||
// (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going).
|
||||
function stopKeyerTx() {
|
||||
wkSendGenRef.current++; // cancel any in-flight macro send (and its pending <LOGQSO> log)
|
||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
@@ -2163,20 +2201,11 @@ export default function App() {
|
||||
while (autoCallMacroRef.current === i && gen === autoCallGenRef.current && wkActiveRef.current) {
|
||||
const m = wkMacros[i];
|
||||
if (!m) break;
|
||||
// wkSend now WAITS for the CW to finish sending on its own (per-segment, all
|
||||
// engines), so we must NOT re-wait the estimated duration here — doing both
|
||||
// double-counted the send time and made the gap between calls far longer than
|
||||
// configured (a 4 s gap became ~13 s). Just wait the configured gap.
|
||||
await wkSend(m.text);
|
||||
// Wait for the message to finish before the gap+resend. The Icom keyer has
|
||||
// no busy echo, so just wait the estimated send time. For the WinKeyer, cap
|
||||
// the wait at the ESTIMATED send time (not the busy flag alone): over a
|
||||
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
|
||||
// true for tens of seconds, which made the next CQ fire ~120s late.
|
||||
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
|
||||
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
|
||||
} else {
|
||||
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
|
||||
for (let k = 0; k < 20 && !wkBusyRef.current && gen === autoCallGenRef.current; k++) await sleep(50); // ≤1s to start
|
||||
const deadline = Date.now() + capMs;
|
||||
while (wkBusyRef.current && gen === autoCallGenRef.current && Date.now() < deadline) await sleep(50);
|
||||
}
|
||||
if (gen !== autoCallGenRef.current) break;
|
||||
await sleep(Math.max(0, wkAutoCallSecsRef.current) * 1000); // the gap before the next call
|
||||
}
|
||||
@@ -2831,7 +2860,7 @@ export default function App() {
|
||||
case 'tools.duplicates': setShowDuplicates(true); break;
|
||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||
case 'tools.downloadRefs': downloadRefs(); break;
|
||||
case 'help.about': setShowAbout(true); break;
|
||||
case 'help.about': setShowAbout(true); checkUpdateNow(); break;
|
||||
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||
}
|
||||
}
|
||||
@@ -2889,6 +2918,7 @@ export default function App() {
|
||||
// ESC didn't stop the Icom or Flex keyer).
|
||||
if (keyerLive) {
|
||||
stopAutoCall();
|
||||
wkSendGenRef.current++; // abort an in-flight macro send so a pending <LOGQSO> won't fire
|
||||
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {});
|
||||
@@ -3682,7 +3712,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-background">
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-background">
|
||||
<ShutdownProgress />
|
||||
{/* ===== TOPBAR ===== */}
|
||||
{compact ? (
|
||||
@@ -4122,12 +4152,14 @@ export default function App() {
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">Ham-radio logbook</p>
|
||||
<p className="mt-3 font-mono text-sm">version <span className="font-semibold text-foreground">{APP_VERSION}</span></p>
|
||||
{updateInfo ? (
|
||||
{checkingUpdate ? (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground inline-flex items-center gap-1"><Loader2 className="size-3 animate-spin" /> {t('upd.checking')}</p>
|
||||
) : updateInfo ? (
|
||||
<button onClick={() => updateInfo.url && BrowserOpenURL(updateInfo.url)} className="mt-1 text-xs text-primary underline hover:opacity-80">
|
||||
Update available: v{updateInfo.latest} — download
|
||||
</button>
|
||||
) : (
|
||||
<p className="mt-1 text-[11px] text-success">You're up to date</p>
|
||||
<p className="mt-1 text-[11px] text-success">{t('upd.upToDate')}</p>
|
||||
)}
|
||||
<p className="mt-3 text-sm">
|
||||
Developed by <span className="font-semibold text-primary">{APP_AUTHOR}</span>
|
||||
@@ -4148,7 +4180,15 @@ export default function App() {
|
||||
<Zap className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2>
|
||||
<span className="flex-1" />
|
||||
<button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden text-[11px] font-semibold">
|
||||
{(['en', 'fr'] as const).map((l) => (
|
||||
<button key={l} onClick={() => setClLang(l)}
|
||||
className={cn('px-2 py-0.5 uppercase', clLang === l ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground ml-1"><X className="size-4" /></button>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-5 py-4 space-y-5">
|
||||
{whatsNew.map((e) => (
|
||||
@@ -4158,7 +4198,7 @@ export default function App() {
|
||||
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
|
||||
</div>
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{(lang === 'fr' ? e.fr : e.en).map((line, i) => (
|
||||
{(clLang === 'fr' ? e.fr : e.en).map((line, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
|
||||
<span>{line}</span>
|
||||
@@ -4456,10 +4496,7 @@ export default function App() {
|
||||
}}
|
||||
onSend={wkSend}
|
||||
onSendMacro={wkSendMacro}
|
||||
onStop={() => { stopAutoCall();
|
||||
if (cwSource === 'icom') IcomStopCW().catch(() => {});
|
||||
else if (cwSource === 'flex') FlexStopCW().catch(() => {});
|
||||
else WinkeyerStop().catch(() => {}); }}
|
||||
onStop={() => { stopAutoCall(); stopKeyerTx(); }}
|
||||
onClose={() => wkSetEnabled(false)}
|
||||
sendOnType={wkSendOnType}
|
||||
onToggleSendOnType={wkToggleSendOnType}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexAmpOperate,
|
||||
GetPGXLStatus, PGXLSetFanMode,
|
||||
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
@@ -263,6 +263,25 @@ function Card({ icon: Icon, title, accent, children }: { icon: any; title: strin
|
||||
);
|
||||
}
|
||||
|
||||
// speMaxW is the amp's rated output, used as the power-bar full scale. Derived from
|
||||
// the model string the amp reports (13K→1.3kW, 15K→1.5kW, 2K/20K→2kW).
|
||||
function speMaxW(model?: string): number {
|
||||
const m = (model || '').toUpperCase();
|
||||
if (m.includes('20K') || m.includes('2K')) return 2000;
|
||||
if (m.includes('15K')) return 1500;
|
||||
return 1300; // 1.3K-FA default
|
||||
}
|
||||
|
||||
// powerLevelLabel spells out the SPE power-level code (L/M/H) in full.
|
||||
function powerLevelLabel(pl?: string): string {
|
||||
switch ((pl || '').trim().toUpperCase()) {
|
||||
case 'L': return 'Low';
|
||||
case 'M': return 'Mid';
|
||||
case 'H': return 'High';
|
||||
default: return pl || '';
|
||||
}
|
||||
}
|
||||
|
||||
// onCWSpeed (optional): notified when the operator changes CW speed here, so the
|
||||
// host can keep the WinKeyer (which actually sends the macros) in sync.
|
||||
export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number) => void; onReportRST?: (rst: string) => void } = {}) {
|
||||
@@ -310,6 +329,29 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
|
||||
// Configured amplifier type (pgxl / spe*), so we show the SPE control when the
|
||||
// operator runs an SPE Expert instead of the PowerGenius.
|
||||
const [ampType, setAmpType] = useState<string>('pgxl');
|
||||
const [ampEnabled, setAmpEnabled] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => GetPGXLSettings().then((s: any) => { if (alive) { setAmpType(s?.type || 'pgxl'); setAmpEnabled(!!s?.enabled); } }).catch(() => {});
|
||||
load();
|
||||
const id = window.setInterval(load, 5000);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, []);
|
||||
const isSPE = ampEnabled && ampType !== 'pgxl';
|
||||
// SPE Expert live status (only polled when an SPE amp is configured).
|
||||
const [spe, setSpe] = useState<any>({ connected: false });
|
||||
useEffect(() => {
|
||||
if (!isSPE) return;
|
||||
let alive = true;
|
||||
const tick = () => GetSPEStatus().then((s: any) => alive && setSpe(s || { connected: false })).catch(() => {});
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1500);
|
||||
return () => { alive = false; window.clearInterval(id); };
|
||||
}, [isSPE]);
|
||||
|
||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||
hold.current[key] = Date.now() + 900;
|
||||
setSt((p) => ({ ...p, [key]: val }));
|
||||
@@ -760,8 +802,68 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||
{st.amp_available && (
|
||||
{/* SPE Expert amplifier (serial/TCP) — shown when it's the configured amp.
|
||||
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
||||
SPE link rather than the Flex amplifier object. */}
|
||||
{isSPE && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · SPE${spe.model ? ' ' + spe.model : ''}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetOperate(!spe.operate).catch(() => {})}
|
||||
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||
spe.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||
{spe.operate ? 'OPERATE' : 'STANDBY'}
|
||||
</button>
|
||||
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetPower(true).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||
<button type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetPower(false).catch(() => {})}
|
||||
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||
</div>
|
||||
{/* Output power level: Low / Mid / High. */}
|
||||
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||
{(['L', 'M', 'H'] as const).map((lvl, i) => {
|
||||
const active = (spe.power_level || '').trim().toUpperCase() === lvl;
|
||||
return (
|
||||
<button key={lvl} type="button" disabled={!spe.connected}
|
||||
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
|
||||
className={cn('px-3 py-2 text-sm font-bold disabled:opacity-30', i > 0 && 'border-l border-border',
|
||||
active ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{powerLevelLabel(lvl)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-sm', spe.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', spe.connected ? 'bg-success' : 'bg-danger')} />
|
||||
{spe.connected ? (spe.tx ? 'TX' : 'RX') : t('flxp.speOffline')}
|
||||
</span>
|
||||
{spe.connected && (
|
||||
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||
{spe.band ? `${spe.band} · ` : ''}{spe.output_w}W · SWR {Number(spe.swr_ant ?? 0).toFixed(1)} · {spe.temp_c}°C · {powerLevelLabel(spe.power_level)}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{(spe.warnings || spe.alarms) && (
|
||||
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {spe.warnings} {spe.alarms}</span>
|
||||
)}
|
||||
</div>
|
||||
{spe.connected && (
|
||||
<MeterBar label={t('flxp.outputPower')} value={Number(spe.output_w) || 0} unit="W"
|
||||
lo={0} hi={speMaxW(spe.model)}
|
||||
display={`${Number(spe.output_w) || 0} W`}
|
||||
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
|
||||
PowerGenius is the selected amp type. Running an SPE Expert hides this
|
||||
Flex-reported card so the two amps don't both show. */}
|
||||
{st.amp_available && !isSPE && (
|
||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" disabled={off}
|
||||
@@ -776,7 +878,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
||||
{/* Fan mode — shown when the PowerGenius is configured (Settings →
|
||||
PowerGenius). The dot shows the direct-connection state; the
|
||||
selector is disabled until connected (hover it for the error). */}
|
||||
{(pg.host || pg.connected) && (
|
||||
{ampType === 'pgxl' && (pg.host || pg.connected) && (
|
||||
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
|
||||
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
|
||||
<span className="text-muted-foreground">{t('flxp.fan')}</span>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
@@ -473,16 +473,33 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
// Detected FTDI serials for the Denkovi picker.
|
||||
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const [detectMsg, setDetectMsg] = useState('');
|
||||
const detectDenkovi = async () => {
|
||||
setDetecting(true);
|
||||
setDetectMsg('');
|
||||
try {
|
||||
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||
setDenkoviSerials(list);
|
||||
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||
} catch { setDenkoviSerials([]); }
|
||||
setDetectMsg(list.length === 0 ? t('station.detectNone') : t('station.detectFound', { n: list.length }));
|
||||
} catch (e: any) { setDenkoviSerials([]); setDetectMsg(String(e?.message || e || t('station.detectNone'))); }
|
||||
finally { setDetecting(false); }
|
||||
};
|
||||
// Connection test result for the current device config.
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const testDevice = async () => {
|
||||
setTesting(true);
|
||||
setTestMsg(null);
|
||||
try {
|
||||
const r: any = await TestStationDevice(device as any);
|
||||
setTestMsg(r?.ok
|
||||
? { ok: true, text: t('station.testOk', { n: r.relays }) }
|
||||
: { ok: false, text: r?.error || t('station.testFail') });
|
||||
} catch (e: any) { setTestMsg({ ok: false, text: String(e?.message || e || t('station.testFail')) }); }
|
||||
finally { setTesting(false); }
|
||||
};
|
||||
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||
return (
|
||||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||
@@ -534,6 +551,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||
{detectMsg && <p className="text-[11px] font-medium text-muted-foreground">{detectMsg}</p>}
|
||||
</div>
|
||||
) : isUsbRelay ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
@@ -581,9 +599,21 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{testMsg && (
|
||||
<span className={cn('inline-flex items-center gap-1.5 text-xs font-medium', testMsg.ok ? 'text-success' : 'text-danger')}>
|
||||
<span className={cn('size-2 rounded-full', testMsg.ok ? 'bg-success' : 'bg-danger')} />
|
||||
{testMsg.text}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||
{t('station.test')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -810,15 +810,12 @@ export function StatsPanel() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2.5">
|
||||
<Card title={t('stats.byBand')} sub={t('stats.byBandSub')} accent="var(--chart-3)">
|
||||
<VBars data={stats.by_band} empty={empty} showValues colorful />
|
||||
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} accent="var(--chart-3)">
|
||||
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||
</Card>
|
||||
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||
</Card>
|
||||
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} className="lg:col-span-2" accent="var(--chart-3)">
|
||||
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||
</Card>
|
||||
|
||||
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const en: Dict = {
|
||||
'upd.install': 'Update now', 'upd.download': 'Download', 'upd.later': 'Later',
|
||||
'upd.downloading': 'Downloading…', 'upd.installing': 'Installing…',
|
||||
'upd.restartNote': 'OpsLog will restart on the new version.',
|
||||
'upd.retry': 'Retry', 'upd.browser': 'Open page',
|
||||
'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date",
|
||||
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
|
||||
'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
|
||||
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
|
||||
@@ -131,7 +131,7 @@ const en: Dict = {
|
||||
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
|
||||
'uscty.backfillRun': 'Fill missing counties',
|
||||
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
|
||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
'sec.relayauto': 'Relay auto-control',
|
||||
@@ -283,7 +283,7 @@ const en: Dict = {
|
||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
||||
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
|
||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
|
||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline',
|
||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
||||
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||
'qrz.openTitle': 'Open {call} on QRZ.com',
|
||||
@@ -369,6 +369,7 @@ const fr: Dict = {
|
||||
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
||||
'upd.checking': 'Recherche de mises à jour…', 'upd.upToDate': 'Vous êtes à jour',
|
||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||
@@ -444,7 +445,7 @@ const fr: Dict = {
|
||||
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
|
||||
'uscty.backfillRun': 'Remplir les comtés manquants',
|
||||
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
'sec.relayauto': 'Relais automatiques',
|
||||
@@ -580,7 +581,7 @@ const fr: Dict = {
|
||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante',
|
||||
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
|
||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
|
||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne',
|
||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
||||
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
||||
|
||||
@@ -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.20.5';
|
||||
export const APP_VERSION = '0.20.6';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+6
@@ -721,6 +721,10 @@ export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SPESetPower(arg1:boolean):Promise<void>;
|
||||
|
||||
export function SPESetPowerLevel(arg1:string):Promise<void>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||
@@ -845,6 +849,8 @@ export function TestQRZUpload():Promise<string>;
|
||||
|
||||
export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationTestResult>;
|
||||
|
||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||
|
||||
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||
|
||||
@@ -1398,6 +1398,14 @@ export function SPESetOperate(arg1) {
|
||||
return window['go']['main']['App']['SPESetOperate'](arg1);
|
||||
}
|
||||
|
||||
export function SPESetPower(arg1) {
|
||||
return window['go']['main']['App']['SPESetPower'](arg1);
|
||||
}
|
||||
|
||||
export function SPESetPowerLevel(arg1) {
|
||||
return window['go']['main']['App']['SPESetPowerLevel'](arg1);
|
||||
}
|
||||
|
||||
export function SaveADIFFile() {
|
||||
return window['go']['main']['App']['SaveADIFFile']();
|
||||
}
|
||||
@@ -1646,6 +1654,10 @@ export function TestRotator(arg1) {
|
||||
return window['go']['main']['App']['TestRotator'](arg1);
|
||||
}
|
||||
|
||||
export function TestStationDevice(arg1) {
|
||||
return window['go']['main']['App']['TestStationDevice'](arg1);
|
||||
}
|
||||
|
||||
export function TestUltrabeam(arg1) {
|
||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||
}
|
||||
|
||||
@@ -2746,6 +2746,22 @@ export namespace main {
|
||||
this.my_pota_ref = source["my_pota_ref"];
|
||||
}
|
||||
}
|
||||
export class StationTestResult {
|
||||
ok: boolean;
|
||||
relays: number;
|
||||
error?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationTestResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.ok = source["ok"];
|
||||
this.relays = source["relays"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
}
|
||||
export class ULSStatusResult {
|
||||
count: number;
|
||||
updated_at: string;
|
||||
|
||||
+19
-7
@@ -198,14 +198,26 @@ func migrate(conn *sql.DB, translate func(string) string) error {
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
var dummy string
|
||||
err := conn.QueryRow(`SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&dummy)
|
||||
if err == nil {
|
||||
continue // already applied
|
||||
// Fetch every applied migration name in ONE query rather than one round-trip
|
||||
// per migration. On a high-latency remote MySQL those 20+ SELECTs dominated the
|
||||
// connect time (each RTT × migration count) — the whole logbook connect could
|
||||
// take tens of seconds. One SELECT + an in-memory set is effectively instant.
|
||||
applied := map[string]bool{}
|
||||
if rows, err := conn.Query(`SELECT name FROM schema_migrations`); err == nil {
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if rows.Scan(&n) == nil {
|
||||
applied[n] = true
|
||||
}
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("check migration %s: %w", name, err)
|
||||
rows.Close()
|
||||
} else {
|
||||
return fmt.Errorf("read applied migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
if applied[name] {
|
||||
continue // already applied
|
||||
}
|
||||
content, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
|
||||
+34
-17
@@ -129,6 +129,22 @@ func translateTextColumns(s string) string {
|
||||
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
||||
// then applies the (translated) embedded migrations. multiStatements is enabled
|
||||
// so multi-statement migration files run in a single Exec.
|
||||
// tuneMySQLPool sizes the connection pool for a SHARED server. A multi-operator
|
||||
// special event runs many OpsLog instances against ONE MySQL, whose max_connections
|
||||
// is a hard global limit (often 100–151). A big per-instance pool (we used 50) let
|
||||
// a handful of ops exhaust the server — "Error 1040: Too many connections" — after
|
||||
// which queries (the on-air widget, award_refs, the grid) fail silently and the UI
|
||||
// looks frozen. A modest cap with brisk idle reaping keeps each instance's server
|
||||
// footprint small so ~10+ ops fit, while still absorbing the UI's concurrent
|
||||
// logbook queries. No max lifetime: a slow first migration can run for minutes on a
|
||||
// single connection and reaping it mid-migration drops the selected database.
|
||||
func tuneMySQLPool(conn *sql.DB) {
|
||||
conn.SetMaxOpenConns(8)
|
||||
conn.SetMaxIdleConns(3)
|
||||
conn.SetConnMaxIdleTime(30 * time.Second) // return idle connections to the server promptly
|
||||
conn.SetConnMaxLifetime(0)
|
||||
}
|
||||
|
||||
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
if strings.TrimSpace(c.Host) == "" {
|
||||
return nil, fmt.Errorf("host is required")
|
||||
@@ -137,29 +153,30 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||
if !validDBIdent(name) {
|
||||
return nil, fmt.Errorf("invalid database name %q (letters, digits, underscore only)", name)
|
||||
}
|
||||
// Ensure the database exists (connect server-level first).
|
||||
if err := PingMySQL(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conn, err := sql.Open("mysql", c.dsn())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql: %w", err)
|
||||
}
|
||||
// The UI fires bursts of concurrent queries (the Preferences dialog alone
|
||||
// loads ~8 settings in parallel, plus the grid and live cluster status). A
|
||||
// low cap turns such a burst into a deadlock-like stall against a remote
|
||||
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
|
||||
conn.SetMaxOpenConns(50)
|
||||
conn.SetMaxIdleConns(10)
|
||||
conn.SetConnMaxIdleTime(90 * time.Second)
|
||||
// No max lifetime: a slow server's first migration can run for minutes on a
|
||||
// single connection, and reaping it mid-migration drops the selected database
|
||||
// (surfacing as "Unknown database"). Idle connections are still recycled
|
||||
// after 90s, and the driver retries stale pooled connections.
|
||||
conn.SetConnMaxLifetime(0)
|
||||
tuneMySQLPool(conn)
|
||||
// Connect DIRECTLY to the database first. Only if that fails — the DB doesn't
|
||||
// exist yet (first-time setup) or the server is unreachable — do we pay for the
|
||||
// server-level connect + CREATE DATABASE (two more handshakes). On a normal
|
||||
// startup against an existing DB this halves the connection round-trips, which
|
||||
// matters a lot on a high-latency remote MySQL (each handshake is several RTTs).
|
||||
if err := conn.Ping(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||
if perr := PingMySQL(c); perr != nil {
|
||||
return nil, perr // creates the DB (or returns a clear "cannot create" error)
|
||||
}
|
||||
conn, err = sql.Open("mysql", c.dsn())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open mysql: %w", err)
|
||||
}
|
||||
tuneMySQLPool(conn)
|
||||
if err := conn.Ping(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
// Set the dialect before migrating so the runner takes the MySQL path
|
||||
// (per-statement, idempotent) rather than the SQLite transaction path.
|
||||
|
||||
@@ -392,8 +392,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||
stationC[st]++
|
||||
}
|
||||
// Bucket a blank/unknown continent under "Unknown" rather than dropping it,
|
||||
// so the continent breakdown totals the same as the QSO count (a handful of
|
||||
// QSOs with no resolved continent made the donut read a few short).
|
||||
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
||||
contC[c]++
|
||||
} else {
|
||||
contC["Unknown"]++
|
||||
}
|
||||
if c := strings.TrimSpace(country.String); c != "" {
|
||||
entityC[c]++
|
||||
@@ -436,6 +441,9 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
s.UniqueCalls = len(calls)
|
||||
s.Entities = len(entities)
|
||||
s.Continents = len(contC)
|
||||
if _, ok := contC["Unknown"]; ok {
|
||||
s.Continents-- // "Unknown" is a catch-all bucket, not a real continent
|
||||
}
|
||||
if !first.IsZero() {
|
||||
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
||||
s.LastQSO = last.UTC().Format(time.RFC3339)
|
||||
|
||||
@@ -20,7 +20,8 @@ func NewDenkovi(serial string, count int) Device {
|
||||
return denkoviStub{count: count}
|
||||
}
|
||||
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (denkoviStub) Close() error { return nil }
|
||||
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
|
||||
@@ -121,6 +121,19 @@ func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
|
||||
return d.writeLocked()
|
||||
}
|
||||
|
||||
// Close releases the FTDI handle so the board can be reopened later (only one
|
||||
// handle may hold a D2XX device at a time).
|
||||
func (d *denkovi) Close() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.opened {
|
||||
procClose.Call(d.h)
|
||||
d.opened = false
|
||||
d.h = 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
@@ -27,6 +27,11 @@ type Device interface {
|
||||
Count() int // number of user-controllable relays
|
||||
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
|
||||
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
|
||||
// Close releases any OS handle the driver holds (serial port, FTDI handle).
|
||||
// Network boards hold nothing and no-op. MUST be called when a cached driver is
|
||||
// discarded so the port/handle is freed for the next open — stateful boards
|
||||
// (Denkovi, USB-serial) can only be opened by one handle at a time.
|
||||
Close() error
|
||||
}
|
||||
|
||||
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
|
||||
@@ -62,7 +67,8 @@ type webswitch struct {
|
||||
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
|
||||
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
|
||||
|
||||
func (w *webswitch) Count() int { return w.count }
|
||||
func (w *webswitch) Count() int { return w.count }
|
||||
func (w *webswitch) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
|
||||
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > w.count {
|
||||
@@ -118,7 +124,8 @@ func NewKMTronic(host, user, pass string) Device {
|
||||
return &kmtronic{host: host, user: user, pass: pass, count: 8}
|
||||
}
|
||||
|
||||
func (k *kmtronic) Count() int { return k.count }
|
||||
func (k *kmtronic) Count() int { return k.count }
|
||||
func (k *kmtronic) Close() error { return nil } // stateless HTTP, nothing to release
|
||||
|
||||
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > k.count {
|
||||
|
||||
@@ -37,6 +37,19 @@ func NewSerialRelay(port string, count int) Device {
|
||||
|
||||
func (s *serialRelay) Count() int { return s.count }
|
||||
|
||||
// Close releases the COM port so it can be reopened later (only one handle may
|
||||
// hold a serial port at a time).
|
||||
func (s *serialRelay) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.port != nil {
|
||||
err := s.port.Close()
|
||||
s.port = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serialRelay) ensureOpen() error {
|
||||
if s.port != nil {
|
||||
return nil
|
||||
|
||||
+155
-15
@@ -25,12 +25,22 @@ import (
|
||||
"time"
|
||||
|
||||
"go.bug.st/serial"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
const (
|
||||
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE
|
||||
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
|
||||
cmdStatus byte = 0x90 // request the status string
|
||||
|
||||
// Best-guess keystroke codes reconstructed from the APG command table after
|
||||
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
|
||||
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
|
||||
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
|
||||
// single physical POWER button. To be verified on hardware.
|
||||
cmdOff byte = 0x0A // OFF key — switch the amplifier off
|
||||
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
|
||||
|
||||
syncHost = 0x55
|
||||
syncAmp = 0xAA
|
||||
|
||||
@@ -77,6 +87,7 @@ type Client struct {
|
||||
|
||||
statusMu sync.RWMutex
|
||||
status Status
|
||||
lastRaw string // last raw status payload logged (log only on change)
|
||||
|
||||
stop chan struct{}
|
||||
running bool
|
||||
@@ -135,6 +146,57 @@ func (c *Client) Operate(on bool) error {
|
||||
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
||||
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
||||
|
||||
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
|
||||
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
|
||||
// the special SPE cable — so it only applies to the serial transport. Over TCP
|
||||
// there is no DTR line and power-on isn't possible remotely.
|
||||
func (c *Client) PowerOn() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
sp, ok := c.conn.(serial.Port)
|
||||
if !ok {
|
||||
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
|
||||
}
|
||||
// A single positive pulse (~1.2s) on DTR, as the manual describes.
|
||||
if err := sp.SetDTR(true); err != nil {
|
||||
return err
|
||||
}
|
||||
time.Sleep(1200 * time.Millisecond)
|
||||
return sp.SetDTR(false)
|
||||
}
|
||||
|
||||
// PowerOff presses the OFF key (switches the amp off).
|
||||
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
|
||||
|
||||
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
|
||||
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
|
||||
// level before the next tap — the status is streamed, so we poll GetStatus rather
|
||||
// than sleeping a fixed time. `level` is "L", "M" or "H" (case-insensitive).
|
||||
func (c *Client) SetPowerLevel(level string) error {
|
||||
want := strings.ToUpper(strings.TrimSpace(level))
|
||||
if want == "" {
|
||||
return nil
|
||||
}
|
||||
// At most 3 taps to walk the 3-way L→M→H cycle around to the target.
|
||||
for i := 0; i < 3; i++ {
|
||||
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) == want {
|
||||
return nil
|
||||
}
|
||||
prev := strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel))
|
||||
if err := c.sendCmd(cmdPower); err != nil {
|
||||
return err
|
||||
}
|
||||
// Wait (up to ~2s) for the streamed status to reflect the change.
|
||||
for w := 0; w < 20; w++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) != prev {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) pollLoop() {
|
||||
t := time.NewTicker(pollEvery)
|
||||
defer t.Stop()
|
||||
@@ -147,6 +209,11 @@ func (c *Client) pollLoop() {
|
||||
c.setErr(fmt.Errorf("connect: %w", err))
|
||||
continue
|
||||
}
|
||||
// The amp streams status frames faster than one per poll, so a backlog
|
||||
// builds up and we'd read stale frames (the display lagged ~15s behind
|
||||
// the real amp). Flush anything pending, THEN request + read exactly one
|
||||
// fresh frame — the status we show is always current.
|
||||
c.drainInput()
|
||||
if err := c.sendCmd(cmdStatus); err != nil {
|
||||
c.mu.Lock()
|
||||
c.dropLocked()
|
||||
@@ -190,6 +257,32 @@ func (c *Client) dropLocked() {
|
||||
}
|
||||
}
|
||||
|
||||
// drainInput discards everything currently pending on the link (OS buffer + the
|
||||
// bufio reader) so the next read returns a fresh frame rather than a queued stale
|
||||
// one. Called before each status request.
|
||||
func (c *Client) drainInput() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn == nil {
|
||||
return
|
||||
}
|
||||
if sp, ok := c.conn.(serial.Port); ok {
|
||||
_ = sp.ResetInputBuffer()
|
||||
} else if nc, ok := c.conn.(net.Conn); ok {
|
||||
_ = nc.SetReadDeadline(time.Now().Add(15 * time.Millisecond))
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := nc.Read(buf)
|
||||
if n == 0 || err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.r != nil {
|
||||
c.r.Reset(c.conn)
|
||||
}
|
||||
}
|
||||
|
||||
// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
|
||||
// the code itself.
|
||||
func (c *Client) sendCmd(code byte) error {
|
||||
@@ -268,28 +361,75 @@ func (c *Client) decodeCSV(payload string) {
|
||||
|
||||
c.statusMu.Lock()
|
||||
defer c.statusMu.Unlock()
|
||||
// Log the raw status frame ONCE per change, so field alignment can be verified
|
||||
// against real hardware (the doc's 19-field layout may differ per firmware).
|
||||
if payload != c.lastRaw {
|
||||
c.lastRaw = payload
|
||||
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
|
||||
}
|
||||
c.status.Connected = true
|
||||
c.status.LastError = ""
|
||||
c.status.Model = get(0)
|
||||
c.status.Operate = get(1) == "O"
|
||||
c.status.TX = get(2) == "T"
|
||||
c.status.Input = get(4)
|
||||
c.status.Band = get(5)
|
||||
c.status.PowerLevel = get(8)
|
||||
c.status.OutputW = pi(get(9))
|
||||
c.status.SWRATU = pf(get(10))
|
||||
c.status.SWRAnt = pf(get(11))
|
||||
c.status.VoltPA = pf(get(12))
|
||||
c.status.CurrPA = pf(get(13))
|
||||
c.status.TempC = pi(get(14))
|
||||
if w := get(17); w != "" && w != "N" {
|
||||
// The real frame carries a leading empty field (it starts with a comma), so the
|
||||
// 19 documented fields live at indices 1..19, not 0..18. Verified against a live
|
||||
// 1.3K-FA: ",13K,S,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,".
|
||||
c.status.Model = get(1)
|
||||
c.status.Operate = get(2) == "O" // "O" = OPERATE, "S" = STANDBY
|
||||
c.status.TX = get(3) == "T" // "T" = transmit, "R" = receive
|
||||
c.status.Input = get(5)
|
||||
c.status.Band = bandName(get(6))
|
||||
c.status.PowerLevel = get(9)
|
||||
c.status.OutputW = pi(get(10))
|
||||
c.status.SWRATU = pf(get(11))
|
||||
c.status.SWRAnt = pf(get(12))
|
||||
c.status.VoltPA = pf(get(13))
|
||||
c.status.CurrPA = pf(get(14))
|
||||
c.status.TempC = pi(get(15))
|
||||
if w := get(18); w != "" && w != "N" {
|
||||
c.status.Warnings = w
|
||||
} else {
|
||||
c.status.Warnings = ""
|
||||
}
|
||||
if a := get(18); a != "" && a != "N" {
|
||||
if a := get(19); a != "" && a != "N" {
|
||||
c.status.Alarms = a
|
||||
} else {
|
||||
c.status.Alarms = ""
|
||||
}
|
||||
}
|
||||
|
||||
// bandName maps the SPE 2-digit band index to a human band label, falling back to
|
||||
// the raw code for anything unrecognised.
|
||||
func bandName(code string) string {
|
||||
// SPE band index, verified against a live 1.3K-FA: the status frame reports the
|
||||
// band as a 2-digit decimal string ordered by descending wavelength — 80m→"01"
|
||||
// and 20m→"05" were both confirmed on hardware (the printed manual's table was
|
||||
// off by one). 00=160m, 01=80m, 02=60m, 03=40m, 04=30m, 05=20m, 06=17m, 07=15m,
|
||||
// 08=12m, 09=10m, 10=6m.
|
||||
switch strings.TrimSpace(code) {
|
||||
case "00":
|
||||
return "160m"
|
||||
case "01":
|
||||
return "80m"
|
||||
case "02":
|
||||
return "60m"
|
||||
case "03":
|
||||
return "40m"
|
||||
case "04":
|
||||
return "30m"
|
||||
case "05":
|
||||
return "20m"
|
||||
case "06":
|
||||
return "17m"
|
||||
case "07":
|
||||
return "15m"
|
||||
case "08":
|
||||
return "12m"
|
||||
case "09":
|
||||
return "10m"
|
||||
case "10":
|
||||
return "6m"
|
||||
case "":
|
||||
return ""
|
||||
default:
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,21 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// emitLiveStatusChanged nudges the frontend to re-read GetLiveStations right when
|
||||
// the shared table actually changes (a row appeared or was removed) — so the
|
||||
// "stations on air" widget updates the instant a QSO is published, matching the
|
||||
// bottom ON-AIR badge instead of waiting for its poll.
|
||||
func (a *App) emitLiveStatusChanged() {
|
||||
if a.ctx != nil {
|
||||
wruntime.EventsEmit(a.ctx, "livestatus:updated", nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Live operator status — for multi-operator events on a SHARED MySQL logbook
|
||||
// (e.g. a special-event call like TM74FR with several ops on different bands).
|
||||
// Each OpsLog instance heartbeats its current activity (operator call + station
|
||||
@@ -231,6 +243,7 @@ func (a *App) publishLiveStatus() {
|
||||
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
|
||||
applog.Printf("livestatus: offline DELETE failed: %v", err)
|
||||
}
|
||||
a.emitLiveStatusChanged()
|
||||
return
|
||||
}
|
||||
lastQSOArg := lastQSO.UTC()
|
||||
@@ -246,6 +259,7 @@ func (a *App) publishLiveStatus() {
|
||||
return
|
||||
}
|
||||
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
|
||||
a.emitLiveStatusChanged()
|
||||
}
|
||||
|
||||
// LiveStation is one operator's live status for the multi-op "who's on air" widget.
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.20.5"
|
||||
appVersion = "0.20.6"
|
||||
|
||||
// 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