Compare commits

...
27 Commits
Author SHA1 Message Date
rouggy 190b86eb1c chore: release v0.20.5 2026-07-21 00:21:55 +02:00
rouggy cfc3d00ea1 feat: Help - What's new button to reopen the changelog anytime
Adds GetChangelog (all entries up to the running version, no seen-marker change)
and a Help menu 'What's new' item that reopens the changelog dialog on demand.
2026-07-21 00:17:11 +02:00
rouggy 9033e8518c feat: What's-new changelog dialog on first launch after an update (EN/FR)
Embeds changelog.json (per-version EN + FR notes, curated from the release's
commits). GetWhatsNew compares the stored last-seen version with the running
build and returns the notes for every newer version, once, then advances the
marker. A dialog shows them in the UI language on the first launch after update.
Seeded with the 0.20.5 notes.
2026-07-20 22:58:05 +02:00
rouggy bfbd9fa61a fix: activity chart uses rolling chronological windows (real dates), not cyclical
Day/Week/Month/Year now show the anchor day hour-by-hour, the last 7 days, the
last 30 days, and the last 12 months — anchored to the period end (else now), in
real date order. Replaces the cyclical hour-of-day/weekday/day-of-month/month
buckets, which left future days empty and ordered the weekend after Monday.
2026-07-20 21:39:24 +02:00
rouggy f0c4f22942 feat: stats activity-over-time granularity selector (timeline / day / week / month / year)
Backend adds cyclical distributions ByHour (hour-of-day), ByDOW (weekday), ByDOM
(day-of-month) and ByMonthYr (calendar month), all UTC. The Activity-over-time
card gains a selector: Timeline keeps the chronological month trend; Day/Week/
Month/Year show the hour/weekday/day/month distribution as bars.
2026-07-20 21:25:33 +02:00
rouggy 19c91f32a0 feat: stats — per-band mode split (CW / phone / data) stacked bar chart
Adds Stats.ByBandCategory (per band: CW/phone/data counts, band-plan order) via a
modeCategory() bucketing in stats.go, and a StackedBandBars chart in the Stats
panel showing the split per band with a legend.
2026-07-20 21:20:29 +02:00
rouggy 3ec23bc613 ui: compact Stations-on-air rows to single line so 5+ fit without scrolling 2026-07-20 19:36:01 +02:00
rouggy 2fbb922bd2 Revert "fix: UDP-logged QSOs adopt the active profile's station callsign (Club Log upload blocked)"
This reverts commit f0afdcc498.
2026-07-20 19:34:33 +02:00
rouggy f0afdcc498 fix: UDP-logged QSOs adopt the active profile's station callsign (Club Log upload blocked)
MSHV/WSJT-X often stamp a personal STATION_CALLSIGN (e.g. F4BPO) that differs
from the special-event/contest profile you're running in OpsLog (e.g. TM74TFR).
The QSO then failed the extsvc station-callsign guard and was never uploaded to
the (matching) Club Log logbook. Live UDP logging now stamps the active profile's
callsign when the base call genuinely differs (portable variants left alone).
2026-07-20 19:32:06 +02:00
rouggy 22352c5748 fix: who's-on-air freq/appearance lag — prompt publish on activity + 5s widget poll
publishLiveStatus only ran on QSO log or the 15s heartbeat, and the widget polled
every 15s, so an operator's band/freq (and their appearance) could trail by up to
a minute. Now ReportLiveActivity debounce-publishes ~1.5s after a freq/band/mode
change, and the widget polls every 5s (and 1.5s after qso:logged).
2026-07-20 19:17:13 +02:00
rouggy 6356d60a66 fix: Stations-on-air widget lagged minutes (timer reset by dbConn object churn)
The 15s poll effect depended on the dbConn OBJECT, which gets a fresh reference
on several events — each reset the interval before it fired, so the widget could
stay stale for over a minute. Depend on the is-mysql boolean instead, and also
refresh ~2s after qso:logged so our own row appears promptly like the ON AIR
badge.
2026-07-20 19:02:57 +02:00
rouggy cafade0dbb remove CW decoder (audio->text) entirely; fix CWX macro not stopping on call typing
- Removes the RX-audio CW decoder: deletes app_cw.go + internal/cwdecode, the
  cwDecoder/cwPitchHz/cwMu/cwStop App fields, and all frontend state/effects, the
  header Ear button, the Tools menu item and the decoded-text strip. The CW
  KEYER (WinKeyer/Icom/Flex) is untouched.
- Fix: typing a callsign while a macro is keying now aborts the CURRENT engine
  (Flex CWX / Icom / WinKeyer) on the first character, not just WinKeyer during
  an auto-call loop — via a shared stopKeyerTx() helper.
2026-07-20 19:00:09 +02:00
rouggy 2e39615554 feat: SPE Expert amplifier control (serial/TCP) — OPERATE toggle + live status
Implements the SPE Application Programmer's Guide protocol: packets
0x55 0x55 0x55|CNT|DATA|CHK (sum%256), OPERATE key 0x0D (toggles STANDBY/OPERATE)
and STATUS request 0x90 whose 0xAA-framed reply is a 19-field CSV (mode, RX/TX,
band, power level, output W, SWR, V/I, temp, warnings, alarms). internal/spe
polls status ~1/s over USB serial (go.bug.st/serial, 8N1) or TCP (RS232-to-
Ethernet bridge) — same codec, different transport.

Wired via startPGXL (starts the SPE client for spe* types), bindings GetSPEStatus
/ SPESetOperate, and a live status card + OPERATE/STANDBY toggle in the Amplifier
settings panel. Only the two example-anchored commands are sent (safe); other
keystroke codes were ambiguous in the guide's table. Untested on hardware.
2026-07-20 17:59:24 +02:00
rouggy 666b933114 feat: generalize Settings 'Power Genius' into 'Amplifier' (type + serial/IP transport) for SPE Expert
Renames the Hardware section to Amplifier and adds an amplifier-type selector
(4O3A PowerGenius XL, SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA) plus a transport
choice for the SPE amps: USB (serial COM + baud) or Network (RS232-to-Ethernet
IP:port). PGXLSettings gains Type/Transport/ComPort/Baud (keys keep the pgxl.*
prefix for back-compat). PowerGenius still drives over TCP; SPE settings are
stored but control is not wired yet (needs the SPE serial protocol).
2026-07-20 17:44:56 +02:00
rouggy def59da748 ui: label both USB relay backends as 'Denkovi USB' (FT245/D2XX vs serial/COM) 2026-07-20 17:33:02 +02:00
rouggy fe69bc308c feat: Denkovi USB 4/8 (selectable) + generic USB-serial relay board (CH340/LCUS)
- Denkovi (FT245 D2XX) now selectable as 4 or 8 relays (a 4-relay board just uses
  the low nibble); NewDenkovi takes a count, driven by the device's Channels.
- New 'usbrelay' backend: cheap CH340/LCUS USB-serial boards over a COM port
  using the common A0 protocol ([0xA0][relay][state][checksum]); write-only, so
  Status is a shadow. Channel count configurable (1/2/4/8/16).
- StationDevice gains Channels; deviceRelayCount() drives label/relay counts for
  the variable-size types. Device editor: channel selector + COM-port picker
  (usbrelay) / FTDI-serial (denkovi).

Both untested on hardware; the A0 command set / Denkovi bit order may need a tweak
after a live test.
2026-07-20 17:23:38 +02:00
rouggy c07a17dc47 feat: Denkovi USB 8-relay board (FT245 D2XX bit-bang)
Third relay device alongside WebSwitch and KMTronic. Despite enumerating as a
COM port, this board is driven via FTDI D2XX synchronous bit-bang (one byte = 8
relays, bit 0 = relay 1), not serial ASCII — so we load ftd2xx.dll at runtime
(no CGO) and call FT_OpenEx/FT_SetBitMode/FT_Write, addressing the board by its
FTDI serial (e.g. DAE0006K), exactly like the vendor tool. Windows-only (stub
elsewhere); ListDenkoviDevices enumerates connected serials for the picker.

Wired into relaydev (Count/Status/Set), app.go (deviceDriver/relayCountFor/type
whitelist + ListDenkoviDevices binding), and the Station device editor (new type
+ FTDI-serial field with Detect). Untested on hardware; bit order / on-polarity
may need a flip after a live test.
2026-07-20 16:50:04 +02:00
rouggy 3cef885934 feat: Flex CWX type-ahead backspace (cwx erase) — phase 2
Completes the CWX type-ahead loop: with send-on-type the keyer-panel CW text
already streams each typed char to the radio's CWX buffer (which keys in order,
so you can keep typing while it sends); this adds the matching un-send. Route
wkBackspace to FlexBackspaceCW -> cwx erase N, so backspacing a mistyped char in
send-on-type removes it from the buffer before the radio keys it.
2026-07-20 16:12:24 +02:00
rouggy b8db653981 fix: ESC now stops the active CW engine (Icom/Flex), not just WinKeyer
The ESC abort keyed WinkeyerStop unconditionally, so it never aborted the Icom
CI-V or Flex CWX keyer. Route the stop through cwSource like the panel Stop
button does.
2026-07-20 15:51:32 +02:00
rouggy 9729ef62ba feat: FlexRadio CWX CW keyer (send/stop/speed) — no WinKeyer/SmartCAT needed
Adds a third CW engine alongside WinKeyer and Icom CI-V: FlexRadio's CWX keyer
over the existing SmartSDR CAT connection.

- cat.FlexController: SendCW (cwx send), StopCW (cwx clear); flex.go implements
  them, escaping the quoted text form. Speed reuses SetCWSpeed (cw wpm).
- App bindings FlexSendCW/FlexStopCW/FlexSetKeySpeed via FlexDo.
- Frontend: cwSource gains 'flex'; macros/auto-call/<LOGQSO>/send-on-type route to
  Flex when the CAT backend is a Flex (estimate-based timing like Icom, no busy
  echo). Keyer panel shows 'Flex CWX' + ready/offline; Settings adds the engine
  option with a CAT-backend guard.

Core send/stop/speed first; type-ahead buffer + mid-send backspace (cwx delete)
to follow.
2026-07-20 15:13:24 +02:00
rouggy cc6411a618 fix: Antenna Genius buttons ignored clicks while the CW decoder ran
PortBtn was defined inside AntGeniusPanel, so every render produced a new
component type and React unmounted/remounted every port button. Normally
re-renders are rare so it's invisible, but the CW decoder floods the app with
cw:text/cw:status events (re-render many times a second), tearing the buttons
down between mousedown and mouseup — the click never completed and the antenna
never switched. Move PortBtn to module scope (stable identity) and pass
onActivate/t as props.
2026-07-20 14:45:29 +02:00
rouggy 991831bdec chore: DDFM catalog file updated from Publish-to-catalog (address+QTH postal OR-rules, full department reference list) 2026-07-20 14:39:15 +02:00
rouggy 1b2da95ad4 ui: widen the Award management modal (max-w-6xl, 95vw) so the fuller footer fits 2026-07-20 14:35:18 +02:00
rouggy 10d86db50a feat: rename the database from Settings, keeping all config
Adds a Rename button to Settings -> Database: renames the current SQLite database
(e.g. opslog.db -> F4BPO.db) while preserving every setting/profile, unlike New
database which starts empty. Implemented as a VACUUM INTO copy + pointer switch;
the old file (open and locked now) is scheduled via config.json delete_pending
and removed, with its -wal/-shm sidecars, on the next launch.
2026-07-20 14:30:45 +02:00
rouggy 8538f48259 feat: Publish-to-catalog export for awards (edit in UI, version, ship to team)
Adds ExportAwardForCatalog(code, version): writes the selected award as a
catalog-format JSON ({"def":…,"references":…}) stamped with a version and with
user_edited cleared, ready to paste over internal/award/catalog/<code>.json. A
new release then propagates the change to the whole team via mergeCatalog —
unedited copies auto-upgrade, edited ones are offered the update.

UI: a version input + "Publish to catalog…" button in the award editor footer,
defaulting to one past the award's current version.
2026-07-20 14:20:30 +02:00
rouggy 0fa91c3d5f feat: ship DDFM v2 (postal-code OR-rule) via the award catalog so it reaches the team
DDFM now derives the French department from the address postal code, not only
from a note typed by hand — the catalog def gains the address OR-rule (\d{2}\d{3}
-> Dxx), France+Corsica scope, and version 2. On next launch every operator whose
DDFM is unedited auto-upgrades to it (user-edited copies are offered it); the
materialised award_refs then recompute so ~500+ French QSOs gain their department.

Also: a catalog definition update now clears the award_refs backfill flag, so any
future catalog bump self-heals the stored columns without a manual flag bump.
2026-07-20 13:57:50 +02:00
rouggy c86d331bd9 fix: re-materialize award_refs so new matching rules reach existing QSOs
The one-shot award_refs backfill never revisited rows after it ran, so a QSO
materialized before an award gained a rule kept stale values — e.g. DDFM's
postal-code OR-rule matched nothing on the 500+ French QSOs whose department
comes from the address, only the handful with it typed in notes. Two fixes:
bump the backfill flag (awards.materialized.v2) so every operator re-materializes
once on next launch with their current definitions, and make RescanAwards (the
Awards tab Rescan button) also recompute award_refs for an on-demand refresh.
2026-07-20 13:41:14 +02:00
28 changed files with 2697 additions and 1035 deletions
+309 -34
View File
@@ -32,7 +32,6 @@ import (
"hamlog/internal/clublog"
"hamlog/internal/cluster"
"hamlog/internal/contest"
"hamlog/internal/cwdecode"
"hamlog/internal/db"
"hamlog/internal/dxcc"
"hamlog/internal/email"
@@ -45,6 +44,7 @@ import (
"hamlog/internal/operating"
"hamlog/internal/pota"
"hamlog/internal/powergenius"
"hamlog/internal/spe"
"hamlog/internal/profile"
"hamlog/internal/qslcard"
"hamlog/internal/qso"
@@ -182,10 +182,16 @@ const (
keyAntGeniusHost = "antgenius.host"
keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius.
keyPGXLEnabled = "pgxl.enabled"
keyPGXLHost = "pgxl.host"
keyPGXLPort = "pgxl.port"
// Amplifier control — Hardware → Amplifier (PowerGenius XL over TCP; SPE Expert
// over USB serial or an RS232-to-Ethernet bridge). Keys keep the pgxl.* prefix
// for backward compatibility with existing saved settings.
keyPGXLEnabled = "pgxl.enabled"
keyPGXLHost = "pgxl.host"
keyPGXLPort = "pgxl.port"
keyPGXLType = "pgxl.type" // "pgxl" | "spe13" | "spe15" | "spe2k"
keyPGXLTransport = "pgxl.transport" // "tcp" | "serial"
keyPGXLComPort = "pgxl.com_port"
keyPGXLBaud = "pgxl.baud"
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
keyWKEnabled = "winkeyer.enabled"
@@ -459,6 +465,7 @@ type App struct {
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE
audioMgr *audio.Manager
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
@@ -474,10 +481,6 @@ type App struct {
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
cwMu sync.Mutex // guards the CW decoder lifecycle
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
startupProfile string // --profile <name> from the command line (activate at startup)
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
@@ -505,6 +508,7 @@ type App struct {
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
liveBand string
liveMode string
livePublishTimer *time.Timer // debounced live-status publish on activity change
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
awardSnapMu sync.Mutex // guards the award QSO snapshot
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
@@ -699,6 +703,20 @@ func (a *App) startup(ctx context.Context) {
usingDefault = false
}
}
// A rename in a previous session left the OLD file to delete now that it's no
// longer open. Only ever delete a file that ISN'T the one we're about to use.
if boot := readBootstrap(dataDir); strings.TrimSpace(boot.DeletePending) != "" {
old := strings.TrimSpace(boot.DeletePending)
if old != a.dbPath {
for _, p := range []string{old, old + "-wal", old + "-shm"} {
if err := os.Remove(p); err == nil {
fmt.Printf("OpsLog: removed old database file %s\n", p)
}
}
}
boot.DeletePending = ""
_ = writeBootstrap(dataDir, boot)
}
if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error()
fmt.Println("OpsLog:", a.startupErr)
@@ -1340,6 +1358,11 @@ func copyFileData(src, dst string) error {
type dbPointer struct {
DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"`
// DeletePending is the previous database file to remove on the NEXT launch —
// set by a rename, which can't delete the still-open old file in-process. The
// startup path deletes it (with its -wal/-shm sidecars) once the new DB is the
// one in use, then clears this.
DeletePending string `json:"delete_pending,omitempty"`
}
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
@@ -1653,9 +1676,10 @@ func (a *App) PickSaveDatabase() (string, error) {
return "", fmt.Errorf("no app context")
}
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Save the OpsLog database to…",
DefaultFilename: "opslog.db",
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
Title: "Save the OpsLog database to…",
DefaultDirectory: filepath.Dir(a.dbPath),
DefaultFilename: "opslog.db",
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
})
}
@@ -1697,6 +1721,44 @@ func (a *App) MoveDatabase(dest string) error {
return writeDBPointer(a.dataDir, dest)
}
// RenameDatabase renames the current database file to dest — keeping ALL config
// (it is the same database under a new name), unlike "New database" which starts
// empty. Implemented as a consistent copy (VACUUM INTO) plus a switch, then the
// ORIGINAL file is scheduled for deletion on the next launch (it is open now and
// can't be removed in-process on Windows). dest must not already exist.
func (a *App) RenameDatabase(dest string) error {
dest = strings.TrimSpace(dest)
if dest == "" {
return fmt.Errorf("no destination given")
}
if a.db == nil {
return fmt.Errorf("database not open")
}
old := a.dbPath
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
return fmt.Errorf("that is already the current database name")
}
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("a file already exists at %s — pick a new name", dest)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return fmt.Errorf("create folder: %w", err)
}
safe := strings.ReplaceAll(dest, "'", "''")
if _, err := a.db.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
return fmt.Errorf("copy database: %w", err)
}
boot := readBootstrap(a.dataDir)
boot.DBPath = dest
// Only schedule the old file for deletion when it's a real, on-disk file (a
// custom path or the default opslog.db) — never something we somehow share
// with the destination.
if strings.TrimSpace(old) != "" && !strings.EqualFold(filepath.Clean(old), filepath.Clean(dest)) {
boot.DeletePending = old
}
return writeBootstrap(a.dataDir, boot)
}
// CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
// points OpsLog at it for the next launch. dest must not already exist.
func (a *App) CreateDatabase(dest string) error {
@@ -2452,6 +2514,14 @@ func (a *App) migrateAwardDefs() {
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
a.reseedRefsFromCatalog(code)
}
// A catalog definition changed (e.g. DDFM gained the postal-code OR-rule), so
// the materialised award_refs on existing QSOs are now stale. Clear the
// one-shot backfill flag; backfillAwardRefsOnce runs later in startup and will
// re-materialise every row against the updated definitions. This makes ANY
// future catalog bump self-heal the stored columns without a manual flag bump.
if len(updated) > 0 {
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "")
}
// Version-gated correction of the built-in awards' Validate sources, which
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
@@ -3415,6 +3485,10 @@ func (a *App) invalidateAwardStats() {
// flips qsl_rcvd flags on existing rows).
func (a *App) RescanAwards() error {
a.invalidateAwardStats()
// Also refresh the materialised award_refs on every QSO (the grid columns read
// from there). Manual trigger for when a definition/reference change should be
// re-applied to existing rows on demand rather than waiting for the next event.
a.recomputeAwardRefsAsync()
return nil
}
@@ -3821,7 +3895,12 @@ func (a *App) recomputeAwardRefsAsync() {
// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs
// backfill has run, so it doesn't re-scan the whole logbook on every launch.
const keyAwardsMaterialized = "awards.materialized.v1"
// keyAwardsMaterialized is versioned: bump the suffix to force every operator to
// re-materialise award_refs once on next launch. Needed when the stored values
// could be stale against the CURRENT award definitions — e.g. rows materialised
// before an award gained a new matching rule (DDFM's postal-code OR-rule), which
// the one-shot backfill would otherwise never revisit.
const keyAwardsMaterialized = "awards.materialized.v2"
// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature
// (or were logged by an older client that didn't materialise them). It runs at
@@ -4186,6 +4265,65 @@ func (a *App) ExportAward(code string) (string, error) {
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
}
// ExportAwardForCatalog writes ONE award as a ready-to-ship CATALOG file — the
// exact shape internal/award/catalog/*.json use ({"def":{…},"references":[…]}).
//
// The workflow it enables: edit an award in the UI, call this with the NEXT
// version number, and paste the file over that award's catalog JSON. A new OpsLog
// release then carries the change to the whole team: every operator whose copy is
// unedited auto-upgrades to it (mergeCatalog), and those who edited it keep theirs
// but are offered the update.
//
// Two things a plain export/mirror can't do and this does, both essential for a
// catalog file: it STAMPS the award's Version (the UI save deliberately never
// bumps it) and it CLEARS user_edited, so a fresh install seeded from this file is
// NOT pre-flagged as the operator's own work — which would otherwise freeze it out
// of every future catalog update.
func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
if a.awardRefs == nil || a.ctx == nil {
return "", fmt.Errorf("db not initialized")
}
code = strings.ToUpper(strings.TrimSpace(code))
var def award.Def
found := false
for _, d := range a.awardDefs() {
if strings.EqualFold(strings.TrimSpace(d.Code), code) {
def, found = d, true
break
}
}
if !found {
return "", fmt.Errorf("unknown award %q", code)
}
def.Version = version
def.UserEdited = false // a catalog seed is not "the operator's edit"
def.Builtin = true
refs, err := a.awardRefs.List(a.ctx, code)
if err != nil {
return "", fmt.Errorf("load references: %w", err)
}
entry := struct {
Def award.Def `json:"def"`
References []awardref.Ref `json:"references,omitempty"`
}{Def: def, References: refs}
b, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return "", err
}
path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
DefaultFilename: strings.ToLower(code) + ".json",
Title: "Export " + code + " for the catalog",
Filters: []wruntime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}},
})
if err != nil || strings.TrimSpace(path) == "" {
return "", err
}
if err := os.WriteFile(path, b, 0o644); err != nil {
return "", err
}
return path, nil
}
// exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
if a.awardRefs == nil {
@@ -10225,6 +10363,45 @@ func (a *App) IcomSendCW(text string) error {
return err
}
// FlexSendCW keys a CW message through the FlexRadio CWX keyer (SmartSDR), so a
// Flex needs no WinKeyer / SmartCAT. Text is already variable-resolved by the UI.
func (a *App) FlexSendCW(text string) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SendCW(text) })
if err != nil {
applog.Printf("flex cw: FlexSendCW(%q) failed: %v", text, err)
}
return err
}
// FlexStopCW clears the CWX buffer, aborting whatever is being keyed.
func (a *App) FlexStopCW() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.StopCW() })
}
// FlexSetKeySpeed sets the CW keyer speed in WPM (the CWX keyer uses the radio's
// CW speed).
func (a *App) FlexSetKeySpeed(wpm int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetCWSpeed(wpm) })
}
// FlexBackspaceCW removes the last n not-yet-keyed characters from the CWX buffer
// (type-ahead correction). n<1 deletes one.
func (a *App) FlexBackspaceCW(n int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.FlexDo(func(fc cat.FlexController) error { return fc.BackspaceCW(n) })
}
// IcomStopCW aborts the CW message currently being sent.
func (a *App) IcomStopCW() error {
if a.cat == nil {
@@ -11224,28 +11401,58 @@ func boolStr(b bool) string {
// StationDevice is one configured relay board for the Station Control tab.
type StationDevice struct {
ID string `json:"id"`
Type string `json:"type"` // "webswitch" | "kmtronic"
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
Name string `json:"name"`
Host string `json:"host"`
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
Pass string `json:"pass,omitempty"`
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
}
// deviceRelayCount is the relay count for a configured device — fixed by type,
// except the Denkovi (4 or 8) and the generic USB-serial board, whose channel
// count the user picks.
func deviceRelayCount(d StationDevice) int {
if d.Type == "usbrelay" || d.Type == "denkovi" {
if d.Channels >= 1 {
return d.Channels
}
return 8
}
return relayCountFor(d.Type)
}
// relayCountFor returns a device type's fixed relay count.
func relayCountFor(typ string) int {
if typ == "kmtronic" {
switch typ {
case "kmtronic", "denkovi":
return 8
default:
return 5 // webswitch 1216H
}
return 5 // webswitch 1216H
}
// deviceDriver builds the wire driver for a configured device.
func deviceDriver(d StationDevice) relaydev.Device {
if d.Type == "kmtronic" {
switch d.Type {
case "kmtronic":
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
case "denkovi":
// Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname.
return relaydev.NewDenkovi(d.Host, deviceRelayCount(d))
case "usbrelay":
// Host carries the COM port (e.g. "COM5"); CH340/LCUS "A0" serial protocol.
return relaydev.NewSerialRelay(d.Host, deviceRelayCount(d))
default:
return relaydev.NewWebswitch(d.Host)
}
return relaydev.NewWebswitch(d.Host)
}
// ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI
// boards, for the settings picker. Windows-only (FTDI D2XX).
func (a *App) ListDenkoviDevices() ([]string, error) {
return relaydev.ListDenkovi()
}
// GetStationDevices returns the configured relay boards (without live state).
@@ -11271,13 +11478,16 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
return fmt.Errorf("db not initialized")
}
for i := range devs {
if devs[i].Type != "kmtronic" {
switch devs[i].Type {
case "kmtronic", "denkovi", "usbrelay", "webswitch":
// known type — keep
default:
devs[i].Type = "webswitch"
}
if strings.TrimSpace(devs[i].ID) == "" {
devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano())
}
n := relayCountFor(devs[i].Type)
n := deviceRelayCount(devs[i])
for len(devs[i].Labels) < n {
devs[i].Labels = append(devs[i].Labels, "")
}
@@ -11318,7 +11528,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
go func(i int) {
defer wg.Done()
d := devs[i]
n := relayCountFor(d.Type)
n := deviceRelayCount(d)
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel()
@@ -11925,19 +12135,25 @@ func (a *App) AntGeniusDeselect(port int) error {
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────
// PGXLSettings is the JSON shape for the Hardware → PowerGenius panel.
// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
// the 4O3A PowerGenius XL (TCP) and the SPE Expert amps (USB serial or an
// RS232-to-Ethernet bridge). The type name is kept for binding compatibility.
type PGXLSettings struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k"
Transport string `json:"transport"` // "tcp" | "serial"
Host string `json:"host"` // TCP transport
Port int `json:"port"` // TCP transport
ComPort string `json:"com_port"` // serial transport
Baud int `json:"baud"` // serial transport
}
func (a *App) GetPGXLSettings() (PGXLSettings, error) {
out := PGXLSettings{Port: 9008}
out := PGXLSettings{Type: "pgxl", Transport: "tcp", Port: 9008, Baud: 115200}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort)
m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort, keyPGXLType, keyPGXLTransport, keyPGXLComPort, keyPGXLBaud)
if err != nil {
return out, err
}
@@ -11946,6 +12162,16 @@ func (a *App) GetPGXLSettings() (PGXLSettings, error) {
if p, _ := strconv.Atoi(m[keyPGXLPort]); p > 0 && p <= 65535 {
out.Port = p
}
if t := strings.TrimSpace(m[keyPGXLType]); t != "" {
out.Type = t
}
if tr := strings.TrimSpace(m[keyPGXLTransport]); tr != "" {
out.Transport = tr
}
out.ComPort = m[keyPGXLComPort]
if b, _ := strconv.Atoi(m[keyPGXLBaud]); b > 0 {
out.Baud = b
}
return out, nil
}
@@ -11956,10 +12182,23 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
if s.Port <= 0 || s.Port > 65535 {
s.Port = 9008
}
if s.Baud <= 0 {
s.Baud = 115200
}
if s.Type == "" {
s.Type = "pgxl"
}
if s.Transport == "" {
s.Transport = "tcp"
}
for k, v := range map[string]string{
keyPGXLEnabled: boolStr(s.Enabled),
keyPGXLHost: strings.TrimSpace(s.Host),
keyPGXLPort: strconv.Itoa(s.Port),
keyPGXLEnabled: boolStr(s.Enabled),
keyPGXLHost: strings.TrimSpace(s.Host),
keyPGXLPort: strconv.Itoa(s.Port),
keyPGXLType: s.Type,
keyPGXLTransport: s.Transport,
keyPGXLComPort: strings.TrimSpace(s.ComPort),
keyPGXLBaud: strconv.Itoa(s.Baud),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -11978,12 +12217,48 @@ func (a *App) startPGXL() {
go a.pgxl.Stop()
a.pgxl = nil
}
if a.spe != nil {
go a.spe.Stop()
a.spe = nil
}
s, err := a.GetPGXLSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
if err != nil || !s.Enabled {
return
}
a.pgxl = powergenius.New(s.Host, s.Port)
_ = a.pgxl.Start()
if s.Type == "" || s.Type == "pgxl" {
if strings.TrimSpace(s.Host) == "" {
return
}
a.pgxl = powergenius.New(s.Host, s.Port)
_ = a.pgxl.Start()
return
}
// SPE Expert — USB serial or an RS232-to-Ethernet bridge.
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
return
}
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" {
return
}
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
_ = a.spe.Start()
}
// GetSPEStatus returns the SPE Expert amplifier state for the UI poll.
func (a *App) GetSPEStatus() spe.Status {
if a.spe == nil {
return spe.Status{}
}
return a.spe.GetStatus()
}
// SPESetOperate puts the SPE amp in OPERATE (true) or STANDBY (false). The amp has
// a single OPERATE toggle key, so this sends it only when the state must change.
func (a *App) SPESetOperate(on bool) error {
if a.spe == nil {
return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
}
return a.spe.Operate(on)
}
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
-149
View File
@@ -1,149 +0,0 @@
package main
import (
"fmt"
"time"
"hamlog/internal/applog"
"hamlog/internal/audio"
"hamlog/internal/cwdecode"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
)
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
// only by the frontend, and only while the entry mode is CW.
//
// Pitch targeting: the single-channel decoder is far more reliable when it locks
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
// instead of auto-searching for the loudest tone. So we follow the radio's CW
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
// back to auto-search otherwise.
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
func (a *App) cwTargetPitch() int {
if a.cwPitchHz > 0 {
return a.cwPitchHz
}
if a.cat != nil {
if st, ok := a.cat.FlexState(); ok && st.Available {
// Only trust the radio's pitch when it's actually in CW.
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
if st.CWPitch > 0 {
return st.CWPitch
}
}
}
}
return 0
}
// StartCWDecoder begins decoding CW from the configured RX audio device. The
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
// call repeatedly; a second call is a no-op while already running.
func (a *App) StartCWDecoder() error {
a.cwMu.Lock()
defer a.cwMu.Unlock()
if a.cwStop != nil {
return nil // already running
}
dev := ""
if a.settings != nil {
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
}
if dev == "" {
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
}
dec := cwdecode.New(audio.SampleRate,
func(text string) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:text", text)
}
},
func(st cwdecode.Status) {
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:status", st)
}
},
)
dec.SetTarget(a.cwTargetPitch())
a.cwDecoder = dec
stop := make(chan struct{})
a.cwStop = stop
go func() {
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
applog.Printf("cw: capture failed: %v", err)
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
}
}
a.cwMu.Lock()
if a.cwStop == stop {
a.cwStop = nil
a.cwDecoder = nil
}
a.cwMu.Unlock()
}()
// Follow the radio's CW pitch live (every second) while this run is active.
go a.cwFollowPitch(stop, dec)
return nil
}
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
t := time.NewTicker(time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
dec.SetTarget(a.cwTargetPitch())
}
}
}
// StopCWDecoder halts the CW decoder if running.
func (a *App) StopCWDecoder() {
a.cwMu.Lock()
stop := a.cwStop
a.cwStop = nil
a.cwDecoder = nil
a.cwMu.Unlock()
if stop != nil {
close(stop)
}
}
// CWDecoderRunning reports whether the decoder is currently capturing.
func (a *App) CWDecoderRunning() bool {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwStop != nil
}
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
// the Flex CW pitch, or search). Applies live to a running decoder.
func (a *App) SetCWDecoderPitch(hz int) {
if hz < 0 {
hz = 0
}
a.cwMu.Lock()
a.cwPitchHz = hz
dec := a.cwDecoder
a.cwMu.Unlock()
if dec != nil {
dec.SetTarget(a.cwTargetPitch())
}
}
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
func (a *App) GetCWDecoderPitch() int {
a.cwMu.Lock()
defer a.cwMu.Unlock()
return a.cwPitchHz
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
_ "embed"
"encoding/json"
"strings"
"hamlog/internal/applog"
)
//go:embed changelog.json
var changelogJSON []byte
// ChangelogEntry is one release's user-facing notes, in English and French. It's
// shown once on the first launch after an update (the "What's new" dialog),
// derived from the release's commit history.
type ChangelogEntry struct {
Version string `json:"version"`
Date string `json:"date"`
EN []string `json:"en"`
FR []string `json:"fr"`
}
const keyChangelogSeen = "changelog.last_seen_version"
func loadChangelog() []ChangelogEntry {
var out []ChangelogEntry
if err := json.Unmarshal(changelogJSON, &out); err != nil {
applog.Printf("changelog: parse failed: %v", err)
}
return out
}
// GetChangelog returns every changelog entry up to the running version (newest
// first), without touching the "seen" marker — for a "What's new" button that
// reopens the notes on demand.
func (a *App) GetChangelog() []ChangelogEntry {
out := []ChangelogEntry{}
for _, e := range loadChangelog() {
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
continue
}
out = append(out, e)
}
return out
}
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
// notes for every version newer than the last one they ran, up to the current
// build — and records the current version as seen so it pops exactly once per
// update. Empty when there's nothing new.
func (a *App) GetWhatsNew() []ChangelogEntry {
if a.settings == nil {
return nil
}
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
cur := appVersion
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
out := []ChangelogEntry{}
for _, e := range loadChangelog() {
if strings.TrimSpace(e.Version) == "" {
continue
}
if versionLess(cur, e.Version) {
continue // don't preview notes for a version newer than we run
}
if lastSeen == "" {
// First run of the feature (fresh install, or upgrade from a build that
// predates it): show only the CURRENT version's notes, once.
if e.Version == cur {
out = append(out, e)
}
continue
}
if versionLess(lastSeen, e.Version) {
out = append(out, e)
}
}
return out
}
+32
View File
@@ -0,0 +1,32 @@
[
{
"version": "0.20.5",
"date": "2026-07-20",
"en": [
"New FlexRadio CWX CW keyer: key CW straight over the SmartSDR connection — no WinKeyer or SmartCAT needed. Type-ahead and mid-send backspace supported.",
"ESC now stops CW on whichever keyer is active (WinKeyer / Icom / Flex), and typing a callsign aborts a running macro.",
"New Amplifier settings section (was 'Power Genius'): control SPE Expert amps (1.3K / 1.5K / 2K) over USB serial or an RS232-to-Ethernet bridge — OPERATE/STANDBY and live status. PowerGenius XL still supported.",
"Denkovi USB relay boards supported (4/8 relays, FT245), plus generic CH340/LCUS USB-serial relay boards.",
"Rename your database from Settings without losing any configuration.",
"Awards: DDFM now finds the French department from the address postal code; refs are stored on each QSO for faster columns; 'Publish to catalog' shares an edited award with your team on the next release; award columns are remembered per profile.",
"Statistics: per-band mode split (CW / phone / data), and an activity chart with a Day / Week / Month / Year selector (rolling, real dates).",
"'Stations on air' widget updates within seconds and fits more stations.",
"The audio CW decoder was removed.",
"Fixes: Antenna Genius buttons ignored clicks while a macro was sending; File → Exit; and more.",
"This 'What's new' summary now appears on the first launch after each update."
],
"fr": [
"Nouveau keyer CW FlexRadio CWX : manipulation CW directement via la connexion SmartSDR — plus besoin de WinKeyer ni de SmartCAT. Type-ahead et retour arrière en cours d'envoi supportés.",
"ESC arrête maintenant le CW sur le moteur actif (WinKeyer / Icom / Flex), et taper un indicatif interrompt une macro en cours.",
"Nouvelle section Amplificateur (ex « Power Genius ») : pilotage des SPE Expert (1.3K / 1.5K / 2K) en USB série ou via un pont RS232→Ethernet — OPERATE/STANDBY et état en direct. PowerGenius XL toujours supporté.",
"Cartes relais USB Denkovi (4/8 relais, FT245), plus cartes USB-série génériques CH340/LCUS.",
"Renommer sa base depuis les réglages sans perdre la configuration.",
"Awards : DDFM trouve le département français depuis le code postal de l'adresse ; les réfs sont stockées sur chaque QSO (colonnes plus rapides) ; « Publier au catalogue » partage un award édité avec l'équipe à la prochaine mise à jour ; les colonnes d'award sont mémorisées par profil.",
"Statistiques : répartition des modes par bande (CW / phone / data), et un graphe d'activité avec sélecteur Jour / Semaine / Mois / Année (glissant, dates réelles).",
"Le widget « Stations on air » se met à jour en quelques secondes et affiche plus de stations.",
"Le décodeur CW audio a été retiré.",
"Corrections : les boutons Antenna Genius ignoraient les clics pendant l'envoi d'une macro ; Fichier → Quitter ; et d'autres.",
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
]
}
]
+123 -139
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react';
@@ -13,7 +13,7 @@ import {
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
LookupCallsign, GetStationSettings, GetListsSettings,
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations,
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
WorkedBefore,
SetCompactMode,
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
@@ -36,8 +36,8 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs,
@@ -283,7 +283,7 @@ function computePrefix(call: string): string {
}
export default function App() {
const { t } = useI18n();
const { t, lang } = useI18n();
// === Lists from settings (fallback for first paint) ===
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
@@ -441,13 +441,21 @@ export default function App() {
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
// Depend on the BOOLEAN (is-mysql), not the dbConn object: dbConn gets a fresh
// reference on several events, and depending on the object reset the 15s timer
// on every such refresh so it could ~never fire (the widget looked stuck for
// minutes). Also refresh right after we log a QSO so our own row shows without
// waiting for the next tick, like the ON AIR badge.
const liveStationsOn = dbConn?.backend === 'mysql';
useEffect(() => {
if (dbConn?.backend !== 'mysql') { setLiveStations([]); return; }
if (!liveStationsOn) { setLiveStations([]); return; }
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
load();
const id = window.setInterval(load, 15 * 1000);
return () => window.clearInterval(id);
}, [dbConn]);
const id = window.setInterval(load, 5 * 1000);
// Small delay lets the async publishLiveStatus MySQL write land before we read.
const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
return () => { window.clearInterval(id); offLogged?.(); };
}, [liveStationsOn]);
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
// in Preferences > Hardware > CAT interface.
@@ -775,7 +783,7 @@ export default function App() {
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
// auto-call and <LOGQSO> are shared; only the transport differs.
const [wkEngine, setWkEngine] = useState<string>('winkeyer');
const cwSource: 'winkeyer' | 'icom' = wkEngine === 'icom' ? 'icom' : 'winkeyer';
const cwSource: 'winkeyer' | 'icom' | 'flex' = wkEngine === 'icom' ? 'icom' : wkEngine === 'flex' ? 'flex' : 'winkeyer';
const cwSourceRef = useRef(cwSource);
useEffect(() => { cwSourceRef.current = cwSource; }, [cwSource]);
// CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
@@ -804,42 +812,14 @@ export default function App() {
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
useEffect(() => {
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected) : wkStatus.connected;
const connected = cwSource === 'icom' ? (catState.backend === 'icom' && catState.connected)
: cwSource === 'flex' ? (catState.backend === 'flex' && catState.connected)
: wkStatus.connected;
wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
// === Digital Voice Keyer (DVK) ===
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
// mode is CW. The decoded text appears in a strip above the tabs.
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
const [cwText, setCwText] = useState('');
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
const cwOn = cwEnabled && mode === 'CW';
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
const cwScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
useEffect(() => {
const hz = parseInt(cwPitch, 10);
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
localStorage.setItem('opslog.cwPitch', cwPitch);
}, [cwPitch, cwOn]);
useEffect(() => {
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
return () => { offT?.(); offS?.(); offE?.(); };
}, []);
// Start/stop the backend decoder as the (enabled, mode) combination changes.
useEffect(() => {
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
else { StopCWDecoder().catch(() => {}); }
}, [cwOn]);
function toggleCwDecoder() {
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
}
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
const [chatAvailable, setChatAvailable] = useState(false);
@@ -1167,6 +1147,12 @@ export default function App() {
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
const [showDeleteAll, setShowDeleteAll] = useState(false);
const [showAbout, setShowAbout] = useState(false);
// "What's new": the changelog for the version(s) since the operator last ran,
// shown once on the first launch after an update (EN/FR per the UI language).
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
useEffect(() => {
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
}, []);
const [showDuplicates, setShowDuplicates] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
const [updating, setUpdating] = useState(false);
@@ -2036,7 +2022,7 @@ export default function App() {
setWkMacros((s.macros ?? []) as WKMacro[]);
setWkEscClears(s.esc_clears_call !== false);
setWkSendOnType(!!s.send_on_type);
setWkEngine(s.engine === 'icom' ? 'icom' : 'winkeyer');
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : 'winkeyer');
} catch { /* keyer not configured */ }
}, []);
@@ -2134,11 +2120,13 @@ export default function App() {
const keyed = resolved ? resolved + ' ' : resolved;
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
if (cwSourceRef.current === 'icom') {
// The rig's keyer gives no busy echo back, so show the text we sent and,
// for <LOGQSO>, wait the estimated send duration before logging.
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show
// the text we sent and, for <LOGQSO>, wait the estimated send duration
// before logging.
setWkSent(resolved);
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
return;
}
@@ -2159,6 +2147,13 @@ export default function App() {
}
// stopAutoCall cancels any running auto-call loop.
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
// stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine
// (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going).
function stopKeyerTx() {
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
}
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
async function runAutoCall(i: number) {
@@ -2174,7 +2169,7 @@ export default function App() {
// the wait at the ESTIMATED send time (not the busy flag alone): over a
// remote/serial-over-IP link the "busy" status lags badly and stays stuck
// true for tens of seconds, which made the next CQ fire ~120s late.
if (cwSourceRef.current === 'icom') {
if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
} else {
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
@@ -2212,8 +2207,14 @@ export default function App() {
writeUiPref('opslog.wkAutoCallSecs', String(v));
}
// send-on-type: key the typed chars verbatim (no variable substitution).
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); }
function wkBackspace() { WinkeyerBackspace().catch(() => {}); }
function wkSendRaw(chars: string) {
if (cwSourceRef.current === 'flex') { FlexSendCW(chars).catch(() => {}); return; }
WinkeyerSend(chars).catch(() => {});
}
function wkBackspace() {
if (cwSourceRef.current === 'flex') { FlexBackspaceCW(1).catch(() => {}); return; }
WinkeyerBackspace().catch(() => {});
}
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
// Resolve slot status for any spot we haven't seen yet — debounced so we
@@ -2659,7 +2660,11 @@ export default function App() {
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
// the first keystroke, so we only abort once.)
if (v.trim() !== '') {
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
// Someone answered: on the FIRST character of a new call (the field was
// empty), abort whatever CW is being sent — a single macro OR an auto-call
// CQ loop — routed to the ACTIVE engine (was WinKeyer-only, so a Flex CWX /
// Icom macro kept keying over the answering station).
if (callsignValRef.current.trim() === '') stopKeyerTx();
stopAutoCall();
}
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
@@ -2783,7 +2788,6 @@ export default function App() {
{ type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
{ type: 'separator' },
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
@@ -2797,9 +2801,10 @@ export default function App() {
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
]},
{ name: 'help', label: t('menu.help'), items: [
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
{ type: 'item', label: t('help.about'), action: 'help.about' },
]},
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, t]);
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]);
function handleMenu(action: string) {
switch (action) {
@@ -2820,7 +2825,6 @@ export default function App() {
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
case 'tools.dvk': setDvkEnabled((v) => !v); break;
case 'tools.cwdecoder': toggleCwDecoder(); break;
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
case 'tools.alerts': setAlertsOpen(true); break;
@@ -2828,6 +2832,7 @@ export default function App() {
case 'tools.refreshCty': refreshCtyDat(); break;
case 'tools.downloadRefs': downloadRefs(); break;
case 'help.about': setShowAbout(true); break;
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
}
}
@@ -2880,7 +2885,14 @@ export default function App() {
const keyerLive = wkActiveRef.current;
// ESC aborts the current CW transmission AND the auto-call loop, so it
// won't resend after the gap — you must click a CQ macro to restart it.
if (keyerLive) { stopAutoCall(); WinkeyerStop().catch(() => {}); }
// Route the abort to whichever engine is active (was WinKeyer-only, so
// ESC didn't stop the Icom or Flex keyer).
if (keyerLive) {
stopAutoCall();
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {});
}
if (!keyerLive || wkEscClearsRef.current) {
resetEntry();
callsignRef.current?.focus();
@@ -3839,24 +3851,6 @@ export default function App() {
<Zap className="size-4" />
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
</button>
<button
type="button"
onClick={toggleCwDecoder}
title={
cwEnabled
? (mode === 'CW' ? 'CW decoder — on (decoding) · click to disable' : 'CW decoder — on, idle until CW mode · click to disable')
: 'CW decoder · click to enable (decodes RX audio in CW mode)'
}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Ear className="size-4" />
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
</button>
<button
type="button"
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
@@ -4146,6 +4140,43 @@ export default function App() {
</div>
)}
{/* What's new — shown once on the first launch after an update. */}
{whatsNew && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
<div className="w-full max-w-lg max-h-[85vh] flex flex-col rounded-xl border border-border bg-card shadow-2xl animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-5 pt-5 pb-3 border-b border-border shrink-0">
<Zap className="size-5 text-primary" />
<h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2>
<span className="flex-1" />
<button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="overflow-y-auto px-5 py-4 space-y-5">
{whatsNew.map((e) => (
<div key={e.version}>
<div className="flex items-baseline gap-2 mb-1.5">
<span className="font-mono font-bold text-primary">v{e.version}</span>
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
</div>
<ul className="space-y-1.5 text-sm">
{(lang === 'fr' ? e.fr : e.en).map((line, i) => (
<li key={i} className="flex gap-2">
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
<span>{line}</span>
</li>
))}
</ul>
</div>
))}
</div>
<div className="px-5 py-3 border-t border-border shrink-0 text-right">
<button onClick={() => setWhatsNew(null)} className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
{t('whatsnew.close')}
</button>
</div>
</div>
</div>
)}
{/* Transient toasts (bottom-right). Errors stack on top of the green
success toast; both auto-dismiss. */}
{/* Unlock encrypted passwords (set via Settings Security). Dismissable:
@@ -4322,31 +4353,27 @@ export default function App() {
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto p-1.5 flex flex-col gap-1">
<div className="flex-1 min-h-0 overflow-auto p-1 flex flex-col gap-0.5">
{liveStations.filter((s) => s.online).length === 0 ? (
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
) : liveStations.filter((s) => s.online).map((s, i) => {
const mc = modeAccent(s.mode);
// Single compact row so 5+ stations fit without scrolling.
return (
<div key={i} className={cn('flex items-center gap-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
<div key={i} className={cn('flex items-center gap-1.5 rounded-md px-1.5 py-1 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
title={s.online ? t('live.onAir') : t('live.offline')} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5 min-w-0">
<span className="text-xs font-bold font-mono truncate">{s.operator}</span>
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
</div>
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
</span>
{s.mode && (
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
)}
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
</div>
<span className="text-xs font-bold font-mono truncate shrink-0 max-w-[72px]" title={s.operator}>{s.operator}</span>
<span className="font-mono text-[11px] font-semibold tabular-nums shrink-0" style={{ color: mc }}>
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
</span>
{s.mode && (
<span className="text-[9px] font-bold uppercase px-1 rounded-full leading-[15px] shrink-0"
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
)}
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
<span className="flex-1" />
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">v{s.version}</span>}
</div>
);
})}
@@ -4406,8 +4433,8 @@ export default function App() {
{wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel
status={cwSource === 'icom'
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' }
status={cwSource === 'icom' || cwSource === 'flex'
? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
: wkStatus}
ports={wkPorts}
port={wkPort}
@@ -4424,11 +4451,15 @@ export default function App() {
onSetSpeed={(w) => {
setWkWpm(w); saveWk({ wpm: w });
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {});
}}
onSend={wkSend}
onSendMacro={wkSendMacro}
onStop={() => { stopAutoCall(); if (cwSource === 'icom') IcomStopCW().catch(() => {}); else WinkeyerStop().catch(() => {}); }}
onStop={() => { stopAutoCall();
if (cwSource === 'icom') IcomStopCW().catch(() => {});
else if (cwSource === 'flex') FlexStopCW().catch(() => {});
else WinkeyerStop().catch(() => {}); }}
onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType}
@@ -4467,53 +4498,6 @@ export default function App() {
</div>{/* /entry + aside row */}
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
{cwOn && (
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
{/* Input-level meter if this stays flat with a strong signal, the RX
audio device is wrong/silent rather than a decode problem. */}
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
</div>
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
</span>
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
<input
type="number"
value={cwPitch}
onChange={(e) => setCwPitch(e.target.value)}
placeholder="auto"
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
/>
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
text (see cwScrollRef effect) so the latest stays in view. */}
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
{cwText.trim() === '' ? (
<span className="text-muted-foreground italic">listening</span>
) : (
<div className="inline-flex items-center whitespace-nowrap">
{cwText.trim().split(/\s+/).map((tok, i) => (
<button
key={i}
type="button"
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
title="Use as callsign"
onClick={() => onCallsignInput(tok, { force: true })}
>
{tok}
</button>
))}
</div>
)}
</div>
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="Clear" onClick={() => setCwText('')}>
<Eraser className="size-3.5" />
</button>
</div>
)}
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
{compact ? null : <>
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
+33 -23
View File
@@ -33,6 +33,37 @@ function pretty(name: string): string {
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
}
// PortBtn is defined at MODULE scope on purpose. Defined inside AntGeniusPanel it
// would be a new component *type* on every render, so React would unmount and
// remount every port button each time the panel re-renders — harmless when that's
// rare, but with the CW decoder running the parent re-renders many times a second
// and the buttons were being torn down mid-click (mousedown and mouseup landing on
// different element instances), so antenna changes silently did nothing.
function PortBtn({ port, index, active, tx, onActivate, t }: {
port: 1 | 2; index: number; active: boolean; tx: boolean;
onActivate: (port: number, antenna: number) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
}) {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
}
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
// match the app's light theme with soft gradients + glows. Each antenna row has
// a port-A button (left) and port-B button (right). Colours: green = selected on
@@ -61,27 +92,6 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
if (filtered.length > 0) list = filtered;
}
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
const letter = port === 1 ? 'A' : 'B';
const cls = tx
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
: active
? (port === 1
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
return (
<button
type="button"
onClick={() => onActivate(port, active ? 0 : index)}
title={active ? t('agp.portDeselect', { letter }) : t('agp.portSelect', { letter })}
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
>
{letter}
</button>
);
};
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
@@ -124,11 +134,11 @@ export function AntGeniusPanel({ status, onActivate, onClose, band }: {
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
return (
<div key={a.index} className="flex items-center gap-1.5">
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} onActivate={onActivate} t={t} />
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
{pretty(a.name)}
</div>
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} onActivate={onActivate} t={t} />
</div>
);
})}
+32 -2
View File
@@ -19,6 +19,7 @@ import {
ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
ExportAwardForCatalog,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App';
@@ -37,7 +38,7 @@ export type AwardDef = {
or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
total: number; builtin?: boolean;
total: number; builtin?: boolean; version?: number;
};
type AwardOrRule = {
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [search, setSearch] = useState('');
const [updating, setUpdating] = useState<string | null>(null);
const [err, setErr] = useState('');
// Version to stamp into a "publish for catalog" export — defaults to one past
// the selected award's current version whenever the selection changes.
const [catVer, setCatVer] = useState('1');
// The err banner doubles as a success/notice area (export path, import counts,
// "populated N refs"). Auto-dismiss it after a few seconds so it doesn't stay
@@ -212,6 +216,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null;
useEffect(() => { setCatVer(String((cur?.version ?? 0) + 1)); }, [cur?.code]);
// ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string };
@@ -299,6 +304,19 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Export the SELECTED award as a catalog-ready JSON, stamped with a version, to
// paste over internal/award/catalog/<code>.json. A new release then ships it to
// the whole team (unedited copies auto-upgrade; edited ones are offered it).
async function exportForCatalog() {
setErr('');
if (!cur) return;
const v = Math.trunc(Number(catVer));
if (!Number.isFinite(v) || v < 1) { setErr(t('awed.catalogBadVersion')); return; }
try {
const p = await ExportAwardForCatalog(cur.code.trim().toUpperCase(), v);
if (p) setErr(t('awed.catalogExportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Import: LOOK FIRST, then ask.
//
// This used to merge by code with "imported wins", silently — import a WAPC
@@ -352,7 +370,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogContent className="max-w-6xl w-[95vw] max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-5 py-3 border-b">
<DialogTitle>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader>
@@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button>
{/* Publish the selected award to the catalog: stamp a version and write a
file to paste over internal/award/catalog/<code>.json, so a release
ships your change to the whole team. */}
{cur && (
<div className="flex items-center gap-1" title={t('awed.catalogPublishTip')}>
<input type="number" min={1} value={catVer} onChange={(e) => setCatVer(e.target.value)}
className="h-8 w-14 rounded border border-input bg-background px-1.5 text-xs font-mono" />
<Button variant="outline" onClick={exportForCatalog}>
<Download className="size-3.5 mr-1" /> {t('awed.catalogPublish')}
</Button>
</div>
)}
<div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
+160 -29
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff,
Compass, Wifi, Construction, UploadCloud, Loader2, FolderOpen, Play, Power, Check, Eye, EyeOff, Pencil,
} from 'lucide-react';
import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -13,7 +13,7 @@ import {
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
GetAntGeniusSettings, SaveAntGeniusSettings,
GetPGXLSettings, SavePGXLSettings,
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
@@ -28,7 +28,7 @@ import {
ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase,
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled,
@@ -276,7 +276,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
winkeyer: 'CW Keyer',
antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna Genius',
pgxl: 'Power Genius',
pgxl: 'Amplifier',
flex: 'FlexRadio',
relayauto: 'Relay auto-control',
audio: 'Audio devices',
@@ -647,7 +647,50 @@ function ADIFMonitorPanel() {
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
// Live SPE Expert amplifier status + OPERATE/STANDBY toggle. Module-scoped (not a
// nested component) so it isn't remounted on every parent render. Polls once a
// second while shown.
function SPEStatusCard() {
const [st, setSt] = useState<any>({ connected: false });
useEffect(() => {
let alive = true;
const tick = () => GetSPEStatus().then((s) => alive && setSt(s || {})).catch(() => {});
tick();
const id = window.setInterval(tick, 1000);
return () => { alive = false; window.clearInterval(id); };
}, []);
const operate = !!st.operate;
return (
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
<div className="flex items-center gap-2">
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
<span className="font-semibold">{st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'}</span>
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
<span className="flex-1" />
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
onClick={() => SPESetOperate(!operate).catch(() => {})}
title="Toggle OPERATE / STANDBY">
{operate ? 'OPERATE' : 'STANDBY'}
</Button>
</div>
{st.connected && (
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
<div>{st.tx ? 'TX' : 'RX'}</div>
<div>Band {st.band}</div>
<div>Pwr {st.power_level}</div>
<div>{st.output_w} W</div>
<div>SWR {Number(st.swr_ant ?? 0).toFixed(2)}</div>
<div>{st.temp_c}°C</div>
<div>{st.volt_pa} V</div>
<div>{st.curr_pa} A</div>
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning"> {st.warnings} {st.alarms}</div>}
</div>
)}
</div>
);
}
function RelayAutoPanel() {
const { t } = useI18n();
const [enabled, setEnabled] = useState(false);
@@ -698,7 +741,7 @@ function RelayAutoPanel() {
<div key={dev.id} className="rounded-md border border-border">
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
<div className="divide-y divide-border/60">
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
{Array.from({ length: (dev.labels?.length || relayCountUI(dev.type)) }, (_, i) => i + 1).map((relay) => {
const r = ruleFor(dev.id, relay);
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
return (
@@ -1013,8 +1056,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
// PowerGenius XL (4O3A) amp fan-control settings.
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
// Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP).
const [pgxl, setPgxl] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number }>(
{ enabled: false, type: 'pgxl', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200 });
// WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string };
@@ -2645,36 +2689,90 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
}
function PGXLPanelSettings() {
const isPGXL = pgxl.type === 'pgxl';
const isSerial = pgxl.transport === 'serial';
return (
<>
<SectionHeader
title="Power Genius XL"
/>
<SectionHeader title="Amplifier" />
<div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
Enable PowerGenius fan control
Enable amplifier control
</label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input
value={pgxl.host ?? ''}
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.70"
className="font-mono"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label>TCP port</Label>
<Input
type="number" min={1} max={65535}
value={pgxl.port}
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
className="font-mono"
/>
<Label>Amplifier</Label>
<Select value={pgxl.type} onValueChange={(v) => setPgxl((s) => ({ ...s, type: v, transport: v === 'pgxl' ? 'tcp' : s.transport }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pgxl">4O3A PowerGenius XL</SelectItem>
<SelectItem value="spe13">SPE Expert 1.3K-FA</SelectItem>
<SelectItem value="spe15">SPE Expert 1.5K-FA</SelectItem>
<SelectItem value="spe2k">SPE Expert 2K-FA</SelectItem>
</SelectContent>
</Select>
</div>
{/* PowerGenius is TCP-only; SPE amps connect over USB serial or an
RS232-to-Ethernet bridge, so they offer both. */}
{!isPGXL && (
<div className="space-y-1">
<Label>Connection</Label>
<Select value={pgxl.transport} onValueChange={(v) => setPgxl((s) => ({ ...s, transport: v }))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="serial">USB (serial COM)</SelectItem>
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{isSerial ? (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>COM port</Label>
<div className="flex items-center gap-2">
<Select value={pgxl.com_port || '_'} onValueChange={(v) => setPgxl((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Input type="number" min={1200} value={pgxl.baud}
onChange={(e) => setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" />
</div>
</div>
) : (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2">
<Label>Host / IP</Label>
<Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.70" className="font-mono" />
</div>
<div className="space-y-1">
<Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={pgxl.port}
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" />
</div>
</div>
)}
{!isPGXL && pgxl.enabled && <SPEStatusCard />}
{!isPGXL && (
<p className="text-[10px] text-muted-foreground">
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
</p>
)}
</div>
</>
);
@@ -2807,6 +2905,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent>
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
</SelectContent>
</Select>
@@ -2837,6 +2936,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div>
</div>
</>
) : wk.engine === 'flex' ? (
<>
<p className="text-xs text-muted-foreground -mt-2">
FlexRadio keys CW through the radio's <strong>CWX</strong> keyer over the existing SmartSDR CAT connection — no WinKeyer or SmartCAT needed. It reuses the connection set in Settings → CAT, so there's nothing else to wire up here. Put a slice in CW mode. Only the speed is set from here; weight, sidetone and break-in are configured on the radio (break-in must be on for CW to actually transmit).
</p>
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
<span aria-hidden></span>
<span>
Your CAT backend is set to <strong>{catCfg.enabled ? (catCfg.backend || 'none') : 'disabled'}</strong>. Flex CWX needs the CAT backend set to <strong>FlexRadio</strong> and connected change it under Settings CAT interface, otherwise sending CW will fail.
</span>
</p>
)}
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label>Speed (WPM)</Label>
<Input type="number" min={6} max={48} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div>
</>
) : (
<>
<div className="grid grid-cols-4 gap-3">
@@ -3978,6 +4097,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
// Rename the CURRENT database (keeps all config), unlike New database which
// starts empty. The old file is removed on the next launch.
async function renameDb() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await RenameDatabase(p);
await refreshDb();
setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function resetDefault() {
try {
await ResetDatabaseToDefault();
@@ -4056,6 +4186,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="flex flex-wrap gap-2">
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button>
<Button variant="outline" size="sm" onClick={openExisting}><FolderOpen className="size-3.5" /> {t('db.openExisting')}</Button>
<Button variant="outline" size="sm" onClick={renameDb} title={t('db.renameTip')}><Pencil className="size-3.5" /> {t('db.rename')}</Button>
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</div>
@@ -12,16 +12,22 @@ import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; labels: string[] };
type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
// the generic USB-serial board, whose channel count the user picks.
const chanCount = (d: Device): number =>
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
@@ -445,11 +451,39 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
const setType = (type: string) => {
const n = RELAY_COUNT[type] ?? 5;
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
const n = chanCount({ ...device, type, channels });
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
onChange({ ...device, type, labels });
onChange({ ...device, type, channels, labels });
};
const setChannels = (channels: number) => {
const n = chanCount({ ...device, channels });
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
onChange({ ...device, channels, labels });
};
const isKM = device.type === 'kmtronic';
const isDenkovi = device.type === 'denkovi';
const isUsbRelay = device.type === 'usbrelay';
// COM ports for the generic USB-serial relay picker.
const [serialPorts, setSerialPorts] = useState<string[]>([]);
useEffect(() => {
if (!isUsbRelay) return;
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
}, [isUsbRelay]);
// Detected FTDI serials for the Denkovi picker.
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
const [detecting, setDetecting] = useState(false);
const detectDenkovi = async () => {
setDetecting(true);
try {
const list = ((await ListDenkoviDevices()) ?? []) as string[];
setDenkoviSerials(list);
// Auto-fill when there's exactly one and nothing chosen yet.
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
} catch { setDenkoviSerials([]); }
finally { setDetecting(false); }
};
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
return (
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
@@ -461,6 +495,8 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -469,6 +505,54 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div>
</div>
{(isDenkovi || isUsbRelay) && (
<div className="space-y-1 max-w-[10rem]">
<Label>{t('station.channels')}</Label>
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent>
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isDenkovi ? (
<div className="space-y-1 max-w-md">
<Label>{t('station.ftdiSerial')}</Label>
<div className="flex items-center gap-2">
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
list="denkovi-serials"
onChange={(e) => onChange({ ...device, host: e.target.value })} />
<datalist id="denkovi-serials">
{denkoviSerials.map((s) => <option key={s} value={s} />)}
</datalist>
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
{t('station.detect')}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
</div>
) : isUsbRelay ? (
<div className="space-y-1 max-w-md">
<Label>{t('station.comPort')}</Label>
<div className="flex items-center gap-2">
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
<SelectContent>
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
<RefreshCw className="size-3.5" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
</div>
) : (
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label>
@@ -487,6 +571,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</>
)}
</div>
)}
<div className="space-y-1">
<Label>{t('station.labels')}</Label>
<div className="grid grid-cols-4 gap-2">
+82 -5
View File
@@ -23,14 +23,16 @@ import { cn } from '@/lib/utils';
// ─────────────────────────────────────────────────────────────────────────────
type Bucket = { key: string; count: number };
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
type Gap = { start: string; end: string; minutes: number };
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
type Stats = {
total: number; unique_calls: number; entities: number; continents: number;
first_qso: string; last_qso: string;
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
// Period / contest metrics.
window_start: string; window_end: string; window_hours: number;
avg_per_hour: number; avg_per_active: number;
@@ -157,6 +159,47 @@ function VBars({ data, empty, height = 150, showValues, colorful }: { data: Buck
);
}
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
// categories are the subject → categorical colour in a fixed order, matching the
// mode colours used elsewhere (CW gold, phone green, data blue).
const BAND_SPLIT = [
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
] as const;
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
const peak = Math.max(1, ...data.map((d) => d.total));
return (
<div>
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
{data.map((d) => (
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
{BAND_SPLIT.map((s) => {
const v = d[s.key];
if (!v) return null;
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
})}
</div>
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
</div>
))}
</div>
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
{BAND_SPLIT.map((s) => (
<span key={s.key} className="inline-flex items-center gap-1">
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
</span>
))}
</div>
</div>
);
}
// ── Contest rate, stacked by operator ────────────────────────────────────────
// The categories (the operators) ARE the subject, so this is categorical colour,
// in the FIXED order the backend sends (busiest first). An operator therefore
@@ -462,6 +505,38 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
}
}
// ActivityCard — the "activity over time" chart with a granularity selector. Its
// own state, module-scoped so a parent re-render doesn't reset the choice. The
// timeline is the chronological month series; day/week/month/year are the cyclical
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
const ACT_GRAN = [
{ key: 'timeline', tkey: 'stats.granTimeline' },
{ key: 'day', tkey: 'stats.granDay' },
{ key: 'week', tkey: 'stats.granWeek' },
{ key: 'month', tkey: 'stats.granMonth' },
{ key: 'year', tkey: 'stats.granYear' },
] as const;
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
const [g, setG] = useState<string>('timeline');
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
return (
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<div className="mb-2 flex flex-wrap gap-1">
{ACT_GRAN.map((o) => (
<button key={o.key} type="button" onClick={() => setG(o.key)}
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
{t(o.tkey)}
</button>
))}
</div>
{g === 'timeline'
? <AreaTrend data={stats.by_month} empty={empty} />
: <VBars data={series} empty={empty} showValues height={160} />}
</Card>
);
}
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
@@ -524,9 +599,10 @@ export function StatsPanel() {
const arr = (v: any) => (Array.isArray(v) ? v : []);
setStats({
...raw,
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
rate: arr(raw.rate), gaps: arr(raw.gaps),
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
} as Stats);
@@ -740,11 +816,12 @@ export function StatsPanel() {
<Card title={t('stats.byMode')} accent="var(--chart-2)">
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
</Card>
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
<AreaTrend data={stats.by_month} empty={empty} />
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} className="lg:col-span-2" accent="var(--chart-3)">
<StackedBandBars data={stats.by_band_category} empty={empty} />
</Card>
<ActivityCard stats={stats} t={t} empty={empty} />
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
worked what, and a cap would quietly delete the 9th operator. Scrolls
instead of truncating. */}
+7 -5
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
source: 'winkeyer' | 'icom'; // CW output engine (chosen in Settings → CW Keyer)
source: 'winkeyer' | 'icom' | 'flex'; // CW output engine (chosen in Settings → CW Keyer)
breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void;
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
{source === 'icom' ? 'Icom CW' : 'WinKeyer'}
{source === 'icom' ? 'Icom CW' : source === 'flex' ? 'Flex CWX' : 'WinKeyer'}
</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" />
{source === 'icom' ? (
{source === 'icom' || source === 'flex' ? (
<span className="text-[11px] font-medium text-muted-foreground">
{connected ? t('wkp.civReady') : t('wkp.civOffline')}
{source === 'flex'
? (connected ? t('wkp.cwxReady') : t('wkp.cwxOffline'))
: (connected ? t('wkp.civReady') : t('wkp.civOffline'))}
</span>
) : !connected ? (
<>
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
<div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
{t('wkp.cwText')}
{source === 'winkeyer' && (
{(source === 'winkeyer' || source === 'flex') && (
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title={t('wkp.sendOnTypeHint')}>
<input type="checkbox" className="accent-primary" checked={sendOnType}
+22 -14
View File
@@ -54,18 +54,18 @@ const en: Dict = {
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
// Language chooser
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
'lang.english': 'English', 'lang.french': 'Français',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size', 'stats.bandSplit': 'Mode split by band', 'stats.bandSplitSub': 'CW / phone / data per band',
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month',
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'Timeline, or the last day / 7 days / 30 days / 12 months', 'stats.granTimeline': 'Timeline', 'stats.granDay': 'Day', 'stats.granWeek': 'Week', 'stats.granMonth': 'Month', 'stats.granYear': 'Year',
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
'stats.byContinentSub': 'Share of the log',
@@ -131,9 +131,9 @@ const en: Dict = {
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
'uscty.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
'sec.relayauto': 'Relay auto-control',
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
'relayauto.enable': 'Enable relay auto-control',
@@ -244,7 +244,7 @@ const en: Dict = {
'db.backend': 'Backend', 'db.configLocal': 'settings stay in the local SQLite file', 'db.connectUse': 'Connect & use',
'db.savedRestart': 'Saved. Restart OpsLog to open this logbook:', 'db.restartNow': 'Restart OpsLog', 'db.restartHint': '(reopens automatically)',
'db.current': 'Current database', 'db.customLoc': '(custom location)', 'db.default': '(default)', 'db.defaultLabel': 'Default:',
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
'db.newDb': 'New database…', 'db.openExisting': 'Open existing…', 'db.saveCopy': 'Save a copy & switch…', 'db.rename': 'Rename…', 'db.renameTip': 'Rename this database (keeps all your config). The old file is removed on the next launch.', 'db.resetDefault': 'Reset to default', 'db.quitNow': 'Quit now',
'db.host': 'Host', 'db.port': 'Port', 'db.database': 'Database', 'db.user': 'User', 'db.testCreate': 'Test & create database', 'db.testing': 'Testing…', 'db.connectedReady': 'Connected — database ready ✓', 'db.failed': 'Failed: ',
'db.mysqlHint': 'Several OpsLog instances pointed at one MySQL database see each other\'s QSOs live (refreshed every 2 s). Test & create the database, then Save & switch logbook above to start logging there.',
'db.dataLocation': 'Data location', 'db.currentDataDir': 'Current data directory',
@@ -275,7 +275,7 @@ const en: Dict = {
'adx.introPart1': 'Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, or type a custom/vendor tag (e.g. ', 'adx.introPart2': '). Stored losslessly and exported in the ', 'adx.fullMode': 'full', 'adx.introPart3': ' ADIF mode.', 'adx.addFieldPh': 'Add ADIF field…', 'adx.showDeprecated': 'Show deprecated', 'adx.noExtra': 'No extra ADIF fields. Use the picker above to add one.', 'adx.deprecated': 'deprecated', 'adx.intl': 'intl', 'adx.nonStandard': 'non-standard', 'adx.removeField': 'Remove field',
// Hardware panels (winkeyer / dvk / antgenius / flex / icom)
'wkp.sending': 'Sending…', 'wkp.connectedV': 'Connected (v{version})', 'wkp.disconnected': 'Disconnected', 'wkp.comPort': 'COM port', 'wkp.noPorts': 'No ports', 'wkp.refreshPorts': 'Refresh ports', 'wkp.connect': 'Connect', 'wkp.disconnect': 'Disconnect', 'wkp.hide': 'Hide / disable WinKeyer',
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rigs own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)',
'wkp.sourceHint': 'CW output: WK = WinKeyer hardware · CI-V = the Icom rigs own keyer (over CAT, no extra hardware)', 'wkp.civReady': 'Icom CI-V ready', 'wkp.civOffline': 'Icom not connected (Settings → CAT)', 'wkp.cwxReady': 'Flex CWX ready', 'wkp.cwxOffline': 'Flex not connected (Settings → CAT)',
'wkp.cwSpeed': 'CW speed (WPM)', 'wkp.faster': 'Faster', 'wkp.slower': 'Slower', 'wkp.cwText': 'CW text', 'wkp.sendOnTypeHint': 'Key each character live as you type (backspace removes un-sent chars)', 'wkp.sendOnType': 'send on type', 'wkp.phLive': 'Type — sent live…', 'wkp.phEnter': 'Type and press Enter to send…', 'wkp.clear': 'Clear', 'wkp.send': 'Send', 'wkp.abort': 'Abort (clear keyer buffer)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "The rig's CW keyer only transmits when break-in is SEMI or FULL. OFF keys the sidetone but stays in receive.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "won't transmit — set SEMI or FULL",
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
@@ -307,6 +307,10 @@ const en: Dict = {
'awed.builtin': 'Built-in',
'awed.awardsFolder': 'Awards folder',
'awed.awardsFolderTip': 'Every award you create is saved here as JSON, automatically. To share one, send the file. To receive one, use Import.',
'awed.catalogPublish': 'Publish to catalog…',
'awed.catalogPublishTip': 'Export this award as a catalog file (with the version on the left), to paste over internal/award/catalog/<code>.json. A new release then ships it to the whole team — copies nobody edited auto-upgrade, edited ones are offered it.',
'awed.catalogExportedTo': 'Catalog file written to:\n{path}\n\nPaste it over internal/award/catalog/<code>.json, then build and release.',
'awed.catalogBadVersion': 'Enter a version number (1 or more).',
'awed.builtinTip': 'Tick before shipping this award in the catalog. Left off, a “Reset to defaults” DELETES it on the user machine — even though you shipped it.',
'awed.protectedFlag': 'Protected',
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
@@ -364,18 +368,18 @@ const fr: Dict = {
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
'lang.english': 'English', 'lang.french': 'Français',
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille", 'stats.bandSplit': 'Répartition des modes par bande', 'stats.bandSplitSub': 'CW / phone / data par bande',
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas dOPERATOR renseigné)',
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois',
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'Chronologie, ou les derniers jour / 7 jours / 30 jours / 12 mois', 'stats.granTimeline': 'Chronologie', 'stats.granDay': 'Jour', 'stats.granWeek': 'Semaine', 'stats.granMonth': 'Mois', 'stats.granYear': 'Année',
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
'stats.byContinentSub': 'Part du journal',
@@ -440,9 +444,9 @@ const fr: Dict = {
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
'uscty.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
'sec.relayauto': 'Relais automatiques',
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
'relayauto.enable': 'Activer les relais automatiques',
@@ -540,7 +544,7 @@ const fr: Dict = {
'db.backend': 'Base de données', 'db.configLocal': 'les réglages restent dans le fichier SQLite local', 'db.connectUse': 'Connecter et utiliser',
'db.savedRestart': 'Enregistré. Redémarrez OpsLog pour ouvrir cette base :', 'db.restartNow': 'Redémarrer OpsLog', 'db.restartHint': '(réouverture automatique)',
'db.current': 'Base actuelle', 'db.customLoc': '(emplacement personnalisé)', 'db.default': '(par défaut)', 'db.defaultLabel': 'Par défaut :',
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
'db.newDb': 'Nouvelle base…', 'db.openExisting': 'Ouvrir existante…', 'db.saveCopy': 'Enregistrer une copie & basculer…', 'db.rename': 'Renommer…', 'db.renameTip': 'Renomme cette base (garde toute ta config). Lancien fichier est supprimé au prochain lancement.', 'db.resetDefault': 'Réinitialiser par défaut', 'db.quitNow': 'Quitter maintenant',
'db.host': 'Hôte', 'db.port': 'Port', 'db.database': 'Base', 'db.user': 'Utilisateur', 'db.testCreate': 'Tester & créer la base', 'db.testing': 'Test…', 'db.connectedReady': 'Connecté — base prête ✓', 'db.failed': 'Échec : ',
'db.mysqlHint': "Plusieurs instances d'OpsLog pointées vers une même base MySQL voient leurs QSO en direct (rafraîchi toutes les 2 s). Teste & crée la base, puis Enregistrer & basculer le journal ci-dessus pour commencer à logger là-bas.",
'db.dataLocation': 'Emplacement des données', 'db.currentDataDir': 'Dossier de données actuel',
@@ -568,7 +572,7 @@ const fr: Dict = {
'ctp.stopContest': 'Arrêter le contest', 'ctp.startContest': 'Démarrer le contest', 'ctp.activeHint': 'Le formulaire de saisie affiche Env/Reç et un badge DUPE ; les QSO sont marqués avec ce contest.', 'ctp.inactiveHint': 'Choisis un contest, règle la fenêtre, puis Démarrer.', 'ctp.scoreboard': 'Tableau des scores', 'ctp.estimate': 'estimation', 'ctp.qsos': 'QSO', 'ctp.mult': 'Mult', 'ctp.scoreEst': 'Score (est.)', 'ctp.last60': 'Dernières 60 min', 'ctp.band': 'Bande',
'adx.introPart1': 'Tous les champs ADIF 3.1.7 non affichés dans les autres onglets. Choisis un champ à ajouter, ou saisis un tag personnalisé/constructeur (ex. ', 'adx.introPart2': '). Stocké sans perte et exporté en mode ADIF ', 'adx.fullMode': 'complet', 'adx.introPart3': '.', 'adx.addFieldPh': 'Ajouter un champ ADIF…', 'adx.showDeprecated': 'Afficher les obsolètes', 'adx.noExtra': 'Aucun champ ADIF supplémentaire. Utilise le sélecteur ci-dessus pour en ajouter un.', 'adx.deprecated': 'obsolète', 'adx.intl': 'intl', 'adx.nonStandard': 'non standard', 'adx.removeField': 'Supprimer le champ',
'wkp.sending': 'Émission…', 'wkp.connectedV': 'Connecté (v{version})', 'wkp.disconnected': 'Déconnecté', 'wkp.comPort': 'Port COM', 'wkp.noPorts': 'Aucun port', 'wkp.refreshPorts': 'Rafraîchir les ports', 'wkp.connect': 'Connecter', 'wkp.disconnect': 'Déconnecter', 'wkp.hide': 'Masquer / désactiver le WinKeyer',
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de lIcom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)',
'wkp.sourceHint': 'Sortie CW : WK = WinKeyer matériel · CI-V = le keyer interne de lIcom (via CAT, sans matériel en plus)', 'wkp.civReady': 'Icom CI-V prêt', 'wkp.civOffline': 'Icom non connecté (Réglages → CAT)', 'wkp.cwxReady': 'Flex CWX prêt', 'wkp.cwxOffline': 'Flex non connecté (Réglages → CAT)',
'wkp.cwSpeed': 'Vitesse CW (WPM)', 'wkp.faster': 'Plus rapide', 'wkp.slower': 'Plus lent', 'wkp.cwText': 'Texte CW', 'wkp.sendOnTypeHint': 'Manipule chaque caractère en direct à la frappe (retour arrière supprime les caractères non émis)', 'wkp.sendOnType': 'émission à la frappe', 'wkp.phLive': 'Tape — émis en direct…', 'wkp.phEnter': 'Tape et appuie sur Entrée pour émettre…', 'wkp.clear': 'Effacer', 'wkp.send': 'Émettre', 'wkp.abort': 'Interrompre (vider le tampon du manipulateur)', 'wkp.stop': 'Stop',
'wkp.breakIn': 'Break-in', 'wkp.breakInHint': "Le manipulateur interne de la radio n'émet que si le break-in est SEMI ou FULL. OFF génère la tonalité mais reste en réception.", 'wkp.bkOff': 'OFF', 'wkp.bkOffWarn': "n'émettra pas — mettre SEMI ou FULL",
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
@@ -598,6 +602,10 @@ const fr: Dict = {
'awed.builtin': 'Intégré',
'awed.awardsFolder': 'Dossier awards',
'awed.awardsFolderTip': 'Chaque diplôme que tu crées est enregistré ici en JSON, automatiquement. Pour en partager un : envoie le fichier. Pour en recevoir un : Importer.',
'awed.catalogPublish': 'Publier au catalogue…',
'awed.catalogPublishTip': "Exporte ce diplôme en fichier catalogue (avec la version à gauche), à coller par-dessus internal/award/catalog/<code>.json. Une nouvelle release le diffuse à toute l'équipe — les copies non modifiées s'upgradent seules, les modifiées se le voient proposer.",
'awed.catalogExportedTo': 'Fichier catalogue écrit dans :\n{path}\n\nColle-le par-dessus internal/award/catalog/<code>.json, puis build et release.',
'awed.catalogBadVersion': 'Entre un numéro de version (1 ou plus).',
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez lutilisateur — alors que tu las livré.',
'awed.protectedFlag': 'Protégé',
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.4';
export const APP_VERSION = '0.20.5';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+23 -10
View File
@@ -11,6 +11,7 @@ import {awardref} from '../models';
import {cluster} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {spe} from '../models';
import {solar} from '../models';
import {winkeyer} from '../models';
import {alerts} from '../models';
@@ -72,8 +73,6 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function CWDecoderRunning():Promise<boolean>;
export function ChatAvailable():Promise<boolean>;
export function CheckForUpdate():Promise<main.UpdateInfo>;
@@ -168,6 +167,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
export function ExportAward(arg1:string):Promise<string>;
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
@@ -190,8 +191,12 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):Promise<void>;
export function FlexBackspaceCW(arg1:number):Promise<void>;
export function FlexMox(arg1:boolean):Promise<void>;
export function FlexSendCW(arg1:string):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>;
export function FlexSetAGCThreshold(arg1:number):Promise<void>;
@@ -222,6 +227,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetFilter(arg1:number,arg2:number):Promise<void>;
export function FlexSetKeySpeed(arg1:number):Promise<void>;
export function FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>;
@@ -278,6 +285,8 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
export function FlexSetXITFreq(arg1:number):Promise<void>;
export function FlexStopCW():Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>;
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
@@ -314,10 +323,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>;
export function GetCWDecoderPitch():Promise<number>;
export function GetCatalogCodes():Promise<Array<string>>;
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
@@ -396,6 +405,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
export function GetRotatorSettings():Promise<main.RotatorSettings>;
export function GetSPEStatus():Promise<spe.Status>;
export function GetSecretStatus():Promise<main.SecretStatus>;
export function GetSlotStats():Promise<qso.SlotStats>;
@@ -418,6 +429,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
@@ -536,6 +549,8 @@ export function ListContests():Promise<Array<contest.Def>>;
export function ListCountries():Promise<Array<string>>;
export function ListDenkoviDevices():Promise<Array<string>>;
export function ListOperatingTree():Promise<Array<operating.Station>>;
export function ListProfiles():Promise<Array<profile.Profile>>;
@@ -676,6 +691,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>;
export function RenameDatabase(arg1:string):Promise<void>;
export function RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
@@ -702,6 +719,8 @@ export function RotatorStop():Promise<void>;
export function RunBackupNow():Promise<string>;
export function SPESetOperate(arg1:boolean):Promise<void>;
export function SaveADIFFile():Promise<string>;
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
@@ -782,8 +801,6 @@ export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>;
export function SetCWDecoderPitch(arg1:number):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
@@ -802,12 +819,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SetUltrabeamDirection(arg1:number):Promise<void>;
export function StartCWDecoder():Promise<void>;
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
export function StopCWDecoder():Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
+44 -20
View File
@@ -102,10 +102,6 @@ export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
}
export function CWDecoderRunning() {
return window['go']['main']['App']['CWDecoderRunning']();
}
export function ChatAvailable() {
return window['go']['main']['App']['ChatAvailable']();
}
@@ -294,6 +290,10 @@ export function ExportAward(arg1) {
return window['go']['main']['App']['ExportAward'](arg1);
}
export function ExportAwardForCatalog(arg1, arg2) {
return window['go']['main']['App']['ExportAwardForCatalog'](arg1, arg2);
}
export function ExportAwards() {
return window['go']['main']['App']['ExportAwards']();
}
@@ -338,10 +338,18 @@ export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
}
export function FlexBackspaceCW(arg1) {
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
}
export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1);
}
export function FlexSendCW(arg1) {
return window['go']['main']['App']['FlexSendCW'](arg1);
}
export function FlexSetAGCMode(arg1) {
return window['go']['main']['App']['FlexSetAGCMode'](arg1);
}
@@ -402,6 +410,10 @@ export function FlexSetFilter(arg1, arg2) {
return window['go']['main']['App']['FlexSetFilter'](arg1, arg2);
}
export function FlexSetKeySpeed(arg1) {
return window['go']['main']['App']['FlexSetKeySpeed'](arg1);
}
export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1);
}
@@ -514,6 +526,10 @@ export function FlexSetXITFreq(arg1) {
return window['go']['main']['App']['FlexSetXITFreq'](arg1);
}
export function FlexStopCW() {
return window['go']['main']['App']['FlexStopCW']();
}
export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1);
}
@@ -586,14 +602,14 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState']();
}
export function GetCWDecoderPitch() {
return window['go']['main']['App']['GetCWDecoderPitch']();
}
export function GetCatalogCodes() {
return window['go']['main']['App']['GetCatalogCodes']();
}
export function GetChangelog() {
return window['go']['main']['App']['GetChangelog']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
@@ -750,6 +766,10 @@ export function GetRotatorSettings() {
return window['go']['main']['App']['GetRotatorSettings']();
}
export function GetSPEStatus() {
return window['go']['main']['App']['GetSPEStatus']();
}
export function GetSecretStatus() {
return window['go']['main']['App']['GetSecretStatus']();
}
@@ -794,6 +814,10 @@ export function GetUltrabeamStatus() {
return window['go']['main']['App']['GetUltrabeamStatus']();
}
export function GetWhatsNew() {
return window['go']['main']['App']['GetWhatsNew']();
}
export function GetWinkeyerSettings() {
return window['go']['main']['App']['GetWinkeyerSettings']();
}
@@ -1030,6 +1054,10 @@ export function ListCountries() {
return window['go']['main']['App']['ListCountries']();
}
export function ListDenkoviDevices() {
return window['go']['main']['App']['ListDenkoviDevices']();
}
export function ListOperatingTree() {
return window['go']['main']['App']['ListOperatingTree']();
}
@@ -1310,6 +1338,10 @@ export function RemovePassphrase(arg1) {
return window['go']['main']['App']['RemovePassphrase'](arg1);
}
export function RenameDatabase(arg1) {
return window['go']['main']['App']['RenameDatabase'](arg1);
}
export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
}
@@ -1362,6 +1394,10 @@ export function RunBackupNow() {
return window['go']['main']['App']['RunBackupNow']();
}
export function SPESetOperate(arg1) {
return window['go']['main']['App']['SPESetOperate'](arg1);
}
export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile']();
}
@@ -1522,10 +1558,6 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1);
}
export function SetCWDecoderPitch(arg1) {
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
}
export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
}
@@ -1562,18 +1594,10 @@ export function SetUltrabeamDirection(arg1) {
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
}
export function StartCWDecoder() {
return window['go']['main']['App']['StartCWDecoder']();
}
export function StationSetRelay(arg1, arg2, arg3) {
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
}
export function StopCWDecoder() {
return window['go']['main']['App']['StopCWDecoder']();
}
export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1);
}
+105
View File
@@ -1728,6 +1728,24 @@ export namespace main {
this.path = source["path"];
}
}
export class ChangelogEntry {
version: string;
date: string;
en: string[];
fr: string[];
static createFrom(source: any = {}) {
return new ChangelogEntry(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.version = source["version"];
this.date = source["date"];
this.en = source["en"];
this.fr = source["fr"];
}
}
export class ChatMessage {
id: number;
operator: string;
@@ -2161,8 +2179,12 @@ export namespace main {
}
export class PGXLSettings {
enabled: boolean;
type: string;
transport: string;
host: string;
port: number;
com_port: string;
baud: number;
static createFrom(source: any = {}) {
return new PGXLSettings(source);
@@ -2171,8 +2193,12 @@ export namespace main {
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"];
this.type = source["type"];
this.transport = source["transport"];
this.host = source["host"];
this.port = source["port"];
this.com_port = source["com_port"];
this.baud = source["baud"];
}
}
export class POTAUnmatched {
@@ -2600,6 +2626,7 @@ export namespace main {
host: string;
user?: string;
pass?: string;
channels?: number;
labels: string[];
static createFrom(source: any = {}) {
@@ -2614,6 +2641,7 @@ export namespace main {
this.host = source["host"];
this.user = source["user"];
this.pass = source["pass"];
this.channels = source["channels"];
this.labels = source["labels"];
}
}
@@ -3401,6 +3429,26 @@ export namespace qslcard {
export namespace qso {
export class BandCategory {
band: string;
cw: number;
phone: number;
data: number;
total: number;
static createFrom(source: any = {}) {
return new BandCategory(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.band = source["band"];
this.cw = source["cw"];
this.phone = source["phone"];
this.data = source["data"];
this.total = source["total"];
}
}
export class BandMode {
band: string;
mode: string;
@@ -3890,12 +3938,17 @@ export namespace qso {
confirmed_any: number;
by_mode: Bucket[];
by_band: Bucket[];
by_band_category: BandCategory[];
by_operator: Bucket[];
by_station: Bucket[];
by_continent: Bucket[];
top_entities: Bucket[];
by_year: Bucket[];
by_month: Bucket[];
by_hour: Bucket[];
by_day7: Bucket[];
by_day30: Bucket[];
by_month12: Bucket[];
window_start: string;
window_end: string;
window_hours: number;
@@ -3929,12 +3982,17 @@ export namespace qso {
this.confirmed_any = source["confirmed_any"];
this.by_mode = this.convertValues(source["by_mode"], Bucket);
this.by_band = this.convertValues(source["by_band"], Bucket);
this.by_band_category = this.convertValues(source["by_band_category"], BandCategory);
this.by_operator = this.convertValues(source["by_operator"], Bucket);
this.by_station = this.convertValues(source["by_station"], Bucket);
this.by_continent = this.convertValues(source["by_continent"], Bucket);
this.top_entities = this.convertValues(source["top_entities"], Bucket);
this.by_year = this.convertValues(source["by_year"], Bucket);
this.by_month = this.convertValues(source["by_month"], Bucket);
this.by_hour = this.convertValues(source["by_hour"], Bucket);
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
this.by_day30 = this.convertValues(source["by_day30"], Bucket);
this.by_month12 = this.convertValues(source["by_month12"], Bucket);
this.window_start = source["window_start"];
this.window_end = source["window_end"];
this.window_hours = source["window_hours"];
@@ -4098,6 +4156,53 @@ export namespace solar {
}
export namespace spe {
export class Status {
connected: boolean;
last_error?: string;
model?: string;
operate: boolean;
tx: boolean;
input?: string;
band?: string;
power_level?: string;
output_w: number;
swr_atu: number;
swr_ant: number;
volt_pa: number;
curr_pa: number;
temp_c: number;
warnings?: string;
alarms?: string;
static createFrom(source: any = {}) {
return new Status(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"];
this.last_error = source["last_error"];
this.model = source["model"];
this.operate = source["operate"];
this.tx = source["tx"];
this.input = source["input"];
this.band = source["band"];
this.power_level = source["power_level"];
this.output_w = source["output_w"];
this.swr_atu = source["swr_atu"];
this.swr_ant = source["swr_ant"];
this.volt_pa = source["volt_pa"];
this.curr_pa = source["curr_pa"];
this.temp_c = source["temp_c"];
this.warnings = source["warnings"];
this.alarms = source["alarms"];
}
}
}
export namespace udp {
export class Config {
+790 -3
View File
@@ -4,11 +4,27 @@
"name": "Départements Français Métropolitains",
"valid": true,
"protected": true,
"ref_display": "name",
"type": "QSOFIELDS",
"field": "note",
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b",
"or_rules": [
{
"field": "address",
"match_by": "code",
"pattern": "\\b(\\d{2})\\d{3}\\b",
"prefix": "D"
},
{
"field": "qth",
"match_by": "code",
"pattern": "\\b(\\d{2})\\d{3}\\b",
"prefix": "D"
}
],
"dxcc_filter": [
227
227,
214
],
"confirm": [
"lotw",
@@ -18,6 +34,777 @@
"lotw"
],
"total": 96,
"builtin": true
}
"builtin": true,
"version": 2
},
"references": [
{
"code": "D01",
"name": "Ain",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D02",
"name": "Aisne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D03",
"name": "Allier",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D04",
"name": "Alpes-de-Haute-Provence",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D05",
"name": "Hautes-Alpes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D06",
"name": "Alpes-Maritimes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D07",
"name": "Ardèche",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D08",
"name": "Ardennes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D09",
"name": "Ariège",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D10",
"name": "Aube",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D11",
"name": "Aude",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D12",
"name": "Aveyron",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D13",
"name": "Bouches-du-Rhône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D14",
"name": "Calvados",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D15",
"name": "Cantal",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D16",
"name": "Charente",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D17",
"name": "Charente-Maritime",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D18",
"name": "Cher",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D19",
"name": "Corrèze",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D21",
"name": "Côte-d'Or",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D22",
"name": "Côtes-d'Armor",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D23",
"name": "Creuse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D24",
"name": "Dordogne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D25",
"name": "Doubs",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D26",
"name": "Drôme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D27",
"name": "Eure",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D28",
"name": "Eure-et-Loir",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D29",
"name": "Finistère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D2A",
"name": "Corse-du-Sud",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D2B",
"name": "Haute-Corse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D30",
"name": "Gard",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D31",
"name": "Haute-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D32",
"name": "Gers",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D33",
"name": "Gironde",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D34",
"name": "Hérault",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D35",
"name": "Ille-et-Vilaine",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D36",
"name": "Indre",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D37",
"name": "Indre-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D38",
"name": "Isère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D39",
"name": "Jura",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D40",
"name": "Landes",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D41",
"name": "Loir-et-Cher",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D42",
"name": "Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D43",
"name": "Haute-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D44",
"name": "Loire-Atlantique",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D45",
"name": "Loiret",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D46",
"name": "Lot",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D47",
"name": "Lot-et-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D48",
"name": "Lozère",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D49",
"name": "Maine-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D50",
"name": "Manche",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D51",
"name": "Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D52",
"name": "Haute-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D53",
"name": "Mayenne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D54",
"name": "Meurthe-et-Moselle",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D55",
"name": "Meuse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D56",
"name": "Morbihan",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D57",
"name": "Moselle",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D58",
"name": "Nièvre",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D59",
"name": "Nord",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D60",
"name": "Oise",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D61",
"name": "Orne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D62",
"name": "Pas-de-Calais",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D63",
"name": "Puy-de-Dôme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D64",
"name": "Pyrénées-Atlantiques",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D65",
"name": "Hautes-Pyrénées",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D66",
"name": "Pyrénées-Orientales",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D67",
"name": "Bas-Rhin",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D68",
"name": "Haut-Rhin",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D69",
"name": "Rhône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D70",
"name": "Haute-Saône",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D71",
"name": "Saône-et-Loire",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D72",
"name": "Sarthe",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D73",
"name": "Savoie",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D74",
"name": "Haute-Savoie",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D75",
"name": "Paris",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D76",
"name": "Seine-Maritime",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D77",
"name": "Seine-et-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D78",
"name": "Yvelines",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D79",
"name": "Deux-Sèvres",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D80",
"name": "Somme",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D81",
"name": "Tarn",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D82",
"name": "Tarn-et-Garonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D83",
"name": "Var",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D84",
"name": "Vaucluse",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D85",
"name": "Vendée",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D86",
"name": "Vienne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D87",
"name": "Haute-Vienne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D88",
"name": "Vosges",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D89",
"name": "Yonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D90",
"name": "Territoire de Belfort",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D91",
"name": "Essonne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D92",
"name": "Hauts-de-Seine",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D93",
"name": "Seine-Saint-Denis",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D94",
"name": "Val-de-Marne",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
},
{
"code": "D95",
"name": "Val-d'Oise",
"dxcc": 227,
"group": "",
"subgrp": "",
"valid": true
}
]
}
+8
View File
@@ -394,6 +394,14 @@ type FlexController interface {
SetCWSpeed(int) error
SetCWPitch(int) error
SetCWBreakInDelay(int) error
// CWX keyer — buffered CW keying via SmartSDR's CWX subsystem, so a Flex needs
// no WinKeyer / SmartCAT. SendCW queues text (the radio buffers and keys it);
// StopCW clears the buffer, aborting the send. BackspaceCW removes the last n
// not-yet-keyed characters from the buffer (un-send while sending — for
// type-ahead corrections).
SendCW(string) error
StopCW() error
BackspaceCW(int) error
SetCWSidetone(bool) error
SetSidetoneLevel(int) error
SetCWFilter(int) error
+31
View File
@@ -1581,6 +1581,37 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
return nil
}
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
// characters fed while it's already sending append and key in order (the basis
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
func (f *Flex) SendCW(text string) error {
text = strings.TrimRight(text, "\r\n")
if strings.TrimSpace(text) == "" {
return nil
}
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
f.send(`cwx send "` + esc + `"`)
return nil
}
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
func (f *Flex) StopCW() error {
f.send("cwx clear")
return nil
}
// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
// corrections (backspacing a mistyped call before the radio has keyed it).
func (f *Flex) BackspaceCW(n int) error {
if n < 1 {
n = 1
}
f.send(fmt.Sprintf("cwx erase %d", n))
return nil
}
func (f *Flex) SetCWSidetone(on bool) error {
f.mu.Lock()
f.tx.cwSidetone = on
-389
View File
@@ -1,389 +0,0 @@
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
// tone (level-independent, so weak or strong signals both key cleanly), an
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
// marks/spaces to Morse and then to characters.
//
// It is deliberately self-contained and dependency-free so it can be unit
// tested with synthetic signals. As with every audio CW decoder, weak signals
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
// out of the decode.
package cwdecode
import (
"math"
"sort"
"sync/atomic"
)
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
type Status struct {
WPM int `json:"wpm"`
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
Active bool `json:"active"` // a tone is currently keyed down
}
// Decoder consumes PCM and emits decoded characters via onChar (one or more
// characters at a time, including " " for word gaps) and periodic onStatus.
type Decoder struct {
fs int
hop int // samples between updates
win int // Goertzel window length
freqs []float64
coeffs []float64 // precomputed 2*cos(w) per freq
ring []float64 // last win samples
acc int // samples since last hop
mags []float64 // per-bin magnitude this hop
nbuf []float64 // scratch for the noise percentile
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
// another goroutine, so it's atomic.
targetHz atomic.Int32
// Pitch lock.
lockIdx int // index of the locked tone bin, -1 = unlocked
candIdx int // current argmax candidate while unlocked
candHops int // consecutive hops the candidate has been dominant
quietHops int // consecutive key-up hops while locked
noise float64 // broadband noise estimate (percentile of bins)
relockHops int // quiet hops before the lock is released
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
// Adaptive keying envelope, on the LOCKED bin's magnitude.
peak, floor float64
state bool // true = mark (key down)
stateHops int
dotHops float64 // adaptive dot length, in hops
markCount int // marks seen since lock (fast WPM adaptation while small)
elem []byte // current "." / "-" run for the in-progress character
charEmitted bool
wordEmitted bool
lastPitch float64
lastRMS float64
statusEvery int
sinceStatus int
onChar func(string)
onStatus func(Status)
}
var morse = map[string]byte{
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
"-.--": 'Y', "--..": 'Z',
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
}
// New builds a decoder for the given sample rate. onChar receives decoded text
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
if sampleRate <= 0 {
sampleRate = 16000
}
d := &Decoder{
fs: sampleRate,
hop: sampleRate / 250, // ~4 ms resolution
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
dotHops: 15, // ~20 WPM seed
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
lockIdx: -1,
candIdx: -1,
statusEvery: 25, // ~10 Hz
onChar: onChar,
onStatus: onStatus,
}
if d.hop < 1 {
d.hop = 1
}
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
// Candidate CW tones: 4001000 Hz every 25 Hz. Deliberately NOT lower: strong
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
for f := 400.0; f <= 1000.0; f += 25 {
d.freqs = append(d.freqs, f)
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
}
d.mags = make([]float64, len(d.freqs))
d.nbuf = make([]float64, len(d.freqs))
return d
}
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
// nearestBin returns the bin index closest to hz.
func (d *Decoder) nearestBin(hz float64) int {
best, bestD := 0, math.Inf(1)
for i, f := range d.freqs {
if dd := math.Abs(f - hz); dd < bestD {
bestD, best = dd, i
}
}
return best
}
// Reset clears decode state (e.g. when the user re-arms the decoder).
func (d *Decoder) Reset() {
d.ring = d.ring[:0]
d.acc = 0
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
d.peak, d.floor = 0, 0
d.state = false
d.stateHops = 0
d.dotHops = 15
d.markCount = 0
d.elem = d.elem[:0]
d.charEmitted, d.wordEmitted = false, false
}
// Process feeds a block of mono samples through the decoder.
func (d *Decoder) Process(samples []int16) {
for _, s := range samples {
d.ring = append(d.ring, float64(s))
if len(d.ring) > d.win {
d.ring = d.ring[len(d.ring)-d.win:]
}
d.acc++
if d.acc >= d.hop && len(d.ring) >= d.win {
d.acc = 0
d.analyze()
d.step()
}
}
}
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
// pitch lock (which tone the envelope detector then follows).
func (d *Decoder) analyze() {
n := float64(len(d.ring))
var sumSq float64
maxIdx, maxMag := 0, -1.0
for i, coeff := range d.coeffs {
var s1, s2 float64
for _, x := range d.ring {
s0 := x + coeff*s1 - s2
s2 = s1
s1 = s0
}
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
d.mags[i] = m
if m > maxMag {
maxMag = m
maxIdx = i
}
}
for _, x := range d.ring {
sumSq += x * x
}
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
if th := int(d.targetHz.Load()); th > 0 {
d.lockIdx = d.nearestBin(float64(th))
d.lastPitch = d.freqs[d.lockIdx]
return
}
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
copy(d.nbuf, d.mags)
sort.Float64s(d.nbuf)
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
if d.lockIdx < 0 {
if maxIdx == d.candIdx {
d.candHops++
} else {
d.candIdx, d.candHops = maxIdx, 1
}
snr := maxMag / (d.noise + 1e-9)
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
// don't eat the first element of a strong signal), a marginal/weak tone
// locks after a couple of stable hops (so we don't lock onto pure noise).
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
d.lockIdx = maxIdx
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
d.quietHops = 0
d.markCount = 0 // relearn WPM fast for this new signal
}
}
if d.lockIdx >= 0 {
d.lastPitch = d.freqs[d.lockIdx]
} else {
d.lastPitch = 0
}
}
// step runs the adaptive envelope on the locked bin and the timing state
// machine, one hop. The envelope adapts to the signal level (not an absolute
// threshold), so weak and strong signals both key correctly.
func (d *Decoder) step() {
on := false
if d.lockIdx >= 0 {
m := d.mags[d.lockIdx]
// Peak: fast attack, slow release.
if m > d.peak {
d.peak += (m - d.peak) * 0.4
} else {
d.peak += (m - d.peak) * 0.02
}
// Floor: drops fast toward the signal, but only RISES between marks (when
// keyed up). Letting the floor rise during a long dash would shrink the
// span until the dash drops below the threshold and fragments into dots —
// the cause of the "all dots" garbage on a strong clean signal.
if m < d.floor {
d.floor += (m - d.floor) * 0.4
} else if !d.state {
d.floor += (m - d.floor) * 0.02
}
span := d.peak - d.floor
// The frozen floor already stops dashes fragmenting, so keep balanced
// thresholds: low enough that short inter-element GAPS are still seen
// (otherwise elements merge into >7-symbol runs that decode to nothing).
if span > d.floor*0.3+1e-9 {
onTh := d.floor + 0.55*span
offTh := d.floor + 0.35*span
if d.state {
on = m > offTh
} else {
on = m > onTh
}
}
// Release the lock after a long quiet so we can retune to a new signal.
if on {
d.quietHops = 0
} else {
d.quietHops++
if d.quietHops > d.relockHops {
// End of the over: flush any pending character and drop a word
// space so the next transmission starts a fresh word (the word-gap
// timer above can't fire once the lock is gone).
if len(d.elem) > 0 && !d.charEmitted {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && d.onChar != nil {
d.onChar(" ")
d.wordEmitted = true
}
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
}
}
}
if on == d.state {
d.stateHops++
if !d.state {
d.spaceProgress()
}
} else {
if d.state {
d.endMark(d.stateHops)
}
d.state = on
d.stateHops = 1
if on {
d.charEmitted, d.wordEmitted = false, false
}
}
d.emitStatus(on)
}
// endMark classifies a finished key-down run as a dot or dash and adapts the
// dot-length estimate. Runs shorter than a third of a dot are rejected as
// clicks/noise.
func (d *Decoder) endMark(hops int) {
h := float64(hops)
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
if h < d.dotHops*0.35 || h < 4 {
return
}
if h > d.dotHops*2 {
d.elem = append(d.elem, '-')
d.adaptDot(h / 3)
} else {
d.elem = append(d.elem, '.')
d.adaptDot(h)
}
}
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
// to ~560 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the
// opening marks: a fast alpha let short noise blips (misclassified as dots) drag
// the dot-length down to the clamp within a few marks — the "60 WPM, all dits"
// garbage. The slow EMA is self-correcting because genuine marks pull it back up.
func (d *Decoder) adaptDot(obs float64) {
const alpha = 0.2
d.markCount++
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
d.dotHops = 5
}
if d.dotHops > 55 {
d.dotHops = 55
}
}
// spaceProgress flushes the current character once the gap exceeds a character
// gap, and a word space once it exceeds a word gap.
func (d *Decoder) spaceProgress() {
g := float64(d.stateHops)
if !d.charEmitted && g > d.dotHops*2 {
d.flushChar()
d.charEmitted = true
}
if !d.wordEmitted && g > d.dotHops*5 {
if d.onChar != nil {
d.onChar(" ")
}
d.wordEmitted = true
}
}
// flushChar looks up the accumulated element string and emits the character.
func (d *Decoder) flushChar() {
if len(d.elem) == 0 {
return
}
if c, ok := morse[string(d.elem)]; ok {
if d.onChar != nil {
d.onChar(string(c))
}
} else if d.onChar != nil && len(d.elem) <= 7 {
// Only flag a genuinely Morse-shaped but unknown char with "?". An
// over-long element run is noise — drop it silently rather than spam "?".
d.onChar("?")
}
d.elem = d.elem[:0]
}
func (d *Decoder) emitStatus(on bool) {
d.sinceStatus++
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
return
}
d.sinceStatus = 0
hopMs := float64(d.hop) / float64(d.fs) * 1000
wpm := 0
if d.dotHops > 0 {
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
}
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
}
-201
View File
@@ -1,201 +0,0 @@
package cwdecode
import (
"math"
"strings"
"testing"
)
// reverse Morse map for the synthesizer.
func charToMorse() map[byte]string {
m := map[byte]string{}
for code, ch := range morse {
m[ch] = code
}
return m
}
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
}
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
dot := fs * 1200 / (wpm * 1000) // samples per dot
c2m := charToMorse()
var out []int16
phase := 0.0
dphi := 2 * math.Pi * pitch / float64(fs)
tone := func(n int) {
for i := 0; i < n; i++ {
out = append(out, int16(amp*math.Sin(phase)))
phase += dphi
}
}
silence := func(n int) {
for i := 0; i < n; i++ {
out = append(out, 0)
}
}
silence(fs / 4) // 250 ms lead-in for AGC warmup
for i := 0; i < len(msg); i++ {
ch := msg[i]
if ch == ' ' {
silence(7 * dot)
continue
}
code := c2m[ch]
for j := 0; j < len(code); j++ {
if code[j] == '.' {
tone(dot)
} else {
tone(3 * dot)
}
silence(dot) // inter-element gap
}
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
}
silence(fs / 4)
return out
}
func TestDecodeCleanSignal(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
// Repeat so AGC warm-up only costs the first word.
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
// Feed in small chunks like the live capture would.
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "PARIS") {
t.Fatalf("decoded %q, want it to contain PARIS", got)
}
}
func TestDecodeWithQRM(t *testing.T) {
const fs = 16000
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
// quieter, sending different text. The pitch lock should hold on the target.
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
mix := make([]int16, len(target))
for i := range target {
v := int(target[i])
if i < len(qrm) {
v += int(qrm[i])
}
if v > 32767 {
v = 32767
} else if v < -32768 {
v = -32768
}
mix[i] = int16(v)
}
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
for i := 0; i < len(mix); i += 256 {
end := i + 256
if end > len(mix) {
end = len(mix)
}
d.Process(mix[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "PARIS") {
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
}
}
func TestDecodeFirstCharStrong(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
// Strong signal: the very first element (T = a dash) must not be eaten by
// lock acquisition. Output should begin with the first character.
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(strings.TrimSpace(sb.String()))
if !strings.HasPrefix(got, "TEST") {
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
}
}
func TestDecodeWithAmplitudeRipple(t *testing.T) {
const fs = 16000
// A real signal's tone amplitude wobbles within a mark; if the floor chases
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
rp := 0.0
for i := range samples {
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
}
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
for i := 0; i < len(samples); i += 256 {
end := i + 256
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "TEST DE OM") {
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
}
}
func TestDecodeCQFixedPitch(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
d.SetTarget(700) // fixed pitch like the user's manual override
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if n := strings.Count(got, "CQ"); n < 2 {
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
}
}
func TestDecodeNumbersAndProsign(t *testing.T) {
const fs = 16000
var sb strings.Builder
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
for i := 0; i < len(samples); i += 200 {
end := i + 200
if end > len(samples) {
end = len(samples)
}
d.Process(samples[i:end])
}
got := strings.ToUpper(sb.String())
if !strings.Contains(got, "599") {
t.Fatalf("decoded %q, want it to contain 599", got)
}
}
+104 -2
View File
@@ -24,6 +24,30 @@ type Bucket struct {
Count int `json:"count"`
}
// BandCategory is one band's QSO count split by mode category (CW / phone /
// digital), for the per-band mode-split chart.
type BandCategory struct {
Band string `json:"band"`
CW int `json:"cw"`
Phone int `json:"phone"`
Data int `json:"data"`
Total int `json:"total"`
}
// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes
// (SSB and the digital-voice family) count as phone; CW is CW; everything else is
// data. Mirrors the frontend's mode colouring.
func modeCategory(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
return "cw"
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION":
return "phone"
default:
return "data"
}
}
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
// are the expensive minutes: they are where the score went.
type Gap struct {
@@ -129,8 +153,9 @@ type Stats struct {
ConfirmedAny int `json:"confirmed_any"`
// Breakdowns, each sorted most → least (bands keep frequency order).
ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"`
ByMode []Bucket `json:"by_mode"`
ByBand []Bucket `json:"by_band"`
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
ByOperator []Bucket `json:"by_operator"`
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
ByContinent []Bucket `json:"by_continent"`
@@ -138,6 +163,14 @@ type Stats struct {
ByYear []Bucket `json:"by_year"` // chronological
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
// the "activity over time" chart's day/week/month/year granularity selector.
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
// ── Period / contest metrics ──
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
// nothing, but across a contest weekend it is the score. The window is the
@@ -268,6 +301,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
entities = map[int]struct{}{}
modeC = map[string]int{}
bandC = map[string]int{}
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
opC = map[string]int{}
stationC = map[string]int{}
contC = map[string]int{}
@@ -338,6 +372,20 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
}
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
bandC[b]++
bc := bandCat[b]
if bc == nil {
bc = &BandCategory{Band: b}
bandCat[b] = bc
}
switch modeCategory(mode.String) {
case "cw":
bc.CW++
case "phone":
bc.Phone++
default:
bc.Data++
}
bc.Total++
}
// op was resolved above (with the operator filter applied).
opC[op]++
@@ -414,6 +462,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
}
return a < b
})
// Per-band CW/phone/data split, in the SAME band-plan order as ByBand.
s.ByBandCategory = []BandCategory{}
for _, b := range s.ByBand {
if bc := bandCat[b.Key]; bc != nil {
s.ByBandCategory = append(s.ByBandCategory, *bc)
}
}
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
// chart would invent activity that never happened. A gap in the log is real
@@ -422,11 +477,58 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
s.ByMonth = fillMonths(monthC, first, last)
s.periodMetrics(times, from, to, first, last)
// Anchor the rolling activity windows to the period end when filtered, else to
// now (so "week" is the last 7 days ending today, like the operator expects).
ref := to
if ref.IsZero() {
ref = time.Now()
}
s.recentSeries(times, ref)
s.ensureNonNil()
return s, nil
}
// recentSeries builds the rolling chronological activity windows for the chart's
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
// filtered, else now). Real dates in order — today and the days before it — so the
// axis reads chronologically and empty days are just zero, not misordered/wrapped.
func (s *Stats) recentSeries(times []entry, ref time.Time) {
ref = ref.UTC()
day0 := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) // midnight of the anchor day
hour := make([]int, 24)
daily := map[string]int{}
monthly := map[string]int{}
for _, e := range times {
t := e.t.UTC()
if t.Year() == ref.Year() && t.YearDay() == ref.YearDay() {
hour[t.Hour()]++
}
daily[t.Format("2006-01-02")]++
monthly[t.Format("2006-01")]++
}
// Day: the anchor day, hour by hour.
for h := 0; h < 24; h++ {
s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
}
// Week: the last 7 days ending today (today is last). Label = weekday + day.
for i := 6; i >= 0; i-- {
d := day0.AddDate(0, 0, -i)
s.ByDay7 = append(s.ByDay7, Bucket{Key: d.Format("Mon 2"), Count: daily[d.Format("2006-01-02")]})
}
// Month: the last 30 days.
for i := 29; i >= 0; i-- {
d := day0.AddDate(0, 0, -i)
s.ByDay30 = append(s.ByDay30, Bucket{Key: d.Format("2/1"), Count: daily[d.Format("2006-01-02")]})
}
// Year: the last 12 months.
m0 := time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, time.UTC)
for i := 11; i >= 0; i-- {
m := m0.AddDate(0, -i, 0)
s.ByMonth12 = append(s.ByMonth12, Bucket{Key: m.Format("Jan 06"), Count: monthly[m.Format("2006-01")]})
}
}
// ensureNonNil replaces every nil slice with an empty one.
//
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
+34
View File
@@ -0,0 +1,34 @@
//go:build !windows
// The Denkovi USB board is driven through FTDI's D2XX bit-bang API (ftd2xx.dll),
// which OpsLog only wires up on Windows. This stub keeps the package building on
// other platforms; every call reports the board is unavailable.
package relaydev
import (
"context"
"fmt"
)
type denkoviStub struct{ count int }
// NewDenkovi returns a stub on non-Windows builds.
func NewDenkovi(serial string, count int) Device {
if count != 4 {
count = 8
}
return denkoviStub{count: count}
}
func (s denkoviStub) Count() int { return s.count }
func (denkoviStub) Status(context.Context) ([]bool, error) {
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
}
func (denkoviStub) Set(context.Context, int, bool) error {
return fmt.Errorf("Denkovi USB relay board is only supported on Windows")
}
// ListDenkovi has no devices to report off Windows.
func ListDenkovi() ([]string, error) {
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
}
+185
View File
@@ -0,0 +1,185 @@
//go:build windows
// Denkovi USB 8-channel relay board (FT245RL). Despite enumerating as a virtual
// COM port, this board is NOT driven by serial/ASCII: the FT245's 8 data lines
// each drive a relay, controlled through FTDI's D2XX "bit-bang" mode. One byte
// written = the 8 relays at once (bit 0 = relay 1). We load ftd2xx.dll at runtime
// (no CGO) and call the D2XX API directly, exactly as the vendor's tool does
// (which addresses the board by its FTDI serial, e.g. "DAE0006K").
package relaydev
import (
"context"
"fmt"
"strings"
"sync"
"syscall"
"unsafe"
)
var (
ftdll = syscall.NewLazyDLL("ftd2xx.dll")
procOpenEx = ftdll.NewProc("FT_OpenEx")
procClose = ftdll.NewProc("FT_Close")
procSetBitMode = ftdll.NewProc("FT_SetBitMode")
procSetBaudRate = ftdll.NewProc("FT_SetBaudRate")
procWrite = ftdll.NewProc("FT_Write")
procPurge = ftdll.NewProc("FT_Purge")
procCreateInfo = ftdll.NewProc("FT_CreateDeviceInfoList")
procGetInfoDetail = ftdll.NewProc("FT_GetDeviceInfoDetail")
)
const (
ftOpenBySerial = 1 // FT_OPEN_BY_SERIAL_NUMBER
ftBitModeSyncBB = 0x04 // FT_BITMODE_SYNC_BITBANG (the mode Denkovi documents)
ftPurgeRX = 1 // FT_PURGE_RX
)
func ftOK(r uintptr) bool { return r == 0 } // FT_OK == 0
type denkovi struct {
serial string
count int
mu sync.Mutex
shadow byte // last output byte (bit n = relay n+1); authoritative state
h uintptr // FT handle
opened bool
}
// NewDenkovi builds a driver for a Denkovi USB relay board (4 or 8 relays)
// identified by its FTDI serial number (shown by the vendor tool / FT_PROG,
// e.g. "DAE0006K"). count defaults to 8; a 4-relay board just uses the low 4 bits.
func NewDenkovi(serial string, count int) Device {
if count != 4 && count != 8 {
count = 8
}
return &denkovi{serial: strings.TrimSpace(serial), count: count}
}
func (d *denkovi) Count() int { return d.count }
// ensureOpen opens the board and puts it in synchronous bit-bang mode with all 8
// lines as outputs. Idempotent.
func (d *denkovi) ensureOpen() error {
if d.opened {
return nil
}
if err := ftdll.Load(); err != nil {
return fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software: %w", err)
}
if d.serial == "" {
return fmt.Errorf("no FTDI serial number set for the Denkovi board")
}
ser, err := syscall.BytePtrFromString(d.serial)
if err != nil {
return err
}
var h uintptr
if r, _, _ := procOpenEx.Call(uintptr(unsafe.Pointer(ser)), ftOpenBySerial, uintptr(unsafe.Pointer(&h))); !ftOK(r) {
return fmt.Errorf("cannot open Denkovi board %q (FT_OpenEx status %d) — is it connected and not in use by another app?", d.serial, r)
}
// All 8 lines output, synchronous bit-bang.
if r, _, _ := procSetBitMode.Call(h, 0xFF, ftBitModeSyncBB); !ftOK(r) {
procClose.Call(h)
return fmt.Errorf("FT_SetBitMode failed (status %d)", r)
}
procSetBaudRate.Call(h, 9600) // bit-bang pin-update clock; relays don't need speed
d.h = h
d.opened = true
// Make the hardware match our shadow (starts all-off on first open).
return d.writeLocked()
}
// writeLocked pushes the shadow byte to the relays. Caller holds d.mu.
func (d *denkovi) writeLocked() error {
var written uint32
b := d.shadow
if r, _, _ := procWrite.Call(d.h, uintptr(unsafe.Pointer(&b)), 1, uintptr(unsafe.Pointer(&written))); !ftOK(r) {
return fmt.Errorf("FT_Write failed (status %d)", r)
}
// Synchronous bit-bang echoes each written byte into the RX buffer; drop it so
// it doesn't fill over the life of the connection.
procPurge.Call(d.h, ftPurgeRX)
return nil
}
func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > d.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
}
d.mu.Lock()
defer d.mu.Unlock()
if err := d.ensureOpen(); err != nil {
return err
}
bit := byte(1) << uint(relay-1) // relay 1 → bit 0
if on {
d.shadow |= bit
} else {
d.shadow &^= bit
}
return d.writeLocked()
}
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
d.mu.Lock()
defer d.mu.Unlock()
if err := d.ensureOpen(); err != nil {
return nil, err
}
out := make([]bool, d.count)
for i := 0; i < d.count; i++ {
out[i] = d.shadow&(byte(1)<<uint(i)) != 0
}
return out, nil
}
// ListDenkovi returns the FTDI serial numbers of connected devices, for the
// settings UI to pick from. Requires ftd2xx.dll.
func ListDenkovi() ([]string, error) {
if err := ftdll.Load(); err != nil {
return nil, fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software")
}
var n uint32
if r, _, _ := procCreateInfo.Call(uintptr(unsafe.Pointer(&n))); !ftOK(r) {
return nil, fmt.Errorf("FT_CreateDeviceInfoList failed (status %d)", r)
}
var out []string
for i := uint32(0); i < n; i++ {
var flags, typ, id, loc uint32
serial := make([]byte, 16)
desc := make([]byte, 64)
var h uintptr
r, _, _ := procGetInfoDetail.Call(
uintptr(i),
uintptr(unsafe.Pointer(&flags)), uintptr(unsafe.Pointer(&typ)),
uintptr(unsafe.Pointer(&id)), uintptr(unsafe.Pointer(&loc)),
uintptr(unsafe.Pointer(&serial[0])), uintptr(unsafe.Pointer(&desc[0])),
uintptr(unsafe.Pointer(&h)),
)
if !ftOK(r) {
continue
}
if s := cstr(serial); s != "" {
out = append(out, s)
}
}
return out, nil
}
// cstr trims a C string (up to the first NUL) from a fixed buffer.
func cstr(b []byte) string {
if i := indexByte(b, 0); i >= 0 {
b = b[:i]
}
return strings.TrimSpace(string(b))
}
func indexByte(b []byte, c byte) int {
for i := range b {
if b[i] == c {
return i
}
}
return -1
}
+89
View File
@@ -0,0 +1,89 @@
package relaydev
import (
"context"
"fmt"
"strings"
"sync"
"go.bug.st/serial"
)
// serialRelay drives the common cheap USB-serial relay boards (CH340 / LCUS-1
// style) that use the "A0" command protocol over a virtual COM port:
//
// [0xA0] [relay 1-based] [state 0|1] [checksum] checksum = (0xA0+relay+state) & 0xFF
//
// e.g. relay 1 ON = A0 01 01 A2, relay 1 OFF = A0 01 00 A1. These boards are
// write-only (no state readback), so Status reflects a shadow of what we sent.
// Channel count varies by board (1/2/4/8/16), so it is configurable.
type serialRelay struct {
portName string
count int
baud int
mu sync.Mutex
port serial.Port
shadow []bool
}
// NewSerialRelay builds a driver for a CH340/LCUS-style USB-serial relay board on
// the given COM port with `count` channels (defaults to 8).
func NewSerialRelay(port string, count int) Device {
if count < 1 {
count = 8
}
return &serialRelay{portName: strings.TrimSpace(port), count: count, baud: 9600, shadow: make([]bool, count)}
}
func (s *serialRelay) Count() int { return s.count }
func (s *serialRelay) ensureOpen() error {
if s.port != nil {
return nil
}
if s.portName == "" {
return fmt.Errorf("no COM port set for the USB relay board")
}
p, err := serial.Open(s.portName, &serial.Mode{BaudRate: s.baud})
if err != nil {
return fmt.Errorf("open %s: %w", s.portName, err)
}
s.port = p
return nil
}
func (s *serialRelay) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > s.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, s.count)
}
s.mu.Lock()
defer s.mu.Unlock()
if err := s.ensureOpen(); err != nil {
return err
}
st := byte(0)
if on {
st = 1
}
r := byte(relay)
frame := []byte{0xA0, r, st, byte(0xA0) + r + st} // last byte = checksum
if _, err := s.port.Write(frame); err != nil {
// Drop the handle so the next call reopens (USB unplugged / port reset).
s.port.Close()
s.port = nil
return fmt.Errorf("write to %s: %w", s.portName, err)
}
s.shadow[relay-1] = on
return nil
}
func (s *serialRelay) Status(ctx context.Context) ([]bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.ensureOpen(); err != nil {
return nil, err
}
out := make([]bool, s.count)
copy(out, s.shadow)
return out, nil
}
+295
View File
@@ -0,0 +1,295 @@
// Package spe drives the SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA amplifiers over
// their proprietary serial protocol (SPE "Application Programmer's Guide" rev 1.1).
// The amp is reached either directly over USB (a virtual COM port) or over TCP via
// an RS232-to-Ethernet bridge — both are just an io.ReadWriteCloser to this code.
//
// Wire format (host → amp): 0x55 0x55 0x55 | CNT | DATA… | CHK
// CNT = number of DATA bytes, CHK = sum(DATA) mod 256.
// Status reply (amp → host): 0xAA 0xAA 0xAA | LEN | <LEN CSV bytes> | chk0 chk1 | CR LF
// LEN is 0x43 (67); the payload is 19 comma-separated fixed fields.
//
// This MVP implements the two commands anchored by worked examples in the guide:
// OPERATE (0x0D, toggles STANDBY↔OPERATE) and STATUS (0x90). Other keystroke codes
// exist but the guide's command table did not extract unambiguously, so they are
// left out rather than risk sending the wrong key to the amplifier.
package spe
import (
"bufio"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
)
const (
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE
cmdStatus byte = 0x90 // request the status string
syncHost = 0x55
syncAmp = 0xAA
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
pollEvery = 800 * time.Millisecond
)
// Status is the decoded amplifier state for the UI.
type Status struct {
Connected bool `json:"connected"`
LastError string `json:"last_error,omitempty"`
Model string `json:"model,omitempty"` // "20K" / "13K"
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
TX bool `json:"tx"` // true = transmitting
Input string `json:"input,omitempty"` // "1" / "2"
Band string `json:"band,omitempty"` // raw 2-char band code
PowerLevel string `json:"power_level,omitempty"` // L / M / H
OutputW int `json:"output_w"`
SWRATU float64 `json:"swr_atu"`
SWRAnt float64 `json:"swr_ant"`
VoltPA float64 `json:"volt_pa"`
CurrPA float64 `json:"curr_pa"`
TempC int `json:"temp_c"` // heatsink (upper) temperature
Warnings string `json:"warnings,omitempty"`
Alarms string `json:"alarms,omitempty"`
}
// Config selects the transport.
type Config struct {
Transport string // "serial" | "tcp"
ComPort string // serial
Baud int // serial
Host string // tcp
Port int // tcp
}
type Client struct {
cfg Config
mu sync.Mutex // serialises access to the connection
conn io.ReadWriteCloser
r *bufio.Reader
statusMu sync.RWMutex
status Status
stop chan struct{}
running bool
}
func New(cfg Config) *Client {
if cfg.Baud <= 0 {
cfg.Baud = 115200
}
return &Client{cfg: cfg, stop: make(chan struct{})}
}
func (c *Client) Start() error {
if c.running {
return nil
}
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setErr(err error) {
c.statusMu.Lock()
c.status.Connected = false
c.status.LastError = err.Error()
c.statusMu.Unlock()
}
// Operate toggles the amplifier between STANDBY and OPERATE (the amp has a single
// OPERATE key that flips the state, so we send it only when the desired state
// differs from the last-read one).
func (c *Client) Operate(on bool) error {
if c.GetStatus().Operate == on {
return nil
}
return c.sendCmd(cmdOperate)
}
// ToggleOperate flips STANDBY/OPERATE unconditionally.
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
defer t.Stop()
for {
select {
case <-c.stop:
return
case <-t.C:
if err := c.ensureConn(); err != nil {
c.setErr(fmt.Errorf("connect: %w", err))
continue
}
if err := c.sendCmd(cmdStatus); err != nil {
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
c.setErr(err)
continue
}
c.readStatus()
}
}
}
func (c *Client) ensureConn() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil
}
var rwc io.ReadWriteCloser
var err error
if c.cfg.Transport == "tcp" {
var nc net.Conn
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
rwc = nc
} else {
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
}
if err != nil {
return err
}
c.conn = rwc
c.r = bufio.NewReader(rwc)
return nil
}
func (c *Client) dropLocked() {
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.r = nil
}
}
// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
// the code itself.
func (c *Client) sendCmd(code byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
if nc, ok := c.conn.(net.Conn); ok {
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
}
pkt := []byte{syncHost, syncHost, syncHost, 0x01, code, code}
_, err := c.conn.Write(pkt)
return err
}
// readStatus reads one amp packet and, when it's a status string, decodes it. ACK
// packets (short) are consumed and ignored.
func (c *Client) readStatus() {
c.mu.Lock()
r := c.r
if nc, ok := c.conn.(net.Conn); ok && nc != nil {
_ = nc.SetReadDeadline(time.Now().Add(ioTimeout))
}
c.mu.Unlock()
if r == nil {
return
}
// Sync on three 0xAA bytes.
run := 0
for run < 3 {
b, err := r.ReadByte()
if err != nil {
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
c.setErr(err)
return
}
if b == syncAmp {
run++
} else {
run = 0
}
}
length, err := r.ReadByte()
if err != nil {
return
}
data := make([]byte, int(length))
if _, err := io.ReadFull(r, data); err != nil {
return
}
// Status strings are the long ones (LEN 0x43 = 67). Short packets are ACKs
// (1 checksum byte, no CRLF) — nothing else to consume for those.
if length >= 40 {
// consume the 2 checksum bytes + CR LF
_, _ = r.Discard(4)
c.decodeCSV(string(data))
} else {
_, _ = r.Discard(1) // ACK checksum
}
}
// decodeCSV parses the 19-field comma-separated status payload.
func (c *Client) decodeCSV(payload string) {
f := strings.Split(payload, ",")
get := func(i int) string {
if i < len(f) {
return strings.TrimSpace(f[i])
}
return ""
}
pf := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
pi := func(s string) int { v, _ := strconv.Atoi(strings.TrimSpace(s)); return v }
c.statusMu.Lock()
defer c.statusMu.Unlock()
c.status.Connected = true
c.status.LastError = ""
c.status.Model = get(0)
c.status.Operate = get(1) == "O"
c.status.TX = get(2) == "T"
c.status.Input = get(4)
c.status.Band = get(5)
c.status.PowerLevel = get(8)
c.status.OutputW = pi(get(9))
c.status.SWRATU = pf(get(10))
c.status.SWRAnt = pf(get(11))
c.status.VoltPA = pf(get(12))
c.status.CurrPA = pf(get(13))
c.status.TempC = pi(get(14))
if w := get(17); w != "" && w != "N" {
c.status.Warnings = w
} else {
c.status.Warnings = ""
}
if a := get(18); a != "" && a != "N" {
c.status.Alarms = a
} else {
c.status.Alarms = ""
}
}
+15 -2
View File
@@ -159,10 +159,23 @@ func (a *App) liveStatusOperator() (op, station string) {
// 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) {
nb := strings.ToUpper(strings.TrimSpace(band))
nm := strings.ToUpper(strings.TrimSpace(mode))
a.liveActMu.Lock()
changed := a.liveFreqHz != freqHz || a.liveBand != nb || a.liveMode != nm
a.liveFreqHz = freqHz
a.liveBand = strings.ToUpper(strings.TrimSpace(band))
a.liveMode = strings.ToUpper(strings.TrimSpace(mode))
a.liveBand = nb
a.liveMode = nm
// Push a fresh row PROMPTLY when the operator's freq/band/mode changes, instead
// of waiting for the next 15 s heartbeat — otherwise "who's on air" showed a
// stale band and took up to a minute to catch up. Debounced so spinning the VFO
// doesn't hammer the shared MySQL.
if changed {
if a.livePublishTimer != nil {
a.livePublishTimer.Stop()
}
a.livePublishTimer = time.AfterFunc(1500*time.Millisecond, func() { a.publishLiveStatus() })
}
a.liveActMu.Unlock()
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.4"
appVersion = "0.20.5"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.