Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
190b86eb1c | ||
|
|
cfc3d00ea1 | ||
|
|
9033e8518c | ||
|
|
bfbd9fa61a | ||
|
|
f0c4f22942 | ||
|
|
19c91f32a0 | ||
|
|
3ec23bc613 | ||
|
|
2fbb922bd2 | ||
|
|
f0afdcc498 | ||
|
|
22352c5748 | ||
|
|
6356d60a66 | ||
|
|
cafade0dbb | ||
|
|
2e39615554 | ||
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 | ||
|
|
3cef885934 | ||
|
|
b8db653981 | ||
|
|
9729ef62ba | ||
|
|
cc6411a618 | ||
|
|
991831bdec | ||
|
|
1b2da95ad4 | ||
|
|
10d86db50a | ||
|
|
8538f48259 | ||
|
|
0fa91c3d5f | ||
|
|
c86d331bd9 |
@@ -32,7 +32,6 @@ import (
|
|||||||
"hamlog/internal/clublog"
|
"hamlog/internal/clublog"
|
||||||
"hamlog/internal/cluster"
|
"hamlog/internal/cluster"
|
||||||
"hamlog/internal/contest"
|
"hamlog/internal/contest"
|
||||||
"hamlog/internal/cwdecode"
|
|
||||||
"hamlog/internal/db"
|
"hamlog/internal/db"
|
||||||
"hamlog/internal/dxcc"
|
"hamlog/internal/dxcc"
|
||||||
"hamlog/internal/email"
|
"hamlog/internal/email"
|
||||||
@@ -45,6 +44,7 @@ import (
|
|||||||
"hamlog/internal/operating"
|
"hamlog/internal/operating"
|
||||||
"hamlog/internal/pota"
|
"hamlog/internal/pota"
|
||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
|
"hamlog/internal/spe"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
@@ -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"
|
||||||
@@ -459,6 +465,7 @@ type App struct {
|
|||||||
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
||||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
|
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||||
@@ -474,10 +481,6 @@ type App struct {
|
|||||||
|
|
||||||
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
||||||
|
|
||||||
cwMu sync.Mutex // guards the CW decoder lifecycle
|
|
||||||
cwStop chan struct{} // stops the CW decoder capture loop; nil when off
|
|
||||||
cwDecoder *cwdecode.Decoder // live decoder (for retargeting the pitch)
|
|
||||||
cwPitchHz int // manual pitch override (0 = auto / follow Flex)
|
|
||||||
startupProfile string // --profile <name> from the command line (activate at startup)
|
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||||
@@ -505,6 +508,7 @@ type App struct {
|
|||||||
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
liveFreqHz int64 // last freq/band/mode the UI reported (fallback when CAT is off)
|
||||||
liveBand string
|
liveBand string
|
||||||
liveMode string
|
liveMode string
|
||||||
|
livePublishTimer *time.Timer // debounced live-status publish on activity change
|
||||||
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
|
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
|
||||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||||
@@ -699,6 +703,20 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
usingDefault = false
|
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)
|
||||||
@@ -1340,6 +1358,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") }
|
||||||
@@ -1654,6 +1677,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"}},
|
||||||
})
|
})
|
||||||
@@ -1697,6 +1721,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 {
|
||||||
@@ -2452,6 +2514,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
|
||||||
@@ -3415,6 +3485,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3821,7 +3895,12 @@ func (a *App) recomputeAwardRefsAsync() {
|
|||||||
|
|
||||||
// keyAwardsMaterialized marks (per profile / logbook) that the one-time award_refs
|
// 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.
|
// backfill has run, so it doesn't re-scan the whole logbook on every launch.
|
||||||
const keyAwardsMaterialized = "awards.materialized.v1"
|
// keyAwardsMaterialized is versioned: bump the suffix to force every operator to
|
||||||
|
// re-materialise award_refs once on next launch. Needed when the stored values
|
||||||
|
// could be stale against the CURRENT award definitions — e.g. rows materialised
|
||||||
|
// before an award gained a new matching rule (DDFM's postal-code OR-rule), which
|
||||||
|
// the one-shot backfill would otherwise never revisit.
|
||||||
|
const keyAwardsMaterialized = "awards.materialized.v2"
|
||||||
|
|
||||||
// backfillAwardRefsOnce populates award_refs for QSOs that predate the feature
|
// 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
|
// (or were logged by an older client that didn't materialise them). It runs at
|
||||||
@@ -4186,6 +4265,65 @@ func (a *App) ExportAward(code string) (string, error) {
|
|||||||
return a.exportAwardBundle([]string{code}, "OpsLog_award_"+code+".json", "Export award "+code)
|
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 {
|
||||||
@@ -10225,6 +10363,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 {
|
||||||
@@ -11224,29 +11401,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 {
|
||||||
@@ -11271,13 +11478,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, "")
|
||||||
}
|
}
|
||||||
@@ -11318,7 +11528,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()
|
||||||
@@ -11925,19 +12135,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
|
||||||
}
|
}
|
||||||
@@ -11946,6 +12162,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11956,10 +12182,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
|
||||||
@@ -11978,12 +12217,48 @@ func (a *App) startPGXL() {
|
|||||||
go a.pgxl.Stop()
|
go a.pgxl.Stop()
|
||||||
a.pgxl = nil
|
a.pgxl = nil
|
||||||
}
|
}
|
||||||
|
if a.spe != nil {
|
||||||
|
go a.spe.Stop()
|
||||||
|
a.spe = nil
|
||||||
|
}
|
||||||
s, err := a.GetPGXLSettings()
|
s, err := a.GetPGXLSettings()
|
||||||
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
|
if err != nil || !s.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.Type == "" || s.Type == "pgxl" {
|
||||||
|
if strings.TrimSpace(s.Host) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.pgxl = powergenius.New(s.Host, s.Port)
|
a.pgxl = powergenius.New(s.Host, s.Port)
|
||||||
_ = a.pgxl.Start()
|
_ = a.pgxl.Start()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPE Expert — USB serial or an RS232-to-Ethernet bridge.
|
||||||
|
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
|
||||||
|
_ = a.spe.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSPEStatus returns the SPE Expert amplifier state for the UI poll.
|
||||||
|
func (a *App) GetSPEStatus() spe.Status {
|
||||||
|
if a.spe == nil {
|
||||||
|
return spe.Status{}
|
||||||
|
}
|
||||||
|
return a.spe.GetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SPESetOperate puts the SPE amp in OPERATE (true) or STANDBY (false). The amp has
|
||||||
|
// a single OPERATE toggle key, so this sends it only when the state must change.
|
||||||
|
func (a *App) SPESetOperate(on bool) error {
|
||||||
|
if a.spe == nil {
|
||||||
|
return fmt.Errorf("SPE amplifier not connected — enable it in Settings → Amplifier")
|
||||||
|
}
|
||||||
|
return a.spe.Operate(on)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
|
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"hamlog/internal/applog"
|
|
||||||
"hamlog/internal/audio"
|
|
||||||
"hamlog/internal/cwdecode"
|
|
||||||
|
|
||||||
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CW decoder: taps the RX audio device (the same "From radio" capture the DVK
|
|
||||||
// and QSO recorder use) and streams decoded Morse text to the UI. It is started
|
|
||||||
// only by the frontend, and only while the entry mode is CW.
|
|
||||||
//
|
|
||||||
// Pitch targeting: the single-channel decoder is far more reliable when it locks
|
|
||||||
// to a KNOWN pitch (a narrow filter at the signal frequency, like a skimmer)
|
|
||||||
// instead of auto-searching for the loudest tone. So we follow the radio's CW
|
|
||||||
// pitch (FlexRadio cw_pitch) when available — or a manual override — and fall
|
|
||||||
// back to auto-search otherwise.
|
|
||||||
|
|
||||||
// cwTargetPitch returns the pitch (Hz) the decoder should lock to: the manual
|
|
||||||
// override if set, else the FlexRadio's CW pitch when it's in CW, else 0 (auto).
|
|
||||||
func (a *App) cwTargetPitch() int {
|
|
||||||
if a.cwPitchHz > 0 {
|
|
||||||
return a.cwPitchHz
|
|
||||||
}
|
|
||||||
if a.cat != nil {
|
|
||||||
if st, ok := a.cat.FlexState(); ok && st.Available {
|
|
||||||
// Only trust the radio's pitch when it's actually in CW.
|
|
||||||
if st.Mode == "CW" || st.Mode == "CWL" || st.Mode == "CWU" {
|
|
||||||
if st.CWPitch > 0 {
|
|
||||||
return st.CWPitch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCWDecoder begins decoding CW from the configured RX audio device. The
|
|
||||||
// frontend calls this when the decoder toggle is on AND the mode is CW. Safe to
|
|
||||||
// call repeatedly; a second call is a no-op while already running.
|
|
||||||
func (a *App) StartCWDecoder() error {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
if a.cwStop != nil {
|
|
||||||
return nil // already running
|
|
||||||
}
|
|
||||||
dev := ""
|
|
||||||
if a.settings != nil {
|
|
||||||
dev, _ = a.settings.Get(a.ctx, keyAudioFromRadio)
|
|
||||||
}
|
|
||||||
if dev == "" {
|
|
||||||
return fmt.Errorf("no RX audio device configured (set \"From radio\" in Audio settings)")
|
|
||||||
}
|
|
||||||
|
|
||||||
dec := cwdecode.New(audio.SampleRate,
|
|
||||||
func(text string) {
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:text", text)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
func(st cwdecode.Status) {
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:status", st)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
a.cwDecoder = dec
|
|
||||||
|
|
||||||
stop := make(chan struct{})
|
|
||||||
a.cwStop = stop
|
|
||||||
go func() {
|
|
||||||
if err := audio.StreamCapture(dev, stop, dec.Process); err != nil {
|
|
||||||
applog.Printf("cw: capture failed: %v", err)
|
|
||||||
if a.ctx != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "cw:error", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
a.cwMu.Lock()
|
|
||||||
if a.cwStop == stop {
|
|
||||||
a.cwStop = nil
|
|
||||||
a.cwDecoder = nil
|
|
||||||
}
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
}()
|
|
||||||
// Follow the radio's CW pitch live (every second) while this run is active.
|
|
||||||
go a.cwFollowPitch(stop, dec)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cwFollowPitch keeps the decoder locked to the current target pitch until stop.
|
|
||||||
func (a *App) cwFollowPitch(stop <-chan struct{}, dec *cwdecode.Decoder) {
|
|
||||||
t := time.NewTicker(time.Second)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
case <-t.C:
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopCWDecoder halts the CW decoder if running.
|
|
||||||
func (a *App) StopCWDecoder() {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
stop := a.cwStop
|
|
||||||
a.cwStop = nil
|
|
||||||
a.cwDecoder = nil
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
if stop != nil {
|
|
||||||
close(stop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CWDecoderRunning reports whether the decoder is currently capturing.
|
|
||||||
func (a *App) CWDecoderRunning() bool {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
return a.cwStop != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCWDecoderPitch sets a manual decode pitch (Hz); 0 returns to auto (follow
|
|
||||||
// the Flex CW pitch, or search). Applies live to a running decoder.
|
|
||||||
func (a *App) SetCWDecoderPitch(hz int) {
|
|
||||||
if hz < 0 {
|
|
||||||
hz = 0
|
|
||||||
}
|
|
||||||
a.cwMu.Lock()
|
|
||||||
a.cwPitchHz = hz
|
|
||||||
dec := a.cwDecoder
|
|
||||||
a.cwMu.Unlock()
|
|
||||||
if dec != nil {
|
|
||||||
dec.SetTarget(a.cwTargetPitch())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCWDecoderPitch returns the manual override (0 = auto / follow Flex).
|
|
||||||
func (a *App) GetCWDecoderPitch() int {
|
|
||||||
a.cwMu.Lock()
|
|
||||||
defer a.cwMu.Unlock()
|
|
||||||
return a.cwPitchHz
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed changelog.json
|
||||||
|
var changelogJSON []byte
|
||||||
|
|
||||||
|
// ChangelogEntry is one release's user-facing notes, in English and French. It's
|
||||||
|
// shown once on the first launch after an update (the "What's new" dialog),
|
||||||
|
// derived from the release's commit history.
|
||||||
|
type ChangelogEntry struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
EN []string `json:"en"`
|
||||||
|
FR []string `json:"fr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyChangelogSeen = "changelog.last_seen_version"
|
||||||
|
|
||||||
|
func loadChangelog() []ChangelogEntry {
|
||||||
|
var out []ChangelogEntry
|
||||||
|
if err := json.Unmarshal(changelogJSON, &out); err != nil {
|
||||||
|
applog.Printf("changelog: parse failed: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChangelog returns every changelog entry up to the running version (newest
|
||||||
|
// first), without touching the "seen" marker — for a "What's new" button that
|
||||||
|
// reopens the notes on demand.
|
||||||
|
func (a *App) GetChangelog() []ChangelogEntry {
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" || versionLess(appVersion, e.Version) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWhatsNew returns the changelog entries the operator hasn't seen yet — the
|
||||||
|
// notes for every version newer than the last one they ran, up to the current
|
||||||
|
// build — and records the current version as seen so it pops exactly once per
|
||||||
|
// update. Empty when there's nothing new.
|
||||||
|
func (a *App) GetWhatsNew() []ChangelogEntry {
|
||||||
|
if a.settings == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastSeen, _ := a.settings.GetGlobal(a.ctx, keyChangelogSeen)
|
||||||
|
cur := appVersion
|
||||||
|
a.setSettingGlobal(keyChangelogSeen, cur) // advance the marker now
|
||||||
|
|
||||||
|
out := []ChangelogEntry{}
|
||||||
|
for _, e := range loadChangelog() {
|
||||||
|
if strings.TrimSpace(e.Version) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(cur, e.Version) {
|
||||||
|
continue // don't preview notes for a version newer than we run
|
||||||
|
}
|
||||||
|
if lastSeen == "" {
|
||||||
|
// First run of the feature (fresh install, or upgrade from a build that
|
||||||
|
// predates it): show only the CURRENT version's notes, once.
|
||||||
|
if e.Version == cur {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if versionLess(lastSeen, e.Version) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"version": "0.20.5",
|
||||||
|
"date": "2026-07-20",
|
||||||
|
"en": [
|
||||||
|
"New FlexRadio CWX CW keyer: key CW straight over the SmartSDR connection — no WinKeyer or SmartCAT needed. Type-ahead and mid-send backspace supported.",
|
||||||
|
"ESC now stops CW on whichever keyer is active (WinKeyer / Icom / Flex), and typing a callsign aborts a running macro.",
|
||||||
|
"New Amplifier settings section (was 'Power Genius'): control SPE Expert amps (1.3K / 1.5K / 2K) over USB serial or an RS232-to-Ethernet bridge — OPERATE/STANDBY and live status. PowerGenius XL still supported.",
|
||||||
|
"Denkovi USB relay boards supported (4/8 relays, FT245), plus generic CH340/LCUS USB-serial relay boards.",
|
||||||
|
"Rename your database from Settings without losing any configuration.",
|
||||||
|
"Awards: DDFM now finds the French department from the address postal code; refs are stored on each QSO for faster columns; 'Publish to catalog' shares an edited award with your team on the next release; award columns are remembered per profile.",
|
||||||
|
"Statistics: per-band mode split (CW / phone / data), and an activity chart with a Day / Week / Month / Year selector (rolling, real dates).",
|
||||||
|
"'Stations on air' widget updates within seconds and fits more stations.",
|
||||||
|
"The audio CW decoder was removed.",
|
||||||
|
"Fixes: Antenna Genius buttons ignored clicks while a macro was sending; File → Exit; and more.",
|
||||||
|
"This 'What's new' summary now appears on the first launch after each update."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau keyer CW FlexRadio CWX : manipulation CW directement via la connexion SmartSDR — plus besoin de WinKeyer ni de SmartCAT. Type-ahead et retour arrière en cours d'envoi supportés.",
|
||||||
|
"ESC arrête maintenant le CW sur le moteur actif (WinKeyer / Icom / Flex), et taper un indicatif interrompt une macro en cours.",
|
||||||
|
"Nouvelle section Amplificateur (ex « Power Genius ») : pilotage des SPE Expert (1.3K / 1.5K / 2K) en USB série ou via un pont RS232→Ethernet — OPERATE/STANDBY et état en direct. PowerGenius XL toujours supporté.",
|
||||||
|
"Cartes relais USB Denkovi (4/8 relais, FT245), plus cartes USB-série génériques CH340/LCUS.",
|
||||||
|
"Renommer sa base depuis les réglages sans perdre la configuration.",
|
||||||
|
"Awards : DDFM trouve le département français depuis le code postal de l'adresse ; les réfs sont stockées sur chaque QSO (colonnes plus rapides) ; « Publier au catalogue » partage un award édité avec l'équipe à la prochaine mise à jour ; les colonnes d'award sont mémorisées par profil.",
|
||||||
|
"Statistiques : répartition des modes par bande (CW / phone / data), et un graphe d'activité avec sélecteur Jour / Semaine / Mois / Année (glissant, dates réelles).",
|
||||||
|
"Le widget « Stations on air » se met à jour en quelques secondes et affiche plus de stations.",
|
||||||
|
"Le décodeur CW audio a été retiré.",
|
||||||
|
"Corrections : les boutons Antenna Genius ignoraient les clics pendant l'envoi d'une macro ; Fichier → Quitter ; et d'autres.",
|
||||||
|
"Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
+117
-133
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Ear, Eraser, Hash, Loader2, Lock,
|
Activity, AlertCircle, Antenna, Bell, CheckCircle2, Clock, CloudOff, Compass, Database, Eraser, Hash, Loader2, Lock,
|
||||||
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, Square, Terminal, Trash2, Unlock, X, Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations,
|
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode,
|
SetCompactMode,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna,
|
||||||
@@ -36,8 +36,8 @@ 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,
|
|
||||||
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
ChatAvailable, GetChatHistory, SendChatMessage, GetOnlineOperators,
|
||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
@@ -283,7 +283,7 @@ function computePrefix(call: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
// === Lists from settings (fallback for first paint) ===
|
// === Lists from settings (fallback for first paint) ===
|
||||||
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
const [bands, setBands] = useState<string[]>(DEFAULT_BANDS);
|
||||||
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
const [modes, setModes] = useState<string[]>(DEFAULT_MODES);
|
||||||
@@ -441,13 +441,21 @@ export default function App() {
|
|||||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||||
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
|
const [liveStations, setLiveStations] = useState<LiveStation[]>([]);
|
||||||
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
|
const [showLiveStations, setShowLiveStations] = useState(() => localStorage.getItem('opslog.showLiveStations') === '1');
|
||||||
|
// Depend on the BOOLEAN (is-mysql), not the dbConn object: dbConn gets a fresh
|
||||||
|
// reference on several events, and depending on the object reset the 15s timer
|
||||||
|
// on every such refresh so it could ~never fire (the widget looked stuck for
|
||||||
|
// minutes). Also refresh right after we log a QSO so our own row shows without
|
||||||
|
// waiting for the next tick, like the ON AIR badge.
|
||||||
|
const liveStationsOn = dbConn?.backend === 'mysql';
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dbConn?.backend !== 'mysql') { setLiveStations([]); return; }
|
if (!liveStationsOn) { setLiveStations([]); return; }
|
||||||
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
|
const load = () => GetLiveStations().then((s) => setLiveStations((s ?? []) as LiveStation[])).catch(() => {});
|
||||||
load();
|
load();
|
||||||
const id = window.setInterval(load, 15 * 1000);
|
const id = window.setInterval(load, 5 * 1000);
|
||||||
return () => window.clearInterval(id);
|
// Small delay lets the async publishLiveStatus MySQL write land before we read.
|
||||||
}, [dbConn]);
|
const offLogged = EventsOn('qso:logged', () => { window.setTimeout(load, 1500); });
|
||||||
|
return () => { window.clearInterval(id); offLogged?.(); };
|
||||||
|
}, [liveStationsOn]);
|
||||||
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
// Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig
|
||||||
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
// can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default
|
||||||
// in Preferences > Hardware > CAT interface.
|
// in Preferences > Hardware > CAT interface.
|
||||||
@@ -775,7 +783,7 @@ export default function App() {
|
|||||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
// 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
|
||||||
@@ -804,42 +812,14 @@ 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]);
|
||||||
|
|
||||||
// === Digital Voice Keyer (DVK) ===
|
// === Digital Voice Keyer (DVK) ===
|
||||||
// CW decoder: taps RX audio and decodes Morse. Runs only when enabled AND the
|
|
||||||
// mode is CW. The decoded text appears in a strip above the tabs.
|
|
||||||
const [cwEnabled, setCwEnabled] = useState(() => localStorage.getItem('opslog.cwDecoder') === '1');
|
|
||||||
const [cwText, setCwText] = useState('');
|
|
||||||
const [cwStatus, setCwStatus] = useState<{ wpm: number; pitch: number; level: number; active: boolean }>({ wpm: 0, pitch: 0, level: 0, active: false });
|
|
||||||
const cwOn = cwEnabled && mode === 'CW';
|
|
||||||
// Keep the decoded line scrolled to the newest text (left-aligned, no scrollbar).
|
|
||||||
const cwScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
useEffect(() => { const el = cwScrollRef.current; if (el) el.scrollLeft = el.scrollWidth; }, [cwText]);
|
|
||||||
// Manual pitch override ('' = Auto: follow the radio's CW pitch / search).
|
|
||||||
const [cwPitch, setCwPitch] = useState(() => localStorage.getItem('opslog.cwPitch') || '');
|
|
||||||
useEffect(() => {
|
|
||||||
const hz = parseInt(cwPitch, 10);
|
|
||||||
SetCWDecoderPitch(Number.isFinite(hz) ? hz : 0).catch(() => {});
|
|
||||||
localStorage.setItem('opslog.cwPitch', cwPitch);
|
|
||||||
}, [cwPitch, cwOn]);
|
|
||||||
useEffect(() => {
|
|
||||||
const offT = EventsOn('cw:text', (t: string) => setCwText((s) => (s + t).slice(-200)));
|
|
||||||
const offS = EventsOn('cw:status', (st: any) => setCwStatus(st));
|
|
||||||
const offE = EventsOn('cw:error', (e: string) => { setError(String(e)); setCwEnabled(false); });
|
|
||||||
return () => { offT?.(); offS?.(); offE?.(); };
|
|
||||||
}, []);
|
|
||||||
// Start/stop the backend decoder as the (enabled, mode) combination changes.
|
|
||||||
useEffect(() => {
|
|
||||||
if (cwOn) { StartCWDecoder().catch((e: any) => { setError(String(e?.message ?? e)); setCwEnabled(false); }); }
|
|
||||||
else { StopCWDecoder().catch(() => {}); }
|
|
||||||
}, [cwOn]);
|
|
||||||
function toggleCwDecoder() {
|
|
||||||
setCwEnabled((v) => { const n = !v; localStorage.setItem('opslog.cwDecoder', n ? '1' : '0'); return n; });
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
// === Multi-op chat (shared MySQL logbook) — docked panel like rotor/DVK ===
|
||||||
const [chatAvailable, setChatAvailable] = useState(false);
|
const [chatAvailable, setChatAvailable] = useState(false);
|
||||||
@@ -1167,6 +1147,12 @@ export default function App() {
|
|||||||
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
const [settingsSection, setSettingsSection] = useState<string | undefined>(undefined);
|
||||||
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
const [showDeleteAll, setShowDeleteAll] = useState(false);
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
// "What's new": the changelog for the version(s) since the operator last ran,
|
||||||
|
// shown once on the first launch after an update (EN/FR per the UI language).
|
||||||
|
const [whatsNew, setWhatsNew] = useState<Array<{ version: string; date: string; en: string[]; fr: string[] }> | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
GetWhatsNew().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); }).catch(() => {});
|
||||||
|
}, []);
|
||||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||||
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string; downloadUrl: string } | null>(null);
|
||||||
const [updating, setUpdating] = useState(false);
|
const [updating, setUpdating] = useState(false);
|
||||||
@@ -2036,7 +2022,7 @@ 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 */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2134,11 +2120,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;
|
||||||
}
|
}
|
||||||
@@ -2159,6 +2147,13 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
// stopAutoCall cancels any running auto-call loop.
|
// stopAutoCall cancels any running auto-call loop.
|
||||||
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
|
function stopAutoCall() { autoCallMacroRef.current = -1; autoCallGenRef.current++; }
|
||||||
|
// stopKeyerTx aborts the CW being sent RIGHT NOW, routed to the active engine
|
||||||
|
// (was WinKeyer-only in a few places, so a Flex CWX / Icom macro kept going).
|
||||||
|
function stopKeyerTx() {
|
||||||
|
if (cwSourceRef.current === 'icom') IcomStopCW().catch(() => {});
|
||||||
|
else if (cwSourceRef.current === 'flex') FlexStopCW().catch(() => {});
|
||||||
|
else WinkeyerStop().catch(() => {});
|
||||||
|
}
|
||||||
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
// runAutoCall sends macro i, waits for the keyer to finish, waits the chosen
|
||||||
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
|
// gap, then resends — looping until cancelled (reply entered, Stop, unchecked).
|
||||||
async function runAutoCall(i: number) {
|
async function runAutoCall(i: number) {
|
||||||
@@ -2174,7 +2169,7 @@ export default function App() {
|
|||||||
// the wait at the ESTIMATED send time (not the busy flag alone): over a
|
// 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;
|
||||||
@@ -2212,8 +2207,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
|
||||||
@@ -2659,7 +2660,11 @@ export default function App() {
|
|||||||
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
|
// rather than finishing the buffered call. (autoCallMacroRef flips to -1 on
|
||||||
// the first keystroke, so we only abort once.)
|
// the first keystroke, so we only abort once.)
|
||||||
if (v.trim() !== '') {
|
if (v.trim() !== '') {
|
||||||
if (autoCallMacroRef.current !== -1) WinkeyerStop().catch(() => {});
|
// Someone answered: on the FIRST character of a new call (the field was
|
||||||
|
// empty), abort whatever CW is being sent — a single macro OR an auto-call
|
||||||
|
// CQ loop — routed to the ACTIVE engine (was WinKeyer-only, so a Flex CWX /
|
||||||
|
// Icom macro kept keying over the answering station).
|
||||||
|
if (callsignValRef.current.trim() === '') stopKeyerTx();
|
||||||
stopAutoCall();
|
stopAutoCall();
|
||||||
}
|
}
|
||||||
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
// No-op guard: external apps (MSHV/WSJT-X) re-broadcast the same DX call
|
||||||
@@ -2783,7 +2788,6 @@ export default function App() {
|
|||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
||||||
{ type: 'item', label: (cwEnabled ? '✓ ' : '') + t('tools.cwDecoder'), action: 'tools.cwdecoder' },
|
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
{ type: 'item', label: (netEnabled ? '✓ ' : '') + t('tools.net'), action: 'tools.net' },
|
||||||
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
{ type: 'item', label: (contestTabEnabled ? '✓ ' : '') + t('tools.contest'), action: 'tools.contest' },
|
||||||
@@ -2797,9 +2801,10 @@ export default function App() {
|
|||||||
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
|
{ type: 'item', label: refsDownloading ? 'Downloading reference lists…' : 'Download reference lists (IOTA/POTA/WWFF/SOTA)', action: 'tools.downloadRefs', disabled: refsDownloading },
|
||||||
]},
|
]},
|
||||||
{ name: 'help', label: t('menu.help'), items: [
|
{ name: 'help', label: t('menu.help'), items: [
|
||||||
|
{ type: 'item', label: t('whatsnew.title'), action: 'help.whatsnew' },
|
||||||
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
{ type: 'item', label: t('help.about'), action: 'help.about' },
|
||||||
]},
|
]},
|
||||||
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, cwEnabled, netEnabled, contestTabEnabled, t]);
|
], [total, selectedId, selectedIds, ctyRefreshing, refsDownloading, exporting, wkEnabled, dvkEnabled, netEnabled, contestTabEnabled, t]);
|
||||||
|
|
||||||
function handleMenu(action: string) {
|
function handleMenu(action: string) {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@@ -2820,7 +2825,6 @@ export default function App() {
|
|||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
|
||||||
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
case 'tools.net': setNetEnabled((v) => { const nv = !v; if (nv) setActiveTab('net'); return nv; }); break;
|
||||||
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
case 'tools.contest': setContestTabEnabled((v) => { const nv = !v; if (nv) setActiveTab('contest'); else setActiveTab((tb) => (tb === 'contest' ? 'recent' : tb)); return nv; }); break;
|
||||||
case 'tools.alerts': setAlertsOpen(true); break;
|
case 'tools.alerts': setAlertsOpen(true); break;
|
||||||
@@ -2828,6 +2832,7 @@ export default function App() {
|
|||||||
case 'tools.refreshCty': refreshCtyDat(); break;
|
case 'tools.refreshCty': refreshCtyDat(); break;
|
||||||
case 'tools.downloadRefs': downloadRefs(); break;
|
case 'tools.downloadRefs': downloadRefs(); break;
|
||||||
case 'help.about': setShowAbout(true); break;
|
case 'help.about': setShowAbout(true); break;
|
||||||
|
case 'help.whatsnew': GetChangelog().then((e: any) => { if (Array.isArray(e) && e.length) setWhatsNew(e); else showToast(t('whatsnew.none')); }).catch(() => {}); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2880,7 +2885,14 @@ export default function App() {
|
|||||||
const keyerLive = wkActiveRef.current;
|
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();
|
||||||
@@ -3839,24 +3851,6 @@ export default function App() {
|
|||||||
<Zap className="size-4" />
|
<Zap className="size-4" />
|
||||||
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
{wkStatus.busy && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-warning animate-pulse" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleCwDecoder}
|
|
||||||
title={
|
|
||||||
cwEnabled
|
|
||||||
? (mode === 'CW' ? 'CW decoder — on (decoding) · click to disable' : 'CW decoder — on, idle until CW mode · click to disable')
|
|
||||||
: 'CW decoder · click to enable (decodes RX audio in CW mode)'
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
|
||||||
cwOn && cwStatus.active ? 'border-success bg-success-muted text-success-muted-foreground'
|
|
||||||
: cwEnabled ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
|
||||||
: 'border-border text-muted-foreground hover:bg-muted',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Ear className="size-4" />
|
|
||||||
{cwOn && cwStatus.active && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success animate-pulse" />}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
onClick={() => { const v = !showRotor; setShowRotor(v); writeUiPref('opslog.showRotor', v ? '1' : '0'); }}
|
||||||
@@ -4146,6 +4140,43 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* What's new — shown once on the first launch after an update. */}
|
||||||
|
{whatsNew && (
|
||||||
|
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4" onClick={() => setWhatsNew(null)}>
|
||||||
|
<div className="w-full max-w-lg max-h-[85vh] flex flex-col rounded-xl border border-border bg-card shadow-2xl animate-in fade-in zoom-in-95" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2 px-5 pt-5 pb-3 border-b border-border shrink-0">
|
||||||
|
<Zap className="size-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-bold tracking-tight">{t('whatsnew.title')}</h2>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<button onClick={() => setWhatsNew(null)} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto px-5 py-4 space-y-5">
|
||||||
|
{whatsNew.map((e) => (
|
||||||
|
<div key={e.version}>
|
||||||
|
<div className="flex items-baseline gap-2 mb-1.5">
|
||||||
|
<span className="font-mono font-bold text-primary">v{e.version}</span>
|
||||||
|
{e.date && <span className="text-[11px] text-muted-foreground">{e.date}</span>}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1.5 text-sm">
|
||||||
|
{(lang === 'fr' ? e.fr : e.en).map((line, i) => (
|
||||||
|
<li key={i} className="flex gap-2">
|
||||||
|
<span className="text-primary mt-1.5 size-1.5 rounded-full bg-primary shrink-0" />
|
||||||
|
<span>{line}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-5 py-3 border-t border-border shrink-0 text-right">
|
||||||
|
<button onClick={() => setWhatsNew(null)} className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90">
|
||||||
|
{t('whatsnew.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
{/* Transient toasts (bottom-right). Errors stack on top of the green
|
||||||
success toast; both auto-dismiss. */}
|
success toast; both auto-dismiss. */}
|
||||||
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
{/* Unlock encrypted passwords (set via Settings → Security). Dismissable:
|
||||||
@@ -4322,31 +4353,27 @@ export default function App() {
|
|||||||
<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 flex flex-col gap-0.5">
|
||||||
{liveStations.filter((s) => s.online).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.filter((s) => s.online).map((s, i) => {
|
) : liveStations.filter((s) => s.online).map((s, i) => {
|
||||||
const mc = modeAccent(s.mode);
|
const mc = modeAccent(s.mode);
|
||||||
|
// Single compact row so 5+ stations fit without scrolling.
|
||||||
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-1.5 rounded-md px-1.5 py-1 border', s.online ? 'bg-muted/40 border-border' : 'border-transparent opacity-60')}>
|
||||||
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
|
<span className={cn('size-2 rounded-full shrink-0', s.online ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')}
|
||||||
title={s.online ? t('live.onAir') : t('live.offline')} />
|
title={s.online ? t('live.onAir') : t('live.offline')} />
|
||||||
<div className="min-w-0 flex-1">
|
<span className="text-xs font-bold font-mono truncate shrink-0 max-w-[72px]" title={s.operator}>{s.operator}</span>
|
||||||
<div className="flex items-baseline gap-1.5 min-w-0">
|
<span className="font-mono text-[11px] font-semibold tabular-nums shrink-0" style={{ color: mc }}>
|
||||||
<span className="text-xs font-bold font-mono truncate">{s.operator}</span>
|
|
||||||
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums ml-auto">v{s.version}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 mt-0.5 min-w-0">
|
|
||||||
<span className="font-mono text-[11px] font-semibold tabular-nums" style={{ color: mc }}>
|
|
||||||
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
|
{s.freq_hz ? (s.freq_hz / 1e6).toFixed(3) : '—'}
|
||||||
</span>
|
</span>
|
||||||
{s.mode && (
|
{s.mode && (
|
||||||
<span className="text-[9px] font-bold uppercase px-1.5 rounded-full leading-[15px] shrink-0"
|
<span className="text-[9px] font-bold uppercase px-1 rounded-full leading-[15px] shrink-0"
|
||||||
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
|
style={{ background: `${mc}22`, color: mc }}>{s.mode}</span>
|
||||||
)}
|
)}
|
||||||
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
|
{s.band && <span className="text-[10px] text-muted-foreground shrink-0">{s.band}</span>}
|
||||||
</div>
|
<span className="flex-1" />
|
||||||
</div>
|
{s.version && <span className="text-[9px] text-muted-foreground shrink-0 tabular-nums">v{s.version}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -4406,8 +4433,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}
|
||||||
@@ -4424,11 +4451,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}
|
||||||
@@ -4467,53 +4498,6 @@ export default function App() {
|
|||||||
</div>{/* /entry + aside row */}
|
</div>{/* /entry + aside row */}
|
||||||
|
|
||||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||||
{cwOn && (
|
|
||||||
<div className="ml-2.5 mt-1.5 -mb-1 w-[45%] flex items-center gap-2 rounded-md border border-success-border/70 bg-success-muted/60 px-2 py-1.5 text-xs">
|
|
||||||
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-success' : 'text-muted-foreground')} />
|
|
||||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
|
||||||
audio device is wrong/silent rather than a decode problem. */}
|
|
||||||
<div className="shrink-0 w-12 h-1.5 rounded bg-muted overflow-hidden" title={`Audio level ${Math.round(cwStatus.level * 100)}%`}>
|
|
||||||
<div className="h-full bg-success transition-[width] duration-100" style={{ width: `${Math.min(100, Math.round(cwStatus.level * 100))}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 font-mono text-[10px] text-muted-foreground tabular-nums">
|
|
||||||
{cwStatus.wpm > 0 ? `${cwStatus.wpm} WPM` : '— WPM'} · {cwStatus.pitch > 0 ? `${cwStatus.pitch} Hz` : '— Hz'}
|
|
||||||
</span>
|
|
||||||
{/* Lock pitch: blank = Auto (follow the Flex CW pitch / search). */}
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={cwPitch}
|
|
||||||
onChange={(e) => setCwPitch(e.target.value)}
|
|
||||||
placeholder="auto"
|
|
||||||
title="Lock the decoder to this pitch (Hz). Blank = follow the radio's CW pitch / auto-search."
|
|
||||||
className="shrink-0 w-14 h-5 rounded border border-success-border/70 bg-card/60 px-1 text-[10px] font-mono text-center outline-none"
|
|
||||||
/>
|
|
||||||
{/* Left-aligned single line, no scrollbar; auto-scrolled to the newest
|
|
||||||
text (see cwScrollRef effect) so the latest stays in view. */}
|
|
||||||
<div ref={cwScrollRef} className="flex-1 min-w-0 overflow-hidden font-mono leading-none flex items-center">
|
|
||||||
{cwText.trim() === '' ? (
|
|
||||||
<span className="text-muted-foreground italic">listening…</span>
|
|
||||||
) : (
|
|
||||||
<div className="inline-flex items-center whitespace-nowrap">
|
|
||||||
{cwText.trim().split(/\s+/).map((tok, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
type="button"
|
|
||||||
className="mr-1 shrink-0 rounded px-1 leading-none hover:bg-success-muted/70"
|
|
||||||
title="Use as callsign"
|
|
||||||
onClick={() => onCallsignInput(tok, { force: true })}
|
|
||||||
>
|
|
||||||
{tok}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button type="button" className="shrink-0 text-muted-foreground hover:text-foreground" title="Clear" onClick={() => setCwText('')}>
|
|
||||||
<Eraser className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
{/* ===== LOWER: tabbed table / cluster / band map ===== */}
|
||||||
{compact ? null : <>
|
{compact ? null : <>
|
||||||
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
<div className={cn('grid gap-2.5 p-2.5 flex-1 min-h-0 grid-rows-[minmax(0,1fr)]',
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetPGXLSettings, SavePGXLSettings,
|
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
@@ -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,50 @@ 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);
|
||||||
|
|
||||||
|
// Live SPE Expert amplifier status + OPERATE/STANDBY toggle. Module-scoped (not a
|
||||||
|
// nested component) so it isn't remounted on every parent render. Polls once a
|
||||||
|
// second while shown.
|
||||||
|
function SPEStatusCard() {
|
||||||
|
const [st, setSt] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetSPEStatus().then((s) => alive && setSt(s || {})).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const operate = !!st.operate;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
||||||
|
<span className="font-semibold">{st.connected ? `SPE ${st.model || 'Expert'}` : 'SPE — not connected'}</span>
|
||||||
|
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
||||||
|
onClick={() => SPESetOperate(!operate).catch(() => {})}
|
||||||
|
title="Toggle OPERATE / STANDBY">
|
||||||
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{st.connected && (
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
|
<div>{st.tx ? 'TX' : 'RX'}</div>
|
||||||
|
<div>Band {st.band}</div>
|
||||||
|
<div>Pwr {st.power_level}</div>
|
||||||
|
<div>{st.output_w} W</div>
|
||||||
|
<div>SWR {Number(st.swr_ant ?? 0).toFixed(2)}</div>
|
||||||
|
<div>{st.temp_c}°C</div>
|
||||||
|
<div>{st.volt_pa} V</div>
|
||||||
|
<div>{st.curr_pa} A</div>
|
||||||
|
{(st.warnings || st.alarms) && <div className="col-span-4 text-warning">⚠ {st.warnings} {st.alarms}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
function RelayAutoPanel() {
|
function RelayAutoPanel() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
@@ -698,7 +741,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 +1056,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 +2689,90 @@ 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 && pgxl.enabled && <SPEStatusCard />}
|
||||||
|
{!isPGXL && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2807,6 +2905,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 +2936,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 +4097,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 +4186,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">
|
||||||
|
|||||||
@@ -23,14 +23,16 @@ import { cn } from '@/lib/utils';
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Bucket = { key: string; count: number };
|
type Bucket = { key: string; count: number };
|
||||||
|
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
|
||||||
type Gap = { start: string; end: string; minutes: number };
|
type Gap = { start: string; end: string; minutes: number };
|
||||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
type Stats = {
|
type Stats = {
|
||||||
total: number; unique_calls: number; entities: number; continents: number;
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
first_qso: string; last_qso: string;
|
first_qso: string; last_qso: string;
|
||||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
|
by_hour: Bucket[]; by_day7: Bucket[]; by_day30: Bucket[]; by_month12: Bucket[];
|
||||||
// Period / contest metrics.
|
// Period / contest metrics.
|
||||||
window_start: string; window_end: string; window_hours: number;
|
window_start: string; window_end: string; window_hours: number;
|
||||||
avg_per_hour: number; avg_per_active: number;
|
avg_per_hour: number; avg_per_active: number;
|
||||||
@@ -157,6 +159,47 @@ function VBars({ data, empty, height = 150, showValues, colorful }: { data: Buck
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||||
|
// categories are the subject → categorical colour in a fixed order, matching the
|
||||||
|
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||||
|
const BAND_SPLIT = [
|
||||||
|
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||||
|
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||||
|
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||||
|
] as const;
|
||||||
|
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||||
|
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||||
|
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||||
|
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||||
|
{BAND_SPLIT.map((s) => {
|
||||||
|
const v = d[s.key];
|
||||||
|
if (!v) return null;
|
||||||
|
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
{BAND_SPLIT.map((s) => (
|
||||||
|
<span key={s.key} className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||||
// The categories (the operators) ARE the subject, so this is categorical colour,
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
// in the FIXED order the backend sends (busiest first). An operator therefore
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
@@ -462,6 +505,38 @@ function periodRange(p: Period, from: string, to: string): [string, string] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActivityCard — the "activity over time" chart with a granularity selector. Its
|
||||||
|
// own state, module-scoped so a parent re-render doesn't reset the choice. The
|
||||||
|
// timeline is the chronological month series; day/week/month/year are the cyclical
|
||||||
|
// distributions (hour-of-day, day-of-week, day-of-month, month-of-year).
|
||||||
|
const ACT_GRAN = [
|
||||||
|
{ key: 'timeline', tkey: 'stats.granTimeline' },
|
||||||
|
{ key: 'day', tkey: 'stats.granDay' },
|
||||||
|
{ key: 'week', tkey: 'stats.granWeek' },
|
||||||
|
{ key: 'month', tkey: 'stats.granMonth' },
|
||||||
|
{ key: 'year', tkey: 'stats.granYear' },
|
||||||
|
] as const;
|
||||||
|
function ActivityCard({ stats, t, empty }: { stats: Stats; t: (k: string, v?: any) => string; empty: string }) {
|
||||||
|
const [g, setG] = useState<string>('timeline');
|
||||||
|
const series = g === 'day' ? stats.by_hour : g === 'week' ? stats.by_day7 : g === 'month' ? stats.by_day30 : g === 'year' ? stats.by_month12 : [];
|
||||||
|
return (
|
||||||
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1">
|
||||||
|
{ACT_GRAN.map((o) => (
|
||||||
|
<button key={o.key} type="button" onClick={() => setG(o.key)}
|
||||||
|
className={cn('px-2 py-0.5 rounded-md text-[11px] border transition-colors',
|
||||||
|
g === o.key ? 'bg-primary text-primary-foreground border-primary' : 'border-border text-muted-foreground hover:bg-muted')}>
|
||||||
|
{t(o.tkey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{g === 'timeline'
|
||||||
|
? <AreaTrend data={stats.by_month} empty={empty} />
|
||||||
|
: <VBars data={series} empty={empty} showValues height={160} />}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
// ── Table view (the WCAG-clean twin) ─────────────────────────────────────────
|
||||||
|
|
||||||
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
function BucketTable({ title, data }: { title: string; data: Bucket[] }) {
|
||||||
@@ -524,9 +599,10 @@ export function StatsPanel() {
|
|||||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
setStats({
|
setStats({
|
||||||
...raw,
|
...raw,
|
||||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
|
by_hour: arr(raw.by_hour), by_day7: arr(raw.by_day7), by_day30: arr(raw.by_day30), by_month12: arr(raw.by_month12),
|
||||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
rate_ops: arr(raw.rate_ops), rate_by_op: arr(raw.rate_by_op),
|
||||||
} as Stats);
|
} as Stats);
|
||||||
@@ -740,11 +816,12 @@ export function StatsPanel() {
|
|||||||
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||||
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} className="lg:col-span-2" accent="var(--chart-3)">
|
||||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||||
<AreaTrend data={stats.by_month} empty={empty} />
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ActivityCard stats={stats} t={t} empty={empty} />
|
||||||
|
|
||||||
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
{/* EVERY operator, never a top-N: on a multi-op contest the point is who
|
||||||
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
worked what, and a cap would quietly delete the 9th operator. Scrolls
|
||||||
instead of truncating. */}
|
instead of truncating. */}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
+22
-14
@@ -54,18 +54,18 @@ const en: Dict = {
|
|||||||
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
|
'btn.logQso': 'Log QSO', 'btn.clear': 'Clear', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||||
// Language chooser
|
// Language chooser
|
||||||
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
'lang.choose': 'Choose your language', 'lang.chooseHint': 'You can change this later in Settings → General.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': "What's new", 'whatsnew.close': 'Got it', 'whatsnew.none': 'No changelog available for this version yet.',
|
||||||
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
'settings.language': 'Language', 'settings.languageHint': 'Interface language.',
|
||||||
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
'stats.tab': 'Statistics', 'stats.title': 'Logbook statistics', 'stats.loading': 'Crunching the log…',
|
||||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||||
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
||||||
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
|
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size', 'stats.bandSplit': 'Mode split by band', 'stats.bandSplitSub': 'CW / phone / data per band',
|
||||||
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
||||||
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
||||||
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
||||||
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
'stats.topEntities': 'Top DXCC entities', 'stats.byYear': 'By year',
|
||||||
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'QSOs per month',
|
'stats.overTime': 'Activity over time', 'stats.overTimeSub': 'Timeline, or the last day / 7 days / 30 days / 12 months', 'stats.granTimeline': 'Timeline', 'stats.granDay': 'Day', 'stats.granWeek': 'Week', 'stats.granMonth': 'Month', 'stats.granYear': 'Year',
|
||||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'Paper QSL',
|
||||||
|
|
||||||
'stats.byContinentSub': 'Share of the log',
|
'stats.byContinentSub': 'Share of the log',
|
||||||
@@ -131,9 +131,9 @@ const en: Dict = {
|
|||||||
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
|
'uscty.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 rig’s 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 rig’s 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.',
|
||||||
@@ -364,18 +368,18 @@ const fr: Dict = {
|
|||||||
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
|
'field.startUtc': 'Début UTC', 'field.endUtc': 'Fin UTC', 'field.snt': 'Env', 'field.rcv': 'Reç',
|
||||||
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
|
'btn.logQso': 'Enregistrer', 'btn.clear': 'Effacer', 'btn.spot': 'Spot', 'btn.saving': '…',
|
||||||
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
'lang.choose': 'Choisissez votre langue', 'lang.chooseHint': 'Modifiable plus tard dans Réglages → Général.',
|
||||||
'lang.english': 'English', 'lang.french': 'Français',
|
'lang.english': 'English', 'lang.french': 'Français', 'whatsnew.title': 'Nouveautés', 'whatsnew.close': 'Compris', 'whatsnew.none': 'Aucune nouveauté pour cette version pour le moment.',
|
||||||
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
'settings.language': 'Langue', 'settings.languageHint': "Langue de l'interface.",
|
||||||
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
'stats.tab': 'Statistiques', 'stats.title': 'Statistiques du journal', 'stats.loading': 'Analyse du journal…',
|
||||||
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||||
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
||||||
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
|
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille", 'stats.bandSplit': 'Répartition des modes par bande', 'stats.bandSplitSub': 'CW / phone / data par bande',
|
||||||
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
||||||
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
||||||
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
||||||
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
'stats.topEntities': 'Top entités DXCC', 'stats.byYear': 'Par année',
|
||||||
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'QSO par mois',
|
'stats.overTime': 'Activité dans le temps', 'stats.overTimeSub': 'Chronologie, ou les derniers jour / 7 jours / 30 jours / 12 mois', 'stats.granTimeline': 'Chronologie', 'stats.granDay': 'Jour', 'stats.granWeek': 'Semaine', 'stats.granMonth': 'Mois', 'stats.granYear': 'Année',
|
||||||
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
'stats.confirmations': 'Confirmations', 'stats.paperQSL': 'QSL papier',
|
||||||
|
|
||||||
'stats.byContinentSub': 'Part du journal',
|
'stats.byContinentSub': 'Part du journal',
|
||||||
@@ -440,9 +444,9 @@ const fr: Dict = {
|
|||||||
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
|
'uscty.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). L’ancien 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 l’Icom (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 l’Icom (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 l’utilisateur — alors que tu l’as livré.',
|
'awed.builtinTip': 'À cocher avant de livrer ce diplôme dans le catalogue. Sans ça, un « Réinitialiser par défaut » le SUPPRIME chez l’utilisateur — alors que tu l’as 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.',
|
||||||
|
|||||||
@@ -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.4';
|
export const APP_VERSION = '0.20.5';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+23
-10
@@ -11,6 +11,7 @@ import {awardref} from '../models';
|
|||||||
import {cluster} from '../models';
|
import {cluster} from '../models';
|
||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
|
import {spe} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
@@ -72,8 +73,6 @@ export function BulkUpdateField(arg1:Array<number>,arg2:string,arg3:string):Prom
|
|||||||
|
|
||||||
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
|
||||||
|
|
||||||
export function CWDecoderRunning():Promise<boolean>;
|
|
||||||
|
|
||||||
export function ChatAvailable():Promise<boolean>;
|
export function ChatAvailable():Promise<boolean>;
|
||||||
|
|
||||||
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
export function CheckForUpdate():Promise<main.UpdateInfo>;
|
||||||
@@ -168,6 +167,8 @@ export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):
|
|||||||
|
|
||||||
export function ExportAward(arg1:string):Promise<string>;
|
export function 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 +191,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 +227,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 +285,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>;
|
||||||
@@ -314,10 +323,10 @@ export function GetCATSettings():Promise<main.CATSettings>;
|
|||||||
|
|
||||||
export function GetCATState():Promise<cat.RigState>;
|
export function GetCATState():Promise<cat.RigState>;
|
||||||
|
|
||||||
export function GetCWDecoderPitch():Promise<number>;
|
|
||||||
|
|
||||||
export function GetCatalogCodes():Promise<Array<string>>;
|
export function GetCatalogCodes():Promise<Array<string>>;
|
||||||
|
|
||||||
|
export function GetChangelog():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
|
||||||
|
|
||||||
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
|
||||||
@@ -396,6 +405,8 @@ export function GetRotatorHeading():Promise<main.RotatorHeading>;
|
|||||||
|
|
||||||
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
export function GetRotatorSettings():Promise<main.RotatorSettings>;
|
||||||
|
|
||||||
|
export function GetSPEStatus():Promise<spe.Status>;
|
||||||
|
|
||||||
export function GetSecretStatus():Promise<main.SecretStatus>;
|
export function GetSecretStatus():Promise<main.SecretStatus>;
|
||||||
|
|
||||||
export function GetSlotStats():Promise<qso.SlotStats>;
|
export function GetSlotStats():Promise<qso.SlotStats>;
|
||||||
@@ -418,6 +429,8 @@ export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
|||||||
|
|
||||||
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
export function GetUltrabeamStatus():Promise<main.UltrabeamStatusInfo>;
|
||||||
|
|
||||||
|
export function GetWhatsNew():Promise<Array<main.ChangelogEntry>>;
|
||||||
|
|
||||||
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
|
||||||
|
|
||||||
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||||
@@ -536,6 +549,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
|||||||
|
|
||||||
export function ListCountries():Promise<Array<string>>;
|
export function 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>>;
|
||||||
@@ -676,6 +691,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>;
|
||||||
@@ -702,6 +719,8 @@ export function RotatorStop():Promise<void>;
|
|||||||
|
|
||||||
export function RunBackupNow():Promise<string>;
|
export function RunBackupNow():Promise<string>;
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SaveADIFFile():Promise<string>;
|
export function SaveADIFFile():Promise<string>;
|
||||||
|
|
||||||
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
export function SaveADIFMonitor(arg1:main.ADIFMonitorConfig):Promise<void>;
|
||||||
@@ -782,8 +801,6 @@ export function SetCATFrequency(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function SetCATMode(arg1:string):Promise<void>;
|
export function SetCATMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1:number):Promise<void>;
|
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
|
||||||
@@ -802,12 +819,8 @@ export function SetUIPref(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
|
||||||
|
|
||||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
export function SyncPOTAHunterLog(arg1:boolean,arg2:boolean):Promise<main.POTASyncResult>;
|
||||||
|
|||||||
@@ -102,10 +102,6 @@ export function BulkUpdateQSL(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CWDecoderRunning() {
|
|
||||||
return window['go']['main']['App']['CWDecoderRunning']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatAvailable() {
|
export function ChatAvailable() {
|
||||||
return window['go']['main']['App']['ChatAvailable']();
|
return window['go']['main']['App']['ChatAvailable']();
|
||||||
}
|
}
|
||||||
@@ -294,6 +290,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 +338,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 +410,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 +526,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);
|
||||||
}
|
}
|
||||||
@@ -586,14 +602,14 @@ export function GetCATState() {
|
|||||||
return window['go']['main']['App']['GetCATState']();
|
return window['go']['main']['App']['GetCATState']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetCWDecoderPitch() {
|
|
||||||
return window['go']['main']['App']['GetCWDecoderPitch']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetCatalogCodes() {
|
export function GetCatalogCodes() {
|
||||||
return window['go']['main']['App']['GetCatalogCodes']();
|
return window['go']['main']['App']['GetCatalogCodes']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetChangelog() {
|
||||||
|
return window['go']['main']['App']['GetChangelog']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetChatHistory(arg1) {
|
export function GetChatHistory(arg1) {
|
||||||
return window['go']['main']['App']['GetChatHistory'](arg1);
|
return window['go']['main']['App']['GetChatHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -750,6 +766,10 @@ export function GetRotatorSettings() {
|
|||||||
return window['go']['main']['App']['GetRotatorSettings']();
|
return window['go']['main']['App']['GetRotatorSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSPEStatus() {
|
||||||
|
return window['go']['main']['App']['GetSPEStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSecretStatus() {
|
export function GetSecretStatus() {
|
||||||
return window['go']['main']['App']['GetSecretStatus']();
|
return window['go']['main']['App']['GetSecretStatus']();
|
||||||
}
|
}
|
||||||
@@ -794,6 +814,10 @@ export function GetUltrabeamStatus() {
|
|||||||
return window['go']['main']['App']['GetUltrabeamStatus']();
|
return window['go']['main']['App']['GetUltrabeamStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetWhatsNew() {
|
||||||
|
return window['go']['main']['App']['GetWhatsNew']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetWinkeyerSettings() {
|
export function GetWinkeyerSettings() {
|
||||||
return window['go']['main']['App']['GetWinkeyerSettings']();
|
return window['go']['main']['App']['GetWinkeyerSettings']();
|
||||||
}
|
}
|
||||||
@@ -1030,6 +1054,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']();
|
||||||
}
|
}
|
||||||
@@ -1310,6 +1338,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);
|
||||||
}
|
}
|
||||||
@@ -1362,6 +1394,10 @@ export function RunBackupNow() {
|
|||||||
return window['go']['main']['App']['RunBackupNow']();
|
return window['go']['main']['App']['RunBackupNow']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SPESetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['SPESetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveADIFFile() {
|
export function SaveADIFFile() {
|
||||||
return window['go']['main']['App']['SaveADIFFile']();
|
return window['go']['main']['App']['SaveADIFFile']();
|
||||||
}
|
}
|
||||||
@@ -1522,10 +1558,6 @@ export function SetCATMode(arg1) {
|
|||||||
return window['go']['main']['App']['SetCATMode'](arg1);
|
return window['go']['main']['App']['SetCATMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetCWDecoderPitch(arg1) {
|
|
||||||
return window['go']['main']['App']['SetCWDecoderPitch'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SetClublogCtyEnabled(arg1) {
|
export function SetClublogCtyEnabled(arg1) {
|
||||||
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1562,18 +1594,10 @@ export function SetUltrabeamDirection(arg1) {
|
|||||||
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
return window['go']['main']['App']['SetUltrabeamDirection'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StartCWDecoder() {
|
|
||||||
return window['go']['main']['App']['StartCWDecoder']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StationSetRelay(arg1, arg2, arg3) {
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StopCWDecoder() {
|
|
||||||
return window['go']['main']['App']['StopCWDecoder']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SwitchCATRig(arg1) {
|
export function SwitchCATRig(arg1) {
|
||||||
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
return window['go']['main']['App']['SwitchCATRig'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1728,6 +1728,24 @@ export namespace main {
|
|||||||
this.path = source["path"];
|
this.path = source["path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class ChangelogEntry {
|
||||||
|
version: string;
|
||||||
|
date: string;
|
||||||
|
en: string[];
|
||||||
|
fr: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new ChangelogEntry(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.version = source["version"];
|
||||||
|
this.date = source["date"];
|
||||||
|
this.en = source["en"];
|
||||||
|
this.fr = source["fr"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ChatMessage {
|
export class ChatMessage {
|
||||||
id: number;
|
id: number;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -2161,8 +2179,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 +2193,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 {
|
||||||
@@ -2600,6 +2626,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 = {}) {
|
||||||
@@ -2614,6 +2641,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"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3401,6 +3429,26 @@ export namespace qslcard {
|
|||||||
|
|
||||||
export namespace qso {
|
export namespace qso {
|
||||||
|
|
||||||
|
export class BandCategory {
|
||||||
|
band: string;
|
||||||
|
cw: number;
|
||||||
|
phone: number;
|
||||||
|
data: number;
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BandCategory(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.band = source["band"];
|
||||||
|
this.cw = source["cw"];
|
||||||
|
this.phone = source["phone"];
|
||||||
|
this.data = source["data"];
|
||||||
|
this.total = source["total"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BandMode {
|
export class BandMode {
|
||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
@@ -3890,12 +3938,17 @@ export namespace qso {
|
|||||||
confirmed_any: number;
|
confirmed_any: number;
|
||||||
by_mode: Bucket[];
|
by_mode: Bucket[];
|
||||||
by_band: Bucket[];
|
by_band: Bucket[];
|
||||||
|
by_band_category: BandCategory[];
|
||||||
by_operator: Bucket[];
|
by_operator: Bucket[];
|
||||||
by_station: Bucket[];
|
by_station: Bucket[];
|
||||||
by_continent: Bucket[];
|
by_continent: Bucket[];
|
||||||
top_entities: Bucket[];
|
top_entities: Bucket[];
|
||||||
by_year: Bucket[];
|
by_year: Bucket[];
|
||||||
by_month: Bucket[];
|
by_month: Bucket[];
|
||||||
|
by_hour: Bucket[];
|
||||||
|
by_day7: Bucket[];
|
||||||
|
by_day30: Bucket[];
|
||||||
|
by_month12: Bucket[];
|
||||||
window_start: string;
|
window_start: string;
|
||||||
window_end: string;
|
window_end: string;
|
||||||
window_hours: number;
|
window_hours: number;
|
||||||
@@ -3929,12 +3982,17 @@ export namespace qso {
|
|||||||
this.confirmed_any = source["confirmed_any"];
|
this.confirmed_any = source["confirmed_any"];
|
||||||
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
||||||
this.by_band = this.convertValues(source["by_band"], Bucket);
|
this.by_band = this.convertValues(source["by_band"], Bucket);
|
||||||
|
this.by_band_category = this.convertValues(source["by_band_category"], BandCategory);
|
||||||
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
||||||
this.by_station = this.convertValues(source["by_station"], Bucket);
|
this.by_station = this.convertValues(source["by_station"], Bucket);
|
||||||
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
||||||
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
this.top_entities = this.convertValues(source["top_entities"], Bucket);
|
||||||
this.by_year = this.convertValues(source["by_year"], Bucket);
|
this.by_year = this.convertValues(source["by_year"], Bucket);
|
||||||
this.by_month = this.convertValues(source["by_month"], Bucket);
|
this.by_month = this.convertValues(source["by_month"], Bucket);
|
||||||
|
this.by_hour = this.convertValues(source["by_hour"], Bucket);
|
||||||
|
this.by_day7 = this.convertValues(source["by_day7"], Bucket);
|
||||||
|
this.by_day30 = this.convertValues(source["by_day30"], Bucket);
|
||||||
|
this.by_month12 = this.convertValues(source["by_month12"], Bucket);
|
||||||
this.window_start = source["window_start"];
|
this.window_start = source["window_start"];
|
||||||
this.window_end = source["window_end"];
|
this.window_end = source["window_end"];
|
||||||
this.window_hours = source["window_hours"];
|
this.window_hours = source["window_hours"];
|
||||||
@@ -4098,6 +4156,53 @@ export namespace solar {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace spe {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
last_error?: string;
|
||||||
|
model?: string;
|
||||||
|
operate: boolean;
|
||||||
|
tx: boolean;
|
||||||
|
input?: string;
|
||||||
|
band?: string;
|
||||||
|
power_level?: string;
|
||||||
|
output_w: number;
|
||||||
|
swr_atu: number;
|
||||||
|
swr_ant: number;
|
||||||
|
volt_pa: number;
|
||||||
|
curr_pa: number;
|
||||||
|
temp_c: number;
|
||||||
|
warnings?: string;
|
||||||
|
alarms?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.operate = source["operate"];
|
||||||
|
this.tx = source["tx"];
|
||||||
|
this.input = source["input"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.power_level = source["power_level"];
|
||||||
|
this.output_w = source["output_w"];
|
||||||
|
this.swr_atu = source["swr_atu"];
|
||||||
|
this.swr_ant = source["swr_ant"];
|
||||||
|
this.volt_pa = source["volt_pa"];
|
||||||
|
this.curr_pa = source["curr_pa"];
|
||||||
|
this.temp_c = source["temp_c"];
|
||||||
|
this.warnings = source["warnings"];
|
||||||
|
this.alarms = source["alarms"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace udp {
|
export namespace udp {
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,389 +0,0 @@
|
|||||||
// Package cwdecode is a real-time CW (Morse) decoder: it turns a stream of
|
|
||||||
// mono PCM samples into decoded text. The pipeline is the classic one — a bank
|
|
||||||
// of Goertzel tone detectors, a pitch LOCK that follows a single tone (so QRM
|
|
||||||
// at other pitches is ignored), an adaptive envelope/threshold on the LOCKED
|
|
||||||
// tone (level-independent, so weak or strong signals both key cleanly), an
|
|
||||||
// adaptive dot-length (WPM) estimate, and a timing state machine that maps
|
|
||||||
// marks/spaces to Morse and then to characters.
|
|
||||||
//
|
|
||||||
// It is deliberately self-contained and dependency-free so it can be unit
|
|
||||||
// tested with synthetic signals. As with every audio CW decoder, weak signals
|
|
||||||
// and very heavy QRM still degrade it; the pitch lock keeps QRM on other tones
|
|
||||||
// out of the decode.
|
|
||||||
package cwdecode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Status is a periodic snapshot for the UI (pitch lock, speed, signal).
|
|
||||||
type Status struct {
|
|
||||||
WPM int `json:"wpm"`
|
|
||||||
Pitch int `json:"pitch"` // Hz of the locked tone (0 = not locked)
|
|
||||||
Level float64 `json:"level"` // 0..1 input audio level (RMS) for the meter
|
|
||||||
Active bool `json:"active"` // a tone is currently keyed down
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decoder consumes PCM and emits decoded characters via onChar (one or more
|
|
||||||
// characters at a time, including " " for word gaps) and periodic onStatus.
|
|
||||||
type Decoder struct {
|
|
||||||
fs int
|
|
||||||
hop int // samples between updates
|
|
||||||
win int // Goertzel window length
|
|
||||||
freqs []float64
|
|
||||||
coeffs []float64 // precomputed 2*cos(w) per freq
|
|
||||||
|
|
||||||
ring []float64 // last win samples
|
|
||||||
acc int // samples since last hop
|
|
||||||
mags []float64 // per-bin magnitude this hop
|
|
||||||
nbuf []float64 // scratch for the noise percentile
|
|
||||||
|
|
||||||
// Fixed-pitch target (Hz). 0 = auto-search; >0 = lock to the nearest bin and
|
|
||||||
// ignore everything else (e.g. follow the radio's CW pitch). Set live from
|
|
||||||
// another goroutine, so it's atomic.
|
|
||||||
targetHz atomic.Int32
|
|
||||||
|
|
||||||
// Pitch lock.
|
|
||||||
lockIdx int // index of the locked tone bin, -1 = unlocked
|
|
||||||
candIdx int // current argmax candidate while unlocked
|
|
||||||
candHops int // consecutive hops the candidate has been dominant
|
|
||||||
quietHops int // consecutive key-up hops while locked
|
|
||||||
noise float64 // broadband noise estimate (percentile of bins)
|
|
||||||
relockHops int // quiet hops before the lock is released
|
|
||||||
acqSNR float64 // tone/noise ratio to acquire after a few stable hops
|
|
||||||
strongSNR float64 // tone/noise ratio to lock immediately (1 hop)
|
|
||||||
|
|
||||||
// Adaptive keying envelope, on the LOCKED bin's magnitude.
|
|
||||||
peak, floor float64
|
|
||||||
state bool // true = mark (key down)
|
|
||||||
stateHops int
|
|
||||||
dotHops float64 // adaptive dot length, in hops
|
|
||||||
markCount int // marks seen since lock (fast WPM adaptation while small)
|
|
||||||
elem []byte // current "." / "-" run for the in-progress character
|
|
||||||
charEmitted bool
|
|
||||||
wordEmitted bool
|
|
||||||
|
|
||||||
lastPitch float64
|
|
||||||
lastRMS float64
|
|
||||||
|
|
||||||
statusEvery int
|
|
||||||
sinceStatus int
|
|
||||||
|
|
||||||
onChar func(string)
|
|
||||||
onStatus func(Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
var morse = map[string]byte{
|
|
||||||
".-": 'A', "-...": 'B', "-.-.": 'C', "-..": 'D', ".": 'E', "..-.": 'F',
|
|
||||||
"--.": 'G', "....": 'H', "..": 'I', ".---": 'J', "-.-": 'K', ".-..": 'L',
|
|
||||||
"--": 'M', "-.": 'N', "---": 'O', ".--.": 'P', "--.-": 'Q', ".-.": 'R',
|
|
||||||
"...": 'S', "-": 'T', "..-": 'U', "...-": 'V', ".--": 'W', "-..-": 'X',
|
|
||||||
"-.--": 'Y', "--..": 'Z',
|
|
||||||
"-----": '0', ".----": '1', "..---": '2', "...--": '3', "....-": '4',
|
|
||||||
".....": '5', "-....": '6', "--...": '7', "---..": '8', "----.": '9',
|
|
||||||
".-.-.-": '.', "--..--": ',', "..--..": '?', "-..-.": '/', "-...-": '=',
|
|
||||||
".-.-.": '+', "-.-.--": '!', "---...": ':', "-....-": '-', ".--.-.": '@',
|
|
||||||
}
|
|
||||||
|
|
||||||
// New builds a decoder for the given sample rate. onChar receives decoded text
|
|
||||||
// incrementally; onStatus receives ~10 snapshots/second. Either may be nil.
|
|
||||||
func New(sampleRate int, onChar func(string), onStatus func(Status)) *Decoder {
|
|
||||||
if sampleRate <= 0 {
|
|
||||||
sampleRate = 16000
|
|
||||||
}
|
|
||||||
d := &Decoder{
|
|
||||||
fs: sampleRate,
|
|
||||||
hop: sampleRate / 250, // ~4 ms resolution
|
|
||||||
win: sampleRate / 72, // ~14 ms Goertzel window (selective, fairly snappy)
|
|
||||||
dotHops: 15, // ~20 WPM seed
|
|
||||||
acqSNR: 1.9, // ignore noise spikes (looser locked onto noise = garbage)
|
|
||||||
strongSNR: 3.2, // only a genuinely strong tone locks in 1 hop
|
|
||||||
lockIdx: -1,
|
|
||||||
candIdx: -1,
|
|
||||||
statusEvery: 25, // ~10 Hz
|
|
||||||
onChar: onChar,
|
|
||||||
onStatus: onStatus,
|
|
||||||
}
|
|
||||||
if d.hop < 1 {
|
|
||||||
d.hop = 1
|
|
||||||
}
|
|
||||||
d.relockHops = int(0.8 * float64(d.fs) / float64(d.hop)) // release lock after ~0.8 s quiet
|
|
||||||
// Candidate CW tones: 400–1000 Hz every 25 Hz. Deliberately NOT lower: strong
|
|
||||||
// low-frequency noise/hum (pink/red noise rises toward DC) would otherwise win
|
|
||||||
// the argmax and lock the decoder onto ~250 Hz junk instead of the signal.
|
|
||||||
for f := 400.0; f <= 1000.0; f += 25 {
|
|
||||||
d.freqs = append(d.freqs, f)
|
|
||||||
d.coeffs = append(d.coeffs, 2*math.Cos(2*math.Pi*f/float64(d.fs)))
|
|
||||||
}
|
|
||||||
d.mags = make([]float64, len(d.freqs))
|
|
||||||
d.nbuf = make([]float64, len(d.freqs))
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetTarget fixes the decode pitch to hz (lock to the nearest bin, ignore other
|
|
||||||
// tones), or returns to auto-search when hz <= 0. Safe to call concurrently.
|
|
||||||
func (d *Decoder) SetTarget(hz int) { d.targetHz.Store(int32(hz)) }
|
|
||||||
|
|
||||||
// nearestBin returns the bin index closest to hz.
|
|
||||||
func (d *Decoder) nearestBin(hz float64) int {
|
|
||||||
best, bestD := 0, math.Inf(1)
|
|
||||||
for i, f := range d.freqs {
|
|
||||||
if dd := math.Abs(f - hz); dd < bestD {
|
|
||||||
bestD, best = dd, i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset clears decode state (e.g. when the user re-arms the decoder).
|
|
||||||
func (d *Decoder) Reset() {
|
|
||||||
d.ring = d.ring[:0]
|
|
||||||
d.acc = 0
|
|
||||||
d.lockIdx, d.candIdx, d.candHops, d.quietHops = -1, -1, 0, 0
|
|
||||||
d.peak, d.floor = 0, 0
|
|
||||||
d.state = false
|
|
||||||
d.stateHops = 0
|
|
||||||
d.dotHops = 15
|
|
||||||
d.markCount = 0
|
|
||||||
d.elem = d.elem[:0]
|
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process feeds a block of mono samples through the decoder.
|
|
||||||
func (d *Decoder) Process(samples []int16) {
|
|
||||||
for _, s := range samples {
|
|
||||||
d.ring = append(d.ring, float64(s))
|
|
||||||
if len(d.ring) > d.win {
|
|
||||||
d.ring = d.ring[len(d.ring)-d.win:]
|
|
||||||
}
|
|
||||||
d.acc++
|
|
||||||
if d.acc >= d.hop && len(d.ring) >= d.win {
|
|
||||||
d.acc = 0
|
|
||||||
d.analyze()
|
|
||||||
d.step()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// analyze runs the Goertzel bank, estimates the noise floor, and maintains the
|
|
||||||
// pitch lock (which tone the envelope detector then follows).
|
|
||||||
func (d *Decoder) analyze() {
|
|
||||||
n := float64(len(d.ring))
|
|
||||||
var sumSq float64
|
|
||||||
maxIdx, maxMag := 0, -1.0
|
|
||||||
for i, coeff := range d.coeffs {
|
|
||||||
var s1, s2 float64
|
|
||||||
for _, x := range d.ring {
|
|
||||||
s0 := x + coeff*s1 - s2
|
|
||||||
s2 = s1
|
|
||||||
s1 = s0
|
|
||||||
}
|
|
||||||
m := math.Sqrt(math.Max(s1*s1+s2*s2-coeff*s1*s2, 0)) / n
|
|
||||||
d.mags[i] = m
|
|
||||||
if m > maxMag {
|
|
||||||
maxMag = m
|
|
||||||
maxIdx = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, x := range d.ring {
|
|
||||||
sumSq += x * x
|
|
||||||
}
|
|
||||||
d.lastRMS = math.Min(1, math.Sqrt(sumSq/n)/32768*4)
|
|
||||||
|
|
||||||
// Fixed-pitch mode: lock straight to the target bin, skip the auto search.
|
|
||||||
// A narrow filter at the known pitch is exactly how a skimmer avoids QRM.
|
|
||||||
if th := int(d.targetHz.Load()); th > 0 {
|
|
||||||
d.lockIdx = d.nearestBin(float64(th))
|
|
||||||
d.lastPitch = d.freqs[d.lockIdx]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Noise floor = 40th percentile of the bins (robust to a few strong tones).
|
|
||||||
copy(d.nbuf, d.mags)
|
|
||||||
sort.Float64s(d.nbuf)
|
|
||||||
d.noise = d.nbuf[int(0.4*float64(len(d.nbuf)-1)+0.5)]
|
|
||||||
|
|
||||||
if d.lockIdx < 0 {
|
|
||||||
if maxIdx == d.candIdx {
|
|
||||||
d.candHops++
|
|
||||||
} else {
|
|
||||||
d.candIdx, d.candHops = maxIdx, 1
|
|
||||||
}
|
|
||||||
snr := maxMag / (d.noise + 1e-9)
|
|
||||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
|
||||||
// don't eat the first element of a strong signal), a marginal/weak tone
|
|
||||||
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
|
||||||
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
|
|
||||||
d.lockIdx = maxIdx
|
|
||||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
|
||||||
d.quietHops = 0
|
|
||||||
d.markCount = 0 // relearn WPM fast for this new signal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if d.lockIdx >= 0 {
|
|
||||||
d.lastPitch = d.freqs[d.lockIdx]
|
|
||||||
} else {
|
|
||||||
d.lastPitch = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// step runs the adaptive envelope on the locked bin and the timing state
|
|
||||||
// machine, one hop. The envelope adapts to the signal level (not an absolute
|
|
||||||
// threshold), so weak and strong signals both key correctly.
|
|
||||||
func (d *Decoder) step() {
|
|
||||||
on := false
|
|
||||||
if d.lockIdx >= 0 {
|
|
||||||
m := d.mags[d.lockIdx]
|
|
||||||
// Peak: fast attack, slow release.
|
|
||||||
if m > d.peak {
|
|
||||||
d.peak += (m - d.peak) * 0.4
|
|
||||||
} else {
|
|
||||||
d.peak += (m - d.peak) * 0.02
|
|
||||||
}
|
|
||||||
// Floor: drops fast toward the signal, but only RISES between marks (when
|
|
||||||
// keyed up). Letting the floor rise during a long dash would shrink the
|
|
||||||
// span until the dash drops below the threshold and fragments into dots —
|
|
||||||
// the cause of the "all dots" garbage on a strong clean signal.
|
|
||||||
if m < d.floor {
|
|
||||||
d.floor += (m - d.floor) * 0.4
|
|
||||||
} else if !d.state {
|
|
||||||
d.floor += (m - d.floor) * 0.02
|
|
||||||
}
|
|
||||||
span := d.peak - d.floor
|
|
||||||
// The frozen floor already stops dashes fragmenting, so keep balanced
|
|
||||||
// thresholds: low enough that short inter-element GAPS are still seen
|
|
||||||
// (otherwise elements merge into >7-symbol runs that decode to nothing).
|
|
||||||
if span > d.floor*0.3+1e-9 {
|
|
||||||
onTh := d.floor + 0.55*span
|
|
||||||
offTh := d.floor + 0.35*span
|
|
||||||
if d.state {
|
|
||||||
on = m > offTh
|
|
||||||
} else {
|
|
||||||
on = m > onTh
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Release the lock after a long quiet so we can retune to a new signal.
|
|
||||||
if on {
|
|
||||||
d.quietHops = 0
|
|
||||||
} else {
|
|
||||||
d.quietHops++
|
|
||||||
if d.quietHops > d.relockHops {
|
|
||||||
// End of the over: flush any pending character and drop a word
|
|
||||||
// space so the next transmission starts a fresh word (the word-gap
|
|
||||||
// timer above can't fire once the lock is gone).
|
|
||||||
if len(d.elem) > 0 && !d.charEmitted {
|
|
||||||
d.flushChar()
|
|
||||||
d.charEmitted = true
|
|
||||||
}
|
|
||||||
if !d.wordEmitted && d.onChar != nil {
|
|
||||||
d.onChar(" ")
|
|
||||||
d.wordEmitted = true
|
|
||||||
}
|
|
||||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if on == d.state {
|
|
||||||
d.stateHops++
|
|
||||||
if !d.state {
|
|
||||||
d.spaceProgress()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if d.state {
|
|
||||||
d.endMark(d.stateHops)
|
|
||||||
}
|
|
||||||
d.state = on
|
|
||||||
d.stateHops = 1
|
|
||||||
if on {
|
|
||||||
d.charEmitted, d.wordEmitted = false, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d.emitStatus(on)
|
|
||||||
}
|
|
||||||
|
|
||||||
// endMark classifies a finished key-down run as a dot or dash and adapts the
|
|
||||||
// dot-length estimate. Runs shorter than a third of a dot are rejected as
|
|
||||||
// clicks/noise.
|
|
||||||
func (d *Decoder) endMark(hops int) {
|
|
||||||
h := float64(hops)
|
|
||||||
// Reject clicks/noise: shorter than a third of a dot AND an absolute floor
|
|
||||||
// of ~4 hops (~16 ms, i.e. faster than ~75 WPM) so noise can't drag the
|
|
||||||
// dot-length estimate down to the clamp (which produced 100 WPM garbage).
|
|
||||||
if h < d.dotHops*0.35 || h < 4 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if h > d.dotHops*2 {
|
|
||||||
d.elem = append(d.elem, '-')
|
|
||||||
d.adaptDot(h / 3)
|
|
||||||
} else {
|
|
||||||
d.elem = append(d.elem, '.')
|
|
||||||
d.adaptDot(h)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
|
||||||
// to ~5–60 WPM). The EMA is deliberately GENTLE (0.2) and NOT accelerated on the
|
|
||||||
// opening marks: a fast alpha let short noise blips (misclassified as dots) drag
|
|
||||||
// the dot-length down to the clamp within a few marks — the "60 WPM, all dits"
|
|
||||||
// garbage. The slow EMA is self-correcting because genuine marks pull it back up.
|
|
||||||
func (d *Decoder) adaptDot(obs float64) {
|
|
||||||
const alpha = 0.2
|
|
||||||
d.markCount++
|
|
||||||
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
|
|
||||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
|
||||||
d.dotHops = 5
|
|
||||||
}
|
|
||||||
if d.dotHops > 55 {
|
|
||||||
d.dotHops = 55
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// spaceProgress flushes the current character once the gap exceeds a character
|
|
||||||
// gap, and a word space once it exceeds a word gap.
|
|
||||||
func (d *Decoder) spaceProgress() {
|
|
||||||
g := float64(d.stateHops)
|
|
||||||
if !d.charEmitted && g > d.dotHops*2 {
|
|
||||||
d.flushChar()
|
|
||||||
d.charEmitted = true
|
|
||||||
}
|
|
||||||
if !d.wordEmitted && g > d.dotHops*5 {
|
|
||||||
if d.onChar != nil {
|
|
||||||
d.onChar(" ")
|
|
||||||
}
|
|
||||||
d.wordEmitted = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// flushChar looks up the accumulated element string and emits the character.
|
|
||||||
func (d *Decoder) flushChar() {
|
|
||||||
if len(d.elem) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c, ok := morse[string(d.elem)]; ok {
|
|
||||||
if d.onChar != nil {
|
|
||||||
d.onChar(string(c))
|
|
||||||
}
|
|
||||||
} else if d.onChar != nil && len(d.elem) <= 7 {
|
|
||||||
// Only flag a genuinely Morse-shaped but unknown char with "?". An
|
|
||||||
// over-long element run is noise — drop it silently rather than spam "?".
|
|
||||||
d.onChar("?")
|
|
||||||
}
|
|
||||||
d.elem = d.elem[:0]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Decoder) emitStatus(on bool) {
|
|
||||||
d.sinceStatus++
|
|
||||||
if d.sinceStatus < d.statusEvery || d.onStatus == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
d.sinceStatus = 0
|
|
||||||
hopMs := float64(d.hop) / float64(d.fs) * 1000
|
|
||||||
wpm := 0
|
|
||||||
if d.dotHops > 0 {
|
|
||||||
wpm = int(math.Round(1200 / (d.dotHops * hopMs)))
|
|
||||||
}
|
|
||||||
d.onStatus(Status{WPM: wpm, Pitch: int(math.Round(d.lastPitch)), Level: d.lastRMS, Active: on})
|
|
||||||
}
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
package cwdecode
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
// reverse Morse map for the synthesizer.
|
|
||||||
func charToMorse() map[byte]string {
|
|
||||||
m := map[byte]string{}
|
|
||||||
for code, ch := range morse {
|
|
||||||
m[ch] = code
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// keyMessage synthesizes a clean keyed tone for msg at the given WPM/pitch.
|
|
||||||
func keyMessage(msg string, fs, wpm int, pitch float64) []int16 {
|
|
||||||
return keyMessageAmp(msg, fs, wpm, pitch, 9000)
|
|
||||||
}
|
|
||||||
|
|
||||||
func keyMessageAmp(msg string, fs, wpm int, pitch, amp float64) []int16 {
|
|
||||||
dot := fs * 1200 / (wpm * 1000) // samples per dot
|
|
||||||
c2m := charToMorse()
|
|
||||||
var out []int16
|
|
||||||
phase := 0.0
|
|
||||||
dphi := 2 * math.Pi * pitch / float64(fs)
|
|
||||||
|
|
||||||
tone := func(n int) {
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out = append(out, int16(amp*math.Sin(phase)))
|
|
||||||
phase += dphi
|
|
||||||
}
|
|
||||||
}
|
|
||||||
silence := func(n int) {
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out = append(out, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
silence(fs / 4) // 250 ms lead-in for AGC warmup
|
|
||||||
for i := 0; i < len(msg); i++ {
|
|
||||||
ch := msg[i]
|
|
||||||
if ch == ' ' {
|
|
||||||
silence(7 * dot)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
code := c2m[ch]
|
|
||||||
for j := 0; j < len(code); j++ {
|
|
||||||
if code[j] == '.' {
|
|
||||||
tone(dot)
|
|
||||||
} else {
|
|
||||||
tone(3 * dot)
|
|
||||||
}
|
|
||||||
silence(dot) // inter-element gap
|
|
||||||
}
|
|
||||||
silence(3 * dot) // inter-character gap (on top of the trailing element gap)
|
|
||||||
}
|
|
||||||
silence(fs / 4)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeCleanSignal(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
|
|
||||||
// Repeat so AGC warm-up only costs the first word.
|
|
||||||
samples := keyMessage("PARIS PARIS PARIS", fs, 22, 700)
|
|
||||||
// Feed in small chunks like the live capture would.
|
|
||||||
for i := 0; i < len(samples); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "PARIS") {
|
|
||||||
t.Fatalf("decoded %q, want it to contain PARIS", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeWithQRM(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
// Target at 700 Hz; a strong interfering keyed signal at 950 Hz, slightly
|
|
||||||
// quieter, sending different text. The pitch lock should hold on the target.
|
|
||||||
target := keyMessageAmp("PARIS PARIS PARIS", fs, 20, 700, 9000)
|
|
||||||
qrm := keyMessageAmp("BK DE QRZ QRZ TEST", fs, 26, 950, 6500)
|
|
||||||
mix := make([]int16, len(target))
|
|
||||||
for i := range target {
|
|
||||||
v := int(target[i])
|
|
||||||
if i < len(qrm) {
|
|
||||||
v += int(qrm[i])
|
|
||||||
}
|
|
||||||
if v > 32767 {
|
|
||||||
v = 32767
|
|
||||||
} else if v < -32768 {
|
|
||||||
v = -32768
|
|
||||||
}
|
|
||||||
mix[i] = int16(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
for i := 0; i < len(mix); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(mix) {
|
|
||||||
end = len(mix)
|
|
||||||
}
|
|
||||||
d.Process(mix[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "PARIS") {
|
|
||||||
t.Fatalf("with QRM, decoded %q, want it to contain PARIS", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeFirstCharStrong(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
// Strong signal: the very first element (T = a dash) must not be eaten by
|
|
||||||
// lock acquisition. Output should begin with the first character.
|
|
||||||
samples := keyMessageAmp("TEST DE", fs, 20, 700, 16000)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(strings.TrimSpace(sb.String()))
|
|
||||||
if !strings.HasPrefix(got, "TEST") {
|
|
||||||
t.Fatalf("first chars lost on a strong signal: decoded %q, want it to start with TEST", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeWithAmplitudeRipple(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
// A real signal's tone amplitude wobbles within a mark; if the floor chases
|
|
||||||
// it, dashes fragment into dots ("all dots" garbage). Apply ±30% ripple.
|
|
||||||
samples := keyMessageAmp("CQ TEST DE OM", fs, 24, 800, 10000)
|
|
||||||
rp := 0.0
|
|
||||||
for i := range samples {
|
|
||||||
rp += 2 * math.Pi * 35 / float64(fs) // 35 Hz amplitude wobble
|
|
||||||
samples[i] = int16(float64(samples[i]) * (1 + 0.3*math.Sin(rp)))
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
for i := 0; i < len(samples); i += 256 {
|
|
||||||
end := i + 256
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "TEST DE OM") {
|
|
||||||
t.Fatalf("dashes fragmented under amplitude ripple: decoded %q", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeCQFixedPitch(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
d.SetTarget(700) // fixed pitch like the user's manual override
|
|
||||||
samples := keyMessageAmp("CQ CQ CQ DE OM", fs, 26, 700, 9000)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if n := strings.Count(got, "CQ"); n < 2 {
|
|
||||||
t.Fatalf("first element of CQ dropped: decoded %q (only %d CQ)", got, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDecodeNumbersAndProsign(t *testing.T) {
|
|
||||||
const fs = 16000
|
|
||||||
var sb strings.Builder
|
|
||||||
d := New(fs, func(s string) { sb.WriteString(s) }, nil)
|
|
||||||
samples := keyMessage("TEST 599 TEST", fs, 18, 650)
|
|
||||||
for i := 0; i < len(samples); i += 200 {
|
|
||||||
end := i + 200
|
|
||||||
if end > len(samples) {
|
|
||||||
end = len(samples)
|
|
||||||
}
|
|
||||||
d.Process(samples[i:end])
|
|
||||||
}
|
|
||||||
got := strings.ToUpper(sb.String())
|
|
||||||
if !strings.Contains(got, "599") {
|
|
||||||
t.Fatalf("decoded %q, want it to contain 599", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,6 +24,30 @@ type Bucket struct {
|
|||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BandCategory is one band's QSO count split by mode category (CW / phone /
|
||||||
|
// digital), for the per-band mode-split chart.
|
||||||
|
type BandCategory struct {
|
||||||
|
Band string `json:"band"`
|
||||||
|
CW int `json:"cw"`
|
||||||
|
Phone int `json:"phone"`
|
||||||
|
Data int `json:"data"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes
|
||||||
|
// (SSB and the digital-voice family) count as phone; CW is CW; everything else is
|
||||||
|
// data. Mirrors the frontend's mode colouring.
|
||||||
|
func modeCategory(mode string) string {
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
return "cw"
|
||||||
|
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION":
|
||||||
|
return "phone"
|
||||||
|
default:
|
||||||
|
return "data"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
||||||
// are the expensive minutes: they are where the score went.
|
// are the expensive minutes: they are where the score went.
|
||||||
type Gap struct {
|
type Gap struct {
|
||||||
@@ -131,6 +155,7 @@ type Stats struct {
|
|||||||
// Breakdowns, each sorted most → least (bands keep frequency order).
|
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||||
ByMode []Bucket `json:"by_mode"`
|
ByMode []Bucket `json:"by_mode"`
|
||||||
ByBand []Bucket `json:"by_band"`
|
ByBand []Bucket `json:"by_band"`
|
||||||
|
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
||||||
ByOperator []Bucket `json:"by_operator"`
|
ByOperator []Bucket `json:"by_operator"`
|
||||||
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||||
ByContinent []Bucket `json:"by_continent"`
|
ByContinent []Bucket `json:"by_continent"`
|
||||||
@@ -138,6 +163,14 @@ type Stats struct {
|
|||||||
ByYear []Bucket `json:"by_year"` // chronological
|
ByYear []Bucket `json:"by_year"` // chronological
|
||||||
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
ByMonth []Bucket `json:"by_month"` // "YYYY-MM", chronological
|
||||||
|
|
||||||
|
// Rolling chronological activity windows (UTC), anchored to the newest QSO, for
|
||||||
|
// the "activity over time" chart's day/week/month/year granularity selector.
|
||||||
|
// Real dates in order (empty buckets are real, just zero) — no cyclical wrap.
|
||||||
|
ByHour []Bucket `json:"by_hour"` // the newest QSO's day, hour by hour (00..23)
|
||||||
|
ByDay7 []Bucket `json:"by_day7"` // the last 7 days ending at the newest QSO
|
||||||
|
ByDay30 []Bucket `json:"by_day30"` // the last 30 days
|
||||||
|
ByMonth12 []Bucket `json:"by_month12"` // the last 12 months
|
||||||
|
|
||||||
// ── Period / contest metrics ──
|
// ── Period / contest metrics ──
|
||||||
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
// Meaningful only over a WINDOW: "12 QSO/h" across seventeen years says
|
||||||
// nothing, but across a contest weekend it is the score. The window is the
|
// nothing, but across a contest weekend it is the score. The window is the
|
||||||
@@ -268,6 +301,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
entities = map[int]struct{}{}
|
entities = map[int]struct{}{}
|
||||||
modeC = map[string]int{}
|
modeC = map[string]int{}
|
||||||
bandC = map[string]int{}
|
bandC = map[string]int{}
|
||||||
|
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||||
opC = map[string]int{}
|
opC = map[string]int{}
|
||||||
stationC = map[string]int{}
|
stationC = map[string]int{}
|
||||||
contC = map[string]int{}
|
contC = map[string]int{}
|
||||||
@@ -338,6 +372,20 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
}
|
}
|
||||||
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||||
bandC[b]++
|
bandC[b]++
|
||||||
|
bc := bandCat[b]
|
||||||
|
if bc == nil {
|
||||||
|
bc = &BandCategory{Band: b}
|
||||||
|
bandCat[b] = bc
|
||||||
|
}
|
||||||
|
switch modeCategory(mode.String) {
|
||||||
|
case "cw":
|
||||||
|
bc.CW++
|
||||||
|
case "phone":
|
||||||
|
bc.Phone++
|
||||||
|
default:
|
||||||
|
bc.Data++
|
||||||
|
}
|
||||||
|
bc.Total++
|
||||||
}
|
}
|
||||||
// op was resolved above (with the operator filter applied).
|
// op was resolved above (with the operator filter applied).
|
||||||
opC[op]++
|
opC[op]++
|
||||||
@@ -414,6 +462,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
}
|
}
|
||||||
return a < b
|
return a < b
|
||||||
})
|
})
|
||||||
|
// Per-band CW/phone/data split, in the SAME band-plan order as ByBand.
|
||||||
|
s.ByBandCategory = []BandCategory{}
|
||||||
|
for _, b := range s.ByBand {
|
||||||
|
if bc := bandCat[b.Key]; bc != nil {
|
||||||
|
s.ByBandCategory = append(s.ByBandCategory, *bc)
|
||||||
|
}
|
||||||
|
}
|
||||||
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
||||||
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
||||||
// chart would invent activity that never happened. A gap in the log is real
|
// chart would invent activity that never happened. A gap in the log is real
|
||||||
@@ -422,11 +477,58 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
s.ByMonth = fillMonths(monthC, first, last)
|
s.ByMonth = fillMonths(monthC, first, last)
|
||||||
|
|
||||||
s.periodMetrics(times, from, to, first, last)
|
s.periodMetrics(times, from, to, first, last)
|
||||||
|
// Anchor the rolling activity windows to the period end when filtered, else to
|
||||||
|
// now (so "week" is the last 7 days ending today, like the operator expects).
|
||||||
|
ref := to
|
||||||
|
if ref.IsZero() {
|
||||||
|
ref = time.Now()
|
||||||
|
}
|
||||||
|
s.recentSeries(times, ref)
|
||||||
s.ensureNonNil()
|
s.ensureNonNil()
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentSeries builds the rolling chronological activity windows for the chart's
|
||||||
|
// day/week/month/year selector, in UTC and anchored to `ref` (the period end when
|
||||||
|
// filtered, else now). Real dates in order — today and the days before it — so the
|
||||||
|
// axis reads chronologically and empty days are just zero, not misordered/wrapped.
|
||||||
|
func (s *Stats) recentSeries(times []entry, ref time.Time) {
|
||||||
|
ref = ref.UTC()
|
||||||
|
day0 := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) // midnight of the anchor day
|
||||||
|
hour := make([]int, 24)
|
||||||
|
daily := map[string]int{}
|
||||||
|
monthly := map[string]int{}
|
||||||
|
for _, e := range times {
|
||||||
|
t := e.t.UTC()
|
||||||
|
if t.Year() == ref.Year() && t.YearDay() == ref.YearDay() {
|
||||||
|
hour[t.Hour()]++
|
||||||
|
}
|
||||||
|
daily[t.Format("2006-01-02")]++
|
||||||
|
monthly[t.Format("2006-01")]++
|
||||||
|
}
|
||||||
|
// Day: the anchor day, hour by hour.
|
||||||
|
for h := 0; h < 24; h++ {
|
||||||
|
s.ByHour = append(s.ByHour, Bucket{Key: fmt.Sprintf("%02dh", h), Count: hour[h]})
|
||||||
|
}
|
||||||
|
// Week: the last 7 days ending today (today is last). Label = weekday + day.
|
||||||
|
for i := 6; i >= 0; i-- {
|
||||||
|
d := day0.AddDate(0, 0, -i)
|
||||||
|
s.ByDay7 = append(s.ByDay7, Bucket{Key: d.Format("Mon 2"), Count: daily[d.Format("2006-01-02")]})
|
||||||
|
}
|
||||||
|
// Month: the last 30 days.
|
||||||
|
for i := 29; i >= 0; i-- {
|
||||||
|
d := day0.AddDate(0, 0, -i)
|
||||||
|
s.ByDay30 = append(s.ByDay30, Bucket{Key: d.Format("2/1"), Count: daily[d.Format("2006-01-02")]})
|
||||||
|
}
|
||||||
|
// Year: the last 12 months.
|
||||||
|
m0 := time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
for i := 11; i >= 0; i-- {
|
||||||
|
m := m0.AddDate(0, -i, 0)
|
||||||
|
s.ByMonth12 = append(s.ByMonth12, Bucket{Key: m.Format("Jan 06"), Count: monthly[m.Format("2006-01")]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ensureNonNil replaces every nil slice with an empty one.
|
// ensureNonNil replaces every nil slice with an empty one.
|
||||||
//
|
//
|
||||||
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
// This is NOT cosmetic. A nil Go slice marshals to JSON `null`, not `[]`, and the
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
// Package spe drives the SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA amplifiers over
|
||||||
|
// their proprietary serial protocol (SPE "Application Programmer's Guide" rev 1.1).
|
||||||
|
// The amp is reached either directly over USB (a virtual COM port) or over TCP via
|
||||||
|
// an RS232-to-Ethernet bridge — both are just an io.ReadWriteCloser to this code.
|
||||||
|
//
|
||||||
|
// Wire format (host → amp): 0x55 0x55 0x55 | CNT | DATA… | CHK
|
||||||
|
// CNT = number of DATA bytes, CHK = sum(DATA) mod 256.
|
||||||
|
// Status reply (amp → host): 0xAA 0xAA 0xAA | LEN | <LEN CSV bytes> | chk0 chk1 | CR LF
|
||||||
|
// LEN is 0x43 (67); the payload is 19 comma-separated fixed fields.
|
||||||
|
//
|
||||||
|
// This MVP implements the two commands anchored by worked examples in the guide:
|
||||||
|
// OPERATE (0x0D, toggles STANDBY↔OPERATE) and STATUS (0x90). Other keystroke codes
|
||||||
|
// exist but the guide's command table did not extract unambiguously, so they are
|
||||||
|
// left out rather than risk sending the wrong key to the amplifier.
|
||||||
|
package spe
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE
|
||||||
|
cmdStatus byte = 0x90 // request the status string
|
||||||
|
|
||||||
|
syncHost = 0x55
|
||||||
|
syncAmp = 0xAA
|
||||||
|
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
pollEvery = 800 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is the decoded amplifier state for the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"` // "20K" / "13K"
|
||||||
|
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
|
||||||
|
TX bool `json:"tx"` // true = transmitting
|
||||||
|
Input string `json:"input,omitempty"` // "1" / "2"
|
||||||
|
Band string `json:"band,omitempty"` // raw 2-char band code
|
||||||
|
PowerLevel string `json:"power_level,omitempty"` // L / M / H
|
||||||
|
OutputW int `json:"output_w"`
|
||||||
|
SWRATU float64 `json:"swr_atu"`
|
||||||
|
SWRAnt float64 `json:"swr_ant"`
|
||||||
|
VoltPA float64 `json:"volt_pa"`
|
||||||
|
CurrPA float64 `json:"curr_pa"`
|
||||||
|
TempC int `json:"temp_c"` // heatsink (upper) temperature
|
||||||
|
Warnings string `json:"warnings,omitempty"`
|
||||||
|
Alarms string `json:"alarms,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects the transport.
|
||||||
|
type Config struct {
|
||||||
|
Transport string // "serial" | "tcp"
|
||||||
|
ComPort string // serial
|
||||||
|
Baud int // serial
|
||||||
|
Host string // tcp
|
||||||
|
Port int // tcp
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises access to the connection
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
r *bufio.Reader
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 115200
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, stop: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
if c.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setErr(err error) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Connected = false
|
||||||
|
c.status.LastError = err.Error()
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operate toggles the amplifier between STANDBY and OPERATE (the amp has a single
|
||||||
|
// OPERATE key that flips the state, so we send it only when the desired state
|
||||||
|
// differs from the last-read one).
|
||||||
|
func (c *Client) Operate(on bool) error {
|
||||||
|
if c.GetStatus().Operate == on {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.sendCmd(cmdOperate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleOperate flips STANDBY/OPERATE unconditionally.
|
||||||
|
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
t := time.NewTicker(pollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
c.setErr(fmt.Errorf("connect: %w", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.sendCmd(cmdStatus); err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setErr(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.readStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rwc io.ReadWriteCloser
|
||||||
|
var err error
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
var nc net.Conn
|
||||||
|
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||||
|
rwc = nc
|
||||||
|
} else {
|
||||||
|
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = rwc
|
||||||
|
c.r = bufio.NewReader(rwc)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropLocked() {
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.r = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
|
||||||
|
// the code itself.
|
||||||
|
func (c *Client) sendCmd(code byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
pkt := []byte{syncHost, syncHost, syncHost, 0x01, code, code}
|
||||||
|
_, err := c.conn.Write(pkt)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readStatus reads one amp packet and, when it's a status string, decodes it. ACK
|
||||||
|
// packets (short) are consumed and ignored.
|
||||||
|
func (c *Client) readStatus() {
|
||||||
|
c.mu.Lock()
|
||||||
|
r := c.r
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok && nc != nil {
|
||||||
|
_ = nc.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Sync on three 0xAA bytes.
|
||||||
|
run := 0
|
||||||
|
for run < 3 {
|
||||||
|
b, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
c.setErr(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if b == syncAmp {
|
||||||
|
run++
|
||||||
|
} else {
|
||||||
|
run = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length, err := r.ReadByte()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := make([]byte, int(length))
|
||||||
|
if _, err := io.ReadFull(r, data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Status strings are the long ones (LEN 0x43 = 67). Short packets are ACKs
|
||||||
|
// (1 checksum byte, no CRLF) — nothing else to consume for those.
|
||||||
|
if length >= 40 {
|
||||||
|
// consume the 2 checksum bytes + CR LF
|
||||||
|
_, _ = r.Discard(4)
|
||||||
|
c.decodeCSV(string(data))
|
||||||
|
} else {
|
||||||
|
_, _ = r.Discard(1) // ACK checksum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCSV parses the 19-field comma-separated status payload.
|
||||||
|
func (c *Client) decodeCSV(payload string) {
|
||||||
|
f := strings.Split(payload, ",")
|
||||||
|
get := func(i int) string {
|
||||||
|
if i < len(f) {
|
||||||
|
return strings.TrimSpace(f[i])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
pf := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
|
||||||
|
pi := func(s string) int { v, _ := strconv.Atoi(strings.TrimSpace(s)); return v }
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
c.status.Model = get(0)
|
||||||
|
c.status.Operate = get(1) == "O"
|
||||||
|
c.status.TX = get(2) == "T"
|
||||||
|
c.status.Input = get(4)
|
||||||
|
c.status.Band = get(5)
|
||||||
|
c.status.PowerLevel = get(8)
|
||||||
|
c.status.OutputW = pi(get(9))
|
||||||
|
c.status.SWRATU = pf(get(10))
|
||||||
|
c.status.SWRAnt = pf(get(11))
|
||||||
|
c.status.VoltPA = pf(get(12))
|
||||||
|
c.status.CurrPA = pf(get(13))
|
||||||
|
c.status.TempC = pi(get(14))
|
||||||
|
if w := get(17); w != "" && w != "N" {
|
||||||
|
c.status.Warnings = w
|
||||||
|
} else {
|
||||||
|
c.status.Warnings = ""
|
||||||
|
}
|
||||||
|
if a := get(18); a != "" && a != "N" {
|
||||||
|
c.status.Alarms = a
|
||||||
|
} else {
|
||||||
|
c.status.Alarms = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-2
@@ -159,10 +159,23 @@ func (a *App) liveStatusOperator() (op, station string) {
|
|||||||
// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
|
// ReportLiveActivity is called by the UI with the current entry-strip freq/band/
|
||||||
// mode, used as a fallback for live status when the CAT isn't connected.
|
// mode, used as a fallback for live status when the CAT isn't connected.
|
||||||
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
|
func (a *App) ReportLiveActivity(freqHz int64, band, mode string) {
|
||||||
|
nb := strings.ToUpper(strings.TrimSpace(band))
|
||||||
|
nm := strings.ToUpper(strings.TrimSpace(mode))
|
||||||
a.liveActMu.Lock()
|
a.liveActMu.Lock()
|
||||||
|
changed := a.liveFreqHz != freqHz || a.liveBand != nb || a.liveMode != nm
|
||||||
a.liveFreqHz = freqHz
|
a.liveFreqHz = freqHz
|
||||||
a.liveBand = strings.ToUpper(strings.TrimSpace(band))
|
a.liveBand = nb
|
||||||
a.liveMode = strings.ToUpper(strings.TrimSpace(mode))
|
a.liveMode = nm
|
||||||
|
// Push a fresh row PROMPTLY when the operator's freq/band/mode changes, instead
|
||||||
|
// of waiting for the next 15 s heartbeat — otherwise "who's on air" showed a
|
||||||
|
// stale band and took up to a minute to catch up. Debounced so spinning the VFO
|
||||||
|
// doesn't hammer the shared MySQL.
|
||||||
|
if changed {
|
||||||
|
if a.livePublishTimer != nil {
|
||||||
|
a.livePublishTimer.Stop()
|
||||||
|
}
|
||||||
|
a.livePublishTimer = time.AfterFunc(1500*time.Millisecond, func() { a.publishLiveStatus() })
|
||||||
|
}
|
||||||
a.liveActMu.Unlock()
|
a.liveActMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.4"
|
appVersion = "0.20.5"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user