Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e95e6a16 | |||
| cc0f9ffc64 | |||
| e1f1ab4922 | |||
| b6d991b799 | |||
| 59f1775fcd | |||
| b2a8b1946f | |||
| 8b1609f5ce | |||
| bde1195b34 | |||
| abdab22010 | |||
| 16dc864dbd |
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
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 / developing
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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
|
||||
offered when MS Office installed it. Flags: flag-icons (MIT), embedded for the
|
||||
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.
|
||||
|
||||
@@ -196,7 +196,8 @@ const (
|
||||
keyExtLoTWStationLoc = "extsvc.lotw.station_location"
|
||||
keyExtLoTWForceCall = "extsvc.lotw.force_station_callsign" // override STATION_CALLSIGN at sign time (e.g. F4BPO/P on the F4BPO cert)
|
||||
keyExtLoTWKeyPassword = "extsvc.lotw.key_password"
|
||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag"
|
||||
keyExtLoTWUploadFlag = "extsvc.lotw.upload_flag" // legacy single flag (migrated to upload_flags)
|
||||
keyExtLoTWUploadFlags = "extsvc.lotw.upload_flags" // CSV set of lotw_sent values to upload (e.g. "N,R")
|
||||
keyExtLoTWWriteLog = "extsvc.lotw.write_log"
|
||||
keyExtLoTWAutoUpload = "extsvc.lotw.auto_upload"
|
||||
keyExtLoTWUploadMode = "extsvc.lotw.upload_mode"
|
||||
@@ -379,6 +380,10 @@ type App struct {
|
||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||
liveActMu sync.Mutex // guards the entry-strip activity reported for live status
|
||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||
liveBand string
|
||||
liveMode string
|
||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||
@@ -519,9 +524,23 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Windows reinstall. It lives OUTSIDE the DB since we must know the path
|
||||
// before opening it.
|
||||
if custom := readDBPointer(dataDir); custom != "" {
|
||||
// Portability guard: a pointer that is merely ANOTHER folder's default DB
|
||||
// location ("…/<other>/data/opslog.db") means the portable folder was
|
||||
// renamed or copied — its config.json still points at the original. Ignore
|
||||
// it and use THIS folder's own data (and clear the stale pointer so it
|
||||
// stops happening). A genuine custom location — another drive, a different
|
||||
// filename — is NOT default-style, so it's still honoured.
|
||||
stale := strings.EqualFold(filepath.Base(custom), "opslog.db") &&
|
||||
strings.EqualFold(filepath.Base(filepath.Dir(custom)), "data") &&
|
||||
!strings.EqualFold(filepath.Clean(filepath.Dir(custom)), filepath.Clean(dataDir))
|
||||
if stale {
|
||||
fmt.Printf("OpsLog: ignoring stale DB pointer %q (folder moved) — using %s\n", custom, a.dbPath)
|
||||
_ = writeDBPointer(dataDir, "")
|
||||
} else {
|
||||
a.dbPath = custom
|
||||
usingDefault = false
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
|
||||
a.startupErr = "cannot create db folder: " + err.Error()
|
||||
fmt.Println("OpsLog:", a.startupErr)
|
||||
@@ -711,6 +730,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
NotifyError: a.notifyExtError,
|
||||
ShouldUpload: a.extShouldUpload,
|
||||
StationCallOf: a.stationCallOf,
|
||||
CloseUploadIDs: a.closeUploadIDs,
|
||||
Logf: applog.Printf,
|
||||
})
|
||||
a.extsvc.SetConfig(a.loadExternalServices())
|
||||
@@ -769,6 +789,7 @@ func (a *App) startup(ctx context.Context) {
|
||||
// Anonymous usage heartbeat (once/day) so we can gauge active users. No-op
|
||||
// when disabled in Preferences or until the PostHog key is configured.
|
||||
go a.sendTelemetryHeartbeat()
|
||||
go a.liveStatusLoop() // multi-op: heartbeat current activity to shared MySQL
|
||||
|
||||
fmt.Println("OpsLog: db ready at", a.dbPath)
|
||||
}
|
||||
@@ -836,7 +857,7 @@ func (a *App) plannedShutdownSteps() []shutdownStep {
|
||||
}
|
||||
}
|
||||
if a.extsvc != nil {
|
||||
if n := a.extsvc.PendingCount(); n > 0 {
|
||||
if n := a.extsvc.CloseUploadCount(); n > 0 {
|
||||
out = append(out, shutdownStep{
|
||||
ID: "extsvc-upload",
|
||||
Label: fmt.Sprintf("Uploading %d QSO(s) to online logbooks", n),
|
||||
@@ -2452,6 +2473,16 @@ func (a *App) invalidateAwardStats() {
|
||||
a.awardSnapMu.Unlock()
|
||||
}
|
||||
|
||||
// RescanAwards forces the next award computation to re-pull the logbook from the
|
||||
// database, bypassing the in-memory snapshot. Bound to the Awards panel's
|
||||
// "Rescan" button so the operator can refresh after an external change the
|
||||
// revision check can't see (e.g. a LoTW/QRZ confirmation download that only
|
||||
// flips qsl_rcvd flags on existing rows).
|
||||
func (a *App) RescanAwards() error {
|
||||
a.invalidateAwardStats()
|
||||
return nil
|
||||
}
|
||||
|
||||
// awardRefMetas loads the reference lists of PREDEFINED awards (Dynamic=false)
|
||||
// into the engine view. Dynamic awards (POTA/SOTA/…) are skipped — their lists
|
||||
// are large and not needed for matching; their names are filled afterwards.
|
||||
@@ -3159,6 +3190,15 @@ func (a *App) DeleteQSO(id int64) error {
|
||||
return a.qso.Delete(a.ctx, id)
|
||||
}
|
||||
|
||||
// DeleteQSOs removes several QSOs at once (multi-row selection). Returns the
|
||||
// number actually deleted.
|
||||
func (a *App) DeleteQSOs(ids []int64) (int64, error) {
|
||||
if a.qso == nil {
|
||||
return 0, fmt.Errorf("db not initialized")
|
||||
}
|
||||
return a.qso.DeleteMany(a.ctx, ids)
|
||||
}
|
||||
|
||||
// QSLBulkUpdate carries the paper-QSL fields to apply to a selection. An empty
|
||||
// string leaves that field unchanged (so you can set only "received = Y + date"
|
||||
// without touching the sent side).
|
||||
@@ -4784,6 +4824,31 @@ func (a *App) getManyScoped(prefix string, keys ...string) (map[string]string, e
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseUploadFlags resolves the LoTW "treat as unsent" status set: prefer the
|
||||
// CSV (new multi-select), fall back to the legacy single flag, and default to
|
||||
// N+R when nothing is configured (covers an imported ADIF still marked unsent).
|
||||
func parseUploadFlags(csv, legacy string) []string {
|
||||
add := func(dst []string, seen map[string]bool, raw string) []string {
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
f := strings.ToUpper(strings.TrimSpace(p))
|
||||
if (f == "N" || f == "R") && !seen[f] {
|
||||
seen[f] = true
|
||||
dst = append(dst, f)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := add(nil, seen, csv)
|
||||
if len(out) == 0 {
|
||||
out = add(out, seen, legacy)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"N", "R"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
var out extsvc.ExternalServices
|
||||
if a.settings == nil {
|
||||
@@ -4800,7 +4865,7 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
keyExtClublogEmail, keyExtClublogPassword, keyExtClublogCallsign,
|
||||
keyExtClublogAPIKey, keyExtClublogAutoUpload, keyExtClublogUploadMode,
|
||||
keyExtLoTWTQSLPath, keyExtLoTWStationLoc, keyExtLoTWForceCall, keyExtLoTWKeyPassword,
|
||||
keyExtLoTWUploadFlag, keyExtLoTWWriteLog,
|
||||
keyExtLoTWUploadFlag, keyExtLoTWUploadFlags, keyExtLoTWWriteLog,
|
||||
keyExtLoTWAutoUpload, keyExtLoTWUploadMode,
|
||||
keyExtLoTWUsername, keyExtLoTWWebPassword)
|
||||
if err != nil {
|
||||
@@ -4832,12 +4897,15 @@ func (a *App) loadExternalServices() extsvc.ExternalServices {
|
||||
StationLocation: m[keyExtLoTWStationLoc],
|
||||
ForceStationCallsign: m[keyExtLoTWForceCall],
|
||||
KeyPassword: m[keyExtLoTWKeyPassword],
|
||||
UploadFlag: m[keyExtLoTWUploadFlag],
|
||||
UploadFlags: parseUploadFlags(m[keyExtLoTWUploadFlags], m[keyExtLoTWUploadFlag]),
|
||||
WriteLog: m[keyExtLoTWWriteLog] == "1",
|
||||
Username: m[keyExtLoTWUsername],
|
||||
Password: m[keyExtLoTWWebPassword],
|
||||
AutoUpload: m[keyExtLoTWAutoUpload] == "1",
|
||||
UploadMode: extsvc.UploadMode(m[keyExtLoTWUploadMode]),
|
||||
// LoTW only ever uploads as an on-close batch (ARRL discourages per-QSO
|
||||
// uploads), so the UI offers no other timing. Force it here so configs
|
||||
// saved by older builds — which stored "immediate" — still batch at close.
|
||||
UploadMode: extsvc.ModeOnClose,
|
||||
}
|
||||
// Default the TQSL path to the standard install location when unset, so
|
||||
// the field is pre-populated if TQSL is present.
|
||||
@@ -4858,34 +4926,35 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
mode := string(extsvc.ModeImmediate)
|
||||
if cfg.QRZ.UploadMode == extsvc.ModeDelayed {
|
||||
mode = string(extsvc.ModeDelayed)
|
||||
// Preserve the chosen upload timing — including "on_close", which the LoTW
|
||||
// batch flush at shutdown depends on. (A previous version collapsed anything
|
||||
// that wasn't "delayed" to "immediate", silently disabling on-close upload.)
|
||||
modeOf := func(m extsvc.UploadMode) string {
|
||||
switch m {
|
||||
case extsvc.ModeDelayed:
|
||||
return string(extsvc.ModeDelayed)
|
||||
case extsvc.ModeOnClose:
|
||||
return string(extsvc.ModeOnClose)
|
||||
default:
|
||||
return string(extsvc.ModeImmediate)
|
||||
}
|
||||
}
|
||||
mode := modeOf(cfg.QRZ.UploadMode)
|
||||
auto := "0"
|
||||
if cfg.QRZ.AutoUpload {
|
||||
auto = "1"
|
||||
}
|
||||
clMode := string(extsvc.ModeImmediate)
|
||||
if cfg.Clublog.UploadMode == extsvc.ModeDelayed {
|
||||
clMode = string(extsvc.ModeDelayed)
|
||||
}
|
||||
clMode := modeOf(cfg.Clublog.UploadMode)
|
||||
clAuto := "0"
|
||||
if cfg.Clublog.AutoUpload {
|
||||
clAuto = "1"
|
||||
}
|
||||
ltMode := string(extsvc.ModeImmediate)
|
||||
if cfg.LoTW.UploadMode == extsvc.ModeDelayed {
|
||||
ltMode = string(extsvc.ModeDelayed)
|
||||
}
|
||||
ltMode := modeOf(cfg.LoTW.UploadMode)
|
||||
ltAuto := "0"
|
||||
if cfg.LoTW.AutoUpload {
|
||||
ltAuto = "1"
|
||||
}
|
||||
ltFlag := strings.ToUpper(strings.TrimSpace(cfg.LoTW.UploadFlag))
|
||||
if ltFlag != "N" && ltFlag != "R" {
|
||||
ltFlag = "R"
|
||||
}
|
||||
ltFlags := strings.Join(parseUploadFlags(strings.Join(cfg.LoTW.UploadFlags, ","), ""), ",")
|
||||
ltWriteLog := "0"
|
||||
if cfg.LoTW.WriteLog {
|
||||
ltWriteLog = "1"
|
||||
@@ -4908,7 +4977,7 @@ func (a *App) SaveExternalServices(cfg extsvc.ExternalServices) error {
|
||||
keyExtLoTWStationLoc: strings.TrimSpace(cfg.LoTW.StationLocation),
|
||||
keyExtLoTWForceCall: strings.ToUpper(strings.TrimSpace(cfg.LoTW.ForceStationCallsign)),
|
||||
keyExtLoTWKeyPassword: cfg.LoTW.KeyPassword,
|
||||
keyExtLoTWUploadFlag: ltFlag,
|
||||
keyExtLoTWUploadFlags: ltFlags,
|
||||
keyExtLoTWWriteLog: ltWriteLog,
|
||||
keyExtLoTWAutoUpload: ltAuto,
|
||||
keyExtLoTWUploadMode: ltMode,
|
||||
@@ -5174,12 +5243,17 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
switch svc {
|
||||
case extsvc.ServiceLoTW:
|
||||
sinceDate := resolveSince(keyExtLoTWLastDownload)
|
||||
if sinceDate != "" {
|
||||
emit("Downloading LoTW confirmations received since " + sinceDate + "…")
|
||||
} else {
|
||||
emit("Downloading all LoTW confirmations…")
|
||||
ownCall := a.uploadOwnerCall(extsvc.ServiceLoTW)
|
||||
callLabel := ownCall
|
||||
if callLabel == "" {
|
||||
callLabel = "all callsigns"
|
||||
}
|
||||
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate)
|
||||
if sinceDate != "" {
|
||||
emit(fmt.Sprintf("Downloading LoTW confirmations for %s received since %s…", callLabel, sinceDate))
|
||||
} else {
|
||||
emit(fmt.Sprintf("Downloading all LoTW confirmations for %s…", callLabel))
|
||||
}
|
||||
adifText, err := extsvc.DownloadLoTWConfirmations(ctx, nil, cfg.LoTW, sinceDate, ownCall)
|
||||
if err != nil {
|
||||
emit("Download failed: " + err.Error())
|
||||
done(matched, total)
|
||||
@@ -5416,6 +5490,13 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
||||
default:
|
||||
emit(fmt.Sprintf("Confirmation download isn't available for %s yet.", svc))
|
||||
}
|
||||
// Confirmations flip lotw_rcvd/qsl_rcvd on EXISTING rows, which doesn't move
|
||||
// the logbook revision (count:maxID) — so the cached award snapshot would
|
||||
// stay stale. Drop it whenever anything was matched or added so the next
|
||||
// Awards view reflects the new confirmations.
|
||||
if matched > 0 || added > 0 {
|
||||
a.invalidateAwardStats()
|
||||
}
|
||||
done(matched+added, total)
|
||||
}
|
||||
|
||||
@@ -5637,6 +5718,84 @@ func (a *App) stationCallOf(id int64) string {
|
||||
return strings.ToUpper(strings.TrimSpace(q.StationCallsign))
|
||||
}
|
||||
|
||||
// uploadOwnerCall returns the callsign OpsLog signs/uploads/downloads as for a
|
||||
// service in the active profile: the configured Force/owner callsign, else the
|
||||
// active profile's callsign. "" when nothing is known (don't scope by call).
|
||||
func (a *App) uploadOwnerCall(svc extsvc.Service) string {
|
||||
cfg := a.loadExternalServices()
|
||||
owner := ""
|
||||
switch svc {
|
||||
case extsvc.ServiceLoTW:
|
||||
owner = cfg.LoTW.ForceStationCallsign
|
||||
case extsvc.ServiceQRZ:
|
||||
owner = cfg.QRZ.ForceStationCallsign
|
||||
case extsvc.ServiceClublog:
|
||||
owner = cfg.Clublog.Callsign
|
||||
}
|
||||
owner = strings.ToUpper(strings.TrimSpace(owner))
|
||||
if owner == "" && a.profiles != nil {
|
||||
if p, perr := a.profiles.Active(a.ctx); perr == nil {
|
||||
owner = strings.ToUpper(strings.TrimSpace(p.Callsign))
|
||||
}
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
// UploadCallsign exposes uploadOwnerCall to the UI so the QSL Manager can show
|
||||
// which of the operator's callsigns a download/upload targets in this profile.
|
||||
func (a *App) UploadCallsign(service string) string {
|
||||
return a.uploadOwnerCall(extsvc.Service(service))
|
||||
}
|
||||
|
||||
// closeUploadIDs returns the QSO ids to upload to a service at app close,
|
||||
// scanning the whole logbook: LoTW matches the configured sent-status set
|
||||
// (N/R), QRZ/Club Log return anything not yet "Y". This is what lets an
|
||||
// imported ADIF (old QSOs still unsent) flush on close.
|
||||
func (a *App) closeUploadIDs(svc extsvc.Service) []int64 {
|
||||
if a.qso == nil {
|
||||
return nil
|
||||
}
|
||||
col := uploadColumnFor(string(svc))
|
||||
if col == "" {
|
||||
return nil
|
||||
}
|
||||
// owner is the callsign this logbook signs/uploads as. Each external
|
||||
// logbook belongs to ONE call, so in a mixed-call DB (F4BPO, F4BPO/P, TM2Q)
|
||||
// we must only sweep the QSOs that belong to it — otherwise TM2Q QSOs would
|
||||
// be signed under the F4BPO certificate, or pushed to the wrong QRZ/Club Log
|
||||
// logbook.
|
||||
var statuses []string
|
||||
if svc == extsvc.ServiceLoTW {
|
||||
statuses = a.loadExternalServices().LoTW.UploadFlags
|
||||
if len(statuses) == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
owner := a.uploadOwnerCall(svc)
|
||||
|
||||
cands, err := a.qso.ListUploadCandidates(a.ctx, col, statuses)
|
||||
if err != nil {
|
||||
applog.Printf("extsvc: close-upload candidate scan for %s failed: %v", svc, err)
|
||||
return nil
|
||||
}
|
||||
out := make([]int64, 0, len(cands))
|
||||
skipped := 0
|
||||
for _, c := range cands {
|
||||
// Keep QSOs that belong to this logbook's call. A blank STATION_CALLSIGN
|
||||
// is assumed to be ours (it gets signed/labelled as owner on upload),
|
||||
// mirroring the per-QSO guard in extsvc.upload.
|
||||
if owner == "" || c.StationCallsign == "" || extsvc.SameBaseCall(c.StationCallsign, owner) {
|
||||
out = append(out, c.ID)
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
if skipped > 0 {
|
||||
applog.Printf("extsvc: %s close-upload skipped %d QSO(s) not matching logbook callsign %q", svc, skipped, owner)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// extShouldUpload reports whether a QSO is eligible for upload to a service,
|
||||
// based on its sent status. QRZ/Club Log upload anything not yet "Y"; LoTW
|
||||
// uploads only QSOs whose lotw_sent matches the configured Upload flag
|
||||
@@ -5663,15 +5822,12 @@ func (a *App) extShouldUpload(svc extsvc.Service, id int64) bool {
|
||||
}
|
||||
return true
|
||||
case extsvc.ServiceLoTW:
|
||||
flag := "R"
|
||||
if a.settings != nil {
|
||||
if m, e := a.settings.GetMany(a.ctx, keyExtLoTWUploadFlag); e == nil {
|
||||
if v := strings.ToUpper(strings.TrimSpace(m[keyExtLoTWUploadFlag])); v == "N" || v == "R" {
|
||||
flag = v
|
||||
for _, f := range a.loadExternalServices().LoTW.UploadFlags {
|
||||
if strings.EqualFold(q.LOTWSent, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.EqualFold(q.LOTWSent, flag)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -6298,6 +6454,196 @@ func (a *App) SetCATMode(mode string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ── FlexRadio control tab (Phase 1: SmartSDR-style transmit controls) ──
|
||||
// These are no-ops / errors unless the active CAT backend is a FlexRadio.
|
||||
|
||||
// GetFlexState returns the radio's transmit/ATU state for the FlexRadio tab.
|
||||
// Available=false when the active backend isn't a connected Flex.
|
||||
func (a *App) GetFlexState() cat.FlexTXState {
|
||||
if a.cat == nil {
|
||||
return cat.FlexTXState{}
|
||||
}
|
||||
st, _ := a.cat.FlexState()
|
||||
return st
|
||||
}
|
||||
|
||||
func (a *App) FlexSetPower(p int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetRFPower(p) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetTunePower(p int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTunePower(p) })
|
||||
}
|
||||
|
||||
func (a *App) FlexTune(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTune(on) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetVox(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOX(on) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetVoxLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOXLevel(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetVoxDelay(ms int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetVOXDelay(ms) })
|
||||
}
|
||||
|
||||
func (a *App) FlexAmpOperate(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAmpOperate(on) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetProcessor(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetProcessor(on) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetProcessorLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetProcessorLevel(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetMon(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMon(on) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetMonLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMonLevel(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetMic(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetMic(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexMox(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.SetPTT(on) // MOX = manual transmit (xmit 1/0)
|
||||
}
|
||||
|
||||
func (a *App) FlexATUStart() error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ATUStart() })
|
||||
}
|
||||
|
||||
func (a *App) FlexATUBypass() error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.ATUBypass() })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetATUMemories(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetATUMemories(on) })
|
||||
}
|
||||
|
||||
// RX slice DSP controls (target the active receive slice).
|
||||
|
||||
func (a *App) FlexSetAGCMode(m string) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAGCMode(m) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetAGCThreshold(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAGCThreshold(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetAudioLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetAudioLevel(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetNB(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNB(on) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetNBLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNBLevel(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetNR(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNR(on) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetNRLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetNRLevel(l) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetANF(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANF(on) })
|
||||
}
|
||||
|
||||
func (a *App) FlexSetANFLevel(l int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetANFLevel(l) })
|
||||
}
|
||||
|
||||
// SwitchCATRig hot-swaps the active OmniRig slot (Rig1 ↔ Rig2) without
|
||||
// requiring a trip through the full Settings panel. Persists the choice
|
||||
// so it survives restart.
|
||||
|
||||
@@ -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>
|
||||
+85
-32
@@ -7,7 +7,7 @@ import {
|
||||
import {
|
||||
AddQSO, ListQSO, CountQSO, ListQSOFiltered, CountQSOFiltered,
|
||||
OpenADIFFile, ImportADIF, SaveADIFFile, ExportADIF, ExportADIFFiltered, ExportADIFSelected,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteAllQSO,
|
||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||
GetStartupStatus, CheckForUpdate,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart,
|
||||
GetAwardDefs,
|
||||
GetUIPref,
|
||||
ReportLiveActivity,
|
||||
} from '../wailsjs/go/main/App';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||
@@ -51,6 +52,7 @@ import { FirstRunModal } from '@/components/FirstRunModal';
|
||||
import { QSOEditModal } from '@/components/QSOEditModal';
|
||||
import { BandMap } from '@/components/BandMap';
|
||||
import { WorldMap, LocatorMap } from '@/components/MainMap';
|
||||
import { FlexPanel } from '@/components/FlexPanel';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
@@ -664,11 +666,11 @@ export default function App() {
|
||||
// map ("map1"), the locator street map ("map2"), the cluster grid or the
|
||||
// worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed),
|
||||
// so it's loaded async on mount and re-read on profile:changed below.
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked';
|
||||
type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex';
|
||||
const [mainPaneLeft, setMainPaneLeft] = useState<MainPaneKind>('map1');
|
||||
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('map2');
|
||||
const loadMainPanes = useCallback(async () => {
|
||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked';
|
||||
const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex';
|
||||
const [l, r] = await Promise.all([
|
||||
GetUIPref('mainPaneLeft').catch(() => ''),
|
||||
GetUIPref('mainPaneRight').catch(() => ''),
|
||||
@@ -677,6 +679,12 @@ export default function App() {
|
||||
setMainPaneRight(valid(r) ? r : 'map2');
|
||||
}, []);
|
||||
useEffect(() => { loadMainPanes(); }, [loadMainPanes]);
|
||||
// Report the current entry-strip band/mode/freq to the backend so the live
|
||||
// operator status (multi-op) has band/mode/freq even when the CAT is off.
|
||||
useEffect(() => {
|
||||
const hz = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0;
|
||||
ReportLiveActivity(hz || 0, band || '', mode || '').catch(() => {});
|
||||
}, [band, mode, freqMhz]);
|
||||
// Cluster filter sidebar visibility — shared by the Cluster tab and the
|
||||
// Main-view cluster pane (portable UI pref). Hiding it keeps the filters
|
||||
// active, it just reclaims the width.
|
||||
@@ -693,8 +701,10 @@ export default function App() {
|
||||
|
||||
// === Modals ===
|
||||
const [editingQSO, setEditingQSO] = useState<QSO | null>(null);
|
||||
const [deletingQSO, setDeletingQSO] = useState<QSO | null>(null);
|
||||
// QSOs queued for the delete confirm (1 or many — multi-row selection).
|
||||
const [deletingIds, setDeletingIds] = useState<number[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
// Re-read the "beam on map" toggle when Preferences closes (it's edited there).
|
||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||
@@ -809,6 +819,17 @@ export default function App() {
|
||||
return [base];
|
||||
}, [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth, ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||
|
||||
// Mechanical boom (rotor) heading + Ultrabeam pattern — so the compass/map can
|
||||
// show where the antenna physically points (boom) vs where it radiates when
|
||||
// the Ultrabeam is reversed/bidirectional.
|
||||
const boomHeading = useMemo<number | null>(() => (
|
||||
rotatorHeading.enabled && rotatorHeading.ok ? ((rotatorHeading.azimuth % 360) + 360) % 360 : null
|
||||
), [rotatorHeading.enabled, rotatorHeading.ok, rotatorHeading.azimuth]);
|
||||
const ubPattern = useMemo<'normal' | 'reverse' | 'bi' | null>(() => {
|
||||
if (!(ubStatus.enabled && ubStatus.connected)) return null;
|
||||
return ubStatus.direction === 1 ? 'reverse' : ubStatus.direction === 2 ? 'bi' : 'normal';
|
||||
}, [ubStatus.enabled, ubStatus.connected, ubStatus.direction]);
|
||||
|
||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||
@@ -1565,8 +1586,8 @@ export default function App() {
|
||||
} catch (err: any) { setError(String(err?.message ?? err)); }
|
||||
}
|
||||
function onModalDelete(id: number) {
|
||||
const q = editingQSO; setEditingQSO(null);
|
||||
if (q) setDeletingQSO(q); else askDelete(id);
|
||||
setEditingQSO(null);
|
||||
setDeletingIds([id]);
|
||||
}
|
||||
|
||||
// Bulk grid actions (right-click menu). Recompute country/zones from
|
||||
@@ -1635,20 +1656,25 @@ export default function App() {
|
||||
showToast(`Exported ${r.count} selected QSO${r.count > 1 ? 's' : ''} → ${r.path}`);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
function askDelete(id: number) {
|
||||
const q = qsos.find((x) => x.id === id);
|
||||
if (q) setDeletingQSO(q);
|
||||
function askDelete(id: number) { setDeletingIds([id]); }
|
||||
// Delete the whole multi-row selection (Edit menu / Delete key).
|
||||
function askDeleteSelected() {
|
||||
if (selectedIds.length > 0) setDeletingIds(selectedIds);
|
||||
else if (selectedId != null) setDeletingIds([selectedId]);
|
||||
}
|
||||
async function confirmDelete() {
|
||||
if (!deletingQSO) return;
|
||||
if (deletingIds.length === 0) return;
|
||||
const ids = deletingIds;
|
||||
try {
|
||||
await DeleteQSO(deletingQSO.id);
|
||||
if (selectedId === deletingQSO.id) setSelectedId(null);
|
||||
setDeletingQSO(null);
|
||||
if (ids.length === 1) await DeleteQSO(ids[0]);
|
||||
else await DeleteQSOs(ids as any);
|
||||
setDeletingIds([]);
|
||||
setSelectedId(null);
|
||||
setSelectedIds([]);
|
||||
await refresh();
|
||||
} catch (err: any) {
|
||||
setError(String(err?.message ?? err));
|
||||
setDeletingQSO(null);
|
||||
setDeletingIds([]);
|
||||
}
|
||||
}
|
||||
async function confirmDeleteAll() {
|
||||
@@ -1859,7 +1885,7 @@ export default function App() {
|
||||
]},
|
||||
{ name: 'edit', label: 'Edit', items: [
|
||||
{ type: 'item', label: 'Edit selected QSO…', action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
|
||||
{ type: 'item', label: 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
|
||||
{ type: 'item', label: selectedIds.length > 1 ? `Delete ${selectedIds.length} selected QSOs` : 'Delete selected QSO', action: 'edit.delete', shortcut: 'Del', disabled: selectedId === null },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: 'Preferences…', action: 'edit.prefs' },
|
||||
]},
|
||||
@@ -1882,7 +1908,7 @@ export default function App() {
|
||||
{ name: 'help', label: 'Help', items: [
|
||||
{ type: 'item', label: 'About OpsLog', action: 'help.about' },
|
||||
]},
|
||||
], [total, selectedId, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled]);
|
||||
|
||||
function handleMenu(action: string) {
|
||||
switch (action) {
|
||||
@@ -1892,7 +1918,7 @@ export default function App() {
|
||||
case 'view.refresh': refresh(); break;
|
||||
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
|
||||
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
|
||||
case 'edit.delete': if (selectedId !== null) askDelete(selectedId); break;
|
||||
case 'edit.delete': askDeleteSelected(); break;
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
@@ -1989,14 +2015,14 @@ export default function App() {
|
||||
}
|
||||
if (typing) return;
|
||||
if (selectedId !== null) {
|
||||
if (e.key === 'Delete') { e.preventDefault(); askDelete(selectedId); return; }
|
||||
if (e.key === 'Delete') { e.preventDefault(); askDeleteSelected(); return; }
|
||||
if (e.key === 'Enter') { e.preventDefault(); openEdit(selectedId); return; }
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, refresh]);
|
||||
}, [selectedId, selectedIds, refresh]);
|
||||
|
||||
// ── Entry-field blocks ─────────────────────────────────────────────────
|
||||
// Each field is defined once here, then composed into either the compact
|
||||
@@ -2051,6 +2077,14 @@ export default function App() {
|
||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} allowFreeText commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||
</div>
|
||||
);
|
||||
// DX country flag, shown large next to RST (moved here from the Country field).
|
||||
const flagBlock = flagURL(details.dxcc) ? (
|
||||
<div className="flex flex-col justify-end shrink-0">
|
||||
<img src={flagURL(details.dxcc)} alt={country} title={country}
|
||||
className="h-9 rounded-[3px] border border-border/60 shadow-sm"
|
||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
</div>
|
||||
) : null;
|
||||
// Deferred-entry date: only shown when the start time is locked (back-entering
|
||||
// a past QSO). Sets the DATE part of qsoStartedAt; the time field keeps the time.
|
||||
const dateBlock = locks.start ? (
|
||||
@@ -2173,13 +2207,7 @@ export default function App() {
|
||||
);
|
||||
const countryRow = (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="w-20 shrink-0 flex items-center gap-1.5">
|
||||
Country
|
||||
{flagURL(details.dxcc) && (
|
||||
<img src={flagURL(details.dxcc)} alt="" className="h-3 rounded-[2px] border border-border/50 shadow-sm mr-0.5"
|
||||
referrerPolicy="no-referrer" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
|
||||
)}
|
||||
</Label>
|
||||
<Label className="w-20 shrink-0">Country</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Combobox value={country} options={countries} placeholder="Country" onChange={(v) => { setCountry(v); markEdited('country'); }} />
|
||||
</div>
|
||||
@@ -2475,6 +2503,7 @@ export default function App() {
|
||||
fromLabel={station.callsign}
|
||||
toLabel={callsign}
|
||||
beamAzimuths={showBeamOnMap ? beamHeadings : []}
|
||||
boomAzimuth={showBeamOnMap && ubPattern && ubPattern !== 'normal' ? boomHeading : null}
|
||||
/>
|
||||
);
|
||||
case 'map2':
|
||||
@@ -2502,6 +2531,12 @@ export default function App() {
|
||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)} />
|
||||
</div>
|
||||
);
|
||||
case 'flex':
|
||||
return (
|
||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||
<FlexPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2908,6 +2943,7 @@ export default function App() {
|
||||
{callsignBlock}
|
||||
{rstTxBlock}
|
||||
{rstRxBlock}
|
||||
{flagBlock}
|
||||
<div className="ml-auto flex gap-2">
|
||||
{dateBlock}
|
||||
{startBlock}
|
||||
@@ -2986,6 +3022,8 @@ export default function App() {
|
||||
<RotorCompass
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
headings={beamHeadings}
|
||||
boomHeading={boomHeading}
|
||||
pattern={ubPattern}
|
||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||
@@ -3079,6 +3117,7 @@ export default function App() {
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
||||
QRZ.com page in the system browser. Styled like a trigger. */}
|
||||
<button
|
||||
@@ -3181,7 +3220,7 @@ export default function App() {
|
||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||
onExportSelected={exportSelectedADIF}
|
||||
onExportFiltered={exportFilteredADIF}
|
||||
onRowSelected={(id) => setSelectedId(id)}
|
||||
onRowSelected={(ids) => { setSelectedIds(ids); setSelectedId(ids[0] ?? null); }}
|
||||
/>
|
||||
<div className="px-3 py-1.5 border-t border-border/60 text-[11px] text-muted-foreground flex items-center justify-between gap-3 bg-muted/30">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -3382,6 +3421,14 @@ export default function App() {
|
||||
<AwardsPanel onEditQSO={openEdit} />
|
||||
</TabsContent>
|
||||
|
||||
{/* FlexRadio SmartSDR-style control panel — only present when the CAT
|
||||
backend is a FlexRadio. */}
|
||||
{catState.backend === 'flex' && (
|
||||
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
|
||||
<FlexPanel />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||
strips). Pick bands with the chips; each strip is clickable to
|
||||
tune the rig. */}
|
||||
@@ -3536,6 +3583,7 @@ export default function App() {
|
||||
onClose={() => { setShowSettings(false); setSettingsSection(undefined); }}
|
||||
onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }}
|
||||
onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }}
|
||||
flexAvailable={catState.backend === 'flex'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3551,16 +3599,21 @@ export default function App() {
|
||||
onOpenDesigner={() => setQslDesignerOpen(true)}
|
||||
/>
|
||||
|
||||
{deletingQSO && (
|
||||
{deletingIds.length > 0 && (() => {
|
||||
const single = deletingIds.length === 1 ? qsos.find((x) => x.id === deletingIds[0]) : null;
|
||||
return (
|
||||
<ConfirmDialog
|
||||
title="Delete QSO?"
|
||||
message={`This will permanently delete the QSO with ${deletingQSO.callsign} on ${fmtDateUTC(deletingQSO.qso_date)} (${deletingQSO.band} ${deletingQSO.mode}). This cannot be undone.`}
|
||||
title={deletingIds.length > 1 ? `Delete ${deletingIds.length} QSOs?` : 'Delete QSO?'}
|
||||
message={single
|
||||
? `This will permanently delete the QSO with ${single.callsign} on ${fmtDateUTC(single.qso_date)} (${single.band} ${single.mode}). This cannot be undone.`
|
||||
: `This will permanently delete ${deletingIds.length} selected QSO${deletingIds.length > 1 ? 's' : ''}. This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
danger
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setDeletingQSO(null)}
|
||||
onCancel={() => setDeletingIds([])}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
{showDeleteAll && (
|
||||
<ConfirmDialog
|
||||
title="Delete ALL QSOs?"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { 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 { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -76,6 +76,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
const [showMissing, setShowMissing] = useState(false);
|
||||
const [stats, setStats] = useState<AwardStats | null>(null);
|
||||
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.
|
||||
useEffect(() => {
|
||||
@@ -85,7 +88,24 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
|
||||
.then((s) => setStats(s as any))
|
||||
.catch(() => setStats(null))
|
||||
.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).
|
||||
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">
|
||||
<Pencil className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
|
||||
title="Rescan all QSOs and recompute this award">
|
||||
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
|
||||
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" />}
|
||||
Rescan
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, Zap, Mic2, Settings2, Power, AudioLines, Antenna, Flame, Gauge } from 'lucide-react';
|
||||
import {
|
||||
GetFlexState, FlexSetPower, FlexSetTunePower, FlexTune, FlexSetVox, FlexSetVoxLevel, FlexSetVoxDelay,
|
||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||
FlexMox, FlexATUStart, FlexATUBypass, FlexSetATUMemories, FlexAmpOperate,
|
||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel,
|
||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||
} 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;
|
||||
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,
|
||||
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>
|
||||
<div className="flex gap-[2px] h-2.5 items-stretch">
|
||||
{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-[1.5px] transition-colors duration-100"
|
||||
style={on ? { background: col, boxShadow: `0 0 3px ${col}66` } : { background: '#cfc6ad', opacity: 0.45 }} />
|
||||
);
|
||||
})}
|
||||
</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>>({});
|
||||
|
||||
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); };
|
||||
}, []);
|
||||
|
||||
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 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' }];
|
||||
|
||||
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>
|
||||
|
||||
<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>
|
||||
</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))} />
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ATU */}
|
||||
<Card icon={Settings2} title="Antenna Tuner">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button type="button" disabled={off} onClick={() => FlexATUStart().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-bold border border-emerald-400 text-emerald-700 hover:bg-emerald-50 disabled:opacity-30">
|
||||
<Antenna className="size-3.5 inline mr-1 -mt-0.5" /> Tune ATU
|
||||
</button>
|
||||
<button type="button" disabled={off} onClick={() => FlexATUBypass().catch(() => {})}
|
||||
className="px-3 py-1.5 rounded-md text-xs font-bold border border-border text-muted-foreground hover:bg-muted disabled:opacity-30">
|
||||
Bypass
|
||||
</button>
|
||||
<Chip on={st.atu_memories} disabled={off} label="MEM"
|
||||
onClick={() => change('atu_memories', !st.atu_memories, () => FlexSetATUMemories(!st.atu_memories))} />
|
||||
<div className="flex-1" />
|
||||
{st.atu_status && (
|
||||
<span className="text-xs font-mono text-muted-foreground">{st.atu_status.replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<p className="text-[11px] text-muted-foreground">Amplifier power / SWR / temperature appear in the Meters panel below (src AMP).</p>
|
||||
</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 alc = radio('ALC');
|
||||
const temp = radio('PATEMP');
|
||||
const volts = radio('13.8B') || meters.find((m) => /volts/i.test(m.unit || '') && !(m.src || '').toUpperCase().includes('AMP'));
|
||||
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 s = sUnit(sig.value); return (
|
||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${sig.value.toFixed(1)} dBm`}
|
||||
segColor={(fr) => { const sv = fr * 19; return sv < 9 ? '#16a34a' : sv < 14 ? '#f59e0b' : '#dc2626'; }} />
|
||||
); })(),
|
||||
fwd && <MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
||||
value={isDbm(fwd) ? dbmToW(fwd.value) : fwd.value} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />,
|
||||
swr && <MeterBar key="w" label="SWR" value={swr.value} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||
alc && <MeterBar key="a" label="ALC" value={alc.value} unit={alc.unit} lo={alc.lo} hi={alc.hi || 100} accent="#7c3aed" />,
|
||||
temp && <MeterBar key="t" label="PA TEMP" value={temp.value} unit={temp.unit} lo={temp.lo || 0} hi={temp.hi || 80} accent="#ea580c" />,
|
||||
volts && <MeterBar key="v" label="VOLTS" value={volts.value} unit={volts.unit} lo={volts.lo || 0} hi={volts.hi || 15} accent="#2563eb" />,
|
||||
].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={dbmToW(m.value)} unit="W" lo={0} hi={1500} accent="#dc2626" extra={`${m.value.toFixed(1)} dBm`} />;
|
||||
}
|
||||
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={m.value} unit={m.unit} lo={m.lo} hi={m.hi} accent={accentFor(m)} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,24 @@ const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const CARTO_ATTR = '© OpenStreetMap © CARTO';
|
||||
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 {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
@@ -60,15 +78,19 @@ interface WorldProps {
|
||||
toGrid: string; // contacted-station grid
|
||||
fromLabel?: string; // operator callsign
|
||||
toLabel?: string; // DX callsign
|
||||
beamAzimuths?: number[]; // antenna heading(s) (deg) → draw a beam lobe each
|
||||
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 }: WorldProps) {
|
||||
export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, beamWidth, boomAzimuth }: WorldProps) {
|
||||
const worldRef = useRef<HTMLDivElement>(null);
|
||||
const worldMap = useRef<L.Map | null>(null);
|
||||
const worldOverlay = 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
|
||||
// pans/zooms freely (e.g. a whole-world view) and the view is remembered
|
||||
@@ -82,7 +104,9 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
if (worldRef.current && !worldMap.current) {
|
||||
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
|
||||
.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);
|
||||
worldMap.current = m;
|
||||
const sv = loadMapView();
|
||||
@@ -93,6 +117,19 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
return () => window.clearTimeout(t);
|
||||
}, []);
|
||||
|
||||
// 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(() => {
|
||||
const m = worldMap.current;
|
||||
if (!m) 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;
|
||||
@@ -109,9 +146,11 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
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, 96);
|
||||
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.8 }).addTo(wo);
|
||||
{ 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) {
|
||||
@@ -138,14 +177,34 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
// 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: '#dc2626', weight: 6, opacity: 0.07 }).addTo(wo);
|
||||
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)]);
|
||||
L.polyline(cl as L.LatLngExpression[], { color: '#dc2626', weight: 1.5, opacity: 0.7, dashArray: '5 4' })
|
||||
// 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) {
|
||||
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
|
||||
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
|
||||
@@ -158,13 +217,29 @@ export function WorldMap({ fromGrid, toGrid, fromLabel, toLabel, beamAzimuths, b
|
||||
}
|
||||
setTimeout(() => { wm.invalidateSize(); }, 0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, autoZoom]);
|
||||
}, [fromGrid, toGrid, fromLabel, toLabel, (beamAzimuths ?? []).map((a) => Math.round(a)).join(','), beamWidth, boomAzimuth, autoZoom]);
|
||||
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
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 { EventsOn } from '../../wailsjs/runtime/runtime';
|
||||
|
||||
@@ -77,6 +77,14 @@ export function fmtQslDate(s?: string): string {
|
||||
// and download confirmations, while the rest of the app stays usable.
|
||||
export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
|
||||
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 [potaRes, setPotaRes] = useState<POTASync | null>(null);
|
||||
const [potaErr, setPotaErr] = useState('');
|
||||
@@ -251,6 +259,17 @@ export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => voi
|
||||
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</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' ? (
|
||||
<>
|
||||
<Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
|
||||
|
||||
@@ -46,7 +46,7 @@ type Props = {
|
||||
rows: QSOForm[];
|
||||
total: number;
|
||||
onRowDoubleClicked?: (q: QSOForm) => void;
|
||||
onRowSelected?: (id: number | null) => void;
|
||||
onRowSelected?: (ids: number[]) => void;
|
||||
onUpdateFromCty?: (ids: number[]) => void;
|
||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||
onUpdateFromClublog?: (ids: number[]) => void;
|
||||
@@ -277,8 +277,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpda
|
||||
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
|
||||
}
|
||||
function onSelectionChanged() {
|
||||
const sel = gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined;
|
||||
onRowSelected?.(sel && sel[0] ? (sel[0].id as number) : null);
|
||||
const sel = (gridRef.current?.api?.getSelectedRows() as QSOForm[] | undefined) ?? [];
|
||||
onRowSelected?.(sel.map((r) => r.id as number).filter((id) => id != null));
|
||||
}
|
||||
|
||||
// ── Column picker (visibility) ──
|
||||
|
||||
@@ -18,7 +18,9 @@ const GRATICULE = geoGraticule10();
|
||||
|
||||
interface Props {
|
||||
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)
|
||||
centerLon?: number | null; // operator longitude
|
||||
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)];
|
||||
}
|
||||
|
||||
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(
|
||||
() => [ { 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' } ],
|
||||
@@ -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')}
|
||||
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
|
||||
<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">
|
||||
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
|
||||
</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} />
|
||||
); })()}
|
||||
|
||||
{/* 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 (
|
||||
<g key={i}>
|
||||
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
GetDataDir,
|
||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
||||
GetQSLDefaults, SaveQSLDefaults,
|
||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
|
||||
GetPOTAToken, SavePOTAToken,
|
||||
@@ -138,6 +139,7 @@ interface Props {
|
||||
onClose: () => 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
|
||||
@@ -447,6 +449,25 @@ 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,
|
||||
@@ -457,11 +478,15 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'cluster', label: 'Cluster spots' },
|
||||
{ value: 'worked', label: 'Worked before' },
|
||||
];
|
||||
function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', value: string) => void }) {
|
||||
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) => MAIN_PANE_OPTIONS.some((o) => o.value === v);
|
||||
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); });
|
||||
}, []);
|
||||
@@ -482,7 +507,7 @@ function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', val
|
||||
<Select value={left} onValueChange={(v) => pick('left', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
@@ -491,7 +516,7 @@ function MainViewPanes({ onChanged }: { onChanged?: (side: 'left' | 'right', val
|
||||
<Select value={right} onValueChange={(v) => pick('right', v)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MAIN_PANE_OPTIONS.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
{options.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
@@ -550,7 +575,7 @@ function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged }: Props) {
|
||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) {
|
||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -724,14 +749,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
api_key: string; email: string; username: string; password: string; callsign: string;
|
||||
force_station_callsign: 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;
|
||||
};
|
||||
type ExternalServices = { qrz: ExtServiceCfg; clublog: ExtServiceCfg; lotw: ExtServiceCfg };
|
||||
const emptyExtCfg = (): ExtServiceCfg => ({
|
||||
api_key: '', email: '', username: '', password: '', callsign: '',
|
||||
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',
|
||||
});
|
||||
const [extSvc, setExtSvc] = useState<ExternalServices>({
|
||||
@@ -2867,17 +2892,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
placeholder="only if your certificate key has a password"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Label className="text-sm">Upload flag</Label>
|
||||
<Label className="text-sm">Consider as unsent</Label>
|
||||
<div>
|
||||
<Select value={lotw.upload_flag || 'R'} onValueChange={(v) => setLotw({ upload_flag: v })}>
|
||||
<SelectTrigger className="h-8 w-64"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="N">Upload when LoTW sent = No</SelectItem>
|
||||
<SelectItem value="R">Upload when LoTW sent = Requested</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-4">
|
||||
{(['N', 'R'] as const).map((f) => {
|
||||
const flags = lotw.upload_flags ?? [];
|
||||
const checked = flags.includes(f);
|
||||
return (
|
||||
<label key={f} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<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">
|
||||
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>
|
||||
@@ -3346,8 +3386,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
Check for updates at startup <span className="text-xs text-muted-foreground">(notifies when a newer OpsLog is published)</span>
|
||||
</label>
|
||||
<TelemetryToggle />
|
||||
<LiveStatusToggle />
|
||||
|
||||
<MainViewPanes onChanged={onMainPaneChanged} />
|
||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
|
||||
|
||||
@@ -23,6 +23,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
|
||||
'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:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Single source of truth for the app version shown in the UI (header + About).
|
||||
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||
export const APP_VERSION = '0.11.1';
|
||||
export const APP_VERSION = '0.11.3';
|
||||
|
||||
// Author / credits, shown in Help -> About.
|
||||
export const APP_AUTHOR = 'F4BPO';
|
||||
|
||||
Vendored
+64
@@ -87,6 +87,8 @@ export function DeleteProfile(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 DisconnectAllClusters():Promise<void>;
|
||||
@@ -115,6 +117,56 @@ export function FilterFields():Promise<Array<string>>;
|
||||
|
||||
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 FlexSetATUMemories(arg1:boolean):Promise<void>;
|
||||
|
||||
export function FlexSetAudioLevel(arg1: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 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 GetAudioSettings():Promise<main.AudioSettings>;
|
||||
@@ -163,8 +215,12 @@ export function GetEmailSettings():Promise<main.EmailSettings>;
|
||||
|
||||
export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||
|
||||
export function GetLogFilePath():Promise<string>;
|
||||
|
||||
export function GetLogbookRevision():Promise<string>;
|
||||
@@ -313,6 +369,10 @@ export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
|
||||
|
||||
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 ResetDatabaseToDefault():Promise<void>;
|
||||
@@ -395,6 +455,8 @@ export function SetCompactMode(arg1:boolean):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 SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||
@@ -439,6 +501,8 @@ export function UpdateQSOsFromCty(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 WinkeyerBackspace():Promise<void>;
|
||||
|
||||
@@ -146,6 +146,10 @@ export function DeleteQSO(arg1) {
|
||||
return window['go']['main']['App']['DeleteQSO'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteQSOs(arg1) {
|
||||
return window['go']['main']['App']['DeleteQSOs'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteUDPIntegration(arg1) {
|
||||
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
|
||||
}
|
||||
@@ -202,6 +206,106 @@ export function 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 FlexSetATUMemories(arg1) {
|
||||
return window['go']['main']['App']['FlexSetATUMemories'](arg1);
|
||||
}
|
||||
|
||||
export function FlexSetAudioLevel(arg1) {
|
||||
return window['go']['main']['App']['FlexSetAudioLevel'](arg1);
|
||||
}
|
||||
|
||||
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 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() {
|
||||
return window['go']['main']['App']['GetActiveProfile']();
|
||||
}
|
||||
@@ -298,10 +402,18 @@ export function GetExternalServices() {
|
||||
return window['go']['main']['App']['GetExternalServices']();
|
||||
}
|
||||
|
||||
export function GetFlexState() {
|
||||
return window['go']['main']['App']['GetFlexState']();
|
||||
}
|
||||
|
||||
export function GetListsSettings() {
|
||||
return window['go']['main']['App']['GetListsSettings']();
|
||||
}
|
||||
|
||||
export function GetLiveStatusEnabled() {
|
||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
||||
}
|
||||
|
||||
export function GetLogFilePath() {
|
||||
return window['go']['main']['App']['GetLogFilePath']();
|
||||
}
|
||||
@@ -598,6 +710,14 @@ export function 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() {
|
||||
return window['go']['main']['App']['ResetAwardDefs']();
|
||||
}
|
||||
@@ -762,6 +882,10 @@ export function 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) {
|
||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||
}
|
||||
@@ -850,6 +974,10 @@ export function 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) {
|
||||
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -385,6 +385,30 @@ export namespace awardref {
|
||||
|
||||
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 {
|
||||
ip: string;
|
||||
port: number;
|
||||
@@ -407,6 +431,96 @@ export namespace cat {
|
||||
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;
|
||||
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.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 RigState {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
@@ -559,7 +673,7 @@ export namespace extsvc {
|
||||
tqsl_path: string;
|
||||
station_location: string;
|
||||
key_password: string;
|
||||
upload_flag: string;
|
||||
upload_flags: string[];
|
||||
write_log: boolean;
|
||||
auto_upload: boolean;
|
||||
upload_mode: string;
|
||||
@@ -579,7 +693,7 @@ export namespace extsvc {
|
||||
this.tqsl_path = source["tqsl_path"];
|
||||
this.station_location = source["station_location"];
|
||||
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.auto_upload = source["auto_upload"];
|
||||
this.upload_mode = source["upload_mode"];
|
||||
|
||||
@@ -226,6 +226,115 @@ 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"`
|
||||
// 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
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
|
||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||
func (m *Manager) exec(fn func(Backend) error) error {
|
||||
|
||||
+776
-5
@@ -4,6 +4,7 @@ package cat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
@@ -33,12 +34,27 @@ type Flex struct {
|
||||
gotHandle bool
|
||||
|
||||
slices map[int]*flexSlice
|
||||
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
|
||||
amp flexAmp // external amplifier (PowerGenius XL) state
|
||||
txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly)
|
||||
lastStateSig string // last logged derived-state signature (log only on change)
|
||||
|
||||
// Live meters streamed over UDP (VITA-49). meterMeta is the definitions
|
||||
// pushed over TCP; meterVal the latest scaled values keyed by meter id.
|
||||
udpConn *net.UDPConn
|
||||
meterMeta map[int]meterInfo
|
||||
meterVal map[int]float64
|
||||
meterSub map[int]bool // ids we've already sent "sub meter <id>" for
|
||||
meterLogAt time.Time // throttle for value logging
|
||||
vitaSeen int // count of UDP datagrams (first few logged for diag)
|
||||
meterRawLogged bool // log the first raw meter-definition status once
|
||||
txRawLogged bool // log the first raw transmit status once (field-name audit)
|
||||
|
||||
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
||||
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
||||
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
||||
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
||||
sentCmds map[int]string // seq → command text, so an R<seq> error names the command
|
||||
|
||||
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
||||
// clicks one of our spots on the panadapter, with the spot's callsign and
|
||||
@@ -52,6 +68,54 @@ type flexSlice struct {
|
||||
active bool
|
||||
tx bool
|
||||
inUse bool
|
||||
// RX DSP controls (SmartSDR slice object).
|
||||
agcMode string // off | slow | med | fast
|
||||
agcThreshold int // 0-100
|
||||
audioLevel int // 0-100 (RX volume)
|
||||
nb bool // noise blanker
|
||||
nbLevel int
|
||||
nr bool // noise reduction
|
||||
nrLevel int
|
||||
anf bool // auto notch filter
|
||||
anfLevel int
|
||||
}
|
||||
|
||||
// flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style
|
||||
// controls). Populated from status pushes in handleStatus; read by FlexState().
|
||||
type flexTX struct {
|
||||
rfPower int
|
||||
tunePower int
|
||||
tune bool
|
||||
transmitting bool // interlock state == TRANSMITTING
|
||||
voxEnable bool
|
||||
voxLevel int
|
||||
voxDelay int
|
||||
procEnable bool
|
||||
procLevel int
|
||||
mon bool
|
||||
monLevel int
|
||||
micLevel int
|
||||
atuStatus string
|
||||
atuMemories bool
|
||||
}
|
||||
|
||||
// flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the
|
||||
// hex id used to address SET commands; operate=true means OPERATE (vs STANDBY).
|
||||
type flexAmp struct {
|
||||
handle string
|
||||
model string
|
||||
operate bool
|
||||
fault string
|
||||
}
|
||||
|
||||
// meterInfo is a meter definition pushed by the radio over TCP. unit drives the
|
||||
// raw-int16 → real-value scaling; src/name identify what it measures.
|
||||
type meterInfo struct {
|
||||
src string // SLC (slice), TX-, COD, RAD, AMP…
|
||||
name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B…
|
||||
unit string // dbm, dbfs, swr, volts, degc, watts…
|
||||
lo float64
|
||||
hi float64
|
||||
}
|
||||
|
||||
// flexTriggerRe matches the radio's "spot <index> triggered" notification, sent
|
||||
@@ -69,6 +133,8 @@ func NewFlex(host string, port int, spotsEnabled bool) *Flex {
|
||||
host: strings.TrimSpace(host), port: port,
|
||||
slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled,
|
||||
spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{},
|
||||
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
|
||||
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,16 +162,21 @@ func (f *Flex) Connect() error {
|
||||
f.conn = conn
|
||||
f.gotHandle = false
|
||||
f.slices = map[int]*flexSlice{}
|
||||
f.meterVal = map[int]float64{}
|
||||
f.meterSub = map[int]bool{}
|
||||
f.mu.Unlock()
|
||||
debugLog.Printf("Flex: connected to %s:%d", host, port)
|
||||
|
||||
go f.reader(conn)
|
||||
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
|
||||
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
|
||||
f.send("client program=OpsLog")
|
||||
f.send("sub slice all")
|
||||
f.send("sub transmit all")
|
||||
f.send("sub radio all")
|
||||
f.send("client program OpsLog")
|
||||
f.send("sub slice all") // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio…
|
||||
f.send("sub tx all") // transmit: rfpower, tunepower, vox, processor, mon, mic
|
||||
f.send("sub atu all") // antenna-tuner status + memories
|
||||
f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby
|
||||
f.send("sub radio all") // radio-wide incl. interlock (TX/RX state)
|
||||
f.startMeters(conn) // open the UDP VITA-49 stream for live meters
|
||||
if f.spotsEnabled {
|
||||
// Subscribe so the radio pushes existing spots (we learn their indices),
|
||||
// then wipe the panadapter so stale spots from a previous session or
|
||||
@@ -119,9 +190,14 @@ func (f *Flex) Connect() error {
|
||||
func (f *Flex) Disconnect() {
|
||||
f.mu.Lock()
|
||||
c := f.conn
|
||||
uc := f.udpConn
|
||||
f.conn = nil
|
||||
f.udpConn = nil
|
||||
f.gotHandle = false
|
||||
f.mu.Unlock()
|
||||
if uc != nil {
|
||||
_ = uc.Close() // unblocks udpReader
|
||||
}
|
||||
if c != nil {
|
||||
_ = c.Close()
|
||||
debugLog.Printf("Flex: disconnected")
|
||||
@@ -136,6 +212,9 @@ func (f *Flex) send(cmd string) int {
|
||||
c := f.conn
|
||||
f.seq++
|
||||
seq := f.seq
|
||||
if f.sentCmds != nil {
|
||||
f.sentCmds[seq] = cmd
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if c == nil {
|
||||
return 0
|
||||
@@ -196,8 +275,12 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
}
|
||||
seq, _ := strconv.Atoi(parts[0])
|
||||
ok := parts[1] == "0" || parts[1] == "00000000"
|
||||
f.mu.Lock()
|
||||
cmdText := f.sentCmds[seq]
|
||||
delete(f.sentCmds, seq)
|
||||
f.mu.Unlock()
|
||||
if !ok {
|
||||
debugLog.Printf("Flex: cmd error %s", line)
|
||||
debugLog.Printf("Flex: cmd error R%d code=%s cmd=%q", seq, parts[1], cmdText)
|
||||
}
|
||||
// A successful "spot add" returns the new spot's index in the message;
|
||||
// pair it with the callsign we stashed under this seq.
|
||||
@@ -239,8 +322,190 @@ func (f *Flex) handleStatus(payload string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Transmit object — RF/tune power, VOX, speech processor, monitor, mic,
|
||||
// tune carrier. Field names per the SmartSDR API (logged so the exact set
|
||||
// is auditable against a real radio).
|
||||
if len(fields) >= 1 && fields[0] == "transmit" {
|
||||
if !f.txRawLogged {
|
||||
f.txRawLogged = true
|
||||
debugLog.Printf("Flex: FIRST transmit status: %s", payload)
|
||||
}
|
||||
debugLog.Printf("Flex: status %s", payload)
|
||||
f.mu.Lock()
|
||||
for _, kv := range fields[1:] {
|
||||
key, val, ok := splitKV(kv)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// Ignore the radio's echo of a field we just set ourselves (it
|
||||
// often re-pushes the OLD value for ~1s, which snapped the slider
|
||||
// back). Our optimistic value stands until the guard expires.
|
||||
if t, ok := f.txSetAt[key]; ok && time.Since(t) < 1200*time.Millisecond {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "rfpower":
|
||||
f.tx.rfPower = atoiDefault(val, f.tx.rfPower)
|
||||
case "tunepower":
|
||||
f.tx.tunePower = atoiDefault(val, f.tx.tunePower)
|
||||
case "tune":
|
||||
f.tx.tune = val == "1"
|
||||
case "vox_enable":
|
||||
f.tx.voxEnable = val == "1"
|
||||
case "vox_level":
|
||||
f.tx.voxLevel = atoiDefault(val, f.tx.voxLevel)
|
||||
case "vox_delay":
|
||||
f.tx.voxDelay = atoiDefault(val, f.tx.voxDelay)
|
||||
case "speech_processor_enable":
|
||||
f.tx.procEnable = val == "1"
|
||||
case "speech_processor_level":
|
||||
f.tx.procLevel = atoiDefault(val, f.tx.procLevel)
|
||||
case "mon", "sb_monitor":
|
||||
f.tx.mon = val == "1"
|
||||
case "mon_gain_sb":
|
||||
f.tx.monLevel = atoiDefault(val, f.tx.monLevel)
|
||||
case "mic_level", "miclevel":
|
||||
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
// ATU object — auto-tuner status + memories.
|
||||
if len(fields) >= 1 && fields[0] == "atu" {
|
||||
debugLog.Printf("Flex: status %s", payload)
|
||||
f.mu.Lock()
|
||||
for _, kv := range fields[1:] {
|
||||
key, val, ok := splitKV(kv)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "status":
|
||||
f.tx.atuStatus = val
|
||||
case "memories_enabled":
|
||||
f.tx.atuMemories = val == "1"
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
// Interlock object — transmit state (RECEIVE / TRANSMITTING / …).
|
||||
if len(fields) >= 1 && fields[0] == "interlock" {
|
||||
f.mu.Lock()
|
||||
for _, kv := range fields[1:] {
|
||||
if key, val, ok := splitKV(kv); ok && key == "state" {
|
||||
f.tx.transmitting = strings.EqualFold(val, "TRANSMITTING")
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
// Amplifier object — "amplifier <handle> model=… operate=… …" (PowerGenius
|
||||
// XL). The handle (hex) addresses the operate/standby SET command.
|
||||
if len(fields) >= 2 && fields[0] == "amplifier" {
|
||||
debugLog.Printf("Flex: status %s", payload)
|
||||
f.mu.Lock()
|
||||
if strings.HasPrefix(fields[1], "0x") {
|
||||
f.amp.handle = fields[1]
|
||||
}
|
||||
removed := false
|
||||
for _, kv := range fields[2:] {
|
||||
if kv == "removed" || kv == "in_use=0" {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
key, val, ok := splitKV(kv)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "handle":
|
||||
f.amp.handle = val
|
||||
case "model":
|
||||
f.amp.model = val
|
||||
case "operate":
|
||||
f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE")
|
||||
case "mode":
|
||||
f.amp.operate = strings.EqualFold(val, "OPERATE")
|
||||
case "fault":
|
||||
f.amp.fault = val
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
f.amp = flexAmp{}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
// Meter definitions — "meter <num>.src=… <num>.nam=… <num>.unit=… …".
|
||||
// The unit scales the UDP values, the name labels them; subscribe to each
|
||||
// new id so the radio streams it.
|
||||
if len(fields) >= 2 && fields[0] == "meter" {
|
||||
if !f.meterRawLogged {
|
||||
f.meterRawLogged = true
|
||||
debugLog.Printf("Flex: meter status raw: %s", payload)
|
||||
}
|
||||
var newIDs []int
|
||||
f.mu.Lock()
|
||||
for _, tok := range fields[1:] {
|
||||
// One meter per token; its fields are '#'-separated:
|
||||
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
|
||||
num := -1
|
||||
var mi meterInfo
|
||||
for _, sub := range strings.Split(tok, "#") {
|
||||
key, val, ok := splitKV(sub)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dot := strings.IndexByte(key, '.')
|
||||
if dot <= 0 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(key[:dot])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
num = n
|
||||
switch key[dot+1:] {
|
||||
case "src":
|
||||
mi.src = val
|
||||
case "nam":
|
||||
mi.name = val
|
||||
case "unit", "units":
|
||||
mi.unit = val
|
||||
case "low", "lo":
|
||||
mi.lo = parseFloatDefault(val, mi.lo)
|
||||
case "hi":
|
||||
mi.hi = parseFloatDefault(val, mi.hi)
|
||||
}
|
||||
}
|
||||
if num < 0 {
|
||||
continue
|
||||
}
|
||||
old, seen := f.meterMeta[num]
|
||||
if !seen {
|
||||
newIDs = append(newIDs, num)
|
||||
}
|
||||
if mi.src != "" {
|
||||
old.src = mi.src
|
||||
}
|
||||
if mi.name != "" {
|
||||
old.name = mi.name
|
||||
}
|
||||
if mi.unit != "" {
|
||||
old.unit = mi.unit
|
||||
}
|
||||
if mi.lo != 0 {
|
||||
old.lo = mi.lo
|
||||
}
|
||||
if mi.hi != 0 {
|
||||
old.hi = mi.hi
|
||||
}
|
||||
f.meterMeta[num] = old
|
||||
}
|
||||
f.mu.Unlock()
|
||||
for _, id := range newIDs {
|
||||
mi := f.meterMeta[id]
|
||||
debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit)
|
||||
f.subscribeMeter(id)
|
||||
}
|
||||
}
|
||||
// Spot status: "spot <index> …". Track the index so we can clear the
|
||||
// panadapter, and log it verbatim — a click on a panadapter spot pushes a
|
||||
@@ -299,6 +564,24 @@ func (f *Flex) handleStatus(payload string) {
|
||||
s.tx = val == "1"
|
||||
case "in_use":
|
||||
s.inUse = val == "1"
|
||||
case "agc_mode":
|
||||
s.agcMode = val
|
||||
case "agc_threshold":
|
||||
s.agcThreshold = atoiDefault(val, s.agcThreshold)
|
||||
case "audio_level":
|
||||
s.audioLevel = atoiDefault(val, s.audioLevel)
|
||||
case "nb":
|
||||
s.nb = val == "1"
|
||||
case "nb_level":
|
||||
s.nbLevel = atoiDefault(val, s.nbLevel)
|
||||
case "nr":
|
||||
s.nr = val == "1"
|
||||
case "nr_level":
|
||||
s.nrLevel = atoiDefault(val, s.nrLevel)
|
||||
case "anf":
|
||||
s.anf = val == "1"
|
||||
case "anf_level":
|
||||
s.anfLevel = atoiDefault(val, s.anfLevel)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
@@ -547,6 +830,494 @@ func (f *Flex) SetPTT(on bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitKV splits a "key=value" token. ok is false when there's no '='.
|
||||
func splitKV(kv string) (key, val string, ok bool) {
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq <= 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return kv[:eq], kv[eq+1:], true
|
||||
}
|
||||
|
||||
// atoiDefault parses an int (or a float like "20.0", truncated), else def.
|
||||
func atoiDefault(s string, def int) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
return n
|
||||
}
|
||||
if fl, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
return int(fl)
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func clampLevel(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 100 {
|
||||
return 100
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// rxSliceLocked returns the active RX slice and its index (-1 when none), using
|
||||
// the same RX-selection rule as ReadState. Caller holds f.mu.
|
||||
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
|
||||
rx, _ := f.pickSlicesLocked()
|
||||
if rx == nil {
|
||||
return -1, nil
|
||||
}
|
||||
for i, s := range f.slices {
|
||||
if s == rx {
|
||||
return i, rx
|
||||
}
|
||||
}
|
||||
return -1, rx
|
||||
}
|
||||
|
||||
// FlexState returns a snapshot of the radio's transmit/ATU state plus the active
|
||||
// RX slice's DSP controls, for the FlexRadio control tab. Available is true once
|
||||
// the handshake has completed.
|
||||
func (f *Flex) FlexState() FlexTXState {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
st := FlexTXState{
|
||||
Available: f.gotHandle && f.conn != nil,
|
||||
Model: f.model,
|
||||
RFPower: f.tx.rfPower,
|
||||
TunePower: f.tx.tunePower,
|
||||
Tune: f.tx.tune,
|
||||
Transmitting: f.tx.transmitting,
|
||||
VoxEnable: f.tx.voxEnable,
|
||||
VoxLevel: f.tx.voxLevel,
|
||||
VoxDelay: f.tx.voxDelay,
|
||||
ProcEnable: f.tx.procEnable,
|
||||
ProcLevel: f.tx.procLevel,
|
||||
Mon: f.tx.mon,
|
||||
MonLevel: f.tx.monLevel,
|
||||
MicLevel: f.tx.micLevel,
|
||||
ATUStatus: f.tx.atuStatus,
|
||||
ATUMemories: f.tx.atuMemories,
|
||||
}
|
||||
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||
st.RXAvail = true
|
||||
st.AGCMode = rx.agcMode
|
||||
st.AGCThreshold = rx.agcThreshold
|
||||
st.AudioLevel = rx.audioLevel
|
||||
st.NB = rx.nb
|
||||
st.NBLevel = rx.nbLevel
|
||||
st.NR = rx.nr
|
||||
st.NRLevel = rx.nrLevel
|
||||
st.ANF = rx.anf
|
||||
st.ANFLevel = rx.anfLevel
|
||||
}
|
||||
if f.amp.handle != "" {
|
||||
st.AmpAvailable = true
|
||||
st.AmpModel = f.amp.model
|
||||
st.AmpOperate = f.amp.operate
|
||||
st.AmpFault = f.amp.fault
|
||||
}
|
||||
if len(f.meterVal) > 0 {
|
||||
ids := make([]int, 0, len(f.meterVal))
|
||||
for id := range f.meterVal {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll
|
||||
for _, id := range ids {
|
||||
mi := f.meterMeta[id]
|
||||
st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// sendSlice sends a "slice s <rxIdx> <param>=<val>" to the active RX slice, and
|
||||
// optimistically updates our cached slice state — the radio doesn't reliably
|
||||
// echo every field back to the client that changed it (e.g. agc_mode), so
|
||||
// without this the UI would snap back to the stale value.
|
||||
func (f *Flex) sendSlice(param string, val any) error {
|
||||
f.mu.Lock()
|
||||
idx, rx := f.rxSliceLocked()
|
||||
connected := f.conn != nil
|
||||
if rx != nil {
|
||||
switch param {
|
||||
case "agc_mode":
|
||||
rx.agcMode = fmt.Sprint(val)
|
||||
case "agc_threshold":
|
||||
rx.agcThreshold = toInt(val)
|
||||
case "audio_level":
|
||||
rx.audioLevel = toInt(val)
|
||||
case "nb":
|
||||
rx.nb = val == "1"
|
||||
case "nb_level":
|
||||
rx.nbLevel = toInt(val)
|
||||
case "nr":
|
||||
rx.nr = val == "1"
|
||||
case "nr_level":
|
||||
rx.nrLevel = toInt(val)
|
||||
case "anf":
|
||||
rx.anf = val == "1"
|
||||
case "anf_level":
|
||||
rx.anfLevel = toInt(val)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if rx == nil || idx < 0 {
|
||||
return fmt.Errorf("flex: no receive slice")
|
||||
}
|
||||
f.send(fmt.Sprintf("slice s %d %s=%v", idx, param, val))
|
||||
return nil
|
||||
}
|
||||
|
||||
// toInt coerces an int or numeric string to int (for the optimistic cache).
|
||||
func toInt(v any) int {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t
|
||||
case string:
|
||||
return atoiDefault(t, 0)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (f *Flex) SetAGCMode(m string) error {
|
||||
switch m {
|
||||
case "off", "slow", "med", "fast":
|
||||
default:
|
||||
return fmt.Errorf("flex: invalid agc mode %q", m)
|
||||
}
|
||||
return f.sendSlice("agc_mode", m)
|
||||
}
|
||||
func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) }
|
||||
func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) }
|
||||
func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) }
|
||||
func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) }
|
||||
func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) }
|
||||
func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) }
|
||||
func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) }
|
||||
func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) }
|
||||
|
||||
// connected reports whether the TCP link is up (commands are no-ops otherwise).
|
||||
func (f *Flex) connected() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.conn != nil
|
||||
}
|
||||
|
||||
// --- FlexController controls (SmartSDR transmit object). ---
|
||||
//
|
||||
// txSet sends a command AND optimistically updates our cached transmit state.
|
||||
// The radio doesn't reliably echo a changed field back to the client that set
|
||||
// it, so without the optimistic update the UI would snap back to the stale
|
||||
// cached value (a real echo, e.g. a change from SmartSDR, still overrides it).
|
||||
// txSet sends a command, optimistically updates our cached transmit state, and
|
||||
// records `field` (the STATUS field name) so the radio's lagging echo of the old
|
||||
// value is ignored for a moment (see handleStatus) — otherwise the slider snaps
|
||||
// back. `field` may be "" for non-guarded commands.
|
||||
func (f *Flex) txSet(cmd, field string, apply func(*flexTX)) error {
|
||||
f.mu.Lock()
|
||||
connected := f.conn != nil
|
||||
if connected && apply != nil {
|
||||
apply(&f.tx)
|
||||
if field != "" {
|
||||
f.txSetAt[field] = time.Now()
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
f.send(cmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) SetRFPower(p int) error {
|
||||
p = clampLevel(p)
|
||||
return f.txSet(fmt.Sprintf("transmit set rfpower=%d", p), "rfpower", func(t *flexTX) { t.rfPower = p })
|
||||
}
|
||||
|
||||
func (f *Flex) SetTunePower(p int) error {
|
||||
p = clampLevel(p)
|
||||
return f.txSet(fmt.Sprintf("transmit set tunepower=%d", p), "tunepower", func(t *flexTX) { t.tunePower = p })
|
||||
}
|
||||
|
||||
func (f *Flex) SetTune(on bool) error {
|
||||
cmd := "transmit tune off"
|
||||
if on {
|
||||
cmd = "transmit tune on"
|
||||
}
|
||||
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
||||
}
|
||||
|
||||
func (f *Flex) SetVOX(on bool) error {
|
||||
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
||||
}
|
||||
|
||||
func (f *Flex) SetVOXLevel(l int) error {
|
||||
l = clampLevel(l)
|
||||
return f.txSet(fmt.Sprintf("transmit set vox_level=%d", l), "vox_level", func(t *flexTX) { t.voxLevel = l })
|
||||
}
|
||||
|
||||
// SetVOXDelay sets the VOX hang time (0-100, a percentage scale in SmartSDR).
|
||||
func (f *Flex) SetVOXDelay(l int) error {
|
||||
l = clampLevel(l)
|
||||
return f.txSet(fmt.Sprintf("transmit set vox_delay=%d", l), "vox_delay", func(t *flexTX) { t.voxDelay = l })
|
||||
}
|
||||
|
||||
func (f *Flex) SetProcessor(on bool) error {
|
||||
return f.txSet("transmit set speech_processor_enable="+boolFlex(on), "speech_processor_enable", func(t *flexTX) { t.procEnable = on })
|
||||
}
|
||||
|
||||
// SetProcessorLevel sets the speech-processor preset: 0=NOR, 1=DX, 2=DX+ (NOT a
|
||||
// 0-100 level — per the SmartSDR transmit API).
|
||||
func (f *Flex) SetProcessorLevel(l int) error {
|
||||
if l < 0 {
|
||||
l = 0
|
||||
}
|
||||
if l > 2 {
|
||||
l = 2
|
||||
}
|
||||
return f.txSet(fmt.Sprintf("transmit set speech_processor_level=%d", l), "speech_processor_level", func(t *flexTX) { t.procLevel = l })
|
||||
}
|
||||
|
||||
func (f *Flex) SetMon(on bool) error {
|
||||
return f.txSet("transmit set mon="+boolFlex(on), "mon", func(t *flexTX) { t.mon = on })
|
||||
}
|
||||
|
||||
func (f *Flex) SetMonLevel(l int) error {
|
||||
l = clampLevel(l)
|
||||
return f.txSet(fmt.Sprintf("transmit set mon_gain_sb=%d", l), "mon_gain_sb", func(t *flexTX) { t.monLevel = l })
|
||||
}
|
||||
|
||||
// SetMic sets the mic gain. The SET token is "miclevel" (one word) even though
|
||||
// the radio reports it back as "mic_level" in the transmit status.
|
||||
func (f *Flex) SetMic(l int) error {
|
||||
l = clampLevel(l)
|
||||
return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l })
|
||||
}
|
||||
|
||||
func (f *Flex) ATUStart() error {
|
||||
if !f.connected() {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
f.send("atu start")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) ATUBypass() error {
|
||||
if !f.connected() {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
f.send("atu bypass")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Flex) SetATUMemories(on bool) error {
|
||||
return f.txSet("atu set memories_enabled="+boolFlex(on), "", func(t *flexTX) { t.atuMemories = on })
|
||||
}
|
||||
|
||||
// SetAmpOperate switches the external amplifier between OPERATE (on=true) and
|
||||
// STANDBY. Needs the amplifier handle learned from its status push.
|
||||
func (f *Flex) SetAmpOperate(on bool) error {
|
||||
f.mu.Lock()
|
||||
handle := f.amp.handle
|
||||
connected := f.conn != nil
|
||||
if handle != "" {
|
||||
f.amp.operate = on // optimistic (radio may not echo to us)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if !connected {
|
||||
return fmt.Errorf("flex: not connected")
|
||||
}
|
||||
if handle == "" {
|
||||
return fmt.Errorf("flex: no amplifier detected")
|
||||
}
|
||||
f.send(fmt.Sprintf("amplifier set %s operate=%s", handle, boolFlex(on)))
|
||||
return nil
|
||||
}
|
||||
|
||||
func boolFlex(b bool) string {
|
||||
if b {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// --- Live meters over UDP (VITA-49) ---
|
||||
|
||||
// flexMeterClass is the VITA-49 packet class code FlexRadio uses for meter
|
||||
// extension packets. The payload is 32-bit words: upper 16 bits = meter id,
|
||||
// lower 16 bits = signed value (scaled per the meter's unit).
|
||||
const flexMeterClass = 0x8002
|
||||
|
||||
// startMeters opens a UDP socket for the radio's VITA-49 realtime stream (sent
|
||||
// from the radio's :4991), tells the radio which local port to stream to, and
|
||||
// starts the reader. The socket is DIALED to radio:4991 and we send a "punch"
|
||||
// datagram + periodic keepalives so Windows Firewall accepts the inbound stream
|
||||
// (an unsolicited inbound UDP to our ephemeral port would otherwise be dropped).
|
||||
func (f *Flex) startMeters(conn net.Conn) {
|
||||
raddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(f.host, "4991"))
|
||||
if err != nil {
|
||||
debugLog.Printf("Flex: meters resolve %s:4991: %v", f.host, err)
|
||||
return
|
||||
}
|
||||
// Unconnected socket: accept the stream from ANY source (the radio's source
|
||||
// port can change across NAT), while we still punch/keepalive toward :4991.
|
||||
uc, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
|
||||
if err != nil {
|
||||
debugLog.Printf("Flex: meters UDP listen failed: %v", err)
|
||||
return
|
||||
}
|
||||
port := uc.LocalAddr().(*net.UDPAddr).Port
|
||||
f.mu.Lock()
|
||||
f.udpConn = uc
|
||||
f.vitaSeen = 0
|
||||
f.mu.Unlock()
|
||||
f.send(fmt.Sprintf("client udpport %d", port)) // route VITA-49 to our port
|
||||
f.send("sub meter all") // stream all meter values
|
||||
_, _ = uc.WriteToUDP([]byte{0}, raddr) // firewall/NAT punch
|
||||
debugLog.Printf("Flex: meters UDP local=:%d punch→%s", port, raddr)
|
||||
go f.udpReader(uc)
|
||||
go f.udpKeepalive(uc, raddr)
|
||||
}
|
||||
|
||||
func (f *Flex) udpReader(uc *net.UDPConn) {
|
||||
buf := make([]byte, 16*1024)
|
||||
for {
|
||||
n, src, err := uc.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return // socket closed on disconnect
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.vitaSeen++
|
||||
seen := f.vitaSeen
|
||||
f.mu.Unlock()
|
||||
if seen <= 3 {
|
||||
debugLog.Printf("Flex: UDP datagram #%d %d bytes from %s", seen, n, src)
|
||||
}
|
||||
f.parseVita(buf[:n], seen)
|
||||
}
|
||||
}
|
||||
|
||||
// udpKeepalive keeps the firewall/NAT mapping open by pinging the radio's :4991.
|
||||
func (f *Flex) udpKeepalive(uc *net.UDPConn, raddr *net.UDPAddr) {
|
||||
t := time.NewTicker(10 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
f.mu.Lock()
|
||||
cur := f.udpConn
|
||||
f.mu.Unlock()
|
||||
if cur != uc {
|
||||
return
|
||||
}
|
||||
if _, err := uc.WriteToUDP([]byte{0}, raddr); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseVita decodes a VITA-49 datagram and, if it's a meter packet, updates the
|
||||
// cached meter values. Header flags are honoured so the payload offset is right.
|
||||
func (f *Flex) parseVita(p []byte, seen int) {
|
||||
if len(p) < 4 {
|
||||
return
|
||||
}
|
||||
w0 := binary.BigEndian.Uint32(p[0:4])
|
||||
off := 4
|
||||
pktType := (w0 >> 28) & 0xF
|
||||
hasClass := (w0>>27)&0x1 == 1
|
||||
tsi := (w0 >> 22) & 0x3
|
||||
tsf := (w0 >> 20) & 0x3
|
||||
if pktType == 0x1 || pktType == 0x3 { // packet types carrying a Stream ID
|
||||
off += 4
|
||||
}
|
||||
var packetClass uint16
|
||||
if hasClass {
|
||||
if off+8 > len(p) {
|
||||
return
|
||||
}
|
||||
packetClass = uint16(binary.BigEndian.Uint32(p[off+4 : off+8]))
|
||||
off += 8
|
||||
}
|
||||
if tsi != 0 {
|
||||
off += 4
|
||||
}
|
||||
if tsf != 0 {
|
||||
off += 8
|
||||
}
|
||||
// Diagnostics: log the first few datagrams's parsed header so we can confirm
|
||||
// the class code (in case 0x8002 / offsets differ on a real radio).
|
||||
if seen <= 3 {
|
||||
debugLog.Printf("Flex: VITA #%d len=%d type=%d class=0x%04x off=%d", seen, len(p), pktType, packetClass, off)
|
||||
}
|
||||
if packetClass != flexMeterClass || off > len(p) {
|
||||
return
|
||||
}
|
||||
payload := p[off:]
|
||||
f.mu.Lock()
|
||||
for i := 0; i+4 <= len(payload); i += 4 {
|
||||
id := int(binary.BigEndian.Uint16(payload[i : i+2]))
|
||||
raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4]))
|
||||
f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit)
|
||||
}
|
||||
if time.Since(f.meterLogAt) > 5*time.Second { // throttled dump to validate names
|
||||
f.meterLogAt = time.Now()
|
||||
var b strings.Builder
|
||||
for id, v := range f.meterVal {
|
||||
mi := f.meterMeta[id]
|
||||
fmt.Fprintf(&b, "%s=%.1f%s ", nonEmpty(mi.name, strconv.Itoa(id)), v, mi.unit)
|
||||
}
|
||||
debugLog.Printf("Flex: meters %s", strings.TrimSpace(b.String()))
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// scaleMeter converts the raw int16 to its real value per the meter's unit.
|
||||
func scaleMeter(raw int16, unit string) float64 {
|
||||
switch strings.ToUpper(unit) {
|
||||
case "DB", "DBM", "DBFS":
|
||||
return float64(raw) / 128.0
|
||||
case "VOLTS", "AMPS":
|
||||
return float64(raw) / 256.0
|
||||
case "DEGC", "DEGF", "TEMPC", "TEMPF":
|
||||
return float64(raw) / 64.0
|
||||
case "SWR":
|
||||
return float64(raw) / 128.0 // raw 128 = SWR 1.0 at idle
|
||||
default:
|
||||
return float64(raw)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeMeter asks the radio to stream a meter's values (once per id).
|
||||
func (f *Flex) subscribeMeter(id int) {
|
||||
f.mu.Lock()
|
||||
if f.meterSub[id] || f.conn == nil {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
f.meterSub[id] = true
|
||||
f.mu.Unlock()
|
||||
f.send(fmt.Sprintf("sub meter %d", id))
|
||||
}
|
||||
|
||||
func nonEmpty(s, def string) string {
|
||||
if s == "" {
|
||||
return def
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseFloatDefault(s string, def float64) float64 {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// flexModeToADIF maps a Flex slice mode to a generic ADIF mode.
|
||||
func flexModeToADIF(m string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(m)) {
|
||||
|
||||
@@ -69,7 +69,7 @@ type ServiceConfig struct {
|
||||
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
|
||||
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
|
||||
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
|
||||
AutoUpload bool `json:"auto_upload"`
|
||||
UploadMode UploadMode `json:"upload_mode"`
|
||||
@@ -84,13 +84,19 @@ func (c ServiceConfig) normalised() ServiceConfig {
|
||||
c.ForceStationCallsign = strings.ToUpper(strings.TrimSpace(c.ForceStationCallsign))
|
||||
c.TQSLPath = strings.TrimSpace(c.TQSLPath)
|
||||
c.StationLocation = strings.TrimSpace(c.StationLocation)
|
||||
// Upload flag is the LoTW sent-status that marks a QSO ready to upload.
|
||||
// Only "N" (no) and "R" (requested) are valid; default to "R".
|
||||
if uf := strings.ToUpper(strings.TrimSpace(c.UploadFlag)); uf == "N" || uf == "R" {
|
||||
c.UploadFlag = uf
|
||||
} else {
|
||||
c.UploadFlag = "R"
|
||||
// Upload flags are the LoTW sent-statuses that mark a QSO ready to upload.
|
||||
// Only "N" (no) and "R" (requested) are valid; clean + dedupe, no default
|
||||
// here (the caller injects N+R when nothing is configured).
|
||||
var flags []string
|
||||
seen := map[string]bool{}
|
||||
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 {
|
||||
case ModeDelayed, ModeOnClose:
|
||||
// keep
|
||||
|
||||
@@ -21,8 +21,11 @@ const lotwReportURL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||
// DownloadLoTWConfirmations fetches confirmed QSOs from LoTW as ADIF text.
|
||||
// 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
|
||||
// returned — used for incremental "Last download" updates.
|
||||
func DownloadLoTWConfirmations(ctx context.Context, client *http.Client, cfg ServiceConfig, since string) (string, error) {
|
||||
// returned — used for incremental "Last download" updates. When ownCall is
|
||||
// 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)
|
||||
if user == "" || cfg.Password == "" {
|
||||
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_qsl", "yes") // only QSLed (confirmed) records
|
||||
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 != "" {
|
||||
q.Set("qso_qslsince", s)
|
||||
}
|
||||
|
||||
+60
-33
@@ -33,6 +33,11 @@ func sameBaseCall(a, b string) bool {
|
||||
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
|
||||
// function fields decouples extsvc from the qso/adif/settings packages and
|
||||
// keeps the upload-scheduling logic testable.
|
||||
@@ -62,6 +67,13 @@ type Deps struct {
|
||||
// option would otherwise silently relabel it). "" → no station call known.
|
||||
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 func(format string, args ...any)
|
||||
}
|
||||
@@ -75,7 +87,6 @@ type Manager struct {
|
||||
mu sync.Mutex
|
||||
cfg ExternalServices
|
||||
rnd *rand.Rand
|
||||
pending map[Service][]int64 // QSO ids queued for ModeOnClose upload
|
||||
}
|
||||
|
||||
func NewManager(deps Deps) *Manager {
|
||||
@@ -87,7 +98,6 @@ func NewManager(deps Deps) *Manager {
|
||||
// Seeded from the clock; the delay only needs to be unpredictable
|
||||
// enough to spread bursts, not cryptographically random.
|
||||
rnd: rand.New(rand.NewSource(time.Now().UnixNano())),
|
||||
pending: map[Service][]int64{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +113,8 @@ func (m *Manager) SetConfig(cfg ExternalServices) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cfg.QRZ = cfg.QRZ.normalised()
|
||||
cfg.Clublog = cfg.Clublog.normalised()
|
||||
cfg.LoTW = cfg.LoTW.normalised()
|
||||
m.cfg = cfg
|
||||
}
|
||||
|
||||
@@ -145,11 +157,9 @@ func (m *Manager) OnQSOLogged(id int64) {
|
||||
// app-close batch, or schedule an immediate / delayed upload.
|
||||
func (m *Manager) route(svc Service, id int64, cfg ServiceConfig) {
|
||||
if cfg.UploadMode == ModeOnClose {
|
||||
m.mu.Lock()
|
||||
m.pending[svc] = append(m.pending[svc], id)
|
||||
n := len(m.pending[svc])
|
||||
m.mu.Unlock()
|
||||
m.logf("extsvc: %s queued QSO %d for on-close upload (%d pending)", svc, id, n)
|
||||
// Nothing to queue: on-close upload sweeps the whole logbook from the
|
||||
// database at shutdown (see FlushOnClose), so this QSO is picked up by
|
||||
// its sent-status then — no in-memory tracking needed.
|
||||
return
|
||||
}
|
||||
m.scheduleUpload(svc, id, cfg)
|
||||
@@ -166,47 +176,64 @@ func (m *Manager) scheduleUpload(svc Service, id int64, cfg ServiceConfig) {
|
||||
go m.upload(svc, id, cfg)
|
||||
}
|
||||
|
||||
// PendingCount returns how many QSOs are queued for on-close upload across
|
||||
// all services. The shutdown sequence uses it to decide whether to show the
|
||||
// upload step.
|
||||
func (m *Manager) PendingCount() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
// onCloseServices returns the services configured for on-close auto-upload,
|
||||
// with the minimum credentials to actually run.
|
||||
func (m *Manager) onCloseServices() []Service {
|
||||
cfg := m.Config()
|
||||
var out []Service
|
||||
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)
|
||||
}
|
||||
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
|
||||
for _, ids := range m.pending {
|
||||
n += len(ids)
|
||||
for _, svc := range m.onCloseServices() {
|
||||
n += len(m.deps.CloseUploadIDs(svc))
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FlushOnClose uploads every queued QSO. Called from the shutdown sequence.
|
||||
// QRZ/Club Log go one-by-one (fast HTTP); LoTW is signed and uploaded as a
|
||||
// single TQSL batch. Returns the number of QSOs uploaded successfully.
|
||||
// FlushOnClose uploads every QSO due for an on-close push, scanning the whole
|
||||
// logbook (not just this session). Called from the shutdown sequence. QRZ/Club
|
||||
// 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 {
|
||||
m.mu.Lock()
|
||||
pending := m.pending
|
||||
m.pending = map[Service][]int64{}
|
||||
cfg := m.cfg
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.deps.CloseUploadIDs == nil {
|
||||
return 0
|
||||
}
|
||||
cfg := m.Config()
|
||||
uploaded := 0
|
||||
for svc, ids := range pending {
|
||||
for _, svc := range m.onCloseServices() {
|
||||
ids := m.deps.CloseUploadIDs(svc)
|
||||
if len(ids) == 0 {
|
||||
continue
|
||||
}
|
||||
switch svc {
|
||||
case ServiceLoTW:
|
||||
uploaded += m.flushLoTWBatch(ids, cfg.LoTW)
|
||||
default:
|
||||
var sc ServiceConfig
|
||||
switch svc {
|
||||
case ServiceQRZ:
|
||||
sc = cfg.QRZ
|
||||
case ServiceClublog:
|
||||
sc = cfg.Clublog
|
||||
}
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +519,56 @@ func (r *Repo) ListForUpload(ctx context.Context, column, value string) ([]Uploa
|
||||
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
|
||||
// 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
|
||||
@@ -651,6 +701,25 @@ func (r *Repo) DeleteAll(ctx context.Context) (int64, error) {
|
||||
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.
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
res, err := r.db.ExecContext(ctx, `DELETE FROM qso WHERE id = ?`, id)
|
||||
|
||||
+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)
|
||||
}
|
||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||
appVersion = "0.11.1"
|
||||
appVersion = "0.11.3"
|
||||
|
||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||
// to https://us.i.posthog.com for a US project.
|
||||
|
||||
Reference in New Issue
Block a user