Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 572e8ca538 | |||
| 6ac9783f7c | |||
| 725600c341 | |||
| 5d9765be09 | |||
| b302d4d87b | |||
| 8b7c42ec9b | |||
| cde0add5e0 | |||
| 0e2ef317c3 | |||
| a9f2e515e1 | |||
| 260172cd6d | |||
| 4b5e2e0b72 | |||
| 4a6ea45665 | |||
| 95d37da3bb | |||
| e1b3f0faf3 | |||
| 6379e2cd1f | |||
| 2228816057 | |||
| 32878c17be | |||
| 079d0c32df | |||
| 45d081ac0c | |||
| 183db7ac2b | |||
| 4d074de27e | |||
| 679e8f8d39 | |||
| dd2deee939 | |||
| cdd71b17c8 | |||
| e8eedcc1dc | |||
| 3c47366f56 | |||
| bd11bb4763 | |||
| 40e95e6a16 | |||
| cc0f9ffc64 | |||
| e1f1ab4922 | |||
| b6d991b799 | |||
| 59f1775fcd | |||
| b2a8b1946f | |||
| 8b1609f5ce | |||
| bde1195b34 | |||
| abdab22010 | |||
| 16dc864dbd | |||
| 01235624ee | |||
| a7bbc53c35 | |||
| 3d15f20c7f | |||
| e5c6bddb29 |
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(go:*)",
|
||||||
|
"Bash(gofmt:*)",
|
||||||
|
"Bash(/c/Users/legre/go/bin/wails:*)",
|
||||||
|
"Bash(wails:*)",
|
||||||
|
"Bash(npm:*)",
|
||||||
|
"Bash(npx:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(rg:*)",
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(echo:*)",
|
||||||
|
"Bash(head:*)",
|
||||||
|
"Bash(tail:*)",
|
||||||
|
"Bash(awk:*)",
|
||||||
|
"Bash(sed:*)",
|
||||||
|
"Bash(sort:*)",
|
||||||
|
"Bash(uniq:*)",
|
||||||
|
"Bash(wc:*)",
|
||||||
|
"Bash(xargs:*)",
|
||||||
|
"Bash(for f in *)",
|
||||||
|
"Bash(git status:*)",
|
||||||
|
"Bash(git diff:*)",
|
||||||
|
"Bash(git log:*)",
|
||||||
|
"Bash(git show:*)",
|
||||||
|
"Bash(curl -sI --max-time 10 https://raw.githubusercontent.com/google/fonts/main/ofl/archivoblack/ArchivoBlack-Regular.ttf)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ desktop.ini
|
|||||||
hamlog.db*
|
hamlog.db*
|
||||||
cty.dat
|
cty.dat
|
||||||
cat.log
|
cat.log
|
||||||
|
*.adi
|
||||||
|
|
||||||
# --- Secrets ---
|
# --- Secrets ---
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -1,19 +1,125 @@
|
|||||||
# README
|
# OpsLog
|
||||||
|
|
||||||
## About
|
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||||
|
(OmniRig **and** native FlexRadio/SmartSDR), DX cluster, awards tracking, maps,
|
||||||
|
QSL management and a QSL-card designer. Built with **Wails v2** (Go backend +
|
||||||
|
React/TypeScript frontend), **pure Go** (no CGO): SQLite for configuration,
|
||||||
|
optional **shared MySQL** for the logbook so several operators can run one log.
|
||||||
|
|
||||||
This is the official Wails Svelte-TS template.
|
Developed by **F4BPO**.
|
||||||
|
|
||||||
## Live Development
|
---
|
||||||
|
|
||||||
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
|
## Building / developing
|
||||||
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
|
|
||||||
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
|
|
||||||
to this in your browser, and you can call your Go code from devtools.
|
|
||||||
|
|
||||||
## Building
|
- **Dev:** `wails dev` (Vite hot-reload; Go methods reachable at http://localhost:34115).
|
||||||
|
- **Build:** `wails build` (use the project's wails v2.11 — `~/go/bin/wails.exe`).
|
||||||
|
- **Regenerate Go↔TS bindings** after changing exported `App` methods:
|
||||||
|
`wails generate module`.
|
||||||
|
- **Release:** `.vscode/release.ps1` (Ctrl+Shift+P → *Tasks: Run Task* →
|
||||||
|
*Release OpsLog*) — bumps the version, pushes source to Gitea, builds the exe
|
||||||
|
and publishes it to Gitea + GitHub releases.
|
||||||
|
|
||||||
To build a redistributable, production mode package, use `wails build`.
|
---
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
- **Log4OM-style entry strip:** callsign, RST tx/rx, name/QTH/grid, band/mode,
|
||||||
|
TX/RX frequency (split), start/end time, comment/note. The contacted entity's
|
||||||
|
**flag** is shown large next to the RST fields.
|
||||||
|
- **Callsign lookup** (QRZ.com / HamQTH) with photo, auto-fill of name/QTH/grid
|
||||||
|
and the QRZ.com tab.
|
||||||
|
- **Offline DXCC** resolution from `cty.dat` (country, CQ/ITU zones, continent),
|
||||||
|
with `/MM` `/AM` and call-area (`/8`, `/W6`) handling, plus ClubLog DXpedition
|
||||||
|
date overrides.
|
||||||
|
- **Recent QSOs**, **Worked-before** matrix (per band/mode slot), bulk re-resolve
|
||||||
|
from cty/QRZ/ClubLog, bulk send to QSL services.
|
||||||
|
- **Profiles:** every setting is per-profile; each profile can point its logbook
|
||||||
|
at the local SQLite file or a **shared MySQL** database (multi-operator).
|
||||||
|
|
||||||
|
## Maps & antenna
|
||||||
|
|
||||||
|
- **Main view = two configurable panes** (per profile, Settings → General →
|
||||||
|
*Main view*): great-circle map, locator (street) map, the cluster grid, the
|
||||||
|
worked-before grid, or the **FlexRadio controls**.
|
||||||
|
- **Great-circle map** with short/long-path distance & azimuth, selectable
|
||||||
|
basemaps (Light / Voyager / Street / Satellite, all key-free and labelled) and
|
||||||
|
the **antenna beam lobe(s)** drawn from the rotor azimuth.
|
||||||
|
- **Rotor compass** (azimuthal-equidistant, click-to-turn) driven by PstRotator.
|
||||||
|
- **Ultrabeam** support (Normal / 180° reverse / Bidirectional): the radiating
|
||||||
|
direction is shown in green and the **mechanical boom** in grey, on both the
|
||||||
|
compass and the map, so you never lose track of where the antenna points.
|
||||||
|
|
||||||
|
## DX Cluster
|
||||||
|
|
||||||
|
- Multiple cluster servers with auto-reconnect, a master for commands.
|
||||||
|
- **Filter sidebar** (callsign search, hide-worked, group duplicates, band /
|
||||||
|
mode / status / source) shared by the Cluster tab and the Main-view cluster
|
||||||
|
pane, with a show/hide toggle.
|
||||||
|
- Per-spot **status** (new / new-band / new-slot / worked), click-to-tune the
|
||||||
|
rig, and a multi-band **Band Map** (panadapter-style strips).
|
||||||
|
|
||||||
|
## CAT control
|
||||||
|
|
||||||
|
- **OmniRig** backend (Rig 1/2, hot-swap), and a native **FlexRadio (SmartSDR)**
|
||||||
|
backend over the radio's TCP API — real-time slice freq/mode/split, auto
|
||||||
|
reconnect, UDP discovery, and **panadapter spots** (cluster spots pushed to the
|
||||||
|
Flex display, click → fill the call).
|
||||||
|
- Mode is taken from the radio; the digital sub-mode (FT4 vs FT8) is inferred
|
||||||
|
from the frequency.
|
||||||
|
|
||||||
|
### FlexRadio control tab (SmartSDR-style)
|
||||||
|
|
||||||
|
Shown only when the CAT backend is a FlexRadio:
|
||||||
|
|
||||||
|
- **Transmit:** RF power, tune power, TUNE, MOX, speech processor (NOR/DX/DX+),
|
||||||
|
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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Keyers & audio
|
||||||
|
|
||||||
|
- **WinKeyer** CW keyer (macros, F-key macros, auto-call repeat).
|
||||||
|
- **Digital Voice Keyer** (DVK) message playback.
|
||||||
|
- **QSO audio recording** (SSB/DAX) archived per QSO; disabled for CW (no DAX
|
||||||
|
audio in CW).
|
||||||
|
|
||||||
|
## QSL & awards
|
||||||
|
|
||||||
|
- **Awards engine:** built-in + custom award definitions (shared **globally**
|
||||||
|
across profiles), worked/confirmed/validated by band & mode, OR rules and
|
||||||
|
manual reference assignment, live reference detection on call entry, and a
|
||||||
|
**Rescan** that re-pulls the logbook (picks up fresh LoTW/QRZ confirmations).
|
||||||
|
- **QSL services:** ClubLog (batched ADIF upload), LoTW, QRZ.com, eQSL — upload
|
||||||
|
and **confirmation download** (which auto-refreshes the award stats).
|
||||||
|
- **QSL Card Designer** (see below).
|
||||||
|
- **E-mail eQSL:** right-click a QSO → *Send eQSL by e-mail* via the configured
|
||||||
|
SMTP account. (Outlook/Hotmail disable basic-auth SMTP — use Gmail with an app
|
||||||
|
password, or a Microsoft app password.)
|
||||||
|
|
||||||
|
## Multi-operator live status (special events)
|
||||||
|
|
||||||
|
For a multi-op special-event call on a shared MySQL logbook (e.g. **TM74TFR**):
|
||||||
|
Settings → General → *Publish live operator status*. Each OpsLog instance
|
||||||
|
heartbeats its current activity (operator call, band, frequency, mode) into a
|
||||||
|
`live_status` table every ~15 s. A small PHP renderer
|
||||||
|
([`docs/livestatus/tm74-status.php`](docs/livestatus/tm74-status.php)) on your
|
||||||
|
own web server reads that table and produces a live page/image you can embed on
|
||||||
|
the station's **QRZ.com** bio (`<img src="…/tm74-status.php?img=1">`). OpsLog
|
||||||
|
only writes to the DB — it is not a web server.
|
||||||
|
|
||||||
|
## Other
|
||||||
|
|
||||||
|
- **Autostart:** launch external programs (WSJT-X, JTAlert, rotator control…) at
|
||||||
|
OpsLog startup, skipping any already running.
|
||||||
|
- **Update check** at startup with a toast (toggleable).
|
||||||
|
- **Anonymous usage telemetry** (a once-a-day heartbeat: random install ID +
|
||||||
|
version + OS — no callsign or QSO data; opt-out in Preferences).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## QSL Card Designer
|
## QSL Card Designer
|
||||||
|
|
||||||
@@ -40,3 +146,13 @@ Fonts: Archivo Black, Lilita One, Baloo 2, Oswald, Great Vibes, Allura (all
|
|||||||
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
OFL, embedded — licenses in `internal/qslcard/assets/fonts/`); Cooper Black is
|
||||||
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
||||||
commonly-worked DXCC entities.
|
commonly-worked DXCC entities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data & storage
|
||||||
|
|
||||||
|
- **Config** (settings, profiles, rigs/antennas, cluster nodes, lookup cache,
|
||||||
|
award lists, QSL templates) always lives in the local SQLite file under
|
||||||
|
`data/` — instant even when the logbook is on a far-away MySQL.
|
||||||
|
- **Logbook** (QSOs) lives where the active profile points it: the local SQLite
|
||||||
|
file or a per-profile shared **MySQL** database.
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/audio"
|
||||||
|
"hamlog/internal/cwdecode"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
||||||
|
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
||||||
|
// only by the frontend, and only while the entry mode is CW.
|
||||||
|
//
|
||||||
|
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
||||||
|
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
||||||
|
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
||||||
|
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
||||||
|
// back to auto-search otherwise.
|
||||||
|
|
||||||
|
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
||||||
|
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
||||||
|
func (a *App) cwTargetPitch() int {
|
||||||
|
if a.cwPitchHz > 0 {
|
||||||
|
return a.cwPitchHz
|
||||||
|
}
|
||||||
|
if a.cat != nil {
|
||||||
|
if st, ok := a.cat.FlexState(); ok && st.Available {
|
||||||
|
// Only trust the radio's pitch when it's actually in CW.
|
||||||
|
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
||||||
|
if st.CWPitch > 0 {
|
||||||
|
return st.CWPitch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
||||||
|
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
||||||
|
// call repeatedly; a second call is a no-op while already running.
|
||||||
|
func (a *App) StartCWDecoder() error {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
if a.cwStop != nil {
|
||||||
|
return nil // already running
|
||||||
|
}
|
||||||
|
dev := ""
|
||||||
|
if a.settings != nil {
|
||||||
|
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
||||||
|
}
|
||||||
|
if dev == "" {
|
||||||
|
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
||||||
|
}
|
||||||
|
|
||||||
|
dec := cwdecode.New(audio.SampleRate,
|
||||||
|
func(text string) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
func(st cwdecode.Status) {
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
a.cwDecoder = dec
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
a.cwStop = stop
|
||||||
|
go func() {
|
||||||
|
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
||||||
|
applog.Printf("cw: capture failed: %v", err)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.cwMu.Lock()
|
||||||
|
if a.cwStop == stop {
|
||||||
|
a.cwStop = nil
|
||||||
|
a.cwDecoder = nil
|
||||||
|
}
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
}()
|
||||||
|
// Follow the radio's CW pitch live (every second) while this run is active.
|
||||||
|
go a.cwFollowPitch(stop, dec)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
||||||
|
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
||||||
|
t := time.NewTicker(time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCWDecoder halts the CW decoder if running.
|
||||||
|
func (a *App) StopCWDecoder() {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
stop := a.cwStop
|
||||||
|
a.cwStop = nil
|
||||||
|
a.cwDecoder = nil
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
if stop != nil {
|
||||||
|
close(stop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CWDecoderRunning reports whether the decoder is currently capturing.
|
||||||
|
func (a *App) CWDecoderRunning() bool {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
return a.cwStop != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
||||||
|
// the Flex CW pitch, or search). Applies live to a running decoder.
|
||||||
|
func (a *App) SetCWDecoderPitch(hz int) {
|
||||||
|
if hz < 0 {
|
||||||
|
hz = 0
|
||||||
|
}
|
||||||
|
a.cwMu.Lock()
|
||||||
|
a.cwPitchHz = hz
|
||||||
|
dec := a.cwDecoder
|
||||||
|
a.cwMu.Unlock()
|
||||||
|
if dec != nil {
|
||||||
|
dec.SetTarget(a.cwTargetPitch())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
||||||
|
func (a *App) GetCWDecoderPitch() int {
|
||||||
|
a.cwMu.Lock()
|
||||||
|
defer a.cwMu.Unlock()
|
||||||
|
return a.cwPitchHz
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ var sensitiveSettingKeys = map[string]bool{
|
|||||||
keyExtClublogPassword: true,
|
keyExtClublogPassword: true,
|
||||||
keyExtLoTWKeyPassword: true,
|
keyExtLoTWKeyPassword: true,
|
||||||
keyExtLoTWWebPassword: true,
|
keyExtLoTWWebPassword: true,
|
||||||
|
keyExtHRDLogCode: true,
|
||||||
|
keyExtEQSLPassword: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
func isSensitiveSetting(key string) bool { return sensitiveSettingKeys[key] }
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
|
||||||
|
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Multi-operator chat over the SHARED MySQL logbook. The database is the message
|
||||||
|
// bus: each OpsLog INSERTs into chat_messages and polls for new rows (~3 s), so
|
||||||
|
// operators on the same shared log (e.g. a special-event call) can talk. No
|
||||||
|
// extra server. Presence is a lightweight heartbeat into chat_presence. Chat is
|
||||||
|
// only available on a MySQL logbook (SQLite/solo has no one else to talk to).
|
||||||
|
|
||||||
|
const (
|
||||||
|
chatPollInterval = 3 * time.Second
|
||||||
|
chatPresenceEvery = 20 * time.Second
|
||||||
|
chatRetentionDays = 7
|
||||||
|
chatHistoryDefault = 80
|
||||||
|
chatPresenceStaleSecs = 120 // a presence row older than this = offline
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChatMessage is one chat line.
|
||||||
|
type ChatMessage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
Station string `json:"station"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
CreatedAt string `json:"created_at"` // ISO UTC
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPresence is one operator currently online (recent heartbeat).
|
||||||
|
type ChatPresence struct {
|
||||||
|
Operator string `json:"operator"`
|
||||||
|
Station string `json:"station"`
|
||||||
|
AgoSecs int `json:"ago_secs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatActive reports whether chat can run (shared MySQL logbook).
|
||||||
|
func (a *App) chatActive() bool {
|
||||||
|
return a.logDb != nil && a.dbBackend == "mysql"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatAvailable lets the UI show/hide the chat icon (only on a shared log).
|
||||||
|
func (a *App) ChatAvailable() bool { return a.chatActive() }
|
||||||
|
|
||||||
|
func (a *App) ensureChatTables() error {
|
||||||
|
if _, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS chat_messages ("+
|
||||||
|
"id BIGINT AUTO_INCREMENT PRIMARY KEY, "+
|
||||||
|
"operator VARCHAR(32), station VARCHAR(32), "+
|
||||||
|
"message TEXT, created_at DATETIME)"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS chat_presence ("+
|
||||||
|
"operator VARCHAR(32) PRIMARY KEY, station VARCHAR(32), updated_at DATETIME)")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendChatMessage posts a message to the shared chat and returns the stored row
|
||||||
|
// (with its id) so the UI can show it immediately; the poll loop dedupes by id.
|
||||||
|
func (a *App) SendChatMessage(text string) (ChatMessage, error) {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ChatMessage{}, nil
|
||||||
|
}
|
||||||
|
if len(text) > 1000 {
|
||||||
|
text = text[:1000]
|
||||||
|
}
|
||||||
|
if !a.chatActive() {
|
||||||
|
return ChatMessage{}, fmt.Errorf("chat is only available on a shared MySQL logbook")
|
||||||
|
}
|
||||||
|
op, station := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return ChatMessage{}, fmt.Errorf("set your callsign/operator in Settings → Station first")
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
res, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO chat_messages (operator, station, message, created_at) VALUES (?, ?, ?, UTC_TIMESTAMP())",
|
||||||
|
op, station, text)
|
||||||
|
if err != nil {
|
||||||
|
return ChatMessage{}, err
|
||||||
|
}
|
||||||
|
id, _ := res.LastInsertId()
|
||||||
|
return ChatMessage{ID: id, Operator: op, Station: station, Message: text,
|
||||||
|
CreatedAt: time.Now().UTC().Format(time.RFC3339)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChatHistory returns the most recent messages (oldest first) for the panel.
|
||||||
|
func (a *App) GetChatHistory(limit int) ([]ChatMessage, error) {
|
||||||
|
if !a.chatActive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 500 {
|
||||||
|
limit = chatHistoryDefault
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT id, operator, station, message, created_at FROM chat_messages ORDER BY id DESC LIMIT ?", limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatMessage
|
||||||
|
for rows.Next() {
|
||||||
|
var m ChatMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
// Reverse to chronological order (we queried newest-first to honour LIMIT).
|
||||||
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnlineOperators lists operators with a recent presence heartbeat.
|
||||||
|
func (a *App) GetOnlineOperators() ([]ChatPresence, error) {
|
||||||
|
if !a.chatActive() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT operator, station, TIMESTAMPDIFF(SECOND, updated_at, UTC_TIMESTAMP()) AS ago "+
|
||||||
|
"FROM chat_presence WHERE updated_at > UTC_TIMESTAMP() - INTERVAL ? SECOND ORDER BY operator",
|
||||||
|
chatPresenceStaleSecs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ChatPresence
|
||||||
|
for rows.Next() {
|
||||||
|
var p ChatPresence
|
||||||
|
if err := rows.Scan(&p.Operator, &p.Station, &p.AgoSecs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatLoop polls for new messages and heartbeats presence while on a shared
|
||||||
|
// MySQL logbook. Started once at startup; a cheap no-op otherwise.
|
||||||
|
func (a *App) chatLoop() {
|
||||||
|
defer func() { _ = recover() }()
|
||||||
|
var lastID int64 = -1 // -1 = not yet baselined
|
||||||
|
var lastDB *sql.DB // logbook the baseline belongs to
|
||||||
|
lastPresence := time.Time{}
|
||||||
|
lastPurge := time.Time{}
|
||||||
|
t := time.NewTicker(chatPollInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
if !a.chatActive() {
|
||||||
|
lastID = -1 // re-baseline if the backend changes
|
||||||
|
lastDB = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Profile switch swaps the logbook under us: re-baseline against the new
|
||||||
|
// DB so we don't query it with the previous log's id cursor.
|
||||||
|
if a.logDb != lastDB {
|
||||||
|
lastID = -1
|
||||||
|
lastDB = a.logDb
|
||||||
|
}
|
||||||
|
if err := a.ensureChatTables(); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
// Presence heartbeat.
|
||||||
|
if now.Sub(lastPresence) >= chatPresenceEvery {
|
||||||
|
if op, station := a.liveStatusOperator(); op != "" {
|
||||||
|
_, _ = a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO chat_presence (operator, station, updated_at) VALUES (?, ?, UTC_TIMESTAMP()) "+
|
||||||
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station)
|
||||||
|
}
|
||||||
|
lastPresence = now
|
||||||
|
}
|
||||||
|
// Baseline on first run so existing history isn't replayed as "new"
|
||||||
|
// (the panel loads it via GetChatHistory).
|
||||||
|
if lastID < 0 {
|
||||||
|
row := a.logDb.QueryRowContext(a.ctx, "SELECT COALESCE(MAX(id),0) FROM chat_messages")
|
||||||
|
_ = row.Scan(&lastID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Emit new messages.
|
||||||
|
rows, err := a.logDb.QueryContext(a.ctx,
|
||||||
|
"SELECT id, operator, station, message, created_at FROM chat_messages WHERE id > ? ORDER BY id", lastID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var m ChatMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.Operator, &m.Station, &m.Message, &m.CreatedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if m.ID > lastID {
|
||||||
|
lastID = m.ID
|
||||||
|
}
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "chat:message", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
// Purge old messages occasionally (hourly).
|
||||||
|
if now.Sub(lastPurge) >= time.Hour {
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"DELETE FROM chat_messages WHERE created_at < UTC_TIMESTAMP() - INTERVAL ? DAY", chatRetentionDays)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("chat: purge failed: %v", err)
|
||||||
|
}
|
||||||
|
lastPurge = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* OpsLog multi-operator LIVE STATUS renderer.
|
||||||
|
*
|
||||||
|
* Reads the shared `live_status` table that every OpsLog instance heartbeats
|
||||||
|
* (operator call + station call + freq/band/mode, refreshed every ~15s) and
|
||||||
|
* shows the operators active in the last 2 minutes.
|
||||||
|
*
|
||||||
|
* Put this file on YOUR web server (the one reachable from the internet), point
|
||||||
|
* it at the SAME MySQL database OpsLog uses for the shared logbook, and embed it
|
||||||
|
* on the QRZ.com bio of the station call:
|
||||||
|
*
|
||||||
|
* <img src="https://your-server/tm74-status.php?img=1"> (image, cached ~min by QRZ)
|
||||||
|
* or <a href="https://your-server/tm74-status.php">Live operators</a> (real-time page)
|
||||||
|
*
|
||||||
|
* QRZ strips <script>/<iframe>, so only an <img> auto-updates the page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$DB_HOST = '10.10.10.15'; // your MySQL host (same as OpsLog's logbook)
|
||||||
|
$DB_NAME = 'opslog'; // database name
|
||||||
|
$DB_USER = 'opslog';
|
||||||
|
$DB_PASS = 'CHANGE_ME';
|
||||||
|
$STALE_SECONDS = 120; // an operator is "active" if seen within this window
|
||||||
|
|
||||||
|
// PHP 8.1+ makes mysqli THROW on errors by default; turn that off so a missing
|
||||||
|
// `live_status` table (not yet created by OpsLog) just yields an empty list
|
||||||
|
// instead of a fatal "table doesn't exist".
|
||||||
|
mysqli_report(MYSQLI_REPORT_OFF);
|
||||||
|
|
||||||
|
$mysqli = @new mysqli($DB_HOST, $DB_USER, $DB_PASS, $DB_NAME);
|
||||||
|
if ($mysqli->connect_errno) {
|
||||||
|
http_response_code(500);
|
||||||
|
exit('DB error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The table is created by OpsLog on first publish; tolerate it not existing yet.
|
||||||
|
$rows = [];
|
||||||
|
$sql = "SELECT operator, station, freq_hz, band, mode, updated_at
|
||||||
|
FROM live_status
|
||||||
|
WHERE updated_at >= UTC_TIMESTAMP() - INTERVAL ? SECOND
|
||||||
|
ORDER BY band, freq_hz";
|
||||||
|
if ($stmt = @$mysqli->prepare($sql)) {
|
||||||
|
$stmt->bind_param('i', $STALE_SECONDS);
|
||||||
|
@$stmt->execute();
|
||||||
|
if ($res = $stmt->get_result()) {
|
||||||
|
while ($r = $res->fetch_assoc()) $rows[] = $r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$station = $rows ? $rows[0]['station'] : 'OpsLog';
|
||||||
|
$fmtFreq = function ($hz) { return $hz > 0 ? number_format($hz / 1e6, 3) . ' MHz' : '—'; };
|
||||||
|
|
||||||
|
// ── Image output (SVG) for the QRZ <img> embed: ?img=1 ──────────────────────
|
||||||
|
if (isset($_GET['img'])) {
|
||||||
|
header('Content-Type: image/svg+xml');
|
||||||
|
header('Cache-Control: no-cache, max-age=30');
|
||||||
|
$rowH = 26; $h = 44 + max(1, count($rows)) * $rowH; $w = 440;
|
||||||
|
echo "<svg xmlns='http://www.w3.org/2000/svg' width='$w' height='$h' font-family='Segoe UI,Arial'>";
|
||||||
|
echo "<rect width='$w' height='$h' rx='8' fill='#0f172a'/>";
|
||||||
|
echo "<text x='14' y='26' fill='#38bdf8' font-size='15' font-weight='bold'>" . htmlspecialchars($station) . " — live operators</text>";
|
||||||
|
$y = 44 + 18;
|
||||||
|
if (!$rows) {
|
||||||
|
echo "<text x='14' y='$y' fill='#94a3b8' font-size='13'>No operator active right now.</text>";
|
||||||
|
}
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$line = sprintf('%-10s %-4s %-9s %s', $r['operator'], $r['band'], $r['mode'], $fmtFreq($r['freq_hz']));
|
||||||
|
echo "<text x='14' y='$y' fill='#e2e8f0' font-size='13' font-family='monospace'>" . htmlspecialchars($line) . "</text>";
|
||||||
|
$y += $rowH;
|
||||||
|
}
|
||||||
|
echo "</svg>";
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML page (real-time when opened directly; auto-refreshes every 20s) ─────
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
?><!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="20">
|
||||||
|
<title><?= htmlspecialchars($station) ?> — live operators</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Segoe UI, Arial, sans-serif; background:#0f172a; color:#e2e8f0; margin:0; padding:16px; }
|
||||||
|
h1 { color:#38bdf8; font-size:18px; margin:0 0 12px; }
|
||||||
|
table { border-collapse:collapse; width:100%; max-width:560px; }
|
||||||
|
th,td { text-align:left; padding:6px 12px; border-bottom:1px solid #1e293b; font-size:14px; }
|
||||||
|
th { color:#94a3b8; text-transform:uppercase; font-size:11px; letter-spacing:.05em; }
|
||||||
|
td.mono { font-family:monospace; }
|
||||||
|
.none { color:#94a3b8; }
|
||||||
|
</style></head><body>
|
||||||
|
<h1><?= htmlspecialchars($station) ?> — operators active now</h1>
|
||||||
|
<?php if (!$rows): ?>
|
||||||
|
<p class="none">No operator active right now.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<table>
|
||||||
|
<tr><th>Operator</th><th>Band</th><th>Mode</th><th>Frequency</th></tr>
|
||||||
|
<?php foreach ($rows as $r): ?>
|
||||||
|
<tr>
|
||||||
|
<td><strong><?= htmlspecialchars($r['operator']) ?></strong></td>
|
||||||
|
<td><?= htmlspecialchars($r['band']) ?></td>
|
||||||
|
<td><?= htmlspecialchars($r['mode']) ?></td>
|
||||||
|
<td class="mono"><?= $fmtFreq($r['freq_hz']) ?></td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</table>
|
||||||
|
<?php endif; ?>
|
||||||
|
</body></html>
|
||||||
+745
-237
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,100 @@
|
|||||||
|
import { Antenna, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type AGAntenna = { index: number; name: string };
|
||||||
|
export type AGStatus = {
|
||||||
|
connected: boolean; host?: string; last_error?: string;
|
||||||
|
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||||
|
antennas: AGAntenna[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||||
|
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||||
|
function pretty(name: string): string {
|
||||||
|
const t = name.trim();
|
||||||
|
if (!t) return t;
|
||||||
|
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||||
|
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||||
|
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||||
|
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||||
|
// Clicking an already-selected port deselects it (port → None).
|
||||||
|
export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||||
|
status: AGStatus;
|
||||||
|
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const list = status.antennas ?? [];
|
||||||
|
|
||||||
|
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||||
|
const letter = port === 1 ? 'A' : 'B';
|
||||||
|
const cls = tx
|
||||||
|
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||||
|
: active
|
||||||
|
? (port === 1
|
||||||
|
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||||
|
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActivate(port, active ? 0 : index)}
|
||||||
|
title={active ? `Port ${letter} — click to deselect` : `Select on port ${letter}`}
|
||||||
|
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
|
<Antenna className={cn('size-4', status.connected ? 'text-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||||
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
|
||||||
|
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? 'online' : 'offline'}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title="Close">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||||
|
{!status.connected ? (
|
||||||
|
<div className="text-center py-6 text-xs space-y-2">
|
||||||
|
<div className="text-muted-foreground italic animate-pulse">Connecting…</div>
|
||||||
|
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||||
|
</div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground italic text-center py-6 text-xs">No antennas configured.</div>
|
||||||
|
) : list.map((a) => {
|
||||||
|
const aActive = status.port_a === a.index;
|
||||||
|
const bActive = status.port_b === a.index;
|
||||||
|
const aTx = aActive && !!status.tx_a;
|
||||||
|
const bTx = bActive && !!status.tx_b;
|
||||||
|
const nameCls = (aTx || bTx)
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]'
|
||||||
|
: aActive
|
||||||
|
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]'
|
||||||
|
: bActive
|
||||||
|
? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]'
|
||||||
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
|
return (
|
||||||
|
<div key={a.index} className="flex items-center gap-1.5">
|
||||||
|
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
||||||
|
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||||
|
{pretty(a.name)}
|
||||||
|
</div>
|
||||||
|
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3, AlertTriangle, ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs } from '../../wailsjs/go/main/App';
|
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats, AwardMissingQSOs, ListAwardReferences, AssignAwardRefToQSOs, RescanAwards } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -76,6 +76,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
const [showMissing, setShowMissing] = useState(false);
|
const [showMissing, setShowMissing] = useState(false);
|
||||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||||
const [statsLoading, setStatsLoading] = useState(false);
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
// Bumped by Rescan to force the stats matrix to re-fetch (the selected award
|
||||||
|
// didn't change, but the backend snapshot did).
|
||||||
|
const [rescanTick, setRescanTick] = useState(0);
|
||||||
|
|
||||||
// Lazily fetch the statistics matrix when the Stats view is shown.
|
// Lazily fetch the statistics matrix when the Stats view is shown.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -85,7 +88,24 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
.then((s) => setStats(s as any))
|
.then((s) => setStats(s as any))
|
||||||
.catch(() => setStats(null))
|
.catch(() => setStats(null))
|
||||||
.finally(() => setStatsLoading(false));
|
.finally(() => setStatsLoading(false));
|
||||||
}, [view, selected]);
|
}, [view, selected, rescanTick]);
|
||||||
|
|
||||||
|
// Rescan: drop the backend snapshot (so confirmations from a fresh LoTW/QRZ
|
||||||
|
// download are picked up) and the cached results, then recompute everything.
|
||||||
|
async function rescan() {
|
||||||
|
if (!selected) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await RescanAwards();
|
||||||
|
setByCode({});
|
||||||
|
setRescanTick((t) => t + 1);
|
||||||
|
await compute(selected, true);
|
||||||
|
} catch (e: any) {
|
||||||
|
setErr(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
// Compute one award (cached). force=true bypasses the cache (Rescan).
|
||||||
async function compute(code: string, force = false) {
|
async function compute(code: string, force = false) {
|
||||||
@@ -192,8 +212,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
|||||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
|
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||||
title="Rescan all QSOs and recompute this award">
|
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
|
||||||
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||||
Rescan
|
Rescan
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { BulkUpdateField } from '../../wailsjs/go/main/App';
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
type FieldKind = 'status' | 'text';
|
||||||
|
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
||||||
|
|
||||||
|
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
||||||
|
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
|
||||||
|
// the backend BulkUpdateField whitelist.
|
||||||
|
const FIELDS: FieldDef[] = [
|
||||||
|
// QSL / upload status
|
||||||
|
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
|
||||||
|
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
|
||||||
|
// My station / operator
|
||||||
|
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
|
||||||
|
// Misc
|
||||||
|
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
|
||||||
|
{ id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_VALUES: { v: string; label: string }[] = [
|
||||||
|
{ v: 'Y', label: 'Y — Yes / uploaded' },
|
||||||
|
{ v: 'N', label: 'N — No' },
|
||||||
|
{ v: 'R', label: 'R — Requested' },
|
||||||
|
{ v: 'I', label: 'I — Ignore' },
|
||||||
|
{ v: '_', label: '(blank — clear)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
ids: number[];
|
||||||
|
onClose: () => void;
|
||||||
|
onApplied: (n: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// BulkEditModal sets one QSL/upload status field to a chosen value across the
|
||||||
|
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
||||||
|
// so they become eligible for upload.
|
||||||
|
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
||||||
|
const [field, setField] = useState('hrdlog_upload');
|
||||||
|
const [statusValue, setStatusValue] = useState('R');
|
||||||
|
const [textValue, setTextValue] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
|
||||||
|
const isStatus = def.kind === 'status';
|
||||||
|
const effectiveValue = isStatus
|
||||||
|
? (statusValue === '_' ? '' : statusValue)
|
||||||
|
: textValue.trim();
|
||||||
|
|
||||||
|
async function apply() {
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const n = await BulkUpdateField(ids as any, field, effectiveValue);
|
||||||
|
onApplied(Number(n) || 0);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message ?? e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Bulk edit field</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
|
||||||
|
This overwrites the current value — there is no undo.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="px-5 py-2 space-y-3">
|
||||||
|
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Field</Label>
|
||||||
|
<Select value={field} onValueChange={setField}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<div key={g}>
|
||||||
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{g}</div>
|
||||||
|
{FIELDS.filter((f) => f.group === g).map((f) => (
|
||||||
|
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Label className="text-sm">Value</Label>
|
||||||
|
{isStatus ? (
|
||||||
|
<Select value={statusValue} onValueChange={setStatusValue}>
|
||||||
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{v.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
className="h-8 text-xs"
|
||||||
|
value={textValue}
|
||||||
|
placeholder="leave empty to clear the field"
|
||||||
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
Will set <span className="font-semibold">{def.label}</span> ={' '}
|
||||||
|
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
|
||||||
|
</div>
|
||||||
|
{error && <div className="text-xs text-rose-700">{error}</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
||||||
|
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
||||||
|
Apply to {ids.length}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { MessageSquare, Send, Users, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export type ChatMsg = { id: number; operator: string; station: string; message: string; created_at: string };
|
||||||
|
export type ChatPresence = { operator: string; station: string; ago_secs: number };
|
||||||
|
|
||||||
|
function hhmm(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChatPanel — presentational multi-op chat panel, docked in the aside row next
|
||||||
|
// to the rotor / WinKeyer / DVK panels. All data + state lives in App.tsx.
|
||||||
|
export function ChatPanel({ msgs, online, myCall, onSend, onClose }: {
|
||||||
|
msgs: ChatMsg[]; online: ChatPresence[]; myCall?: string;
|
||||||
|
onSend: (text: string) => void; onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const me = (myCall || '').toUpperCase();
|
||||||
|
useEffect(() => { const el = listRef.current; if (el) el.scrollTop = el.scrollHeight; }, [msgs]);
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const t = text.trim();
|
||||||
|
if (!t) return;
|
||||||
|
setText('');
|
||||||
|
onSend(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30 shrink-0">
|
||||||
|
<MessageSquare className="size-4 text-sky-600" />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">Chat</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
{/* Online count — hover to see who's connected. */}
|
||||||
|
<div className="relative group">
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground cursor-default">
|
||||||
|
<Users className="size-3.5" />{online.length}
|
||||||
|
</span>
|
||||||
|
{online.length > 0 && (
|
||||||
|
<div className="hidden group-hover:block absolute right-0 top-5 z-20 min-w-[130px] rounded-md border border-border bg-popover shadow-lg p-1.5">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1">Online</div>
|
||||||
|
{online.map((o) => (
|
||||||
|
<div key={o.operator} className="font-mono text-[11px] whitespace-nowrap">
|
||||||
|
{o.operator}{o.station && o.station.toUpperCase() !== o.operator.toUpperCase() ? <span className="text-muted-foreground"> · {o.station}</span> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground" title="Close">
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div ref={listRef} className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 text-xs">
|
||||||
|
{msgs.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground italic text-center py-6">No messages yet.</div>
|
||||||
|
) : msgs.map((m) => {
|
||||||
|
const mine = m.operator.toUpperCase() === me;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={cn('flex flex-col', mine && 'items-end')}>
|
||||||
|
<div className={cn('max-w-[85%] rounded-lg px-2 py-1', mine ? 'bg-sky-100 text-sky-900' : 'bg-muted')}>
|
||||||
|
{!mine && <span className="font-mono font-bold text-[10px] text-primary mr-1">{m.operator}</span>}
|
||||||
|
<span className="whitespace-pre-wrap break-words">{m.message}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[9px] text-muted-foreground/70 px-1">{hhmm(m.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 p-2 border-t border-border/60 shrink-0">
|
||||||
|
<input
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); send(); } }}
|
||||||
|
placeholder="Message…"
|
||||||
|
maxLength={1000}
|
||||||
|
className="flex-1 h-8 rounded-md border border-border bg-background px-2 text-xs outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={send} disabled={!text.trim()}
|
||||||
|
className="inline-flex items-center justify-center size-8 rounded-md bg-primary text-primary-foreground disabled:opacity-40">
|
||||||
|
<Send className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -214,7 +214,7 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className={cn('flex-1 min-h-0', open === 'stats' ? 'overflow-hidden' : 'overflow-y-auto')}>
|
||||||
{open === 'stats' && (
|
{open === 'stats' && (
|
||||||
<div className="px-3 py-2.5">
|
<div className="px-3 py-2.5">
|
||||||
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
<BandSlotGrid wb={wb} busy={!!wbBusy} currentBand={band} currentMode={mode} bands={bands} hasCall={callsign.trim() !== ''} />
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'iota', label: 'IOTA', type: 'text' },
|
{ value: 'iota', label: 'IOTA', type: 'text' },
|
||||||
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
|
||||||
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
|
||||||
|
{ value: 'wwff_ref', label: 'WWFF ref', type: 'text' },
|
||||||
{ value: 'rig', label: 'Rig', type: 'text' },
|
{ value: 'rig', label: 'Rig', type: 'text' },
|
||||||
{ value: 'ant', label: 'Antenna', type: 'text' },
|
{ value: 'ant', label: 'Antenna', type: 'text' },
|
||||||
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
|
||||||
@@ -64,6 +65,7 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
|
||||||
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
|
||||||
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
|
||||||
|
{ value: 'hrdlog_qso_upload_status', label: 'HRDLog upload status', type: 'text' },
|
||||||
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
|
||||||
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
|
||||||
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
{ value: 'stx', label: 'Serial sent', type: 'number' },
|
||||||
@@ -74,6 +76,17 @@ const FIELDS: { value: string; label: string; type: FieldType }[] = [
|
|||||||
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
|
||||||
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
{ value: 'my_grid', label: 'My grid', type: 'text' },
|
||||||
{ value: 'my_country', label: 'My country', type: 'text' },
|
{ value: 'my_country', label: 'My country', type: 'text' },
|
||||||
|
{ value: 'my_state', label: 'My state', type: 'text' },
|
||||||
|
{ value: 'my_cnty', label: 'My county', type: 'text' },
|
||||||
|
{ value: 'my_iota', label: 'My IOTA', type: 'text' },
|
||||||
|
{ value: 'my_sota_ref', label: 'My SOTA ref', type: 'text' },
|
||||||
|
{ value: 'my_pota_ref', label: 'My POTA ref', type: 'text' },
|
||||||
|
{ value: 'my_wwff_ref', label: 'My WWFF ref', type: 'text' },
|
||||||
|
{ value: 'my_street', label: 'My street', type: 'text' },
|
||||||
|
{ value: 'my_city', label: 'My city', type: 'text' },
|
||||||
|
{ value: 'my_postal_code', label: 'My postal code', type: 'text' },
|
||||||
|
{ value: 'my_rig', label: 'My rig', type: 'text' },
|
||||||
|
{ value: 'my_antenna', label: 'My antenna', type: 'text' },
|
||||||
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
|
||||||
{ value: 'comment', label: 'Comment', type: 'text' },
|
{ value: 'comment', label: 'Comment', type: 'text' },
|
||||||
{ value: 'notes', label: 'Notes', type: 'text' },
|
{ value: 'notes', label: 'Notes', type: 'text' },
|
||||||
@@ -211,6 +224,7 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
)}
|
)}
|
||||||
{conditions.map((c, i) => {
|
{conditions.map((c, i) => {
|
||||||
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
|
||||||
|
const fieldType = FIELDS.find((f) => f.value === c.field)?.type ?? 'text';
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex items-center gap-2">
|
<div key={i} className="flex items-center gap-2">
|
||||||
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
|
||||||
@@ -231,9 +245,10 @@ export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Input
|
<Input
|
||||||
|
type={fieldType === 'date' ? 'date' : fieldType === 'number' ? 'number' : 'text'}
|
||||||
className="h-8 flex-1 text-xs"
|
className="h-8 flex-1 text-xs"
|
||||||
disabled={!needsValue}
|
disabled={!needsValue}
|
||||||
placeholder={needsValue ? 'value' : '—'}
|
placeholder={needsValue ? (fieldType === 'date' ? 'YYYY-MM-DD' : 'value') : '—'}
|
||||||
value={c.value}
|
value={c.value}
|
||||||
onChange={(e) => setCond(i, { value: e.target.value })}
|
onChange={(e) => setCond(i, { value: e.target.value })}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, Zap, Power, AudioLines, Flame, Gauge } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||||
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
|
FlexMox, FlexAmpOperate,
|
||||||
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
||||||
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
|
FlexSetAPF, FlexSetAPFLevel, FlexSetCWSpeed, FlexSetCWPitch, FlexSetCWBreakInDelay,
|
||||||
|
FlexSetCWSidetone, FlexSetSidetoneLevel, FlexSetCWFilter, FlexSetFilter,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type FlexState = {
|
||||||
|
available: boolean; model?: string;
|
||||||
|
rf_power: number; tune_power: number; tune: boolean; transmitting: boolean;
|
||||||
|
vox_enable: boolean; vox_level: number; vox_delay: number;
|
||||||
|
proc_enable: boolean; proc_level: number;
|
||||||
|
mon: boolean; mon_level: number; mic_level: number;
|
||||||
|
atu_status?: string; atu_memories: boolean;
|
||||||
|
rx_avail: boolean; agc_mode?: string; agc_threshold: number; audio_level: number;
|
||||||
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; anf_level: number;
|
||||||
|
mode?: string;
|
||||||
|
cw_speed: number; cw_pitch: number; cw_break_in_delay: number; cw_sidetone: boolean; cw_mon_level: number;
|
||||||
|
apf: boolean; apf_level: number; filter_lo: number; filter_hi: number;
|
||||||
|
amp_available: boolean; amp_model?: string; amp_operate: boolean; amp_fault?: string;
|
||||||
|
meters?: Meter[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Meter = { id: number; src?: string; name?: string; unit?: string; value: number; lo: number; hi: number };
|
||||||
|
|
||||||
|
const ZERO: FlexState = {
|
||||||
|
available: false, rf_power: 0, tune_power: 0, tune: false, transmitting: false,
|
||||||
|
vox_enable: false, vox_level: 0, vox_delay: 0, proc_enable: false, proc_level: 0,
|
||||||
|
mon: false, mon_level: 0, mic_level: 0, atu_memories: false,
|
||||||
|
rx_avail: false, agc_threshold: 0, audio_level: 0,
|
||||||
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, anf_level: 0,
|
||||||
|
cw_speed: 25, cw_pitch: 600, cw_break_in_delay: 30, cw_sidetone: true, cw_mon_level: 0,
|
||||||
|
apf: false, apf_level: 0, filter_lo: 0, filter_hi: 0,
|
||||||
|
amp_available: false, amp_operate: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Slider({ value, onChange, disabled, accent = '#16a34a', step = 1, max = 100 }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; max?: number;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(0, Math.min(max, value));
|
||||||
|
const pct = max > 0 ? (v / max) * 100 : 0;
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
|
||||||
|
// is ignored), so attach a non-passive native listener; read live values via
|
||||||
|
// refs to avoid stale closures.
|
||||||
|
const valRef = useRef(value); valRef.current = value;
|
||||||
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||||||
|
const disRef = useRef(disabled); disRef.current = disabled;
|
||||||
|
const stepRef = useRef(step); stepRef.current = step;
|
||||||
|
const maxRef = useRef(max); maxRef.current = max;
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (disRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||||||
|
const nv = Math.max(0, Math.min(maxRef.current, valRef.current + d));
|
||||||
|
if (nv !== valRef.current) cbRef.current(nv);
|
||||||
|
};
|
||||||
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="range" min={0} max={max} value={v} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
|
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer')}
|
||||||
|
style={{ background: `linear-gradient(to right, ${accent} ${pct}%, #d8cfb8 ${pct}%)`, borderColor: accent }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segmented — radio-style multi-choice (AGC, Processor preset).
|
||||||
|
function Segmented({ value, options, onChange, disabled }: {
|
||||||
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.v} type="button" disabled={disabled} onClick={() => onChange(o.v)}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||||
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chip — a compact on/off pill (NB/NR/ANF/VOX/MON…).
|
||||||
|
function Chip({ on, onClick, label, disabled, accent = 'emerald' }: {
|
||||||
|
on: boolean; onClick: () => void; label: string; disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber';
|
||||||
|
}) {
|
||||||
|
const onCls = {
|
||||||
|
emerald: 'bg-emerald-600 border-emerald-600 text-white',
|
||||||
|
violet: 'bg-violet-600 border-violet-600 text-white',
|
||||||
|
cyan: 'bg-cyan-600 border-cyan-600 text-white',
|
||||||
|
amber: 'bg-amber-500 border-amber-500 text-white',
|
||||||
|
}[accent];
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} disabled={disabled}
|
||||||
|
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors disabled:opacity-30',
|
||||||
|
on ? onCls : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelRow({ label, on, onToggle, value, onLevel, disabled, accent, sliderAccent }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||||
|
disabled?: boolean; accent?: 'emerald' | 'violet' | 'cyan' | 'amber'; sliderAccent?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} disabled={disabled} accent={accent} />
|
||||||
|
<Slider value={value} disabled={disabled || !on} accent={sliderAccent} onChange={onLevel} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
|
const METER_SEGMENTS = 26;
|
||||||
|
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor }: {
|
||||||
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
|
segColor?: (frac: number) => string;
|
||||||
|
}) {
|
||||||
|
const span = hi - lo;
|
||||||
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border/70 px-2.5 py-2 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0">
|
||||||
|
<div className="flex items-baseline justify-between gap-1 mb-1.5">
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className="text-sm font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90">
|
||||||
|
{display !== undefined ? display : (
|
||||||
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
|
<div className="flex gap-[2px] h-3 items-stretch rounded-[3px] bg-black/10 p-[2px]">
|
||||||
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
|
const on = i < lit;
|
||||||
|
const frac = i / METER_SEGMENTS;
|
||||||
|
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||||
|
style={on
|
||||||
|
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||||
|
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{extra && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexPanel() {
|
||||||
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
|
const hold = useRef<Record<string, number>>({});
|
||||||
|
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||||
|
// read steadily instead of jumping every poll.
|
||||||
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
|
const peakHold = (key: string, val: number) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const p = peak.current[key];
|
||||||
|
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; }
|
||||||
|
return p.v;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const s = (await GetFlexState()) as any as FlexState;
|
||||||
|
if (!alive) return;
|
||||||
|
setSt((prev) => {
|
||||||
|
const now = Date.now();
|
||||||
|
const merged: any = { ...s };
|
||||||
|
for (const k in hold.current) {
|
||||||
|
if (hold.current[k] > now) merged[k] = (prev as any)[k];
|
||||||
|
}
|
||||||
|
return merged as FlexState;
|
||||||
|
});
|
||||||
|
} catch { /* not connected */ }
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 400);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// PowerGenius XL direct connection (fan mode), independent of the Flex link.
|
||||||
|
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||||
|
hold.current[key] = Date.now() + 900;
|
||||||
|
setSt((p) => ({ ...p, [key]: val }));
|
||||||
|
send().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const off = !st.available;
|
||||||
|
const rxOff = off || !st.rx_avail;
|
||||||
|
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||||
|
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||||
|
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||||
|
const CW_BW = [100, 200, 300, 400, 500];
|
||||||
|
const SSB_BW = [1800, 2100, 2400, 2800, 3000, 4000, 6000];
|
||||||
|
const curBW = Math.max(0, (st.filter_hi || 0) - (st.filter_lo || 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
||||||
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||||||
|
{/* Header strip */}
|
||||||
|
<div className="flex items-center gap-3 rounded-xl px-4 py-3 text-white shadow-sm"
|
||||||
|
style={{ background: 'linear-gradient(135deg,#1e293b,#0f172a)' }}>
|
||||||
|
<Radio className="size-6 text-sky-400" />
|
||||||
|
<div className="flex flex-col leading-tight">
|
||||||
|
<span className="text-base font-extrabold tracking-tight">{st.model || 'FlexRadio'}</span>
|
||||||
|
<span className="text-[11px] text-slate-400">SmartSDR remote control</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-extrabold tracking-wider',
|
||||||
|
!st.available ? 'bg-slate-700 text-slate-300'
|
||||||
|
: st.transmitting ? 'bg-rose-500 text-white shadow-[0_0_16px] shadow-rose-500/60' : 'bg-emerald-500 text-white')}>
|
||||||
|
<span className="size-2 rounded-full bg-current" />
|
||||||
|
{!st.available ? 'OFFLINE' : st.transmitting ? 'TX' : 'RX'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{off && (
|
||||||
|
<div className="text-center text-sm text-muted-foreground py-6">
|
||||||
|
Waiting for the FlexRadio… (set CAT to FlexRadio and connect)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TX + RX columns */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{/* TRANSMIT */}
|
||||||
|
<Card icon={Zap} title="Transmit" accent="#dc2626">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">RF Power</span>
|
||||||
|
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||||||
|
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.rf_power}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">Tune Pwr</span>
|
||||||
|
<Slider value={st.tune_power} disabled={off} accent="#d97706" onChange={(v) => change('tune_power', v, () => FlexSetTunePower(v))} />
|
||||||
|
<span className="w-9 text-right text-sm font-mono font-bold tabular-nums">{st.tune_power}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-0.5">
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => change('tune', !st.tune, () => FlexTune(!st.tune))}
|
||||||
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
st.tune ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50' : 'bg-card text-amber-700 border-amber-400 hover:bg-amber-50')}>
|
||||||
|
TUNE
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => change('transmitting', !st.transmitting, () => FlexMox(!st.transmitting))}
|
||||||
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
st.transmitting ? 'bg-rose-600 text-white border-rose-600 shadow-[0_0_14px] shadow-rose-600/50' : 'bg-card text-rose-700 border-rose-400 hover:bg-rose-50')}>
|
||||||
|
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCW ? (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={st.proc_enable} disabled={off} label="PROC" accent="violet"
|
||||||
|
onClick={() => change('proc_enable', !st.proc_enable, () => FlexSetProcessor(!st.proc_enable))} />
|
||||||
|
<Segmented value={String(st.proc_level)} options={PROC} disabled={off || !st.proc_enable}
|
||||||
|
onChange={(v) => change('proc_level', parseInt(v, 10), () => FlexSetProcessorLevel(parseInt(v, 10)))} />
|
||||||
|
<span className="flex-1" />
|
||||||
|
</div>
|
||||||
|
<LevelRow label="VOX" on={st.vox_enable} disabled={off} value={st.vox_level} sliderAccent="#16a34a"
|
||||||
|
onToggle={() => change('vox_enable', !st.vox_enable, () => FlexSetVox(!st.vox_enable))}
|
||||||
|
onLevel={(v) => change('vox_level', v, () => FlexSetVoxLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground pl-0.5">VOX Dly</span>
|
||||||
|
<Slider value={st.vox_delay} disabled={off || !st.vox_enable} accent="#16a34a"
|
||||||
|
onChange={(v) => change('vox_delay', v, () => FlexSetVoxDelay(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.vox_delay}</span>
|
||||||
|
</div>
|
||||||
|
<LevelRow label="MON" on={st.mon} disabled={off} value={st.mon_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
|
onToggle={() => change('mon', !st.mon, () => FlexSetMon(!st.mon))}
|
||||||
|
onLevel={(v) => change('mon_level', v, () => FlexSetMonLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">MIC</span>
|
||||||
|
<Slider value={st.mic_level} disabled={off} accent="#2563eb" onChange={(v) => change('mic_level', v, () => FlexSetMic(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_level}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* CW keyer controls (replace VOX/PROC/MIC when the slice is in CW). */
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Speed</span>
|
||||||
|
<Slider value={st.cw_speed} disabled={off} max={60} accent="#0d9488" onChange={(v) => change('cw_speed', v, () => FlexSetCWSpeed(v))} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_speed} wpm</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Pitch</span>
|
||||||
|
<Slider value={st.cw_pitch} disabled={off} max={1000} step={10} accent="#7c3aed" onChange={(v) => change('cw_pitch', v, () => FlexSetCWPitch(v))} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_pitch} Hz</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold text-muted-foreground">Delay</span>
|
||||||
|
<Slider value={st.cw_break_in_delay} disabled={off} max={1000} step={1} accent="#d97706" onChange={(v) => change('cw_break_in_delay', v, () => FlexSetCWBreakInDelay(v))} />
|
||||||
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.cw_break_in_delay} ms</span>
|
||||||
|
</div>
|
||||||
|
<LevelRow label="STONE" on={st.cw_sidetone} disabled={off} value={st.cw_mon_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
|
onToggle={() => change('cw_sidetone', !st.cw_sidetone, () => FlexSetCWSidetone(!st.cw_sidetone))}
|
||||||
|
onLevel={(v) => change('cw_mon_level', v, () => FlexSetSidetoneLevel(v))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* RECEIVE */}
|
||||||
|
<Card icon={AudioLines} title="Receive (active slice)" accent="#0891b2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AGC</span>
|
||||||
|
<Segmented value={(st.agc_mode || 'med').toLowerCase()} options={AGC} disabled={rxOff}
|
||||||
|
onChange={(v) => change('agc_mode', v, () => FlexSetAGCMode(v))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Thresh</span>
|
||||||
|
<Slider value={st.agc_threshold} disabled={rxOff} accent="#64748b" onChange={(v) => change('agc_threshold', v, () => FlexSetAGCThreshold(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.agc_threshold}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">AF</span>
|
||||||
|
<Slider value={st.audio_level} disabled={rxOff} accent="#16a34a" onChange={(v) => change('audio_level', v, () => FlexSetAudioLevel(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.audio_level}</span>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<LevelRow label="NB" on={st.nb} disabled={rxOff} value={st.nb_level} accent="amber" sliderAccent="#d97706"
|
||||||
|
onToggle={() => change('nb', !st.nb, () => FlexSetNB(!st.nb))}
|
||||||
|
onLevel={(v) => change('nb_level', v, () => FlexSetNBLevel(v))} />
|
||||||
|
<LevelRow label="NR" on={st.nr} disabled={rxOff} value={st.nr_level} accent="cyan" sliderAccent="#0891b2"
|
||||||
|
onToggle={() => change('nr', !st.nr, () => FlexSetNR(!st.nr))}
|
||||||
|
onLevel={(v) => change('nr_level', v, () => FlexSetNRLevel(v))} />
|
||||||
|
{/* ANF (auto notch) is for carriers in voice — meaningless on a CW tone, so hide it in CW. */}
|
||||||
|
{!isCW && (
|
||||||
|
<LevelRow label="ANF" on={st.anf} disabled={rxOff} value={st.anf_level} accent="violet" sliderAccent="#7c3aed"
|
||||||
|
onToggle={() => change('anf', !st.anf, () => FlexSetANF(!st.anf))}
|
||||||
|
onLevel={(v) => change('anf_level', v, () => FlexSetANFLevel(v))} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isCW && (
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<LevelRow label="APF" on={st.apf} disabled={rxOff} value={st.apf_level} accent="emerald" sliderAccent="#16a34a"
|
||||||
|
onToggle={() => change('apf', !st.apf, () => FlexSetAPF(!st.apf))}
|
||||||
|
onLevel={(v) => change('apf_level', v, () => FlexSetAPFLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{CW_BW.map((bw) => (
|
||||||
|
<button key={bw} type="button" disabled={rxOff}
|
||||||
|
onClick={() => { setSt((p) => { const c = ((p.filter_lo || 0) + (p.filter_hi || 0)) ? Math.round(((p.filter_lo || 0) + (p.filter_hi || 0)) / 2) : (p.cw_pitch || 600); return { ...p, filter_lo: c - bw / 2, filter_hi: c + bw / 2 }; }); FlexSetCWFilter(bw).catch(() => {}); }}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||||
|
Math.abs(curBW - bw) <= 1 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{bw}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 font-mono">Hz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isCW && (
|
||||||
|
<div className="border-t border-border/60 pt-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-14 shrink-0 text-[11px] font-bold text-muted-foreground">Filter</span>
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{SSB_BW.map((bw) => (
|
||||||
|
<button key={bw} type="button" disabled={rxOff}
|
||||||
|
onClick={() => {
|
||||||
|
const lsb = (st.mode || '').toUpperCase().includes('LSB');
|
||||||
|
let lo: number, hi: number;
|
||||||
|
if (lsb) { const near = (st.filter_hi && st.filter_hi < 0) ? st.filter_hi : -100; hi = near; lo = near - bw; }
|
||||||
|
else { const near = (st.filter_lo && st.filter_lo > 0) ? st.filter_lo : 100; lo = near; hi = near + bw; }
|
||||||
|
setSt((p) => ({ ...p, filter_lo: lo, filter_hi: hi }));
|
||||||
|
FlexSetFilter(lo, hi).catch(() => {});
|
||||||
|
}}
|
||||||
|
className={cn('px-1.5 py-1 text-[11px] font-bold tracking-wide transition-colors disabled:opacity-30 border-l border-border first:border-l-0',
|
||||||
|
Math.abs(curBW - bw) <= 50 ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{(bw / 1000).toFixed(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted-foreground/70 font-mono">kHz</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* External amplifier (PowerGenius XL) — only when detected. */}
|
||||||
|
{st.amp_available && (
|
||||||
|
<Card icon={Flame} title={`Amplifier${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" disabled={off}
|
||||||
|
onClick={() => change('amp_operate', !st.amp_operate, () => FlexAmpOperate(!st.amp_operate))}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
st.amp_operate ? 'bg-orange-600 text-white border-orange-600 shadow-[0_0_14px] shadow-orange-600/50' : 'bg-card text-orange-700 border-orange-400 hover:bg-orange-50')}>
|
||||||
|
{st.amp_operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{st.amp_operate ? 'Amplifier is in line (transmitting through PA).' : 'Amplifier bypassed (standby).'}
|
||||||
|
</span>
|
||||||
|
{/* 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) && (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? 'PowerGenius connected' : (pg.last_error || 'PowerGenius offline')}>
|
||||||
|
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-rose-500')} />
|
||||||
|
<span className="text-muted-foreground">Fan</span>
|
||||||
|
<select
|
||||||
|
disabled={!pg.connected}
|
||||||
|
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
|
||||||
|
onChange={(e) => { const v = e.target.value; setPg((s) => ({ ...s, fan_mode: v })); PGXLSetFanMode(v).catch(() => {}); }}
|
||||||
|
className="h-8 rounded-md border border-orange-300 bg-card px-2 text-xs font-semibold text-orange-800 outline-none focus:border-orange-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<option value="STANDARD">Standard</option>
|
||||||
|
<option value="CONTEST">Contest</option>
|
||||||
|
<option value="BROADCAST">Broadcast</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{st.amp_fault && st.amp_fault !== 'NONE' && (
|
||||||
|
<span className="px-2 py-1 rounded bg-rose-100 text-rose-800 text-xs font-bold">FAULT: {st.amp_fault}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live meters (UDP VITA-49 stream) */}
|
||||||
|
<Card icon={Gauge} title="Meters">
|
||||||
|
{(() => {
|
||||||
|
const meters = st.meters || [];
|
||||||
|
if (off || meters.length === 0) {
|
||||||
|
return <p className="text-[11px] text-muted-foreground text-center py-2">No meters yet — waiting for the radio's UDP stream…</p>;
|
||||||
|
}
|
||||||
|
const isDbm = (m?: Meter) => !!m && /dbm/i.test(m.unit || '');
|
||||||
|
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
|
||||||
|
// Radio meters (exclude the amplifier's, which we show separately).
|
||||||
|
const radio = (name: string) => meters.find((m) =>
|
||||||
|
(m.name || '').toUpperCase().includes(name) && !(m.src || '').toUpperCase().includes('AMP'));
|
||||||
|
const sig = radio('LEVEL') || radio('SIGNAL');
|
||||||
|
const fwd = radio('FWDPWR');
|
||||||
|
const swr = radio('SWR');
|
||||||
|
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
|
||||||
|
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
|
||||||
|
const accentFor = (m: Meter) => /swr/i.test(`${m.unit}${m.name}`) ? '#dc2626'
|
||||||
|
: /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c'
|
||||||
|
: /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
|
// S-meter: dBm → S-units (S9 = -73 dBm on HF, 6 dB per unit).
|
||||||
|
const sUnit = (dbm: number) => {
|
||||||
|
const s = (dbm + 127) / 6; // S0 = -127 dBm
|
||||||
|
if (s >= 9) {
|
||||||
|
const over = Math.round(dbm + 73); // dB over S9
|
||||||
|
return { display: over > 0 ? `S9+${over}` : 'S9', bar: s };
|
||||||
|
}
|
||||||
|
return { display: `S${Math.max(0, Math.round(s))}`, bar: Math.max(0, s) };
|
||||||
|
};
|
||||||
|
const cur = [
|
||||||
|
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||||
|
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
||||||
|
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||||
|
); })(),
|
||||||
|
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||||
|
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||||
|
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
||||||
|
); })(),
|
||||||
|
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||||
|
{amp.length > 0 && (
|
||||||
|
<div className="border-t border-border/60 pt-2 mt-1">
|
||||||
|
<div className="text-[10px] font-bold tracking-wider text-muted-foreground mb-1.5">AMPLIFIER</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{amp.map((m) => {
|
||||||
|
if (/fwd|pwr/i.test(m.name || '') && isDbm(m)) {
|
||||||
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
||||||
|
}
|
||||||
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Radio, AudioLines, RefreshCw } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
GetIcomState, IcomRefresh,
|
||||||
|
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||||||
|
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type IcomState = {
|
||||||
|
available: boolean; model?: string; mode?: string;
|
||||||
|
af_gain: number; rf_gain: number;
|
||||||
|
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
|
||||||
|
agc?: string; preamp: number; att: number; filter: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZERO: IcomState = {
|
||||||
|
available: false, af_gain: 0, rf_gain: 0,
|
||||||
|
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
|
||||||
|
preamp: 0, att: 0, filter: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
|
||||||
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
|
||||||
|
}) {
|
||||||
|
const v = Math.max(0, Math.min(100, value));
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={100} value={v} disabled={disabled}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||||
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||||
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
||||||
|
'[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
||||||
|
style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Segmented({ value, options, onChange }: {
|
||||||
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||||
|
{options.map((o) => (
|
||||||
|
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||||
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||||
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{o.l}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick}
|
||||||
|
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||||
|
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LevelRow({ label, on, onToggle, value, onLevel }: {
|
||||||
|
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip on={on} onClick={onToggle} label={label} />
|
||||||
|
<Slider value={value} disabled={!on} onChange={onLevel} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend.
|
||||||
|
// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects
|
||||||
|
// the last refresh plus optimistic updates. Front-panel knob changes show after
|
||||||
|
// the next ↻ Refresh.
|
||||||
|
export function IcomPanel() {
|
||||||
|
const [st, setSt] = useState<IcomState>(ZERO);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||||||
|
const refresh = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try { await IcomRefresh(); } catch {}
|
||||||
|
await load();
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
const id = window.setInterval(load, 1500); // cheap cache poll (mode + optimistic state)
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Optimistic local update + fire the command; the cache poll reconciles.
|
||||||
|
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
|
||||||
|
setSt((s) => ({ ...s, ...patch }));
|
||||||
|
fn().catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!st.available) {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||||
|
Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</span> : null}</div>
|
||||||
|
<button type="button" onClick={refresh} disabled={busy}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
|
||||||
|
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card icon={Radio} title="Receive" accent="#2563eb">
|
||||||
|
<Row label="AF">
|
||||||
|
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="RF">
|
||||||
|
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
||||||
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||||||
|
</Row>
|
||||||
|
<Row label="AGC">
|
||||||
|
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||||||
|
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Preamp">
|
||||||
|
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||||||
|
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Att">
|
||||||
|
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
||||||
|
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
<Row label="Filter">
|
||||||
|
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||||||
|
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||||||
|
</Row>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card icon={AudioLines} title="Noise / Notch" accent="#16a34a">
|
||||||
|
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||||
|
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||||
|
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||||||
|
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
||||||
|
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
||||||
|
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||||
|
<span className="text-xs text-muted-foreground">Auto notch filter</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+215
-142
@@ -14,22 +14,15 @@ function saveMapView(m: L.Map) {
|
|||||||
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
writeUiPref('opslog.mapView', JSON.stringify({ lat: c.lat, lon: c.lng, zoom: m.getZoom() }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// MainMap — Log4OM-style dual map for the Main tab:
|
// The Main tab is built from two independent map panes that the operator can
|
||||||
// • Left: a world map with the great-circle path drawn from the operator to
|
// place on either side (Settings → Main view):
|
||||||
// the contacted station, plus distance + short/long-path azimuth.
|
// • WorldMap ("map1"): a world map with the great-circle path from the
|
||||||
// • Right: a street map zoomed onto the contacted station's grid locator.
|
// operator to the contacted station, distance, short/long-path azimuth and
|
||||||
|
// the antenna beam lobe.
|
||||||
|
// • LocatorMap ("map2"): a street map zoomed onto the contacted station's grid.
|
||||||
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
|
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
|
||||||
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
|
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
|
||||||
|
|
||||||
interface Props {
|
|
||||||
fromGrid: string; // operator grid (active profile)
|
|
||||||
toGrid: string; // contacted-station grid
|
|
||||||
fromLabel?: string; // operator callsign
|
|
||||||
toLabel?: string; // DX callsign
|
|
||||||
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
|
|
||||||
beamWidth?: number; // beamwidth (deg), default 30
|
|
||||||
}
|
|
||||||
|
|
||||||
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
// unwrapLon makes a lat/lon ring continuous in longitude (each point within
|
||||||
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
// 180° of the previous) so a polygon crossing the antimeridian doesn't snap
|
||||||
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
// across the whole map. Coords may exceed ±180; Leaflet (worldCopyJump) is fine.
|
||||||
@@ -53,6 +46,24 @@ const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
|||||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||||
const OSM_ATTR = '© OpenStreetMap contributors';
|
const OSM_ATTR = '© OpenStreetMap contributors';
|
||||||
|
|
||||||
|
// Selectable basemaps for the world (great-circle) map. All key-free and all
|
||||||
|
// LABELLED (country/continent names). `labelsUrl` adds a transparent place-name
|
||||||
|
// overlay on top of an imagery basemap (so satellite keeps its names too).
|
||||||
|
type BasemapKey = 'light' | 'voyager' | 'street' | 'satellite';
|
||||||
|
const BASEMAPS: Record<BasemapKey, { label: string; url: string; attr: string; subdomains?: string; labelsUrl?: string }> = {
|
||||||
|
light: { label: 'Light', url: CARTO_LIGHT, attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||||
|
voyager: { label: 'Voyager', url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
||||||
|
attr: CARTO_ATTR, subdomains: 'abcd' },
|
||||||
|
street: { label: 'Street', url: OSM, attr: OSM_ATTR },
|
||||||
|
satellite: { label: 'Satellite', url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
|
attr: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||||
|
labelsUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}' },
|
||||||
|
};
|
||||||
|
function loadBasemap(): BasemapKey {
|
||||||
|
const v = localStorage.getItem('opslog.mapBasemap');
|
||||||
|
return v === 'voyager' || v === 'street' || v === 'satellite' ? v : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
function dot(color: string): L.DivIcon {
|
function dot(color: string): L.DivIcon {
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
@@ -62,14 +73,24 @@ function dot(color: string): L.DivIcon {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth }: Props) {
|
interface WorldProps {
|
||||||
|
fromGrid: string; // operator grid (active profile)
|
||||||
|
toGrid: string; // contacted-station grid
|
||||||
|
fromLabel?: string; // operator callsign
|
||||||
|
toLabel?: string; // DX callsign
|
||||||
|
beamAzimuths?: number[]; // radiating heading(s) (deg) → draw a beam lobe each
|
||||||
|
beamWidth?: number; // beamwidth (deg), default 30
|
||||||
|
boomAzimuth?: number | null; // mechanical boom (rotor) heading → grey reference line
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorldMap — great-circle path + beam lobe(s), the "map1" pane.
|
||||||
|
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
||||||
const worldRef = useRef<HTMLDivElement>(null);
|
const worldRef = useRef<HTMLDivElement>(null);
|
||||||
const locatorRef = useRef<HTMLDivElement>(null);
|
|
||||||
const worldMap = useRef<L.Map | null>(null);
|
const worldMap = useRef<L.Map | null>(null);
|
||||||
const locatorMap = useRef<L.Map | null>(null);
|
|
||||||
// Layers we add/remove as the QSO changes (kept separate from the basemap).
|
|
||||||
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
const worldOverlay = useRef<L.LayerGroup | null>(null);
|
||||||
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
const baseLayer = useRef<L.TileLayer | null>(null);
|
||||||
|
const labelsLayer = useRef<L.TileLayer | null>(null);
|
||||||
|
const [basemap, setBasemap] = useState<BasemapKey>(loadBasemap);
|
||||||
|
|
||||||
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
|
// Auto-zoom the world map to fit QTH→DX on each QSO. When off, the operator
|
||||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||||
@@ -83,100 +104,110 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
if (worldRef.current && !worldMap.current) {
|
if (worldRef.current && !worldMap.current) {
|
||||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
||||||
.setView([20, 0], 1);
|
.setView([20, 0], 1);
|
||||||
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
|
const bm = BASEMAPS[basemap];
|
||||||
|
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
||||||
worldOverlay.current = L.layerGroup().addTo(m);
|
worldOverlay.current = L.layerGroup().addTo(m);
|
||||||
worldMap.current = m;
|
worldMap.current = m;
|
||||||
// Restore the saved free-pan view when not auto-zooming.
|
|
||||||
const sv = loadMapView();
|
const sv = loadMapView();
|
||||||
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
if (!autoZoomRef.current && sv) m.setView([sv.lat, sv.lon], sv.zoom);
|
||||||
// Remember the view as the user pans/zooms (only meaningful when free).
|
|
||||||
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
m.on('moveend', () => { if (!autoZoomRef.current) saveMapView(m); });
|
||||||
}
|
}
|
||||||
if (locatorRef.current && !locatorMap.current) {
|
const t = window.setTimeout(() => { worldMap.current?.invalidateSize(); }, 80);
|
||||||
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
|
||||||
.setView([20, 0], 2);
|
|
||||||
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
|
||||||
locatorOverlay.current = L.layerGroup().addTo(m);
|
|
||||||
locatorMap.current = m;
|
|
||||||
}
|
|
||||||
// The Main tab may have just become visible — fix tile sizing.
|
|
||||||
const t = window.setTimeout(() => {
|
|
||||||
worldMap.current?.invalidateSize();
|
|
||||||
locatorMap.current?.invalidateSize();
|
|
||||||
}, 80);
|
|
||||||
return () => window.clearTimeout(t);
|
return () => window.clearTimeout(t);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Redraw overlays whenever the operator/DX grids change.
|
// Swap the basemap (and its optional place-name overlay) when the operator
|
||||||
|
// picks a different one. Vector overlays (path/beam) live in Leaflet's
|
||||||
|
// overlayPane, always above any tile layer, so nothing to re-stack there.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const wm = worldMap.current, lm = locatorMap.current;
|
const m = worldMap.current;
|
||||||
const wo = worldOverlay.current, lo = locatorOverlay.current;
|
if (!m) return;
|
||||||
if (!wm || !lm || !wo || !lo) return;
|
if (baseLayer.current) { m.removeLayer(baseLayer.current); baseLayer.current = null; }
|
||||||
|
if (labelsLayer.current) { m.removeLayer(labelsLayer.current); labelsLayer.current = null; }
|
||||||
|
const bm = BASEMAPS[basemap];
|
||||||
|
baseLayer.current = L.tileLayer(bm.url, { attribution: bm.attr, subdomains: bm.subdomains ?? 'abc', maxZoom: 19 }).addTo(m);
|
||||||
|
if (bm.labelsUrl) labelsLayer.current = L.tileLayer(bm.labelsUrl, { maxZoom: 19 }).addTo(m);
|
||||||
|
}, [basemap]);
|
||||||
|
|
||||||
|
// Redraw overlays whenever the operator/DX grids (or beam) change.
|
||||||
|
useEffect(() => {
|
||||||
|
const wm = worldMap.current, wo = worldOverlay.current;
|
||||||
|
if (!wm || !wo) return;
|
||||||
wo.clearLayers();
|
wo.clearLayers();
|
||||||
lo.clearLayers();
|
|
||||||
|
|
||||||
const from = gridToLatLon(fromGrid);
|
const from = gridToLatLon(fromGrid);
|
||||||
const to = gridToLatLon(toGrid);
|
const to = gridToLatLon(toGrid);
|
||||||
|
|
||||||
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
|
||||||
if (from && beamAzimuths && beamAzimuths.length) {
|
|
||||||
const half = (beamWidth ?? 30) / 2;
|
|
||||||
const D = 5500; // lobe length (km)
|
|
||||||
// A great circle pointing poleward runs to lat ±90, where Mercator is
|
|
||||||
// infinite — the line then snaps across the top of the map. Generate the
|
|
||||||
// radial with plenty of points (smooth curve) and STOP it just before the
|
|
||||||
// pole, so a north/south beam draws a clean line toward the edge instead.
|
|
||||||
const radial = (b: number): [number, number][] => {
|
|
||||||
const pts: [number, number][] = [];
|
|
||||||
const N = 64;
|
|
||||||
for (let i = 1; i <= N; i++) {
|
|
||||||
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
|
|
||||||
pts.push([d.lat, d.lon]);
|
|
||||||
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
|
|
||||||
}
|
|
||||||
return pts;
|
|
||||||
};
|
|
||||||
for (const az of beamAzimuths) {
|
|
||||||
const arc: [number, number][] = [];
|
|
||||||
for (let b = az - half; b <= az + half + 0.001; b += 2) {
|
|
||||||
const d = destinationPoint(from.lat, from.lon, b, D);
|
|
||||||
arc.push([d.lat, d.lon]);
|
|
||||||
}
|
|
||||||
const ring = unwrapLon([
|
|
||||||
[from.lat, from.lon],
|
|
||||||
...radial(az - half),
|
|
||||||
...arc,
|
|
||||||
...radial(az + half).reverse(),
|
|
||||||
]);
|
|
||||||
// Near a pole the lobe's two edges diverge wildly (one runs NW, the
|
|
||||||
// other NE) and look broken on a Mercator map — so for a poleward beam
|
|
||||||
// show ONLY the clean boresight (below). Otherwise draw the filled lobe.
|
|
||||||
if (!ring.some(([la]) => Math.abs(la) > 78)) {
|
|
||||||
L.polygon(ring as L.LatLngExpression[], {
|
|
||||||
color: '#dc2626', weight: 1, opacity: 0.5, fillColor: '#dc2626', fillOpacity: 0.14,
|
|
||||||
}).addTo(wo);
|
|
||||||
}
|
|
||||||
// Boresight (dashed centre line) — always; great-circle polyline is safe.
|
|
||||||
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
|
||||||
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
|
|
||||||
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Left: world + great-circle arc ──
|
|
||||||
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
|
|
||||||
if (to) {
|
|
||||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
|
||||||
.bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo);
|
|
||||||
}
|
|
||||||
if (from && to) {
|
if (from && to) {
|
||||||
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
|
L.marker([from.lat, from.lon], { icon: dot('#2563eb'), title: fromLabel || 'QTH' })
|
||||||
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
|
.bindTooltip(`${fromLabel ? fromLabel + ' · ' : ''}${fromGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
// Only re-frame the map when auto-zoom is on; otherwise keep the user's
|
.addTo(wo);
|
||||||
// chosen (remembered) view so the beam heading stays visible.
|
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||||
|
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
|
.addTo(wo);
|
||||||
|
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 128);
|
||||||
|
// smoothFactor: 0 keeps every vertex (Leaflet otherwise simplifies the
|
||||||
|
// line, which makes a smooth arc look angular/bumpy).
|
||||||
|
L.polyline(unwrapLon(pts) as L.LatLngExpression[],
|
||||||
|
{ color: '#2563eb', weight: 2, opacity: 0.85, smoothFactor: 0 }).addTo(wo);
|
||||||
|
|
||||||
|
// ── Antenna beam lobe(s) (drawn first, under the arc/markers) ──
|
||||||
|
if (beamAzimuths && beamAzimuths.length) {
|
||||||
|
const half = (beamWidth ?? 30) / 2;
|
||||||
|
const D = 5500; // lobe length (km)
|
||||||
|
// Great-circle radial out to distance D, stopping just short of the pole
|
||||||
|
// so a poleward line doesn't snap across the top of the Mercator map.
|
||||||
|
const radial = (b: number): [number, number][] => {
|
||||||
|
const out: [number, number][] = [];
|
||||||
|
const N = 64;
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const d = destinationPoint(from.lat, from.lon, b, (D * i) / N);
|
||||||
|
out.push([d.lat, d.lon]);
|
||||||
|
if (Math.abs(d.lat) > 86) break; // near a pole — stop before the snap
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
for (const az of beamAzimuths) {
|
||||||
|
// Draw the lobe as a FAN of translucent great-circle radials, not a
|
||||||
|
// filled polygon: a polygon breaks badly near the poles on Mercator
|
||||||
|
// (its edges run off toward ±90° and the fill smears across the map),
|
||||||
|
// while each radial LINE stays clean. The overlapping lines read as a
|
||||||
|
// lobe — solid near the antenna, fanning out toward the front. Works
|
||||||
|
// for any azimuth, north/south included.
|
||||||
|
for (let b = az - half; b <= az + half + 0.001; b += 1.5) {
|
||||||
|
const line = unwrapLon([[from.lat, from.lon], ...radial(b)]);
|
||||||
|
L.polyline(line as L.LatLngExpression[], { color: '#ff2d2d', weight: 6, opacity: 0.12, smoothFactor: 0 }).addTo(wo);
|
||||||
|
}
|
||||||
|
const cl = unwrapLon([[from.lat, from.lon], ...radial(az)]);
|
||||||
|
// Dark casing under the boresight so the bright dashed line stays
|
||||||
|
// readable on any basemap (esp. dark satellite imagery). Same dashArray
|
||||||
|
// as the red line so the casing tracks each dash — otherwise the wide
|
||||||
|
// casing peeks through the gaps and the line looks bumpy.
|
||||||
|
L.polyline(cl as L.LatLngExpression[], { color: '#000', weight: 4, opacity: 0.4, dashArray: '5 4', smoothFactor: 0 }).addTo(wo);
|
||||||
|
L.polyline(cl as L.LatLngExpression[], { color: '#ff2d2d', weight: 2, opacity: 0.95, dashArray: '5 4', smoothFactor: 0 })
|
||||||
|
.bindTooltip(`Beam ${Math.round(az)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mechanical boom (rotor) direction — thin grey dashed line. Drawn when the
|
||||||
|
// Ultrabeam radiates elsewhere (reverse/bi) so the boom heading stays visible
|
||||||
|
// next to the red radiating lobe(s).
|
||||||
|
if (boomAzimuth != null) {
|
||||||
|
const bpts: [number, number][] = [[from.lat, from.lon]];
|
||||||
|
const N = 64, D = 5500;
|
||||||
|
for (let i = 1; i <= N; i++) {
|
||||||
|
const d = destinationPoint(from.lat, from.lon, boomAzimuth, (D * i) / N);
|
||||||
|
bpts.push([d.lat, d.lon]);
|
||||||
|
if (Math.abs(d.lat) > 86) break;
|
||||||
|
}
|
||||||
|
L.polyline(unwrapLon(bpts) as L.LatLngExpression[], { color: '#64748b', weight: 1.5, opacity: 0.85, dashArray: '3 4', smoothFactor: 0 })
|
||||||
|
.bindTooltip(`Boom ${Math.round(boomAzimuth)}°`, { permanent: false, direction: 'top' }).addTo(wo);
|
||||||
|
}
|
||||||
|
|
||||||
if (autoZoom) {
|
if (autoZoom) {
|
||||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression)); // include the arc
|
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||||
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
|
||||||
}
|
}
|
||||||
} else if (autoZoom && to) {
|
} else if (autoZoom && to) {
|
||||||
@@ -184,8 +215,89 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
} else if (autoZoom && from) {
|
} else if (autoZoom && from) {
|
||||||
wm.setView([from.lat, from.lon], 3);
|
wm.setView([from.lat, from.lon], 3);
|
||||||
}
|
}
|
||||||
|
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
|
||||||
|
|
||||||
// ── Right: street map on the DX locator ──
|
const path = pathBetween(fromGrid, toGrid);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||||
|
<div ref={worldRef} className="absolute inset-0" />
|
||||||
|
{/* Basemap picker — Light / Street / Satellite (key-free tiles). */}
|
||||||
|
<div className="absolute top-1 left-12 z-[500] flex rounded-md overflow-hidden shadow border border-border backdrop-blur">
|
||||||
|
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setBasemap(k); writeUiPref('opslog.mapBasemap', k); }}
|
||||||
|
title={`Basemap: ${BASEMAPS[k].label}`}
|
||||||
|
className={`px-2 py-1 text-[11px] font-medium transition-colors ${
|
||||||
|
basemap === k ? 'bg-primary text-primary-foreground' : 'bg-card/90 text-muted-foreground hover:bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{BASEMAPS[k].label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
|
||||||
|
(remembered across restarts), so the beam heading stays visible. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const v = !autoZoom;
|
||||||
|
setAutoZoom(v);
|
||||||
|
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
||||||
|
const m = worldMap.current;
|
||||||
|
if (!v && m) saveMapView(m);
|
||||||
|
}}
|
||||||
|
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
||||||
|
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
||||||
|
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Zoom DX
|
||||||
|
</button>
|
||||||
|
{path && (
|
||||||
|
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
||||||
|
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
||||||
|
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
||||||
|
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
||||||
|
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocatorProps {
|
||||||
|
toGrid: string; // contacted-station grid
|
||||||
|
toLabel?: string; // DX callsign
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocatorMap — street map zoomed onto the DX grid, the "map2" pane.
|
||||||
|
export function LocatorMap({ toGrid, toLabel }: LocatorProps) {
|
||||||
|
const locatorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const locatorMap = useRef<L.Map | null>(null);
|
||||||
|
const locatorOverlay = useRef<L.LayerGroup | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (locatorRef.current && !locatorMap.current) {
|
||||||
|
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
|
||||||
|
.setView([20, 0], 2);
|
||||||
|
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
|
||||||
|
locatorOverlay.current = L.layerGroup().addTo(m);
|
||||||
|
locatorMap.current = m;
|
||||||
|
}
|
||||||
|
const t = window.setTimeout(() => { locatorMap.current?.invalidateSize(); }, 80);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const lm = locatorMap.current, lo = locatorOverlay.current;
|
||||||
|
if (!lm || !lo) return;
|
||||||
|
lo.clearLayers();
|
||||||
|
const to = gridToLatLon(toGrid);
|
||||||
if (to) {
|
if (to) {
|
||||||
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
|
||||||
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
|
||||||
@@ -195,57 +307,18 @@ export function MainMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, be
|
|||||||
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
|
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
|
||||||
}
|
}
|
||||||
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
|
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
|
||||||
} else if (from) {
|
|
||||||
lm.setView([from.lat, from.lon], 5);
|
|
||||||
}
|
}
|
||||||
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
|
setTimeout(() => { lm.invalidateSize(); }, 0);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [toGrid, toLabel]);
|
||||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
|
|
||||||
|
|
||||||
const path = pathBetween(fromGrid, toGrid);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0 gap-2 p-2">
|
<div className="relative isolate h-full w-full rounded-lg overflow-hidden border border-border">
|
||||||
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
<div ref={locatorRef} className="absolute inset-0" />
|
||||||
<div className="relative isolate rounded-lg overflow-hidden border border-border">
|
{!gridToLatLon(toGrid) && (
|
||||||
<div ref={worldRef} className="absolute inset-0" />
|
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
|
||||||
{/* Auto-zoom toggle: on = frame QTH→DX each QSO; off = free pan/zoom
|
Enter a grid or look up the callsign to center the map.
|
||||||
(remembered across restarts), so the beam heading stays visible. */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const v = !autoZoom;
|
|
||||||
setAutoZoom(v);
|
|
||||||
writeUiPref('opslog.mapAutoZoomDX', v ? '1' : '0');
|
|
||||||
const m = worldMap.current;
|
|
||||||
if (!v && m) saveMapView(m); // entering free mode → remember current view
|
|
||||||
// (turning it on re-frames via the redraw effect, which depends on autoZoom)
|
|
||||||
}}
|
|
||||||
title={autoZoom ? 'Auto-zoom to DX is ON — click for free pan/zoom (remembered)' : 'Free pan/zoom — click to auto-zoom to the DX'}
|
|
||||||
className={`absolute top-1 right-1 z-[500] rounded-md px-2 py-1 text-[11px] font-medium shadow border backdrop-blur transition-colors ${
|
|
||||||
autoZoom ? 'bg-primary text-primary-foreground border-primary' : 'bg-card/90 text-muted-foreground border-border hover:bg-card'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Zoom DX
|
|
||||||
</button>
|
|
||||||
{path && (
|
|
||||||
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
|
|
||||||
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
|
|
||||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
|
|
||||||
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
|
|
||||||
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="relative isolate rounded-lg overflow-hidden border border-border">
|
)}
|
||||||
<div ref={locatorRef} className="absolute inset-0" />
|
|
||||||
{!gridToLatLon(toGrid) && (
|
|
||||||
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
|
|
||||||
Enter a grid or look up the callsign to center the map.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
|
||||||
|
import { AllCommunityModule, ModuleRegistry, themeQuartz, type ColDef } from 'ag-grid-community';
|
||||||
|
import { AgGridReact } from 'ag-grid-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL } from '../../wailsjs/go/main/App';
|
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL, UploadCallsign } from '../../wailsjs/go/main/App';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
import { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||||
|
|
||||||
|
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||||
|
|
||||||
|
// Warm theme matching the other grids (Recent QSOs / Cluster).
|
||||||
|
const qslTheme = themeQuartz.withParams({
|
||||||
|
fontFamily: 'inherit', fontSize: 12.5, backgroundColor: '#faf6ea', foregroundColor: '#2a2419',
|
||||||
|
headerBackgroundColor: '#e8dfc9', headerTextColor: '#5a4f3a', headerFontWeight: 600,
|
||||||
|
oddRowBackgroundColor: '#f5efe0', rowHoverColor: '#ecdcb4', selectedRowBackgroundColor: '#f0d9a8',
|
||||||
|
borderColor: '#c8b994', rowBorder: { color: '#d8c9a8', width: 1 }, columnBorder: { color: '#d8c9a8', width: 1 },
|
||||||
|
cellHorizontalPadding: 10, rowHeight: 30, headerHeight: 32, spacing: 4, accentColor: '#b8410c', iconSize: 12,
|
||||||
|
});
|
||||||
|
|
||||||
type UploadRow = {
|
type UploadRow = {
|
||||||
id: number; qso_date: string; callsign: string;
|
id: number; qso_date: string; callsign: string;
|
||||||
band: string; mode: string; country: string; status: string;
|
band: string; mode: string; country: string; status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
||||||
|
{ field: 'qso_date', headerName: 'Date UTC', valueFormatter: (p) => fmtDate(p.value), minWidth: 150 },
|
||||||
|
{ field: 'callsign', headerName: 'Callsign', cellClass: 'font-mono font-bold', width: 130 },
|
||||||
|
{ field: 'band', headerName: 'Band', width: 90 },
|
||||||
|
{ field: 'mode', headerName: 'Mode', width: 100 },
|
||||||
|
{ field: 'country', headerName: 'Country', flex: 1, minWidth: 140 },
|
||||||
|
{ field: 'status', headerName: 'Sent', width: 90, valueFormatter: (p) => p.value || '—' },
|
||||||
|
];
|
||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_slot: boolean;
|
||||||
@@ -23,6 +45,8 @@ type Confirmation = {
|
|||||||
const SERVICES = [
|
const SERVICES = [
|
||||||
{ v: 'qrz', label: 'QRZ.com' },
|
{ v: 'qrz', label: 'QRZ.com' },
|
||||||
{ v: 'clublog', label: 'Club Log' },
|
{ v: 'clublog', label: 'Club Log' },
|
||||||
|
{ v: 'hrdlog', label: 'HRDLog.net' },
|
||||||
|
{ v: 'eqsl', label: 'eQSL.cc' },
|
||||||
{ v: 'lotw', label: 'LoTW' },
|
{ v: 'lotw', label: 'LoTW' },
|
||||||
{ v: 'pota', label: 'POTA hunter log' },
|
{ v: 'pota', label: 'POTA hunter log' },
|
||||||
{ v: 'paper', label: 'Paper QSL' },
|
{ v: 'paper', label: 'Paper QSL' },
|
||||||
@@ -77,6 +101,14 @@ export function fmtQslDate(s?: string): string {
|
|||||||
// and download confirmations, while the rest of the app stays usable.
|
// and download confirmations, while the rest of the app stays usable.
|
||||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||||
const [service, setService] = useState('lotw');
|
const [service, setService] = useState('lotw');
|
||||||
|
// The callsign this profile signs/uploads/downloads as for the selected
|
||||||
|
// service (Force station callsign, else the profile call). Shown so the user
|
||||||
|
// knows WHICH of their calls a download/upload targets in a mixed-call log.
|
||||||
|
const [uploadCall, setUploadCall] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (service === 'pota' || service === 'paper') { setUploadCall(''); return; }
|
||||||
|
UploadCallsign(service).then((c) => setUploadCall(c || '')).catch(() => setUploadCall(''));
|
||||||
|
}, [service]);
|
||||||
const [potaSyncing, setPotaSyncing] = useState(false);
|
const [potaSyncing, setPotaSyncing] = useState(false);
|
||||||
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||||
const [potaErr, setPotaErr] = useState('');
|
const [potaErr, setPotaErr] = useState('');
|
||||||
@@ -141,7 +173,10 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
}
|
}
|
||||||
const [sent, setSent] = useState('R');
|
const [sent, setSent] = useState('R');
|
||||||
const [rows, setRows] = useState<UploadRow[]>([]);
|
const [rows, setRows] = useState<UploadRow[]>([]);
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
// Selection lives in the (virtualized) ag-grid — it handles 25k rows smoothly.
|
||||||
|
const gridRef = useRef<AgGridReact<UploadRow>>(null);
|
||||||
|
const [selectedCount, setSelectedCount] = useState(0);
|
||||||
|
const selectAllNext = useRef(false); // selectAll once after the next data load
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [addNotFound, setAddNotFound] = useState(false);
|
const [addNotFound, setAddNotFound] = useState(false);
|
||||||
@@ -172,10 +207,20 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
return () => { offLog(); offDone(); offConf(); };
|
return () => { offLog(); offDone(); offConf(); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedCount = selected.size;
|
|
||||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
|
||||||
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
const serviceLabel = useMemo(() => SERVICES.find((s) => s.v === service)?.label ?? service, [service]);
|
||||||
|
|
||||||
|
// Grid selection → just track the count; ids are read from the grid at upload.
|
||||||
|
function onUploadSelChanged() {
|
||||||
|
setSelectedCount(gridRef.current?.api?.getSelectedNodes()?.length ?? 0);
|
||||||
|
}
|
||||||
|
// After "Select required" loads new rows, select them all (the old default).
|
||||||
|
function onUploadRowsLoaded() {
|
||||||
|
if (selectAllNext.current) {
|
||||||
|
selectAllNext.current = false;
|
||||||
|
gridRef.current?.api?.selectAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
const shownConfs = useMemo(() => confirmations.filter((c) => {
|
||||||
switch (confFilter) {
|
switch (confFilter) {
|
||||||
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
case 'new': return c.new_dxcc || c.new_band || c.new_slot;
|
||||||
@@ -192,32 +237,22 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
try {
|
try {
|
||||||
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
const r: any = await FindQSOsForUpload(service, sent === '_' ? '' : sent);
|
||||||
const list = (r ?? []) as UploadRow[];
|
const list = (r ?? []) as UploadRow[];
|
||||||
|
selectAllNext.current = true; // pre-select everything once the grid renders
|
||||||
setRows(list);
|
setRows(list);
|
||||||
setSelected(new Set(list.map((x) => x.id)));
|
setSelectedCount(list.length);
|
||||||
setViewMode('upload');
|
setViewMode('upload');
|
||||||
setShowLog(false);
|
setShowLog(false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(String(e?.message ?? e));
|
setError(String(e?.message ?? e));
|
||||||
setRows([]);
|
setRows([]);
|
||||||
setSelected(new Set());
|
setSelectedCount(0);
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
}, [service, sent]);
|
}, [service, sent]);
|
||||||
|
|
||||||
function toggle(id: number) {
|
|
||||||
setSelected((s) => {
|
|
||||||
const n = new Set(s);
|
|
||||||
if (n.has(id)) n.delete(id); else n.add(id);
|
|
||||||
return n;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function toggleAll() {
|
|
||||||
setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upload() {
|
async function upload() {
|
||||||
const ids = rows.filter((r) => selected.has(r.id)).map((r) => r.id);
|
const ids = ((gridRef.current?.api?.getSelectedRows() as UploadRow[] | undefined) ?? []).map((r) => r.id);
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
setLogLines([]); setBusy(true); setLogAction('upload'); setShowLog(true);
|
||||||
try { await UploadQSOsManual(service, ids); }
|
try { await UploadQSOsManual(service, ids); }
|
||||||
@@ -251,6 +286,17 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{uploadCall && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
|
||||||
|
<span
|
||||||
|
className="h-8 inline-flex items-center rounded border border-border bg-muted/40 px-2 font-mono text-sm font-semibold"
|
||||||
|
title="Upload/download is scoped to this callsign (Force station callsign, else the active profile's call)"
|
||||||
|
>
|
||||||
|
{uploadCall}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{service === 'pota' ? (
|
{service === 'pota' ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||||
@@ -463,33 +509,21 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
|||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
<div className="text-sm text-muted-foreground py-10 text-center">Pick a service + sent status, then “Select required”.</div>
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-xs border-collapse">
|
<div className="h-full w-full">
|
||||||
<thead className="sticky top-0 bg-card">
|
<AgGridReact<UploadRow>
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
ref={gridRef}
|
||||||
<th className="py-1.5 px-2 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
|
theme={qslTheme}
|
||||||
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
|
rowData={rows}
|
||||||
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
|
columnDefs={UPLOAD_COLS}
|
||||||
<th className="py-1.5 px-2">Country</th><th className="py-1.5 px-2">Sent</th>
|
defaultColDef={{ sortable: true, resizable: true, filter: true }}
|
||||||
</tr>
|
rowSelection={{ mode: 'multiRow', checkboxes: true, headerCheckbox: true }}
|
||||||
</thead>
|
onSelectionChanged={onUploadSelChanged}
|
||||||
<tbody>
|
onRowDataUpdated={onUploadRowsLoaded}
|
||||||
{rows.map((r) => (
|
animateRows={false}
|
||||||
<tr key={r.id}
|
suppressCellFocus
|
||||||
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', selected.has(r.id) && 'bg-primary/5')}
|
getRowId={(p) => String((p.data as any).id)}
|
||||||
onClick={() => toggle(r.id)}>
|
/>
|
||||||
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}>
|
</div>
|
||||||
<Checkbox checked={selected.has(r.id)} onCheckedChange={() => toggle(r.id)} />
|
|
||||||
</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
|
|
||||||
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
|
|
||||||
<td className="py-1 px-2">{r.band}</td>
|
|
||||||
<td className="py-1 px-2">{r.mode}</td>
|
|
||||||
<td className="py-1 px-2 text-muted-foreground">{r.country}</td>
|
|
||||||
<td className="py-1 px-2 font-mono">{r.status || '—'}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine } from 'lucide-react';
|
||||||
|
|
||||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ type Props = {
|
|||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
};
|
};
|
||||||
@@ -19,25 +20,26 @@ type Props = {
|
|||||||
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
const UPLOAD_TARGETS: { service: string; label: string }[] = [
|
||||||
{ service: 'qrz', label: 'Send to QRZ.com' },
|
{ service: 'qrz', label: 'Send to QRZ.com' },
|
||||||
{ service: 'clublog', label: 'Send to Club Log' },
|
{ service: 'clublog', label: 'Send to Club Log' },
|
||||||
|
{ service: 'hrdlog', label: 'Send to HRDLog.net' },
|
||||||
|
{ service: 'eqsl', label: 'Send to eQSL.cc' },
|
||||||
{ service: 'lotw', label: 'Send to LoTW' },
|
{ service: 'lotw', label: 'Send to LoTW' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
// Lightweight right-click menu for the QSO grids. AG Grid's native context
|
||||||
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
// menu is an Enterprise feature, so this is a plain floating menu driven by
|
||||||
// onCellContextMenu. Closes on any outside click, scroll or Escape.
|
// onCellContextMenu. Stays open until the user clicks outside, presses Escape,
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!menu) return;
|
if (!menu) return;
|
||||||
const close = () => onClose();
|
const close = () => onClose();
|
||||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
window.addEventListener('mousedown', close);
|
window.addEventListener('mousedown', close);
|
||||||
window.addEventListener('scroll', close, true);
|
|
||||||
window.addEventListener('resize', close);
|
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousedown', close);
|
window.removeEventListener('mousedown', close);
|
||||||
window.removeEventListener('scroll', close, true);
|
|
||||||
window.removeEventListener('resize', close);
|
|
||||||
window.removeEventListener('keydown', onKey);
|
window.removeEventListener('keydown', onKey);
|
||||||
};
|
};
|
||||||
}, [menu, onClose]);
|
}, [menu, onClose]);
|
||||||
@@ -105,6 +107,19 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onBulkEdit && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onBulkEdit(menu.ids); onClose(); }}
|
||||||
|
>
|
||||||
|
<PencilLine className="size-4 text-indigo-600" />
|
||||||
|
<span>Bulk edit field… ({n})</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{(onExportSelected || onExportFiltered) && (
|
{(onExportSelected || onExportFiltered) && (
|
||||||
<>
|
<>
|
||||||
<div className="my-1 border-t border-border" />
|
<div className="my-1 border-t border-border" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
AllCommunityModule, ModuleRegistry, themeQuartz,
|
AllCommunityModule, ModuleRegistry, themeQuartz,
|
||||||
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
|
||||||
@@ -46,15 +46,19 @@ type Props = {
|
|||||||
rows: QSOForm[];
|
rows: QSOForm[];
|
||||||
total: number;
|
total: number;
|
||||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||||
onRowSelected?: (id: number | null) => void;
|
onRowSelected?: (ids: number[]) => void;
|
||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
onBulkEdit?: (ids: number[]) => void;
|
||||||
onExportSelected?: (ids: number[]) => void;
|
onExportSelected?: (ids: number[]) => void;
|
||||||
onExportFiltered?: () => void;
|
onExportFiltered?: () => void;
|
||||||
|
// One column per defined award; the cell shows the reference this QSO counts
|
||||||
|
// for (from row.award_refs[CODE], attached by the parent). Hidden by default.
|
||||||
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
const COL_STATE_KEY = 'hamlog.qsoColState.v2';
|
||||||
@@ -218,7 +222,7 @@ export const GROUP_ORDER = [
|
|||||||
'Contest', 'Propagation', 'My station', 'Misc',
|
'Contest', 'Propagation', 'My station', 'Misc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onExportSelected, onExportFiltered }: Props) {
|
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, awardCols }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -244,10 +248,21 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
// Compute initial column defs: all columns defined, but those not marked
|
// Compute initial column defs: all columns defined, but those not marked
|
||||||
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
// defaultVisible start hidden. The user's saved state (loaded onGridReady)
|
||||||
// overrides this so a previously toggled column wins.
|
// overrides this so a previously toggled column wins.
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => COL_CATALOG.map((c) => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const base = COL_CATALOG.map((c) => {
|
||||||
return { ...rest, hide: !defaultVisible };
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
}), []);
|
return { ...rest, hide: !defaultVisible };
|
||||||
|
});
|
||||||
|
const awards: ColDef<QSOForm>[] = (awardCols ?? []).map((a) => ({
|
||||||
|
colId: `award_${a.code}`,
|
||||||
|
headerName: a.code,
|
||||||
|
headerTooltip: `${a.name} — reference this QSO counts for`,
|
||||||
|
width: 110,
|
||||||
|
cellClass: 'text-[11px]',
|
||||||
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
|
}));
|
||||||
|
return [...base, ...awards];
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -273,12 +288,24 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
if (state) saveState(COL_STATE_KEY, state);
|
if (state) saveState(COL_STATE_KEY, state);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The award columns load asynchronously; when they arrive (or change) the
|
||||||
|
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide`
|
||||||
|
// default — wiping the user's saved visibility (award columns reappear,
|
||||||
|
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after
|
||||||
|
// every rebuild so the user's choices win. No-op before the grid is ready.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
const local = loadLocal(COL_STATE_KEY);
|
||||||
|
if (local) api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
|
||||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||||
}
|
}
|
||||||
function onSelectionChanged() {
|
function onSelectionChanged() {
|
||||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
@@ -365,6 +392,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
|
onBulkEdit={onBulkEdit}
|
||||||
onExportSelected={onExportSelected}
|
onExportSelected={onExportSelected}
|
||||||
onExportFiltered={onExportFiltered}
|
onExportFiltered={onExportFiltered}
|
||||||
/>
|
/>
|
||||||
@@ -405,6 +433,26 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{awardCols && awardCols.length > 0 && (
|
||||||
|
<div className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
|
||||||
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{awardCols.map((a) => (
|
||||||
|
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
|
||||||
|
<span className="font-mono font-semibold">{a.code}</span>
|
||||||
|
<span className="text-muted-foreground truncate">{a.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const GRATICULE = geoGraticule10();
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bearing?: number | null; // short-path azimuth to DX (deg)
|
bearing?: number | null; // short-path azimuth to DX (deg)
|
||||||
headings: number[]; // antenna heading(s) — rotor + Ultrabeam pattern
|
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
|
||||||
|
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
|
||||||
|
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
|
||||||
centerLat?: number | null; // operator latitude (projection centre)
|
centerLat?: number | null; // operator latitude (projection centre)
|
||||||
centerLon?: number | null; // operator longitude
|
centerLon?: number | null; // operator longitude
|
||||||
rotorEnabled?: boolean;
|
rotorEnabled?: boolean;
|
||||||
@@ -36,7 +38,7 @@ function pt(az: number, radius: number): [number, number] {
|
|||||||
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
export function RotorCompass({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, onGoto, onClose }: Props) {
|
||||||
const cardinals = useMemo(
|
const cardinals = useMemo(
|
||||||
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
|
||||||
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
|
||||||
@@ -76,6 +78,18 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
|
|||||||
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}
|
||||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
{pattern && (
|
||||||
|
<span
|
||||||
|
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
|
||||||
|
pattern === 'reverse' ? 'bg-amber-200 text-amber-900'
|
||||||
|
: pattern === 'bi' ? 'bg-sky-200 text-sky-900'
|
||||||
|
: 'bg-emerald-200 text-emerald-900')}
|
||||||
|
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
|
||||||
|
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
|
||||||
|
: 'Ultrabeam normal'}>
|
||||||
|
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
<span className="font-mono text-sm font-bold text-emerald-700 tabular-nums">
|
||||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||||
</span>
|
</span>
|
||||||
@@ -123,7 +137,21 @@ export function RotorCompass({ bearing, headings, centerLat, centerLon, rotorEna
|
|||||||
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
|
||||||
); })()}
|
); })()}
|
||||||
|
|
||||||
{/* antenna heading needle(s) — green; two when bidirectional */}
|
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
|
||||||
|
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
|
||||||
|
operator sees where the antenna physically points vs where it boom-sits */}
|
||||||
|
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
|
||||||
|
const [x, y] = pt(boomHeading, MAP_R - 2);
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
|
||||||
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
|
||||||
|
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* radiating heading needle(s) — green; two when bidirectional */}
|
||||||
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
|
||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
|
GetPGXLSettings, SavePGXLSettings,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
@@ -29,11 +31,13 @@ import {
|
|||||||
GetDataDir,
|
GetDataDir,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
|
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
TestLoTWUpload, ListTQSLStationLocations,
|
TestLoTWUpload, ListTQSLStationLocations,
|
||||||
ComputeStationInfo,
|
ComputeStationInfo,
|
||||||
|
GetUIPref, SetUIPref,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import type { profile as profileModels } from '../../wailsjs/go/models';
|
import type { profile as profileModels } from '../../wailsjs/go/models';
|
||||||
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
import type { LookupSettingsForm, StationSettingsForm, ListsSettingsForm, ModePresetForm } from '@/types';
|
||||||
@@ -136,6 +140,8 @@ interface Props {
|
|||||||
initialSection?: string;
|
initialSection?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
|
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
||||||
|
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
||||||
@@ -166,6 +172,8 @@ type SectionId =
|
|||||||
| 'rotator'
|
| 'rotator'
|
||||||
| 'winkeyer'
|
| 'winkeyer'
|
||||||
| 'antenna'
|
| 'antenna'
|
||||||
|
| 'antgenius'
|
||||||
|
| 'pgxl'
|
||||||
| 'audio';
|
| 'audio';
|
||||||
|
|
||||||
type TreeNode =
|
type TreeNode =
|
||||||
@@ -200,9 +208,11 @@ const TREE: TreeNode[] = [
|
|||||||
{
|
{
|
||||||
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
|
||||||
{ kind: 'item', label: 'CAT interface', id: 'cat' },
|
{ kind: 'item', label: 'CAT interface', id: 'cat' },
|
||||||
{ kind: 'item', label: 'Rotator', id: 'rotator' },
|
{ kind: 'item', label: 'PstRotator', id: 'rotator' },
|
||||||
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
|
{ kind: 'item', label: 'CW Keyer', id: 'winkeyer' },
|
||||||
{ kind: 'item', label: 'Antenna', id: 'antenna' },
|
{ kind: 'item', label: 'UltraBeam', id: 'antenna' },
|
||||||
|
{ kind: 'item', label: 'Antenna Genius', id: 'antgenius' },
|
||||||
|
{ kind: 'item', label: 'Power Genius', id: 'pgxl' },
|
||||||
{ kind: 'item', label: 'Audio devices', id: 'audio' },
|
{ kind: 'item', label: 'Audio devices', id: 'audio' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -225,9 +235,11 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'Rotator',
|
rotator: 'PstRotator',
|
||||||
winkeyer: 'CW Keyer',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'Antenna',
|
antenna: 'UltraBeam',
|
||||||
|
antgenius: 'Antenna Genius',
|
||||||
|
pgxl: 'Power Genius',
|
||||||
audio: 'Audio devices',
|
audio: 'Audio devices',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -445,6 +457,82 @@ function TelemetryToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LiveStatusToggle publishes this operator's current activity (call + band +
|
||||||
|
// freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op
|
||||||
|
// events — a small web script on your server renders it for the QRZ page. Only
|
||||||
|
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
||||||
|
function LiveStatusToggle() {
|
||||||
|
const [on, setOn] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
GetLiveStatusEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
||||||
|
Publish live operator status <span className="text-xs text-muted-foreground">(multi-op on shared MySQL — feeds a QRZ live page)</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// MainViewPanes lets the operator choose what the Main tab's left and right
|
||||||
|
// panes show, independently: the great-circle map, the locator street map, the
|
||||||
|
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||||
|
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||||
|
const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
||||||
|
{ value: 'map1', label: 'Map — great-circle + beam' },
|
||||||
|
{ value: 'map2', label: 'Map — locator (street)' },
|
||||||
|
{ value: 'cluster', label: 'Cluster spots' },
|
||||||
|
{ value: 'worked', label: 'Worked before' },
|
||||||
|
];
|
||||||
|
function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) {
|
||||||
|
const [left, setLeft] = useState('map1');
|
||||||
|
const [right, setRight] = useState('map2');
|
||||||
|
// FlexRadio is only offered when the CAT backend is a Flex.
|
||||||
|
const options = flexAvailable
|
||||||
|
? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }]
|
||||||
|
: MAIN_PANE_OPTIONS;
|
||||||
|
useEffect(() => {
|
||||||
|
const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||||
|
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||||
|
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||||
|
}, []);
|
||||||
|
const pick = (side: 'left' | 'right', v: string) => {
|
||||||
|
if (side === 'left') setLeft(v); else setRight(v);
|
||||||
|
// Persist (per-profile) AND tell the parent the new value directly, so the
|
||||||
|
// Main view updates from the chosen value — never a stale DB re-read.
|
||||||
|
SetUIPref(side === 'left' ? 'mainPaneLeft' : 'mainPaneRight', v).catch(() => {});
|
||||||
|
onChanged?.(side, v);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">Main view</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">Choose what the Main tab shows on each side (per profile).</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 max-w-xl">
|
||||||
|
<label className="flex flex-col gap-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">Left pane</span>
|
||||||
|
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">Right pane</span>
|
||||||
|
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
|
// FlexDiscover scans the LAN for FlexRadio broadcasts and lets the user pick one
|
||||||
// (fills the IP/port). Self-contained so it can own its state (rendered inside
|
// (fills the IP/port). Self-contained so it can own its state (rendered inside
|
||||||
// the hook-less CATPanel).
|
// the hook-less CATPanel).
|
||||||
@@ -495,7 +583,7 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
|
||||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -530,7 +618,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [bandDraft, setBandDraft] = useState('');
|
const [bandDraft, setBandDraft] = useState('');
|
||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0,
|
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
||||||
|
icom_port: '', icom_baud: 115200, icom_addr: 0x98, poll_ms: 250, delay_ms: 0,
|
||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
@@ -546,6 +635,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [ubTesting, setUbTesting] = useState(false);
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
|
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||||
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' });
|
||||||
|
|
||||||
|
// PowerGenius XL (4O3A) amp fan-control settings.
|
||||||
|
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
||||||
|
|
||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
type WKSettings = {
|
type WKSettings = {
|
||||||
@@ -667,20 +762,21 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
// wired today. upload_mode is 'immediate' | 'delayed' (per-service).
|
||||||
type ExtServiceCfg = {
|
type ExtServiceCfg = {
|
||||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||||
|
code: string; qth_nickname: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
tqsl_path: string; station_location: string; key_password: string;
|
tqsl_path: string; station_location: string; key_password: string;
|
||||||
upload_flag: string; write_log: boolean;
|
upload_flags: string[]; write_log: boolean;
|
||||||
auto_upload: boolean; upload_mode: string;
|
auto_upload: boolean; upload_mode: string;
|
||||||
};
|
};
|
||||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg; hrdlog: ExtServiceCfg; eqsl: ExtServiceCfg };
|
||||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||||
api_key: '', email: '', username: '', password: '', callsign: '',
|
api_key: '', email: '', username: '', password: '', callsign: '', code: '', qth_nickname: '',
|
||||||
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
force_station_callsign: '', tqsl_path: '', station_location: '', key_password: '',
|
||||||
upload_flag: 'R', write_log: false,
|
upload_flags: ['N', 'R'], write_log: false,
|
||||||
auto_upload: false, upload_mode: 'immediate',
|
auto_upload: false, upload_mode: 'immediate',
|
||||||
});
|
});
|
||||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||||
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(),
|
qrz: emptyExtCfg(), clublog: emptyExtCfg(), lotw: emptyExtCfg(), hrdlog: emptyExtCfg(), eqsl: emptyExtCfg(),
|
||||||
});
|
});
|
||||||
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [qrzTest, setQrzTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [qrzTesting, setQrzTesting] = useState(false);
|
const [qrzTesting, setQrzTesting] = useState(false);
|
||||||
@@ -688,10 +784,14 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const [clublogTesting, setClublogTesting] = useState(false);
|
const [clublogTesting, setClublogTesting] = useState(false);
|
||||||
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [lotwTest, setLotwTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
const [lotwTesting, setLotwTesting] = useState(false);
|
const [lotwTesting, setLotwTesting] = useState(false);
|
||||||
|
const [hrdlogTest, setHrdlogTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [hrdlogTesting, setHrdlogTesting] = useState(false);
|
||||||
|
const [eqslTest, setEqslTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
const [eqslTesting, setEqslTesting] = useState(false);
|
||||||
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
const [stationLocations, setStationLocations] = useState<string[]>([]);
|
||||||
// Active tab in the External Services panel — lifted here because
|
// Active tab in the External Services panel — lifted here because
|
||||||
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
// PANELS[selected]() is called as a function, so panels can't hold hooks.
|
||||||
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz');
|
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'lotw' | 'pota'>('qrz');
|
||||||
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
|
||||||
const [potaToken, setPotaToken] = useState('');
|
const [potaToken, setPotaToken] = useState('');
|
||||||
const [potaBusy, setPotaBusy] = useState(false);
|
const [potaBusy, setPotaBusy] = useState(false);
|
||||||
@@ -798,6 +898,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
setCatCfg(c);
|
setCatCfg(c);
|
||||||
setRotator(r);
|
setRotator(r);
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
|
try { setPgxl(await GetPGXLSettings() as any); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
setExtSvc(es as any);
|
setExtSvc(es as any);
|
||||||
@@ -837,6 +939,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
try { setCatCfg(await GetCATSettings() as any); } catch {}
|
||||||
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
|
try { setPgxl(await GetPGXLSettings() as any); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||||
@@ -1004,6 +1108,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
await SaveCATSettings(catCfg as any);
|
await SaveCATSettings(catCfg as any);
|
||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
|
await SavePGXLSettings(pgxl as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
await SaveEmailSettings(emailCfg as any);
|
await SaveEmailSettings(emailCfg as any);
|
||||||
@@ -1689,6 +1795,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
||||||
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
||||||
|
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1725,7 +1832,40 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{catCfg.backend === 'omnirig' && (
|
{catCfg.backend === 'icom' && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Icom CI-V port</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={catCfg.icom_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_port: v }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder="Select COM port" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" variant="outline" size="sm"
|
||||||
|
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Baud rate</Label>
|
||||||
|
<Select value={String(catCfg.icom_baud || 115200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_baud: parseInt(v) || 115200 }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>CI-V address (hex)</Label>
|
||||||
|
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||||||
|
onChange={(e) => { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} />
|
||||||
|
<p className="text-xs text-muted-foreground">IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Poll interval (ms)</Label>
|
<Label>Poll interval (ms)</Label>
|
||||||
@@ -1822,7 +1962,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Antenna (Ultrabeam)"
|
title="Antenna (Ultrabeam)"
|
||||||
hint="OpsLog talks to the Ultrabeam controller over TCP — typically via an RS232↔Ethernet adapter. Enter its IP address and port; the pattern direction (Normal / 180° / Bidirectional) is then controlled from the status bar."
|
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -1884,9 +2023,68 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
{ubTest.msg}
|
{ubTest.msg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground">
|
</div>
|
||||||
Once enabled, the antenna's direction control (Normal / 180° / Bidirectional) appears in the bottom status bar, next to CAT and Rotator. Changing the direction re-tunes the elements at the current frequency.
|
</>
|
||||||
</p>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AntGeniusPanelSettings() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Antenna Genius (4O3A)"
|
||||||
|
hint="OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B)."
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={antgenius.enabled} onCheckedChange={(c) => setAntgenius((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
Enable Antenna Genius control
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={antgenius.host ?? ''}
|
||||||
|
onChange={(e) => setAntgenius((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.60"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PGXLPanelSettings() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Power Genius XL"
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
Enable PowerGenius fan control
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={pgxl.host ?? ''}
|
||||||
|
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.70"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={pgxl.port}
|
||||||
|
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -1970,7 +2168,6 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="CW Keyer"
|
title="CW Keyer"
|
||||||
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
|
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -2523,9 +2720,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
|
const TABS: { k: typeof extSvcTab; label: string; ready?: boolean }[] = [
|
||||||
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
{ k: 'qrz', label: 'QRZ.COM', ready: true },
|
||||||
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
{ k: 'clublog', label: 'CLUBLOG', ready: true },
|
||||||
{ k: 'hrdlog', label: 'HRDLOG.NET' },
|
{ k: 'hrdlog', label: 'HRDLOG.NET', ready: true },
|
||||||
{ k: 'eqsl', label: 'EQSL' },
|
{ k: 'eqsl', label: 'EQSL', ready: true },
|
||||||
{ k: 'hamqth', label: 'HAMQTH' },
|
|
||||||
{ k: 'lotw', label: 'LOTW', ready: true },
|
{ k: 'lotw', label: 'LOTW', ready: true },
|
||||||
{ k: 'pota', label: 'POTA', ready: true },
|
{ k: 'pota', label: 'POTA', ready: true },
|
||||||
];
|
];
|
||||||
@@ -2578,6 +2774,42 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hrdlog = extSvc.hrdlog;
|
||||||
|
const setHrdlog = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, hrdlog: { ...s.hrdlog, ...patch } }));
|
||||||
|
|
||||||
|
async function testHrdlog() {
|
||||||
|
setHrdlogTesting(true);
|
||||||
|
setHrdlogTest(null);
|
||||||
|
try {
|
||||||
|
await SaveExternalServices(extSvc as any);
|
||||||
|
const msg = await TestHRDLogUpload();
|
||||||
|
setHrdlogTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setHrdlogTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setHrdlogTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const eqsl = extSvc.eqsl;
|
||||||
|
const setEqsl = (patch: Partial<ExtServiceCfg>) =>
|
||||||
|
setExtSvc((s) => ({ ...s, eqsl: { ...s.eqsl, ...patch } }));
|
||||||
|
|
||||||
|
async function testEqsl() {
|
||||||
|
setEqslTesting(true);
|
||||||
|
setEqslTest(null);
|
||||||
|
try {
|
||||||
|
await SaveExternalServices(extSvc as any);
|
||||||
|
const msg = await TestEQSLUpload();
|
||||||
|
setEqslTest({ ok: true, msg });
|
||||||
|
} catch (e: any) {
|
||||||
|
setEqslTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
|
} finally {
|
||||||
|
setEqslTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lotw = extSvc.lotw;
|
const lotw = extSvc.lotw;
|
||||||
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
const setLotw = (patch: Partial<ExtServiceCfg>) =>
|
||||||
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
setExtSvc((s) => ({ ...s, lotw: { ...s.lotw, ...patch } }));
|
||||||
@@ -2753,6 +2985,131 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : extSvcTab === 'hrdlog' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Station callsign</Label>
|
||||||
|
<Input
|
||||||
|
value={hrdlog.callsign}
|
||||||
|
onChange={(e) => setHrdlog({ callsign: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="defaults to the active profile's callsign"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Upload code</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={hrdlog.code}
|
||||||
|
onChange={(e) => setHrdlog({ code: e.target.value })}
|
||||||
|
placeholder="HRDLog account → My Account → Upload code"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||||
|
Find the upload code on HRDLog.net under <em>My Account</em>. It authorises uploads for this callsign.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={hrdlog.auto_upload}
|
||||||
|
onCheckedChange={(c) => setHrdlog({ auto_upload: !!c })}
|
||||||
|
/>
|
||||||
|
Automatic upload on new QSO
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Upload timing</Label>
|
||||||
|
<Select
|
||||||
|
value={hrdlog.upload_mode || 'immediate'}
|
||||||
|
onValueChange={(v) => setHrdlog({ upload_mode: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="immediate">Immediate</SelectItem>
|
||||||
|
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||||
|
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testHrdlog} disabled={hrdlogTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {hrdlogTesting ? 'Testing…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
{hrdlogTest && (
|
||||||
|
<span className={cn('text-xs', hrdlogTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||||
|
{hrdlogTest.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : extSvcTab === 'eqsl' ? (
|
||||||
|
<div className="space-y-4 max-w-2xl">
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Username (callsign)</Label>
|
||||||
|
<Input
|
||||||
|
value={eqsl.username}
|
||||||
|
onChange={(e) => setEqsl({ username: e.target.value.toUpperCase() })}
|
||||||
|
placeholder="your eQSL.cc login (callsign)"
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={eqsl.password}
|
||||||
|
onChange={(e) => setEqsl({ password: e.target.value })}
|
||||||
|
placeholder="eQSL.cc account password"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
<Label className="text-sm">QTH nickname</Label>
|
||||||
|
<Input
|
||||||
|
value={eqsl.qth_nickname}
|
||||||
|
onChange={(e) => setEqsl({ qth_nickname: e.target.value })}
|
||||||
|
placeholder="optional — required only if your eQSL account has several QTHs"
|
||||||
|
className="text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground -mt-1">
|
||||||
|
The QTH nickname tells eQSL which location profile to file the QSO under. Leave blank if you have only one.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-3">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={eqsl.auto_upload}
|
||||||
|
onCheckedChange={(c) => setEqsl({ auto_upload: !!c })}
|
||||||
|
/>
|
||||||
|
Automatic upload on new QSO
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
|
<Label className="text-sm">Upload timing</Label>
|
||||||
|
<Select
|
||||||
|
value={eqsl.upload_mode || 'immediate'}
|
||||||
|
onValueChange={(v) => setEqsl({ upload_mode: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="immediate">Immediate</SelectItem>
|
||||||
|
<SelectItem value="delayed">Delayed (1–2 min, lets you fix mistakes)</SelectItem>
|
||||||
|
<SelectItem value="on_close">On app close (batch)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="outline" size="sm" onClick={testEqsl} disabled={eqslTesting}>
|
||||||
|
<UploadCloud className="size-3.5" /> {eqslTesting ? 'Testing…' : 'Test connection'}
|
||||||
|
</Button>
|
||||||
|
{eqslTest && (
|
||||||
|
<span className={cn('text-xs', eqslTest.ok ? 'text-emerald-700' : 'text-rose-700')}>
|
||||||
|
{eqslTest.msg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : extSvcTab === 'lotw' ? (
|
) : extSvcTab === 'lotw' ? (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-4 max-w-2xl">
|
||||||
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
|
||||||
@@ -2812,17 +3169,32 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
placeholder="only if your certificate key has a password"
|
placeholder="only if your certificate key has a password"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
/>
|
/>
|
||||||
<Label className="text-sm">Upload flag</Label>
|
<Label className="text-sm">Consider as unsent</Label>
|
||||||
<div>
|
<div>
|
||||||
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
|
<div className="flex items-center gap-4">
|
||||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
{(['N', 'R'] as const).map((f) => {
|
||||||
<SelectContent>
|
const flags = lotw.upload_flags ?? [];
|
||||||
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
|
const checked = flags.includes(f);
|
||||||
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
|
return (
|
||||||
</SelectContent>
|
<label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
</Select>
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(c) => {
|
||||||
|
const next = c
|
||||||
|
? Array.from(new Set([...flags, f]))
|
||||||
|
: flags.filter((x) => x !== f);
|
||||||
|
setLotw({ upload_flags: next });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{f === 'N' ? 'No (N)' : 'Requested (R)'}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground mt-1">
|
<div className="text-[10px] text-muted-foreground mt-1">
|
||||||
Must match your default LoTW <em>sent</em> status in Confirmations, or new QSOs won't be picked up.
|
At app close, every QSO whose LoTW <em>sent</em> status is one of these is signed and
|
||||||
|
uploaded in one TQSL batch — including QSOs imported from an ADIF. Uploaded QSOs become
|
||||||
|
<em> Y</em> and won't be re-sent. Must include your default <em>sent</em> status from Confirmations.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3291,6 +3663,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
||||||
</label>
|
</label>
|
||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
|
<LiveStatusToggle />
|
||||||
|
|
||||||
|
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||||
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
||||||
@@ -3469,6 +3844,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
|
|||||||
rotator: RotatorPanel,
|
rotator: RotatorPanel,
|
||||||
winkeyer: WinkeyerPanel,
|
winkeyer: WinkeyerPanel,
|
||||||
antenna: UltrabeamPanel,
|
antenna: UltrabeamPanel,
|
||||||
|
antgenius: AntGeniusPanelSettings,
|
||||||
|
pgxl: PGXLPanelSettings,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export function WinkeyerPanel({
|
|||||||
someone answers. The seconds box is the gap AFTER the message. */}
|
someone answers. The seconds box is the gap AFTER the message. */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
<label className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
|
||||||
title="After you click a macro (e.g. F1 CQ), resend it on a loop — message, then the gap, then repeat — until a callsign is entered or you press Stop">
|
title="Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.">
|
||||||
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
<input type="checkbox" className="accent-primary" checked={autoCall} disabled={!connected}
|
||||||
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
onChange={(e) => onToggleAutoCall(e.target.checked)} />
|
||||||
Auto-call
|
Auto-call
|
||||||
@@ -193,7 +193,7 @@ export function WinkeyerPanel({
|
|||||||
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
value={autoCallSecs} onChange={(e) => onSetAutoCallSecs(parseInt(e.target.value) || 0)} />
|
||||||
<span className="text-[9px] text-muted-foreground">sec</span>
|
<span className="text-[9px] text-muted-foreground">sec</span>
|
||||||
</div>
|
</div>
|
||||||
{autoCall && <span className="text-[10px] text-amber-600/80">click a macro to loop it</span>}
|
{autoCall && <span className="text-[10px] text-amber-600/80">click a CQ macro to loop it</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ type Props = {
|
|||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
// One column per defined award (cell = the reference this QSO counts for).
|
||||||
|
awardCols?: { code: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
|
||||||
@@ -65,7 +67,7 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL }: Props) {
|
export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, awardCols }: Props) {
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [menu, setMenu] = useState<QSOMenuState>(null);
|
const [menu, setMenu] = useState<QSOMenuState>(null);
|
||||||
@@ -93,10 +95,21 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
const count = wb?.count ?? 0;
|
const count = wb?.count ?? 0;
|
||||||
const entries = wb?.entries ?? [];
|
const entries = wb?.entries ?? [];
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => COL_CATALOG.map((c) => {
|
const columnDefs = useMemo<ColDef<WorkedEntry>[]>(() => {
|
||||||
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
const base = COL_CATALOG.map((c) => {
|
||||||
return { ...rest, hide: !defaultVisible };
|
const { group: _g, label: _l, defaultVisible, ...rest } = c;
|
||||||
}), []);
|
return { ...rest, hide: !defaultVisible };
|
||||||
|
});
|
||||||
|
const awards: ColDef<WorkedEntry>[] = (awardCols ?? []).map((a) => ({
|
||||||
|
colId: `award_${a.code}`,
|
||||||
|
headerName: a.code,
|
||||||
|
headerTooltip: `${a.name} — reference this QSO counts for`,
|
||||||
|
width: 110,
|
||||||
|
cellClass: 'text-[11px]',
|
||||||
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
|
}));
|
||||||
|
return [...base, ...awards];
|
||||||
|
}, [awardCols]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
sortable: true, resizable: true, filter: true, suppressMovable: false,
|
||||||
@@ -283,6 +296,26 @@ export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, on
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{awardCols && awardCols.length > 0 && (
|
||||||
|
<div className="rounded-md border border-border p-2.5">
|
||||||
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Awards</span>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>all</button>
|
||||||
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>none</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{awardCols.map((a) => (
|
||||||
|
<label key={a.code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-accent/30 rounded px-1 py-0.5">
|
||||||
|
<Checkbox checked={isColVisible(`award_${a.code}`)} onCheckedChange={(v) => setColVisible(`award_${a.code}`, !!v)} />
|
||||||
|
<span className="font-mono font-semibold">{a.code}</span>
|
||||||
|
<span className="text-muted-foreground truncate">{a.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
<Button variant="ghost" size="sm" onClick={resetDefaults}>Reset to defaults</Button>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
|
|||||||
const PORTABLE_KEYS = [
|
const PORTABLE_KEYS = [
|
||||||
'hamlog.qsoLimit', // QSO list page size
|
'hamlog.qsoLimit', // QSO list page size
|
||||||
'bandmap.side', // band map docked left / right
|
'bandmap.side', // band map docked left / right
|
||||||
|
'bandmap.show', // band map open / closed
|
||||||
'opslog.autofocusWB', // auto-focus Worked-before
|
'opslog.autofocusWB', // auto-focus Worked-before
|
||||||
'hamlog.filterPresets', // Filter Builder saved presets
|
'hamlog.filterPresets', // Filter Builder saved presets
|
||||||
'opslog.showRotor', // rotor compass shown next to the keyers
|
'opslog.showRotor', // rotor compass shown next to the keyers
|
||||||
@@ -22,6 +23,8 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
'opslog.mapAutoZoomDX', // Main map: auto-zoom to the DX (vs free pan/zoom)
|
||||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.11';
|
export const APP_VERSION = '0.13';
|
||||||
|
|
||||||
// Author / credits, shown in Help → About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+155
-1
@@ -5,10 +5,12 @@ import {qso} from '../models';
|
|||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
|
import {powergenius} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
import {operating} from '../models';
|
import {operating} from '../models';
|
||||||
@@ -23,6 +25,10 @@ export function ActivateProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||||
|
|
||||||
|
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||||
@@ -33,10 +39,18 @@ export function AwardFields():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
export function AwardMissingQSOs(arg1:string):Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
|
export function AwardRefsForQSOs(arg1:Array<number>):Promise<Record<number, Record<string, string>>>;
|
||||||
|
|
||||||
export function BrowseExecutable():Promise<string>;
|
export function BrowseExecutable():Promise<string>;
|
||||||
|
|
||||||
|
export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Promise<number>;
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
|
export function CWDecoderRunning():Promise<boolean>;
|
||||||
|
|
||||||
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
|
|
||||||
export function ClearLookupCache():Promise<void>;
|
export function ClearLookupCache():Promise<void>;
|
||||||
@@ -87,6 +101,8 @@ export function DeleteProfile(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function DeleteQSO(arg1:number):Promise<void>;
|
export function DeleteQSO(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function DeleteQSOs(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
export function DeleteUDPIntegration(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function DisconnectAllClusters():Promise<void>;
|
export function DisconnectAllClusters():Promise<void>;
|
||||||
@@ -115,8 +131,80 @@ export function FilterFields():Promise<Array<string>>;
|
|||||||
|
|
||||||
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
|
||||||
|
|
||||||
|
export function FlexATUBypass():Promise<void>;
|
||||||
|
|
||||||
|
export function FlexATUStart():Promise<void>;
|
||||||
|
|
||||||
|
export function FlexAmpOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexMox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAGCMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetANFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAPF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAPFLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetAudioLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWBreakInDelay(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWFilter(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWSidetone(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetCWSpeed(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMic(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMon(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetMonLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetPower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetProcessor(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetProcessorLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetSidetoneLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetTunePower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetVox(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetVoxDelay(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexSetVoxLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||||
|
|
||||||
|
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||||
|
|
||||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||||
|
|
||||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||||
@@ -139,6 +227,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
|||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
export function GetCATState():Promise<cat.RigState>;
|
||||||
|
|
||||||
|
export function GetCWDecoderPitch():Promise<number>;
|
||||||
|
|
||||||
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
|
|
||||||
export function GetClusterAutoConnect():Promise<boolean>;
|
export function GetClusterAutoConnect():Promise<boolean>;
|
||||||
@@ -163,8 +255,14 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
|
|||||||
|
|
||||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||||
|
|
||||||
|
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||||
|
|
||||||
|
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||||
|
|
||||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||||
|
|
||||||
|
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
|
|
||||||
export function GetLogbookRevision():Promise<string>;
|
export function GetLogbookRevision():Promise<string>;
|
||||||
@@ -173,6 +271,12 @@ export function GetLookupSettings():Promise<main.LookupSettings>;
|
|||||||
|
|
||||||
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
export function GetMySQLSettings():Promise<main.MySQLSettings>;
|
||||||
|
|
||||||
|
export function GetOnlineOperators():Promise<Array<main.ChatPresence>>;
|
||||||
|
|
||||||
|
export function GetPGXLSettings():Promise<main.PGXLSettings>;
|
||||||
|
|
||||||
|
export function GetPGXLStatus():Promise<powergenius.Status>;
|
||||||
|
|
||||||
export function GetPOTAToken():Promise<string>;
|
export function GetPOTAToken():Promise<string>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
@@ -203,7 +307,31 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
|||||||
|
|
||||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
|
export function IcomRefresh():Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAGC(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetANF(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetAtt(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetFilter(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNB(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetNRLevel(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetPreamp(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function IcomSetRFGain(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||||
|
|
||||||
@@ -251,6 +379,8 @@ export function OpenExternalURL(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
|
||||||
|
|
||||||
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
|
|
||||||
export function PickBackupFolder():Promise<string>;
|
export function PickBackupFolder():Promise<string>;
|
||||||
@@ -313,6 +443,10 @@ export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
|||||||
|
|
||||||
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
|
||||||
|
|
||||||
|
export function ReportLiveActivity(arg1:number,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RescanAwards():Promise<void>;
|
||||||
|
|
||||||
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
export function ResetAwardDefs():Promise<Array<award.Def>>;
|
||||||
|
|
||||||
export function ResetDatabaseToDefault():Promise<void>;
|
export function ResetDatabaseToDefault():Promise<void>;
|
||||||
@@ -329,6 +463,8 @@ export function RunBackupNow():Promise<string>;
|
|||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
|
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||||
@@ -357,6 +493,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.A
|
|||||||
|
|
||||||
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
|
||||||
|
|
||||||
|
export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
||||||
|
|
||||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
@@ -375,6 +513,8 @@ export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
|
||||||
|
|
||||||
|
export function SendChatMessage(arg1:string):Promise<main.ChatMessage>;
|
||||||
|
|
||||||
export function SendClusterCommand(arg1:string):Promise<void>;
|
export function SendClusterCommand(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
|
||||||
@@ -387,6 +527,8 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
@@ -395,6 +537,8 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -403,14 +547,22 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|
||||||
export function TestClublogUpload():Promise<string>;
|
export function TestClublogUpload():Promise<string>;
|
||||||
|
|
||||||
|
export function TestEQSLUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestEmail(arg1:string):Promise<void>;
|
export function TestEmail(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function TestHRDLogUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLoTWUpload():Promise<string>;
|
export function TestLoTWUpload():Promise<string>;
|
||||||
|
|
||||||
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
|
||||||
@@ -439,6 +591,8 @@ export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
|||||||
|
|
||||||
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
|
export function UploadCallsign(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
|
||||||
|
|
||||||
export function WinkeyerBackspace():Promise<void>;
|
export function WinkeyerBackspace():Promise<void>;
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ export function AddQSO(arg1) {
|
|||||||
return window['go']['main']['App']['AddQSO'](arg1);
|
return window['go']['main']['App']['AddQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AntGeniusActivate(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AntGeniusDeselect(arg1) {
|
||||||
|
return window['go']['main']['App']['AntGeniusDeselect'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ApplyAwardPreset(arg1, arg2) {
|
export function ApplyAwardPreset(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -38,14 +46,30 @@ export function AwardMissingQSOs(arg1) {
|
|||||||
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
return window['go']['main']['App']['AwardMissingQSOs'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AwardRefsForQSOs(arg1) {
|
||||||
|
return window['go']['main']['App']['AwardRefsForQSOs'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function BrowseExecutable() {
|
export function BrowseExecutable() {
|
||||||
return window['go']['main']['App']['BrowseExecutable']();
|
return window['go']['main']['App']['BrowseExecutable']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BulkUpdateField(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['BulkUpdateField'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function BulkUpdateQSL(arg1, arg2) {
|
export function BulkUpdateQSL(arg1, arg2) {
|
||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CWDecoderRunning() {
|
||||||
|
return window['go']['main']['App']['CWDecoderRunning']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatAvailable() {
|
||||||
|
return window['go']['main']['App']['ChatAvailable']();
|
||||||
|
}
|
||||||
|
|
||||||
export function CheckForUpdate() {
|
export function CheckForUpdate() {
|
||||||
return window['go']['main']['App']['CheckForUpdate']();
|
return window['go']['main']['App']['CheckForUpdate']();
|
||||||
}
|
}
|
||||||
@@ -146,6 +170,10 @@ export function DeleteQSO(arg1) {
|
|||||||
return window['go']['main']['App']['DeleteQSO'](arg1);
|
return window['go']['main']['App']['DeleteQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function DeleteQSOs(arg1) {
|
||||||
|
return window['go']['main']['App']['DeleteQSOs'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteUDPIntegration(arg1) {
|
export function DeleteUDPIntegration(arg1) {
|
||||||
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
@@ -202,10 +230,154 @@ export function FindQSOsForUpload(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
return window['go']['main']['App']['FindQSOsForUpload'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FlexATUBypass() {
|
||||||
|
return window['go']['main']['App']['FlexATUBypass']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexATUStart() {
|
||||||
|
return window['go']['main']['App']['FlexATUStart']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexAmpOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexAmpOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexMox(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexMox'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAGCMode(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAGCThreshold(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAGCThreshold'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetANF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetANF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetANFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetANFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAPF(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAPF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAPFLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAPFLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetATUMemories(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetAudioLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWBreakInDelay(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWBreakInDelay'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWFilter(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWFilter'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWPitch(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWPitch'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWSidetone(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWSidetone'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetCWSpeed(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetCWSpeed'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetFilter(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetMic(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMic'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetMon(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMon'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetMonLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetMonLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNB(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNBLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNBLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNR(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetProcessor(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetProcessor'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetProcessorLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetProcessorLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetSidetoneLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetSidetoneLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetTunePower(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetTunePower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetVox(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetVox'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetVoxDelay(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetVoxDelay'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexSetVoxLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexSetVoxLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlexTune(arg1) {
|
||||||
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetActiveProfile() {
|
export function GetActiveProfile() {
|
||||||
return window['go']['main']['App']['GetActiveProfile']();
|
return window['go']['main']['App']['GetActiveProfile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetAntGeniusSettings() {
|
||||||
|
return window['go']['main']['App']['GetAntGeniusSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetAntGeniusStatus() {
|
||||||
|
return window['go']['main']['App']['GetAntGeniusStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAudioSettings() {
|
export function GetAudioSettings() {
|
||||||
return window['go']['main']['App']['GetAudioSettings']();
|
return window['go']['main']['App']['GetAudioSettings']();
|
||||||
}
|
}
|
||||||
@@ -250,6 +422,14 @@ export function GetCATState() {
|
|||||||
return window['go']['main']['App']['GetCATState']();
|
return window['go']['main']['App']['GetCATState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetCWDecoderPitch() {
|
||||||
|
return window['go']['main']['App']['GetCWDecoderPitch']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetChatHistory(arg1) {
|
||||||
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetClublogCtyInfo() {
|
export function GetClublogCtyInfo() {
|
||||||
return window['go']['main']['App']['GetClublogCtyInfo']();
|
return window['go']['main']['App']['GetClublogCtyInfo']();
|
||||||
}
|
}
|
||||||
@@ -298,10 +478,22 @@ export function GetExternalServices() {
|
|||||||
return window['go']['main']['App']['GetExternalServices']();
|
return window['go']['main']['App']['GetExternalServices']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetFlexState() {
|
||||||
|
return window['go']['main']['App']['GetFlexState']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetIcomState() {
|
||||||
|
return window['go']['main']['App']['GetIcomState']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetListsSettings() {
|
export function GetListsSettings() {
|
||||||
return window['go']['main']['App']['GetListsSettings']();
|
return window['go']['main']['App']['GetListsSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetLiveStatusEnabled() {
|
||||||
|
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetLogFilePath() {
|
export function GetLogFilePath() {
|
||||||
return window['go']['main']['App']['GetLogFilePath']();
|
return window['go']['main']['App']['GetLogFilePath']();
|
||||||
}
|
}
|
||||||
@@ -318,6 +510,18 @@ export function GetMySQLSettings() {
|
|||||||
return window['go']['main']['App']['GetMySQLSettings']();
|
return window['go']['main']['App']['GetMySQLSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetOnlineOperators() {
|
||||||
|
return window['go']['main']['App']['GetOnlineOperators']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetPGXLSettings() {
|
||||||
|
return window['go']['main']['App']['GetPGXLSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetPGXLStatus() {
|
||||||
|
return window['go']['main']['App']['GetPGXLStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPOTAToken() {
|
export function GetPOTAToken() {
|
||||||
return window['go']['main']['App']['GetPOTAToken']();
|
return window['go']['main']['App']['GetPOTAToken']();
|
||||||
}
|
}
|
||||||
@@ -378,8 +582,56 @@ export function HasBuiltinReferences(arg1) {
|
|||||||
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportADIF(arg1, arg2, arg3) {
|
export function IcomRefresh() {
|
||||||
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
|
return window['go']['main']['App']['IcomRefresh']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetAFGain(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAFGain'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetAGC(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAGC'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetANF(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetANF'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetAtt(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetAtt'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetFilter(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetFilter'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetNB(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNB'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetNBLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNBLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetNR(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNR'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetNRLevel(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetNRLevel'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetPreamp(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetPreamp'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IcomSetRFGain(arg1) {
|
||||||
|
return window['go']['main']['App']['IcomSetRFGain'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportADIF(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportAwardReferencesText(arg1, arg2) {
|
export function ImportAwardReferencesText(arg1, arg2) {
|
||||||
@@ -474,6 +726,10 @@ export function OperatingDefaultForBand(arg1) {
|
|||||||
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PGXLSetFanMode(arg1) {
|
||||||
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function PickAudioFolder() {
|
export function PickAudioFolder() {
|
||||||
return window['go']['main']['App']['PickAudioFolder']();
|
return window['go']['main']['App']['PickAudioFolder']();
|
||||||
}
|
}
|
||||||
@@ -598,6 +854,14 @@ export function ReplaceAwardReferences(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ReportLiveActivity(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['ReportLiveActivity'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RescanAwards() {
|
||||||
|
return window['go']['main']['App']['RescanAwards']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ResetAwardDefs() {
|
export function ResetAwardDefs() {
|
||||||
return window['go']['main']['App']['ResetAwardDefs']();
|
return window['go']['main']['App']['ResetAwardDefs']();
|
||||||
}
|
}
|
||||||
@@ -630,6 +894,10 @@ export function SaveADIFFile() {
|
|||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveAntGeniusSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveAntGeniusSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveAudioSettings(arg1) {
|
export function SaveAudioSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
return window['go']['main']['App']['SaveAudioSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -686,6 +954,10 @@ export function SaveOperatingStation(arg1) {
|
|||||||
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
return window['go']['main']['App']['SaveOperatingStation'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SavePGXLSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SavePGXLSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SavePOTAToken(arg1) {
|
export function SavePOTAToken(arg1) {
|
||||||
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||||
}
|
}
|
||||||
@@ -722,6 +994,10 @@ export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
|
|||||||
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SendChatMessage(arg1) {
|
||||||
|
return window['go']['main']['App']['SendChatMessage'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SendClusterCommand(arg1) {
|
export function SendClusterCommand(arg1) {
|
||||||
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
return window['go']['main']['App']['SendClusterCommand'](arg1);
|
||||||
}
|
}
|
||||||
@@ -746,6 +1022,10 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetCWDecoderPitch(arg1) {
|
||||||
|
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1) {
|
export function SetClublogCtyEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -762,6 +1042,10 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetLiveStatusEnabled(arg1) {
|
||||||
|
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -778,6 +1062,14 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartCWDecoder() {
|
||||||
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StopCWDecoder() {
|
||||||
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
@@ -790,10 +1082,18 @@ export function TestClublogUpload() {
|
|||||||
return window['go']['main']['App']['TestClublogUpload']();
|
return window['go']['main']['App']['TestClublogUpload']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestEQSLUpload() {
|
||||||
|
return window['go']['main']['App']['TestEQSLUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestEmail(arg1) {
|
export function TestEmail(arg1) {
|
||||||
return window['go']['main']['App']['TestEmail'](arg1);
|
return window['go']['main']['App']['TestEmail'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TestHRDLogUpload() {
|
||||||
|
return window['go']['main']['App']['TestHRDLogUpload']();
|
||||||
|
}
|
||||||
|
|
||||||
export function TestLoTWUpload() {
|
export function TestLoTWUpload() {
|
||||||
return window['go']['main']['App']['TestLoTWUpload']();
|
return window['go']['main']['App']['TestLoTWUpload']();
|
||||||
}
|
}
|
||||||
@@ -850,6 +1150,10 @@ export function UpdateQSOsFromQRZ(arg1) {
|
|||||||
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
|
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UploadCallsign(arg1) {
|
||||||
|
return window['go']['main']['App']['UploadCallsign'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function UploadQSOsManual(arg1, arg2) {
|
export function UploadQSOsManual(arg1, arg2) {
|
||||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,69 @@ export namespace adif {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace antgenius {
|
||||||
|
|
||||||
|
export class Antenna {
|
||||||
|
index: number;
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Antenna(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.index = source["index"];
|
||||||
|
this.name = source["name"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
host?: string;
|
||||||
|
last_error?: string;
|
||||||
|
port_a: number;
|
||||||
|
port_b: number;
|
||||||
|
tx_a: boolean;
|
||||||
|
tx_b: boolean;
|
||||||
|
antennas: Antenna[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.port_a = source["port_a"];
|
||||||
|
this.port_b = source["port_b"];
|
||||||
|
this.tx_a = source["tx_a"];
|
||||||
|
this.tx_b = source["tx_b"];
|
||||||
|
this.antennas = this.convertValues(source["antennas"], Antenna);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace audio {
|
export namespace audio {
|
||||||
|
|
||||||
export class Device {
|
export class Device {
|
||||||
@@ -385,6 +448,30 @@ export namespace awardref {
|
|||||||
|
|
||||||
export namespace cat {
|
export namespace cat {
|
||||||
|
|
||||||
|
export class FlexMeter {
|
||||||
|
id: number;
|
||||||
|
src?: string;
|
||||||
|
name?: string;
|
||||||
|
unit?: string;
|
||||||
|
value: number;
|
||||||
|
lo: number;
|
||||||
|
hi: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlexMeter(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.src = source["src"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.unit = source["unit"];
|
||||||
|
this.value = source["value"];
|
||||||
|
this.lo = source["lo"];
|
||||||
|
this.hi = source["hi"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class FlexRadio {
|
export class FlexRadio {
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -407,6 +494,154 @@ export namespace cat {
|
|||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class FlexTXState {
|
||||||
|
available: boolean;
|
||||||
|
model?: string;
|
||||||
|
rf_power: number;
|
||||||
|
tune_power: number;
|
||||||
|
tune: boolean;
|
||||||
|
transmitting: boolean;
|
||||||
|
vox_enable: boolean;
|
||||||
|
vox_level: number;
|
||||||
|
vox_delay: number;
|
||||||
|
proc_enable: boolean;
|
||||||
|
proc_level: number;
|
||||||
|
mon: boolean;
|
||||||
|
mon_level: number;
|
||||||
|
mic_level: number;
|
||||||
|
atu_status?: string;
|
||||||
|
atu_memories: boolean;
|
||||||
|
rx_avail: boolean;
|
||||||
|
agc_mode?: string;
|
||||||
|
agc_threshold: number;
|
||||||
|
audio_level: number;
|
||||||
|
nb: boolean;
|
||||||
|
nb_level: number;
|
||||||
|
nr: boolean;
|
||||||
|
nr_level: number;
|
||||||
|
anf: boolean;
|
||||||
|
anf_level: number;
|
||||||
|
mode?: string;
|
||||||
|
cw_speed: number;
|
||||||
|
cw_pitch: number;
|
||||||
|
cw_break_in_delay: number;
|
||||||
|
cw_sidetone: boolean;
|
||||||
|
cw_mon_level: number;
|
||||||
|
apf: boolean;
|
||||||
|
apf_level: number;
|
||||||
|
filter_lo: number;
|
||||||
|
filter_hi: number;
|
||||||
|
amp_available: boolean;
|
||||||
|
amp_model?: string;
|
||||||
|
amp_operate: boolean;
|
||||||
|
amp_fault?: string;
|
||||||
|
meters?: FlexMeter[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new FlexTXState(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.available = source["available"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.rf_power = source["rf_power"];
|
||||||
|
this.tune_power = source["tune_power"];
|
||||||
|
this.tune = source["tune"];
|
||||||
|
this.transmitting = source["transmitting"];
|
||||||
|
this.vox_enable = source["vox_enable"];
|
||||||
|
this.vox_level = source["vox_level"];
|
||||||
|
this.vox_delay = source["vox_delay"];
|
||||||
|
this.proc_enable = source["proc_enable"];
|
||||||
|
this.proc_level = source["proc_level"];
|
||||||
|
this.mon = source["mon"];
|
||||||
|
this.mon_level = source["mon_level"];
|
||||||
|
this.mic_level = source["mic_level"];
|
||||||
|
this.atu_status = source["atu_status"];
|
||||||
|
this.atu_memories = source["atu_memories"];
|
||||||
|
this.rx_avail = source["rx_avail"];
|
||||||
|
this.agc_mode = source["agc_mode"];
|
||||||
|
this.agc_threshold = source["agc_threshold"];
|
||||||
|
this.audio_level = source["audio_level"];
|
||||||
|
this.nb = source["nb"];
|
||||||
|
this.nb_level = source["nb_level"];
|
||||||
|
this.nr = source["nr"];
|
||||||
|
this.nr_level = source["nr_level"];
|
||||||
|
this.anf = source["anf"];
|
||||||
|
this.anf_level = source["anf_level"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.cw_speed = source["cw_speed"];
|
||||||
|
this.cw_pitch = source["cw_pitch"];
|
||||||
|
this.cw_break_in_delay = source["cw_break_in_delay"];
|
||||||
|
this.cw_sidetone = source["cw_sidetone"];
|
||||||
|
this.cw_mon_level = source["cw_mon_level"];
|
||||||
|
this.apf = source["apf"];
|
||||||
|
this.apf_level = source["apf_level"];
|
||||||
|
this.filter_lo = source["filter_lo"];
|
||||||
|
this.filter_hi = source["filter_hi"];
|
||||||
|
this.amp_available = source["amp_available"];
|
||||||
|
this.amp_model = source["amp_model"];
|
||||||
|
this.amp_operate = source["amp_operate"];
|
||||||
|
this.amp_fault = source["amp_fault"];
|
||||||
|
this.meters = this.convertValues(source["meters"], FlexMeter);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class IcomTXState {
|
||||||
|
available: boolean;
|
||||||
|
model?: string;
|
||||||
|
mode?: string;
|
||||||
|
af_gain: number;
|
||||||
|
rf_gain: number;
|
||||||
|
nb: boolean;
|
||||||
|
nb_level: number;
|
||||||
|
nr: boolean;
|
||||||
|
nr_level: number;
|
||||||
|
anf: boolean;
|
||||||
|
agc?: string;
|
||||||
|
preamp: number;
|
||||||
|
att: number;
|
||||||
|
filter: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new IcomTXState(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.available = source["available"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.af_gain = source["af_gain"];
|
||||||
|
this.rf_gain = source["rf_gain"];
|
||||||
|
this.nb = source["nb"];
|
||||||
|
this.nb_level = source["nb_level"];
|
||||||
|
this.nr = source["nr"];
|
||||||
|
this.nr_level = source["nr_level"];
|
||||||
|
this.anf = source["anf"];
|
||||||
|
this.agc = source["agc"];
|
||||||
|
this.preamp = source["preamp"];
|
||||||
|
this.att = source["att"];
|
||||||
|
this.filter = source["filter"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RigState {
|
export class RigState {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
@@ -555,11 +790,13 @@ export namespace extsvc {
|
|||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
|
code: string;
|
||||||
|
qth_nickname: string;
|
||||||
force_station_callsign: string;
|
force_station_callsign: string;
|
||||||
tqsl_path: string;
|
tqsl_path: string;
|
||||||
station_location: string;
|
station_location: string;
|
||||||
key_password: string;
|
key_password: string;
|
||||||
upload_flag: string;
|
upload_flags: string[];
|
||||||
write_log: boolean;
|
write_log: boolean;
|
||||||
auto_upload: boolean;
|
auto_upload: boolean;
|
||||||
upload_mode: string;
|
upload_mode: string;
|
||||||
@@ -575,11 +812,13 @@ export namespace extsvc {
|
|||||||
this.username = source["username"];
|
this.username = source["username"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
this.callsign = source["callsign"];
|
this.callsign = source["callsign"];
|
||||||
|
this.code = source["code"];
|
||||||
|
this.qth_nickname = source["qth_nickname"];
|
||||||
this.force_station_callsign = source["force_station_callsign"];
|
this.force_station_callsign = source["force_station_callsign"];
|
||||||
this.tqsl_path = source["tqsl_path"];
|
this.tqsl_path = source["tqsl_path"];
|
||||||
this.station_location = source["station_location"];
|
this.station_location = source["station_location"];
|
||||||
this.key_password = source["key_password"];
|
this.key_password = source["key_password"];
|
||||||
this.upload_flag = source["upload_flag"];
|
this.upload_flags = source["upload_flags"];
|
||||||
this.write_log = source["write_log"];
|
this.write_log = source["write_log"];
|
||||||
this.auto_upload = source["auto_upload"];
|
this.auto_upload = source["auto_upload"];
|
||||||
this.upload_mode = source["upload_mode"];
|
this.upload_mode = source["upload_mode"];
|
||||||
@@ -589,6 +828,8 @@ export namespace extsvc {
|
|||||||
qrz: ServiceConfig;
|
qrz: ServiceConfig;
|
||||||
clublog: ServiceConfig;
|
clublog: ServiceConfig;
|
||||||
lotw: ServiceConfig;
|
lotw: ServiceConfig;
|
||||||
|
hrdlog: ServiceConfig;
|
||||||
|
eqsl: ServiceConfig;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ExternalServices(source);
|
return new ExternalServices(source);
|
||||||
@@ -599,6 +840,8 @@ export namespace extsvc {
|
|||||||
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
|
this.qrz = this.convertValues(source["qrz"], ServiceConfig);
|
||||||
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
this.clublog = this.convertValues(source["clublog"], ServiceConfig);
|
||||||
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
this.lotw = this.convertValues(source["lotw"], ServiceConfig);
|
||||||
|
this.hrdlog = this.convertValues(source["hrdlog"], ServiceConfig);
|
||||||
|
this.eqsl = this.convertValues(source["eqsl"], ServiceConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
@@ -715,6 +958,20 @@ export namespace lookup {
|
|||||||
|
|
||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
|
export class AntGeniusSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new AntGeniusSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class AudioSettings {
|
export class AudioSettings {
|
||||||
from_radio: string;
|
from_radio: string;
|
||||||
to_radio: string;
|
to_radio: string;
|
||||||
@@ -900,6 +1157,9 @@ export namespace main {
|
|||||||
flex_host: string;
|
flex_host: string;
|
||||||
flex_port: number;
|
flex_port: number;
|
||||||
flex_spots: boolean;
|
flex_spots: boolean;
|
||||||
|
icom_port: string;
|
||||||
|
icom_baud: number;
|
||||||
|
icom_addr: number;
|
||||||
poll_ms: number;
|
poll_ms: number;
|
||||||
delay_ms: number;
|
delay_ms: number;
|
||||||
digital_default: string;
|
digital_default: string;
|
||||||
@@ -916,11 +1176,50 @@ export namespace main {
|
|||||||
this.flex_host = source["flex_host"];
|
this.flex_host = source["flex_host"];
|
||||||
this.flex_port = source["flex_port"];
|
this.flex_port = source["flex_port"];
|
||||||
this.flex_spots = source["flex_spots"];
|
this.flex_spots = source["flex_spots"];
|
||||||
|
this.icom_port = source["icom_port"];
|
||||||
|
this.icom_baud = source["icom_baud"];
|
||||||
|
this.icom_addr = source["icom_addr"];
|
||||||
this.poll_ms = source["poll_ms"];
|
this.poll_ms = source["poll_ms"];
|
||||||
this.delay_ms = source["delay_ms"];
|
this.delay_ms = source["delay_ms"];
|
||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ChatMessage {
|
||||||
|
id: number;
|
||||||
|
operator: string;
|
||||||
|
station: string;
|
||||||
|
message: string;
|
||||||
|
created_at: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChatMessage(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.station = source["station"];
|
||||||
|
this.message = source["message"];
|
||||||
|
this.created_at = source["created_at"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class ChatPresence {
|
||||||
|
operator: string;
|
||||||
|
station: string;
|
||||||
|
ago_secs: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChatPresence(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.operator = source["operator"];
|
||||||
|
this.station = source["station"];
|
||||||
|
this.ago_secs = source["ago_secs"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ClublogCtyInfo {
|
export class ClublogCtyInfo {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
loaded: boolean;
|
loaded: boolean;
|
||||||
@@ -1174,6 +1473,22 @@ export namespace main {
|
|||||||
this.database = source["database"];
|
this.database = source["database"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class PGXLSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new PGXLSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.port = source["port"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class POTAUnmatched {
|
export class POTAUnmatched {
|
||||||
activator: string;
|
activator: string;
|
||||||
date: string;
|
date: string;
|
||||||
@@ -1810,6 +2125,33 @@ export namespace operating {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace powergenius {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
host?: string;
|
||||||
|
last_error?: string;
|
||||||
|
state?: string;
|
||||||
|
fan_mode?: string;
|
||||||
|
temperature: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.fan_mode = source["fan_mode"];
|
||||||
|
this.temperature = source["temperature"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace profile {
|
export namespace profile {
|
||||||
|
|
||||||
export class ProfileDB {
|
export class ProfileDB {
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP
|
||||||
|
// text API (default port 9007). On connect the device sends a banner line
|
||||||
|
// (e.g. "V4.1.16 AG"); commands are "C<seq>|<command>\r" and the device replies
|
||||||
|
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
|
||||||
|
// "S<0>|<message>" status pushes once you subscribe with "sub port/antenna".
|
||||||
|
//
|
||||||
|
// (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used
|
||||||
|
// by pre-v4 firmware and is NOT what v4 speaks.)
|
||||||
|
package antgenius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPort = 9007
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
writeTimeout = 3 * time.Second
|
||||||
|
readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead
|
||||||
|
keepaliveEvery = 3 * time.Second // periodic "port get" refreshes state + keeps the link alive
|
||||||
|
reconnectDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Antenna is one configured antenna (index + name as stored on the device).
|
||||||
|
type Antenna struct {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the snapshot the UI renders.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
PortA int `json:"port_a"` // active antenna index on port A (0 = none)
|
||||||
|
PortB int `json:"port_b"` // active antenna index on port B
|
||||||
|
TxA bool `json:"tx_a"` // port A is transmitting
|
||||||
|
TxB bool `json:"tx_b"` // port B is transmitting
|
||||||
|
Antennas []Antenna `json:"antennas"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
|
||||||
|
mu sync.Mutex // guards conn + writes
|
||||||
|
conn net.Conn
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
antennas map[int]string // index → name (rebuilt into status.Antennas)
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = defaultPort
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
antennas: map[int]string{},
|
||||||
|
status: Status{Host: host},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.runLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setStatus(fn func(*Status)) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
fn(&c.status)
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets
|
||||||
|
// the port to "None"). We set both RX and TX antennas and force manual mode so
|
||||||
|
// the choice sticks regardless of the device's auto band-following.
|
||||||
|
func (c *Client) Activate(port, antenna int) error {
|
||||||
|
if port != 1 && port != 2 {
|
||||||
|
return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port)
|
||||||
|
}
|
||||||
|
if antenna < 0 {
|
||||||
|
return fmt.Errorf("antgenius: invalid antenna %d", antenna)
|
||||||
|
}
|
||||||
|
if err := c.send(fmt.Sprintf("port set %d rxant=%d txant=%d", port, antenna, antenna)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Ask for the new port state so the snapshot reflects it promptly (the
|
||||||
|
// subscription also pushes it, but this makes the change deterministic).
|
||||||
|
_ = c.send(fmt.Sprintf("port get %d", port))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) runLoop() {
|
||||||
|
for {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||||
|
if c.sleep(reconnectDelay) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
c.conn = conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
|
||||||
|
|
||||||
|
// Subscribe to live updates and pull the initial state. Command set and
|
||||||
|
// order mirror a known-working Node-RED v4 client (WA9WUD).
|
||||||
|
_ = c.send("antenna list")
|
||||||
|
_ = c.send("sub port all")
|
||||||
|
_ = c.send("port get 1")
|
||||||
|
_ = c.send("port get 2")
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go c.keepalive(conn, done)
|
||||||
|
err = c.readLoop(conn) // blocks until the link errors
|
||||||
|
close(done)
|
||||||
|
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn == conn {
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
conn.Close()
|
||||||
|
c.setStatus(func(s *Status) {
|
||||||
|
s.Connected = false
|
||||||
|
if err != nil {
|
||||||
|
s.LastError = "read: " + err.Error()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if c.sleep(reconnectDelay) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepalive periodically re-reads a port so an idle-but-dead link is detected
|
||||||
|
// (the read loop's idle timeout fires if these stop producing replies).
|
||||||
|
func (c *Client) keepalive(conn net.Conn, done chan struct{}) {
|
||||||
|
t := time.NewTicker(keepaliveEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
_ = c.send("port get 1")
|
||||||
|
_ = c.send("port get 2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readLoop(conn net.Conn) error {
|
||||||
|
r := bufio.NewReader(conn)
|
||||||
|
var sb strings.Builder
|
||||||
|
for {
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout))
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b == '\r' || b == '\n' {
|
||||||
|
if sb.Len() > 0 {
|
||||||
|
c.handleLine(sb.String())
|
||||||
|
sb.Reset()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteByte(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// send writes a "C<seq>|<command>\r" line to the device.
|
||||||
|
func (c *Client) send(command string) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("antgenius: not connected")
|
||||||
|
}
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout))
|
||||||
|
// The device only accepts the constant "C1|" sequence prefix for every
|
||||||
|
// command (using incrementing sequence numbers makes it drop the link);
|
||||||
|
// commands are LF-terminated.
|
||||||
|
_, err := fmt.Fprintf(c.conn, "C1|%s\n", command)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleLine parses one response/status/banner line and updates the snapshot.
|
||||||
|
func (c *Client) handleLine(line string) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Banner like "V4.1.16 AG" — just confirms the link is up.
|
||||||
|
if line[0] == 'V' && strings.Contains(line, "AG") {
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// R<seq>|<hex>|<message> or S<seq>|<message>
|
||||||
|
var msg string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "R"):
|
||||||
|
p := strings.SplitN(line, "|", 3)
|
||||||
|
if len(p) == 3 {
|
||||||
|
msg = p[2]
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(line, "S"):
|
||||||
|
p := strings.SplitN(line, "|", 2)
|
||||||
|
if len(p) == 2 {
|
||||||
|
msg = p[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(msg, "antenna "):
|
||||||
|
c.parseAntenna(msg)
|
||||||
|
case strings.HasPrefix(msg, "port "):
|
||||||
|
c.parsePort(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAntenna handles "antenna <id> name=<name> tx=.. rx=.. inband=..".
|
||||||
|
// The name may contain spaces, so it's extracted up to the " tx=" field.
|
||||||
|
func (c *Client) parseAntenna(msg string) {
|
||||||
|
fields := strings.Fields(msg)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := ""
|
||||||
|
if i := strings.Index(msg, "name="); i >= 0 {
|
||||||
|
name = msg[i+len("name="):]
|
||||||
|
if j := strings.Index(name, " tx="); j >= 0 {
|
||||||
|
name = name[:j]
|
||||||
|
}
|
||||||
|
// The device stores spaces as underscores in names.
|
||||||
|
name = strings.TrimSpace(strings.ReplaceAll(name, "_", " "))
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if name != "" && !isPlaceholderName(name) {
|
||||||
|
c.antennas[id] = name
|
||||||
|
} else {
|
||||||
|
delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown
|
||||||
|
}
|
||||||
|
c.status.Antennas = sortedAntennas(c.antennas)
|
||||||
|
c.status.Connected = true
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePort handles "port <id> ... rxant=<n> txant=<n> ...". The active antenna
|
||||||
|
// shown is the TX antenna, falling back to the RX antenna when TX is none.
|
||||||
|
func (c *Client) parsePort(msg string) {
|
||||||
|
fields := strings.Fields(msg)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(fields[1])
|
||||||
|
if err != nil || (id != 1 && id != 2) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx := kvInt(msg, "txant")
|
||||||
|
rx := kvInt(msg, "rxant")
|
||||||
|
active := tx
|
||||||
|
if active == 0 {
|
||||||
|
active = rx
|
||||||
|
}
|
||||||
|
txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag
|
||||||
|
c.setStatus(func(s *Status) {
|
||||||
|
s.Connected = true
|
||||||
|
if id == 1 {
|
||||||
|
s.PortA, s.TxA = active, txOn
|
||||||
|
} else {
|
||||||
|
s.PortB, s.TxB = active, txOn
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) sleep(d time.Duration) (stopped bool) {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return true
|
||||||
|
case <-time.After(d):
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// kvInt extracts the integer value of a "key=<int>" token from a space-
|
||||||
|
// separated string (returns 0 if absent).
|
||||||
|
func kvInt(s, key string) int {
|
||||||
|
i := strings.Index(s, key+"=")
|
||||||
|
if i < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v := s[i+len(key)+1:]
|
||||||
|
if sp := strings.IndexByte(v, ' '); sp >= 0 {
|
||||||
|
v = v[:sp]
|
||||||
|
}
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// isPlaceholderName reports whether name is an unconfigured-slot default like
|
||||||
|
// "Antenna 4" / "antenna_5" (after underscores become spaces): the word
|
||||||
|
// "antenna" followed by a number, which the UI shouldn't list.
|
||||||
|
func isPlaceholderName(name string) bool {
|
||||||
|
f := strings.Fields(strings.ToLower(name))
|
||||||
|
if len(f) != 2 || f[0] != "antenna" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := strconv.Atoi(f[1])
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedAntennas(m map[int]string) []Antenna {
|
||||||
|
out := make([]Antenna, 0, len(m))
|
||||||
|
for idx, name := range m {
|
||||||
|
out = append(out, Antenna{Index: idx, Name: name})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index })
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package antgenius
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestHandleAntennaList(t *testing.T) {
|
||||||
|
c := New("x", 9007)
|
||||||
|
// Names may contain spaces — must be captured up to " tx=".
|
||||||
|
c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000")
|
||||||
|
c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000")
|
||||||
|
st := c.GetStatus()
|
||||||
|
if len(st.Antennas) != 2 {
|
||||||
|
t.Fatalf("got %d antennas, want 2: %+v", len(st.Antennas), st.Antennas)
|
||||||
|
}
|
||||||
|
if st.Antennas[0].Index != 1 || st.Antennas[0].Name != "UB VL2.3" {
|
||||||
|
t.Errorf("antenna 1 = %+v", st.Antennas[0])
|
||||||
|
}
|
||||||
|
if st.Antennas[1].Name != "DX Commander" {
|
||||||
|
t.Errorf("antenna 2 name = %q", st.Antennas[1].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAntennaUnderscoreAndPlaceholder(t *testing.T) {
|
||||||
|
c := New("x", 9007)
|
||||||
|
c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space
|
||||||
|
c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||||
|
c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered
|
||||||
|
st := c.GetStatus()
|
||||||
|
if len(st.Antennas) != 1 || st.Antennas[0].Name != "Hex Beam" {
|
||||||
|
t.Fatalf("want only [Hex Beam], got %+v", st.Antennas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlePortStatus(t *testing.T) {
|
||||||
|
c := New("x", 9007)
|
||||||
|
// Async push after "sub port all": active antenna is txant (fallback rxant).
|
||||||
|
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0")
|
||||||
|
c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0")
|
||||||
|
st := c.GetStatus()
|
||||||
|
if st.PortA != 2 {
|
||||||
|
t.Errorf("PortA = %d, want 2", st.PortA)
|
||||||
|
}
|
||||||
|
if st.PortB != 0 {
|
||||||
|
t.Errorf("PortB = %d, want 0 (none)", st.PortB)
|
||||||
|
}
|
||||||
|
// A "port get" reply (R-line) must parse the same way.
|
||||||
|
c.handleLine("R15|0|port 2 source=MANUAL band=3 rxant=5 txant=5 inband=0 inhibit=0")
|
||||||
|
if st = c.GetStatus(); st.PortB != 5 {
|
||||||
|
t.Errorf("PortB after port get = %d, want 5", st.PortB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortTxFallbackToRx(t *testing.T) {
|
||||||
|
c := New("x", 9007)
|
||||||
|
c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0")
|
||||||
|
if st := c.GetStatus(); st.PortA != 3 {
|
||||||
|
t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKvInt(t *testing.T) {
|
||||||
|
s := "port 1 source=MANUAL band=6 rxant=2 txant=7 inhibit=0"
|
||||||
|
if v := kvInt(s, "txant"); v != 7 {
|
||||||
|
t.Errorf("txant = %d, want 7", v)
|
||||||
|
}
|
||||||
|
if v := kvInt(s, "rxant"); v != 2 {
|
||||||
|
t.Errorf("rxant = %d, want 2", v)
|
||||||
|
}
|
||||||
|
if v := kvInt(s, "missing"); v != 0 {
|
||||||
|
t.Errorf("missing = %d, want 0", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package audio
|
||||||
|
|
||||||
|
// SampleRate is the fixed capture rate (mono 16-bit PCM). Exported so consumers
|
||||||
|
// like the CW decoder can configure their DSP to match.
|
||||||
|
const SampleRate = sampleRate
|
||||||
|
|
||||||
|
// StreamCapture captures from deviceID and calls onSamples with mono 16-bit PCM
|
||||||
|
// frames (as int16) until stop closes. A thin wrapper over the internal capture
|
||||||
|
// loop for live consumers (the CW decoder) that want samples, not raw bytes.
|
||||||
|
func StreamCapture(deviceID string, stop <-chan struct{}, onSamples func([]int16)) error {
|
||||||
|
return captureStream(deviceID, stop, func(chunk []byte) {
|
||||||
|
if len(chunk) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSamples(bytesToInt16(chunk))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -226,6 +226,200 @@ func (m *Manager) SendSpot(s SpotInfo) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
|
||||||
|
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
|
||||||
|
// the radio over TCP; live meters arrive over a separate UDP stream later.)
|
||||||
|
type FlexTXState struct {
|
||||||
|
Available bool `json:"available"` // backend is Flex and handshaked
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
RFPower int `json:"rf_power"`
|
||||||
|
TunePower int `json:"tune_power"`
|
||||||
|
Tune bool `json:"tune"` // tune carrier active
|
||||||
|
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
|
||||||
|
VoxEnable bool `json:"vox_enable"`
|
||||||
|
VoxLevel int `json:"vox_level"`
|
||||||
|
VoxDelay int `json:"vox_delay"`
|
||||||
|
ProcEnable bool `json:"proc_enable"`
|
||||||
|
ProcLevel int `json:"proc_level"`
|
||||||
|
Mon bool `json:"mon"`
|
||||||
|
MonLevel int `json:"mon_level"`
|
||||||
|
MicLevel int `json:"mic_level"`
|
||||||
|
ATUStatus string `json:"atu_status,omitempty"`
|
||||||
|
ATUMemories bool `json:"atu_memories"`
|
||||||
|
// Active RX slice DSP controls.
|
||||||
|
RXAvail bool `json:"rx_avail"` // an RX slice exists
|
||||||
|
AGCMode string `json:"agc_mode,omitempty"`
|
||||||
|
AGCThreshold int `json:"agc_threshold"`
|
||||||
|
AudioLevel int `json:"audio_level"`
|
||||||
|
NB bool `json:"nb"`
|
||||||
|
NBLevel int `json:"nb_level"`
|
||||||
|
NR bool `json:"nr"`
|
||||||
|
NRLevel int `json:"nr_level"`
|
||||||
|
ANF bool `json:"anf"`
|
||||||
|
ANFLevel int `json:"anf_level"`
|
||||||
|
// CW / mode-specific controls.
|
||||||
|
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
|
||||||
|
CWSpeed int `json:"cw_speed"`
|
||||||
|
CWPitch int `json:"cw_pitch"`
|
||||||
|
CWBreakInDelay int `json:"cw_break_in_delay"`
|
||||||
|
CWSidetone bool `json:"cw_sidetone"`
|
||||||
|
CWMonLevel int `json:"cw_mon_level"` // sidetone level
|
||||||
|
APF bool `json:"apf"`
|
||||||
|
APFLevel int `json:"apf_level"`
|
||||||
|
FilterLo int `json:"filter_lo"`
|
||||||
|
FilterHi int `json:"filter_hi"`
|
||||||
|
// External amplifier (PowerGenius XL).
|
||||||
|
AmpAvailable bool `json:"amp_available"`
|
||||||
|
AmpModel string `json:"amp_model,omitempty"`
|
||||||
|
AmpOperate bool `json:"amp_operate"`
|
||||||
|
AmpFault string `json:"amp_fault,omitempty"`
|
||||||
|
// Live meters streamed over UDP (S-meter, PWR, SWR, temp, voltage…).
|
||||||
|
Meters []FlexMeter `json:"meters,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexMeter is one live meter value (already scaled to real units).
|
||||||
|
type FlexMeter struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
|
||||||
|
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
|
||||||
|
Unit string `json:"unit,omitempty"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Lo float64 `json:"lo"`
|
||||||
|
Hi float64 `json:"hi"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexController is an OPTIONAL backend capability (the FlexRadio backend): the
|
||||||
|
// SmartSDR-style transmit controls. Backends that don't implement it are skipped
|
||||||
|
// by the FlexRadio tab. FlexState() is mutex-guarded in the backend so it's safe
|
||||||
|
// to read off the CAT goroutine; the setters are dispatched onto it via FlexDo.
|
||||||
|
type FlexController interface {
|
||||||
|
FlexState() FlexTXState
|
||||||
|
SetRFPower(int) error
|
||||||
|
SetTunePower(int) error
|
||||||
|
SetTune(bool) error
|
||||||
|
SetVOX(bool) error
|
||||||
|
SetVOXLevel(int) error
|
||||||
|
SetVOXDelay(int) error
|
||||||
|
SetProcessor(bool) error
|
||||||
|
SetProcessorLevel(int) error
|
||||||
|
SetMon(bool) error
|
||||||
|
SetMonLevel(int) error
|
||||||
|
SetMic(int) error
|
||||||
|
ATUStart() error
|
||||||
|
ATUBypass() error
|
||||||
|
SetATUMemories(bool) error
|
||||||
|
// RX slice DSP controls (target the active receive slice).
|
||||||
|
SetAGCMode(string) error
|
||||||
|
SetAGCThreshold(int) error
|
||||||
|
SetAudioLevel(int) error
|
||||||
|
SetNB(bool) error
|
||||||
|
SetNBLevel(int) error
|
||||||
|
SetNR(bool) error
|
||||||
|
SetNRLevel(int) error
|
||||||
|
SetANF(bool) error
|
||||||
|
SetANFLevel(int) error
|
||||||
|
SetAPF(bool) error
|
||||||
|
SetAPFLevel(int) error
|
||||||
|
// CW keyer + mode-specific controls.
|
||||||
|
SetCWSpeed(int) error
|
||||||
|
SetCWPitch(int) error
|
||||||
|
SetCWBreakInDelay(int) error
|
||||||
|
SetCWSidetone(bool) error
|
||||||
|
SetSidetoneLevel(int) error
|
||||||
|
SetCWFilter(int) error
|
||||||
|
SetFilter(lo, hi int) error
|
||||||
|
// External amplifier (PowerGenius XL) operate/standby.
|
||||||
|
SetAmpOperate(bool) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexState returns the current FlexRadio transmit state, or (zero, false) when
|
||||||
|
// the active backend isn't a Flex. Safe to call from any goroutine.
|
||||||
|
func (m *Manager) FlexState() (FlexTXState, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if fc, ok := b.(FlexController); ok {
|
||||||
|
return fc.FlexState(), true
|
||||||
|
}
|
||||||
|
return FlexTXState{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlexDo dispatches a FlexRadio control onto the CAT goroutine. Errors if the
|
||||||
|
// active backend isn't a Flex.
|
||||||
|
func (m *Manager) FlexDo(fn func(FlexController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
fc, ok := b.(FlexController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not a FlexRadio")
|
||||||
|
}
|
||||||
|
return fn(fc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomTXState is the Icom receive-DSP state surfaced to the dedicated Icom
|
||||||
|
// control tab. Levels are 0-100 (scaled from the rig's 0-255). Unlike Flex,
|
||||||
|
// the Icom doesn't push changes, so these reflect the last RefreshIcom() read
|
||||||
|
// plus the optimistic updates each setter applies.
|
||||||
|
type IcomTXState struct {
|
||||||
|
Available bool `json:"available"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
Mode string `json:"mode,omitempty"`
|
||||||
|
AFGain int `json:"af_gain"`
|
||||||
|
RFGain int `json:"rf_gain"`
|
||||||
|
NB bool `json:"nb"`
|
||||||
|
NBLevel int `json:"nb_level"`
|
||||||
|
NR bool `json:"nr"`
|
||||||
|
NRLevel int `json:"nr_level"`
|
||||||
|
ANF bool `json:"anf"`
|
||||||
|
AGC string `json:"agc,omitempty"` // FAST | MID | SLOW
|
||||||
|
Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
|
Att int `json:"att"` // dB attenuation, 0=off
|
||||||
|
Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the
|
||||||
|
// receive-DSP controls shown on the Icom tab. IcomState() is mutex-guarded in
|
||||||
|
// the backend so it's safe off the CAT goroutine; setters dispatch via IcomDo.
|
||||||
|
type IcomController interface {
|
||||||
|
IcomState() IcomTXState
|
||||||
|
RefreshIcom() error // re-read all DSP state from the rig
|
||||||
|
SetAFGain(int) error
|
||||||
|
SetRFGain(int) error
|
||||||
|
SetNB(bool) error
|
||||||
|
SetNBLevel(int) error
|
||||||
|
SetNR(bool) error
|
||||||
|
SetNRLevel(int) error
|
||||||
|
SetANF(bool) error
|
||||||
|
SetAGC(string) error
|
||||||
|
SetPreamp(int) error
|
||||||
|
SetAtt(int) error
|
||||||
|
SetIcomFilter(int) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomState returns the current Icom DSP state, or (zero, false) when the active
|
||||||
|
// backend isn't an Icom. Safe to call from any goroutine.
|
||||||
|
func (m *Manager) IcomState() (IcomTXState, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
b := m.backend
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if ic, ok := b.(IcomController); ok {
|
||||||
|
return ic.IcomState(), true
|
||||||
|
}
|
||||||
|
return IcomTXState{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
|
||||||
|
// active backend isn't an Icom.
|
||||||
|
func (m *Manager) IcomDo(fn func(IcomController) error) error {
|
||||||
|
return m.exec(func(b Backend) error {
|
||||||
|
ic, ok := b.(IcomController)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("active CAT backend is not an Icom")
|
||||||
|
}
|
||||||
|
return fn(ic)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||||
func (m *Manager) exec(fn func(Backend) error) error {
|
func (m *Manager) exec(fn func(Backend) error) error {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
// Package civ implements the Icom CI-V protocol independently of the transport
|
||||||
|
// carrying it. The exact same frames travel over a USB/serial port (local
|
||||||
|
// control) and, wrapped in Icom's UDP "serial" stream, over the network
|
||||||
|
// (remote control). Keeping the wire format in one place means the USB backend
|
||||||
|
// (icomserial) and a future network backend (icomnet) share all of it — only
|
||||||
|
// the transport differs.
|
||||||
|
//
|
||||||
|
// Frame layout: FE FE <to> <from> <cmd> [sub] [data…] FD
|
||||||
|
package civ
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Protocol bytes.
|
||||||
|
const (
|
||||||
|
Pre = 0xFE // preamble (sent twice at the start of every frame)
|
||||||
|
End = 0xFD // end-of-message
|
||||||
|
OK = 0xFB // rig acknowledged a set command
|
||||||
|
NG = 0xFA // rig rejected a set command
|
||||||
|
|
||||||
|
// AddrController is the conventional address software uses for itself.
|
||||||
|
AddrController = 0xE0
|
||||||
|
)
|
||||||
|
|
||||||
|
// Commands (the few Phase-1 control needs; more get added with the panel).
|
||||||
|
const (
|
||||||
|
CmdTransceiveFreq = 0x00 // unsolicited freq update (dial turned)
|
||||||
|
CmdTransceiveMode = 0x01 // unsolicited mode update
|
||||||
|
CmdReadFreq = 0x03
|
||||||
|
CmdReadMode = 0x04
|
||||||
|
CmdSetFreq = 0x05
|
||||||
|
CmdSetMode = 0x06
|
||||||
|
CmdPTT = 0x1C // sub 0x00 = PTT
|
||||||
|
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
|
||||||
|
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
|
||||||
|
|
||||||
|
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||||
|
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
||||||
|
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||||
|
|
||||||
|
SubDataMode = 0x06
|
||||||
|
SubPTT = 0x00
|
||||||
|
|
||||||
|
// CmdLevel sub-commands.
|
||||||
|
SubLevelAF = 0x01 // AF (volume)
|
||||||
|
SubLevelRF = 0x02 // RF gain
|
||||||
|
SubLevelNR = 0x06 // noise-reduction depth
|
||||||
|
SubLevelNB = 0x12 // noise-blanker depth
|
||||||
|
|
||||||
|
// CmdSwitch sub-commands.
|
||||||
|
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||||
|
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
|
||||||
|
SubSwNB = 0x22 // noise blanker on/off
|
||||||
|
SubSwNR = 0x40 // noise reduction on/off
|
||||||
|
SubSwANF = 0x41 // auto-notch on/off
|
||||||
|
)
|
||||||
|
|
||||||
|
// Icom mode codes (used by CmdReadMode / CmdSetMode).
|
||||||
|
const (
|
||||||
|
ModeLSB = 0x00
|
||||||
|
ModeUSB = 0x01
|
||||||
|
ModeAM = 0x02
|
||||||
|
ModeCW = 0x03
|
||||||
|
ModeRTTY = 0x04
|
||||||
|
ModeFM = 0x05
|
||||||
|
ModeCWR = 0x07
|
||||||
|
ModeRTTYR = 0x08
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frame builds a complete CI-V frame (preamble … end) for payload, which is the
|
||||||
|
// command byte followed by any sub-command/data bytes.
|
||||||
|
func Frame(to, from byte, payload ...byte) []byte {
|
||||||
|
f := make([]byte, 0, len(payload)+5)
|
||||||
|
f = append(f, Pre, Pre, to, from)
|
||||||
|
f = append(f, payload...)
|
||||||
|
f = append(f, End)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// FreqToBCD encodes a frequency in Hz as the 5 little-endian BCD bytes Icom
|
||||||
|
// expects (10 digits, 2 per byte, least-significant byte first).
|
||||||
|
func FreqToBCD(hz int64) []byte {
|
||||||
|
if hz < 0 {
|
||||||
|
hz = 0
|
||||||
|
}
|
||||||
|
b := make([]byte, 5)
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
lo := hz % 10
|
||||||
|
hz /= 10
|
||||||
|
hi := hz % 10
|
||||||
|
hz /= 10
|
||||||
|
b[i] = byte(lo) | byte(hi)<<4
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
||||||
|
func BCDToFreq(b []byte) int64 {
|
||||||
|
var hz int64
|
||||||
|
mult := int64(1)
|
||||||
|
for i := 0; i < len(b) && i < 5; i++ {
|
||||||
|
hz += int64(b[i]&0x0F) * mult
|
||||||
|
mult *= 10
|
||||||
|
hz += int64(b[i]>>4) * mult
|
||||||
|
mult *= 10
|
||||||
|
}
|
||||||
|
return hz
|
||||||
|
}
|
||||||
|
|
||||||
|
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
||||||
|
// CmdLevel commands use (e.g. 128 → 0x01 0x28, 255 → 0x02 0x55).
|
||||||
|
func LevelToBCD(v int) []byte {
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > 255 {
|
||||||
|
v = 255
|
||||||
|
}
|
||||||
|
return []byte{byte(v / 100), byte(((v/10)%10)<<4 | v%10)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BCDToLevel decodes the 2 BCD bytes of a CmdLevel response back to 0-255.
|
||||||
|
func BCDToLevel(b []byte) int {
|
||||||
|
if len(b) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||||
|
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||||
|
func ByteToBCD(v int) byte {
|
||||||
|
if v < 0 {
|
||||||
|
v = 0
|
||||||
|
}
|
||||||
|
if v > 99 {
|
||||||
|
v = 99
|
||||||
|
}
|
||||||
|
return byte((v/10)<<4 | v%10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BCDToByte(b byte) int { return int(b>>4)*10 + int(b&0x0F) }
|
||||||
|
|
||||||
|
// ModeToADIF maps an Icom mode byte (plus the data-mode flag) to an ADIF mode
|
||||||
|
// string. Data mode on USB/LSB is surfaced as "DATA" so the app can substitute
|
||||||
|
// the user's preferred digital mode (FT8/RTTY/…), matching the OmniRig backend.
|
||||||
|
func ModeToADIF(m byte, data bool) string {
|
||||||
|
switch m {
|
||||||
|
case ModeCW, ModeCWR:
|
||||||
|
return "CW"
|
||||||
|
case ModeRTTY, ModeRTTYR:
|
||||||
|
return "RTTY"
|
||||||
|
case ModeAM:
|
||||||
|
return "AM"
|
||||||
|
case ModeFM:
|
||||||
|
return "FM"
|
||||||
|
case ModeLSB, ModeUSB:
|
||||||
|
if data {
|
||||||
|
return "DATA"
|
||||||
|
}
|
||||||
|
return "SSB"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
|
||||||
|
// model. Unknown addresses fall back to a hex label.
|
||||||
|
func ModelName(addr byte) string {
|
||||||
|
switch addr {
|
||||||
|
case 0x94:
|
||||||
|
return "IC-7300"
|
||||||
|
case 0x98:
|
||||||
|
return "IC-7610"
|
||||||
|
case 0xA2:
|
||||||
|
return "IC-9700"
|
||||||
|
case 0xA4:
|
||||||
|
return "IC-705"
|
||||||
|
case 0x88:
|
||||||
|
return "IC-7700"
|
||||||
|
case 0x80:
|
||||||
|
return "IC-7800"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoded is one parsed CI-V frame. Data is everything after the command byte
|
||||||
|
// (so it still carries the sub-command for multi-byte commands like 1A 06).
|
||||||
|
type Decoded struct {
|
||||||
|
To byte
|
||||||
|
From byte
|
||||||
|
Cmd byte
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan extracts every complete frame from buf and reports how many leading
|
||||||
|
// bytes the caller may now discard. A trailing partial frame (or a lone
|
||||||
|
// preamble byte) is left unconsumed so it can be completed by the next read.
|
||||||
|
func Scan(buf []byte) (frames []Decoded, consumed int) {
|
||||||
|
pos := 0
|
||||||
|
for {
|
||||||
|
p := indexPreamble(buf, pos)
|
||||||
|
if p < 0 {
|
||||||
|
// No further preamble. Keep a trailing FE (possible start of the
|
||||||
|
// next preamble); otherwise everything seen is consumable.
|
||||||
|
if len(buf) > 0 && buf[len(buf)-1] == Pre {
|
||||||
|
return frames, len(buf) - 1
|
||||||
|
}
|
||||||
|
return frames, len(buf)
|
||||||
|
}
|
||||||
|
start := p + 2
|
||||||
|
for start < len(buf) && buf[start] == Pre { // tolerate padding FEs
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
end := bytes.IndexByte(buf[start:], End)
|
||||||
|
if end < 0 {
|
||||||
|
return frames, p // incomplete frame — keep from its preamble
|
||||||
|
}
|
||||||
|
end += start
|
||||||
|
if body := buf[start:end]; len(body) >= 3 {
|
||||||
|
frames = append(frames, Decoded{
|
||||||
|
To: body[0],
|
||||||
|
From: body[1],
|
||||||
|
Cmd: body[2],
|
||||||
|
Data: append([]byte(nil), body[3:]...),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pos = end + 1
|
||||||
|
consumed = pos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexPreamble returns the index of the next FE FE pair at or after from.
|
||||||
|
func indexPreamble(buf []byte, from int) int {
|
||||||
|
for i := from; i+1 < len(buf); i++ {
|
||||||
|
if buf[i] == Pre && buf[i+1] == Pre {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package civ
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFreqBCDRoundTrip(t *testing.T) {
|
||||||
|
cases := []int64{0, 1, 7074000, 14250000, 28074000, 50313000, 144174000, 1296000000}
|
||||||
|
for _, hz := range cases {
|
||||||
|
b := FreqToBCD(hz)
|
||||||
|
if len(b) != 5 {
|
||||||
|
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
||||||
|
}
|
||||||
|
if got := BCDToFreq(b); got != hz {
|
||||||
|
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreqBCDKnownEncoding(t *testing.T) {
|
||||||
|
// 14.250.000 Hz → little-endian BCD 00 00 25 14 00.
|
||||||
|
want := []byte{0x00, 0x00, 0x25, 0x14, 0x00}
|
||||||
|
if got := FreqToBCD(14250000); !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("FreqToBCD(14250000) = % X, want % X", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrame(t *testing.T) {
|
||||||
|
// Read-frequency request to a 7610 (0x98) from the controller (0xE0).
|
||||||
|
got := Frame(0x98, AddrController, CmdReadFreq)
|
||||||
|
want := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
|
||||||
|
if !bytes.Equal(got, want) {
|
||||||
|
t.Errorf("Frame = % X, want % X", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSingleFreqResponse(t *testing.T) {
|
||||||
|
// Rig (0x98) → controller (0xE0): freq read response for 14.250 MHz.
|
||||||
|
in := Frame(AddrController, 0x98, CmdReadFreq, 0x00, 0x00, 0x25, 0x14, 0x00)
|
||||||
|
frames, consumed := Scan(in)
|
||||||
|
if consumed != len(in) {
|
||||||
|
t.Fatalf("consumed=%d, want %d", consumed, len(in))
|
||||||
|
}
|
||||||
|
if len(frames) != 1 {
|
||||||
|
t.Fatalf("got %d frames, want 1", len(frames))
|
||||||
|
}
|
||||||
|
f := frames[0]
|
||||||
|
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
||||||
|
t.Errorf("addrs/cmd wrong: %+v", f)
|
||||||
|
}
|
||||||
|
if hz := BCDToFreq(f.Data); hz != 14250000 {
|
||||||
|
t.Errorf("decoded freq %d, want 14250000", hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
|
||||||
|
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
|
||||||
|
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
|
||||||
|
buf := append(append([]byte{}, echo...), resp...)
|
||||||
|
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
|
||||||
|
|
||||||
|
frames, consumed := Scan(buf)
|
||||||
|
if len(frames) != 2 {
|
||||||
|
t.Fatalf("got %d frames, want 2", len(frames))
|
||||||
|
}
|
||||||
|
// The partial frame must be left unconsumed so the next read can finish it.
|
||||||
|
if consumed != len(echo)+len(resp) {
|
||||||
|
t.Errorf("consumed=%d, want %d (partial frame retained)", consumed, len(echo)+len(resp))
|
||||||
|
}
|
||||||
|
if frames[1].Cmd != CmdReadMode || len(frames[1].Data) < 1 || frames[1].Data[0] != ModeCW {
|
||||||
|
t.Errorf("second frame wrong: %+v", frames[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModeToADIF(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
m byte
|
||||||
|
data bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{ModeUSB, false, "SSB"},
|
||||||
|
{ModeLSB, false, "SSB"},
|
||||||
|
{ModeUSB, true, "DATA"},
|
||||||
|
{ModeCW, false, "CW"},
|
||||||
|
{ModeCWR, false, "CW"},
|
||||||
|
{ModeRTTY, false, "RTTY"},
|
||||||
|
{ModeAM, false, "AM"},
|
||||||
|
{ModeFM, false, "FM"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := ModeToADIF(c.m, c.data); got != c.want {
|
||||||
|
t.Errorf("ModeToADIF(0x%02X, %v) = %q, want %q", c.m, c.data, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelBCDRoundTrip(t *testing.T) {
|
||||||
|
for _, v := range []int{0, 1, 50, 99, 100, 128, 200, 255} {
|
||||||
|
b := LevelToBCD(v)
|
||||||
|
if len(b) != 2 {
|
||||||
|
t.Fatalf("LevelToBCD(%d) len=%d", v, len(b))
|
||||||
|
}
|
||||||
|
if got := BCDToLevel(b); got != v {
|
||||||
|
t.Errorf("level round trip %d → % X → %d", v, b, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Known encodings from the Icom CI-V reference.
|
||||||
|
if got := LevelToBCD(128); !bytes.Equal(got, []byte{0x01, 0x28}) {
|
||||||
|
t.Errorf("LevelToBCD(128) = % X, want 01 28", got)
|
||||||
|
}
|
||||||
|
if got := LevelToBCD(255); !bytes.Equal(got, []byte{0x02, 0x55}) {
|
||||||
|
t.Errorf("LevelToBCD(255) = % X, want 02 55", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestByteBCDRoundTrip(t *testing.T) {
|
||||||
|
for _, v := range []int{0, 6, 12, 18, 21} {
|
||||||
|
if got := BCDToByte(ByteToBCD(v)); got != v {
|
||||||
|
t.Errorf("byte BCD round trip %d → %d", v, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelName(t *testing.T) {
|
||||||
|
if got := ModelName(0x98); got != "IC-7610" {
|
||||||
|
t.Errorf("ModelName(0x98) = %q, want IC-7610", got)
|
||||||
|
}
|
||||||
|
if got := ModelName(0x12); got != "Icom (0x12)" {
|
||||||
|
t.Errorf("ModelName(0x12) = %q, want fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1028
-6
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,557 @@
|
|||||||
|
package cat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/cat/civ"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local
|
||||||
|
// control). It speaks the shared civ protocol, so when the network backend
|
||||||
|
// (icomnet) is added it will reuse the same encode/decode — only the transport
|
||||||
|
// changes. Implements Backend; all methods run on the Manager's CAT goroutine,
|
||||||
|
// so the port is accessed single-threaded (no locking needed).
|
||||||
|
type IcomSerial struct {
|
||||||
|
portName string
|
||||||
|
baud int
|
||||||
|
rigAddr byte // rig's CI-V address (IC-7610 default 0x98)
|
||||||
|
digital string // mode to command for DATA (FT8/RTTY/…)
|
||||||
|
|
||||||
|
port serial.Port
|
||||||
|
rx []byte // accumulated bytes awaiting a complete frame
|
||||||
|
model string
|
||||||
|
|
||||||
|
curFreq int64 // last frequency read (for sideband choice)
|
||||||
|
curModeByte byte // last raw Icom mode byte (for filter re-send)
|
||||||
|
lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
|
||||||
|
lastSetFreqAt time.Time
|
||||||
|
|
||||||
|
// dsp caches the receive-DSP state for the Icom control tab. Read off the
|
||||||
|
// CAT goroutine via IcomState(), written on the CAT goroutine (RefreshIcom
|
||||||
|
// / setters) — hence the mutex.
|
||||||
|
dspMu sync.Mutex
|
||||||
|
dsp IcomTXState
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
icomReadTimeout = 350 * time.Millisecond // wait for a poll response
|
||||||
|
icomCmdTimeout = 400 * time.Millisecond // wait for a set ack (FB/FA)
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to
|
||||||
|
// 115200, rig address to the IC-7610's 0x98 when out of range.
|
||||||
|
func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial {
|
||||||
|
if baud <= 0 {
|
||||||
|
baud = 115200
|
||||||
|
}
|
||||||
|
if civAddr <= 0 || civAddr > 0xFF {
|
||||||
|
civAddr = 0x98 // IC-7610
|
||||||
|
}
|
||||||
|
if digitalDefault == "" {
|
||||||
|
digitalDefault = "FT8"
|
||||||
|
}
|
||||||
|
return &IcomSerial{
|
||||||
|
portName: portName,
|
||||||
|
baud: baud,
|
||||||
|
rigAddr: byte(civAddr),
|
||||||
|
digital: strings.ToUpper(digitalDefault),
|
||||||
|
model: "Icom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) Name() string { return "icom" }
|
||||||
|
|
||||||
|
func (b *IcomSerial) Connect() error {
|
||||||
|
if b.portName == "" {
|
||||||
|
return fmt.Errorf("no serial port configured")
|
||||||
|
}
|
||||||
|
port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err)
|
||||||
|
}
|
||||||
|
// Short read timeout so recv() polls in a tight loop without blocking the
|
||||||
|
// CAT goroutine when the rig is silent.
|
||||||
|
_ = port.SetReadTimeout(60 * time.Millisecond)
|
||||||
|
b.port = port
|
||||||
|
b.rx = b.rx[:0]
|
||||||
|
b.model = civ.ModelName(b.rigAddr)
|
||||||
|
|
||||||
|
// Best-effort model identification: ask the rig for its own CI-V address.
|
||||||
|
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
|
||||||
|
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00
|
||||||
|
}); err == nil {
|
||||||
|
b.model = civ.ModelName(f.Data[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.readDSP() // best-effort initial snapshot for the control tab
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) Disconnect() {
|
||||||
|
if b.port != nil {
|
||||||
|
_ = b.port.Close()
|
||||||
|
b.port = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadState polls the rig for frequency and mode. A failed frequency read is
|
||||||
|
// treated as "lost the rig" so the Manager reconnects.
|
||||||
|
func (b *IcomSerial) ReadState() (RigState, error) {
|
||||||
|
if b.port == nil {
|
||||||
|
return RigState{}, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
s := RigState{Backend: b.Name(), Connected: true, Rig: b.model}
|
||||||
|
|
||||||
|
hz, err := b.readFreq()
|
||||||
|
if err != nil {
|
||||||
|
return RigState{}, err
|
||||||
|
}
|
||||||
|
s.FreqHz = hz
|
||||||
|
b.curFreq = hz
|
||||||
|
|
||||||
|
if m, ok := b.readMode(); ok {
|
||||||
|
b.curModeByte = m
|
||||||
|
data := b.readDataMode() // best-effort; ignored on failure
|
||||||
|
s.Mode = civ.ModeToADIF(m, data)
|
||||||
|
if s.Mode == "DATA" {
|
||||||
|
s.Mode = b.digital
|
||||||
|
}
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp.Mode = s.Mode
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetFrequency(hz int64) error {
|
||||||
|
if hz <= 0 {
|
||||||
|
return fmt.Errorf("invalid frequency")
|
||||||
|
}
|
||||||
|
b.lastSetFreq, b.lastSetFreqAt = hz, time.Now()
|
||||||
|
return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetMode(mode string) error {
|
||||||
|
code, data, err := b.modeCode(mode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Set the base mode (keeping the rig's current filter by sending only the
|
||||||
|
// mode byte), then set the data-mode flag for digital modes.
|
||||||
|
if err := b.exec(civ.CmdSetMode, code); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dataByte := byte(0)
|
||||||
|
if data {
|
||||||
|
dataByte = 1
|
||||||
|
}
|
||||||
|
// Filter 0x01 (FIL1) is the conventional default for the data-mode set.
|
||||||
|
_ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetPTT(on bool) error {
|
||||||
|
state := byte(0)
|
||||||
|
if on {
|
||||||
|
state = 1
|
||||||
|
}
|
||||||
|
return b.exec(civ.CmdPTT, civ.SubPTT, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (b *IcomSerial) write(payload ...byte) error {
|
||||||
|
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// recv reads from the port until a frame from the rig satisfies match or the
|
||||||
|
// timeout elapses. Frames that are our own echo (from == controller) or don't
|
||||||
|
// match are discarded.
|
||||||
|
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
tmp := make([]byte, 256)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := b.port.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return civ.Decoded{}, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.rx = append(b.rx, tmp[:n]...)
|
||||||
|
frames, consumed := civ.Scan(b.rx)
|
||||||
|
if consumed > 0 {
|
||||||
|
b.rx = append(b.rx[:0], b.rx[consumed:]...)
|
||||||
|
}
|
||||||
|
for _, f := range frames {
|
||||||
|
if f.From != b.rigAddr {
|
||||||
|
continue // skip echo of our own commands
|
||||||
|
}
|
||||||
|
if match(f) {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
|
||||||
|
func (b *IcomSerial) exec(payload ...byte) error {
|
||||||
|
if err := b.write(payload...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.OK || d.Cmd == civ.NG
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if f.Cmd == civ.NG {
|
||||||
|
return fmt.Errorf("icom: rig rejected command 0x%02X", payload[0])
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readFreq() (int64, error) {
|
||||||
|
if err := b.write(civ.CmdReadFreq); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdReadFreq || d.Cmd == civ.CmdTransceiveFreq
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return civ.BCDToFreq(f.Data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readMode() (byte, bool) {
|
||||||
|
if err := b.write(civ.CmdReadMode); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return (d.Cmd == civ.CmdReadMode || d.Cmd == civ.CmdTransceiveMode) && len(d.Data) >= 1
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f.Data[0], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readDataMode() bool {
|
||||||
|
if err := b.write(civ.CmdExtra, civ.SubDataMode); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubDataMode
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return f.Data[1] != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeCode maps an ADIF mode to an Icom mode byte plus whether the data-mode
|
||||||
|
// flag should be set. SSB sideband follows the usual convention (LSB below
|
||||||
|
// 10 MHz, USB above); the frequency just commanded is preferred over the last
|
||||||
|
// poll so a clicked spot (freq then mode) picks the right sideband immediately.
|
||||||
|
func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
|
||||||
|
freq := b.curFreq
|
||||||
|
if b.lastSetFreq > 0 && time.Since(b.lastSetFreqAt) < 5*time.Second {
|
||||||
|
freq = b.lastSetFreq
|
||||||
|
}
|
||||||
|
usb := byte(civ.ModeUSB)
|
||||||
|
if freq > 0 && freq < 10_000_000 {
|
||||||
|
usb = civ.ModeLSB
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
return civ.ModeCW, false, nil
|
||||||
|
case "SSB":
|
||||||
|
return usb, false, nil
|
||||||
|
case "AM":
|
||||||
|
return civ.ModeAM, false, nil
|
||||||
|
case "FM":
|
||||||
|
return civ.ModeFM, false, nil
|
||||||
|
case "RTTY", "FSK":
|
||||||
|
return civ.ModeRTTY, false, nil
|
||||||
|
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE":
|
||||||
|
// Digital data modes ride on USB with the data flag set (FT8 etc.).
|
||||||
|
return civ.ModeUSB, true, nil
|
||||||
|
}
|
||||||
|
return 0, false, fmt.Errorf("icom: unsupported mode %q", mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IcomController: receive-DSP controls for the Icom tab ───────────────────
|
||||||
|
|
||||||
|
func (b *IcomSerial) IcomState() IcomTXState {
|
||||||
|
b.dspMu.Lock()
|
||||||
|
defer b.dspMu.Unlock()
|
||||||
|
return b.dsp
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
|
||||||
|
// goroutine (dispatched via IcomDo).
|
||||||
|
func (b *IcomSerial) RefreshIcom() error {
|
||||||
|
if b.port == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
b.readDSP()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readDSP polls every DSP value once and replaces the cache. Best-effort: a
|
||||||
|
// value the rig doesn't answer keeps its previous cached value rather than
|
||||||
|
// stalling (each read has a short timeout).
|
||||||
|
func (b *IcomSerial) readDSP() {
|
||||||
|
st := IcomTXState{Available: true, Model: b.model}
|
||||||
|
b.dspMu.Lock()
|
||||||
|
st.Mode = b.dsp.Mode // preserve mode (set by ReadState)
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelAF); ok {
|
||||||
|
st.AFGain = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelRF); ok {
|
||||||
|
st.RFGain = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelNR); ok {
|
||||||
|
st.NRLevel = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readLevel(civ.SubLevelNB); ok {
|
||||||
|
st.NBLevel = from255(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwNB); ok {
|
||||||
|
st.NB = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwNR); ok {
|
||||||
|
st.NR = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwANF); ok {
|
||||||
|
st.ANF = v != 0
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwAGC); ok {
|
||||||
|
st.AGC = agcName(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readSwitch(civ.SubSwPreamp); ok {
|
||||||
|
st.Preamp = int(v)
|
||||||
|
}
|
||||||
|
if v, ok := b.readAtt(); ok {
|
||||||
|
st.Att = v
|
||||||
|
}
|
||||||
|
if _, f, ok := b.readModeFilter(); ok {
|
||||||
|
st.Filter = int(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.dspMu.Lock()
|
||||||
|
b.dsp = st
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
const icomDSPTimeout = 150 * time.Millisecond // shorter: unsupported reads mustn't stall the poll
|
||||||
|
|
||||||
|
func (b *IcomSerial) readLevel(sub byte) (int, bool) {
|
||||||
|
if err := b.write(civ.CmdLevel, sub); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdLevel && len(d.Data) >= 3 && d.Data[0] == sub
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return civ.BCDToLevel(f.Data[1:3]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readSwitch(sub byte) (byte, bool) {
|
||||||
|
if err := b.write(civ.CmdSwitch, sub); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdSwitch && len(d.Data) >= 2 && d.Data[0] == sub
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return f.Data[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readAtt() (int, bool) {
|
||||||
|
if err := b.write(civ.CmdAtt); err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdAtt && len(d.Data) >= 1
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return civ.BCDToByte(f.Data[0]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) {
|
||||||
|
if err := b.write(civ.CmdReadMode); err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||||
|
return d.Cmd == civ.CmdReadMode && len(d.Data) >= 2
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
return f.Data[0], f.Data[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAFGain(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAF}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.AFGain = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetRFGain(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRF}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.RFGain = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNB(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwNB, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NB = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNBLevel(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNB}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NBLevel = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNR(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwNR, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NR = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetNRLevel(p int) error {
|
||||||
|
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNR}, civ.LevelToBCD(to255(p))...)...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.NRLevel = clampPct(p) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetANF(on bool) error {
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwANF, boolByte(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.ANF = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAGC(name string) error {
|
||||||
|
v := agcValue(name)
|
||||||
|
if v == 0 {
|
||||||
|
return fmt.Errorf("icom: invalid AGC %q", name)
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwAGC, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.AGC = strings.ToUpper(name) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetPreamp(n int) error {
|
||||||
|
if n < 0 || n > 2 {
|
||||||
|
return fmt.Errorf("icom: invalid preamp %d", n)
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdSwitch, civ.SubSwPreamp, byte(n)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Preamp = n })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetAtt(db int) error {
|
||||||
|
if err := b.exec(civ.CmdAtt, civ.ByteToBCD(db)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Att = db })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) SetIcomFilter(n int) error {
|
||||||
|
if n < 1 || n > 3 {
|
||||||
|
return fmt.Errorf("icom: invalid filter %d", n)
|
||||||
|
}
|
||||||
|
if b.curModeByte == 0 {
|
||||||
|
// Need the current mode to re-send with the chosen filter.
|
||||||
|
if m, _, ok := b.readModeFilter(); ok {
|
||||||
|
b.curModeByte = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := b.exec(civ.CmdSetMode, b.curModeByte, byte(n)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.setCache(func(s *IcomTXState) { s.Filter = n })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
|
||||||
|
b.dspMu.Lock()
|
||||||
|
fn(&b.dsp)
|
||||||
|
b.dspMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── small helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func to255(p int) int { return clampPct(p) * 255 / 100 }
|
||||||
|
func from255(v int) int { return (v*100 + 127) / 255 }
|
||||||
|
func clampPct(p int) int { return min(100, max(0, p)) }
|
||||||
|
|
||||||
|
func boolByte(on bool) byte {
|
||||||
|
if on {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func agcName(v byte) string {
|
||||||
|
switch v {
|
||||||
|
case 1:
|
||||||
|
return "FAST"
|
||||||
|
case 2:
|
||||||
|
return "MID"
|
||||||
|
case 3:
|
||||||
|
return "SLOW"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func agcValue(name string) byte {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(name)) {
|
||||||
|
case "FAST":
|
||||||
|
return 1
|
||||||
|
case "MID":
|
||||||
|
return 2
|
||||||
|
case "SLOW":
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
||||||
|
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
||||||
|
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
|
||||||
|
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
|
||||||
|
// tone (level-independent, so weak or strong signals both key cleanly), an
|
||||||
|
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
|
||||||
|
// marks/spaces to Morse and then to characters.
|
||||||
|
//
|
||||||
|
// It is deliberately self-contained and dependency-free so it can be unit
|
||||||
|
// tested with synthetic signals. As with every audio CW decoder, weak signals
|
||||||
|
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
|
||||||
|
// out of the decode.
|
||||||
|
package cwdecode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
||||||
|
type Status struct {
|
||||||
|
WPM int `json:"wpm"`
|
||||||
|
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
||||||
|
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
||||||
|
Active bool `json:"active"` // a tone is currently keyed down
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
||||||
|
// characters at a time, including " " for word gaps) and periodic onStatus.
|
||||||
|
type Decoder struct {
|
||||||
|
fs int
|
||||||
|
hop int // samples between updates
|
||||||
|
win int // Goertzel window length
|
||||||
|
freqs []float64
|
||||||
|
coeffs []float64 // precomputed 2*cos(w) per freq
|
||||||
|
|
||||||
|
ring []float64 // last win samples
|
||||||
|
acc int // samples since last hop
|
||||||
|
mags []float64 // per-bin magnitude this hop
|
||||||
|
nbuf []float64 // scratch for the noise percentile
|
||||||
|
|
||||||
|
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
|
||||||
|
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
|
||||||
|
// another goroutine, so it's atomic.
|
||||||
|
targetHz atomic.Int32
|
||||||
|
|
||||||
|
// Pitch lock.
|
||||||
|
lockIdx int // index of the locked tone bin, -1 = unlocked
|
||||||
|
candIdx int // current argmax candidate while unlocked
|
||||||
|
candHops int // consecutive hops the candidate has been dominant
|
||||||
|
quietHops int // consecutive key-up hops while locked
|
||||||
|
noise float64 // broadband noise estimate (percentile of bins)
|
||||||
|
relockHops int // quiet hops before the lock is released
|
||||||
|
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
|
||||||
|
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
|
||||||
|
|
||||||
|
// Adaptive keying envelope, on the LOCKED bin's magnitude.
|
||||||
|
peak, floor float64
|
||||||
|
state bool // true = mark (key down)
|
||||||
|
stateHops int
|
||||||
|
dotHops float64 // adaptive dot length, in hops
|
||||||
|
elem []byte // current "." / "-" run for the in-progress character
|
||||||
|
charEmitted bool
|
||||||
|
wordEmitted bool
|
||||||
|
|
||||||
|
lastPitch float64
|
||||||
|
lastRMS float64
|
||||||
|
|
||||||
|
statusEvery int
|
||||||
|
sinceStatus int
|
||||||
|
|
||||||
|
onChar func(string)
|
||||||
|
onStatus func(Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var morse = map[string]byte{
|
||||||
|
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
|
||||||
|
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
|
||||||
|
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
|
||||||
|
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
|
||||||
|
"-.--": 'Y', "--..": 'Z',
|
||||||
|
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
|
||||||
|
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
|
||||||
|
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
||||||
|
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a decoder for the given sample rate. onChar receives decoded text
|
||||||
|
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
||||||
|
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
||||||
|
if sampleRate <= 0 {
|
||||||
|
sampleRate = 16000
|
||||||
|
}
|
||||||
|
d := &Decoder{
|
||||||
|
fs: sampleRate,
|
||||||
|
hop: sampleRate / 250, // ~4 ms resolution
|
||||||
|
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
|
||||||
|
dotHops: 15, // ~20 WPM seed
|
||||||
|
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
|
||||||
|
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
|
||||||
|
lockIdx: -1,
|
||||||
|
candIdx: -1,
|
||||||
|
statusEvery: 25, // ~10 Hz
|
||||||
|
onChar: onChar,
|
||||||
|
onStatus: onStatus,
|
||||||
|
}
|
||||||
|
if d.hop < 1 {
|
||||||
|
d.hop = 1
|
||||||
|
}
|
||||||
|
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
||||||
|
// Candidate CW tones: 400–1000 Hz every 25 Hz. Deliberately NOT lower: strong
|
||||||
|
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
|
||||||
|
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
|
||||||
|
for f := 400.0; f <= 1000.0; f += 25 {
|
||||||
|
d.freqs = append(d.freqs, f)
|
||||||
|
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
||||||
|
}
|
||||||
|
d.mags = make([]float64, len(d.freqs))
|
||||||
|
d.nbuf = make([]float64, len(d.freqs))
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
|
||||||
|
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
|
||||||
|
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
||||||
|
|
||||||
|
// nearestBin returns the bin index closest to hz.
|
||||||
|
func (d *Decoder) nearestBin(hz float64) int {
|
||||||
|
best, bestD := 0, math.Inf(1)
|
||||||
|
for i, f := range d.freqs {
|
||||||
|
if dd := math.Abs(f - hz); dd < bestD {
|
||||||
|
bestD, best = dd, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
||||||
|
func (d *Decoder) Reset() {
|
||||||
|
d.ring = d.ring[:0]
|
||||||
|
d.acc = 0
|
||||||
|
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
||||||
|
d.peak, d.floor = 0, 0
|
||||||
|
d.state = false
|
||||||
|
d.stateHops = 0
|
||||||
|
d.dotHops = 15
|
||||||
|
d.elem = d.elem[:0]
|
||||||
|
d.charEmitted, d.wordEmitted = false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process feeds a block of mono samples through the decoder.
|
||||||
|
func (d *Decoder) Process(samples []int16) {
|
||||||
|
for _, s := range samples {
|
||||||
|
d.ring = append(d.ring, float64(s))
|
||||||
|
if len(d.ring) > d.win {
|
||||||
|
d.ring = d.ring[len(d.ring)-d.win:]
|
||||||
|
}
|
||||||
|
d.acc++
|
||||||
|
if d.acc >= d.hop && len(d.ring) >= d.win {
|
||||||
|
d.acc = 0
|
||||||
|
d.analyze()
|
||||||
|
d.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
|
||||||
|
// pitch lock (which tone the envelope detector then follows).
|
||||||
|
func (d *Decoder) analyze() {
|
||||||
|
n := float64(len(d.ring))
|
||||||
|
var sumSq float64
|
||||||
|
maxIdx, maxMag := 0, -1.0
|
||||||
|
for i, coeff := range d.coeffs {
|
||||||
|
var s1, s2 float64
|
||||||
|
for _, x := range d.ring {
|
||||||
|
s0 := x + coeff*s1 - s2
|
||||||
|
s2 = s1
|
||||||
|
s1 = s0
|
||||||
|
}
|
||||||
|
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
|
||||||
|
d.mags[i] = m
|
||||||
|
if m > maxMag {
|
||||||
|
maxMag = m
|
||||||
|
maxIdx = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, x := range d.ring {
|
||||||
|
sumSq += x * x
|
||||||
|
}
|
||||||
|
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
||||||
|
|
||||||
|
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
|
||||||
|
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
|
||||||
|
if th := int(d.targetHz.Load()); th > 0 {
|
||||||
|
d.lockIdx = d.nearestBin(float64(th))
|
||||||
|
d.lastPitch = d.freqs[d.lockIdx]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
|
||||||
|
copy(d.nbuf, d.mags)
|
||||||
|
sort.Float64s(d.nbuf)
|
||||||
|
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
|
||||||
|
|
||||||
|
if d.lockIdx < 0 {
|
||||||
|
if maxIdx == d.candIdx {
|
||||||
|
d.candHops++
|
||||||
|
} else {
|
||||||
|
d.candIdx, d.candHops = maxIdx, 1
|
||||||
|
}
|
||||||
|
snr := maxMag / (d.noise + 1e-9)
|
||||||
|
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
||||||
|
// don't eat the first element of a strong signal), a marginal/weak tone
|
||||||
|
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
||||||
|
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
|
||||||
|
d.lockIdx = maxIdx
|
||||||
|
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||||
|
d.quietHops = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.lockIdx >= 0 {
|
||||||
|
d.lastPitch = d.freqs[d.lockIdx]
|
||||||
|
} else {
|
||||||
|
d.lastPitch = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// step runs the adaptive envelope on the locked bin and the timing state
|
||||||
|
// machine, one hop. The envelope adapts to the signal level (not an absolute
|
||||||
|
// threshold), so weak and strong signals both key correctly.
|
||||||
|
func (d *Decoder) step() {
|
||||||
|
on := false
|
||||||
|
if d.lockIdx >= 0 {
|
||||||
|
m := d.mags[d.lockIdx]
|
||||||
|
// Peak: fast attack, slow release.
|
||||||
|
if m > d.peak {
|
||||||
|
d.peak += (m - d.peak) * 0.4
|
||||||
|
} else {
|
||||||
|
d.peak += (m - d.peak) * 0.02
|
||||||
|
}
|
||||||
|
// Floor: drops fast toward the signal, but only RISES between marks (when
|
||||||
|
// keyed up). Letting the floor rise during a long dash would shrink the
|
||||||
|
// span until the dash drops below the threshold and fragments into dots —
|
||||||
|
// the cause of the "all dots" garbage on a strong clean signal.
|
||||||
|
if m < d.floor {
|
||||||
|
d.floor += (m - d.floor) * 0.4
|
||||||
|
} else if !d.state {
|
||||||
|
d.floor += (m - d.floor) * 0.02
|
||||||
|
}
|
||||||
|
span := d.peak - d.floor
|
||||||
|
// The frozen floor already stops dashes fragmenting, so keep balanced
|
||||||
|
// thresholds: low enough that short inter-element GAPS are still seen
|
||||||
|
// (otherwise elements merge into >7-symbol runs that decode to nothing).
|
||||||
|
if span > d.floor*0.3+1e-9 {
|
||||||
|
onTh := d.floor + 0.55*span
|
||||||
|
offTh := d.floor + 0.35*span
|
||||||
|
if d.state {
|
||||||
|
on = m > offTh
|
||||||
|
} else {
|
||||||
|
on = m > onTh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Release the lock after a long quiet so we can retune to a new signal.
|
||||||
|
if on {
|
||||||
|
d.quietHops = 0
|
||||||
|
} else {
|
||||||
|
d.quietHops++
|
||||||
|
if d.quietHops > d.relockHops {
|
||||||
|
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if on == d.state {
|
||||||
|
d.stateHops++
|
||||||
|
if !d.state {
|
||||||
|
d.spaceProgress()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if d.state {
|
||||||
|
d.endMark(d.stateHops)
|
||||||
|
}
|
||||||
|
d.state = on
|
||||||
|
d.stateHops = 1
|
||||||
|
if on {
|
||||||
|
d.charEmitted, d.wordEmitted = false, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.emitStatus(on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
||||||
|
// dot-length estimate. Runs shorter than a third of a dot are rejected as
|
||||||
|
// clicks/noise.
|
||||||
|
func (d *Decoder) endMark(hops int) {
|
||||||
|
h := float64(hops)
|
||||||
|
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
|
||||||
|
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
|
||||||
|
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
|
||||||
|
if h < d.dotHops*0.35 || h < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h > d.dotHops*2 {
|
||||||
|
d.elem = append(d.elem, '-')
|
||||||
|
d.adaptDot(h / 3)
|
||||||
|
} else {
|
||||||
|
d.elem = append(d.elem, '.')
|
||||||
|
d.adaptDot(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||||
|
// to ~5–100 WPM).
|
||||||
|
func (d *Decoder) adaptDot(obs float64) {
|
||||||
|
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
|
||||||
|
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||||
|
d.dotHops = 5
|
||||||
|
}
|
||||||
|
if d.dotHops > 55 {
|
||||||
|
d.dotHops = 55
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spaceProgress flushes the current character once the gap exceeds a character
|
||||||
|
// gap, and a word space once it exceeds a word gap.
|
||||||
|
func (d *Decoder) spaceProgress() {
|
||||||
|
g := float64(d.stateHops)
|
||||||
|
if !d.charEmitted && g > d.dotHops*2 {
|
||||||
|
d.flushChar()
|
||||||
|
d.charEmitted = true
|
||||||
|
}
|
||||||
|
if !d.wordEmitted && g > d.dotHops*5 {
|
||||||
|
if d.onChar != nil {
|
||||||
|
d.onChar(" ")
|
||||||
|
}
|
||||||
|
d.wordEmitted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushChar looks up the accumulated element string and emits the character.
|
||||||
|
func (d *Decoder) flushChar() {
|
||||||
|
if len(d.elem) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if c, ok := morse[string(d.elem)]; ok {
|
||||||
|
if d.onChar != nil {
|
||||||
|
d.onChar(string(c))
|
||||||
|
}
|
||||||
|
} else if d.onChar != nil && len(d.elem) <= 7 {
|
||||||
|
// Only flag a genuinely Morse-shaped but unknown char with "?". An
|
||||||
|
// over-long element run is noise — drop it silently rather than spam "?".
|
||||||
|
d.onChar("?")
|
||||||
|
}
|
||||||
|
d.elem = d.elem[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Decoder) emitStatus(on bool) {
|
||||||
|
d.sinceStatus++
|
||||||
|
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.sinceStatus = 0
|
||||||
|
hopMs := float64(d.hop) / float64(d.fs) * 1000
|
||||||
|
wpm := 0
|
||||||
|
if d.dotHops > 0 {
|
||||||
|
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
|
||||||
|
}
|
||||||
|
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package cwdecode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reverse Morse map for the synthesizer.
|
||||||
|
func charToMorse() map[byte]string {
|
||||||
|
m := map[byte]string{}
|
||||||
|
for code, ch := range morse {
|
||||||
|
m[ch] = code
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
||||||
|
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
||||||
|
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
||||||
|
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
||||||
|
c2m := charToMorse()
|
||||||
|
var out []int16
|
||||||
|
phase := 0.0
|
||||||
|
dphi := 2 * math.Pi * pitch / float64(fs)
|
||||||
|
|
||||||
|
tone := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = append(out, int16(amp*math.Sin(phase)))
|
||||||
|
phase += dphi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
silence := func(n int) {
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = append(out, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
silence(fs / 4) // 250 ms lead-in for AGC warmup
|
||||||
|
for i := 0; i < len(msg); i++ {
|
||||||
|
ch := msg[i]
|
||||||
|
if ch == ' ' {
|
||||||
|
silence(7 * dot)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
code := c2m[ch]
|
||||||
|
for j := 0; j < len(code); j++ {
|
||||||
|
if code[j] == '.' {
|
||||||
|
tone(dot)
|
||||||
|
} else {
|
||||||
|
tone(3 * dot)
|
||||||
|
}
|
||||||
|
silence(dot) // inter-element gap
|
||||||
|
}
|
||||||
|
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
|
||||||
|
}
|
||||||
|
silence(fs / 4)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCleanSignal(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
|
||||||
|
// Repeat so AGC warm-up only costs the first word.
|
||||||
|
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
|
||||||
|
// Feed in small chunks like the live capture would.
|
||||||
|
for i := 0; i < len(samples); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "PARIS") {
|
||||||
|
t.Fatalf("decoded %q, want it to contain PARIS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeWithQRM(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
|
||||||
|
// quieter, sending different text. The pitch lock should hold on the target.
|
||||||
|
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
||||||
|
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
|
||||||
|
mix := make([]int16, len(target))
|
||||||
|
for i := range target {
|
||||||
|
v := int(target[i])
|
||||||
|
if i < len(qrm) {
|
||||||
|
v += int(qrm[i])
|
||||||
|
}
|
||||||
|
if v > 32767 {
|
||||||
|
v = 32767
|
||||||
|
} else if v < -32768 {
|
||||||
|
v = -32768
|
||||||
|
}
|
||||||
|
mix[i] = int16(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
for i := 0; i < len(mix); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(mix) {
|
||||||
|
end = len(mix)
|
||||||
|
}
|
||||||
|
d.Process(mix[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "PARIS") {
|
||||||
|
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeFirstCharStrong(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
// Strong signal: the very first element (T = a dash) must not be eaten by
|
||||||
|
// lock acquisition. Output should begin with the first character.
|
||||||
|
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
|
||||||
|
for i := 0; i < len(samples); i += 200 {
|
||||||
|
end := i + 200
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(strings.TrimSpace(sb.String()))
|
||||||
|
if !strings.HasPrefix(got, "TEST") {
|
||||||
|
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeWithAmplitudeRipple(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
// A real signal's tone amplitude wobbles within a mark; if the floor chases
|
||||||
|
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
|
||||||
|
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
|
||||||
|
rp := 0.0
|
||||||
|
for i := range samples {
|
||||||
|
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
|
||||||
|
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
for i := 0; i < len(samples); i += 256 {
|
||||||
|
end := i + 256
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "TEST DE OM") {
|
||||||
|
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeCQFixedPitch(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
d.SetTarget(700) // fixed pitch like the user's manual override
|
||||||
|
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
|
||||||
|
for i := 0; i < len(samples); i += 200 {
|
||||||
|
end := i + 200
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if n := strings.Count(got, "CQ"); n < 2 {
|
||||||
|
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeNumbersAndProsign(t *testing.T) {
|
||||||
|
const fs = 16000
|
||||||
|
var sb strings.Builder
|
||||||
|
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
||||||
|
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
|
||||||
|
for i := 0; i < len(samples); i += 200 {
|
||||||
|
end := i + 200
|
||||||
|
if end > len(samples) {
|
||||||
|
end = len(samples)
|
||||||
|
}
|
||||||
|
d.Process(samples[i:end])
|
||||||
|
}
|
||||||
|
got := strings.ToUpper(sb.String())
|
||||||
|
if !strings.Contains(got, "599") {
|
||||||
|
t.Fatalf("decoded %q, want it to contain 599", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,8 +214,8 @@ func TestNormalize(t *testing.T) {
|
|||||||
"f4bpo": "F4BPO",
|
"f4bpo": "F4BPO",
|
||||||
" F4BPO ": "F4BPO",
|
" F4BPO ": "F4BPO",
|
||||||
"F4BPO/P": "F4BPO",
|
"F4BPO/P": "F4BPO",
|
||||||
"F4BPO/MM": "", // maritime mobile → no DXCC entity
|
"F4BPO/MM": "F4BPO", // maritime mobile → strip, keep home entity for the log
|
||||||
"F4BPO/AM": "", // aeronautical mobile → no DXCC entity
|
"F4BPO/AM": "F4BPO", // aeronautical mobile → strip, keep home entity for the log
|
||||||
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
|
||||||
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
|
||||||
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// eqslImportURL is eQSL.cc's ADIF import endpoint. It accepts a form-encoded
|
||||||
|
// POST (or URL params) with the account credentials and the ADIF content.
|
||||||
|
const eqslImportURL = "https://www.eQSL.cc/qslcard/ImportADIF.cfm"
|
||||||
|
|
||||||
|
// eqslResultRe extracts "Result: X out of Y records added" from the reply.
|
||||||
|
var eqslResultRe = regexp.MustCompile(`(?i)result:\s*(\d+)\s+out of\s+(\d+)\s+records added`)
|
||||||
|
|
||||||
|
// eqslPost performs the import POST and returns the raw response body. eQSL
|
||||||
|
// replies HTTP 200 with a plain-text/HTML body for both success and errors;
|
||||||
|
// callers classify it via the markers below.
|
||||||
|
func eqslPost(ctx context.Context, client *http.Client, user, pswd, adif string) (string, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("EQSL_USER", user)
|
||||||
|
form.Set("EQSL_PSWD", pswd)
|
||||||
|
form.Set("ADIFData", adif)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, eqslImportURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("eqsl: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return msg, fmt.Errorf("eqsl: http %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authErrEQSL returns a reason when the response signals bad credentials, else
|
||||||
|
// "". eQSL replies "Error: No match on eQSL_User/eQSL_Pswd".
|
||||||
|
func authErrEQSL(body string) string {
|
||||||
|
if strings.Contains(strings.ToLower(body), "no match on eqsl") {
|
||||||
|
return "invalid username or password"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslRecordWithNickname prepends the APP_EQSL_QTH_NICKNAME tag to an ADIF
|
||||||
|
// record when nick is set, so eQSL files the QSO under the right QTH profile
|
||||||
|
// (required when the account has more than one). ADIF field order is free, so
|
||||||
|
// prepending before the rest of the record is valid.
|
||||||
|
func eqslRecordWithNickname(record, nick string) string {
|
||||||
|
nick = strings.TrimSpace(nick)
|
||||||
|
if nick == "" {
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("<APP_EQSL_QTH_NICKNAME:%d>%s%s", len(nick), nick, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadEQSL pushes one ADIF record to eQSL.cc for the given account. qthNick
|
||||||
|
// is the optional eQSL QTH nickname.
|
||||||
|
//
|
||||||
|
// eQSL replies with text: "Result: 1 out of 1 records added" on success,
|
||||||
|
// "Bad record: Duplicate" for an already-present QSO (treated as success so
|
||||||
|
// retries are idempotent), or "Error: No match on eQSL_User/eQSL_Pswd" for bad
|
||||||
|
// credentials.
|
||||||
|
func UploadEQSL(ctx context.Context, client *http.Client, user, pswd, qthNick, adifRecord string) (UploadResult, error) {
|
||||||
|
user = strings.ToUpper(strings.TrimSpace(user))
|
||||||
|
if user == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: username (callsign) not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pswd) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: password not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(adifRecord) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("eqsl: empty adif record")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := eqslPost(ctx, client, user, pswd, eqslRecordWithNickname(adifRecord, qthNick))
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{OK: false, Message: body}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b := strings.ToLower(body)
|
||||||
|
if reason := authErrEQSL(body); reason != "" {
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: %s", reason)
|
||||||
|
}
|
||||||
|
if strings.Contains(b, "duplicate") {
|
||||||
|
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||||
|
}
|
||||||
|
if m := eqslResultRe.FindStringSubmatch(body); m != nil {
|
||||||
|
added, _ := strconv.Atoi(m[1])
|
||||||
|
if added >= 1 {
|
||||||
|
return UploadResult{OK: true, Message: strings.TrimSpace(m[0])}, nil
|
||||||
|
}
|
||||||
|
// "0 out of N" — eQSL accepted nothing; surface why if it said so.
|
||||||
|
reason := eqslReason(body)
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||||
|
}
|
||||||
|
reason := eqslReason(body)
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("eqsl: upload failed: %s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// eqslReason trims an eQSL reply to a short human-readable reason: the first
|
||||||
|
// "Error:" / "Warning:" / "Bad record:" line if present, else the whole body
|
||||||
|
// (capped), else a generic phrase.
|
||||||
|
func eqslReason(body string) string {
|
||||||
|
for _, line := range strings.Split(body, "\n") {
|
||||||
|
l := strings.TrimSpace(line)
|
||||||
|
ll := strings.ToLower(l)
|
||||||
|
if strings.HasPrefix(ll, "error:") || strings.HasPrefix(ll, "warning:") || strings.Contains(ll, "bad record") {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b := strings.TrimSpace(body)
|
||||||
|
if b == "" {
|
||||||
|
return "upload rejected"
|
||||||
|
}
|
||||||
|
if len(b) > 200 {
|
||||||
|
b = b[:200]
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEQSL validates the configured eQSL credentials with a REAL request: it
|
||||||
|
// posts an empty ADIF so nothing is inserted, then checks for the bad-login
|
||||||
|
// marker. Anything else means the credentials were accepted.
|
||||||
|
func TestEQSL(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
user := strings.ToUpper(strings.TrimSpace(cfg.Username))
|
||||||
|
if user == "" {
|
||||||
|
return "", fmt.Errorf("eqsl: username (callsign) not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.Password) == "" {
|
||||||
|
return "", fmt.Errorf("eqsl: password not set")
|
||||||
|
}
|
||||||
|
body, err := eqslPost(ctx, client, user, cfg.Password, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reason := authErrEQSL(body); reason != "" {
|
||||||
|
return "", fmt.Errorf("eqsl: %s", reason)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Credentials accepted — %s", user), nil
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ const (
|
|||||||
ServiceQRZ Service = "qrz" // QRZ.com Logbook
|
ServiceQRZ Service = "qrz" // QRZ.com Logbook
|
||||||
ServiceClublog Service = "clublog" // Club Log real-time upload
|
ServiceClublog Service = "clublog" // Club Log real-time upload
|
||||||
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
ServiceLoTW Service = "lotw" // ARRL Logbook of The World (via TQSL)
|
||||||
|
ServiceHRDLog Service = "hrdlog" // HRDLog.net real-time upload
|
||||||
|
ServiceEQSL Service = "eqsl" // eQSL.cc ADIF upload
|
||||||
)
|
)
|
||||||
|
|
||||||
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
// UploadMode selects when an auto-upload fires after a QSO is saved.
|
||||||
@@ -64,12 +66,14 @@ type ServiceConfig struct {
|
|||||||
Email string `json:"email"` // Club Log account email
|
Email string `json:"email"` // Club Log account email
|
||||||
Username string `json:"username"` // LoTW website login (for confirmation download)
|
Username string `json:"username"` // LoTW website login (for confirmation download)
|
||||||
Password string `json:"password"` // Club Log account / LoTW website password
|
Password string `json:"password"` // Club Log account / LoTW website password
|
||||||
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
|
Callsign string `json:"callsign"` // Club Log / HRDLog logbook (owner) callsign
|
||||||
|
Code string `json:"code"` // HRDLog: account upload code
|
||||||
|
QTHNickname string `json:"qth_nickname"` // eQSL: QTH nickname (when the account has several)
|
||||||
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
|
||||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||||
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
|
||||||
UploadFlag string `json:"upload_flag"` // LoTW: sent status that means "ready to upload" — "N" or "R"
|
UploadFlags []string `json:"upload_flags"` // LoTW: set of lotw_sent values that mean "ready to upload" — any of "N"/"R"
|
||||||
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
WriteLog bool `json:"write_log"` // LoTW: pass -t to write a TQSL diagnostic log
|
||||||
AutoUpload bool `json:"auto_upload"`
|
AutoUpload bool `json:"auto_upload"`
|
||||||
UploadMode UploadMode `json:"upload_mode"`
|
UploadMode UploadMode `json:"upload_mode"`
|
||||||
@@ -81,16 +85,25 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
|||||||
c.APIKey = strings.TrimSpace(c.APIKey)
|
c.APIKey = strings.TrimSpace(c.APIKey)
|
||||||
c.Email = strings.TrimSpace(c.Email)
|
c.Email = strings.TrimSpace(c.Email)
|
||||||
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
c.Callsign = strings.ToUpper(strings.TrimSpace(c.Callsign))
|
||||||
|
c.Code = strings.TrimSpace(c.Code)
|
||||||
|
c.Username = strings.TrimSpace(c.Username)
|
||||||
|
c.QTHNickname = strings.TrimSpace(c.QTHNickname)
|
||||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||||
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||||||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||||||
// Upload flag is the LoTW sent-status that marks a QSO ready to upload.
|
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
|
||||||
// Only "N" (no) and "R" (requested) are valid; default to "R".
|
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
|
||||||
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" {
|
// here (the caller injects N+R when nothing is configured).
|
||||||
c.UploadFlag = uf
|
var flags []string
|
||||||
} else {
|
seen := map[string]bool{}
|
||||||
c.UploadFlag = "R"
|
for _, f := range c.UploadFlags {
|
||||||
|
f = strings.ToUpper(strings.TrimSpace(f))
|
||||||
|
if (f == "N" || f == "R") && !seen[f] {
|
||||||
|
seen[f] = true
|
||||||
|
flags = append(flags, f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
c.UploadFlags = flags
|
||||||
switch c.UploadMode {
|
switch c.UploadMode {
|
||||||
case ModeDelayed, ModeOnClose:
|
case ModeDelayed, ModeOnClose:
|
||||||
// keep
|
// keep
|
||||||
@@ -105,6 +118,8 @@ type ExternalServices struct {
|
|||||||
QRZ ServiceConfig `json:"qrz"`
|
QRZ ServiceConfig `json:"qrz"`
|
||||||
Clublog ServiceConfig `json:"clublog"`
|
Clublog ServiceConfig `json:"clublog"`
|
||||||
LoTW ServiceConfig `json:"lotw"`
|
LoTW ServiceConfig `json:"lotw"`
|
||||||
|
HRDLog ServiceConfig `json:"hrdlog"`
|
||||||
|
EQSL ServiceConfig `json:"eqsl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UploadResult is the outcome of a single upload attempt.
|
// UploadResult is the outcome of a single upload attempt.
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package extsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hrdlogUploadURL is HRDLog.net's real-time upload endpoint. It accepts a
|
||||||
|
// form-encoded POST with the uploader's callsign, the account's secret upload
|
||||||
|
// Code (HRDLog account → "My Account" → upload code), an App identifier, and
|
||||||
|
// one ADIF record.
|
||||||
|
const hrdlogUploadURL = "https://robot.hrdlog.net/NewEntry.aspx"
|
||||||
|
|
||||||
|
// hrdlogApp is the App identifier sent to HRDLog so uploads are attributed to
|
||||||
|
// OpsLog in the user's HRDLog activity.
|
||||||
|
const hrdlogApp = "OpsLog"
|
||||||
|
|
||||||
|
// hrdlogPost performs the form POST to NewEntry.aspx and returns the raw
|
||||||
|
// response body. The endpoint replies HTTP 200 with a small XML document even
|
||||||
|
// for errors; callers classify it via the markers in classifyHRDLog.
|
||||||
|
func hrdlogPost(ctx context.Context, client *http.Client, callsign, code, adif string) (string, error) {
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("Callsign", callsign)
|
||||||
|
form.Set("Code", code)
|
||||||
|
form.Set("App", hrdlogApp)
|
||||||
|
form.Set("ADIFData", adif)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, hrdlogUploadURL, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hrdlog: build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
if client == nil {
|
||||||
|
client = &http.Client{Timeout: 20 * time.Second}
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("hrdlog: request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
msg := strings.TrimSpace(string(body))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
if msg == "" {
|
||||||
|
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return msg, fmt.Errorf("hrdlog: http %d: %s", resp.StatusCode, msg)
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authErrHRDLog returns a non-empty, human-readable reason when the response
|
||||||
|
// signals a credential problem (wrong upload code or unregistered callsign),
|
||||||
|
// or "" otherwise. Markers mirror HRDLog's documented XML replies
|
||||||
|
// ("Invalid token</error>" / "Unknown user</error>").
|
||||||
|
func authErrHRDLog(body string) string {
|
||||||
|
b := strings.ToLower(body)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(b, "invalid token"):
|
||||||
|
return "invalid upload code"
|
||||||
|
case strings.Contains(b, "unknown user"):
|
||||||
|
return "callsign not registered at HRDLog"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadHRDLog pushes one ADIF record to HRDLog.net. callsign is the station
|
||||||
|
// callsign the log belongs to, code is the account's upload code.
|
||||||
|
//
|
||||||
|
// Form fields (application/x-www-form-urlencoded POST):
|
||||||
|
//
|
||||||
|
// Callsign=<station call>&Code=<upload code>&App=OpsLog&ADIFData=<one record>
|
||||||
|
//
|
||||||
|
// HRDLog replies with XML: "<insert>1" on success, "<insert>0" for a duplicate
|
||||||
|
// (already logged — treated as success so retries are idempotent), or an
|
||||||
|
// "<error>…</error>" payload otherwise.
|
||||||
|
func UploadHRDLog(ctx context.Context, client *http.Client, callsign, code, adifRecord string) (UploadResult, error) {
|
||||||
|
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if callsign == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("hrdlog: station callsign not set")
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("hrdlog: upload code not set")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(adifRecord) == "" {
|
||||||
|
return UploadResult{}, fmt.Errorf("hrdlog: empty adif record")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := hrdlogPost(ctx, client, callsign, code, adifRecord)
|
||||||
|
if err != nil {
|
||||||
|
return UploadResult{OK: false, Message: body}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
b := strings.ToLower(body)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(b, "<insert>1"):
|
||||||
|
return UploadResult{OK: true, Message: "uploaded"}, nil
|
||||||
|
case strings.Contains(b, "<insert>0"):
|
||||||
|
return UploadResult{OK: true, Message: "already in logbook"}, nil
|
||||||
|
}
|
||||||
|
reason := authErrHRDLog(body)
|
||||||
|
if reason == "" {
|
||||||
|
reason = body
|
||||||
|
if reason == "" {
|
||||||
|
reason = "upload rejected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UploadResult{OK: false, Message: reason}, fmt.Errorf("hrdlog: upload failed: %s", reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: HRDLog's NewEntry.aspx inserts ONLY the first record of a multi-record
|
||||||
|
// ADIFData, so there is no batch upload — callers must POST one record per
|
||||||
|
// request (see UploadHRDLog). The bulk uploader in app.go does exactly that.
|
||||||
|
|
||||||
|
// TestHRDLog validates the configured HRDLog credentials with a REAL request:
|
||||||
|
// it posts an empty ADIF so nothing is inserted, then checks for HRDLog's auth
|
||||||
|
// errors. A wrong upload code comes back as "Invalid token", a wrong callsign
|
||||||
|
// as "Unknown user"; anything else means the credentials were accepted (HRDLog
|
||||||
|
// simply had no QSO to add).
|
||||||
|
func TestHRDLog(ctx context.Context, client *http.Client, cfg ServiceConfig) (string, error) {
|
||||||
|
callsign := strings.ToUpper(strings.TrimSpace(cfg.Callsign))
|
||||||
|
code := strings.TrimSpace(cfg.Code)
|
||||||
|
if callsign == "" {
|
||||||
|
return "", fmt.Errorf("hrdlog: station callsign not set")
|
||||||
|
}
|
||||||
|
if code == "" {
|
||||||
|
return "", fmt.Errorf("hrdlog: upload code not set")
|
||||||
|
}
|
||||||
|
body, err := hrdlogPost(ctx, client, callsign, code, "")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if reason := authErrHRDLog(body); reason != "" {
|
||||||
|
return "", fmt.Errorf("hrdlog: %s", reason)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Credentials accepted — %s", callsign), nil
|
||||||
|
}
|
||||||
@@ -21,8 +21,11 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
|||||||
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
||||||
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
// Uses the LoTW *website* login (Username/Password), not the TQSL cert. When
|
||||||
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
// since is non-empty (YYYY-MM-DD) only confirmations received since then are
|
||||||
// returned — used for incremental "Last download" updates.
|
// returned — used for incremental "Last download" updates. When ownCall is
|
||||||
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
|
// non-empty, only confirmations for that station callsign are returned (an
|
||||||
|
// LoTW account holds every call you operate — F4BPO, F4BPO/P, TM2Q — so this
|
||||||
|
// scopes the pull to the active profile's call).
|
||||||
|
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since, ownCall string) (string, error) {
|
||||||
user := strings.TrimSpace(cfg.Username)
|
user := strings.TrimSpace(cfg.Username)
|
||||||
if user == "" || cfg.Password == "" {
|
if user == "" || cfg.Password == "" {
|
||||||
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
return "", fmt.Errorf("lotw: website login (username/password) not set")
|
||||||
@@ -33,6 +36,9 @@ func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg Ser
|
|||||||
q.Set("qso_query", "1")
|
q.Set("qso_query", "1")
|
||||||
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
q.Set("qso_qsl", "yes") // only QSLed (confirmed) records
|
||||||
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
|
q.Set("qso_qsldetail", "yes") // include QSL_RCVD / QSLRDATE detail
|
||||||
|
if c := strings.TrimSpace(ownCall); c != "" {
|
||||||
|
q.Set("qso_owncall", c) // restrict to this station callsign
|
||||||
|
}
|
||||||
if s := strings.TrimSpace(since); s != "" {
|
if s := strings.TrimSpace(since); s != "" {
|
||||||
q.Set("qso_qslsince", s)
|
q.Set("qso_qslsince", s)
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-39
@@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool {
|
|||||||
return baseCall(a) == baseCall(b)
|
return baseCall(a) == baseCall(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SameBaseCall is the exported form of sameBaseCall, so the host app can apply
|
||||||
|
// the same "same operator?" rule when filtering an on-close upload batch by the
|
||||||
|
// active logbook's callsign.
|
||||||
|
func SameBaseCall(a, b string) bool { return sameBaseCall(a, b) }
|
||||||
|
|
||||||
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
// Deps are the host-app callbacks the Manager needs. Keeping them as
|
||||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||||
// keeps the upload-scheduling logic testable.
|
// keeps the upload-scheduling logic testable.
|
||||||
@@ -62,6 +67,13 @@ type Deps struct {
|
|||||||
// option would otherwise silently relabel it). "" → no station call known.
|
// option would otherwise silently relabel it). "" → no station call known.
|
||||||
StationCallOf func(id int64) string
|
StationCallOf func(id int64) string
|
||||||
|
|
||||||
|
// CloseUploadIDs returns the QSO ids to upload for a service when the app
|
||||||
|
// closes — scanning the WHOLE logbook, not just this session: LoTW returns
|
||||||
|
// rows whose lotw_sent matches the configured status set; QRZ/Club Log
|
||||||
|
// return anything not yet "Y". This is what makes an imported ADIF (old
|
||||||
|
// QSOs still marked unsent) upload on close. nil → nothing to do.
|
||||||
|
CloseUploadIDs func(svc Service) []int64
|
||||||
|
|
||||||
// Logf is an optional diagnostic logger.
|
// Logf is an optional diagnostic logger.
|
||||||
Logf func(format string, args ...any)
|
Logf func(format string, args ...any)
|
||||||
}
|
}
|
||||||
@@ -72,10 +84,9 @@ type Deps struct {
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
deps Deps
|
deps Deps
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cfg ExternalServices
|
cfg ExternalServices
|
||||||
rnd *rand.Rand
|
rnd *rand.Rand
|
||||||
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager(deps Deps) *Manager {
|
func NewManager(deps Deps) *Manager {
|
||||||
@@ -86,8 +97,7 @@ func NewManager(deps Deps) *Manager {
|
|||||||
deps: deps,
|
deps: deps,
|
||||||
// Seeded from the clock; the delay only needs to be unpredictable
|
// Seeded from the clock; the delay only needs to be unpredictable
|
||||||
// enough to spread bursts, not cryptographically random.
|
// enough to spread bursts, not cryptographically random.
|
||||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||||
pending: map[Service][]int64{},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +113,10 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
|||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
cfg.QRZ = cfg.QRZ.normalised()
|
cfg.QRZ = cfg.QRZ.normalised()
|
||||||
|
cfg.Clublog = cfg.Clublog.normalised()
|
||||||
|
cfg.LoTW = cfg.LoTW.normalised()
|
||||||
|
cfg.HRDLog = cfg.HRDLog.normalised()
|
||||||
|
cfg.EQSL = cfg.EQSL.normalised()
|
||||||
m.cfg = cfg
|
m.cfg = cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,17 +153,23 @@ func (m *Manager) OnQSOLogged(id int64) {
|
|||||||
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
|
if lt := cfg.LoTW; lt.AutoUpload && lt.TQSLPath != "" && lt.StationLocation != "" {
|
||||||
m.route(ServiceLoTW, id, lt)
|
m.route(ServiceLoTW, id, lt)
|
||||||
}
|
}
|
||||||
|
// HRDLog — needs the station callsign + the account upload code.
|
||||||
|
if h := cfg.HRDLog; h.AutoUpload && h.Callsign != "" && h.Code != "" {
|
||||||
|
m.route(ServiceHRDLog, id, h)
|
||||||
|
}
|
||||||
|
// eQSL — needs the account username (callsign) + password.
|
||||||
|
if e := cfg.EQSL; e.AutoUpload && e.Username != "" && e.Password != "" {
|
||||||
|
m.route(ServiceEQSL, id, e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// route sends a logged QSO down the configured timing path: queue it for the
|
// route sends a logged QSO down the configured timing path: queue it for the
|
||||||
// app-close batch, or schedule an immediate / delayed upload.
|
// app-close batch, or schedule an immediate / delayed upload.
|
||||||
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||||
if cfg.UploadMode == ModeOnClose {
|
if cfg.UploadMode == ModeOnClose {
|
||||||
m.mu.Lock()
|
// Nothing to queue: on-close upload sweeps the whole logbook from the
|
||||||
m.pending[svc] = append(m.pending[svc], id)
|
// database at shutdown (see FlushOnClose), so this QSO is picked up by
|
||||||
n := len(m.pending[svc])
|
// its sent-status then — no in-memory tracking needed.
|
||||||
m.mu.Unlock()
|
|
||||||
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.scheduleUpload(svc, id, cfg)
|
m.scheduleUpload(svc, id, cfg)
|
||||||
@@ -166,47 +186,82 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
|||||||
go m.upload(svc, id, cfg)
|
go m.upload(svc, id, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PendingCount returns how many QSOs are queued for on-close upload across
|
// onCloseServices returns the services configured for on-close auto-upload,
|
||||||
// all services. The shutdown sequence uses it to decide whether to show the
|
// with the minimum credentials to actually run.
|
||||||
// upload step.
|
func (m *Manager) onCloseServices() []Service {
|
||||||
func (m *Manager) PendingCount() int {
|
cfg := m.Config()
|
||||||
m.mu.Lock()
|
var out []Service
|
||||||
defer m.mu.Unlock()
|
if q := cfg.QRZ; q.AutoUpload && q.UploadMode == ModeOnClose && q.APIKey != "" {
|
||||||
|
out = append(out, ServiceQRZ)
|
||||||
|
}
|
||||||
|
if c := cfg.Clublog; c.AutoUpload && c.UploadMode == ModeOnClose && c.Email != "" && c.Password != "" {
|
||||||
|
out = append(out, ServiceClublog)
|
||||||
|
}
|
||||||
|
if l := cfg.LoTW; l.AutoUpload && l.UploadMode == ModeOnClose && l.TQSLPath != "" && l.StationLocation != "" {
|
||||||
|
out = append(out, ServiceLoTW)
|
||||||
|
}
|
||||||
|
if h := cfg.HRDLog; h.AutoUpload && h.UploadMode == ModeOnClose && h.Callsign != "" && h.Code != "" {
|
||||||
|
out = append(out, ServiceHRDLog)
|
||||||
|
}
|
||||||
|
if e := cfg.EQSL; e.AutoUpload && e.UploadMode == ModeOnClose && e.Username != "" && e.Password != "" {
|
||||||
|
out = append(out, ServiceEQSL)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseUploadCount returns how many QSOs across the whole logbook would be
|
||||||
|
// uploaded at app close (sum over every on-close service). The shutdown
|
||||||
|
// sequence uses it to decide whether to show the upload step and its label.
|
||||||
|
func (m *Manager) CloseUploadCount() int {
|
||||||
|
if m.deps.CloseUploadIDs == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
n := 0
|
n := 0
|
||||||
for _, ids := range m.pending {
|
for _, svc := range m.onCloseServices() {
|
||||||
n += len(ids)
|
n += len(m.deps.CloseUploadIDs(svc))
|
||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
|
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
|
||||||
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
|
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
|
||||||
// single TQSL batch. Returns the number of QSOs uploaded successfully.
|
// Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a single TQSL
|
||||||
|
// batch. Returns the number of QSOs uploaded successfully.
|
||||||
func (m *Manager) FlushOnClose() int {
|
func (m *Manager) FlushOnClose() int {
|
||||||
m.mu.Lock()
|
if m.deps.CloseUploadIDs == nil {
|
||||||
pending := m.pending
|
return 0
|
||||||
m.pending = map[Service][]int64{}
|
}
|
||||||
cfg := m.cfg
|
cfg := m.Config()
|
||||||
m.mu.Unlock()
|
|
||||||
|
|
||||||
uploaded := 0
|
uploaded := 0
|
||||||
for svc, ids := range pending {
|
for _, svc := range m.onCloseServices() {
|
||||||
|
ids := m.deps.CloseUploadIDs(svc)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
switch svc {
|
switch svc {
|
||||||
case ServiceLoTW:
|
case ServiceLoTW:
|
||||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||||
default:
|
case ServiceQRZ:
|
||||||
var sc ServiceConfig
|
|
||||||
switch svc {
|
|
||||||
case ServiceQRZ:
|
|
||||||
sc = cfg.QRZ
|
|
||||||
case ServiceClublog:
|
|
||||||
sc = cfg.Clublog
|
|
||||||
}
|
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
if m.upload(svc, id, sc) {
|
if m.upload(svc, id, cfg.QRZ) {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ServiceClublog:
|
||||||
|
for _, id := range ids {
|
||||||
|
if m.upload(svc, id, cfg.Clublog) {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ServiceHRDLog:
|
||||||
|
for _, id := range ids {
|
||||||
|
if m.upload(svc, id, cfg.HRDLog) {
|
||||||
|
uploaded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case ServiceEQSL:
|
||||||
|
for _, id := range ids {
|
||||||
|
if m.upload(svc, id, cfg.EQSL) {
|
||||||
uploaded++
|
uploaded++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,8 +331,10 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
switch svc {
|
switch svc {
|
||||||
case ServiceQRZ, ServiceLoTW:
|
case ServiceQRZ, ServiceLoTW:
|
||||||
owner = cfg.ForceStationCallsign
|
owner = cfg.ForceStationCallsign
|
||||||
case ServiceClublog:
|
case ServiceClublog, ServiceHRDLog:
|
||||||
owner = cfg.Callsign
|
owner = cfg.Callsign
|
||||||
|
case ServiceEQSL:
|
||||||
|
owner = cfg.Username
|
||||||
}
|
}
|
||||||
if owner != "" && m.deps.StationCallOf != nil {
|
if owner != "" && m.deps.StationCallOf != nil {
|
||||||
qcall := m.deps.StationCallOf(id)
|
qcall := m.deps.StationCallOf(id)
|
||||||
@@ -324,6 +381,24 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
res, err = UploadLoTW(ctx, cfg, "", record)
|
res, err = UploadLoTW(ctx, cfg, "", record)
|
||||||
|
case ServiceHRDLog:
|
||||||
|
// HRDLog takes the station callsign as a separate param, so the ADIF
|
||||||
|
// keeps the QSO's own station call (no override), like Club Log.
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
res, err = UploadHRDLog(ctx, m.deps.Client, cfg.Callsign, cfg.Code, record)
|
||||||
|
case ServiceEQSL:
|
||||||
|
// eQSL keeps the QSO's own station call; the account is identified by
|
||||||
|
// the Username + Password, with an optional QTH nickname.
|
||||||
|
record, ok := m.deps.BuildADIF(id, "")
|
||||||
|
if !ok {
|
||||||
|
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
res, err = UploadEQSL(ctx, m.deps.Client, cfg.Username, cfg.Password, cfg.QTHNickname, record)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// Package powergenius drives a 4O3A PowerGenius XL amplifier over its TCP text
|
||||||
|
// API (same "Genius Series" line protocol as the Antenna Genius). OpsLog reads
|
||||||
|
// the amp's operate state via the FlexRadio amplifier object, but the fan mode
|
||||||
|
// is a PGXL-only setting only reachable on the amp's own control port — hence
|
||||||
|
// this small direct client. Commands are "C<id>|<cmd>\n"; replies are
|
||||||
|
// "R<id>|0|<k=v …>" and asynchronous "S0|<k=v …>".
|
||||||
|
package powergenius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPort = 9008
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
pollEvery = 1500 * time.Millisecond
|
||||||
|
reconnectDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is the snapshot the UI renders (only the bits OpsLog needs).
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
||||||
|
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||||
|
Temperature float64 `json:"temperature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises command send/recv on the connection
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
|
||||||
|
cmdID atomic.Int64
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = defaultPort
|
||||||
|
}
|
||||||
|
return &Client{host: host, port: port, stop: make(chan struct{}), status: Status{Host: host}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setStatus(fn func(*Status)) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
fn(&c.status)
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
|
||||||
|
func (c *Client) SetFanMode(mode string) error {
|
||||||
|
m := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
switch m {
|
||||||
|
case "STANDARD", "CONTEST", "BROADCAST":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("powergenius: invalid fan mode %q", mode)
|
||||||
|
}
|
||||||
|
if _, err := c.command("setup fanmode=" + m); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.setStatus(func(s *Status) { s.FanMode = m }) // optimistic
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOperate puts the amp in OPERATE (1) or STANDBY (0).
|
||||||
|
func (c *Client) SetOperate(on bool) error {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
_, err := c.command("operate=" + v)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
t := time.NewTicker(pollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := c.ensureConnected(); err != nil {
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := c.command("status"); err != nil {
|
||||||
|
c.dropConn()
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConnected() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(conn)
|
||||||
|
// Discard the version banner the device sends on connect.
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
_, _ = c.reader.ReadString('\n')
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropConn() {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// command sends "C<id>|<cmd>\n" and parses the single-line reply into status.
|
||||||
|
func (c *Client) command(cmd string) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return "", fmt.Errorf("powergenius: not connected")
|
||||||
|
}
|
||||||
|
id := c.cmdID.Add(1)
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
c.parse(line)
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse handles "R<id>|0|<k=v …>" and "S0|<k=v …>" status lines.
|
||||||
|
func (c *Client) parse(resp string) {
|
||||||
|
var data string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(resp, "R"):
|
||||||
|
p := strings.SplitN(resp, "|", 3)
|
||||||
|
if len(p) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = p[2]
|
||||||
|
case strings.HasPrefix(resp, "S"):
|
||||||
|
p := strings.SplitN(resp, "|", 2)
|
||||||
|
if len(p) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = p[1]
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
for _, pair := range strings.Fields(data) {
|
||||||
|
kv := strings.SplitN(pair, "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch kv[0] {
|
||||||
|
case "state":
|
||||||
|
c.status.State = kv[1]
|
||||||
|
case "fanmode":
|
||||||
|
c.status.FanMode = strings.ToUpper(kv[1])
|
||||||
|
case "temp":
|
||||||
|
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
+197
-2
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -491,6 +492,7 @@ var uploadStatusCols = map[string]bool{
|
|||||||
"lotw_sent": true,
|
"lotw_sent": true,
|
||||||
"qrzcom_qso_upload_status": true,
|
"qrzcom_qso_upload_status": true,
|
||||||
"clublog_qso_upload_status": true,
|
"clublog_qso_upload_status": true,
|
||||||
|
"hrdlog_qso_upload_status": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListForUpload returns QSOs whose per-service sent-status column equals
|
// ListForUpload returns QSOs whose per-service sent-status column equals
|
||||||
@@ -519,6 +521,56 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UploadCandidate is a QSO eligible for an on-close upload: its id plus its
|
||||||
|
// STATION_CALLSIGN, so the caller can keep only the rows that belong to the
|
||||||
|
// active logbook's callsign (a mixed-call DB — F4BPO, F4BPO/P, TM2Q — must not
|
||||||
|
// all be signed under one cert).
|
||||||
|
type UploadCandidate struct {
|
||||||
|
ID int64
|
||||||
|
StationCallsign string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUploadCandidates returns QSOs eligible for an on-close upload to a
|
||||||
|
// service, scanning the whole logbook. For LoTW (column "lotw_sent"), statuses
|
||||||
|
// is the set of sent-status values to treat as "to send" (e.g. N, R); rows
|
||||||
|
// already "Y" are excluded. For QRZ/Club Log, statuses is ignored and anything
|
||||||
|
// whose upload status isn't yet "Y" qualifies.
|
||||||
|
func (r *Repo) ListUploadCandidates(ctx context.Context, column string, statuses []string) ([]UploadCandidate, error) {
|
||||||
|
if !uploadStatusCols[column] {
|
||||||
|
return nil, fmt.Errorf("invalid upload column %q", column)
|
||||||
|
}
|
||||||
|
var where string
|
||||||
|
var args []any
|
||||||
|
if column == "lotw_sent" {
|
||||||
|
if len(statuses) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(statuses))
|
||||||
|
for i, s := range statuses {
|
||||||
|
ph[i] = "?"
|
||||||
|
args = append(args, strings.ToUpper(strings.TrimSpace(s)))
|
||||||
|
}
|
||||||
|
where = "UPPER(COALESCE(lotw_sent,'')) IN (" + strings.Join(ph, ",") + ")"
|
||||||
|
} else {
|
||||||
|
where = "UPPER(COALESCE(" + column + ",'')) <> 'Y'"
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx,
|
||||||
|
`SELECT id, COALESCE(station_callsign,'') FROM qso WHERE `+where+` ORDER BY qso_date`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list upload candidates: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []UploadCandidate
|
||||||
|
for rows.Next() {
|
||||||
|
var c UploadCandidate
|
||||||
|
if err := rows.Scan(&c.ID, &c.StationCallsign); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
|
// MarkQRZUploaded stamps QRZCOM_QSO_UPLOAD_STATUS=Y and the upload date on
|
||||||
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
|
// a QSO after a successful push to the QRZ.com logbook. date is an ADIF
|
||||||
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
|
// YYYYMMDD string. Only the two QRZ columns are touched — no full-row
|
||||||
@@ -547,6 +599,19 @@ func (r *Repo) MarkClublogUploaded(ctx context.Context, id int64, date string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkHRDLogUploaded stamps HRDLOG_QSO_UPLOAD_STATUS=Y and the upload date
|
||||||
|
// after a successful HRDLog.net push. date is an ADIF YYYYMMDD string.
|
||||||
|
func (r *Repo) MarkHRDLogUploaded(ctx context.Context, id int64, date string) error {
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET hrdlog_qso_upload_status = 'Y', hrdlog_qso_upload_date = ?,
|
||||||
|
updated_at = ? WHERE id = ?`,
|
||||||
|
date, db.NowISO(), id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark hrdlog uploaded %d: %w", id, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a
|
// MarkLoTWUploaded stamps LOTW_QSL_SENT=Y and the sent date after a
|
||||||
// successful TQSL upload. date is an ADIF YYYYMMDD string.
|
// successful TQSL upload. date is an ADIF YYYYMMDD string.
|
||||||
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) error {
|
||||||
@@ -560,6 +625,29 @@ func (r *Repo) MarkLoTWUploaded(ctx context.Context, id int64, date string) erro
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarkUploadedBatch sets <statusCol>='Y' and <dateCol>=date on EVERY id in one
|
||||||
|
// UPDATE — used by bulk upload (Club Log / HRDLog) so a 25k-QSO run isn't one
|
||||||
|
// round-trip per QSO on a remote MySQL. statusCol/dateCol come from a fixed
|
||||||
|
// whitelist (not user input), so the column interpolation is safe.
|
||||||
|
func (r *Repo) MarkUploadedBatch(ctx context.Context, statusCol, dateCol, date string, ids []int64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
|
||||||
|
args := make([]any, 0, len(ids)+2)
|
||||||
|
args = append(args, date, db.NowISO())
|
||||||
|
for _, id := range ids {
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
_, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET `+statusCol+` = 'Y', `+dateCol+` = ?, updated_at = ? WHERE id IN (`+ph+`)`,
|
||||||
|
args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mark uploaded batch (%d): %w", len(ids), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkEQSLSent stamps EQSL_QSL_SENT=Y and the sent date after a successful
|
// MarkEQSLSent stamps EQSL_QSL_SENT=Y and the sent date after a successful
|
||||||
// eQSL e-mail. date is an ADIF YYYYMMDD string.
|
// eQSL e-mail. date is an ADIF YYYYMMDD string.
|
||||||
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
||||||
@@ -573,6 +661,76 @@ func (r *Repo) MarkEQSLSent(ctx context.Context, id int64, date string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bulkEditableCols whitelists the columns BulkSetField may write. Limited to
|
||||||
|
// TEXT fields where setting one value across many QSOs is meaningful: the
|
||||||
|
// per-service QSL/upload status fields, plus "my station"/operator fields that
|
||||||
|
// are naturally constant across a run (grid, antenna, rig, address, …). It
|
||||||
|
// deliberately excludes per-QSO fields (callsign, band, mode, date, RST, the
|
||||||
|
// contacted station's details) and numeric columns (power, zones, lat/lon),
|
||||||
|
// which would be corrupted or meaningless if bulk-set to a single value.
|
||||||
|
var bulkEditableCols = map[string]bool{
|
||||||
|
// QSL / upload status
|
||||||
|
"lotw_sent": true,
|
||||||
|
"lotw_rcvd": true,
|
||||||
|
"eqsl_sent": true,
|
||||||
|
"eqsl_rcvd": true,
|
||||||
|
"qsl_sent": true,
|
||||||
|
"qsl_rcvd": true,
|
||||||
|
"qsl_via": true,
|
||||||
|
"qrzcom_qso_upload_status": true,
|
||||||
|
"clublog_qso_upload_status": true,
|
||||||
|
"hrdlog_qso_upload_status": true,
|
||||||
|
// My station / operator
|
||||||
|
"station_callsign": true,
|
||||||
|
"operator": true,
|
||||||
|
"my_grid": true,
|
||||||
|
"my_country": true,
|
||||||
|
"my_state": true,
|
||||||
|
"my_cnty": true,
|
||||||
|
"my_iota": true,
|
||||||
|
"my_sota_ref": true,
|
||||||
|
"my_pota_ref": true,
|
||||||
|
"my_wwff_ref": true,
|
||||||
|
"my_street": true,
|
||||||
|
"my_city": true,
|
||||||
|
"my_postal_code": true,
|
||||||
|
"my_rig": true,
|
||||||
|
"my_antenna": true,
|
||||||
|
"my_sig": true,
|
||||||
|
"my_sig_info": true,
|
||||||
|
// Misc text
|
||||||
|
"comment": true,
|
||||||
|
"notes": true,
|
||||||
|
"rig": true,
|
||||||
|
"ant": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// BulkSetField sets one whitelisted column to value on every listed QSO in a
|
||||||
|
// single statement. value "" clears the field. Returns rows affected.
|
||||||
|
func (r *Repo) BulkSetField(ctx context.Context, ids []int64, column, value string) (int64, error) {
|
||||||
|
if !bulkEditableCols[column] {
|
||||||
|
return 0, fmt.Errorf("field %q is not bulk-editable", column)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(ids))
|
||||||
|
args := make([]any, 0, len(ids)+2)
|
||||||
|
args = append(args, value, db.NowISO())
|
||||||
|
for i, id := range ids {
|
||||||
|
ph[i] = "?"
|
||||||
|
args = append(args, id)
|
||||||
|
}
|
||||||
|
res, err := r.db.ExecContext(ctx,
|
||||||
|
`UPDATE qso SET `+column+` = ?, updated_at = ? WHERE id IN (`+strings.Join(ph, ",")+`)`,
|
||||||
|
args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("bulk set %s: %w", column, err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
// Update overwrites all editable fields of an existing QSO. updated_at is bumped.
|
||||||
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
func (r *Repo) Update(ctx context.Context, q QSO) error {
|
||||||
if q.ID == 0 {
|
if q.ID == 0 {
|
||||||
@@ -651,6 +809,25 @@ func (r *Repo) DeleteAll(ctx context.Context) (int64, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeleteMany removes several QSOs in one statement. Returns the number deleted.
|
||||||
|
func (r *Repo) DeleteMany(ctx context.Context, ids []int64) (int64, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
ph := make([]string, len(ids))
|
||||||
|
args := make([]any, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
ph[i] = "?"
|
||||||
|
args[i] = id
|
||||||
|
}
|
||||||
|
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id IN (`+strings.Join(ph, ",")+`)`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("delete qsos: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Delete removes a QSO by id.
|
// Delete removes a QSO by id.
|
||||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
|
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
|
||||||
@@ -746,16 +923,26 @@ var filterableColumns = map[string]bool{
|
|||||||
"name": true, "qth": true, "address": true, "email": true,
|
"name": true, "qth": true, "address": true, "email": true,
|
||||||
"grid": true, "country": true, "state": true, "cnty": true,
|
"grid": true, "country": true, "state": true, "cnty": true,
|
||||||
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
|
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
|
||||||
"iota": true, "sota_ref": true, "pota_ref": true, "rig": true, "ant": true,
|
"iota": true, "sota_ref": true, "pota_ref": true, "wwff_ref": true, "rig": true, "ant": true,
|
||||||
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
|
||||||
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
|
||||||
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true,
|
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true,
|
||||||
"contest_id": true, "srx": true, "stx": true,
|
"contest_id": true, "srx": true, "stx": true,
|
||||||
"prop_mode": true, "sat_name": true,
|
"prop_mode": true, "sat_name": true,
|
||||||
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
|
||||||
|
"my_state": true, "my_cnty": true, "my_iota": true, "my_sota_ref": true, "my_pota_ref": true,
|
||||||
|
"my_wwff_ref": true, "my_street": true, "my_city": true, "my_postal_code": true,
|
||||||
|
"my_rig": true, "my_antenna": true, "my_sig": true, "my_sig_info": true,
|
||||||
"tx_pwr": true, "comment": true, "notes": true,
|
"tx_pwr": true, "comment": true, "notes": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dateColumns are stored as full ISO timestamps; a filter on a bare YYYY-MM-DD
|
||||||
|
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||||
|
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||||
|
|
||||||
|
// bareDateRe matches a plain calendar date with no time component.
|
||||||
|
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||||
|
|
||||||
// filterableExtras whitelists virtual filter fields stored inside extras_json
|
// filterableExtras whitelists virtual filter fields stored inside extras_json
|
||||||
// (valid ADIF fields we don't promote to columns). The value is the uppercase
|
// (valid ADIF fields we don't promote to columns). The value is the uppercase
|
||||||
// ADIF/Extras key; the SQL expression uses json_extract.
|
// ADIF/Extras key; the SQL expression uses json_extract.
|
||||||
@@ -802,6 +989,14 @@ func conditionSQL(c Condition) (string, []any, error) {
|
|||||||
return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
|
return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
|
||||||
}
|
}
|
||||||
v := c.Value
|
v := c.Value
|
||||||
|
// Date columns hold full ISO timestamps ("2020-01-01T12:34:56.000Z"). When
|
||||||
|
// the user filters on a bare calendar date, compare only the date part so
|
||||||
|
// "= 2020-01-01" matches that whole day and "<= 2020-12-31" includes it
|
||||||
|
// (a raw string compare would otherwise drop times on the boundary day).
|
||||||
|
if dateColumns[strings.ToLower(strings.TrimSpace(c.Field))] && bareDateRe.MatchString(strings.TrimSpace(v)) {
|
||||||
|
col = "substr(" + col + ",1,10)"
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
switch c.Op {
|
switch c.Op {
|
||||||
case "eq":
|
case "eq":
|
||||||
return col + " = ?", []any{v}, nil
|
return col + " = ?", []any{v}, nil
|
||||||
|
|||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// call + freq/band/mode from CAT) into a `live_status` table every ~15s. A tiny
|
||||||
|
// web script on the operator's own server reads that table and renders a live
|
||||||
|
// page/image that the QRZ.com bio can embed (`<img src=…>`). OpsLog only WRITES
|
||||||
|
// to the DB — it is not a web server. Rows older than a couple of minutes are
|
||||||
|
// "stale" (operator went offline); the web side ignores them.
|
||||||
|
|
||||||
|
const keyLiveStatusEnabled = "livestatus.enabled"
|
||||||
|
|
||||||
|
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
||||||
|
func (a *App) GetLiveStatusEnabled() bool {
|
||||||
|
if a.settings == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled)
|
||||||
|
return strings.TrimSpace(v) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLiveStatusEnabled turns live-status publishing on or off (off also removes
|
||||||
|
// this operator's row immediately).
|
||||||
|
func (a *App) SetLiveStatusEnabled(on bool) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
val := "0"
|
||||||
|
if on {
|
||||||
|
val = "1"
|
||||||
|
}
|
||||||
|
if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
applog.Printf("livestatus: enabled (logbook backend=%q, mysql conn=%v)", a.dbBackend, a.logDb != nil)
|
||||||
|
go a.publishLiveStatus() // show up right away
|
||||||
|
} else {
|
||||||
|
a.clearLiveStatus()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusLoop heartbeats the current activity while enabled. Started once at
|
||||||
|
// startup; cheap no-op when disabled or not on MySQL.
|
||||||
|
func (a *App) liveStatusLoop() {
|
||||||
|
defer func() { _ = recover() }() // never crash the app from here
|
||||||
|
applog.Printf("livestatus: loop started")
|
||||||
|
a.publishLiveStatus() // attempt immediately, don't wait the first tick
|
||||||
|
t := time.NewTicker(15 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
a.publishLiveStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusActive reports whether publishing should run (MySQL logbook + on).
|
||||||
|
func (a *App) liveStatusActive() bool {
|
||||||
|
return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// liveStatusOperator returns this instance's operator id (the operator callsign,
|
||||||
|
// falling back to the station callsign for a single-op setup). The callsign and
|
||||||
|
// operator live on the ACTIVE PROFILE (station_profiles table), NOT in the
|
||||||
|
// settings KV — read them there.
|
||||||
|
func (a *App) liveStatusOperator() (op, station string) {
|
||||||
|
if a.profiles == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
station = strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||||
|
op = strings.ToUpper(strings.TrimSpace(p.Operator))
|
||||||
|
if op == "" {
|
||||||
|
op = station
|
||||||
|
}
|
||||||
|
return op, station
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
|
||||||
|
// mode, used as a fallback for live status when the CAT isn't connected.
|
||||||
|
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
a.liveFreqHz = freqHz
|
||||||
|
a.liveBand = strings.ToUpper(strings.TrimSpace(band))
|
||||||
|
a.liveMode = strings.ToUpper(strings.TrimSpace(mode))
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishLiveStatus upserts this operator's current activity. Best effort, with
|
||||||
|
// explicit logging so a silent no-op is diagnosable.
|
||||||
|
func (a *App) publishLiveStatus() {
|
||||||
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
|
return // not a MySQL logbook — nothing to do (silent, runs every 15s)
|
||||||
|
}
|
||||||
|
if !a.GetLiveStatusEnabled() {
|
||||||
|
return // disabled (silent)
|
||||||
|
}
|
||||||
|
op, station := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var freqHz int64
|
||||||
|
var band, mode string
|
||||||
|
if a.cat != nil {
|
||||||
|
st := a.cat.State()
|
||||||
|
if st.Connected {
|
||||||
|
freqHz, band, mode = st.FreqHz, st.Band, st.Mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fall back to whatever the entry strip last reported (so band/mode/freq are
|
||||||
|
// published even when the CAT isn't connected).
|
||||||
|
a.liveActMu.Lock()
|
||||||
|
if freqHz == 0 {
|
||||||
|
freqHz = a.liveFreqHz
|
||||||
|
}
|
||||||
|
if band == "" {
|
||||||
|
band = a.liveBand
|
||||||
|
}
|
||||||
|
if mode == "" {
|
||||||
|
mode = a.liveMode
|
||||||
|
}
|
||||||
|
a.liveActMu.Unlock()
|
||||||
|
if err := a.ensureLiveStatusTable(); err != nil {
|
||||||
|
applog.Printf("livestatus: CREATE TABLE failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"INSERT INTO live_status (operator, station, freq_hz, band, mode, updated_at) "+
|
||||||
|
"VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
|
||||||
|
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
|
||||||
|
"band=VALUES(band), mode=VALUES(mode), updated_at=UTC_TIMESTAMP()",
|
||||||
|
op, station, freqHz, band, mode)
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("livestatus: INSERT failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s", op, station, freqHz, band, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureLiveStatusTable() error {
|
||||||
|
_, err := a.logDb.ExecContext(a.ctx,
|
||||||
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
|
"station VARCHAR(32), "+
|
||||||
|
"freq_hz BIGINT, "+
|
||||||
|
"band VARCHAR(16), "+
|
||||||
|
"mode VARCHAR(16), "+
|
||||||
|
"updated_at DATETIME)")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearLiveStatus removes this operator's row (on disable / shutdown).
|
||||||
|
func (a *App) clearLiveStatus() {
|
||||||
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
op, _ := a.liveStatusOperator()
|
||||||
|
if op == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op)
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/wailsapp/wails/v2"
|
"github.com/wailsapp/wails/v2"
|
||||||
"github.com/wailsapp/wails/v2/pkg/options"
|
"github.com/wailsapp/wails/v2/pkg/options"
|
||||||
@@ -11,9 +13,32 @@ import (
|
|||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
var assets embed.FS
|
var assets embed.FS
|
||||||
|
|
||||||
|
// profileArg extracts a profile name from the command line. Accepts
|
||||||
|
// "--profile NAME", "--profile=NAME", "-profile NAME", "-p NAME" so a desktop
|
||||||
|
// shortcut can launch OpsLog straight into a given profile (e.g. F4BPO / TM2Q).
|
||||||
|
func profileArg(args []string) string {
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
a := args[i]
|
||||||
|
switch {
|
||||||
|
case a == "--profile" || a == "-profile" || a == "-p":
|
||||||
|
if i+1 < len(args) {
|
||||||
|
return strings.TrimSpace(args[i+1])
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(a, "--profile="):
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(a, "--profile="))
|
||||||
|
case strings.HasPrefix(a, "-profile="):
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(a, "-profile="))
|
||||||
|
case strings.HasPrefix(a, "-p="):
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(a, "-p="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Create an instance of the app structure
|
// Create an instance of the app structure
|
||||||
app := NewApp()
|
app := NewApp()
|
||||||
|
app.startupProfile = profileArg(os.Args[1:])
|
||||||
|
|
||||||
// Create application with options
|
// Create application with options
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -13,15 +13,15 @@ import (
|
|||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Anonymous usage telemetry — a once-a-day "app_opened" heartbeat to PostHog so
|
// Anonymous usage telemetry - a once-a-day "app_opened" heartbeat to PostHog so
|
||||||
// the OpsLog author can see how many people actively use it. Privacy by design:
|
// the OpsLog author can see how many people actively use it. Privacy by design:
|
||||||
// only a random install ID + app version + OS are sent (no callsign, no QSO
|
// only a random install ID + app version + OS are sent (no callsign, no QSO
|
||||||
// data, no IP beyond what any HTTP request reveals). Users can disable it in
|
// data, no IP beyond what any HTTP request reveals). Users can disable it in
|
||||||
// Preferences → General. See [[user-analytics-posthog]] notes in MEMORY.
|
// Preferences -> General. See [[user-analytics-posthog]] notes in MEMORY.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.11"
|
appVersion = "0.13"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user