Compare commits

...
24 Commits
Author SHA1 Message Date
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
rouggy 77a752efe3 chore: release v0.20.4 2026-07-20 12:06:32 +02:00
rouggy 781fbfaa30 fix: wire up File - Exit (was disabled and had no handler)
The File menu's Exit item was hard-disabled and its action had no case in the
menu dispatcher, so it did nothing. Enable it and route file.exit to QuitApp,
which runs the same graceful beforeClose shutdown as the window close button.
2026-07-20 12:05:12 +02:00
rouggy 61c11c0fe3 fix: clear entry-form award refs when the callsign is wiped
Live award detection merges pickable refs into award_refs per call, but nothing
dropped them when the call was cleared, so a previous call's ref lingered onto
the next (IT9AOT's ref still showing after typing F4BPO, both listed in the F3
Awards tab). Clear award_refs the moment the callsign field is emptied.
2026-07-20 11:57:04 +02:00
rouggy 64b746f007 feat: materialize award references into the QSO row (award_refs column)
Award refs are now computed once and stored on the QSO as a JSON column
(award_refs, e.g. {"DDFM":"74","WAJA":"12"}) instead of recomputing the whole
award engine on every grid page load. Written on log/edit/UDP-import and
bulk-recomputed when an award definition or reference list changes; a one-time
per-logbook backfill materializes pre-existing QSOs. The grid reads the column
directly like any other field, so it's fast and available everywhere (including
the shared MySQL logbook).

Also fix per-profile grid column layout: the DB copy was already per-profile,
but the localStorage cache used one global namespace and shadowed it, so a
profile switch kept the previous profile's columns/widths. gridPrefs now scopes
the cache by active profile id and the grids remount on profile:changed.
2026-07-20 11:51:49 +02:00
rouggy 9cc72c7575 fix: OmniRig reads VFO A first (FreqA→Freq fallback), fixing IC-7610 CAT
The 7610-only FreqA gate broke CAT on the 7610 when FreqA was momentarily 0.
Match DXHunter/WSJT-X: read FreqA first for all rigs, fall back to the generic
Freq (IC-9100 etc.), then FreqB. OmniRig's generic Freq maps to VFO B on the
7610, which is why keying off FreqA is correct.
2026-07-20 10:11:10 +02:00
rouggy 9e4f43f648 fix: offline operators removed from live_status + award-column visibility
Live status: when an operator goes offline (no QSO in the window) OpsLog now
DELETEs its live_status row instead of just flipping online=0, so a status page
that lists present/recent rows shows them as gone without having to read the
online column — the whole point of the feature. The row reappears on the next
QSO.

Recent QSOs columns: toggling an award column (e.g. DDFM) rebuilt columnDefs and
made AG Grid re-apply every colDef hide default, so hidden non-award columns
(QTH/Grid) came back and restoringRef stayed stuck true (saving off). The
restore-saved-state effect now also runs on awardShown changes, so the other
columns keep the user's visibility.
2026-07-20 09:55:14 +02:00
rouggy 5f044b959e chore: release v0.20.3 2026-07-20 01:25:28 +02:00
rouggy 68a49be8c1 feat: rate meter OP/Team display, on-air-only station widget, column-filter count
Rate meter: on a shared MySQL logbook it now shows two lines — OP (the active
operator, accent) and Team (all operators, foreground) — each with the 10/60-min
QSOs/hour; single-op keeps the one-line display.

Live-stations widget: only stations currently on air are listed (offline ones
are hidden rather than greyed).

Recent QSOs footer: 'Showing X of Y' now reflects the AG-Grid COLUMN filters
(the funnel icons) — the grid reports its displayed row count, so filtering a
column shows how many QSOs remain instead of always the full total.
2026-07-20 01:08:17 +02:00
rouggy 8eb82d6cdb feat: QSO rate meter — per-operator AND team totals
GetQSORate now returns both the active operator's rate (their own performance)
and the whole station's rate (all operators combined). RecentRateBreakdown
computes both in one scan of the recent rows (per-operator + all-operators),
replacing RecentRate.
2026-07-20 01:08:17 +02:00
rouggy d327db3f57 fix: auto-update relaunch — clear Mark-of-the-Web + wait for exit
The new build downloaded and OpsLog quit, but never came back. Two Windows
causes: the freshly written exe carried the internet Zone.Identifier mark, so
SmartScreen wanted to prompt "are you sure you want to open this?" — invisibly,
since we launch it programmatically — and silently blocked the launch; and
starting the new exe while the old one was still exiting raced the single-
instance mutex.

Now the swapped exe's Zone.Identifier stream is removed, and the relaunch is
done by a detached, hidden PowerShell that Wait-Process's on our PID (so we're
fully gone and the mutex is free) before Start-Process'ing the new exe.
2026-07-19 19:53:19 +02:00
27 changed files with 2357 additions and 307 deletions
+476 -46
View File
@@ -182,10 +182,16 @@ const (
keyAntGeniusHost = "antgenius.host" keyAntGeniusHost = "antgenius.host"
keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN) keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius. // 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" keyPGXLEnabled = "pgxl.enabled"
keyPGXLHost = "pgxl.host" keyPGXLHost = "pgxl.host"
keyPGXLPort = "pgxl.port" 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. // WinKeyer CW keyer (serial) — Hardware → CW Keyer.
keyWKEnabled = "winkeyer.enabled" keyWKEnabled = "winkeyer.enabled"
@@ -699,6 +705,20 @@ func (a *App) startup(ctx context.Context) {
usingDefault = false 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 { if err := os.MkdirAll(filepath.Dir(a.dbPath), 0o755); err != nil {
a.startupErr = "cannot create db folder: " + err.Error() a.startupErr = "cannot create db folder: " + err.Error()
fmt.Println("OpsLog:", a.startupErr) fmt.Println("OpsLog:", a.startupErr)
@@ -812,6 +832,7 @@ func (a *App) startup(ctx context.Context) {
applog.Printf("startup: logbook backend = %s", backend) applog.Printf("startup: logbook backend = %s", backend)
a.logDb = logbookConn a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn) a.qso = qso.NewRepo(logbookConn)
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
@@ -1339,6 +1360,11 @@ func copyFileData(src, dst string) error {
type dbPointer struct { type dbPointer struct {
DBPath string `json:"db_path"` DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"` 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") } func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
@@ -1653,6 +1679,7 @@ func (a *App) PickSaveDatabase() (string, error) {
} }
return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ return wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{
Title: "Save the OpsLog database to…", Title: "Save the OpsLog database to…",
DefaultDirectory: filepath.Dir(a.dbPath),
DefaultFilename: "opslog.db", DefaultFilename: "opslog.db",
Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}}, Filters: []wruntime.FileFilter{{DisplayName: "SQLite database (*.db)", Pattern: "*.db"}},
}) })
@@ -1696,6 +1723,44 @@ func (a *App) MoveDatabase(dest string) error {
return writeDBPointer(a.dataDir, dest) 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 // CreateDatabase creates a fresh, empty logbook at dest (schema migrated) and
// points OpsLog at it for the next launch. dest must not already exist. // points OpsLog at it for the next launch. dest must not already exist.
func (a *App) CreateDatabase(dest string) error { func (a *App) CreateDatabase(dest string) error {
@@ -1873,6 +1938,7 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
q.ID = id q.ID = id
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
a.noteLiveQSO() // multi-op: flip this operator back "online" a.noteLiveQSO() // multi-op: flip this operator back "online"
a.materializeAwardRefs(q) // stamp award_refs so the grid columns show at once
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT). // Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
wruntime.EventsEmit(a.ctx, "qso:logged", id) wruntime.EventsEmit(a.ctx, "qso:logged", id)
a.saveQSORecording(&q) a.saveQSORecording(&q)
@@ -2450,6 +2516,14 @@ func (a *App) migrateAwardDefs() {
applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version) applog.Printf("awards: %s updated from the catalog (v%d)", code, defByCode(migrated, code).Version)
a.reseedRefsFromCatalog(code) 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 // Version-gated correction of the built-in awards' Validate sources, which
// an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED // an earlier version wrongly set equal to Confirm (so VALIDATED == CONFIRMED
// even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate // even for paper-QSL-only entities). Re-apply the canonical Confirm/Validate
@@ -2515,6 +2589,7 @@ func (a *App) SaveAwardDefs(defs []award.Def) error {
return err return err
} }
go a.mirrorAwardsToFolder(defs) go a.mirrorAwardsToFolder(defs)
a.recomputeAwardRefsAsync() // definitions changed → refresh every row's award_refs
return nil return nil
} }
@@ -3412,6 +3487,10 @@ func (a *App) invalidateAwardStats() {
// flips qsl_rcvd flags on existing rows). // flips qsl_rcvd flags on existing rows).
func (a *App) RescanAwards() error { func (a *App) RescanAwards() error {
a.invalidateAwardStats() 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 return nil
} }
@@ -3639,24 +3718,31 @@ func (a *App) ComputeQSOAwardRefs(q qso.QSO) ([]QSOAwardRef, error) {
return out, nil return out, nil
} }
// AwardRefsForQSOs returns, per QSO id, a map of award code → the reference(s) // awardMatCtx bundles the precompiled award state needed to derive a QSO's
// that QSO contributes to (joined when several). Powers the per-award columns in // materialised references. Built ONCE (newAwardMatCtx) and reused across a batch
// the Recent QSOs / Worked-before grids. The reference metadata is computed ONCE // or a full recompute so award.Compute's per-pattern compilation isn't repeated.
// for the whole batch so a page of QSOs stays cheap. type awardMatCtx struct {
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) { defs []award.Def
out := map[int64]map[string]string{} metas map[string][]award.RefMeta
if a.qso == nil || len(ids) == 0 { fieldByCode map[string]string
return out, nil dispByCode map[string]string
nameOf award.NameResolver
} }
func (a *App) newAwardMatCtx() awardMatCtx {
defs := a.awardDefs() defs := a.awardDefs()
metas := a.awardRefMetas(defs)
fieldByCode := map[string]string{} fieldByCode := map[string]string{}
dispByCode := map[string]string{} dispByCode := map[string]string{}
for _, d := range defs { for _, d := range defs {
fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field)) fieldByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.Field))
dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay)) dispByCode[strings.ToUpper(d.Code)] = strings.ToLower(strings.TrimSpace(d.RefDisplay))
} }
nameOf := func(field, ref string) string { return awardMatCtx{
defs: defs,
metas: a.awardRefMetas(defs),
fieldByCode: fieldByCode,
dispByCode: dispByCode,
nameOf: func(field, ref string) string {
switch field { switch field {
case "dxcc": case "dxcc":
if n, err := strconv.Atoi(ref); err == nil { if n, err := strconv.Atoi(ref); err == nil {
@@ -3666,24 +3752,28 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
return continentName(ref) return continentName(ref)
} }
return "" return ""
},
} }
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error { }
// awardRefLabels derives, for one QSO, the map of award code → the reference(s)
// that QSO contributes to (joined when several), formatted per the award's
// RefDisplay choice (ref / name / both; DXCC shows the country name by default).
func (a *App) awardRefLabels(ac awardMatCtx, q qso.QSO) map[string]string {
a.enrichQSOForAwards(&q) a.enrichQSOForAwards(&q)
results := award.Compute(defs, []qso.QSO{q}, metas, nameOf) results := award.Compute(ac.defs, []qso.QSO{q}, ac.metas, ac.nameOf)
m := map[string]string{} m := map[string]string{}
for i := range results { for i := range results {
r := &results[i] r := &results[i]
code := strings.ToUpper(r.Code) code := strings.ToUpper(r.Code)
dxccField := fieldByCode[code] == "dxcc" dxccField := ac.fieldByCode[code] == "dxcc"
var refs []string var refs []string
for _, rf := range r.Refs { for _, rf := range r.Refs {
if !rf.Worked { if !rf.Worked {
continue continue
} }
// Per-award display choice: ref (default), name (description), or
// both. DXCC keeps showing the country name under the default.
label := rf.Ref label := rf.Ref
switch dispByCode[code] { switch ac.dispByCode[code] {
case "name": case "name":
if rf.Name != "" { if rf.Name != "" {
label = rf.Name label = rf.Name
@@ -3703,7 +3793,154 @@ func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error)
m[code] = strings.Join(refs, ", ") m[code] = strings.Join(refs, ", ")
} }
} }
if len(m) > 0 { return m
}
// awardRefsJSONFor returns awardRefLabels marshalled to a compact JSON object
// keyed by award code, or "" when the QSO contributes to no award (blank column).
func (a *App) awardRefsJSONFor(ac awardMatCtx, q qso.QSO) string {
m := a.awardRefLabels(ac, q)
if len(m) == 0 {
return ""
}
b, err := json.Marshal(m)
if err != nil {
return ""
}
return string(b)
}
// materializeAwardRefs computes ONE QSO's award references and stores them on the
// row (the award_refs column) so the grid columns read them straight from the DB.
// Cheap: an in-memory award.Compute plus one targeted UPDATE. Called on every
// log / edit / UDP-import. Never fatal — a failure just leaves the column stale
// until the next recompute.
func (a *App) materializeAwardRefs(q qso.QSO) {
if a.qso == nil || q.ID == 0 {
return
}
ac := a.newAwardMatCtx()
if err := a.qso.SetAwardRefs(a.ctx, q.ID, a.awardRefsJSONFor(ac, q)); err != nil {
applog.Printf("award_refs: store for qso %d failed: %v", q.ID, err)
}
}
// materializeAwardRefsForIDs recomputes award_refs for a specific set of QSOs
// (e.g. after a bulk cty/QRZ/Club Log update or a bulk edit changed fields that
// feed awards). Writes only the changed rows, in one transaction.
func (a *App) materializeAwardRefsForIDs(ids []int64) {
if a.qso == nil || len(ids) == 0 {
return
}
ac := a.newAwardMatCtx()
changes := map[int64]string{}
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
changes[q.ID] = js
}
return nil
})
if err != nil {
applog.Printf("award_refs: recompute for ids failed: %v", err)
return
}
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
applog.Printf("award_refs: batch write for ids failed: %v", err)
}
}
// RecomputeAllAwardRefs rebuilds every QSO's materialised award references. Run
// when an award definition or reference list changes (stored labels would
// otherwise go stale) and once after upgrade to backfill existing rows. Rows
// whose result is unchanged are skipped; the changed ones are written in a single
// transaction (SetAwardRefsBatch) so even a large logbook on a remote MySQL is
// one round-trip's worth of work rather than N. Returns how many rows changed.
func (a *App) RecomputeAllAwardRefs() (int, error) {
if a.qso == nil {
return 0, fmt.Errorf("db not initialized")
}
ac := a.newAwardMatCtx()
// Collect updates during the scan; DON'T write mid-iteration (a nested query on
// the same connection can deadlock SQLite / trip MySQL "commands out of sync").
changes := map[int64]string{}
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
if js := a.awardRefsJSONFor(ac, q); js != q.AwardRefs {
changes[q.ID] = js
}
return nil
})
if err != nil {
return 0, err
}
if err := a.qso.SetAwardRefsBatch(a.ctx, changes); err != nil {
return 0, err
}
return len(changes), nil
}
// recomputeAwardRefsAsync runs a full recompute off the UI goroutine and, when
// done, tells the frontend to reload so the refreshed award columns show. Used
// wherever the set of matches could shift for MANY rows at once: an award
// definition / reference-list change, or a bulk ADIF import.
func (a *App) recomputeAwardRefsAsync() {
go func() {
n, err := a.RecomputeAllAwardRefs()
if err != nil {
applog.Printf("award_refs: bulk recompute failed: %v", err)
return
}
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
}
}()
}
// 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.
// 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
// most once per logbook — guarded by a per-profile flag — in the background so it
// never delays startup, even on a large remote MySQL logbook.
func (a *App) backfillAwardRefsOnce() {
if a.qso == nil || a.settings == nil {
return
}
if done, _ := a.settings.Get(a.ctx, keyAwardsMaterialized); done == "1" {
return
}
go func() {
n, err := a.RecomputeAllAwardRefs()
if err != nil {
applog.Printf("award_refs: initial backfill failed: %v", err)
return
}
_ = a.settings.Set(a.ctx, keyAwardsMaterialized, "1")
applog.Printf("award_refs: backfilled %d QSO(s)", n)
if a.ctx != nil && n > 0 {
wruntime.EventsEmit(a.ctx, "awards:recomputed", n)
}
}()
}
// AwardRefsForQSOs returns, per QSO id, the award code → reference(s) map. It is
// the live (non-materialised) path, kept for callers that want fresh values
// without touching the DB. The grid now reads the stored award_refs column
// instead, but this stays available and is the single source of the label logic.
func (a *App) AwardRefsForQSOs(ids []int64) (map[int64]map[string]string, error) {
out := map[int64]map[string]string{}
if a.qso == nil || len(ids) == 0 {
return out, nil
}
ac := a.newAwardMatCtx()
err := a.qso.IterateByIDs(a.ctx, ids, func(q qso.QSO) error {
if m := a.awardRefLabels(ac, q); len(m) > 0 {
out[q.ID] = m out[q.ID] = m
} }
return nil return nil
@@ -3749,6 +3986,17 @@ func (a *App) GetAwardReferenceMeta() ([]AwardRefMeta, error) {
// UpdateAwardReferenceList downloads the latest reference list for an award and // UpdateAwardReferenceList downloads the latest reference list for an award and
// replaces the stored set. Returns the new reference count. // replaces the stored set. Returns the new reference count.
func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) { func (a *App) UpdateAwardReferenceList(code string) (AwardRefMeta, error) {
meta, err := a.updateAwardReferenceList(code)
if err == nil {
a.recomputeAwardRefsAsync() // new reference list → labels change → refresh rows
}
return meta, err
}
// updateAwardReferenceList is the recompute-free core, so DownloadAllReferenceLists
// can update several lists in a loop and trigger ONE bulk recompute at the end
// rather than one per list.
func (a *App) updateAwardReferenceList(code string) (AwardRefMeta, error) {
if a.awardRefs == nil { if a.awardRefs == nil {
return AwardRefMeta{}, fmt.Errorf("db not initialized") return AwardRefMeta{}, fmt.Errorf("db not initialized")
} }
@@ -3785,7 +4033,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
if !awardref.CanUpdate(code) { if !awardref.CanUpdate(code) {
continue continue
} }
meta, err := a.UpdateAwardReferenceList(code) meta, err := a.updateAwardReferenceList(code)
if err != nil { if err != nil {
parts = append(parts, fmt.Sprintf("%s ✗", code)) parts = append(parts, fmt.Sprintf("%s ✗", code))
if firstErr == nil { if firstErr == nil {
@@ -3795,6 +4043,7 @@ func (a *App) DownloadAllReferenceLists() (string, error) {
} }
parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count)) parts = append(parts, fmt.Sprintf("%s %d", code, meta.Count))
} }
a.recomputeAwardRefsAsync() // one bulk recompute after all lists updated
return strings.Join(parts, " · "), firstErr return strings.Join(parts, " · "), firstErr
} }
@@ -3838,6 +4087,7 @@ func (a *App) DeleteAwardReference(code, refCode string) error {
} }
a.markAwardEdited(code) a.markAwardEdited(code)
a.mirrorAwards() a.mirrorAwards()
a.recomputeAwardRefsAsync()
return nil return nil
} }
@@ -3881,6 +4131,7 @@ func (a *App) ReplaceAwardReferences(code string, refs []awardref.Ref) (int, err
} }
a.markAwardEdited(code) a.markAwardEdited(code)
a.mirrorAwards() a.mirrorAwards()
a.recomputeAwardRefsAsync() // reference list replaced → refresh every row's award_refs
return n, nil return n, nil
} }
@@ -4016,6 +4267,65 @@ func (a *App) ExportAward(code string) (string, error) {
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code) 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. // exportAwardBundle writes the given award codes (nil = all) to a JSON bundle.
func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) { func (a *App) exportAwardBundle(codes []string, defaultName, title string) (string, error) {
if a.awardRefs == nil { if a.awardRefs == nil {
@@ -4485,6 +4795,7 @@ func (a *App) UpdateQSO(q qso.QSO) error {
err := a.qso.Update(a.ctx, q) err := a.qso.Update(a.ctx, q)
if err == nil { if err == nil {
a.invalidateAwardStats() a.invalidateAwardStats()
a.materializeAwardRefs(q) // fields may have changed → refresh award_refs
} }
return err return err
} }
@@ -4518,31 +4829,31 @@ func (a *App) GetOperators() ([]string, error) {
// QSORate is the live QSO-rate meter shown in the header: how many QSOs were // QSORate is the live QSO-rate meter shown in the header: how many QSOs were
// logged in the trailing 10 and 60 minutes. // logged in the trailing 10 and 60 minutes.
type QSORate struct { type QSORate struct {
Last10 int `json:"last10"` Last10 int `json:"last10"` // active operator, last 10 min
Last60 int `json:"last60"` Last60 int `json:"last60"` // active operator, last 60 min
TeamLast10 int `json:"team_last10"` // ALL operators (the whole station), last 10 min
TeamLast60 int `json:"team_last60"` // ALL operators, last 60 min
} }
// GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes. // GetQSORate returns the number of QSOs logged in the last 10 and 60 minutes, both
// Cheap (scans only the most recent rows); polled by the header and refreshed on // for the active operator (their own performance) AND for all operators combined
// each qso:logged event. // (the team/station rate). Cheap (one scan of the most recent rows); polled by the
// header and refreshed on each qso:logged event.
func (a *App) GetQSORate() QSORate { func (a *App) GetQSORate() QSORate {
if a.qso == nil { if a.qso == nil {
return QSORate{} return QSORate{}
} }
// Per-operator on a shared logbook: count only the ACTIVE profile's operator
// so each op sees their own performance, not the cumulative station rate. An
// empty operator (single-op / station owner) matches all their QSOs.
operator := "" operator := ""
if a.profiles != nil { if a.profiles != nil {
if p, err := a.profiles.Active(a.ctx); err == nil { if p, err := a.profiles.Active(a.ctx); err == nil {
operator = p.Operator operator = p.Operator
} }
} }
counts, err := a.qso.RecentRate(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute) op, all, err := a.qso.RecentRateBreakdown(a.ctx, time.Now(), operator, 10*time.Minute, 60*time.Minute)
if err != nil || len(counts) < 2 { if err != nil || len(op) < 2 || len(all) < 2 {
return QSORate{} return QSORate{}
} }
return QSORate{Last10: counts[0], Last60: counts[1]} return QSORate{Last10: op[0], Last60: op[1], TeamLast10: all[0], TeamLast60: all[1]}
} }
// GetContestRuns lists the (contest, year) pairs actually present in the log, so // GetContestRuns lists the (contest, year) pairs actually present in the log, so
@@ -4812,6 +5123,7 @@ func (a *App) BulkUpdateField(ids []int64, field, value string) (int64, error) {
} }
if n > 0 { if n > 0 {
a.invalidateAwardStats() a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids) // the edited field may feed an award → refresh
} }
return n, nil return n, nil
} }
@@ -4961,7 +5273,11 @@ func (a *App) ImportADIF(path string, dupMode string, applyCty bool, applyStatio
im.OnProgress = func(processed, total int) { im.OnProgress = func(processed, total int) {
wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total}) wruntime.EventsEmit(a.ctx, "import:progress", map[string]int{"processed": processed, "total": total})
} }
return im.ImportFile(a.ctx, path) res, err := im.ImportFile(a.ctx, path)
if err == nil && (res.Imported > 0 || res.Updated > 0) {
a.recomputeAwardRefsAsync() // materialise award_refs for the imported rows
}
return res, err
} }
// SaveADIFFile shows a native Save-As dialog suggesting a timestamped // SaveADIFFile shows a native Save-As dialog suggesting a timestamped
@@ -8760,6 +9076,7 @@ func (a *App) UpdateQSOsFromCty(ids []int64) (int, error) {
} }
if changed > 0 { if changed > 0 {
a.invalidateAwardStats() a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids) // entity fields changed → refresh award_refs
} }
return changed, nil return changed, nil
} }
@@ -8835,6 +9152,7 @@ func (a *App) UpdateQSOsFromQRZ(ids []int64) (int, error) {
} }
if changed > 0 { if changed > 0 {
a.invalidateAwardStats() a.invalidateAwardStats()
a.materializeAwardRefsForIDs(ids) // entity/geo fields changed → refresh award_refs
} }
return changed, nil return changed, nil
} }
@@ -9290,6 +9608,7 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
} }
q.ID = id q.ID = id
a.noteLiveQSO() // multi-op: flip this operator back "online" a.noteLiveQSO() // multi-op: flip this operator back "online"
a.materializeAwardRefs(q)
a.saveQSORecording(&q) a.saveQSORecording(&q)
if a.extsvc != nil { if a.extsvc != nil {
a.extsvc.OnQSOLogged(id) a.extsvc.OnQSOLogged(id)
@@ -10046,6 +10365,45 @@ func (a *App) IcomSendCW(text string) error {
return err 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. // IcomStopCW aborts the CW message currently being sent.
func (a *App) IcomStopCW() error { func (a *App) IcomStopCW() error {
if a.cat == nil { if a.cat == nil {
@@ -11045,29 +11403,59 @@ func boolStr(b bool) string {
// StationDevice is one configured relay board for the Station Control tab. // StationDevice is one configured relay board for the Station Control tab.
type StationDevice struct { type StationDevice struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` // "webswitch" | "kmtronic" Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
Name string `json:"name"` 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) User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
Pass string `json:"pass,omitempty"` 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) 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. // relayCountFor returns a device type's fixed relay count.
func relayCountFor(typ string) int { func relayCountFor(typ string) int {
if typ == "kmtronic" { switch typ {
case "kmtronic", "denkovi":
return 8 return 8
} default:
return 5 // webswitch 1216H return 5 // webswitch 1216H
} }
}
// deviceDriver builds the wire driver for a configured device. // deviceDriver builds the wire driver for a configured device.
func deviceDriver(d StationDevice) relaydev.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) 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). // GetStationDevices returns the configured relay boards (without live state).
func (a *App) GetStationDevices() []StationDevice { func (a *App) GetStationDevices() []StationDevice {
@@ -11092,13 +11480,16 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
return fmt.Errorf("db not initialized") return fmt.Errorf("db not initialized")
} }
for i := range devs { 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" devs[i].Type = "webswitch"
} }
if strings.TrimSpace(devs[i].ID) == "" { if strings.TrimSpace(devs[i].ID) == "" {
devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano()) 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 { for len(devs[i].Labels) < n {
devs[i].Labels = append(devs[i].Labels, "") devs[i].Labels = append(devs[i].Labels, "")
} }
@@ -11139,7 +11530,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
go func(i int) { go func(i int) {
defer wg.Done() defer wg.Done()
d := devs[i] d := devs[i]
n := relayCountFor(d.Type) n := deviceRelayCount(d)
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type} ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second) ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
defer cancel() defer cancel()
@@ -11746,19 +12137,25 @@ func (a *App) AntGeniusDeselect(port int) error {
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ───── // ── 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 { type PGXLSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Host string `json:"host"` Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k"
Port int `json:"port"` 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) { func (a *App) GetPGXLSettings() (PGXLSettings, error) {
out := PGXLSettings{Port: 9008} out := PGXLSettings{Type: "pgxl", Transport: "tcp", Port: 9008, Baud: 115200}
if a.settings == nil { if a.settings == nil {
return out, fmt.Errorf("db not initialized") 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 { if err != nil {
return out, err return out, err
} }
@@ -11767,6 +12164,16 @@ func (a *App) GetPGXLSettings() (PGXLSettings, error) {
if p, _ := strconv.Atoi(m[keyPGXLPort]); p > 0 && p <= 65535 { if p, _ := strconv.Atoi(m[keyPGXLPort]); p > 0 && p <= 65535 {
out.Port = p 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 return out, nil
} }
@@ -11777,10 +12184,23 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
if s.Port <= 0 || s.Port > 65535 { if s.Port <= 0 || s.Port > 65535 {
s.Port = 9008 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{ for k, v := range map[string]string{
keyPGXLEnabled: boolStr(s.Enabled), keyPGXLEnabled: boolStr(s.Enabled),
keyPGXLHost: strings.TrimSpace(s.Host), keyPGXLHost: strings.TrimSpace(s.Host),
keyPGXLPort: strconv.Itoa(s.Port), 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 { if err := a.settings.Set(a.ctx, k, v); err != nil {
return err return err
@@ -11800,7 +12220,17 @@ func (a *App) startPGXL() {
a.pgxl = nil a.pgxl = nil
} }
s, err := a.GetPGXLSettings() s, err := a.GetPGXLSettings()
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" { if err != nil || !s.Enabled {
return
}
// Only the PowerGenius XL (TCP) is driven for now. SPE Expert control (serial /
// RS232-to-Ethernet) is a separate protocol, not yet implemented — its settings
// are stored but no client is started.
if s.Type != "" && s.Type != "pgxl" {
applog.Printf("amplifier: type %q selected — control not implemented yet (settings saved)", s.Type)
return
}
if strings.TrimSpace(s.Host) == "" {
return return
} }
a.pgxl = powergenius.New(s.Host, s.Port) a.pgxl = powergenius.New(s.Host, s.Port)
+140 -52
View File
@@ -36,14 +36,14 @@ import {
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetWinkeyerStatus,
WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace, WinkeyerConnect, WinkeyerDisconnect, WinkeyerSend, WinkeyerStop, WinkeyerSetSpeed, WinkeyerBackspace,
IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState, IcomSendCW, IcomStopCW, IcomSetKeySpeed, IcomSetBreakIn, GetIcomState,
FlexSendCW, FlexStopCW, FlexSetKeySpeed, FlexBackspaceCW,
GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop, GetDVKMessages, GetDVKStatus, DVKPlay, DVKStop,
StartCWDecoder, StopCWDecoder, SetCWDecoderPitch, StartCWDecoder, StopCWDecoder, SetCWDecoderPitch,
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators, ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock, QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
GetAwardDefs, GetAwardDefs,
GetUIPref, GetUIPref, GetActiveProfile, QuitApp,
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec, ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
AwardRefsForQSOs,
} from '../wailsjs/go/main/App'; } from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs'; import { applyAwardRefs } from '@/lib/awardRefs';
@@ -87,6 +87,7 @@ import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { setGridPrefsProfile } from '@/lib/gridPrefs';
import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel'; import { DvkPanel, type DVKMsg, type DVKStat } from '@/components/DvkPanel';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -135,6 +136,22 @@ const emptyDetails: DetailsState = {
award_refs: '', award_refs: '',
}; };
// parseAwardRefs turns the QSO row's materialised award_refs JSON string
// ({"DDFM":"74","WAJA":"12"}) into the code→ref object the grid columns read.
// Tolerant of empty / malformed values (returns {}), and passes an already-parsed
// object straight through.
function parseAwardRefs(s: any): Record<string, string> {
if (!s) return {};
if (typeof s === 'object') return s as Record<string, string>;
if (typeof s !== 'string') return {};
try {
const o = JSON.parse(s);
return o && typeof o === 'object' ? o : {};
} catch {
return {};
}
}
function fmtDateUTC(s: any): string { function fmtDateUTC(s: any): string {
if (!s) return ''; if (!s) return '';
const d = new Date(s); const d = new Date(s);
@@ -637,6 +654,7 @@ export default function App() {
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' }); const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
const [matchCount, setMatchCount] = useState<number | null>(null); const [matchCount, setMatchCount] = useState<number | null>(null);
const [gridFilteredCount, setGridFilteredCount] = useState<number | null>(null); // rows after AG-Grid column filters, or null if none
// The selected tab is remembered across restarts. Only the always-present tabs // The selected tab is remembered across restarts. Only the always-present tabs
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on // are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
// a feature or CAT backend that isn't known this early, and restoring one that // a feature or CAT backend that isn't known this early, and restoring one that
@@ -758,7 +776,7 @@ export default function App() {
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros, // CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
// auto-call and <LOGQSO> are shared; only the transport differs. // auto-call and <LOGQSO> are shared; only the transport differs.
const [wkEngine, setWkEngine] = useState<string>('winkeyer'); 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); const cwSourceRef = useRef(cwSource);
useEffect(() => { cwSourceRef.current = cwSource; }, [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 // CW break-in (0=OFF, 1=SEMI, 2=FULL) — must be on for the rig's 0x17 keyer to
@@ -787,7 +805,9 @@ export default function App() {
const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log const wkBusyRef = useRef(false); // live "keyer is sending" flag, for the <LOGQSO> wait-then-log
useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]); useEffect(() => { wkBusyRef.current = wkStatus.busy; }, [wkStatus.busy]);
useEffect(() => { 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; wkActiveRef.current = wkEnabled && connected;
}, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]); }, [wkEnabled, wkStatus.connected, cwSource, catState.backend, catState.connected]);
useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]); useEffect(() => { wkEscClearsRef.current = wkEscClears; }, [wkEscClears]);
@@ -1134,10 +1154,10 @@ export default function App() {
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General. // QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1'); const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]); useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number }>({ last10: 0, last60: 0 }); const [qsoRate, setQsoRate] = useState<{ last10: number; last60: number; team10: number; team60: number }>({ last10: 0, last60: 0, team10: 0, team60: 0 });
useEffect(() => { useEffect(() => {
if (!showQsoRate) return; if (!showQsoRate) return;
const load = () => { GetQSORate().then((r) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0 })).catch(() => {}); }; const load = () => { GetQSORate().then((r: any) => setQsoRate({ last10: r?.last10 ?? 0, last60: r?.last60 ?? 0, team10: r?.team_last10 ?? 0, team60: r?.team_last60 ?? 0 })).catch(() => {}); };
load(); load();
// Refresh on each logged QSO (immediate feedback) and on a 30s tick so the // Refresh on each logged QSO (immediate feedback) and on a 30s tick so the
// trailing windows roll forward even when nothing new is logged. // trailing windows roll forward even when nothing new is logged.
@@ -1233,38 +1253,28 @@ export default function App() {
// list once, then compute each shown QSO's reference per award and attach it // list once, then compute each shown QSO's reference per award and attach it
// to the rows (the grids render one hideable column per award). // to the rows (the grids render one hideable column per award).
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]); const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
// Bumped whenever award definitions are saved so the grid columns AND the // Bumped when award definitions change (or the backend finishes a bulk
// per-QSO refs re-fetch — the ref/name display choice is computed live, so // award_refs recompute) so the set of award COLUMNS re-reads from GetAwardDefs.
// changing it updates ALL contacts (old included) with no restart. // The per-QSO values themselves ride on the row (award_refs) and refresh with
// the grid reload triggered alongside this bump.
const [awardsVersion, setAwardsVersion] = useState(0); const [awardsVersion, setAwardsVersion] = useState(0);
useEffect(() => { useEffect(() => {
GetAwardDefs().then((defs: any[]) => GetAwardDefs().then((defs: any[]) =>
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))), setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
).catch(() => {}); ).catch(() => {});
}, [awardsVersion]); }, [awardsVersion]);
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({}); // Award references are now MATERIALISED on the QSO row (the award_refs JSON
useEffect(() => { // column, written by the backend on log/edit and bulk-recomputed when awards
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean); // change). The grid reads them straight from the row — no per-page backend
if (ids.length === 0 || awardCols.length === 0) { setQsoAwardRefs({}); return; } // recompute — so here we just parse the stored JSON string into the code→ref
let alive = true; // object the award columns expect (keys are already upper-case).
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [qsos, awardCols.length, awardsVersion]);
const qsosWithAwards = useMemo( const qsosWithAwards = useMemo(
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })), () => (qsos as any[]).map((q) => ({ ...q, award_refs: parseAwardRefs(q.award_refs) })),
[qsos, qsoAwardRefs], [qsos],
); );
const [wbAwardRefs, setWbAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = ((wb?.entries ?? []) as any[]).map((e) => e.id).filter(Boolean);
if (ids.length === 0 || awardCols.length === 0) { setWbAwardRefs({}); return; }
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [wb, awardCols.length, awardsVersion]);
const wbWithAwards = useMemo( const wbWithAwards = useMemo(
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null), () => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: parseAwardRefs(e.award_refs) })) } : null),
[wb, wbAwardRefs], [wb],
); );
// Always-current copy of the entry callsign, so the UDP event handlers // Always-current copy of the entry callsign, so the UDP event handlers
// (which live in a []-deps effect with a stale `callsign` closure) can // (which live in a []-deps effect with a stale `callsign` closure) can
@@ -1425,6 +1435,15 @@ export default function App() {
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); }; return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
}, [refresh]); }, [refresh]);
// The backend bulk-recomputed the materialised award_refs (an award definition
// or reference list changed, or the one-time backfill ran). Reload the rows so
// the award columns show the new values, and bump awardsVersion so the set of
// award COLUMNS refreshes too (a new award may have appeared).
useEffect(() => {
const off = EventsOn('awards:recomputed', () => { refresh(); setAwardsVersion((v) => v + 1); });
return () => { off(); };
}, [refresh]);
// Backend-emitted toast messages (e.g. recording auto-send result/skip). // Backend-emitted toast messages (e.g. recording auto-send result/skip).
useEffect(() => { useEffect(() => {
const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); }); const off = EventsOn('toast', (msg: any) => { if (msg) showToast(String(msg)); });
@@ -2020,15 +2039,29 @@ export default function App() {
setWkMacros((s.macros ?? []) as WKMacro[]); setWkMacros((s.macros ?? []) as WKMacro[]);
setWkEscClears(s.esc_clears_call !== false); setWkEscClears(s.esc_clears_call !== false);
setWkSendOnType(!!s.send_on_type); 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 */ } } catch { /* keyer not configured */ }
}, []); }, []);
// Active profile id — scopes the grids' column layout (visibility / width /
// order) so each profile keeps its own. Seeded once on mount and updated on
// every profile switch; the grids take it as their React key so they remount
// and re-read the now-correct per-profile layout.
const [activeProfileId, setActiveProfileId] = useState<number | null>(null);
useEffect(() => {
GetActiveProfile().then((p: any) => {
if (p && p.id != null) { setGridPrefsProfile(p.id); setActiveProfileId(p.id); }
}).catch(() => {});
}, []);
// Every setting is per-profile, so when the active profile changes the whole // Every setting is per-profile, so when the active profile changes the whole
// main UI re-reads its config (station identity, lists, CAT, keyer). The Go // main UI re-reads its config (station identity, lists, CAT, keyer). The Go
// side reloads its managers; this keeps the React state in sync. // side reloads its managers; this keeps the React state in sync.
useEffect(() => { useEffect(() => {
const off = EventsOn('profile:changed', () => { const off = EventsOn('profile:changed', (id: any) => {
// Re-scope the grid column layout BEFORE the grids remount (key change).
setGridPrefsProfile(id ?? null);
setActiveProfileId(typeof id === 'number' ? id : null);
loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes(); loadStation(); loadLists(); loadCATCfg(); reloadWk(); loadMainPanes();
// The chat is per shared logbook — clear the previous profile's messages // The chat is per shared logbook — clear the previous profile's messages
// and reload for the new logbook (or hide if it isn't a MySQL log). // and reload for the new logbook (or hide if it isn't a MySQL log).
@@ -2104,11 +2137,13 @@ export default function App() {
const keyed = resolved ? resolved + ' ' : resolved; const keyed = resolved ? resolved + ' ' : resolved;
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "") const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms)); const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
if (cwSourceRef.current === 'icom') { if (cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex') {
// The rig's keyer gives no busy echo back, so show the text we sent and, // The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so show
// for <LOGQSO>, wait the estimated send duration before logging. // the text we sent and, for <LOGQSO>, wait the estimated send duration
// before logging.
setWkSent(resolved); 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(); } if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
return; return;
} }
@@ -2144,7 +2179,7 @@ export default function App() {
// the wait at the ESTIMATED send time (not the busy flag alone): over a // 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 // 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. // 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); await sleep(Math.round(estimateCwMs(resolveCW(m.text), wkWpm)) + 300);
} else { } else {
const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500; const capMs = Math.round(estimateCwMs(resolveCW(m.text), wkWpm) * 1.4) + 2500;
@@ -2182,8 +2217,14 @@ export default function App() {
writeUiPref('opslog.wkAutoCallSecs', String(v)); writeUiPref('opslog.wkAutoCallSecs', String(v));
} }
// send-on-type: key the typed chars verbatim (no variable substitution). // send-on-type: key the typed chars verbatim (no variable substitution).
function wkSendRaw(chars: string) { WinkeyerSend(chars).catch(() => {}); } function wkSendRaw(chars: string) {
function wkBackspace() { WinkeyerBackspace().catch(() => {}); } 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 }); } 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 // Resolve slot status for any spot we haven't seen yet — debounced so we
@@ -2642,7 +2683,14 @@ export default function App() {
// keeps the pre-roll from before this); clearing it discards the take. // keeps the pre-roll from before this); clearing it discards the take.
// Recording START happens on blur (leaving the callsign field), NOT here — // Recording START happens on blur (leaving the callsign field), NOT here —
// you may type a call and work it minutes later. Clearing it cancels. // you may type a call and work it minutes later. Clearing it cancels.
if (v.trim() === '') { QSOAudioCancel(); setRecording(false); recordingCallRef.current = ""; } if (v.trim() === '') {
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
// Callsign wiped → drop this contact's award references. They are auto-added
// per call (live detection merges pickable refs into award_refs), so without
// this they'd carry over to the NEXT call — e.g. IT9AOT's ref lingering when
// you then type F4BPO, showing both in the F3 Awards tab.
updateDetails({ award_refs: '' });
}
const isEmpty = v.trim() === ''; const isEmpty = v.trim() === '';
if (!isEmpty && !locks.start) { if (!isEmpty && !locks.start) {
// Restart the start time on every callsign change (each keystroke, a // Restart the start time on every callsign change (each keystroke, a
@@ -2725,7 +2773,7 @@ export default function App() {
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 }, { type: 'item', label: t('file.deleteAll'), action: 'file.deleteall', disabled: total === 0 },
{ type: 'separator' }, { type: 'separator' },
{ type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q', disabled: true }, { type: 'item', label: t('file.exit'), action: 'file.exit', shortcut: 'Ctrl+Q' },
]}, ]},
{ name: 'edit', label: t('menu.edit'), items: [ { name: 'edit', label: t('menu.edit'), items: [
{ type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null }, { type: 'item', label: t('edit.editSel'), action: 'edit.edit', shortcut: 'Enter', disabled: selectedId === null },
@@ -2770,6 +2818,7 @@ export default function App() {
case 'file.export': exportAdif(); break; case 'file.export': exportAdif(); break;
case 'file.exportCabrillo': exportCabrillo(); break; case 'file.exportCabrillo': exportCabrillo(); break;
case 'file.deleteall': setShowDeleteAll(true); break; case 'file.deleteall': setShowDeleteAll(true); break;
case 'file.exit': QuitApp(); break;
case 'view.refresh': refresh(); break; case 'view.refresh': refresh(); break;
case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break; case 'view.clearfilters': setFilterCallsign(''); setActiveFilter({ conditions: [], match: 'AND' }); break;
case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break; case 'edit.edit': if (selectedId !== null) openEdit(selectedId); break;
@@ -2842,7 +2891,14 @@ export default function App() {
const keyerLive = wkActiveRef.current; const keyerLive = wkActiveRef.current;
// ESC aborts the current CW transmission AND the auto-call loop, so it // 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. // 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) { if (!keyerLive || wkEscClearsRef.current) {
resetEntry(); resetEntry();
callsignRef.current?.focus(); callsignRef.current?.focus();
@@ -3609,6 +3665,7 @@ export default function App() {
return ( return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden"> <div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
<RecentQSOsGrid <RecentQSOsGrid
key={`rqg-${activeProfileId ?? 'x'}`}
rows={qsosWithAwards as any} rows={qsosWithAwards as any}
total={total} total={total}
awardCols={awardCols} awardCols={awardCols}
@@ -3887,12 +3944,28 @@ export default function App() {
the last columns (profile / band map / compact) onto a 2nd row. */} the last columns (profile / band map / compact) onto a 2nd row. */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{showQsoRate && ( {showQsoRate && (
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap" <div className="flex items-center gap-2 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
title={t('rate.title')}> title={t('rate.title')}>
{/* Contest-style rate: QSOs/hour projected from each window (10-min
count ×6; the 60-min count is already per hour). On a shared MySQL
logbook it shows both OP (the active operator, accent) and TEAM (all
operators, muted); single-op shows one line. */}
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} /> <Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
{/* Contest-style rate: QSOs/hour projected from each window {dbConn?.backend === 'mysql' ? (
(10-min count ×6; the 60-min count is already per hour). Numbers <div className="flex flex-col gap-0.5 leading-none">
glow the brand accent when active, dim to muted when idle. */} <div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">OP</span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span><span className="text-muted-foreground text-[7px]">10</span></span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span><span className="text-muted-foreground text-[7px]">60</span></span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground uppercase tracking-wider text-[8px] w-9">Team</span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team10 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team10 * 6}</span><span className="text-muted-foreground text-[7px]">10</span></span>
<span className="inline-flex items-baseline gap-0.5"><span className={cn('font-bold text-[11px]', qsoRate.team60 > 0 ? 'text-foreground' : 'text-muted-foreground')}>{qsoRate.team60}</span><span className="text-muted-foreground text-[7px]">60</span></span>
</div>
</div>
) : (
<>
<span className="inline-flex items-baseline gap-1"> <span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10</span> <span className="text-muted-foreground uppercase tracking-wider text-[9px]">10</span>
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span> <span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
@@ -3901,7 +3974,9 @@ export default function App() {
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60</span> <span className="text-muted-foreground uppercase tracking-wider text-[9px]">60</span>
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span> <span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
</span> </span>
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span> </>
)}
<span className="text-muted-foreground text-[9px] uppercase tracking-wider self-center">Q/h</span>
</div> </div>
)} )}
@@ -4259,16 +4334,16 @@ export default function App() {
<Radio className="size-3.5 text-primary" /> <Radio className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span> <span className="text-[11px] font-semibold uppercase tracking-wider truncate">{t('live.stationsTitle')}</span>
<div className="flex-1" /> <div className="flex-1" />
<span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}/{liveStations.length}</span> <span className="text-[10px] text-muted-foreground tabular-nums">{liveStations.filter((s) => s.online).length}</span>
<button type="button" className="text-muted-foreground hover:text-foreground shrink-0" <button type="button" className="text-muted-foreground hover:text-foreground shrink-0"
onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}> onClick={() => { setShowLiveStations(false); writeUiPref('opslog.showLiveStations', '0'); }} title={t('live.stationsHide')}>
<X className="size-3.5" /> <X className="size-3.5" />
</button> </button>
</div> </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.5 flex flex-col gap-1">
{liveStations.length === 0 ? ( {liveStations.filter((s) => s.online).length === 0 ? (
<p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p> <p className="text-xs text-muted-foreground italic px-1 py-2">{t('live.stationsEmpty')}</p>
) : liveStations.map((s, i) => { ) : liveStations.filter((s) => s.online).map((s, i) => {
const mc = modeAccent(s.mode); const mc = modeAccent(s.mode);
return ( 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-2 rounded-md px-2 py-1.5 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
@@ -4349,8 +4424,8 @@ export default function App() {
{wkEnabled && ( {wkEnabled && (
<div className="w-[380px] shrink-0 min-h-0"> <div className="w-[380px] shrink-0 min-h-0">
<WinkeyerPanel <WinkeyerPanel
status={cwSource === 'icom' status={cwSource === 'icom' || cwSource === 'flex'
? { connected: catState.backend === 'icom' && catState.connected, busy: false, wpm: wkWpm, version: 0, port: 'CI-V' } ? { connected: catState.backend === cwSource && catState.connected, busy: false, wpm: wkWpm, version: 0, port: cwSource === 'flex' ? 'CWX' : 'CI-V' }
: wkStatus} : wkStatus}
ports={wkPorts} ports={wkPorts}
port={wkPort} port={wkPort}
@@ -4367,11 +4442,15 @@ export default function App() {
onSetSpeed={(w) => { onSetSpeed={(w) => {
setWkWpm(w); saveWk({ wpm: w }); setWkWpm(w); saveWk({ wpm: w });
if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {}); if (cwSource === 'icom') IcomSetKeySpeed(w).catch(() => {});
else if (cwSource === 'flex') FlexSetKeySpeed(w).catch(() => {});
else WinkeyerSetSpeed(w).catch(() => {}); else WinkeyerSetSpeed(w).catch(() => {});
}} }}
onSend={wkSend} onSend={wkSend}
onSendMacro={wkSendMacro} 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)} onClose={() => wkSetEnabled(false)}
sendOnType={wkSendOnType} sendOnType={wkSendOnType}
onToggleSendOnType={wkToggleSendOnType} onToggleSendOnType={wkToggleSendOnType}
@@ -4617,9 +4696,11 @@ export default function App() {
)} )}
<RecentQSOsGrid <RecentQSOsGrid
key={`rqg2-${activeProfileId ?? 'x'}`}
rows={qsosWithAwards as any} rows={qsosWithAwards as any}
total={total} total={total}
awardCols={awardCols} awardCols={awardCols}
onFilteredCountChange={setGridFilteredCount}
onRowDoubleClicked={(q) => openEdit(q.id as number)} onRowDoubleClicked={(q) => openEdit(q.id as number)}
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromCty={bulkUpdateFromCty}
onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromQRZ={bulkUpdateFromQRZ}
@@ -4655,11 +4736,18 @@ export default function App() {
onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }} onClick={() => { setActiveFilter({ conditions: [], match: 'AND' }); setFilterCallsign(''); }}
>clear</button> >clear</button>
) : null} ) : null}
{gridFilteredCount != null ? (
<span>
Showing <span className="font-semibold text-foreground">{gridFilteredCount}</span> of{' '}
<span className="font-semibold text-foreground">{qsos.length}</span> (column filter)
</span>
) : (
<span> <span>
Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '} Showing <span className="font-semibold text-foreground">{qsos.length}</span> of{' '}
<span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span> <span className="font-semibold text-foreground">{(activeFilter.conditions?.length || filterCallsign) && matchCount != null ? matchCount : total}</span>
{(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''} {(activeFilter.conditions?.length || filterCallsign) ? ` matches · ${total} total` : ''}
</span> </span>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{qsos.length >= qsoLimit && qsos.length < total && ( {qsos.length >= qsoLimit && qsos.length < total && (
+33 -23
View File
@@ -33,6 +33,37 @@ function pretty(name: string): string {
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase(); 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 // 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 // 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 // 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; 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 ( 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="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"> <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'; : 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
return ( return (
<div key={a.index} className="flex items-center gap-1.5"> <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)}> <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)} {pretty(a.name)}
</div> </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> </div>
); );
})} })}
+32 -2
View File
@@ -19,6 +19,7 @@ import {
ListCountries, DXCCForCountry, DXCCName, ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences, PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder, ExportAwards, ImportAwards, InspectAwardImport, ApplyAwardImport, GetCatalogCodes, OpenAwardsFolder,
ExportAwardForCatalog,
GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward, GetAwardUpdates, ApplyAwardUpdate, DismissAwardUpdate, ExplainAward,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
@@ -37,7 +38,7 @@ export type AwardDef = {
or_rules?: AwardOrRule[]; or_rules?: AwardOrRule[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[]; 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; 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 = { type AwardOrRule = {
@@ -155,6 +156,9 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [updating, setUpdating] = useState<string | null>(null); const [updating, setUpdating] = useState<string | null>(null);
const [err, setErr] = useState(''); 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, // 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 // "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 cur = defs[sel];
const selUpdate = updates.find((u) => (u.code ?? '').toUpperCase() === (cur?.code ?? '').toUpperCase()) ?? null; 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. // ── Award tester: run the award's rules against a real QSO and show every step.
type Rejected = { candidate: string; reason: string }; 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 })); if (p) setErr(t('awed.exportedTo', { path: p }));
} catch (e: any) { setErr(String(e?.message ?? e)); } } 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. // Import: LOOK FIRST, then ask.
// //
// This used to merge by code with "imported wins", silently — import a WAPC // 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 ( return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}> <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"> <DialogHeader className="px-5 py-3 border-b">
<DialogTitle>{t('awed.awardManagement')}</DialogTitle> <DialogTitle>{t('awed.awardManagement')}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -726,6 +744,18 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
title={t('awed.awardsFolderTip')}> title={t('awed.awardsFolderTip')}>
<FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')} <FolderOpen className="size-3.5 mr-1" /> {t('awed.awardsFolder')}
</Button> </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" /> <div className="flex-1" />
<Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button> <Button variant="outline" onClick={onClose}>{t('awed.cancel')}</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button> <Button onClick={save}><Save className="size-3.5 mr-1" /> {t('awed.save')}</Button>
+22 -7
View File
@@ -49,6 +49,10 @@ type Props = {
onExportCabrilloSelected?: (ids: number[]) => void; onExportCabrilloSelected?: (ids: number[]) => void;
onExportCabrilloFiltered?: () => void; onExportCabrilloFiltered?: () => void;
onDelete?: (ids: number[]) => void; onDelete?: (ids: number[]) => void;
// Reports how many rows the grid shows after its COLUMN filters (the funnel
// icons), or null when no column filter is active — so the parent's "Showing X
// of Y" can reflect them. Fired on filter change and when the data updates.
onFilteredCountChange?: (count: number | null) => void;
// One column per defined award; the cell shows the reference this QSO counts // One column per defined award; the cell shows the reference this QSO counts
// for (from row.award_refs[CODE], attached by the parent). Hidden by default. // for (from row.award_refs[CODE], attached by the parent). Hidden by default.
awardCols?: { code: string; name: string }[]; awardCols?: { code: string; name: string }[];
@@ -245,7 +249,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] => const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_')); (st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, awardCols }: Props) { export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n(); const { t } = useI18n();
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@@ -360,17 +364,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
} }
}); });
} }
// Report the post-column-filter row count (funnel filters) to the parent, or
// null when no column filter is active, so "Showing X of Y" reflects them.
const reportFilteredCount = useCallback((e: { api?: any }) => {
const api = e?.api ?? gridRef.current?.api;
if (!api || !onFilteredCountChange) return;
onFilteredCountChange(api.isAnyFilterPresent?.() ? api.getDisplayedRowCount() : null);
}, [onFilteredCountChange]);
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
if (restoringRef.current) return; // ignore the events fired by a column rebuild if (restoringRef.current) return; // ignore the events fired by a column rebuild
const state = gridRef.current?.api?.getColumnState(); const state = gridRef.current?.api?.getColumnState();
if (state) saveState(colStateKey, stripAwardCols(state)); if (state) saveState(colStateKey, stripAwardCols(state));
}, []); }, []);
// The award columns load asynchronously; when they arrive (or change) the // columnDefs is rebuilt whenever the award columns load OR the user toggles an
// columnDefs memo is rebuilt and AG Grid re-applies each colDef's `hide` // award column (both change the memo restoringRef flips true at line 316). Each
// default — wiping the user's saved visibility (award columns reappear, // rebuild makes AG Grid re-apply every colDef's `hide` default, wiping the user's
// manually-shown ones like LoTW sent vanish). Re-apply the saved state after // saved visibility of the NON-award columns (QTH/Grid reappear, a manually-shown
// every rebuild so the user's choices win. No-op before the grid is ready. // LoTW-sent vanishes). Re-apply the saved (award-stripped) state after EVERY such
// rebuild — hence awardShown in the deps, not just awardCols; without it, toggling
// an award reset the other columns AND left restoringRef stuck true (saving off).
useEffect(() => { useEffect(() => {
const api = gridRef.current?.api; const api = gridRef.current?.api;
const local = loadLocal(colStateKey); const local = loadLocal(colStateKey);
@@ -378,7 +391,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
// Re-enable saving once AG Grid has settled the column events from the rebuild. // Re-enable saving once AG Grid has settled the column events from the rebuild.
const t = window.setTimeout(() => { restoringRef.current = false; }, 0); const t = window.setTimeout(() => { restoringRef.current = false; }, 0);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [awardCols]); }, [awardCols, awardShown]);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data); if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
@@ -467,6 +480,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={onGridReady} onGridReady={onGridReady}
onFilterChanged={reportFilteredCount}
onModelUpdated={reportFilteredCount}
onColumnResized={saveColumnState} onColumnResized={saveColumnState}
onColumnMoved={saveColumnState} onColumnMoved={saveColumnState}
onColumnPinned={saveColumnState} onColumnPinned={saveColumnState}
+110 -23
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, 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'; } from 'lucide-react';
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -28,7 +28,7 @@ import {
ConnectClusterServer, DisconnectClusterServer, ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, 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, GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram, GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
GetTelemetryEnabled, SetTelemetryEnabled, GetTelemetryEnabled, SetTelemetryEnabled,
@@ -276,7 +276,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
winkeyer: 'CW Keyer', winkeyer: 'CW Keyer',
antenna: 'Ultrabeam / Steppir', antenna: 'Ultrabeam / Steppir',
antgenius: 'Antenna Genius', antgenius: 'Antenna Genius',
pgxl: 'Power Genius', pgxl: 'Amplifier',
flex: 'FlexRadio', flex: 'FlexRadio',
relayauto: 'Relay auto-control', relayauto: 'Relay auto-control',
audio: 'Audio devices', audio: 'Audio devices',
@@ -647,7 +647,7 @@ function ADIFMonitorPanel() {
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] }; 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[] }; 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 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);
function RelayAutoPanel() { function RelayAutoPanel() {
const { t } = useI18n(); const { t } = useI18n();
const [enabled, setEnabled] = useState(false); const [enabled, setEnabled] = useState(false);
@@ -698,7 +698,7 @@ function RelayAutoPanel() {
<div key={dev.id} className="rounded-md border border-border"> <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="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"> <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 r = ruleFor(dev.id, relay);
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`; const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
return ( return (
@@ -1013,8 +1013,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007. // 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: '' }); const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
// PowerGenius XL (4O3A) amp fan-control settings. // Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP).
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 }); 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. // WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string }; type WKMac = { label: string; text: string };
@@ -2645,36 +2646,89 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
} }
function PGXLPanelSettings() { function PGXLPanelSettings() {
const isPGXL = pgxl.type === 'pgxl';
const isSerial = pgxl.transport === 'serial';
return ( return (
<> <>
<SectionHeader <SectionHeader title="Amplifier" />
title="Power Genius XL"
/>
<div className="space-y-4 max-w-xl"> <div className="space-y-4 max-w-xl">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} /> <Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
Enable PowerGenius fan control Enable amplifier control
</label> </label>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<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="grid grid-cols-3 gap-3">
<div className="space-y-1 col-span-2"> <div className="space-y-1 col-span-2">
<Label>Host / IP</Label> <Label>Host / IP</Label>
<Input <Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
value={pgxl.host ?? ''} placeholder="192.168.1.70" className="font-mono" />
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
placeholder="192.168.1.70"
className="font-mono"
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>TCP port</Label> <Label>TCP port</Label>
<Input <Input type="number" min={1} max={65535} value={pgxl.port}
type="number" min={1} max={65535} onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" />
value={pgxl.port}
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
className="font-mono"
/>
</div> </div>
</div> </div>
)}
{!isPGXL && (
<p className="text-xs text-warning">
SPE Expert control is not wired up yet these settings are saved, but OpsLog can't command the amp until its serial protocol is implemented.
</p>
)}
</div> </div>
</> </>
); );
@@ -2807,6 +2861,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<SelectContent> <SelectContent>
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem> <SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
<SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem> <SelectItem value="icom">Icom CI-V (rig keyer)</SelectItem>
<SelectItem value="flex">FlexRadio (CWX)</SelectItem>
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem> <SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -2837,6 +2892,26 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
</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"> <div className="grid grid-cols-4 gap-3">
@@ -3978,6 +4053,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
setDbMsg(p); setDbMsg(p);
} catch (e: any) { setErr(String(e?.message ?? e)); } } 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() { async function resetDefault() {
try { try {
await ResetDatabaseToDefault(); await ResetDatabaseToDefault();
@@ -4056,6 +4142,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="sm" onClick={createNew}><Plus className="size-3.5" /> {t('db.newDb')}</Button> <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={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> <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>} {dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
</div> </div>
@@ -12,16 +12,22 @@ import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay, GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop, GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices, ListSerialPorts,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null }; 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 Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] }; 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 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' }; 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 { function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') }; 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); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []); useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
const setType = (type: string) => { 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] ?? ''); 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 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 ( return (
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3"> <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> <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> <SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem> <SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</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> </SelectContent>
</Select> </Select>
</div> </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 })} /> <Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div> </div>
</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('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}> <div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label> <Label>{t('station.host')}</Label>
@@ -487,6 +571,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
</> </>
)} )}
</div> </div>
)}
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('station.labels')}</Label> <Label>{t('station.labels')}</Label>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
+7 -5
View File
@@ -26,7 +26,7 @@ interface Props {
wpm: number; wpm: number;
macros: WKMacro[]; macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits 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 breakIn?: number; // Icom CW break-in: 0=OFF, 1=SEMI, 2=FULL
onSetBreakIn?: (mode: number) => void; onSetBreakIn?: (mode: number) => void;
onSelectPort: (p: string) => void; onSelectPort: (p: string) => void;
@@ -101,14 +101,16 @@ export function WinkeyerPanel({
<Radio className="size-4 text-primary shrink-0" /> <Radio className="size-4 text-primary shrink-0" />
{/* CW output engine (chosen in Settings → CW Keyer). */} {/* CW output engine (chosen in Settings → CW Keyer). */}
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0"> <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>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-warning animate-pulse' : 'bg-success') : 'bg-muted-foreground/40')} <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')} /> title={connected ? (status.busy ? t('wkp.sending') : t('wkp.connectedV', { version: status.version })) : t('wkp.disconnected')} />
<div className="flex-1" /> <div className="flex-1" />
{source === 'icom' ? ( {source === 'icom' || source === 'flex' ? (
<span className="text-[11px] font-medium text-muted-foreground"> <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> </span>
) : !connected ? ( ) : !connected ? (
<> <>
@@ -183,7 +185,7 @@ export function WinkeyerPanel({
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2"> <Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
{t('wkp.cwText')} {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" <label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title={t('wkp.sendOnTypeHint')}> title={t('wkp.sendOnTypeHint')}>
<input type="checkbox" className="accent-primary" checked={sendOnType} <input type="checkbox" className="accent-primary" checked={sendOnType}
+26 -4
View File
@@ -6,11 +6,32 @@
// back to the DB copy and re-seed the cache. // back to the DB copy and re-seed the cache.
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App'; import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
// The DB copy is ALREADY per-profile (the backend prefixes every ui.* key with
// the active profile id). The localStorage cache, however, is one namespace for
// the whole WebView, so without scoping it too a profile switch would keep
// serving the previous profile's cached layout and the correct per-profile DB
// value would never win. lsScope makes the cache per-profile as well; it's set
// once the active profile is known and updated on every profile switch.
let lsScope = '';
// setGridPrefsProfile scopes the localStorage cache to a profile. Call it before
// the grids read their state (at startup) and again whenever the active profile
// changes so each profile keeps its own column layout / widths.
export function setGridPrefsProfile(id: number | string | null | undefined): void {
lsScope = id == null || id === '' ? '' : `p${id}.`;
}
// lsKey scopes ONLY the localStorage cache key. The DB key passed to
// GetUIPref/SetUIPref is left untouched — the backend already scopes it.
function lsKey(key: string): string {
return lsScope + key;
}
// loadLocal reads the cached column state synchronously (used in onGridReady // loadLocal reads the cached column state synchronously (used in onGridReady
// to apply instantly, before the async DB round-trip). // to apply instantly, before the async DB round-trip).
export function loadLocal(key: string): any[] | null { export function loadLocal(key: string): any[] | null {
try { try {
const raw = localStorage.getItem(key); const raw = localStorage.getItem(lsKey(key));
const v = raw ? JSON.parse(raw) : null; const v = raw ? JSON.parse(raw) : null;
return Array.isArray(v) ? v : null; return Array.isArray(v) ? v : null;
} catch { } catch {
@@ -29,15 +50,16 @@ export async function loadRemote(key: string): Promise<any[] | null> {
} }
} }
// saveState write-throughs to both the cache and the DB (fire-and-forget). // saveState write-throughs to both the cache and the DB (fire-and-forget). Only
// the cache key is profile-scoped; the DB key is scoped by the backend.
export function saveState(key: string, state: any[]) { export function saveState(key: string, state: any[]) {
const json = JSON.stringify(state); const json = JSON.stringify(state);
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ } try { localStorage.setItem(lsKey(key), json); } catch { /* quota / private mode */ }
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ }); SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
} }
// seedLocal writes a value into the cache without touching the DB (used after // seedLocal writes a value into the cache without touching the DB (used after
// hydrating the cache from the DB on a fresh machine). // hydrating the cache from the DB on a fresh machine).
export function seedLocal(key: string, state: any[]) { export function seedLocal(key: string, state: any[]) {
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ } try { localStorage.setItem(lsKey(key), JSON.stringify(state)); } catch { /* ignore */ }
} }
+16 -8
View File
@@ -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.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.backfillRun': 'Fill missing counties',
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.', '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.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', '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.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', '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.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.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.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.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.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', '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', '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) // 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.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.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.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}', '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.builtin': 'Built-in',
'awed.awardsFolder': 'Awards folder', '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.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.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.protectedFlag': 'Protected',
'awed.protectedTip': 'Protected awards cannot be deleted from the editor.', 'awed.protectedTip': 'Protected awards cannot be deleted from the editor.',
@@ -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.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.backfillRun': 'Remplir les comtés manquants',
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.', '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.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', '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.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', '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.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.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.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.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.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', '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', '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', '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.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.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.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}', '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.builtin': 'Intégré',
'awed.awardsFolder': 'Dossier awards', '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.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.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.protectedFlag': 'Protégé',
'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.', 'awed.protectedTip': 'Un diplôme protégé ne peut pas être supprimé depuis l’éditeur.',
+4 -1
View File
@@ -32,7 +32,10 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter', 'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked', 'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab 'opslog.activeTab', // last selected tab
'hamlog.awardColsShown', // which award columns are shown in the QSO grid // NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
// through this global path would fight that per-profile scoping.
]; ];
// syncPortablePrefs reconciles the DB with the local cache at startup: // syncPortablePrefs reconciles the DB with the local cache at startup:
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.20.2'; export const APP_VERSION = '0.20.4';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+16
View File
@@ -168,6 +168,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
export function ExportAward(arg1:string):Promise<string>; export function ExportAward(arg1:string):Promise<string>;
export function ExportAwardForCatalog(arg1:string,arg2:number):Promise<string>;
export function ExportAwards():Promise<string>; export function ExportAwards():Promise<string>;
export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>; export function ExportCabrillo(arg1:string):Promise<main.CabrilloResult>;
@@ -190,8 +192,12 @@ export function FlexAmpOperate(arg1:boolean):Promise<void>;
export function FlexApplyBandAntenna(arg1:string):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 FlexMox(arg1:boolean):Promise<void>;
export function FlexSendCW(arg1:string):Promise<void>;
export function FlexSetAGCMode(arg1:string):Promise<void>; export function FlexSetAGCMode(arg1:string):Promise<void>;
export function FlexSetAGCThreshold(arg1:number):Promise<void>; export function FlexSetAGCThreshold(arg1:number):Promise<void>;
@@ -222,6 +228,8 @@ export function FlexSetCWSpeed(arg1:number):Promise<void>;
export function FlexSetFilter(arg1:number,arg2: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 FlexSetMic(arg1:number):Promise<void>;
export function FlexSetMicProfile(arg1:string):Promise<void>; export function FlexSetMicProfile(arg1:string):Promise<void>;
@@ -278,6 +286,8 @@ export function FlexSetXIT(arg1:boolean):Promise<void>;
export function FlexSetXITFreq(arg1:number):Promise<void>; export function FlexSetXITFreq(arg1:number):Promise<void>;
export function FlexStopCW():Promise<void>;
export function FlexTune(arg1:boolean):Promise<void>; export function FlexTune(arg1:boolean):Promise<void>;
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>; export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
@@ -536,6 +546,8 @@ export function ListContests():Promise<Array<contest.Def>>;
export function ListCountries():Promise<Array<string>>; export function ListCountries():Promise<Array<string>>;
export function ListDenkoviDevices():Promise<Array<string>>;
export function ListOperatingTree():Promise<Array<operating.Station>>; export function ListOperatingTree():Promise<Array<operating.Station>>;
export function ListProfiles():Promise<Array<profile.Profile>>; export function ListProfiles():Promise<Array<profile.Profile>>;
@@ -666,6 +678,8 @@ export function QSOAudioRestart():Promise<boolean>;
export function QuitApp():Promise<void>; export function QuitApp():Promise<void>;
export function RecomputeAllAwardRefs():Promise<number>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>; export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function RefreshSolar():Promise<void>; export function RefreshSolar():Promise<void>;
@@ -674,6 +688,8 @@ export function ReloadUDPIntegrations():Promise<Array<string>>;
export function RemovePassphrase(arg1:string):Promise<void>; 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 RenderEQSL(arg1:number,arg2:number):Promise<string>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>; export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
+32
View File
@@ -294,6 +294,10 @@ export function ExportAward(arg1) {
return window['go']['main']['App']['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() { export function ExportAwards() {
return window['go']['main']['App']['ExportAwards'](); return window['go']['main']['App']['ExportAwards']();
} }
@@ -338,10 +342,18 @@ export function FlexApplyBandAntenna(arg1) {
return window['go']['main']['App']['FlexApplyBandAntenna'](arg1); return window['go']['main']['App']['FlexApplyBandAntenna'](arg1);
} }
export function FlexBackspaceCW(arg1) {
return window['go']['main']['App']['FlexBackspaceCW'](arg1);
}
export function FlexMox(arg1) { export function FlexMox(arg1) {
return window['go']['main']['App']['FlexMox'](arg1); return window['go']['main']['App']['FlexMox'](arg1);
} }
export function FlexSendCW(arg1) {
return window['go']['main']['App']['FlexSendCW'](arg1);
}
export function FlexSetAGCMode(arg1) { export function FlexSetAGCMode(arg1) {
return window['go']['main']['App']['FlexSetAGCMode'](arg1); return window['go']['main']['App']['FlexSetAGCMode'](arg1);
} }
@@ -402,6 +414,10 @@ export function FlexSetFilter(arg1, arg2) {
return window['go']['main']['App']['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) { export function FlexSetMic(arg1) {
return window['go']['main']['App']['FlexSetMic'](arg1); return window['go']['main']['App']['FlexSetMic'](arg1);
} }
@@ -514,6 +530,10 @@ export function FlexSetXITFreq(arg1) {
return window['go']['main']['App']['FlexSetXITFreq'](arg1); return window['go']['main']['App']['FlexSetXITFreq'](arg1);
} }
export function FlexStopCW() {
return window['go']['main']['App']['FlexStopCW']();
}
export function FlexTune(arg1) { export function FlexTune(arg1) {
return window['go']['main']['App']['FlexTune'](arg1); return window['go']['main']['App']['FlexTune'](arg1);
} }
@@ -1030,6 +1050,10 @@ export function ListCountries() {
return window['go']['main']['App']['ListCountries'](); return window['go']['main']['App']['ListCountries']();
} }
export function ListDenkoviDevices() {
return window['go']['main']['App']['ListDenkoviDevices']();
}
export function ListOperatingTree() { export function ListOperatingTree() {
return window['go']['main']['App']['ListOperatingTree'](); return window['go']['main']['App']['ListOperatingTree']();
} }
@@ -1290,6 +1314,10 @@ export function QuitApp() {
return window['go']['main']['App']['QuitApp'](); return window['go']['main']['App']['QuitApp']();
} }
export function RecomputeAllAwardRefs() {
return window['go']['main']['App']['RecomputeAllAwardRefs']();
}
export function RefreshCtyDat() { export function RefreshCtyDat() {
return window['go']['main']['App']['RefreshCtyDat'](); return window['go']['main']['App']['RefreshCtyDat']();
} }
@@ -1306,6 +1334,10 @@ export function RemovePassphrase(arg1) {
return window['go']['main']['App']['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) { export function RenderEQSL(arg1, arg2) {
return window['go']['main']['App']['RenderEQSL'](arg1, arg2); return window['go']['main']['App']['RenderEQSL'](arg1, arg2);
} }
+16
View File
@@ -2161,8 +2161,12 @@ export namespace main {
} }
export class PGXLSettings { export class PGXLSettings {
enabled: boolean; enabled: boolean;
type: string;
transport: string;
host: string; host: string;
port: number; port: number;
com_port: string;
baud: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new PGXLSettings(source); return new PGXLSettings(source);
@@ -2171,8 +2175,12 @@ export namespace main {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.type = source["type"];
this.transport = source["transport"];
this.host = source["host"]; this.host = source["host"];
this.port = source["port"]; this.port = source["port"];
this.com_port = source["com_port"];
this.baud = source["baud"];
} }
} }
export class POTAUnmatched { export class POTAUnmatched {
@@ -2407,6 +2415,8 @@ export namespace main {
export class QSORate { export class QSORate {
last10: number; last10: number;
last60: number; last60: number;
team_last10: number;
team_last60: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new QSORate(source); return new QSORate(source);
@@ -2416,6 +2426,8 @@ export namespace main {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.last10 = source["last10"]; this.last10 = source["last10"];
this.last60 = source["last60"]; this.last60 = source["last60"];
this.team_last10 = source["team_last10"];
this.team_last60 = source["team_last60"];
} }
} }
export class RelayAutoRule { export class RelayAutoRule {
@@ -2596,6 +2608,7 @@ export namespace main {
host: string; host: string;
user?: string; user?: string;
pass?: string; pass?: string;
channels?: number;
labels: string[]; labels: string[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -2610,6 +2623,7 @@ export namespace main {
this.host = source["host"]; this.host = source["host"];
this.user = source["user"]; this.user = source["user"];
this.pass = source["pass"]; this.pass = source["pass"];
this.channels = source["channels"];
this.labels = source["labels"]; this.labels = source["labels"];
} }
} }
@@ -3644,6 +3658,7 @@ export namespace qso {
my_arrl_sect?: string; my_arrl_sect?: string;
my_vucc_grids?: string; my_vucc_grids?: string;
extras?: Record<string, string>; extras?: Record<string, string>;
award_refs?: string;
// Go type: time // Go type: time
created_at: any; created_at: any;
// Go type: time // Go type: time
@@ -3781,6 +3796,7 @@ export namespace qso {
this.my_arrl_sect = source["my_arrl_sect"]; this.my_arrl_sect = source["my_arrl_sect"];
this.my_vucc_grids = source["my_vucc_grids"]; this.my_vucc_grids = source["my_vucc_grids"];
this.extras = source["extras"]; this.extras = source["extras"];
this.award_refs = source["award_refs"];
this.created_at = this.convertValues(source["created_at"], null); this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null); this.updated_at = this.convertValues(source["updated_at"], null);
} }
+789 -2
View File
@@ -4,11 +4,27 @@
"name": "Départements Français Métropolitains", "name": "Départements Français Métropolitains",
"valid": true, "valid": true,
"protected": true, "protected": true,
"ref_display": "name",
"type": "QSOFIELDS", "type": "QSOFIELDS",
"field": "note", "field": "note",
"pattern": "(?i)\\b(D\\d{1,2}[AB]?)\\b", "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": [ "dxcc_filter": [
227 227,
214
], ],
"confirm": [ "confirm": [
"lotw", "lotw",
@@ -18,6 +34,777 @@
"lotw" "lotw"
], ],
"total": 96, "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 SetCWSpeed(int) error
SetCWPitch(int) error SetCWPitch(int) error
SetCWBreakInDelay(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 SetCWSidetone(bool) error
SetSidetoneLevel(int) error SetSidetoneLevel(int) error
SetCWFilter(int) error SetCWFilter(int) error
+31
View File
@@ -1581,6 +1581,37 @@ func (f *Flex) SetCWBreakInDelay(ms int) error {
return nil 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 { func (f *Flex) SetCWSidetone(on bool) error {
f.mu.Lock() f.mu.Lock()
f.tx.cwSidetone = on f.tx.cwSidetone = on
+10 -21
View File
@@ -87,14 +87,6 @@ func (o *OmniRig) Connect() error {
return nil return nil
} }
// isIC7610 reports whether the connected rig is an IC-7610. OmniRig's generic
// Freq property reads the wrong VFO on the 7610 (its Main/Sub model confuses the
// stock ini), so we read VFO A explicitly for it instead — matching what Log4OM
// shows.
func (o *OmniRig) isIC7610() bool {
return strings.Contains(strings.ToUpper(o.rigType), "7610")
}
func (o *OmniRig) Disconnect() { func (o *OmniRig) Disconnect() {
if o.rig != nil { if o.rig != nil {
o.rig.Release() o.rig.Release()
@@ -204,23 +196,20 @@ func (o *OmniRig) ReadState() (RigState, error) {
s.FreqHz, s.RxFreqHz = freqB, freqA s.FreqHz, s.RxFreqHz = freqB, freqA
} }
} else { } else {
// Simplex: the operating frequency is OmniRig's generic Freq (the active // Simplex: read VFO A first, fall back to the generic Freq — exactly like
// VFO), like Log4OM. Fall back to the per-VFO value only if Freq is 0. // DXHunter/WSJT-X. PM_FREQA rigs (Yaesu, Kenwood) populate FreqA; some
// Icoms (IC-9100 etc.) only populate the generic Freq. On the IC-7610
// OmniRig's generic Freq reports VFO B (its Main/Sub model confuses the
// stock ini), so keying off FreqA gives the operator the VFO they expect.
s.Split = false s.Split = false
s.RxFreqHz = 0 s.RxFreqHz = 0
switch {
case freqA != 0:
s.FreqHz = freqA
case freqMain != 0:
s.FreqHz = freqMain s.FreqHz = freqMain
// IC-7610 quirk: OmniRig's generic Freq reports VFO B (its Main/Sub model default:
// confuses the stock ini), so OpsLog showed the wrong VFO. Read VFO A
// explicitly for the 7610 — what the operator actually wants to see.
if o.isIC7610() && freqA != 0 {
s.FreqHz = freqA
}
if s.FreqHz == 0 {
if s.Vfo == "B" || s.Vfo == "BB" {
s.FreqHz = freqB s.FreqHz = freqB
} else {
s.FreqHz = freqA
}
} }
} }
return s, nil return s, nil
@@ -0,0 +1,10 @@
-- Materialised award references per QSO. As soon as a QSO matches an award
-- (via the operator's award definitions) or a reference is set by hand, the
-- resolved reference(s) are stored here as a compact JSON object keyed by award
-- code, e.g. {"DDFM":"74","WAJA":"12"}. The grid's per-award columns then read
-- straight from the row like any other column instead of recomputing the whole
-- award engine on every page load — much faster, and easy to display anywhere
-- (including the shared MySQL logbook). Kept in step by the app: written on log
-- / edit / UDP-import and bulk-recomputed when an award definition or reference
-- list changes. SQLite ADD COLUMN is metadata-only, fast even on large logbooks.
ALTER TABLE qso ADD COLUMN award_refs TEXT;
+77 -21
View File
@@ -197,6 +197,14 @@ type QSO struct {
// ADIF field names (e.g. "MS_SHOWER"); values are the raw string content. // ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
Extras map[string]string `json:"extras,omitempty"` Extras map[string]string `json:"extras,omitempty"`
// AwardRefs is the materialised award-reference JSON for this QSO — a compact
// object keyed by award code, e.g. {"DDFM":"74","WAJA":"12"}. Derived (the app
// computes it on log/edit and bulk-recomputes it when awards change) and stored
// so the grid's award columns read straight from the row. Written ONLY via
// SetAwardRefs, never through the normal insert/update column list, so an edit
// that doesn't know about it can't clobber it.
AwardRefs string `json:"award_refs,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
@@ -250,7 +258,10 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids, credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
extras_json` extras_json`
const selectCols = `id, ` + columnList + `, created_at, updated_at` // award_refs is read here but is NOT part of columnList (the insert/update
// write path) — it is a derived cache written only via SetAwardRefs, so a
// normal QSO write can never clobber it.
const selectCols = `id, ` + columnList + `, award_refs, created_at, updated_at`
// columnCount is derived from columnList at init so they can never drift. // columnCount is derived from columnList at init so they can never drift.
var columnCount = countColumns(columnList) var columnCount = countColumns(columnList)
@@ -813,6 +824,49 @@ func (r *Repo) SetExtra(ctx context.Context, id int64, key, value string) error
return nil return nil
} }
// SetAwardRefs stores the materialised award-reference JSON for one QSO. Like
// SetExtra it is a targeted single-column UPDATE — never a full-row write — so it
// cannot clobber a field another action changed meanwhile. updated_at is left
// UNTOUCHED on purpose: award_refs is a derived cache, and bumping updated_at
// would masquerade as a real edit (re-triggering uploads / sync that watch it).
func (r *Repo) SetAwardRefs(ctx context.Context, id int64, jsonStr string) error {
if id == 0 {
return fmt.Errorf("missing id")
}
if _, err := r.db.ExecContext(ctx,
`UPDATE qso SET award_refs = ? WHERE id = ?`, jsonStr, id); err != nil {
return fmt.Errorf("set award_refs: %w", err)
}
return nil
}
// SetAwardRefsBatch stores materialised award refs for many QSOs in a single
// transaction — used by the bulk recompute so a large logbook on a remote MySQL
// is one round-trip's worth of work, not N. Like SetAwardRefs it touches only the
// award_refs column and leaves updated_at alone (derived cache). A nil/empty map
// is a no-op.
func (r *Repo) SetAwardRefsBatch(ctx context.Context, byID map[int64]string) error {
if len(byID) == 0 {
return nil
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `UPDATE qso SET award_refs = ? WHERE id = ?`)
if err != nil {
return fmt.Errorf("prepare: %w", err)
}
defer stmt.Close()
for id, js := range byID {
if _, err := stmt.ExecContext(ctx, js, id); err != nil {
return fmt.Errorf("set award_refs %d: %w", id, err)
}
}
return tx.Commit()
}
// Update overwrites all editable fields of an existing QSO. updated_at is bumped. // Update overwrites all editable fields of an existing QSO. updated_at is bumped.
func (r *Repo) Update(ctx context.Context, q QSO) error { func (r *Repo) Update(ctx context.Context, q QSO) error {
if q.ID == 0 { if q.ID == 0 {
@@ -1921,21 +1975,20 @@ func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, boo
return time.Time{}, false return time.Time{}, false
} }
// RecentRate counts QSOs whose start time falls within each trailing window from // RecentRateBreakdown counts, in ONE pass over the most recent rows, QSOs whose
// `now` — the live "QSO rate" meter shown in the header. When operator is non-empty // start time falls within each trailing window from `now` — for a specific operator
// (multi-op on a shared logbook) only that operator's QSOs are counted, so each op // (their own rate, `op`) AND for ALL operators combined (the team/station rate,
// sees their OWN performance, not the cumulative rate; empty operator matches every // `all`). The header rate meter shows both. It scans only recently inserted rows
// QSO. It scans only the most recently inserted rows (ORDER BY id DESC LIMIT), since // (ORDER BY id DESC LIMIT), since any QSO in the last hour was inserted recently, so
// any QSO in the last hour was inserted recently; that keeps it cheap even on a large // it stays cheap on a large log. qso_date is parsed with parseTimeLoose (backend-
// log. qso_date is the repo's text column, parsed with parseTimeLoose (backend-format // format agnostic).
// agnostic). func (r *Repo) RecentRateBreakdown(ctx context.Context, now time.Time, operator string, windows ...time.Duration) (op []int, all []int, err error) {
func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, windows ...time.Duration) ([]int, error) { op = make([]int, len(windows))
counts := make([]int, len(windows)) all = make([]int, len(windows))
// 2000 rows covers a full hour for one operator even in a busy multi-op run // 2000 rows covers a full hour even in a busy multi-op run.
// (other operators' rows are discarded before counting).
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`) rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 2000`)
if err != nil { if err != nil {
return counts, err return op, all, err
} }
defer rows.Close() defer rows.Close()
now = now.UTC() now = now.UTC()
@@ -1943,23 +1996,24 @@ func (r *Repo) RecentRate(ctx context.Context, now time.Time, operator string, w
for rows.Next() { for rows.Next() {
var oper, dateStr sql.NullString var oper, dateStr sql.NullString
if err := rows.Scan(&oper, &dateStr); err != nil { if err := rows.Scan(&oper, &dateStr); err != nil {
return counts, err return op, all, err
}
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
continue // a different operator's QSO — not part of my rate
} }
t := parseTimeLoose(dateStr.String).UTC() t := parseTimeLoose(dateStr.String).UTC()
if t.IsZero() || t.After(now) { if t.IsZero() || t.After(now) {
continue continue
} }
mine := strings.ToUpper(strings.TrimSpace(oper.String)) == opFilter
age := now.Sub(t) age := now.Sub(t)
for i, w := range windows { for i, w := range windows {
if age <= w { if age <= w {
counts[i]++ all[i]++
if mine {
op[i]++
} }
} }
} }
return counts, rows.Err() }
return op, all, rows.Err()
} }
// ExistingDedupeKeys returns a set of every QSO key currently in the DB, // ExistingDedupeKeys returns a set of every QSO key currently in the DB,
@@ -2374,6 +2428,7 @@ func scanQSO(s scanner) (QSO, error) {
creditGranted, creditSubmitted sql.NullString creditGranted, creditSubmitted sql.NullString
myARRLSect, myVUCCGrids sql.NullString myARRLSect, myVUCCGrids sql.NullString
extrasJSON sql.NullString extrasJSON sql.NullString
awardRefs sql.NullString
createdStr, updatedStr string createdStr, updatedStr string
) )
if err := s.Scan( if err := s.Scan(
@@ -2401,7 +2456,7 @@ func scanQSO(s scanner) (QSO, error) {
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class, &skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
&darcDOK, &myDarcDOK, &region, &silentKey, &swl, &qsoComplete, &qsoRandom, &darcDOK, &myDarcDOK, &region, &silentKey, &swl, &qsoComplete, &qsoRandom,
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids, &creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
&extrasJSON, &createdStr, &updatedStr, &extrasJSON, &awardRefs, &createdStr, &updatedStr,
); err != nil { ); err != nil {
return QSO{}, fmt.Errorf("scan qso: %w", err) return QSO{}, fmt.Errorf("scan qso: %w", err)
} }
@@ -2598,6 +2653,7 @@ func scanQSO(s scanner) (QSO, error) {
q.MyARRLSect = myARRLSect.String q.MyARRLSect = myARRLSect.String
q.MyVUCCGrids = myVUCCGrids.String q.MyVUCCGrids = myVUCCGrids.String
q.Extras = decodeExtras(extrasJSON.String) q.Extras = decodeExtras(extrasJSON.String)
q.AwardRefs = awardRefs.String
return q, nil return q, nil
} }
+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
}
+17 -13
View File
@@ -202,33 +202,37 @@ func (a *App) publishLiveStatus() {
} }
a.liveActMu.Unlock() a.liveActMu.Unlock()
lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB) lastQSO := a.liveLastQSOTime() // authoritative (in-memory OR shared DB)
// Online = a new contact was logged within the window. An operator who leaves // On air = a new contact was logged within the window. An operator who leaves
// the log open but stops working shows offline after `liveOnlineWindow`; the // the log open but stops working goes offline after `liveOnlineWindow`; the next
// next QSO flips them back on. never-logged (zero time) → offline. // QSO puts them back on. never-logged (zero time) → offline.
online := 0 online := !lastQSO.IsZero() && time.Since(lastQSO) < liveOnlineWindow
var lastQSOArg any
if !lastQSO.IsZero() {
lastQSOArg = lastQSO.UTC()
if time.Since(lastQSO) < liveOnlineWindow {
online = 1
}
}
if err := a.ensureLiveStatusTable(); err != nil { if err := a.ensureLiveStatusTable(); err != nil {
applog.Printf("livestatus: CREATE TABLE failed: %v", err) applog.Printf("livestatus: CREATE TABLE failed: %v", err)
return return
} }
// Offline → REMOVE the row entirely, not just flip a flag: a status page that
// lists the present rows (the common case, keyed on updated_at) then shows the
// operator as gone without having to read the online column. The row reappears
// on the next QSO. This is the whole point — no one shows on air when they're not.
if !online {
if _, err := a.logDb.ExecContext(a.ctx, "DELETE FROM live_status WHERE operator=?", op); err != nil {
applog.Printf("livestatus: offline DELETE failed: %v", err)
}
return
}
lastQSOArg := lastQSO.UTC()
_, err := a.logDb.ExecContext(a.ctx, _, err := a.logDb.ExecContext(a.ctx,
"INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+ "INSERT INTO live_status (operator, station, freq_hz, band, mode, online, version, last_qso_at, updated_at) "+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP()) "+
"ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+ "ON DUPLICATE KEY UPDATE station=VALUES(station), freq_hz=VALUES(freq_hz), "+
"band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+ "band=VALUES(band), mode=VALUES(mode), online=VALUES(online), version=VALUES(version), "+
"last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()", "last_qso_at=VALUES(last_qso_at), updated_at=UTC_TIMESTAMP()",
op, station, freqHz, band, mode, online, appVersion, lastQSOArg) op, station, freqHz, band, mode, 1, appVersion, lastQSOArg)
if err != nil { if err != nil {
applog.Printf("livestatus: INSERT failed: %v", err) applog.Printf("livestatus: INSERT failed: %v", err)
return return
} }
applog.Printf("livestatus: published op=%s station=%s %dHz %s %s online=%d", op, station, freqHz, band, mode, online) applog.Printf("livestatus: published op=%s station=%s %dHz %s %s ON AIR", op, station, freqHz, band, mode)
} }
// LiveStation is one operator's live status for the multi-op "who's on air" widget. // LiveStation is one operator's live status for the multi-op "who's on air" widget.
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.20.2" appVersion = "0.20.4"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.
+19 -6
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime" wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
@@ -159,14 +160,26 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
_ = os.Rename(oldExe, exe) // roll back _ = os.Rename(oldExe, exe) // roll back
return fmt.Errorf("install new exe: %w", err) return fmt.Errorf("install new exe: %w", err)
} }
applog.Printf("update: installed new exe, relaunching") // Clear the "downloaded from the internet" mark (NTFS Zone.Identifier stream).
// Otherwise Windows SmartScreen wants to prompt "are you sure you want to open
// this?" — but since we launch the exe programmatically that prompt never shows,
// and the launch is silently blocked. This is exactly why the relaunch failed.
_ = os.Remove(exe + ":Zone.Identifier")
applog.Printf("update: installed new exe, scheduling relaunch")
// Relaunch with a flag so the fresh instance waits for THIS one to exit and // Relaunch via a detached, hidden PowerShell that WAITS for this process to exit
// free the single-instance mutex instead of bailing out immediately. // (so the single-instance mutex is free) and THEN starts the new exe. Launching
cmd := exec.Command(exe, "--post-update") // the new exe directly while we're still alive raced the mutex and often left
cmd.Dir = dir // nothing running; waiting for our own exit first makes the restart reliable,
// and the launcher outlives us.
quoted := strings.ReplaceAll(exe, "'", "''")
ps := fmt.Sprintf(
"Wait-Process -Id %d -ErrorAction SilentlyContinue; Start-Sleep -Milliseconds 400; Start-Process -FilePath '%s' -ArgumentList '--post-update'",
os.Getpid(), quoted)
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000} // CREATE_NO_WINDOW
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch: %w", err) return fmt.Errorf("schedule relaunch: %w", err)
} }
if a.ctx != nil { if a.ctx != nil {
wruntime.Quit(a.ctx) wruntime.Quit(a.ctx)